diff --git a/changelog.d/899-room-only-link-join.fixed.md b/changelog.d/899-room-only-link-join.fixed.md new file mode 100644 index 00000000..6e2a4331 --- /dev/null +++ b/changelog.d/899-room-only-link-join.fixed.md @@ -0,0 +1 @@ +A group link now works when the host's or the guest's Bot used a face-to-face room before: that room-only identity is promoted before the link is made or joined (and for links made earlier, when FlyBot starts), and the Join dialog says "already joined" only when the group is really in your sidebar; otherwise it says who you are waiting for and Join asks again ([#899](https://github.com/ThinkFlowLab/FlyBot/issues/899)). diff --git a/core/src/host/hooks.js b/core/src/host/hooks.js index 02bed51c..18220fb0 100644 --- a/core/src/host/hooks.js +++ b/core/src/host/hooks.js @@ -1132,6 +1132,8 @@ export function install(rt) { if (!impl || typeof impl !== 'object') { rt.roomInviteServer = undefined; return () => {} } const wrapped = { ...impl } rt.roomInviteServer = wrapped + // #899: links minted on a room-only Bot get its full identity, so their knocks are read. + if (typeof wrapped.identify === 'function') void Promise.resolve().then(() => rt.load?.()).then(() => rt.identifyInviteBots?.()).catch(error => rt.warn?.('ds-bot: link Bots not identified: %s', error)) return () => { if (rt.roomInviteServer === wrapped) rt.roomInviteServer = undefined } }, // The pairs a room link opens, `[{ from, to, scope, ticket }]` (reach.js ticketPeers): the diff --git a/core/src/host/room-membership.js b/core/src/host/room-membership.js index fb6d99a7..03ea1847 100644 --- a/core/src/host/room-membership.js +++ b/core/src/host/room-membership.js @@ -1067,6 +1067,9 @@ export function install(rt) { const handle = state.bots?.[botId]?.fields?.social?.member?.handle return typeof handle === 'string' && handle.startsWith('@') ? handle : undefined } + // #899: a link's Bots need a full identity: a room-only one (left by a face-to-face room) is not + // polled by the host and cannot send the guest's knock, so it is promoted like a missing one. + const activeHandleOf = botId => (state.bots?.[botId]?.fields?.social?.member?.roomOnly ? undefined : memberHandleOf(botId)) // The Bot the link knocks on: the room's lead when it has a Bot Social identity, else the first // member that has one. A stranger's seat talks to this Bot, and to no other of this home. function inviteBotOf(room) { @@ -1083,8 +1086,9 @@ export function install(rt) { // A link is the owner's consent to show this room outside: the Bot it knocks on gets a Bot Social // identity now if it has none (a home with no friends has none yet). const lead = [room.admin, ...(room.members ?? [])].find(id => id && rt.botOf?.(id)) - if (lead && !memberHandleOf(lead)) await identify([lead]) + if (lead && !activeHandleOf(lead)) await identify([lead]) const botId = inviteBotOf(room) + if (botId && !activeHandleOf(botId)) await identify([botId]) const handle = botId ? memberHandleOf(botId) : shareInviter(room).inviterHandle if (!handle) throw invalid('This machine has no Bot Social identity yet') const origin = rt.roomInviteOrigin || ROOM_INVITE_ORIGIN @@ -1147,6 +1151,19 @@ export function install(rt) { return { revoked: true, ...(wanted === undefined && gone.length > 1 ? { count: gone.length } : {}) } } + // #899: a link minted before its Bot was promoted (room-only) is never read on this home; promote + // every Bot a server link of a room knocks on, once the server record is wired in. + rt.identifyInviteBots = async function identifyInviteBots() { + const ids = new Set() + for (const room of Object.values(state.rooms || {})) { + for (const invite of invitesOf(room)) { + if (invite.server === true && invite.botId && state.bots?.[invite.botId] && !activeHandleOf(invite.botId) && (openInvite(invite) || seatedBy(invite))) ids.add(invite.botId) + } + } + if (ids.size > 0) await identify([...ids]) + return [...ids] + } + async function identify(ids) { if (typeof rt.roomInviteServer?.identify !== 'function') return try { await rt.roomInviteServer.identify(ids) } catch (error) { @@ -1219,6 +1236,8 @@ export function install(rt) { owner: cleanPeerName(seen.lead?.ownerName, ''), expiresAt: Number(seen.expiresAt) || undefined, claimed: seen.claimed === true, + // #899: a claim is not a seat: the room is here only once the inviter's home seated this one. + ...(seen.claimed === true ? { joined: joinedByTicket(parsed.ticket) } : {}), } } catch (error) { const code = String(error?.code ?? '') @@ -1226,6 +1245,8 @@ export function install(rt) { return { handle: parsed.handle, ticket: parsed.ticket, refused: refusalOf(code) } } } + const joinedByTicket = ticket => Object.values(state.rooms || {}).some(room => room.origin === 'inbound-share' && !room.hidden + && room.sharedFrom?.ticket === ticket && currentParticipants(room).some(p => p.kind === 'bot' && !p.remote)) // Bot Social's refusal code, as the one reason the dialog names. function refusalOf(code) { switch (code) { @@ -1276,9 +1297,9 @@ export function install(rt) { if (asked.length > 0 && picked.length !== new Set(asked).size) throw invalid('Pick Bots of your own to bring') if (picked.length === 0) throw invalid('This machine has no Bot Social identity yet') if (picked.length > TICKET_SEATS_MAX) throw invalid(`A link brings at most ${TICKET_SEATS_MAX} Bots`) - const missing = picked.filter(bot => !memberHandleOf(bot.id)).map(bot => bot.id) + const missing = picked.filter(bot => !activeHandleOf(bot.id)).map(bot => bot.id) if (missing.length > 0) await identify(missing) - const handles = picked.map(bot => memberHandleOf(bot.id)) + const handles = picked.map(bot => activeHandleOf(bot.id)) if (handles.some(handle => !handle)) throw invalid('This machine has no Bot Social identity yet') let claim try { diff --git a/core/test/room-ticket-seat.test.js b/core/test/room-ticket-seat.test.js index 0ba55fae..5bac8562 100644 --- a/core/test/room-ticket-seat.test.js +++ b/core/test/room-ticket-seat.test.js @@ -462,3 +462,52 @@ test('#884: a join that names a ticket this home never claimed opens nothing', a assert.equal(ok.refused, undefined) assert.equal(Object.keys(b.rt.state.rooms).length, before + 1, 'the claimed join projects the room') }) + +test('#899: a room-only identity is promoted for the link it makes and the link it joins, and a claim is not a seat', async (t) => { + const { a, b, log, settle } = await twoHomes(t) + const asked = [] + // The plugin's identify promotes a room-only identity (register forInvite drops the flag, #863). + const promote = rt => async (ids) => { + asked.push(...ids) + for (const id of ids) delete rt.state.bots[id].fields.social.member.roomOnly + } + // Both Chiefs used a face-to-face room before: a handle, but room-only (not polled, cannot send). + a.rt.state.bots['chief-a'].fields.social.member.roomOnly = true + b.rt.state.bots['chief-b'].fields.social.member.roomOnly = true + a.rt.roomInviteServer.identify = promote(a.rt) + b.rt.roomInviteServer.identify = promote(b.rt) + const invite = await a.rt.mintRoomInvite('war', { memberId: 'owner-1' }) + assert.deepEqual(asked, ['chief-a'], 'the Bot the link knocks on gets a full identity') + assert.equal(a.rt.state.bots['chief-a'].fields.social.member.roomOnly, undefined) + + // The knock is lost once (the claim landed at Bot Social): the guest is not told it joined. + const send = b.rt.flybotSend + b.rt.flybotSend = async () => { throw new Error('connect ECONNRESET') } + await assert.rejects(() => b.rt.askLocalInvite(invite.url, { accept: true, bots: ['chief-b'] }, { memberId: 'owner-1' }), /ECONNRESET/) + assert.deepEqual(asked, ['chief-a', 'chief-b'], "the guest's room-only Bot is promoted before it knocks") + const waiting = await b.rt.previewLocalInvite(invite.url) + assert.equal(waiting.claimed, true) + assert.equal(waiting.joined, false, 'claimed, but no room here yet') + + // Join again knocks again under the same claim, and the room arrives. + b.rt.flybotSend = send + const again = await b.rt.askLocalInvite(invite.url, { accept: true, bots: ['chief-b'] }, { memberId: 'owner-1' }) + assert.equal(again.claimed, true) + await settle() + assert.ok(log.some(m => parseRoomShare(m.text)?.op === 'ask' && m.delivered)) + assert.ok(guestRoomOf(b.rt), 'the host seated the guest') + assert.equal((await b.rt.previewLocalInvite(invite.url)).joined, true) +}) + +test('#899: a link minted on a room-only Bot is promoted when the host wires the server in', async (t) => { + const { a } = await twoHomes(t) + const invite = await a.rt.mintRoomInvite('war', { memberId: 'owner-1' }) + a.rt.state.bots['chief-a'].fields.social.member.roomOnly = true + const asked = [] + a.rt.roomInviteServer.identify = async ids => { asked.push(...ids) } + assert.deepEqual(await a.rt.identifyInviteBots(), ['chief-a']) + assert.deepEqual(asked, ['chief-a']) + await a.rt.revokeRoomInvite('war', invite.ticket) + asked.length = 0 + assert.deepEqual(await a.rt.identifyInviteBots(), [], 'a withdrawn link that seated nobody wakes nothing') +}) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 88776063..6838bf6e 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -158,7 +158,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. 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. Covered by `core/test/room-ticket-seat.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. Covered by `core/test/room-ticket-seat.test.js`. ### Face-to-face owner send diff --git a/docs/subsystems/plugin-runtime.md b/docs/subsystems/plugin-runtime.md index 7cc8a169..9fb4ab9e 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. `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.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 d29cdc86..424a2af3 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -188,7 +188,7 @@ pins the list. The authority for the list is Bot Social's README and `docs/FACE_ ### 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` means this home already joined (*You already joined this group with that link.*), never someone else's claim. +- 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). ### Discarded promise `.catch` (#213) diff --git a/patches/ds-bot/files/src/host/hooks.js b/patches/ds-bot/files/src/host/hooks.js index 02bed51c..18220fb0 100644 --- a/patches/ds-bot/files/src/host/hooks.js +++ b/patches/ds-bot/files/src/host/hooks.js @@ -1132,6 +1132,8 @@ export function install(rt) { if (!impl || typeof impl !== 'object') { rt.roomInviteServer = undefined; return () => {} } const wrapped = { ...impl } rt.roomInviteServer = wrapped + // #899: links minted on a room-only Bot get its full identity, so their knocks are read. + if (typeof wrapped.identify === 'function') void Promise.resolve().then(() => rt.load?.()).then(() => rt.identifyInviteBots?.()).catch(error => rt.warn?.('ds-bot: link Bots not identified: %s', error)) return () => { if (rt.roomInviteServer === wrapped) rt.roomInviteServer = undefined } }, // The pairs a room link opens, `[{ from, to, scope, ticket }]` (reach.js ticketPeers): the diff --git a/patches/ds-bot/files/src/host/room-membership.js b/patches/ds-bot/files/src/host/room-membership.js index fb6d99a7..03ea1847 100644 --- a/patches/ds-bot/files/src/host/room-membership.js +++ b/patches/ds-bot/files/src/host/room-membership.js @@ -1067,6 +1067,9 @@ export function install(rt) { const handle = state.bots?.[botId]?.fields?.social?.member?.handle return typeof handle === 'string' && handle.startsWith('@') ? handle : undefined } + // #899: a link's Bots need a full identity: a room-only one (left by a face-to-face room) is not + // polled by the host and cannot send the guest's knock, so it is promoted like a missing one. + const activeHandleOf = botId => (state.bots?.[botId]?.fields?.social?.member?.roomOnly ? undefined : memberHandleOf(botId)) // The Bot the link knocks on: the room's lead when it has a Bot Social identity, else the first // member that has one. A stranger's seat talks to this Bot, and to no other of this home. function inviteBotOf(room) { @@ -1083,8 +1086,9 @@ export function install(rt) { // A link is the owner's consent to show this room outside: the Bot it knocks on gets a Bot Social // identity now if it has none (a home with no friends has none yet). const lead = [room.admin, ...(room.members ?? [])].find(id => id && rt.botOf?.(id)) - if (lead && !memberHandleOf(lead)) await identify([lead]) + if (lead && !activeHandleOf(lead)) await identify([lead]) const botId = inviteBotOf(room) + if (botId && !activeHandleOf(botId)) await identify([botId]) const handle = botId ? memberHandleOf(botId) : shareInviter(room).inviterHandle if (!handle) throw invalid('This machine has no Bot Social identity yet') const origin = rt.roomInviteOrigin || ROOM_INVITE_ORIGIN @@ -1147,6 +1151,19 @@ export function install(rt) { return { revoked: true, ...(wanted === undefined && gone.length > 1 ? { count: gone.length } : {}) } } + // #899: a link minted before its Bot was promoted (room-only) is never read on this home; promote + // every Bot a server link of a room knocks on, once the server record is wired in. + rt.identifyInviteBots = async function identifyInviteBots() { + const ids = new Set() + for (const room of Object.values(state.rooms || {})) { + for (const invite of invitesOf(room)) { + if (invite.server === true && invite.botId && state.bots?.[invite.botId] && !activeHandleOf(invite.botId) && (openInvite(invite) || seatedBy(invite))) ids.add(invite.botId) + } + } + if (ids.size > 0) await identify([...ids]) + return [...ids] + } + async function identify(ids) { if (typeof rt.roomInviteServer?.identify !== 'function') return try { await rt.roomInviteServer.identify(ids) } catch (error) { @@ -1219,6 +1236,8 @@ export function install(rt) { owner: cleanPeerName(seen.lead?.ownerName, ''), expiresAt: Number(seen.expiresAt) || undefined, claimed: seen.claimed === true, + // #899: a claim is not a seat: the room is here only once the inviter's home seated this one. + ...(seen.claimed === true ? { joined: joinedByTicket(parsed.ticket) } : {}), } } catch (error) { const code = String(error?.code ?? '') @@ -1226,6 +1245,8 @@ export function install(rt) { return { handle: parsed.handle, ticket: parsed.ticket, refused: refusalOf(code) } } } + const joinedByTicket = ticket => Object.values(state.rooms || {}).some(room => room.origin === 'inbound-share' && !room.hidden + && room.sharedFrom?.ticket === ticket && currentParticipants(room).some(p => p.kind === 'bot' && !p.remote)) // Bot Social's refusal code, as the one reason the dialog names. function refusalOf(code) { switch (code) { @@ -1276,9 +1297,9 @@ export function install(rt) { if (asked.length > 0 && picked.length !== new Set(asked).size) throw invalid('Pick Bots of your own to bring') if (picked.length === 0) throw invalid('This machine has no Bot Social identity yet') if (picked.length > TICKET_SEATS_MAX) throw invalid(`A link brings at most ${TICKET_SEATS_MAX} Bots`) - const missing = picked.filter(bot => !memberHandleOf(bot.id)).map(bot => bot.id) + const missing = picked.filter(bot => !activeHandleOf(bot.id)).map(bot => bot.id) if (missing.length > 0) await identify(missing) - const handles = picked.map(bot => memberHandleOf(bot.id)) + const handles = picked.map(bot => activeHandleOf(bot.id)) if (handles.some(handle => !handle)) throw invalid('This machine has no Bot Social identity yet') let claim try { 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 0ba55fae..5bac8562 100644 --- a/patches/ds-bot/files/test/room-ticket-seat.test.js +++ b/patches/ds-bot/files/test/room-ticket-seat.test.js @@ -462,3 +462,52 @@ test('#884: a join that names a ticket this home never claimed opens nothing', a assert.equal(ok.refused, undefined) assert.equal(Object.keys(b.rt.state.rooms).length, before + 1, 'the claimed join projects the room') }) + +test('#899: a room-only identity is promoted for the link it makes and the link it joins, and a claim is not a seat', async (t) => { + const { a, b, log, settle } = await twoHomes(t) + const asked = [] + // The plugin's identify promotes a room-only identity (register forInvite drops the flag, #863). + const promote = rt => async (ids) => { + asked.push(...ids) + for (const id of ids) delete rt.state.bots[id].fields.social.member.roomOnly + } + // Both Chiefs used a face-to-face room before: a handle, but room-only (not polled, cannot send). + a.rt.state.bots['chief-a'].fields.social.member.roomOnly = true + b.rt.state.bots['chief-b'].fields.social.member.roomOnly = true + a.rt.roomInviteServer.identify = promote(a.rt) + b.rt.roomInviteServer.identify = promote(b.rt) + const invite = await a.rt.mintRoomInvite('war', { memberId: 'owner-1' }) + assert.deepEqual(asked, ['chief-a'], 'the Bot the link knocks on gets a full identity') + assert.equal(a.rt.state.bots['chief-a'].fields.social.member.roomOnly, undefined) + + // The knock is lost once (the claim landed at Bot Social): the guest is not told it joined. + const send = b.rt.flybotSend + b.rt.flybotSend = async () => { throw new Error('connect ECONNRESET') } + await assert.rejects(() => b.rt.askLocalInvite(invite.url, { accept: true, bots: ['chief-b'] }, { memberId: 'owner-1' }), /ECONNRESET/) + assert.deepEqual(asked, ['chief-a', 'chief-b'], "the guest's room-only Bot is promoted before it knocks") + const waiting = await b.rt.previewLocalInvite(invite.url) + assert.equal(waiting.claimed, true) + assert.equal(waiting.joined, false, 'claimed, but no room here yet') + + // Join again knocks again under the same claim, and the room arrives. + b.rt.flybotSend = send + const again = await b.rt.askLocalInvite(invite.url, { accept: true, bots: ['chief-b'] }, { memberId: 'owner-1' }) + assert.equal(again.claimed, true) + await settle() + assert.ok(log.some(m => parseRoomShare(m.text)?.op === 'ask' && m.delivered)) + assert.ok(guestRoomOf(b.rt), 'the host seated the guest') + assert.equal((await b.rt.previewLocalInvite(invite.url)).joined, true) +}) + +test('#899: a link minted on a room-only Bot is promoted when the host wires the server in', async (t) => { + const { a } = await twoHomes(t) + const invite = await a.rt.mintRoomInvite('war', { memberId: 'owner-1' }) + a.rt.state.bots['chief-a'].fields.social.member.roomOnly = true + const asked = [] + a.rt.roomInviteServer.identify = async ids => { asked.push(...ids) } + assert.deepEqual(await a.rt.identifyInviteBots(), ['chief-a']) + assert.deepEqual(asked, ['chief-a']) + await a.rt.revokeRoomInvite('war', invite.ticket) + asked.length = 0 + assert.deepEqual(await a.rt.identifyInviteBots(), [], 'a withdrawn link that seated nobody wakes nothing') +}) diff --git a/plugins/social/client.js b/plugins/social/client.js index b5fe4e60..76b31ac5 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -1572,11 +1572,17 @@ window.__ModuleLoader__.load({ return } const card = invitePreview({ name: seen?.name, lead: seen?.owner ? `${seen.lead || 'Bot'} (${seen.owner})` : seen?.lead, expiresAt: isoAt(seen?.expiresAt) }) - if (seen?.claimed) { - // `claimed` is this home's own claim: it already joined (#892), so say that rather than refuse. + if (seen?.claimed && seen?.joined) { + // `claimed` is this home's own claim (#892); `joined` says the room really is here (#899). setInvite({ link: text, status: { tone: 'hint', text: 'You already joined this group with that link. It is in your sidebar.' } }) return } + if (seen?.claimed) { + // #899: a claim with no room here is a knock nobody answered yet: say so, and let Join knock again. + const who = seen.owner || seen.lead || 'the group\u2019s owner' + setInvite({ link: text, status: { tone: 'hint', text: `Waiting for ${who} to let you in. If the group does not appear, press Join group to ask again.` }, preview: { ...(card ?? { name: 'A group on another FlyBot', lead: '', people: '', expiresAt: '', clockOffsetMs: 0 }), local: true, link: text } }) + return + } if (!card) { // An older server has no record to read: the friend's knock is still the way in. setInvite({ link: text, status: null, preview: { name: 'A group on another FlyBot', lead: '', people: '', expiresAt: '', clockOffsetMs: 0, local: true, link: text } }) diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index 7939ceb0..fc46f41a 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -9951,10 +9951,23 @@ test('#876: each refusal of a group link is its own sentence, never "not an invi } assert.equal(seen.size, 7, 'seven refusals, seven sentences') // A link this home already joined with is said before anyone presses Join. - const taken = localLinkDialog(async () => ({ preview: { name: 'Trip plan', lead: 'Chief', expiresAt: Date.now() + 60_000, claimed: true } })) + const taken = localLinkDialog(async () => ({ preview: { name: 'Trip plan', lead: 'Chief', expiresAt: Date.now() + 60_000, claimed: true, joined: true } })) taken.render().find(n => n.props['data-flybot'] === 'face-link-join-go').props.onClick() await settleUi() assert.match(taken.last().status.text, /You already joined this group with that link/, '#892: claimed is this home\'s own claim, never someone else\'s') + // #899: a claim whose room never arrived is not "joined": it waits, and Join knocks again. + const stale = localLinkDialog(async (endpoint) => (endpoint === 'room-invite-ask' + ? { sent: 'allow', claimed: true, bots: ['@chief.bob.home'] } + : { preview: { name: 'Trip plan', lead: 'Chief', owner: 'Alice', claimed: true, joined: false } })) + stale.render().find(n => n.props['data-flybot'] === 'face-link-join-go').props.onClick() + await settleUi() + assert.doesNotMatch(stale.last().status.text, /already joined/) + assert.equal(stale.last().status.text, 'Waiting for Alice to let you in. If the group does not appear, press Join group to ask again.') + assert.equal(stale.last().preview?.local, true, 'the card stays, so Join can ask again') + stale.render().find(n => n.props['data-flybot'] === 'face-link-preview-join').props.onClick() + await settleUi() + assert.deepEqual(plain(stale.hostCalls.at(-1)), ['room-invite-ask', { link: LOCAL_URL, accept: true, bots: ['a', 'b'] }]) + assert.match(stale.last().status.text, /^Asked to join "Trip plan"/) // And a refusal on Join (the claim) is said the same way. const late = localLinkDialog(async (endpoint) => (endpoint === 'room-invite-ask' ? { refused: 'expired' } : {}), { link: LOCAL_URL, status: null, preview: { name: 'Trip plan', local: true, link: LOCAL_URL } }) late.render().find(n => n.props['data-flybot'] === 'face-link-preview-join').props.onClick()