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/1064-invite-claim-seat.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A group invite link seats the guest's Bots when they claim it, even if the accepting knock never arrives, and the group opens in the guest's sidebar. A claim the host cannot finish is named on the group's Members card ([#1064](https://github.com/ThinkFlowLab/FlyBot/issues/1064)).
5 changes: 5 additions & 0 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,11 @@ var ZH = {
"1 earlier link still works.": "还有 1 个之前的链接仍然有效。",
"{count} earlier links still work.": "还有 {count} 个之前的链接仍然有效。",
"Withdraw all": "全部撤回",
"{names} cannot be shown to someone who joins by link. Share them (Bot settings → Sharing) or remove them from the group, then seat the guest.": "{names} 不能给通过链接加入的人看到。请在 Bot 设置 → 共享里共享他们,或把他们移出群,然后再安排客人入座。",
"A Bot in this group cannot be shown to someone who joins by link. Share that Bot or remove it, then seat the guest.": "这个群里有一个 Bot 不能给通过链接加入的人看到。请共享该 Bot 或把它移出群,然后再安排客人入座。",
"A guest is in the group, but telling their FlyBot is waiting for your approval.": "客人已经在群里,但通知对方 FlyBot 还在等你批准。",
"A guest is in the group, but their FlyBot has not opened it yet. Seat them again.": "客人已经在群里,但对方的 FlyBot 还没有打开这个群。请再安排一次入座。",
"Seat them": "安排入座",
"Paste an invite link or code": "粘贴邀请链接或邀请码",
"A coding session without a Bot": "不带 Bot 的编码会话",
"New Bot “{name}”": "新建 Bot「{name}」",
Expand Down
5 changes: 5 additions & 0 deletions core/src/client/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ const ZH = {
'1 earlier link still works.': '还有 1 个之前的链接仍然有效。',
'{count} earlier links still work.': '还有 {count} 个之前的链接仍然有效。',
'Withdraw all': '全部撤回',
'{names} cannot be shown to someone who joins by link. Share them (Bot settings → Sharing) or remove them from the group, then seat the guest.': '{names} 不能给通过链接加入的人看到。请在 Bot 设置 → 共享里共享他们,或把他们移出群,然后再安排客人入座。',
'A Bot in this group cannot be shown to someone who joins by link. Share that Bot or remove it, then seat the guest.': '这个群里有一个 Bot 不能给通过链接加入的人看到。请共享该 Bot 或把它移出群,然后再安排客人入座。',
'A guest is in the group, but telling their FlyBot is waiting for your approval.': '客人已经在群里,但通知对方 FlyBot 还在等你批准。',
'A guest is in the group, but their FlyBot has not opened it yet. Seat them again.': '客人已经在群里,但对方的 FlyBot 还没有打开这个群。请再安排一次入座。',
'Seat them': '安排入座',
'Paste an invite link or code': '粘贴邀请链接或邀请码',
'A coding session without a Bot': '不带 Bot 的编码会话',
'New Bot “{name}”': '新建 Bot「{name}」',
Expand Down
4 changes: 2 additions & 2 deletions core/src/host/authz.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ const BOT_TARGETED = new Set([
const ROOM_TARGETED = new Set(['update-room', 'delete-room', 'room-progress', 'create-room', 'room-history', 'room-post',
// #876: the room's own link. Reading it is a member's read; minting and withdrawing hand out
// or kill a way in, so they follow the room's admin rule.
'room-invite', 'room-invite-mint', 'room-invite-revoke'])
'room-invite', 'room-invite-mint', 'room-invite-revoke', 'room-invite-seat'])

// Endpoints whose body names a Session.
const SESSION_TARGETED = new Set(['earlier', 'send', 'answer-question', 'dismiss-question', 'approval-answer', 'exchange'])
Expand Down Expand Up @@ -343,7 +343,7 @@ export function install(rt) {
const me = currentParticipants(room).find(p => p.kind === 'human' && p.id === actor.memberId)
return roomOwnedBy(actor, id) || roomMemberOf(actor, id) || me?.role === 'admin' ? ALLOW : deny('bot/forbidden', 'You are not a member of that room')
}
if (ep === 'room-invite-mint' || ep === 'room-invite-revoke') {
if (ep === 'room-invite-mint' || ep === 'room-invite-revoke' || ep === 'room-invite-seat') {
const id = body?.id ?? body?.roomId
if (roomOwnedBy(actor, id)) return ALLOW
const room = state.rooms?.[id]
Expand Down
8 changes: 8 additions & 0 deletions core/src/host/channel.js
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,14 @@ export function install(rt) {
const bots = Array.isArray(body.bots) ? body.bots.filter(id => typeof id === 'string').slice(0, 8) : undefined
return ok(await rt.askLocalInvite(String(body.link ?? body.url ?? ''), { accept: body.accept === true, ...(bots ? { bots } : {}) }, actor))
}
// #1064: the owner finishes a claim the poll could not (a Bot that cannot be shown, a notice
// that did not leave). Same read as the poll, with the retries reset.
case 'room-invite-seat': {
await rt.load()
const roomId = String(body.roomId ?? body.id ?? '')
const seated = await rt.seatOpenClaims({ roomId, force: true })
return ok({ ...seated, invite: rt.localInviteOf(roomId) ?? null, invites: rt.localInvitesOf(roomId) })
}
case 'folders':
case 'folder-pick':
case 'folder-list':
Expand Down
2 changes: 2 additions & 0 deletions core/src/host/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -1151,6 +1151,8 @@ export function install(rt) {
// transport lets a room share through to exactly these, under `scope`, and to nobody else off
// the roster.
ticketPeers: () => rt.ticketPeers?.() ?? [],
// #1064: seat homes that claimed an open server link, without waiting for their knock.
seatOpenClaims: (opts) => rt.seatOpenClaims?.(opts) ?? Promise.resolve({ seated: [], waiting: [] }),
},
remote: {
publish: list => remoteMembers.publish(plugin, list),
Expand Down
187 changes: 151 additions & 36 deletions core/src/host/room-membership.js
Original file line number Diff line number Diff line change
Expand Up @@ -1255,17 +1255,18 @@ export function install(rt) {
// Membership notice: the add/remove click is the owner's consent, so this does not wait
// for a chat line. Bot Social is 1:1; the envelope is the group record the other FlyBot sees.
rt.notifyRoomShare = async function notifyRoomShare(room, { op = 'join', handle } = {}) {
if (!room || isFaceRoom(room) || room.origin === 'inbound-share') return
if (!room || isFaceRoom(room) || room.origin === 'inbound-share') return []
const wanted = handle ? String(handle) : undefined
const remotes = (op === 'leave' ? (room.participants ?? []) : currentParticipants(room))
.filter(p => isRemoteParticipant(p) && (!wanted || p.handle === wanted || p.id === wanted))
if (remotes.length === 0) return
if (remotes.length === 0) return []
const from = shareInviter(room)
const results = []
for (const remote of remotes) {
if (isTicketSeat(remote)) {
const seat = seatSender(room, remote)
if (!seat) continue
await sendShareTo(room, remote, {
if (!seat) { results.push(undefined); continue }
results.push(await sendShareTo(room, remote, {
op,
roomId: room.id,
name: room.name,
Expand All @@ -1274,20 +1275,21 @@ export function install(rt) {
ticket: remote.ticket,
...(op === 'join' ? { roster: roomRoster(room), notice: room.notice || '', history: shareHistory(room) } : {}),
...seat.from,
}, 'room-share', seat.botId, room.id)
}, 'room-share', seat.botId, room.id))
continue
}
if (op !== 'leave' && !publishedRemoteOf(state, remote.handle)) continue
await sendShareTo(room, remote, {
results.push(await sendShareTo(room, remote, {
op,
roomId: room.id,
name: room.name,
invitedHandle: remote.handle,
seq: room.seq ?? 0,
...(op === 'join' ? { roster: roomRoster(room), notice: room.notice || '', history: shareHistory(room) } : {}),
...from,
}, 'room-share')
}, 'room-share'))
}
return results
}

// #852, #878, #884: the link invitations for a room that lives here. A room keeps a list of tickets
Expand Down Expand Up @@ -1334,6 +1336,8 @@ export function install(rt) {
...(invite.server === true ? { server: true } : {}),
...(seatedBy(invite) ? { joined: Object.keys(homesOf(invite)).length } : {}),
...(invite.withdrawnAt ? { withdrawn: true } : {}),
// #1064: a claim the poll could not finish. Local Bot names only — a guest handle is not shown.
...(invite.waiting?.reason ? { waiting: { reason: String(invite.waiting.reason), bots: (Array.isArray(invite.waiting.bots) ? invite.waiting.bots : []).map(name => cleanPeerName(name, '')).filter(Boolean).slice(0, TICKET_SEATS_MAX) } } : {}),
}
}

Expand Down Expand Up @@ -1686,40 +1690,24 @@ export function install(rt) {
return { handled: true, room, accepted: true }
}

// #884: the accepting knock on a ticket Bot Social minted. The knock is a request, never the
// authority: the claim is read back from the server with this home's own credential, and the seats
// are the Bots it names — all of them or none. Each way it can fail has its own reason.
async function seatByTicket(room, invite, data, event) {
const asker = String(event.fromHandle ?? '')
if (typeof rt.roomInviteServer?.verify !== 'function') return { handled: true, refused: 'lookup-failed' }
let claim
try {
claim = await rt.roomInviteServer.verify(invite.ticket)
} catch (error) {
const why = refusalOf(String(error?.code ?? ''))
return { handled: true, refused: why === 'unknown' ? 'revoked' : why }
}
if (!claim || String(claim.roomId ?? '') !== room.id) return { handled: true, refused: 'wrong-room' }
// A link admits every home it is given to (Bot Social #65): the minter reads each home's claim, and
// the knock is answered from the one whose Bots include the asker. An older server names one claimer.
const claims = Array.isArray(claim.claimers) ? claim.claimers : claim.claimer ? [claim.claimer] : []
const botsOf = c => (Array.isArray(c?.bots) ? c.bots : []).map(String).filter(handle => handle.startsWith('@'))
if (!claims.some(c => botsOf(c).length > 0)) return { handled: true, refused: 'unclaimed' }
const mine = claims.find(c => botsOf(c).includes(asker))
const seats = botsOf(mine)
const botsOfClaim = c => (Array.isArray(c?.bots) ? c.bots : []).map(String).filter(handle => handle.startsWith('@'))

// Seat one home's claim, all of its Bots or none. The claim was already read back from the server.
async function admitClaim(room, invite, mine, asker) {
const seats = botsOfClaim(mine)
const home = typeof mine?.home === 'string' ? mine.home : undefined
// The Bot that knocked must be one a claim brought, from the home that claimed it.
if (!mine || (home && event.fromHome && event.fromHome !== home)) return { handled: true, refused: 'claimed-elsewhere' }
if (seats.length > TICKET_SEATS_MAX) return { handled: true, refused: 'too-many' }
if (seats.length > TICKET_SEATS_MAX) return { refused: 'too-many' }
const names = new Map((Array.isArray(mine?.members) ? mine.members : []).map(m => [String(m?.handle ?? ''), m]))
const result = await withRoomLock(async () => {
materialize(room)
// A Bot of this room that may not be shown outside would be read by the stranger: refuse rather
// than seat a room whose lines could not all go out.
const blocked = unsharable(room).filter(p => p.id !== invite.botId)
if (blocked.length > 0) return { refused: 'not-addable' }
if (blocked.length > 0) return { refused: 'not-addable', bots: blocked.map(p => state.bots?.[p.id]?.name).filter(name => typeof name === 'string' && name) }
const fresh = seats.filter(handle => !currentParticipants(room).some(p => p.remote === true && (p.handle === handle || p.id === handle)))
const at = Date.now()
const homesBefore = JSON.stringify(homesOf(invite))
const legacy = invite.acceptedBy !== undefined || invite.seats !== undefined
invite.homes = { ...homesOf(invite), [home ?? asker]: seats }
delete invite.acceptedBy
delete invite.seats
Expand Down Expand Up @@ -1749,13 +1737,140 @@ export function install(rt) {
})
}
syncMembers(room)
await rt.save?.()
// A poll reads this again every few seconds: a claim that is already seated must not rewrite the team.
if (fresh.length > 0 || legacy || homesBefore !== JSON.stringify(homesOf(invite))) await rt.save?.()
return { fresh }
})
if (result.refused) return { handled: true, refused: result.refused }
if (result.refused) return { refused: result.refused, bots: result.bots }
return { seats, fresh: result.fresh }
}

// #884: the accepting knock on a ticket Bot Social minted. The knock is a request, never the
// authority: the claim is read back from the server with this home's own credential, and the seats
// are the Bots it names — all of them or none. Each way it can fail has its own reason.
async function seatByTicket(room, invite, data, event) {
const asker = String(event.fromHandle ?? '')
if (typeof rt.roomInviteServer?.verify !== 'function') return { handled: true, refused: 'lookup-failed' }
let claim
try {
claim = await rt.roomInviteServer.verify(invite.ticket)
} catch (error) {
const why = refusalOf(String(error?.code ?? ''))
return { handled: true, refused: why === 'unknown' ? 'revoked' : why }
}
if (!claim || String(claim.roomId ?? '') !== room.id) return { handled: true, refused: 'wrong-room' }
// A link admits every home it is given to (Bot Social #65): the minter reads each home's claim, and
// the knock is answered from the one whose Bots include the asker. An older server names one claimer.
const claims = Array.isArray(claim.claimers) ? claim.claimers : claim.claimer ? [claim.claimer] : []
if (!claims.some(c => botsOfClaim(c).length > 0)) return { handled: true, refused: 'unclaimed' }
const mine = claims.find(c => botsOfClaim(c).includes(asker))
const home = typeof mine?.home === 'string' ? mine.home : undefined
// The Bot that knocked must be one a claim brought, from the home that claimed it.
if (!mine || (home && event.fromHome && event.fromHome !== home)) return { handled: true, refused: 'claimed-elsewhere' }
const admitted = await admitClaim(room, invite, mine, asker)
if (admitted.refused) return { handled: true, refused: admitted.refused }
// Every seat hears the join, a retry included (the reply was lost): no second seat is made.
for (const handle of seats) await rt.notifyRoomShare?.(room, { op: 'join', handle })
return { handled: true, room, accepted: true, seats }
// A send that left is remembered so the claim poll does not send it again.
const told = new Set(Array.isArray(invite.told) ? invite.told : [])
const held = new Set(Array.isArray(invite.held) ? invite.held : [])
for (const handle of admitted.seats) {
const sent = (await rt.notifyRoomShare?.(room, { op: 'join', handle }))?.[0]
const action = sent?.action
if (action === 'allow' || action === 'sent') { told.add(handle); held.delete(handle) }
else if (action === 'hold') held.add(handle)
}
invite.told = [...told]
if (held.size > 0) invite.held = [...held]
else delete invite.held
await rt.save?.()
return { handled: true, room, accepted: true, seats: admitted.seats }
}

// Tell a seated handle once. `allow` / `sent` is delivered; `hold` is already on a card, so it is
// not sent again; a refusal is retried a few times and then left for the owner to finish.
async function tellSeats(room, invite, handles, { force = false } = {}) {
if (force) {
delete invite.tellTries
delete invite.held
}
const told = new Set(Array.isArray(invite.told) ? invite.told : [])
const held = new Set(force ? [] : (Array.isArray(invite.held) ? invite.held : []))
let outcome = 'told'
for (const handle of handles) {
if (told.has(handle)) continue
if (held.has(handle)) { outcome = 'held'; continue }
const tries = invite.tellTries?.[handle] ?? 0
if (!force && tries >= 3) { outcome = 'not-delivered'; continue }
invite.tellTries = { ...(invite.tellTries ?? {}), [handle]: tries + 1 }
const sent = (await rt.notifyRoomShare?.(room, { op: 'join', handle }))?.[0]
const action = sent?.action
if (action === 'allow' || action === 'sent') {
told.add(handle)
held.delete(handle)
const rest = { ...(invite.tellTries ?? {}) }
delete rest[handle]
invite.tellTries = rest
} else if (action === 'hold') {
held.add(handle)
outcome = 'held'
} else outcome = 'not-delivered'
}
invite.told = [...told]
if (held.size > 0) invite.held = [...held]
else delete invite.held
if (invite.tellTries && Object.keys(invite.tellTries).length === 0) delete invite.tellTries
return outcome
}

// #1064: a guest's Join claims the ticket at Bot Social and then knocks. The knock is a request,
// and it can be refused, held, or never read (a room-only Bot is not polled). The claim is the
// authority either way: read every open server link and seat the homes it names. A room that
// cannot be shown, or a notice that does not leave, stays on the link as `waiting` for the owner.
rt.seatOpenClaims = async function seatOpenClaims({ roomId, force = false } = {}) {
if (typeof rt.roomInviteServer?.verify !== 'function') return { seated: [], waiting: [] }
const wanted = roomId === undefined || roomId === null || roomId === '' ? undefined : String(roomId)
const rooms = wanted ? [rt.roomOf(wanted)].filter(Boolean) : Object.values(state.rooms || {})
const seated = []
const waiting = []
for (const room of rooms) {
if (!room || room.origin === 'inbound-share' || isFaceRoom(room)) continue
for (const invite of invitesOf(room)) {
if (invite.server !== true || !openInvite(invite)) continue
let claim
try {
claim = await rt.roomInviteServer.verify(invite.ticket)
} catch (error) {
rt.warn?.('ds-bot: a room link claim was not read: %s', error)
continue
}
if (!claim || String(claim.roomId ?? '') !== room.id) continue
const claims = (Array.isArray(claim.claimers) ? claim.claimers : claim.claimer ? [claim.claimer] : []).filter(c => botsOfClaim(c).length > 0)
const mark = () => JSON.stringify({ waiting: invite.waiting ?? null, told: invite.told ?? null, held: invite.held ?? null, tries: invite.tellTries ?? null })
const before = mark()
let blocked = null
if (claims.length === 0) {
if (invite.waiting) delete invite.waiting
} else for (const mine of claims) {
const admitted = await admitClaim(room, invite, mine, botsOfClaim(mine)[0])
if (admitted.refused === 'not-addable') {
blocked = { reason: 'not-addable', bots: admitted.bots ?? [] }
continue
}
if (admitted.refused) continue
seated.push(...(admitted.fresh ?? []))
const outcome = await tellSeats(room, invite, admitted.seats ?? [], { force })
if (outcome === 'not-delivered') blocked = blocked ?? { reason: 'not-delivered', bots: [] }
else if (outcome === 'held') blocked = blocked ?? { reason: 'held', bots: [] }
}
if (claims.length > 0) {
if (blocked) invite.waiting = { reason: blocked.reason, bots: blocked.bots, at: invite.waiting?.at ?? Date.now() }
else if (invite.waiting) delete invite.waiting
}
if (mark() !== before) await rt.save?.()
if (invite.waiting) waiting.push({ roomId: room.id, ticket: invite.ticket, reason: invite.waiting.reason, bots: invite.waiting.bots ?? [] })
}
}
return { seated, waiting }
}

// A line or a leave that a remote seat sent into a room of this home (#884). The seat must be current
Expand Down
Loading
Loading