Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/1176-invite-link-already-seated.fixed.md
Original file line number Diff line number Diff line change
@@ -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).
14 changes: 10 additions & 4 deletions core/src/host/room-membership.js
Original file line number Diff line number Diff line change
Expand Up @@ -1543,26 +1543,32 @@ 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 ?? '')
if (code === 'invite_not_found') return { handle: parsed.handle, ticket: parsed.ticket }
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) {
Expand Down
33 changes: 33 additions & 0 deletions core/test/room-ticket-seat.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] }
Expand Down
2 changes: 1 addition & 1 deletion docs/subsystems/core-host.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/subsystems/social.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <owner> 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 <owner> 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".
Expand Down
14 changes: 10 additions & 4 deletions patches/ds-bot/files/src/host/room-membership.js
Original file line number Diff line number Diff line change
Expand Up @@ -1543,26 +1543,32 @@ 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 ?? '')
if (code === 'invite_not_found') return { handle: parsed.handle, ticket: parsed.ticket }
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) {
Expand Down
33 changes: 33 additions & 0 deletions patches/ds-bot/files/test/room-ticket-seat.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] }
Expand Down
7 changes: 4 additions & 3 deletions plugins/social/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading
Loading