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/899-room-only-link-join.fixed.md
Original file line number Diff line number Diff line change
@@ -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)).
2 changes: 2 additions & 0 deletions core/src/host/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 24 additions & 3 deletions core/src/host/room-membership.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -1219,13 +1236,17 @@ 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 ?? '')
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))
// Bot Social's refusal code, as the one reason the dialog names.
function refusalOf(code) {
switch (code) {
Expand Down Expand Up @@ -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 {
Expand Down
49 changes: 49 additions & 0 deletions core/test/room-ticket-seat.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
Loading
Loading