From a73cbdcdb0b5c4b7de9668cb125bb7eb23eaf866 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 01:59:12 +0000 Subject: [PATCH] Seat a room-link claim without waiting for the knock A guest's Join records the claim at Bot Social and then knocks. When that knock is denied, held, or never read, the host never seated anyone and the guest stayed on "Asked to join". The inbox poll now reads open server claims and seats them; a claim the host cannot finish is named on the Members card. Fixes #1064 Co-authored-by: Danyang Chen --- changelog.d/1064-invite-claim-seat.fixed.md | 1 + core/client.js | 5 + core/src/client/i18n.js | 5 + core/src/host/authz.js | 4 +- core/src/host/channel.js | 8 + core/src/host/hooks.js | 2 + core/src/host/room-membership.js | 187 ++++++++++++++---- core/test/authz.test.js | 2 +- core/test/room-ticket-seat.test.js | 93 +++++++++ docs/subsystems/core-client.md | 2 +- docs/subsystems/core-host.md | 2 +- docs/subsystems/plugin-runtime.md | 2 +- docs/subsystems/social.md | 2 +- patches/ds-bot/0001-core-fixes.patch | 7 +- patches/ds-bot/0005-social-seams.patch | 10 +- patches/ds-bot/0008-client-bundle.patch | 7 +- patches/ds-bot/0012-room-membership.patch | 10 +- patches/ds-bot/files/src/host/authz.js | 4 +- patches/ds-bot/files/src/host/hooks.js | 2 + .../ds-bot/files/src/host/room-membership.js | 187 ++++++++++++++---- patches/ds-bot/files/test/authz.test.js | 2 +- .../files/test/room-ticket-seat.test.js | 93 +++++++++ plugins/social/client.js | 39 ++++ plugins/social/src/social.js | 5 + plugins/social/test/social.test.js | 80 ++++++++ 25 files changed, 670 insertions(+), 91 deletions(-) create mode 100644 changelog.d/1064-invite-claim-seat.fixed.md diff --git a/changelog.d/1064-invite-claim-seat.fixed.md b/changelog.d/1064-invite-claim-seat.fixed.md new file mode 100644 index 00000000..efb9c58a --- /dev/null +++ b/changelog.d/1064-invite-claim-seat.fixed.md @@ -0,0 +1 @@ +A group invite link seats the guest's Bots when they claim it, even if the accepting knock never arrives, and the group opens in the guest's sidebar. A claim the host cannot finish is named on the group's Members card ([#1064](https://github.com/ThinkFlowLab/FlyBot/issues/1064)). diff --git a/core/client.js b/core/client.js index bd061f09..eaab9ae1 100644 --- a/core/client.js +++ b/core/client.js @@ -94,6 +94,11 @@ var ZH = { "1 earlier link still works.": "还有 1 个之前的链接仍然有效。", "{count} earlier links still work.": "还有 {count} 个之前的链接仍然有效。", "Withdraw all": "全部撤回", + "{names} cannot be shown to someone who joins by link. Share them (Bot settings → Sharing) or remove them from the group, then seat the guest.": "{names} 不能给通过链接加入的人看到。请在 Bot 设置 → 共享里共享他们,或把他们移出群,然后再安排客人入座。", + "A Bot in this group cannot be shown to someone who joins by link. Share that Bot or remove it, then seat the guest.": "这个群里有一个 Bot 不能给通过链接加入的人看到。请共享该 Bot 或把它移出群,然后再安排客人入座。", + "A guest is in the group, but telling their FlyBot is waiting for your approval.": "客人已经在群里,但通知对方 FlyBot 还在等你批准。", + "A guest is in the group, but their FlyBot has not opened it yet. Seat them again.": "客人已经在群里,但对方的 FlyBot 还没有打开这个群。请再安排一次入座。", + "Seat them": "安排入座", "Paste an invite link or code": "粘贴邀请链接或邀请码", "A coding session without a Bot": "不带 Bot 的编码会话", "New Bot “{name}”": "新建 Bot「{name}」", diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index 55d5f65a..585282bc 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -57,6 +57,11 @@ const ZH = { '1 earlier link still works.': '还有 1 个之前的链接仍然有效。', '{count} earlier links still work.': '还有 {count} 个之前的链接仍然有效。', 'Withdraw all': '全部撤回', + '{names} cannot be shown to someone who joins by link. Share them (Bot settings → Sharing) or remove them from the group, then seat the guest.': '{names} 不能给通过链接加入的人看到。请在 Bot 设置 → 共享里共享他们,或把他们移出群,然后再安排客人入座。', + 'A Bot in this group cannot be shown to someone who joins by link. Share that Bot or remove it, then seat the guest.': '这个群里有一个 Bot 不能给通过链接加入的人看到。请共享该 Bot 或把它移出群,然后再安排客人入座。', + 'A guest is in the group, but telling their FlyBot is waiting for your approval.': '客人已经在群里,但通知对方 FlyBot 还在等你批准。', + 'A guest is in the group, but their FlyBot has not opened it yet. Seat them again.': '客人已经在群里,但对方的 FlyBot 还没有打开这个群。请再安排一次入座。', + 'Seat them': '安排入座', 'Paste an invite link or code': '粘贴邀请链接或邀请码', 'A coding session without a Bot': '不带 Bot 的编码会话', 'New Bot “{name}”': '新建 Bot「{name}」', diff --git a/core/src/host/authz.js b/core/src/host/authz.js index 855730ad..15fbd73e 100644 --- a/core/src/host/authz.js +++ b/core/src/host/authz.js @@ -50,7 +50,7 @@ const BOT_TARGETED = new Set([ const ROOM_TARGETED = new Set(['update-room', 'delete-room', 'room-progress', 'create-room', 'room-history', 'room-post', // #876: the room's own link. Reading it is a member's read; minting and withdrawing hand out // or kill a way in, so they follow the room's admin rule. - 'room-invite', 'room-invite-mint', 'room-invite-revoke']) + 'room-invite', 'room-invite-mint', 'room-invite-revoke', 'room-invite-seat']) // Endpoints whose body names a Session. const SESSION_TARGETED = new Set(['earlier', 'send', 'answer-question', 'dismiss-question', 'approval-answer', 'exchange']) @@ -343,7 +343,7 @@ export function install(rt) { const me = currentParticipants(room).find(p => p.kind === 'human' && p.id === actor.memberId) return roomOwnedBy(actor, id) || roomMemberOf(actor, id) || me?.role === 'admin' ? ALLOW : deny('bot/forbidden', 'You are not a member of that room') } - if (ep === 'room-invite-mint' || ep === 'room-invite-revoke') { + if (ep === 'room-invite-mint' || ep === 'room-invite-revoke' || ep === 'room-invite-seat') { const id = body?.id ?? body?.roomId if (roomOwnedBy(actor, id)) return ALLOW const room = state.rooms?.[id] diff --git a/core/src/host/channel.js b/core/src/host/channel.js index 75425cd8..bec978e6 100644 --- a/core/src/host/channel.js +++ b/core/src/host/channel.js @@ -412,6 +412,14 @@ export function install(rt) { const bots = Array.isArray(body.bots) ? body.bots.filter(id => typeof id === 'string').slice(0, 8) : undefined return ok(await rt.askLocalInvite(String(body.link ?? body.url ?? ''), { accept: body.accept === true, ...(bots ? { bots } : {}) }, actor)) } + // #1064: the owner finishes a claim the poll could not (a Bot that cannot be shown, a notice + // that did not leave). Same read as the poll, with the retries reset. + case 'room-invite-seat': { + await rt.load() + const roomId = String(body.roomId ?? body.id ?? '') + const seated = await rt.seatOpenClaims({ roomId, force: true }) + return ok({ ...seated, invite: rt.localInviteOf(roomId) ?? null, invites: rt.localInvitesOf(roomId) }) + } case 'folders': case 'folder-pick': case 'folder-list': diff --git a/core/src/host/hooks.js b/core/src/host/hooks.js index 9287890d..e652a93e 100644 --- a/core/src/host/hooks.js +++ b/core/src/host/hooks.js @@ -1151,6 +1151,8 @@ export function install(rt) { // transport lets a room share through to exactly these, under `scope`, and to nobody else off // the roster. ticketPeers: () => rt.ticketPeers?.() ?? [], + // #1064: seat homes that claimed an open server link, without waiting for their knock. + seatOpenClaims: (opts) => rt.seatOpenClaims?.(opts) ?? Promise.resolve({ seated: [], waiting: [] }), }, remote: { publish: list => remoteMembers.publish(plugin, list), diff --git a/core/src/host/room-membership.js b/core/src/host/room-membership.js index ea81e994..17564f1b 100644 --- a/core/src/host/room-membership.js +++ b/core/src/host/room-membership.js @@ -1255,17 +1255,18 @@ export function install(rt) { // Membership notice: the add/remove click is the owner's consent, so this does not wait // for a chat line. Bot Social is 1:1; the envelope is the group record the other FlyBot sees. rt.notifyRoomShare = async function notifyRoomShare(room, { op = 'join', handle } = {}) { - if (!room || isFaceRoom(room) || room.origin === 'inbound-share') return + if (!room || isFaceRoom(room) || room.origin === 'inbound-share') return [] const wanted = handle ? String(handle) : undefined const remotes = (op === 'leave' ? (room.participants ?? []) : currentParticipants(room)) .filter(p => isRemoteParticipant(p) && (!wanted || p.handle === wanted || p.id === wanted)) - if (remotes.length === 0) return + if (remotes.length === 0) return [] const from = shareInviter(room) + const results = [] for (const remote of remotes) { if (isTicketSeat(remote)) { const seat = seatSender(room, remote) - if (!seat) continue - await sendShareTo(room, remote, { + if (!seat) { results.push(undefined); continue } + results.push(await sendShareTo(room, remote, { op, roomId: room.id, name: room.name, @@ -1274,11 +1275,11 @@ export function install(rt) { ticket: remote.ticket, ...(op === 'join' ? { roster: roomRoster(room), notice: room.notice || '', history: shareHistory(room) } : {}), ...seat.from, - }, 'room-share', seat.botId, room.id) + }, 'room-share', seat.botId, room.id)) continue } if (op !== 'leave' && !publishedRemoteOf(state, remote.handle)) continue - await sendShareTo(room, remote, { + results.push(await sendShareTo(room, remote, { op, roomId: room.id, name: room.name, @@ -1286,8 +1287,9 @@ export function install(rt) { seq: room.seq ?? 0, ...(op === 'join' ? { roster: roomRoster(room), notice: room.notice || '', history: shareHistory(room) } : {}), ...from, - }, 'room-share') + }, 'room-share')) } + return results } // #852, #878, #884: the link invitations for a room that lives here. A room keeps a list of tickets @@ -1334,6 +1336,8 @@ export function install(rt) { ...(invite.server === true ? { server: true } : {}), ...(seatedBy(invite) ? { joined: Object.keys(homesOf(invite)).length } : {}), ...(invite.withdrawnAt ? { withdrawn: true } : {}), + // #1064: a claim the poll could not finish. Local Bot names only — a guest handle is not shown. + ...(invite.waiting?.reason ? { waiting: { reason: String(invite.waiting.reason), bots: (Array.isArray(invite.waiting.bots) ? invite.waiting.bots : []).map(name => cleanPeerName(name, '')).filter(Boolean).slice(0, TICKET_SEATS_MAX) } } : {}), } } @@ -1686,40 +1690,24 @@ export function install(rt) { return { handled: true, room, accepted: true } } - // #884: the accepting knock on a ticket Bot Social minted. The knock is a request, never the - // authority: the claim is read back from the server with this home's own credential, and the seats - // are the Bots it names — all of them or none. Each way it can fail has its own reason. - async function seatByTicket(room, invite, data, event) { - const asker = String(event.fromHandle ?? '') - if (typeof rt.roomInviteServer?.verify !== 'function') return { handled: true, refused: 'lookup-failed' } - let claim - try { - claim = await rt.roomInviteServer.verify(invite.ticket) - } catch (error) { - const why = refusalOf(String(error?.code ?? '')) - return { handled: true, refused: why === 'unknown' ? 'revoked' : why } - } - if (!claim || String(claim.roomId ?? '') !== room.id) return { handled: true, refused: 'wrong-room' } - // A link admits every home it is given to (Bot Social #65): the minter reads each home's claim, and - // the knock is answered from the one whose Bots include the asker. An older server names one claimer. - const claims = Array.isArray(claim.claimers) ? claim.claimers : claim.claimer ? [claim.claimer] : [] - const botsOf = c => (Array.isArray(c?.bots) ? c.bots : []).map(String).filter(handle => handle.startsWith('@')) - if (!claims.some(c => botsOf(c).length > 0)) return { handled: true, refused: 'unclaimed' } - const mine = claims.find(c => botsOf(c).includes(asker)) - const seats = botsOf(mine) + const botsOfClaim = c => (Array.isArray(c?.bots) ? c.bots : []).map(String).filter(handle => handle.startsWith('@')) + + // Seat one home's claim, all of its Bots or none. The claim was already read back from the server. + async function admitClaim(room, invite, mine, asker) { + const seats = botsOfClaim(mine) const home = typeof mine?.home === 'string' ? mine.home : undefined - // The Bot that knocked must be one a claim brought, from the home that claimed it. - if (!mine || (home && event.fromHome && event.fromHome !== home)) return { handled: true, refused: 'claimed-elsewhere' } - if (seats.length > TICKET_SEATS_MAX) return { handled: true, refused: 'too-many' } + if (seats.length > TICKET_SEATS_MAX) return { refused: 'too-many' } const names = new Map((Array.isArray(mine?.members) ? mine.members : []).map(m => [String(m?.handle ?? ''), m])) const result = await withRoomLock(async () => { materialize(room) // A Bot of this room that may not be shown outside would be read by the stranger: refuse rather // than seat a room whose lines could not all go out. const blocked = unsharable(room).filter(p => p.id !== invite.botId) - if (blocked.length > 0) return { refused: 'not-addable' } + if (blocked.length > 0) return { refused: 'not-addable', bots: blocked.map(p => state.bots?.[p.id]?.name).filter(name => typeof name === 'string' && name) } const fresh = seats.filter(handle => !currentParticipants(room).some(p => p.remote === true && (p.handle === handle || p.id === handle))) const at = Date.now() + const homesBefore = JSON.stringify(homesOf(invite)) + const legacy = invite.acceptedBy !== undefined || invite.seats !== undefined invite.homes = { ...homesOf(invite), [home ?? asker]: seats } delete invite.acceptedBy delete invite.seats @@ -1749,13 +1737,140 @@ export function install(rt) { }) } syncMembers(room) - await rt.save?.() + // A poll reads this again every few seconds: a claim that is already seated must not rewrite the team. + if (fresh.length > 0 || legacy || homesBefore !== JSON.stringify(homesOf(invite))) await rt.save?.() return { fresh } }) - if (result.refused) return { handled: true, refused: result.refused } + if (result.refused) return { refused: result.refused, bots: result.bots } + return { seats, fresh: result.fresh } + } + + // #884: the accepting knock on a ticket Bot Social minted. The knock is a request, never the + // authority: the claim is read back from the server with this home's own credential, and the seats + // are the Bots it names — all of them or none. Each way it can fail has its own reason. + async function seatByTicket(room, invite, data, event) { + const asker = String(event.fromHandle ?? '') + if (typeof rt.roomInviteServer?.verify !== 'function') return { handled: true, refused: 'lookup-failed' } + let claim + try { + claim = await rt.roomInviteServer.verify(invite.ticket) + } catch (error) { + const why = refusalOf(String(error?.code ?? '')) + return { handled: true, refused: why === 'unknown' ? 'revoked' : why } + } + if (!claim || String(claim.roomId ?? '') !== room.id) return { handled: true, refused: 'wrong-room' } + // A link admits every home it is given to (Bot Social #65): the minter reads each home's claim, and + // the knock is answered from the one whose Bots include the asker. An older server names one claimer. + const claims = Array.isArray(claim.claimers) ? claim.claimers : claim.claimer ? [claim.claimer] : [] + if (!claims.some(c => botsOfClaim(c).length > 0)) return { handled: true, refused: 'unclaimed' } + const mine = claims.find(c => botsOfClaim(c).includes(asker)) + const home = typeof mine?.home === 'string' ? mine.home : undefined + // The Bot that knocked must be one a claim brought, from the home that claimed it. + if (!mine || (home && event.fromHome && event.fromHome !== home)) return { handled: true, refused: 'claimed-elsewhere' } + const admitted = await admitClaim(room, invite, mine, asker) + if (admitted.refused) return { handled: true, refused: admitted.refused } // Every seat hears the join, a retry included (the reply was lost): no second seat is made. - for (const handle of seats) await rt.notifyRoomShare?.(room, { op: 'join', handle }) - return { handled: true, room, accepted: true, seats } + // A send that left is remembered so the claim poll does not send it again. + const told = new Set(Array.isArray(invite.told) ? invite.told : []) + const held = new Set(Array.isArray(invite.held) ? invite.held : []) + for (const handle of admitted.seats) { + const sent = (await rt.notifyRoomShare?.(room, { op: 'join', handle }))?.[0] + const action = sent?.action + if (action === 'allow' || action === 'sent') { told.add(handle); held.delete(handle) } + else if (action === 'hold') held.add(handle) + } + invite.told = [...told] + if (held.size > 0) invite.held = [...held] + else delete invite.held + await rt.save?.() + return { handled: true, room, accepted: true, seats: admitted.seats } + } + + // Tell a seated handle once. `allow` / `sent` is delivered; `hold` is already on a card, so it is + // not sent again; a refusal is retried a few times and then left for the owner to finish. + async function tellSeats(room, invite, handles, { force = false } = {}) { + if (force) { + delete invite.tellTries + delete invite.held + } + const told = new Set(Array.isArray(invite.told) ? invite.told : []) + const held = new Set(force ? [] : (Array.isArray(invite.held) ? invite.held : [])) + let outcome = 'told' + for (const handle of handles) { + if (told.has(handle)) continue + if (held.has(handle)) { outcome = 'held'; continue } + const tries = invite.tellTries?.[handle] ?? 0 + if (!force && tries >= 3) { outcome = 'not-delivered'; continue } + invite.tellTries = { ...(invite.tellTries ?? {}), [handle]: tries + 1 } + const sent = (await rt.notifyRoomShare?.(room, { op: 'join', handle }))?.[0] + const action = sent?.action + if (action === 'allow' || action === 'sent') { + told.add(handle) + held.delete(handle) + const rest = { ...(invite.tellTries ?? {}) } + delete rest[handle] + invite.tellTries = rest + } else if (action === 'hold') { + held.add(handle) + outcome = 'held' + } else outcome = 'not-delivered' + } + invite.told = [...told] + if (held.size > 0) invite.held = [...held] + else delete invite.held + if (invite.tellTries && Object.keys(invite.tellTries).length === 0) delete invite.tellTries + return outcome + } + + // #1064: a guest's Join claims the ticket at Bot Social and then knocks. The knock is a request, + // and it can be refused, held, or never read (a room-only Bot is not polled). The claim is the + // authority either way: read every open server link and seat the homes it names. A room that + // cannot be shown, or a notice that does not leave, stays on the link as `waiting` for the owner. + rt.seatOpenClaims = async function seatOpenClaims({ roomId, force = false } = {}) { + if (typeof rt.roomInviteServer?.verify !== 'function') return { seated: [], waiting: [] } + const wanted = roomId === undefined || roomId === null || roomId === '' ? undefined : String(roomId) + const rooms = wanted ? [rt.roomOf(wanted)].filter(Boolean) : Object.values(state.rooms || {}) + const seated = [] + const waiting = [] + for (const room of rooms) { + if (!room || room.origin === 'inbound-share' || isFaceRoom(room)) continue + for (const invite of invitesOf(room)) { + if (invite.server !== true || !openInvite(invite)) continue + let claim + try { + claim = await rt.roomInviteServer.verify(invite.ticket) + } catch (error) { + rt.warn?.('ds-bot: a room link claim was not read: %s', error) + continue + } + if (!claim || String(claim.roomId ?? '') !== room.id) continue + const claims = (Array.isArray(claim.claimers) ? claim.claimers : claim.claimer ? [claim.claimer] : []).filter(c => botsOfClaim(c).length > 0) + const mark = () => JSON.stringify({ waiting: invite.waiting ?? null, told: invite.told ?? null, held: invite.held ?? null, tries: invite.tellTries ?? null }) + const before = mark() + let blocked = null + if (claims.length === 0) { + if (invite.waiting) delete invite.waiting + } else for (const mine of claims) { + const admitted = await admitClaim(room, invite, mine, botsOfClaim(mine)[0]) + if (admitted.refused === 'not-addable') { + blocked = { reason: 'not-addable', bots: admitted.bots ?? [] } + continue + } + if (admitted.refused) continue + seated.push(...(admitted.fresh ?? [])) + const outcome = await tellSeats(room, invite, admitted.seats ?? [], { force }) + if (outcome === 'not-delivered') blocked = blocked ?? { reason: 'not-delivered', bots: [] } + else if (outcome === 'held') blocked = blocked ?? { reason: 'held', bots: [] } + } + if (claims.length > 0) { + if (blocked) invite.waiting = { reason: blocked.reason, bots: blocked.bots, at: invite.waiting?.at ?? Date.now() } + else if (invite.waiting) delete invite.waiting + } + if (mark() !== before) await rt.save?.() + if (invite.waiting) waiting.push({ roomId: room.id, ticket: invite.ticket, reason: invite.waiting.reason, bots: invite.waiting.bots ?? [] }) + } + } + return { seated, waiting } } // A line or a leave that a remote seat sent into a room of this home (#884). The seat must be current diff --git a/core/test/authz.test.js b/core/test/authz.test.js index 4b7c1e52..2c6aecd5 100644 --- a/core/test/authz.test.js +++ b/core/test/authz.test.js @@ -105,7 +105,7 @@ test('owner cannot touch another owner Bot or room', async (t) => { } // #876: a room's link is a member's read, and minting or withdrawing it is the room admin's call. - for (const ep of ['room-invite', 'room-invite-mint', 'room-invite-revoke']) { + for (const ep of ['room-invite', 'room-invite-mint', 'room-invite-revoke', 'room-invite-seat']) { assert.equal(rt.authorize(actorA, ep, { id: 'ra' }).ok, true, ep) const gate = rt.authorize(actorA, ep, { id: 'rb' }) assert.equal(gate.ok, false, ep) diff --git a/core/test/room-ticket-seat.test.js b/core/test/room-ticket-seat.test.js index 201c3b90..7aa8c370 100644 --- a/core/test/room-ticket-seat.test.js +++ b/core/test/room-ticket-seat.test.js @@ -604,3 +604,96 @@ test('#899: a link minted on a room-only Bot is promoted when the host wires the asked.length = 0 assert.deepEqual(await a.rt.identifyInviteBots(), [], 'a withdrawn link that seated nobody wakes nothing') }) + +test('#1064: a claim seats the guest when the accepting knock never arrives', async (t) => { + const { server, a, b, log, settle } = await twoHomes(t) + const invite = await a.rt.mintRoomInvite('war', { memberId: 'owner-1' }) + const guestSend = b.rt.flybotSend + const held = [] + b.rt.flybotSend = async (msg) => { held.push(msg); return { action: 'deny' } } + const joined = await b.rt.askLocalInvite(invite.url, { accept: true, bots: ['chief-b', 'helper-b'] }, { memberId: 'owner-1' }) + assert.equal(joined.claimed, true, 'the ticket was claimed at the server') + assert.equal(joined.sent, 'deny', 'the accepting knock did not leave') + await settle() + const seats = () => a.rt.state.rooms.war.participants.filter(p => p.remote === true && p.leftSeq === undefined) + assert.equal(seats().length, 0, 'a knock that never arrives seats nobody') + assert.equal(guestRoomOf(b.rt), undefined, 'the guest has no sidebar room yet') + assert.equal(held.length, 1, 'the knock was attempted') + assert.equal(log.some(m => parseRoomShare(m.text)?.op === 'ask'), false, 'the denied knock was not delivered') + + assert.equal(typeof a.rt.seatOpenClaims, 'function', 'the host reads open claims without waiting for the knock') + await a.rt.seatOpenClaims() + await settle() + assert.deepEqual(seats().map(p => p.handle).sort(), ['@chief.bob.home', '@helper.bob.home']) + assert.ok(seats().every(p => p.admittedBy === 'ticket' && p.ticket === invite.ticket && p.hostBotId === 'chief-a')) + const mirror = guestRoomOf(b.rt) + assert.ok(mirror && !mirror.hidden, 'the joins opened the group on the guest') + assert.equal(Object.values(b.rt.state.rooms).filter(r => r.origin === 'inbound-share').length, 1) + assert.deepEqual(mirror.participants.filter(p => p.leftSeq === undefined && p.kind === 'bot').map(p => p.id).sort(), ['@chief.alice.home', '@writer.alice.home', 'chief-b', 'helper-b']) + assert.equal(mirror.sharedFrom.ticket, invite.ticket) + assert.equal(mirror.sharedFrom.remoteRoomId, 'war') + + const joins = () => log.filter(m => parseRoomShare(m.text)?.op === 'join' && m.from === 'chief-a') + const joinCount = joins().length + assert.ok(joinCount >= 2, 'each seated Bot is told') + await a.rt.seatOpenClaims() + await settle() + assert.equal(seats().length, 2, 'a second read does not add a seat') + assert.equal(joins().length, joinCount, 'a seat already told is not told again') + assert.equal(Object.values(b.rt.state.rooms).filter(r => r.origin === 'inbound-share').length, 1) + + b.rt.flybotSend = guestSend + const line = await a.rt.postRoomMessage('war', { text: 'Ship on Friday?', authorId: 'owner-1', authorKind: 'human', displayName: 'Alice' }) + await a.rt.fanoutRoomLine(a.rt.state.rooms.war, line) + await settle() + assert.ok(mirror.log.some(entry => String(entry.text).includes('Ship on Friday?')), 'the host line arrived') + await b.rt.fanoutRoomLine(mirror, { who: 'Helper', botId: 'helper-b', authorId: 'helper-b', authorKind: 'bot', text: 'Friday works.', seq: (mirror.seq ?? 0) + 1 }) + await settle() + assert.ok(a.rt.state.rooms.war.log.some(entry => String(entry.text).includes('Friday works.')), 'the guest line landed in the host room') + + const late = await a.rt.mintRoomInvite('war', { memberId: 'owner-1' }) + b.rt.flybotSend = async () => ({ action: 'deny' }) + const second = await b.rt.askLocalInvite(late.url, { accept: true, bots: ['chief-b'] }, { memberId: 'owner-1' }) + assert.equal(second.claimed, true) + server.tickets.get(late.ticket).roomId = 'other' + await a.rt.seatOpenClaims() + await settle() + assert.equal(seats().length, 2, 'a claim that names another room seats nobody') + + const verify = a.rt.roomInviteServer.verify + a.rt.roomInviteServer.verify = undefined + assert.deepEqual(await a.rt.seatOpenClaims(), { seated: [], waiting: [] }) + a.rt.roomInviteServer.verify = verify + assert.equal(seats().length, 2, 'no claim was dropped') +}) + +test('#1064: a room that cannot be shown keeps a pending claim the host can finish', async (t) => { + const { a, b, settle } = await twoHomes(t) + a.rt.state.members['owner-2'] = { id: 'owner-2', createdAt: 1 } + a.rt.state.bots['other-a'] = { id: 'other-a', sessionId: 'other-a', name: 'Private', instructions: '', createdAt: 3, ownerId: 'owner-2' } + const war = a.rt.state.rooms.war + war.members = [...war.members, 'other-a'] + war.participants = [...war.participants, { kind: 'bot', id: 'other-a', role: 'member', displayName: 'Private', joinedSeq: 0, joinedAt: 1 }] + const invite = await a.rt.mintRoomInvite('war', { memberId: 'owner-1' }) + b.rt.flybotSend = async () => ({ action: 'deny' }) + const joined = await b.rt.askLocalInvite(invite.url, { accept: true, bots: ['chief-b'] }, { memberId: 'owner-1' }) + assert.equal(joined.claimed, true) + assert.equal(typeof a.rt.seatOpenClaims, 'function') + const blocked = await a.rt.seatOpenClaims() + const seats = () => war.participants.filter(p => p.remote === true && p.leftSeq === undefined) + assert.equal(seats().length, 0, 'a room that cannot be shown seats nobody') + assert.equal(blocked.waiting?.[0]?.reason, 'not-addable') + const view = a.rt.localInviteOf('war') + assert.equal(view.waiting.reason, 'not-addable') + assert.ok(view.waiting.bots.includes('Private'), 'the host is told which Bot blocks the seat') + assert.equal(guestRoomOf(b.rt), undefined) + + war.members = war.members.filter(id => id !== 'other-a') + war.participants = war.participants.filter(p => p.id !== 'other-a') + const done = await a.rt.seatOpenClaims({ roomId: 'war', force: true }) + await settle() + assert.deepEqual(seats().map(p => p.handle), ['@chief.bob.home']) + assert.deepEqual(done.waiting, []) + assert.equal(a.rt.localInviteOf('war').waiting, undefined) + assert.ok(guestRoomOf(b.rt), 'once the room can be shown, the guest sidebar opens') +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index ebbb2dd7..abcc3407 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -75,7 +75,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second ### Face-to-face room lead -- A local group's **Members · N** card (`group-settings.js`) ends with the plugin rows of slot `flybot.room.members` (`FlyBotRoomMembers` in `flybot-slots.js`), after the note on who leads the group (#892) and on the card's own row padding (`.bt-flybot-room-members`), never in a face or shared (`inbound-share`) room; the rows get `host`, the core endpoint call (also `__flybot__.host`), for `room-invite` / `room-invite-mint` / `room-invite-revoke` (#876, the social plugin's **Invite by link**). Covered by `core/test/room-members-slot.test.js`. Plugin copy that core's dictionary carries is translated through `__flybot__.t` (#892: Invite by link, the Join-or-create dialog, including **Enter 6 digits** under a partial room number, #1052), so a zh UI reads it in Chinese. A room a link opened is titled by its own name on the joining home (`roomTitle` in `remote-label.js`: sidebar, header, drawer, brand); "Shared room from …" stays as the sidebar row's hover and the **Shared** tag's title (#892). In a room projected from another home the Members rows carry what that side could confirm (#904): a Bot learned from the host's roster reads **Unconfirmed** until this home holds its own fact about it, one with no Bot Social identity reads **Not connected**, and the remove **×** is not offered there — the host's membership is changed on the host's side. +- A local group's **Members · N** card (`group-settings.js`) ends with the plugin rows of slot `flybot.room.members` (`FlyBotRoomMembers` in `flybot-slots.js`), after the note on who leads the group (#892) and on the card's own row padding (`.bt-flybot-room-members`), never in a face or shared (`inbound-share`) room; the rows get `host`, the core endpoint call (also `__flybot__.host`), for `room-invite` / `room-invite-mint` / `room-invite-revoke` / `room-invite-seat` (#876, #1064, the social plugin's **Invite by link**; a claim the host could not finish is named there, and **Seat them** is in the same dictionary). Covered by `core/test/room-members-slot.test.js`. Plugin copy that core's dictionary carries is translated through `__flybot__.t` (#892: Invite by link, the Join-or-create dialog, including **Enter 6 digits** under a partial room number, #1052), so a zh UI reads it in Chinese. A room a link opened is titled by its own name on the joining home (`roomTitle` in `remote-label.js`: sidebar, header, drawer, brand); "Shared room from …" stays as the sidebar row's hover and the **Shared** tag's title (#892). In a room projected from another home the Members rows carry what that side could confirm (#904): a Bot learned from the host's roster reads **Unconfirmed** until this home holds its own fact about it, one with no Bot Social identity reads **Not connected**, and the remove **×** is not offered there — the host's membership is changed on the host's side. - The face room's details (`FaceMembers` in `details-drawer.js`) open with `FaceLead` (`group-settings.js`, #476): the current lead as `Bot (person)`, *Default* until the creator picked one. Only when `room.face.lead.canPick` (this home made the room and Bot Social stores a lead) does it show **Change**, a **Make lead** button per Bot of every home and **Use the default lead**; each calls `social/face-lead`. Everyone else sees it read-only. `FacePersonRow` marks the lead's Bot with a *Lead* tag (`isLeadSeat`, by handle). Never a chat command. ### Room avatars in face-to-face chats diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 20de5c07..96b85947 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -206,7 +206,7 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi - The room record is the only authority: `room.localInvites [{ ticket, handle, botId, expiresAt?, mintedAt, mintedBy, server?, url?, homes?, acceptedAt?, withdrawnAt? }]`. A link has **no deadline and is not single-use** (owner decision 2026-10-24, #892, Bot Social #65): anyone it is given to can join with it, each home once (`homes: { [home]: [seat handles] }`; a record from before keeps its one `acceptedBy` / `seats`, read through `homesOf`), until the owner withdraws it or the room is deleted. A link minted with an `expiresAt` (an older Bot Social, or before the change) keeps that deadline. Several may be live at once (#878); a record written before the list (`room.localInvite`) is moved into it on first read. `rt.mintRoomInvite(roomId, actor)` adds one (dead links that seated nobody are dropped then); `rt.localInvitesOf(roomId)` lists the open ones (not withdrawn, not out of date; a link someone joined with stays open) and `rt.localInviteOf(roomId)` is the newest; `rt.roomOfLocalInvite(ticket)` resolves the room from the ticket; `rt.revokeRoomInvite(roomId, ticket?)` withdraws one link, or without a ticket every open one: it sets `withdrawnAt` (nobody new joins; the server ticket is revoked without `endClaims`), the seats it made stay, and a withdrawn link that seats nobody is dropped. A shared (`inbound-share`) or face-to-face room refuses to mint — it has its own invitation. - Three steps on the same envelope, answered by the room record: the invitee's host sends `{ op: 'ask', ticket }`; the inviter answers `{ op: 'invite', ticket, roomId, name, lead, expiresAt }`, which is information only (it consumes nothing and seats nobody); the accepting knock `{ op: 'ask', ticket, accept: true }` runs verify → consume → add → the existing `join`, which `addRoomParticipant` sends. `applyInboundRoomShare` handles both ops **before** the branch that projects a room for an unmatched `join` / `line`, so a knock never conjures a room; `parseRoomShare` accepts `ask` without a `roomId` and requires one on `invite`. The asker is `event.fromHandle` (the transport's sender), never a field of the payload. The accepting home is added to `homes`: the same home retrying is idempotent success (the lost `join` reply is sent again) and another home may join too (#892); an add the room refuses removes that home again. Refusals are local on the inviter's host (`unknown` / `revoked` / `expired` / `no-sender` / `not-addable`) and change nothing. The UI half — the Members card's **Invite by link** row and the dialog the offer draws — is still to come. Covered by `core/test/remote-room.test.js` (#852). - The endpoints the UI calls (#876): `room-invite` reads the live link (`rt.localInviteOf`, what the Members card copies and counts down), `room-invite-mint` and `room-invite-revoke` are the room owner's or a human admin's call (`authz.js`), and `room-invite-ask` is the invitee's side: `rt.askLocalInvite` reads the handle and ticket out of the link and sends `{ op: 'ask', ticket }` from one of the actor's own Bots whose home has a Bot Social identity — the link cannot name a sender, so the transport does, as on the answering side. Join is the same call with `accept: true`, and the answer arrives as `invite` through `applyInboundRoomShare` (`rt.onLocalInviteOffer`). A face-room link, a bare ticket and a ticket outside the 16–64 shape are refused in one sentence and send nothing; the knock carries no `roomId`, so an invitee can never name a room it has not seen. -- Strangers (#884, #878): when the social plugin registers Bot Social's ticket record (`flybot.rooms.serveInvites({ identify, mint, preview, claim, verify, revoke, endClaim })`, `rt.roomInviteServer`), a mint goes to the server (`server: true`; a server that fails refuses the mint — it is never swapped for a local ticket — and one without `local_room_invites` answers `undefined`, which keeps the friend-only local ticket). The link knocks on `invite.botId`, the room lead when it has a Bot Social identity; a lead with none gets one first through the seam's `identify` (the link is the owner's consent, as accepting a friend's invite is), and so do the Bots a guest brings on Join. A room-only identity (left by a face-to-face room) counts as none (#899): it is not polled and cannot send the knock, so `identify` promotes it, and when the seam is registered `rt.identifyInviteBots` promotes the Bot of every server link still open or holding a seat. `room-invite-preview` answers `joined` next to `claimed`: a claim is not a seat, and `joined` is true only when the room that ticket seated is here. On the invitee's side, Join (`room-invite-ask` with `accept: true` and `bots`, the invitee's own Bot ids) claims the ticket at the server for those Bots, all or none, then the first of them sends `{ op: 'ask', ticket, accept: true, roomId, bots }`; `room-invite-preview` (`rt.previewLocalInvite`) reads the server's public record before Join. On the inviter's side the knock is only a request: `seatByTicket` reads the claims back with this home's own credential (`verify` → `claimers`, every home that joined; an older server's single `claimer`) and seats exactly the Bots of the claim that brought the asker, from that home, in this room, or nothing — refusals `unclaimed` / `claimed-elsewhere` / `wrong-room` / `lookup-failed` / `expired` / `revoked` / `not-addable`, each its own. A seat is `{ remote: true, admittedBy: 'ticket', ticket, hostBotId }`: no published friend (`publishedRemoteOf` stays empty, nothing in the sidebar), and `notifyRoomShare` / `fanoutRoomLine` reach it from `hostBotId` only, with `meta.scope` = the room id so the transport sends it under that room (Bot Social's room reach). A Bot Social link is the minting owner's consent to show the room as it is: while a link is open or holds a seat, that owner's Bots pass `mayShareRoom` for the room (`reach.js openedByLink`). Lines and leaves a remote seat sends to a room of this home are ingested into it (`ownSeatOf` → `ingestSeatShare`: logged, fanned out to the other seats but not back to the sender's, called local Bots answer through `rt.startInboundTurn`); a `line` for a room of this home from anyone it does not seat is dropped (`not-seated`) and never projects a copy. Withdrawing a link does not end its seats (owner decision on #853). A seat ends when the host removes it or its home leaves (once that home's last seat is gone its claim ends at the server, `settleTicket` → `endClaim`; the link stays open for others), and when the room is deleted (`rt.dropRoomInvites`, called by `deleteRoom`, revokes every link with `endClaims`). On the guest's side a `join` naming a ticket projects only when this home claimed it: `state.roomClaims[ticket]` names that inviter (the transport's sender), that room, and the Bot the join names, else `unclaimed` and nothing is made. One projected room holds every Bot a ticket seated (`sharedFrom.ticket`; each `join` adds or revives only the Bot it names) and the owner as a human participant, so the person the link admitted can write there; a person's own line goes under their Bot Social owner name when this home only knows them as `You` or their member id (`seatSayer`, both directions). Ticket lines carry the text as written (`seatText`: a stored fence is opened, never nested) and the receiving home fences it once. a seated Bot's line goes from that Bot under the inviter's room id, and the last of its Bots leaving gives the claim back at the server. Reach: `reach.js ticketPeers(state)` lists the only pairs a link opens — `{ from, to, scope, ticket }` for a seat here (from its `hostBotId`), for a room of another home that seated Bots of mine (from those Bots to its inviter), and for a claim this home made (`state.roomClaims[ticket]`, the accepting knock's way out, dropped when the last seated Bot leaves or after a day unanswered). `reach()` allows a local → remote send that matches one exactly under `meta.scope` (the exact handle, never any Bot of that home), and the social plugin reads the same list (`flybot.rooms.ticketPeers()`) for its transport. A seat is enumerated from the link's own record of who it admitted, and `reach.js` reads that record through `homesOf` (`room-membership.js`) — one reading, used by both halves, so a link accepted before `homes` was written (`acceptedBy` + `seats`) enumerates the same pair and a room that predates the field does not lose it (#929); `linkSeats` stays read-only, writes no state and sends nothing. A join also carries `history`: the recent chat lines (`who`, `authorKind`, `seq`, unwrapped text, and a Bot's handle when this home has one). An absent field does not backfill (an older host); an empty list means there were no chat lines. The guest copies them into the mirror once, only while that log has no chat lines yet, and does not start a turn for them (`applyShareHistory`). A line that arrives after the join — on the guest mirror (`applyInboundRoomShare`) and on the host room (`ingestSeatShare`), and a guest Bot's own reply from `startInboundTurn` — is posted into the room Session with `rt.showSharedLine` (`displayOnly`). The conversation pane is that Session; `room.log` alone only feeds the sidebar preview, because the channel view strips `log`. The followup is not appended again, so it does not fan out a second time, and `openingSpeakers` returns nothing for it, so a name in the text does not open a round. A redelivery of the same remote seq does not draw a second bubble (`room.shownShare`). Covered by `core/test/room-ticket-seat.test.js` and the `#923`/`#929` cases in `core/test/remote-room.test.js`. +- Strangers (#884, #878): when the social plugin registers Bot Social's ticket record (`flybot.rooms.serveInvites({ identify, mint, preview, claim, verify, revoke, endClaim })`, `rt.roomInviteServer`), a mint goes to the server (`server: true`; a server that fails refuses the mint — it is never swapped for a local ticket — and one without `local_room_invites` answers `undefined`, which keeps the friend-only local ticket). The link knocks on `invite.botId`, the room lead when it has a Bot Social identity; a lead with none gets one first through the seam's `identify` (the link is the owner's consent, as accepting a friend's invite is), and so do the Bots a guest brings on Join. A room-only identity (left by a face-to-face room) counts as none (#899): it is not polled and cannot send the knock, so `identify` promotes it, and when the seam is registered `rt.identifyInviteBots` promotes the Bot of every server link still open or holding a seat. `room-invite-preview` answers `joined` next to `claimed`: a claim is not a seat, and `joined` is true only when the room that ticket seated is here. On the invitee's side, Join (`room-invite-ask` with `accept: true` and `bots`, the invitee's own Bot ids) claims the ticket at the server for those Bots, all or none, then the first of them sends `{ op: 'ask', ticket, accept: true, roomId, bots }`; `room-invite-preview` (`rt.previewLocalInvite`) reads the server's public record before Join. On the inviter's side the knock is only a request: `seatByTicket` reads the claims back with this home's own credential (`verify` → `claimers`, every home that joined; an older server's single `claimer`) and seats exactly the Bots of the claim that brought the asker, from that home, in this room, or nothing — refusals `unclaimed` / `claimed-elsewhere` / `wrong-room` / `lookup-failed` / `expired` / `revoked` / `not-addable`, each its own. A seat is `{ remote: true, admittedBy: 'ticket', ticket, hostBotId }`: no published friend (`publishedRemoteOf` stays empty, nothing in the sidebar), and `notifyRoomShare` / `fanoutRoomLine` reach it from `hostBotId` only, with `meta.scope` = the room id so the transport sends it under that room (Bot Social's room reach). A Bot Social link is the minting owner's consent to show the room as it is: while a link is open or holds a seat, that owner's Bots pass `mayShareRoom` for the room (`reach.js openedByLink`). Lines and leaves a remote seat sends to a room of this home are ingested into it (`ownSeatOf` → `ingestSeatShare`: logged, fanned out to the other seats but not back to the sender's, called local Bots answer through `rt.startInboundTurn`); a `line` for a room of this home from anyone it does not seat is dropped (`not-seated`) and never projects a copy. Withdrawing a link does not end its seats (owner decision on #853). A seat ends when the host removes it or its home leaves (once that home's last seat is gone its claim ends at the server, `settleTicket` → `endClaim`; the link stays open for others), and when the room is deleted (`rt.dropRoomInvites`, called by `deleteRoom`, revokes every link with `endClaims`). On the guest's side a `join` naming a ticket projects only when this home claimed it: `state.roomClaims[ticket]` names that inviter (the transport's sender), that room, and the Bot the join names, else `unclaimed` and nothing is made. One projected room holds every Bot a ticket seated (`sharedFrom.ticket`; each `join` adds or revives only the Bot it names) and the owner as a human participant, so the person the link admitted can write there; a person's own line goes under their Bot Social owner name when this home only knows them as `You` or their member id (`seatSayer`, both directions). Ticket lines carry the text as written (`seatText`: a stored fence is opened, never nested) and the receiving home fences it once. a seated Bot's line goes from that Bot under the inviter's room id, and the last of its Bots leaving gives the claim back at the server. Reach: `reach.js ticketPeers(state)` lists the only pairs a link opens — `{ from, to, scope, ticket }` for a seat here (from its `hostBotId`), for a room of another home that seated Bots of mine (from those Bots to its inviter), and for a claim this home made (`state.roomClaims[ticket]`, the accepting knock's way out, dropped when the last seated Bot leaves or after a day unanswered). `reach()` allows a local → remote send that matches one exactly under `meta.scope` (the exact handle, never any Bot of that home), and the social plugin reads the same list (`flybot.rooms.ticketPeers()`) for its transport. A seat is enumerated from the link's own record of who it admitted, and `reach.js` reads that record through `homesOf` (`room-membership.js`) — one reading, used by both halves, so a link accepted before `homes` was written (`acceptedBy` + `seats`) enumerates the same pair and a room that predates the field does not lose it (#929); `linkSeats` stays read-only, writes no state and sends nothing. A join also carries `history`: the recent chat lines (`who`, `authorKind`, `seq`, unwrapped text, and a Bot's handle when this home has one). An absent field does not backfill (an older host); an empty list means there were no chat lines. The guest copies them into the mirror once, only while that log has no chat lines yet, and does not start a turn for them (`applyShareHistory`). A line that arrives after the join — on the guest mirror (`applyInboundRoomShare`) and on the host room (`ingestSeatShare`), and a guest Bot's own reply from `startInboundTurn` — is posted into the room Session with `rt.showSharedLine` (`displayOnly`). The conversation pane is that Session; `room.log` alone only feeds the sidebar preview, because the channel view strips `log`. The followup is not appended again, so it does not fan out a second time, and `openingSpeakers` returns nothing for it, so a name in the text does not open a round. A redelivery of the same remote seq does not draw a second bubble (`room.shownShare`). #1064: the knock is not the only way those seats are made. The social plugin's inbox poll calls `rt.seatOpenClaims` (`flybot.rooms.seatOpenClaims`), which `verify`s every open server link and seats each claiming home the same way, then sends `join` only for a seat whose notice has not yet left (`invite.told`). A lost, denied or unread knock still seats the guest, and the `join` opens the group on the guest's sidebar. A room that cannot be shown (`not-addable`), a notice waiting on approval (`held`) or one that does not leave (`not-delivered`, after a few tries) keeps `waiting` on the link (`reason`, and for `not-addable` the local Bot names). The Members card says so, and **Seat them** calls `room-invite-seat` (the room admin, same rule as mint), which runs that read again with the tries reset. Covered by `core/test/room-ticket-seat.test.js` and the `#923`/`#929` cases in `core/test/remote-room.test.js`. ### Face-to-face owner send diff --git a/docs/subsystems/plugin-runtime.md b/docs/subsystems/plugin-runtime.md index 97d91086..75754a92 100644 --- a/docs/subsystems/plugin-runtime.md +++ b/docs/subsystems/plugin-runtime.md @@ -82,7 +82,7 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots ### Inbound shared rooms on `flybot.rooms` - `flybot.rooms.applyInbound` turns a Bot Social inbox envelope into the owner's inbound-share projection; `leaveInbound` hides those rooms when the invited Bot is removed or its grant is revoked. Face-to-face methods are unchanged. Still not team mode. -- `flybot.rooms.serveInvites({ identify, mint, preview, claim, verify, revoke })` registers Bot Social's record of a local room's invite tickets (#884; `rt.roomInviteServer`, see core-host's "A local room's invite link"); it returns the unregister function. Registering it also runs `rt.identifyInviteBots` once (#899), so a link minted on a room-only Bot gets that Bot's full identity through `identify`. `mint` answers `undefined` when the server has no `local_room_invites`, and a refusal throws an Error whose `code` is Bot Social's. `flybot.rooms.ticketPeers()` lists the pairs a link opens (`{ from, to, scope, ticket }`, reach.js `ticketPeers`), so a transport can let a room share through to exactly those, under `scope`, and to nobody else off its roster. +- `flybot.rooms.serveInvites({ identify, mint, preview, claim, verify, revoke })` registers Bot Social's record of a local room's invite tickets (#884; `rt.roomInviteServer`, see core-host's "A local room's invite link"); it returns the unregister function. Registering it also runs `rt.identifyInviteBots` once (#899), so a link minted on a room-only Bot gets that Bot's full identity through `identify`. `mint` answers `undefined` when the server has no `local_room_invites`, and a refusal throws an Error whose `code` is Bot Social's. `flybot.rooms.seatOpenClaims(opts)` reads those claims and seats them without waiting for the accepting knock (#1064; `rt.seatOpenClaims`). `flybot.rooms.ticketPeers()` lists the pairs a link opens (`{ from, to, scope, ticket }`, reach.js `ticketPeers`), so a transport can let a room share through to exactly those, under `scope`, and to nobody else off its roster. ### Face-to-face owner lines diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 09755568..1bb816e4 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -205,7 +205,7 @@ Settings → **Bot Social** lists friends and shares once the server is connecte ### A local room's link for a stranger (#883, #884) - Bot Social #60 keeps a local room's invite tickets (`/v1/local-room-invites`, feature `local_room_invites`). `face-rooms.js` `createFaceRoomApi().localInvites` has `mint` / `preview` (with this home's token the minter reads every home that joined, `claimers`, and a claiming home its own `claimer`) / `claim` / `revoke(ticket, { endClaims })` / `endClaim(ticket, home)` (Bot Social #65: a link has no deadline and admits anyone it is given to until withdrawn), on the same connector token as the face-room routes; a refusal keeps Bot Social's code (`expired`, `revoked`, `already_claimed`, `own_invite`, `invite_not_found`, …). `social.js` registers them as the host's seam (`flybot.rooms.serveInvites`); `verify` is the preview read, and `mint` answers `undefined` on a server without the feature, so the host keeps its friend-only link. The Members card's **Invite by link** row (`LocalInviteRow`, #876, #892) mints with **Create link** / **New link**, shows the QR, the link, **Copy link** and **Withdraw link**; a link has no deadline, so it says *Works until you withdraw it.* (a link an older server dates counts down), and when earlier links of the room are still open it says how many and offers **Withdraw all**. On the joining side a preview with `claimed` is this home's own claim, never someone else's: with `joined` (the room is here) the dialog says *You already joined this group with that link.*; without it (#899) it says *Waiting for to let you in…* and keeps the card, so **Join group** claims again (the same Bots are an idempotent claim) and knocks again. -- The connector exception is one pair at a time. `ticketPair(msg)` passes a send only when it is a room share (`via` `room` / `room-share`, never a plain line, no files), from the exact local Bot, to the exact handle, under the room id in `meta.scope`, as `flybot.rooms.ticketPeers()` lists it (a seat here from the Bot its link named; on the guest side, the seated Bots to the room's inviter, and the claim's accepting knock), and only when the text is the host's whole envelope (`FLYBOT-ROOM-SHARE\n{json}`) for that same room id. The roster sync (`leaveMissingRemotes`) never drops a seat a link admitted (`admittedBy: 'ticket'`): its link decides, not the friend list. Such a send skips the roster and the hold (the link is the owner's consent, like adding a friend's Bot to a group) and goes out as `send_as_member` with `room_id` = that scope, which Bot Social's room reach carries and refuses once the link is withdrawn or given back. Every other send to someone off the roster keeps the deny sentence. Covered by `social.test.js` (#883) and `face-rooms.test.js` (#884). +- The connector exception is one pair at a time. `ticketPair(msg)` passes a send only when it is a room share (`via` `room` / `room-share`, never a plain line, no files), from the exact local Bot, to the exact handle, under the room id in `meta.scope`, as `flybot.rooms.ticketPeers()` lists it (a seat here from the Bot its link named; on the guest side, the seated Bots to the room's inviter, and the claim's accepting knock), and only when the text is the host's whole envelope (`FLYBOT-ROOM-SHARE\n{json}`) for that same room id. The roster sync (`leaveMissingRemotes`) never drops a seat a link admitted (`admittedBy: 'ticket'`): its link decides, not the friend list. Such a send skips the roster and the hold (the link is the owner's consent, like adding a friend's Bot to a group) and goes out as `send_as_member` with `room_id` = that scope, which Bot Social's room reach carries and refuses once the link is withdrawn or given back. Every other send to someone off the roster keeps the deny sentence. `pollOnce` also calls `flybot.rooms.seatOpenClaims` (#1064) before it reads any inbox, so a claim is seated even when the Bot the link knocks on is room-only and is not polled; the guest's **Join group** says the group was asked only when the ticket was claimed or the knock actually left (`sent` is not `deny` or `hold` with no claim). A claim the host could not finish (`waiting` on `room-invite`) is named on this row, and **Seat them** calls `room-invite-seat`. Covered by `social.test.js` (#883, #1064) and `face-rooms.test.js` (#884). - A 1:1 line between two Bots that are both live in one open face room names that room too (#881, Bot Social #43/#50): both send paths (a Bot's line in `sendOut`, the owner's typed line in `sendChat`) pass `room_id` = the `frm_` id when `roomContextFor` finds a room in `settings.faceRooms` this home has not left that still holds both Bots (the target as a `remote` member), so Bot Social decides on the room-scoped grant the pair already holds instead of answering `no_relationship`. A room line (`via` `room` / `room-share`, see above) and a local room (#852, no `face_rooms` row) name none; a room that is left, closed or dropped stops answering, and the next send is back to the plain per-grant rules. A room-scoped refusal is the room's answer and is never retried on another Bot of this home: `room_not_shared` reads *that room is closed between these Bots now. Send again from the room, or join it again.* and a room-scoped `no_relationship` reads *the room no longer lets these Bots reach each other. Join the room together again.* ### Discarded promise `.catch` (#213) diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 1c97e906..74b59d6e 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -464,7 +464,7 @@ diff --git a/src/client/feedback.js b/src/client/feedback.js diff --git a/src/client/i18n.js b/src/client/i18n.js --- a/src/client/i18n.js +++ b/src/client/i18n.js -@@ -12,6 +12,54 @@ const ZH = { +@@ -12,6 +12,59 @@ const ZH = { 'Unread activity': '有未读', Search: '搜索', 'New chat': '新对话', @@ -513,6 +513,11 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js + '1 earlier link still works.': '还有 1 个之前的链接仍然有效。', + '{count} earlier links still work.': '还有 {count} 个之前的链接仍然有效。', + 'Withdraw all': '全部撤回', ++ '{names} cannot be shown to someone who joins by link. Share them (Bot settings → Sharing) or remove them from the group, then seat the guest.': '{names} 不能给通过链接加入的人看到。请在 Bot 设置 → 共享里共享他们,或把他们移出群,然后再安排客人入座。', ++ 'A Bot in this group cannot be shown to someone who joins by link. Share that Bot or remove it, then seat the guest.': '这个群里有一个 Bot 不能给通过链接加入的人看到。请共享该 Bot 或把它移出群,然后再安排客人入座。', ++ 'A guest is in the group, but telling their FlyBot is waiting for your approval.': '客人已经在群里,但通知对方 FlyBot 还在等你批准。', ++ 'A guest is in the group, but their FlyBot has not opened it yet. Seat them again.': '客人已经在群里,但对方的 FlyBot 还没有打开这个群。请再安排一次入座。', ++ 'Seat them': '安排入座', + 'Paste an invite link or code': '粘贴邀请链接或邀请码', + 'A coding session without a Bot': '不带 Bot 的编码会话', + 'New Bot “{name}”': '新建 Bot「{name}」', diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index 0f119a8a..8b5ccbab 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -613,7 +613,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js return ok(room) } case 'update-room': { -@@ -175,8 +175,57 @@ export function install(rt) { +@@ -175,8 +175,65 @@ export function install(rt) { add: Array.isArray(body.add) ? body.add : undefined, remove: Array.isArray(body.remove) ? body.remove : undefined, workDir: body.workDir, @@ -670,6 +670,14 @@ diff --git a/src/host/channel.js b/src/host/channel.js + await rt.load() + const bots = Array.isArray(body.bots) ? body.bots.filter(id => typeof id === 'string').slice(0, 8) : undefined + return ok(await rt.askLocalInvite(String(body.link ?? body.url ?? ''), { accept: body.accept === true, ...(bots ? { bots } : {}) }, actor)) ++ } ++ // #1064: the owner finishes a claim the poll could not (a Bot that cannot be shown, a notice ++ // that did not leave). Same read as the poll, with the retries reset. ++ case 'room-invite-seat': { ++ await rt.load() ++ const roomId = String(body.roomId ?? body.id ?? '') ++ const seated = await rt.seatOpenClaims({ roomId, force: true }) ++ return ok({ ...seated, invite: rt.localInviteOf(roomId) ?? null, invites: rt.localInvitesOf(roomId) }) } case 'folders': case 'folder-pick': diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 4d2fd258..171e35e4 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -3,7 +3,7 @@ FlyBot patch 0008-client-bundle on Fei2411/DeepSeek-Bot@2b10c70 (ds-bot/). Gener diff --git a/client.js b/client.js --- a/client.js +++ b/client.js -@@ -49,6 +49,54 @@ var ZH = { +@@ -49,6 +49,59 @@ var ZH = { "Unread activity": "有未读", Search: "搜索", "New chat": "新对话", @@ -52,6 +52,11 @@ diff --git a/client.js b/client.js + "1 earlier link still works.": "还有 1 个之前的链接仍然有效。", + "{count} earlier links still work.": "还有 {count} 个之前的链接仍然有效。", + "Withdraw all": "全部撤回", ++ "{names} cannot be shown to someone who joins by link. Share them (Bot settings → Sharing) or remove them from the group, then seat the guest.": "{names} 不能给通过链接加入的人看到。请在 Bot 设置 → 共享里共享他们,或把他们移出群,然后再安排客人入座。", ++ "A Bot in this group cannot be shown to someone who joins by link. Share that Bot or remove it, then seat the guest.": "这个群里有一个 Bot 不能给通过链接加入的人看到。请共享该 Bot 或把它移出群,然后再安排客人入座。", ++ "A guest is in the group, but telling their FlyBot is waiting for your approval.": "客人已经在群里,但通知对方 FlyBot 还在等你批准。", ++ "A guest is in the group, but their FlyBot has not opened it yet. Seat them again.": "客人已经在群里,但对方的 FlyBot 还没有打开这个群。请再安排一次入座。", ++ "Seat them": "安排入座", + "Paste an invite link or code": "粘贴邀请链接或邀请码", + "A coding session without a Bot": "不带 Bot 的编码会话", + "New Bot “{name}”": "新建 Bot「{name}」", diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index 18701c85..3c36b5e6 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -32,13 +32,15 @@ diff --git a/src/client/api.js b/src/client/api.js diff --git a/src/client/composer.js b/src/client/composer.js --- a/src/client/composer.js +++ b/src/client/composer.js -@@ -2,5 +2,5 @@ +@@ -1,7 +1,7 @@ + import { createElement as h, Fragment, useEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { ChatsIcon, ClockIcon, MicIcon, PaperclipIcon, PeopleIcon, PlusIcon, WaveIcon } from './icons.js' -import { BotMark } from './mark.js' +import { BotMark, markBotForMention } from './mark.js' import { useLinger } from './motion.js' import { t } from './i18n.js' + @@ -53,6 +53,20 @@ export function useDictation(onText) { return { listening, note, start, stop } } @@ -60,11 +62,7 @@ diff --git a/src/client/composer.js b/src/client/composer.js export function ComposerVoice({ sessionId, inputActions, actions, useUi }) { const dictation = useDictation((text, final) => { if (final && inputActions) inputActions.insertText(text, inputActions.captureInsertion()) -@@ -92,11 +92,11 @@ - // Character marks for the "@" menu rows. Each Bot keeps one component, so the menu - // does not remount its mark on every keystroke. - export function menuIcons(roster) { - const marks = new Map() +@@ -96,7 +96,7 @@ export function menuIcons(roster) { return (row) => { if (!row.bot) return PeopleIcon const id = row.bot.id diff --git a/patches/ds-bot/files/src/host/authz.js b/patches/ds-bot/files/src/host/authz.js index 855730ad..15fbd73e 100644 --- a/patches/ds-bot/files/src/host/authz.js +++ b/patches/ds-bot/files/src/host/authz.js @@ -50,7 +50,7 @@ const BOT_TARGETED = new Set([ const ROOM_TARGETED = new Set(['update-room', 'delete-room', 'room-progress', 'create-room', 'room-history', 'room-post', // #876: the room's own link. Reading it is a member's read; minting and withdrawing hand out // or kill a way in, so they follow the room's admin rule. - 'room-invite', 'room-invite-mint', 'room-invite-revoke']) + 'room-invite', 'room-invite-mint', 'room-invite-revoke', 'room-invite-seat']) // Endpoints whose body names a Session. const SESSION_TARGETED = new Set(['earlier', 'send', 'answer-question', 'dismiss-question', 'approval-answer', 'exchange']) @@ -343,7 +343,7 @@ export function install(rt) { const me = currentParticipants(room).find(p => p.kind === 'human' && p.id === actor.memberId) return roomOwnedBy(actor, id) || roomMemberOf(actor, id) || me?.role === 'admin' ? ALLOW : deny('bot/forbidden', 'You are not a member of that room') } - if (ep === 'room-invite-mint' || ep === 'room-invite-revoke') { + if (ep === 'room-invite-mint' || ep === 'room-invite-revoke' || ep === 'room-invite-seat') { const id = body?.id ?? body?.roomId if (roomOwnedBy(actor, id)) return ALLOW const room = state.rooms?.[id] diff --git a/patches/ds-bot/files/src/host/hooks.js b/patches/ds-bot/files/src/host/hooks.js index 9287890d..e652a93e 100644 --- a/patches/ds-bot/files/src/host/hooks.js +++ b/patches/ds-bot/files/src/host/hooks.js @@ -1151,6 +1151,8 @@ export function install(rt) { // transport lets a room share through to exactly these, under `scope`, and to nobody else off // the roster. ticketPeers: () => rt.ticketPeers?.() ?? [], + // #1064: seat homes that claimed an open server link, without waiting for their knock. + seatOpenClaims: (opts) => rt.seatOpenClaims?.(opts) ?? Promise.resolve({ seated: [], waiting: [] }), }, remote: { publish: list => remoteMembers.publish(plugin, list), diff --git a/patches/ds-bot/files/src/host/room-membership.js b/patches/ds-bot/files/src/host/room-membership.js index ea81e994..17564f1b 100644 --- a/patches/ds-bot/files/src/host/room-membership.js +++ b/patches/ds-bot/files/src/host/room-membership.js @@ -1255,17 +1255,18 @@ export function install(rt) { // Membership notice: the add/remove click is the owner's consent, so this does not wait // for a chat line. Bot Social is 1:1; the envelope is the group record the other FlyBot sees. rt.notifyRoomShare = async function notifyRoomShare(room, { op = 'join', handle } = {}) { - if (!room || isFaceRoom(room) || room.origin === 'inbound-share') return + if (!room || isFaceRoom(room) || room.origin === 'inbound-share') return [] const wanted = handle ? String(handle) : undefined const remotes = (op === 'leave' ? (room.participants ?? []) : currentParticipants(room)) .filter(p => isRemoteParticipant(p) && (!wanted || p.handle === wanted || p.id === wanted)) - if (remotes.length === 0) return + if (remotes.length === 0) return [] const from = shareInviter(room) + const results = [] for (const remote of remotes) { if (isTicketSeat(remote)) { const seat = seatSender(room, remote) - if (!seat) continue - await sendShareTo(room, remote, { + if (!seat) { results.push(undefined); continue } + results.push(await sendShareTo(room, remote, { op, roomId: room.id, name: room.name, @@ -1274,11 +1275,11 @@ export function install(rt) { ticket: remote.ticket, ...(op === 'join' ? { roster: roomRoster(room), notice: room.notice || '', history: shareHistory(room) } : {}), ...seat.from, - }, 'room-share', seat.botId, room.id) + }, 'room-share', seat.botId, room.id)) continue } if (op !== 'leave' && !publishedRemoteOf(state, remote.handle)) continue - await sendShareTo(room, remote, { + results.push(await sendShareTo(room, remote, { op, roomId: room.id, name: room.name, @@ -1286,8 +1287,9 @@ export function install(rt) { seq: room.seq ?? 0, ...(op === 'join' ? { roster: roomRoster(room), notice: room.notice || '', history: shareHistory(room) } : {}), ...from, - }, 'room-share') + }, 'room-share')) } + return results } // #852, #878, #884: the link invitations for a room that lives here. A room keeps a list of tickets @@ -1334,6 +1336,8 @@ export function install(rt) { ...(invite.server === true ? { server: true } : {}), ...(seatedBy(invite) ? { joined: Object.keys(homesOf(invite)).length } : {}), ...(invite.withdrawnAt ? { withdrawn: true } : {}), + // #1064: a claim the poll could not finish. Local Bot names only — a guest handle is not shown. + ...(invite.waiting?.reason ? { waiting: { reason: String(invite.waiting.reason), bots: (Array.isArray(invite.waiting.bots) ? invite.waiting.bots : []).map(name => cleanPeerName(name, '')).filter(Boolean).slice(0, TICKET_SEATS_MAX) } } : {}), } } @@ -1686,40 +1690,24 @@ export function install(rt) { return { handled: true, room, accepted: true } } - // #884: the accepting knock on a ticket Bot Social minted. The knock is a request, never the - // authority: the claim is read back from the server with this home's own credential, and the seats - // are the Bots it names — all of them or none. Each way it can fail has its own reason. - async function seatByTicket(room, invite, data, event) { - const asker = String(event.fromHandle ?? '') - if (typeof rt.roomInviteServer?.verify !== 'function') return { handled: true, refused: 'lookup-failed' } - let claim - try { - claim = await rt.roomInviteServer.verify(invite.ticket) - } catch (error) { - const why = refusalOf(String(error?.code ?? '')) - return { handled: true, refused: why === 'unknown' ? 'revoked' : why } - } - if (!claim || String(claim.roomId ?? '') !== room.id) return { handled: true, refused: 'wrong-room' } - // A link admits every home it is given to (Bot Social #65): the minter reads each home's claim, and - // the knock is answered from the one whose Bots include the asker. An older server names one claimer. - const claims = Array.isArray(claim.claimers) ? claim.claimers : claim.claimer ? [claim.claimer] : [] - const botsOf = c => (Array.isArray(c?.bots) ? c.bots : []).map(String).filter(handle => handle.startsWith('@')) - if (!claims.some(c => botsOf(c).length > 0)) return { handled: true, refused: 'unclaimed' } - const mine = claims.find(c => botsOf(c).includes(asker)) - const seats = botsOf(mine) + const botsOfClaim = c => (Array.isArray(c?.bots) ? c.bots : []).map(String).filter(handle => handle.startsWith('@')) + + // Seat one home's claim, all of its Bots or none. The claim was already read back from the server. + async function admitClaim(room, invite, mine, asker) { + const seats = botsOfClaim(mine) const home = typeof mine?.home === 'string' ? mine.home : undefined - // The Bot that knocked must be one a claim brought, from the home that claimed it. - if (!mine || (home && event.fromHome && event.fromHome !== home)) return { handled: true, refused: 'claimed-elsewhere' } - if (seats.length > TICKET_SEATS_MAX) return { handled: true, refused: 'too-many' } + if (seats.length > TICKET_SEATS_MAX) return { refused: 'too-many' } const names = new Map((Array.isArray(mine?.members) ? mine.members : []).map(m => [String(m?.handle ?? ''), m])) const result = await withRoomLock(async () => { materialize(room) // A Bot of this room that may not be shown outside would be read by the stranger: refuse rather // than seat a room whose lines could not all go out. const blocked = unsharable(room).filter(p => p.id !== invite.botId) - if (blocked.length > 0) return { refused: 'not-addable' } + if (blocked.length > 0) return { refused: 'not-addable', bots: blocked.map(p => state.bots?.[p.id]?.name).filter(name => typeof name === 'string' && name) } const fresh = seats.filter(handle => !currentParticipants(room).some(p => p.remote === true && (p.handle === handle || p.id === handle))) const at = Date.now() + const homesBefore = JSON.stringify(homesOf(invite)) + const legacy = invite.acceptedBy !== undefined || invite.seats !== undefined invite.homes = { ...homesOf(invite), [home ?? asker]: seats } delete invite.acceptedBy delete invite.seats @@ -1749,13 +1737,140 @@ export function install(rt) { }) } syncMembers(room) - await rt.save?.() + // A poll reads this again every few seconds: a claim that is already seated must not rewrite the team. + if (fresh.length > 0 || legacy || homesBefore !== JSON.stringify(homesOf(invite))) await rt.save?.() return { fresh } }) - if (result.refused) return { handled: true, refused: result.refused } + if (result.refused) return { refused: result.refused, bots: result.bots } + return { seats, fresh: result.fresh } + } + + // #884: the accepting knock on a ticket Bot Social minted. The knock is a request, never the + // authority: the claim is read back from the server with this home's own credential, and the seats + // are the Bots it names — all of them or none. Each way it can fail has its own reason. + async function seatByTicket(room, invite, data, event) { + const asker = String(event.fromHandle ?? '') + if (typeof rt.roomInviteServer?.verify !== 'function') return { handled: true, refused: 'lookup-failed' } + let claim + try { + claim = await rt.roomInviteServer.verify(invite.ticket) + } catch (error) { + const why = refusalOf(String(error?.code ?? '')) + return { handled: true, refused: why === 'unknown' ? 'revoked' : why } + } + if (!claim || String(claim.roomId ?? '') !== room.id) return { handled: true, refused: 'wrong-room' } + // A link admits every home it is given to (Bot Social #65): the minter reads each home's claim, and + // the knock is answered from the one whose Bots include the asker. An older server names one claimer. + const claims = Array.isArray(claim.claimers) ? claim.claimers : claim.claimer ? [claim.claimer] : [] + if (!claims.some(c => botsOfClaim(c).length > 0)) return { handled: true, refused: 'unclaimed' } + const mine = claims.find(c => botsOfClaim(c).includes(asker)) + const home = typeof mine?.home === 'string' ? mine.home : undefined + // The Bot that knocked must be one a claim brought, from the home that claimed it. + if (!mine || (home && event.fromHome && event.fromHome !== home)) return { handled: true, refused: 'claimed-elsewhere' } + const admitted = await admitClaim(room, invite, mine, asker) + if (admitted.refused) return { handled: true, refused: admitted.refused } // Every seat hears the join, a retry included (the reply was lost): no second seat is made. - for (const handle of seats) await rt.notifyRoomShare?.(room, { op: 'join', handle }) - return { handled: true, room, accepted: true, seats } + // A send that left is remembered so the claim poll does not send it again. + const told = new Set(Array.isArray(invite.told) ? invite.told : []) + const held = new Set(Array.isArray(invite.held) ? invite.held : []) + for (const handle of admitted.seats) { + const sent = (await rt.notifyRoomShare?.(room, { op: 'join', handle }))?.[0] + const action = sent?.action + if (action === 'allow' || action === 'sent') { told.add(handle); held.delete(handle) } + else if (action === 'hold') held.add(handle) + } + invite.told = [...told] + if (held.size > 0) invite.held = [...held] + else delete invite.held + await rt.save?.() + return { handled: true, room, accepted: true, seats: admitted.seats } + } + + // Tell a seated handle once. `allow` / `sent` is delivered; `hold` is already on a card, so it is + // not sent again; a refusal is retried a few times and then left for the owner to finish. + async function tellSeats(room, invite, handles, { force = false } = {}) { + if (force) { + delete invite.tellTries + delete invite.held + } + const told = new Set(Array.isArray(invite.told) ? invite.told : []) + const held = new Set(force ? [] : (Array.isArray(invite.held) ? invite.held : [])) + let outcome = 'told' + for (const handle of handles) { + if (told.has(handle)) continue + if (held.has(handle)) { outcome = 'held'; continue } + const tries = invite.tellTries?.[handle] ?? 0 + if (!force && tries >= 3) { outcome = 'not-delivered'; continue } + invite.tellTries = { ...(invite.tellTries ?? {}), [handle]: tries + 1 } + const sent = (await rt.notifyRoomShare?.(room, { op: 'join', handle }))?.[0] + const action = sent?.action + if (action === 'allow' || action === 'sent') { + told.add(handle) + held.delete(handle) + const rest = { ...(invite.tellTries ?? {}) } + delete rest[handle] + invite.tellTries = rest + } else if (action === 'hold') { + held.add(handle) + outcome = 'held' + } else outcome = 'not-delivered' + } + invite.told = [...told] + if (held.size > 0) invite.held = [...held] + else delete invite.held + if (invite.tellTries && Object.keys(invite.tellTries).length === 0) delete invite.tellTries + return outcome + } + + // #1064: a guest's Join claims the ticket at Bot Social and then knocks. The knock is a request, + // and it can be refused, held, or never read (a room-only Bot is not polled). The claim is the + // authority either way: read every open server link and seat the homes it names. A room that + // cannot be shown, or a notice that does not leave, stays on the link as `waiting` for the owner. + rt.seatOpenClaims = async function seatOpenClaims({ roomId, force = false } = {}) { + if (typeof rt.roomInviteServer?.verify !== 'function') return { seated: [], waiting: [] } + const wanted = roomId === undefined || roomId === null || roomId === '' ? undefined : String(roomId) + const rooms = wanted ? [rt.roomOf(wanted)].filter(Boolean) : Object.values(state.rooms || {}) + const seated = [] + const waiting = [] + for (const room of rooms) { + if (!room || room.origin === 'inbound-share' || isFaceRoom(room)) continue + for (const invite of invitesOf(room)) { + if (invite.server !== true || !openInvite(invite)) continue + let claim + try { + claim = await rt.roomInviteServer.verify(invite.ticket) + } catch (error) { + rt.warn?.('ds-bot: a room link claim was not read: %s', error) + continue + } + if (!claim || String(claim.roomId ?? '') !== room.id) continue + const claims = (Array.isArray(claim.claimers) ? claim.claimers : claim.claimer ? [claim.claimer] : []).filter(c => botsOfClaim(c).length > 0) + const mark = () => JSON.stringify({ waiting: invite.waiting ?? null, told: invite.told ?? null, held: invite.held ?? null, tries: invite.tellTries ?? null }) + const before = mark() + let blocked = null + if (claims.length === 0) { + if (invite.waiting) delete invite.waiting + } else for (const mine of claims) { + const admitted = await admitClaim(room, invite, mine, botsOfClaim(mine)[0]) + if (admitted.refused === 'not-addable') { + blocked = { reason: 'not-addable', bots: admitted.bots ?? [] } + continue + } + if (admitted.refused) continue + seated.push(...(admitted.fresh ?? [])) + const outcome = await tellSeats(room, invite, admitted.seats ?? [], { force }) + if (outcome === 'not-delivered') blocked = blocked ?? { reason: 'not-delivered', bots: [] } + else if (outcome === 'held') blocked = blocked ?? { reason: 'held', bots: [] } + } + if (claims.length > 0) { + if (blocked) invite.waiting = { reason: blocked.reason, bots: blocked.bots, at: invite.waiting?.at ?? Date.now() } + else if (invite.waiting) delete invite.waiting + } + if (mark() !== before) await rt.save?.() + if (invite.waiting) waiting.push({ roomId: room.id, ticket: invite.ticket, reason: invite.waiting.reason, bots: invite.waiting.bots ?? [] }) + } + } + return { seated, waiting } } // A line or a leave that a remote seat sent into a room of this home (#884). The seat must be current diff --git a/patches/ds-bot/files/test/authz.test.js b/patches/ds-bot/files/test/authz.test.js index 4b7c1e52..2c6aecd5 100644 --- a/patches/ds-bot/files/test/authz.test.js +++ b/patches/ds-bot/files/test/authz.test.js @@ -105,7 +105,7 @@ test('owner cannot touch another owner Bot or room', async (t) => { } // #876: a room's link is a member's read, and minting or withdrawing it is the room admin's call. - for (const ep of ['room-invite', 'room-invite-mint', 'room-invite-revoke']) { + for (const ep of ['room-invite', 'room-invite-mint', 'room-invite-revoke', 'room-invite-seat']) { assert.equal(rt.authorize(actorA, ep, { id: 'ra' }).ok, true, ep) const gate = rt.authorize(actorA, ep, { id: 'rb' }) assert.equal(gate.ok, false, ep) 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 201c3b90..7aa8c370 100644 --- a/patches/ds-bot/files/test/room-ticket-seat.test.js +++ b/patches/ds-bot/files/test/room-ticket-seat.test.js @@ -604,3 +604,96 @@ test('#899: a link minted on a room-only Bot is promoted when the host wires the asked.length = 0 assert.deepEqual(await a.rt.identifyInviteBots(), [], 'a withdrawn link that seated nobody wakes nothing') }) + +test('#1064: a claim seats the guest when the accepting knock never arrives', async (t) => { + const { server, a, b, log, settle } = await twoHomes(t) + const invite = await a.rt.mintRoomInvite('war', { memberId: 'owner-1' }) + const guestSend = b.rt.flybotSend + const held = [] + b.rt.flybotSend = async (msg) => { held.push(msg); return { action: 'deny' } } + const joined = await b.rt.askLocalInvite(invite.url, { accept: true, bots: ['chief-b', 'helper-b'] }, { memberId: 'owner-1' }) + assert.equal(joined.claimed, true, 'the ticket was claimed at the server') + assert.equal(joined.sent, 'deny', 'the accepting knock did not leave') + await settle() + const seats = () => a.rt.state.rooms.war.participants.filter(p => p.remote === true && p.leftSeq === undefined) + assert.equal(seats().length, 0, 'a knock that never arrives seats nobody') + assert.equal(guestRoomOf(b.rt), undefined, 'the guest has no sidebar room yet') + assert.equal(held.length, 1, 'the knock was attempted') + assert.equal(log.some(m => parseRoomShare(m.text)?.op === 'ask'), false, 'the denied knock was not delivered') + + assert.equal(typeof a.rt.seatOpenClaims, 'function', 'the host reads open claims without waiting for the knock') + await a.rt.seatOpenClaims() + await settle() + assert.deepEqual(seats().map(p => p.handle).sort(), ['@chief.bob.home', '@helper.bob.home']) + assert.ok(seats().every(p => p.admittedBy === 'ticket' && p.ticket === invite.ticket && p.hostBotId === 'chief-a')) + const mirror = guestRoomOf(b.rt) + assert.ok(mirror && !mirror.hidden, 'the joins opened the group on the guest') + assert.equal(Object.values(b.rt.state.rooms).filter(r => r.origin === 'inbound-share').length, 1) + assert.deepEqual(mirror.participants.filter(p => p.leftSeq === undefined && p.kind === 'bot').map(p => p.id).sort(), ['@chief.alice.home', '@writer.alice.home', 'chief-b', 'helper-b']) + assert.equal(mirror.sharedFrom.ticket, invite.ticket) + assert.equal(mirror.sharedFrom.remoteRoomId, 'war') + + const joins = () => log.filter(m => parseRoomShare(m.text)?.op === 'join' && m.from === 'chief-a') + const joinCount = joins().length + assert.ok(joinCount >= 2, 'each seated Bot is told') + await a.rt.seatOpenClaims() + await settle() + assert.equal(seats().length, 2, 'a second read does not add a seat') + assert.equal(joins().length, joinCount, 'a seat already told is not told again') + assert.equal(Object.values(b.rt.state.rooms).filter(r => r.origin === 'inbound-share').length, 1) + + b.rt.flybotSend = guestSend + const line = await a.rt.postRoomMessage('war', { text: 'Ship on Friday?', authorId: 'owner-1', authorKind: 'human', displayName: 'Alice' }) + await a.rt.fanoutRoomLine(a.rt.state.rooms.war, line) + await settle() + assert.ok(mirror.log.some(entry => String(entry.text).includes('Ship on Friday?')), 'the host line arrived') + await b.rt.fanoutRoomLine(mirror, { who: 'Helper', botId: 'helper-b', authorId: 'helper-b', authorKind: 'bot', text: 'Friday works.', seq: (mirror.seq ?? 0) + 1 }) + await settle() + assert.ok(a.rt.state.rooms.war.log.some(entry => String(entry.text).includes('Friday works.')), 'the guest line landed in the host room') + + const late = await a.rt.mintRoomInvite('war', { memberId: 'owner-1' }) + b.rt.flybotSend = async () => ({ action: 'deny' }) + const second = await b.rt.askLocalInvite(late.url, { accept: true, bots: ['chief-b'] }, { memberId: 'owner-1' }) + assert.equal(second.claimed, true) + server.tickets.get(late.ticket).roomId = 'other' + await a.rt.seatOpenClaims() + await settle() + assert.equal(seats().length, 2, 'a claim that names another room seats nobody') + + const verify = a.rt.roomInviteServer.verify + a.rt.roomInviteServer.verify = undefined + assert.deepEqual(await a.rt.seatOpenClaims(), { seated: [], waiting: [] }) + a.rt.roomInviteServer.verify = verify + assert.equal(seats().length, 2, 'no claim was dropped') +}) + +test('#1064: a room that cannot be shown keeps a pending claim the host can finish', async (t) => { + const { a, b, settle } = await twoHomes(t) + a.rt.state.members['owner-2'] = { id: 'owner-2', createdAt: 1 } + a.rt.state.bots['other-a'] = { id: 'other-a', sessionId: 'other-a', name: 'Private', instructions: '', createdAt: 3, ownerId: 'owner-2' } + const war = a.rt.state.rooms.war + war.members = [...war.members, 'other-a'] + war.participants = [...war.participants, { kind: 'bot', id: 'other-a', role: 'member', displayName: 'Private', joinedSeq: 0, joinedAt: 1 }] + const invite = await a.rt.mintRoomInvite('war', { memberId: 'owner-1' }) + b.rt.flybotSend = async () => ({ action: 'deny' }) + const joined = await b.rt.askLocalInvite(invite.url, { accept: true, bots: ['chief-b'] }, { memberId: 'owner-1' }) + assert.equal(joined.claimed, true) + assert.equal(typeof a.rt.seatOpenClaims, 'function') + const blocked = await a.rt.seatOpenClaims() + const seats = () => war.participants.filter(p => p.remote === true && p.leftSeq === undefined) + assert.equal(seats().length, 0, 'a room that cannot be shown seats nobody') + assert.equal(blocked.waiting?.[0]?.reason, 'not-addable') + const view = a.rt.localInviteOf('war') + assert.equal(view.waiting.reason, 'not-addable') + assert.ok(view.waiting.bots.includes('Private'), 'the host is told which Bot blocks the seat') + assert.equal(guestRoomOf(b.rt), undefined) + + war.members = war.members.filter(id => id !== 'other-a') + war.participants = war.participants.filter(p => p.id !== 'other-a') + const done = await a.rt.seatOpenClaims({ roomId: 'war', force: true }) + await settle() + assert.deepEqual(seats().map(p => p.handle), ['@chief.bob.home']) + assert.deepEqual(done.waiting, []) + assert.equal(a.rt.localInviteOf('war').waiting, undefined) + assert.ok(guestRoomOf(b.rt), 'once the room can be shown, the guest sidebar opens') +}) diff --git a/plugins/social/client.js b/plugins/social/client.js index 5a26804d..242487c7 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -1164,12 +1164,18 @@ window.__ModuleLoader__.load({ const [busy, setBusy] = useState(false) const [copied, setCopied] = useState(false) const [, setTick] = useState(0) + const [pending, setPending] = useState(null) // The newest open link is the one shown; the others still work until withdrawn (no deadline). const openOf = value => (Array.isArray(value?.invites) ? value.invites.filter(item => item?.url) : []) + const waitingOf = value => { + const open = openOf(value) + return open.find(item => item?.waiting?.reason)?.waiting ?? (value?.invite?.waiting?.reason ? value.invite.waiting : null) + } const take = (value) => { const shown = value?.invite?.url ? value.invite : null setInvite(shown) setOthers(openOf(value).filter(item => item.ticket !== shown?.ticket).length) + setPending(waitingOf(value)) } // How many open links there are besides the one on screen (none shown: all of them). // A count that cannot be read keeps the one on screen: the mint or withdraw itself already landed. @@ -1177,6 +1183,7 @@ window.__ModuleLoader__.load({ let value try { value = await host('room-invite', { roomId }) } catch { return } setOthers(openOf(value).filter(item => item.ticket !== shownTicket).length) + setPending(waitingOf(value)) } useEffect(() => { if (!roomId) return undefined @@ -1211,8 +1218,22 @@ window.__ModuleLoader__.load({ await host('room-invite-revoke', { roomId }) setInvite(null) setOthers(0) + setPending(null) setNote({ tone: 'hint', text: tr('Every unused link was withdrawn.') }) }) + const seat = () => run(async () => { + take(await host('room-invite-seat', { roomId })) + }) + const waitingText = (item) => { + if (item?.reason === 'not-addable') { + const names = (Array.isArray(item.bots) ? item.bots : []).filter(Boolean).join(', ') + return names + ? tr('{names} cannot be shown to someone who joins by link. Share them (Bot settings → Sharing) or remove them from the group, then seat the guest.', { names }) + : tr('A Bot in this group cannot be shown to someone who joins by link. Share that Bot or remove it, then seat the guest.') + } + if (item?.reason === 'held') return tr('A guest is in the group, but telling their FlyBot is waiting for your approval.') + return tr('A guest is in the group, but their FlyBot has not opened it yet. Seat them again.') + } const copy = async () => { const clip = typeof navigator !== 'undefined' ? navigator.clipboard : undefined try { @@ -1251,6 +1272,9 @@ window.__ModuleLoader__.load({ : tr('Works until you withdraw it.')), h('button', { type: 'button', className: 'fs-link', 'data-flybot': 'room-invite-revoke', disabled: busy, onClick: revoke }, tr('Withdraw link'))) : null, + pending ? h('div', { className: 'fs-invite-waiting' }, + h('div', { className: 'fs-hint', 'data-flybot': 'room-invite-waiting', role: 'status' }, waitingText(pending)), + h('button', { type: 'button', className: 'fs-link', 'data-flybot': 'room-invite-seat', disabled: busy, onClick: seat }, tr('Seat them'))) : null, // Earlier links have no deadline either: say they still work, and let the owner end them all. others > 0 ? h('div', { className: 'fs-invite-others', 'data-flybot': 'room-invite-others' }, @@ -1691,6 +1715,21 @@ window.__ModuleLoader__.load({ setInvite({ link: text, status: { tone: 'error', text: localRefusal(result.refused) } }) return } + // A claim is what the other home seats from (#1064). A knock that never left, and no claim + // behind it, did not ask anyone — saying it did is how a guest stays on this sentence. + const sent = result?.sent + if (result?.claimed !== true && (sent === 'deny' || sent === 'hold')) { + setInvite({ + link: text, + status: { + tone: 'error', + text: sent === 'hold' + ? 'This FlyBot has not approved the request yet, so their group has not been asked.' + : 'Their FlyBot was not asked. Check the link and try again.', + }, + }) + return + } const name = invite.preview?.name && invite.preview.name !== 'A group on another FlyBot' ? `"${invite.preview.name}"` : 'the group' setInvite({ link: '', status: { tone: 'ok', text: `Asked to join ${name}. It opens in your sidebar as soon as their FlyBot seats your Bots.` } }) } catch (e) { diff --git a/plugins/social/src/social.js b/plugins/social/src/social.js index 3df3aa49..8d307044 100644 --- a/plugins/social/src/social.js +++ b/plugins/social/src/social.js @@ -2230,6 +2230,11 @@ export function createSocial(service, config = {}, { client: given, api: givenAp } async function pollOnce() { void syncLocalLooks().catch(error => warn('looks not synced', error)) + // #1064: a guest's claim is the seat, not the knock. Read open links even when the Bot the + // link knocks on is room-only and this loop never opens its inbox. + if (typeof flybot.rooms?.seatOpenClaims === 'function') { + await flybot.rooms.seatOpenClaims().catch(error => warn('a room link claim was not seated', error)) + } const targets = home ? flybot.bot.list().filter(bot => activeMember(bot.id)).map(bot => ({ local: bot, botId: memberOf(bot.id).bot_id })) : [{ local: (config.localBot ? flybot.bot.find(config.localBot) : undefined) ?? flybot.bot.list().find(bot => bot.isMain), botId: config.botId }] diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index 79f07e8a..a70e54d6 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -288,6 +288,26 @@ test('#90: an ungranted Bot (Chief included) is not registered or polled; invite assert.deepEqual(received.get('b') ?? [], []) }) +test('#1064: the inbox poll seats open room-link claims', async (t) => { + const { ctx, stop } = await team() + t.after(stop) + const server = mockServer() + let calls = 0 + // scope() builds a fresh service, so the stub has to be on the one createSocial keeps. + const scope = ctx.flybot.scope.bind(ctx.flybot) + ctx.flybot.scope = (name) => { + const scoped = scope(name) + if (name === 'social') scoped.rooms.seatOpenClaims = async () => { calls += 1; return { seated: [], waiting: [] } } + return scoped + } + const social = createSocial(ctx.flybot, { autoAccept: false, inboxPollMs: 0 }, { client: createMcpClient({ server: 'https://s', token: 'owner-token', fetch: server.fetch }), home: { id: 'h1', handle: '@danyang.home' } }) + t.after(() => social.dispose()) + await social.pollInbox() + assert.equal(calls, 1, 'a poll reads claims even when no Bot inbox is read') + await social.pollInbox() + assert.equal(calls, 2) +}) + test('#848: registration keeps the owner-level name and home the share envelope carries', async (t) => { const { ctx, stop } = await team() t.after(stop) @@ -11230,6 +11250,39 @@ test('#876: each refusal of a group link is its own sentence, never "not an invi assert.equal(late.last().status.text, 'That link has expired. Ask for a new one.') }) +test('#1064: Join does not say the group was asked when nothing was claimed', async () => { + const denied = localLinkDialog(async (endpoint) => { + if (endpoint === 'room-invite-preview') return { preview: { handle: '@chief.alice.home', ticket: 'AbCdEfGhIjKlMnOpQrStUv' } } + if (endpoint === 'room-invite-ask') return { sent: 'deny' } + return {} + }) + denied.render().find(n => n.props['data-flybot'] === 'face-link-join-go').props.onClick() + await settleUi() + assert.equal(denied.last().preview?.name, 'A group on another FlyBot') + denied.render().find(n => n.props['data-flybot'] === 'face-link-preview-join').props.onClick() + await settleUi() + assert.equal(denied.last().status.tone, 'error') + assert.equal(denied.last().status.text, 'Their FlyBot was not asked. Check the link and try again.') + assert.doesNotMatch(denied.last().status.text, /Asked to join/) + + const held = localLinkDialog(async (endpoint) => (endpoint === 'room-invite-ask' ? { sent: 'hold' } : {}), { link: LOCAL_URL, status: null, preview: { name: 'Trip plan', local: true, link: LOCAL_URL } }) + held.render().find(n => n.props['data-flybot'] === 'face-link-preview-join').props.onClick() + await settleUi() + assert.equal(held.last().status.tone, 'error') + assert.equal(held.last().status.text, 'This FlyBot has not approved the request yet, so their group has not been asked.') + assert.doesNotMatch(held.last().status.text, /Asked to join/) + + // A claim is what the other home seats from, even when the knock itself was refused. + const claimed = localLinkDialog(async (endpoint) => (endpoint === 'room-invite-ask' + ? { sent: 'deny', claimed: true, bots: ['@chief.bob.home'] } + : { preview: { name: 'Trip plan', lead: 'Chief', owner: 'Alice' } })) + claimed.render().find(n => n.props['data-flybot'] === 'face-link-join-go').props.onClick() + await settleUi() + claimed.render().find(n => n.props['data-flybot'] === 'face-link-preview-join').props.onClick() + await settleUi() + assert.match(claimed.last().status.text, /^Asked to join "Trip plan"/) +}) + // The Members-card row with a React stand-in that keeps every useState by its call order. function memberInviteRow(host) { const hostCalls = [] @@ -11316,6 +11369,33 @@ test('#892: earlier links still work, so the row says how many and can withdraw assert.equal(row.text(tree, 'room-invite-note'), 'Every unused link was withdrawn.') }) +test('#1064: a pending claim is named on the Members card and Seat them asks the host', async () => { + const url = 'https://social.example.com/r/@chief.alice.home/AbCdEfGhIjKlMnOpQrStUv' + const ticket = 'AbCdEfGhIjKlMnOpQrStUv' + const waiting = { reason: 'not-addable', bots: ['Private'] } + const open = { ticket, handle: '@chief.alice.home', url, expiresAt: null, server: true, waiting } + const clear = { ...open, waiting: undefined } + let seated = false + const row = memberInviteRow(async (endpoint) => { + if (endpoint === 'room-invite-mint') return { invite: clear } + if (endpoint === 'room-invite-seat') { + seated = true + return { invite: clear, invites: [clear] } + } + if (endpoint === 'room-invite') return { invite: seated ? clear : open, invites: [seated ? clear : open] } + return {} + }) + await row.byHook(row.draw(), 'room-invite-mint').props.onClick() + let tree = row.draw() + assert.match(row.text(tree, 'room-invite-waiting'), /Private/) + assert.match(row.text(tree, 'room-invite-waiting'), /cannot be shown/) + assert.equal(row.byHook(tree, 'room-invite-seat').children.join(''), 'Seat them') + await row.byHook(tree, 'room-invite-seat').props.onClick() + assert.deepEqual(plain(row.hostCalls.find(([endpoint]) => endpoint === 'room-invite-seat')), ['room-invite-seat', { roomId: 'r1' }]) + tree = row.draw() + assert.equal(row.byHook(tree, 'room-invite-waiting'), undefined, 'a finished claim leaves the card') +}) + test('#892: the dialog names both ways in: a number entered together, or a group invite link or its QR', () => { const tree = localLinkDialog(async () => ({}), { link: '', status: null }).render() const find = name => tree.find(n => n.props['data-flybot'] === name)