diff --git a/changelog.d/1176-invite-link-already-seated.fixed.md b/changelog.d/1176-invite-link-already-seated.fixed.md new file mode 100644 index 000000000..fe4c3a9dc --- /dev/null +++ b/changelog.d/1176-invite-link-already-seated.fixed.md @@ -0,0 +1 @@ +Pasting a group invite link for a room this home already holds no longer ends on "Asked to join": the preview now says the group is already in the sidebar and draws no Join card, when the link seated the room and when the host added one of these Bots to it by add-member (#1176). diff --git a/core/src/host/room-membership.js b/core/src/host/room-membership.js index 6e0fe5d0a..dae2faff3 100644 --- a/core/src/host/room-membership.js +++ b/core/src/host/room-membership.js @@ -1543,17 +1543,21 @@ export function install(rt) { try { const seen = await rt.roomInviteServer.preview(parsed.ticket) if (!seen) return { handle: parsed.handle, ticket: parsed.ticket } + const roomId = String(seen.roomId ?? '') return { handle: parsed.handle, ticket: parsed.ticket, server: true, + // #1176: the room the link is for, so a copy this home already holds can be recognised. + ...(roomId ? { roomId } : {}), name: cleanPeerName(seen.name, ''), lead: cleanPeerName(seen.lead?.name, cleanPeerName(seen.lead?.handle, '')), 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) } : {}), + // #899/#1176: a claim is not a room and a room is not a claim. The room is here when this home + // holds it — the ticket seated it, or the host added this Bot by add-member (remote room id, no ticket). + joined: roomIsHere({ ticket: parsed.ticket, roomId }), } } catch (error) { const code = String(error?.code ?? '') @@ -1561,8 +1565,10 @@ 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)) + const roomIsHere = ({ ticket, roomId }) => Object.values(state.rooms || {}).some(room => room.origin === 'inbound-share' && !room.hidden + && currentParticipants(room).some(p => p.kind === 'bot' && !p.remote) + && ((ticket && room.sharedFrom?.ticket === ticket) + || (roomId && String(room.sharedFrom?.remoteRoomId ?? '') === String(roomId)))) // Bot Social's refusal code, as the one reason the dialog names. function refusalOf(code) { switch (code) { diff --git a/core/test/room-ticket-seat.test.js b/core/test/room-ticket-seat.test.js index 366a6ab56..ceafc795e 100644 --- a/core/test/room-ticket-seat.test.js +++ b/core/test/room-ticket-seat.test.js @@ -501,6 +501,39 @@ test('#899: a room-only identity is promoted for the link it makes and the link assert.equal((await b.rt.previewLocalInvite(invite.url)).joined, true) }) +test('#1176: a link for a room this home was added to by add-member says it is here, not that it knocked', async (t) => { + const { a, b } = await twoHomes(t) + const invite = await a.rt.mintRoomInvite('war', { memberId: 'owner-1' }) + // The ordinary add-member shape: a join that names no ticket at all, so the copy here is keyed by the + // remote room id (sharedFrom.remoteRoomId) and a ticket match can never find it. + const added = encodeRoomShare({ op: 'join', roomId: 'war', name: 'Launch plan', invitedHandle: '@chief.bob.home', inviterHandle: '@chief.alice.home', inviterName: 'Alice' }) + const out = await b.rt.applyInboundRoomShare({ text: added, fromHandle: '@chief.alice.home', fromHome: '@alice.home', fromName: 'Bot', localBotId: 'chief-b' }) + assert.equal(out.refused, undefined, 'the add-member join projects the room') + const mirror = guestRoomOf(b.rt) + assert.ok(mirror, 'the room is on this side') + assert.equal(mirror.sharedFrom.ticket, undefined, 'add-member wrote no ticket for this room') + assert.equal(mirror.sharedFrom.remoteRoomId, 'war') + + const preview = await b.rt.previewLocalInvite(invite.url) + assert.equal(preview.roomId, 'war', 'the preview names the room the link is for') + assert.equal(preview.claimed, false, 'this home never claimed this link') + assert.equal(preview.joined, true, 'the room is already here, so the link is not a knock') +}) + +test('#1176: a link whose room is not here is still a knock, whatever the room id says', async (t) => { + const { a, b } = await twoHomes(t) + const invite = await a.rt.mintRoomInvite('war', { memberId: 'owner-1' }) + // Same remote room id, but the copy here was hidden (the host removed this Bot, #1167): it is not a room + // this person can be told they are in. + const added = encodeRoomShare({ op: 'join', roomId: 'war', name: 'Launch plan', invitedHandle: '@chief.bob.home', inviterHandle: '@chief.alice.home', inviterName: 'Alice' }) + await b.rt.applyInboundRoomShare({ text: added, fromHandle: '@chief.alice.home', fromHome: '@alice.home', fromName: 'Bot', localBotId: 'chief-b' }) + const mirror = guestRoomOf(b.rt) + mirror.hidden = true + const preview = await b.rt.previewLocalInvite(invite.url) + assert.equal(preview.roomId, 'war') + assert.equal(preview.joined, false, 'a hidden room is not one this person is in') +}) + test('a ticket seat shows the other home\'s lines in the room session, including lines from before the join', async (t) => { const { a, b, log, settle } = await twoHomes(t) const shown = { a: [], b: [] } diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index b64ee72c2..984267253 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -222,7 +222,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`). #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. #1161: a quiet poll reads one open server link about once a minute and, when the preview says `Too many attempts` (or answers `rate_limited` / `locked_out`), waits out the minutes that sentence names or the `Retry-After`, up to 10 minutes, so a home that stays open does not preview again before that cool-down elapses; **Seat them** and a read of one room still verify at once, and a preview that did not answer is tried again on the next tick. Every room-share text, a `join` and a `line` alike, is kept within Bot Social's 1–4000 character limit (`ROOM_SHARE_TEXT_MAX`): instruction-override lines are left out of its history and notice, then the oldest history, the notice, and roster entries are dropped until it fits; a history or roster emptied only to fit is omitted, so it is not a false "no lines" or "no Bots". A seating send that is still `pending_approval` is `hold` and is not recorded as told; one whose hold is not listed yet is retried as not delivered. 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 `roomId` and `joined` next to `claimed`: a claim is not a seat and a seat is not a claim — `joined` is true when this home holds the room, matched by that link's ticket or, since #1176, by `sharedFrom.remoteRoomId` for a room the host added one of these Bots to by add-member (no ticket), so a link for a room already here is not reported as a knock. 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. #1161: a quiet poll reads one open server link about once a minute and, when the preview says `Too many attempts` (or answers `rate_limited` / `locked_out`), waits out the minutes that sentence names or the `Retry-After`, up to 10 minutes, so a home that stays open does not preview again before that cool-down elapses; **Seat them** and a read of one room still verify at once, and a preview that did not answer is tried again on the next tick. Every room-share text, a `join` and a `line` alike, is kept within Bot Social's 1–4000 character limit (`ROOM_SHARE_TEXT_MAX`): instruction-override lines are left out of its history and notice, then the oldest history, the notice, and roster entries are dropped until it fits; a history or roster emptied only to fit is omitted, so it is not a false "no lines" or "no Bots". A seating send that is still `pending_approval` is `hold` and is not recorded as told; one whose hold is not listed yet is retried as not delivered. 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/social.md b/docs/subsystems/social.md index 14a756421..20a4cd390 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -211,7 +211,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. +- 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, and `joined` is read before it: `joined` is true when this home already holds the room — a seat of that link, or a room the host added a Bot to by add-member (#1176, recognised through the preview's `roomId`) — and the dialog then says *This group is already in your sidebar.* with no Join card and no claim sent, because nothing is left to ask. With a claim and no room here (#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, except a `user-mention` of a Bot that still has a live seat in a local or inbound group this sender belongs to (#1126), including a seat a group invite admitted (`admittedBy: 'ticket'`). That mention is the group's line: it goes out as a `FLYBOT-ROOM-SHARE` `op:line` whose `seq` is the room's next line number (`flybot.rooms.takeLineSeq`, the same counter a stored line uses), so a second mention is a new line and not a redelivery of seq 0. An inbound mirror names the host's room id inside the envelope. An ordinary local room names no Bot Social `room_id`. A ticket-admitted seat names `room_id` as that link's scope (this room's id on the host, the host's room id on the guest). A plain line and `message_bot` to that same seat stay denied, and a mention after the seat has left is refused again. A stranger outside every shared room 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`. #1161: that poll reads one open link about once a minute instead of previewing every link on every tick, and a preview that says `Too many attempts` (or `rate_limited` / `locked_out`) waits out the retry it names, up to 10 minutes. `sendTicketShare` refuses a `payload.text` outside 1–4000 characters before calling Bot Social. A seating send that comes back `pending_approval` and is not cleared is not reported as delivered: `autoSendRoomEnvelope` returns `'kept'` for a finding (the card stays, the host records `hold`) and `false` when the hold is not listed yet (the host retries). A blanket hold, and an ordinary `policy` hold `roomPolicyClears` accepts, still leave. Covered by `social.test.js` (#883, #1064, #1161) 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 mention of a shared local-group seat (#1126) does not use this face id: an ordinary seat names none, and a ticket-admitted seat names the link's scope (above); 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.* - **#1141.** Every local Bot that still sits in a live ordinary group with a remote seat may message that seat, and a send to that seat's home, without being the Bot named in the friendship `via` and without a separate Sharing grant. The line goes out as the room envelope (`sendCoSeatMention`), not a 1:1 that Bot Social answers with `no_relationship`. The owner's typed line in that seat's chat (`sendChat`) uses the same envelope when no covered Bot can send it. A stranger, and a seat that has left, stay on the old refusal. When Bot Social reports the friendship gone, `forgetFriendHome` hides that home's remote chats, drops its seats (including a ticket seat), and a later send says the friend is gone instead of staying "you are not friends". diff --git a/patches/ds-bot/files/src/host/room-membership.js b/patches/ds-bot/files/src/host/room-membership.js index 6e0fe5d0a..dae2faff3 100644 --- a/patches/ds-bot/files/src/host/room-membership.js +++ b/patches/ds-bot/files/src/host/room-membership.js @@ -1543,17 +1543,21 @@ export function install(rt) { try { const seen = await rt.roomInviteServer.preview(parsed.ticket) if (!seen) return { handle: parsed.handle, ticket: parsed.ticket } + const roomId = String(seen.roomId ?? '') return { handle: parsed.handle, ticket: parsed.ticket, server: true, + // #1176: the room the link is for, so a copy this home already holds can be recognised. + ...(roomId ? { roomId } : {}), name: cleanPeerName(seen.name, ''), lead: cleanPeerName(seen.lead?.name, cleanPeerName(seen.lead?.handle, '')), 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) } : {}), + // #899/#1176: a claim is not a room and a room is not a claim. The room is here when this home + // holds it — the ticket seated it, or the host added this Bot by add-member (remote room id, no ticket). + joined: roomIsHere({ ticket: parsed.ticket, roomId }), } } catch (error) { const code = String(error?.code ?? '') @@ -1561,8 +1565,10 @@ 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)) + const roomIsHere = ({ ticket, roomId }) => Object.values(state.rooms || {}).some(room => room.origin === 'inbound-share' && !room.hidden + && currentParticipants(room).some(p => p.kind === 'bot' && !p.remote) + && ((ticket && room.sharedFrom?.ticket === ticket) + || (roomId && String(room.sharedFrom?.remoteRoomId ?? '') === String(roomId)))) // Bot Social's refusal code, as the one reason the dialog names. function refusalOf(code) { switch (code) { 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 366a6ab56..ceafc795e 100644 --- a/patches/ds-bot/files/test/room-ticket-seat.test.js +++ b/patches/ds-bot/files/test/room-ticket-seat.test.js @@ -501,6 +501,39 @@ test('#899: a room-only identity is promoted for the link it makes and the link assert.equal((await b.rt.previewLocalInvite(invite.url)).joined, true) }) +test('#1176: a link for a room this home was added to by add-member says it is here, not that it knocked', async (t) => { + const { a, b } = await twoHomes(t) + const invite = await a.rt.mintRoomInvite('war', { memberId: 'owner-1' }) + // The ordinary add-member shape: a join that names no ticket at all, so the copy here is keyed by the + // remote room id (sharedFrom.remoteRoomId) and a ticket match can never find it. + const added = encodeRoomShare({ op: 'join', roomId: 'war', name: 'Launch plan', invitedHandle: '@chief.bob.home', inviterHandle: '@chief.alice.home', inviterName: 'Alice' }) + const out = await b.rt.applyInboundRoomShare({ text: added, fromHandle: '@chief.alice.home', fromHome: '@alice.home', fromName: 'Bot', localBotId: 'chief-b' }) + assert.equal(out.refused, undefined, 'the add-member join projects the room') + const mirror = guestRoomOf(b.rt) + assert.ok(mirror, 'the room is on this side') + assert.equal(mirror.sharedFrom.ticket, undefined, 'add-member wrote no ticket for this room') + assert.equal(mirror.sharedFrom.remoteRoomId, 'war') + + const preview = await b.rt.previewLocalInvite(invite.url) + assert.equal(preview.roomId, 'war', 'the preview names the room the link is for') + assert.equal(preview.claimed, false, 'this home never claimed this link') + assert.equal(preview.joined, true, 'the room is already here, so the link is not a knock') +}) + +test('#1176: a link whose room is not here is still a knock, whatever the room id says', async (t) => { + const { a, b } = await twoHomes(t) + const invite = await a.rt.mintRoomInvite('war', { memberId: 'owner-1' }) + // Same remote room id, but the copy here was hidden (the host removed this Bot, #1167): it is not a room + // this person can be told they are in. + const added = encodeRoomShare({ op: 'join', roomId: 'war', name: 'Launch plan', invitedHandle: '@chief.bob.home', inviterHandle: '@chief.alice.home', inviterName: 'Alice' }) + await b.rt.applyInboundRoomShare({ text: added, fromHandle: '@chief.alice.home', fromHome: '@alice.home', fromName: 'Bot', localBotId: 'chief-b' }) + const mirror = guestRoomOf(b.rt) + mirror.hidden = true + const preview = await b.rt.previewLocalInvite(invite.url) + assert.equal(preview.roomId, 'war') + assert.equal(preview.joined, false, 'a hidden room is not one this person is in') +}) + test('a ticket seat shows the other home\'s lines in the room session, including lines from before the join', async (t) => { const { a, b, log, settle } = await twoHomes(t) const shown = { a: [], b: [] } diff --git a/plugins/social/client.js b/plugins/social/client.js index 888367bac..48a5e2d41 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -1695,9 +1695,10 @@ 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 && 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.' } }) + if (seen?.joined) { + // #1176: the room is here however it arrived — a link this home claimed (#899), or the host + // adding one of these Bots by add-member. Neither is a knock: no Join card, no claim sent. + setInvite({ link: text, status: { tone: 'hint', text: 'This group is already in your sidebar.' } }) return } if (seen?.claimed) { diff --git a/plugins/social/test/invite-joined.test.js b/plugins/social/test/invite-joined.test.js new file mode 100644 index 000000000..de7bd436b --- /dev/null +++ b/plugins/social/test/invite-joined.test.js @@ -0,0 +1,52 @@ +// A group link for a room this home already holds (#1176): the link is no knock, so the dialog must say +// the room is in the sidebar and offer nothing to Join from. +// +// The state comes from the host (`room-invite-preview` answers `joined` whether the link seated the room +// or the host added a Bot by add-member, `core/src/host/room-membership.js`), so all this half has to do +// is read `joined` *before* `claimed` — a room that arrived by add-member never carries `claimed`, and +// the old order fell through to the preview card, whose Join sent a claim that changed nothing. +// +// The assertion reads the shipped `client.js`, the artefact the browser loads, the way +// `group-phone.test.js` pins the phone block: a rule that only exists in a source comment would not count. +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { test } from 'node:test' + +const SOURCE = readFileSync(new URL('../client.js', import.meta.url), 'utf8') + +// One function body, from its opening line to the next top-level `const = async`: what a reader +// of the dialog sees between the preview answer and the Join handler. +const bodyOf = (name) => { + const start = SOURCE.indexOf(`const ${name} = async (text) => {`) + assert.notEqual(start, -1, `${name} is in the shipped file`) + const rest = SOURCE.slice(start + 1) + const end = rest.search(/\n {6}const \w+ = (async )?\(/) + return end === -1 ? rest : rest.slice(0, end) +} + +test('#1176: the shipped dialog branches on joined before it reads claimed', () => { + const body = bodyOf('previewLocal') + const joined = body.indexOf('if (seen?.joined) {') + const claimed = body.indexOf('if (seen?.claimed) {') + assert.notEqual(joined, -1, 'the joined branch is drawn from the preview answer') + assert.notEqual(claimed, -1, 'the claim branch is still there for a claim with no room here') + assert.ok(joined < claimed, 'joined is read first: an add-member room carries no claim') +}) + +test('#1176: the joined branch says the room is here and offers nothing to join', () => { + const body = bodyOf('previewLocal') + const start = body.indexOf('if (seen?.joined) {') + const branch = body.slice(start, body.indexOf('\n }', start) + 1) + assert.match(branch, /This group is already in your sidebar\./) + assert.doesNotMatch(branch, /preview:/, 'no card is drawn, so there is no Join button') + assert.doesNotMatch(branch, /room-invite-ask/, 'and no claim is sent') + assert.doesNotMatch(SOURCE, /You already joined this group with that link/, 'one sentence for a room that is here, not two') +}) + +test('#1176: a claim with no room here still waits, so the Join button stays', () => { + const body = bodyOf('previewLocal') + const claimed = body.indexOf('if (seen?.claimed) {') + const branch = body.slice(claimed, body.indexOf('\n }', claimed) + 1) + assert.match(branch, /Waiting for \$\{who\} to let you in/) + assert.match(branch, /preview: \{/, 'the card stays for a knock nobody answered yet') +}) diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index a6797774f..8817494e8 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -11636,11 +11636,14 @@ test('#876: each refusal of a group link is its own sentence, never "not an invi seen.add(text) } 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, joined: true } })) + // A room this home already holds is said before anyone presses Join — the link seated it, or the host + // added a Bot by add-member (#1176: `joined` without `claimed`, so a claim is not the only way in). + const taken = localLinkDialog(async () => ({ preview: { name: 'Trip plan', lead: 'Chief', expiresAt: Date.now() + 60_000, claimed: false, 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') + assert.equal(taken.last().status.text, 'This group is already in your sidebar.', '#1176: the room is here, so the link is no knock') + assert.equal(taken.last().preview, undefined, 'no card, so nothing to Join from') + assert.deepEqual(taken.hostCalls.map(call => call[0]), ['room-invite-preview'], 'and no claim is sent') // #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'] }