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/949-invite-mention.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
An @ of a Bot in a room joined by invite link now starts that Bot's turn. The name the @ menu inserts (`Chief (owner)`, `Data (Friend)`) is a call, including for a Bot that has no Bot Social handle; a fenced or quoted @ stays literal text ([#949](https://github.com/ThinkFlowLab/FlyBot/issues/949)).
39 changes: 27 additions & 12 deletions core/src/host/room-membership.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
// of a friend's room that invited their granted Bot.

import { randomBytes } from 'node:crypto'
import { cleanPeerName, disambiguatedRemoteTitle, isChiefRecord, remoteBotTitle, sharedRoomTitle } from '../shared/remote-label.js'
import { cleanPeerName, disambiguatedRemoteTitle, faceAliases, isChiefRecord, remoteBotName, remoteBotTitle, sharedRoomTitle } from '../shared/remote-label.js'
import { formatFaceRoomTitle, isAutoFaceTitle, faceRoomCreatedAt } from '../shared/face-title.js'
import { HISTORY_MAX, HISTORY_TEXT, encodeRoomShare, parseRoomInviteLink, parseRoomShare, roomInviteUrl } from '../shared/room-share.js'
import { cleanFaceLead, cleanRoleLabel, faceLeadOf } from './face-lead.js'
Expand Down Expand Up @@ -451,27 +451,41 @@ export function install(rt) {

const maxHopsOf = room => (Number.isFinite(Number(room?.maxHops)) ? Number(room.maxHops) : DEFAULT_MAX_HOPS)

// #825: which friends' Bots a line calls, by the one "@" rule the rest of the host uses — over each
// remote participant's handle and the name it is shown as. Fenced and quoted text is literal, so a
// handle inside a block never starts a turn.
// #825, #949: which remote Bots a line calls, by the same "@" rule as the rest of the host. The menu
// inserts remoteBotTitle ("Chief (bob)", "Data (Friend)"), which is not the stored "Name · owner"
// label and not a not-connected id. Every alias of one seat shares one token: its social handle, or
// the roster name when it has none (localCalls already matches that name). Fenced and quoted text
// is literal, so a title inside a block never starts a turn.
const callsIn = (room, text) => {
const said = String(text ?? '')
if (!said.includes('@')) return []
const names = []
for (const peer of currentParticipants(room).filter(isRemoteParticipant)) {
const handle = peer.handle ?? peer.id
for (const name of [String(handle).replace(/^@+/, ''), peer.displayName, peer.label]) {
if (name) names.push({ name: String(name), handle })
const raw = typeof peer.handle === 'string' ? peer.handle.trim() : ''
const social = raw && !raw.startsWith('not-connected:') ? (raw.startsWith('@') ? raw : `@${raw.replace(/^@+/, '')}`) : ''
const rosterName = String(peer.displayName || peer.name || '').trim()
const token = social || rosterName
if (!token) continue
const bare = remoteBotName(peer, rosterName)
const aliases = [
...faceAliases(peer, bare || rosterName),
remoteBotTitle(peer),
rosterName,
peer.label,
social.replace(/^@+/, ''),
]
for (const name of new Set(aliases.map(value => String(value ?? '').trim()).filter(Boolean))) {
names.push({ name, handle: token })
}
}
if (names.length === 0) return []
const found = new Set()
for (const span of mentionSpans(said, names)) if (span.bot) found.add(span.bot.handle)
for (const span of mentionSpans(said, names)) if (span.bot?.handle) found.add(span.bot.handle)
return [...found]
}

// A line's `calls` are handles; the projected room answers with the local Bot behind the handle it
// was invited as. Anything else in the list is another home's business.
// A line's `calls` are the sending home's tokens: a social handle, or the roster name when the Bot
// has none (#949). This home answers with the local Bot behind a token it recognises.
const localCalls = (room, calls) => {
if (!Array.isArray(calls) || calls.length === 0) return []
const wanted = new Set(calls.map(handle => String(handle ?? '').replace(/^@+/, '').toLowerCase()).filter(Boolean))
Expand All @@ -486,7 +500,7 @@ export function install(rt) {
for (const peer of currentParticipants(room)) {
if (peer.kind !== 'bot' || peer.remote) continue
const record = state.bots?.[peer.id]
const handles = [peer.handle, peer.id, record?.fields?.social?.member?.handle, record?.name]
const handles = [peer.handle, peer.id, peer.displayName, record?.fields?.social?.member?.handle, record?.name]
.filter(Boolean).map(value => String(value).replace(/^@+/, '').toLowerCase())
if (handles.some(handle => wanted.has(handle)) && !out.includes(peer.id)) out.push(peer.id)
}
Expand Down Expand Up @@ -1007,7 +1021,8 @@ export function install(rt) {
// #904: a roster is text a stranger wrote. It is read only for the room this mirror is bound to and
// only from that room's own inviter; anything else is dropped whole. Every entry it names stays
// unverified unless this home already holds a fact about that handle (the seat that carried the join,
// or a published friend Bot) — an unverified entry is displayed, never addressed.
// or a published friend Bot). Verification is a signature: an unverified entry is still listed, and
// an @ of it is a call (#949).
function rosterOf(data, room, event) {
if (!Array.isArray(data?.roster)) return undefined
const bound = room?.sharedFrom ?? {}
Expand Down
52 changes: 51 additions & 1 deletion core/test/room-ticket-seat.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { install as installRooms, publishedRemoteOf } from '../src/host/room-mem
import { install as installReach, mayShareRoom } from '../src/host/reach.js'
import { encodeRoomShare, parseRoomShare } from '../src/shared/room-share.js'
import { unwrapUntrusted } from '../src/host/delivery.js'
import { roomTitle, sharedRoomTitle } from '../src/shared/remote-label.js'
import { remoteBotTitle, roomTitle, sharedRoomTitle } from '../src/shared/remote-label.js'

const ALICE = {
version: 2,
Expand Down Expand Up @@ -542,6 +542,56 @@ test('a ticket seat shows the other home\'s lines in the room session, including
assert.equal(guestShows().length, 1, 'a redelivery does not show the host line again')
})

test('#949: an @ of the name the menu inserts starts that Bot, including one with no social handle', async (t) => {
const { a, b, settle } = await twoHomes(t)
const turns = { a: [], b: [] }
a.rt.startInboundTurn = async (args) => { turns.a.push(args.botId) }
b.rt.startInboundTurn = async (args) => { turns.b.push(args.botId) }
// Data has no Bot Social identity. The guest still lists it, and the @ menu titles it from that record.
a.rt.state.bots['data-a'] = {
id: 'data-a', sessionId: 'data-a', name: 'Data', instructions: '', createdAt: 3, ownerId: 'owner-1', fields: {},
}
const war = a.rt.state.rooms.war
war.members = [...war.members, 'data-a']
war.participants = [...war.participants, { kind: 'bot', id: 'data-a', role: 'member', displayName: 'Data', joinedSeq: 0, joinedAt: 1 }]

const invite = await a.rt.mintRoomInvite('war', { memberId: 'owner-1' })
await b.rt.askLocalInvite(invite.url, { accept: true, bots: ['chief-b'] }, { memberId: 'owner-1' })
await settle()
const mirror = guestRoomOf(b.rt)
assert.ok(mirror, 'the join projected the room')
const data = (mirror.participants ?? []).find(p => p.remote && p.displayName === 'Data')
assert.ok(data, 'the host Bot with no identity is on the guest roster')
// The menu reads the seat it was given. With no owner and no handle the title is the fallback the screenshot shows.
delete data.ownerName
delete data.ownerHome
delete data.home
delete data.handle
assert.equal(remoteBotTitle(data), 'Data (Friend)')

await b.rt.postRoomMessage(mirror.id, { text: '@Data (Friend) 你好', authorId: 'owner-1', authorKind: 'human', displayName: 'Bob' })
await settle()
assert.deepEqual(turns.a, ['data-a'], 'the host Bot the guest @\'d takes a turn')

// The seat's stored name is Bot Social's "Name · owner" label. The menu inserts the title, not that string.
const seat = a.rt.state.rooms.war.participants.find(p => p.remote && p.handle === '@chief.bob.home')
assert.ok(seat, 'the guest Chief is seated on the host')
seat.displayName = 'Chief · FlyBot 682b8a43'
seat.ownerName = 'FlyBot 682b8a43'
const chiefTitle = remoteBotTitle(seat)
assert.notEqual(chiefTitle, seat.displayName, 'the menu title is not the stored label')
assert.match(chiefTitle, /^Chief \(/)
turns.b.length = 0
await a.rt.postRoomMessage('war', { text: `@${chiefTitle} 你好`, authorId: 'owner-1', authorKind: 'human', displayName: 'Alice' })
await settle()
assert.deepEqual(turns.b, ['chief-b'], 'the guest Bot the host @\'d by that title takes a turn')

turns.a.length = 0
await b.rt.postRoomMessage(mirror.id, { text: '```\n@Data (Friend)\n```', authorId: 'owner-1', authorKind: 'human', displayName: 'Bob' })
await settle()
assert.deepEqual(turns.a, [], 'a fenced title is literal text, not a call')
})

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' })
Expand Down
Loading
Loading