diff --git a/changelog.d/949-invite-mention.fixed.md b/changelog.d/949-invite-mention.fixed.md new file mode 100644 index 00000000..07ebc8c5 --- /dev/null +++ b/changelog.d/949-invite-mention.fixed.md @@ -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)). diff --git a/core/src/host/room-membership.js b/core/src/host/room-membership.js index cabb1103..a2c79092 100644 --- a/core/src/host/room-membership.js +++ b/core/src/host/room-membership.js @@ -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' @@ -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)) @@ -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) } @@ -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 ?? {} diff --git a/core/test/room-ticket-seat.test.js b/core/test/room-ticket-seat.test.js index 66025fb6..201c3b90 100644 --- a/core/test/room-ticket-seat.test.js +++ b/core/test/room-ticket-seat.test.js @@ -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, @@ -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' }) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index a9fa9d0e..d519598d 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -187,8 +187,8 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi ### Inbound shared rooms -- Bot Social conversations are 1:1; it does not list a FlyBot group. When a room gains a granted remote Bot (create-room, addParticipants, or 新建群聊 picking a friend's chat), the host fans out a `FLYBOT-ROOM-SHARE` envelope (`src/shared/room-share.js`) through `flybotSend` (`via: 'room-share'` for join/leave, `via: 'room'` for lines, `meta.roomId` set) so the Bot's owner's FlyBot can project it immediately — including a room with no chat lines yet. Receive is `rt.applyInboundRoomShare` (`origin: 'inbound-share'`, `recordInboundSharedRoom`), labelled **Shared room from \** with the room name stored. The envelope names the person who invited the Bot (#848): a human participant in the room when there is one, else the owner-level `ownerName` / `home` the social plugin keeps on that Bot's member record (read from `whoami`), so a group made from the sidebar no longer says `a friend`; a human participant whose name is this home's own label `You` (an owner with no name yet) is skipped for that owner-level name, so the far side never reads itself as the inviter (#883); a room projected in from an inbound share forwards its own `sharedFrom` inviter instead of this home's owner, so re-sharing never renames the person who invited it. A leave envelope, hide, or grant revoke marks it left/hidden. A projection is **named** from the envelope and **matched** by the transport (#873, #909): `applyInboundRoomShare` still shows `data.inviterName` (so a room carried here by a Chief's Bot reads after the person who invited it), but which room a share may touch is decided by `event.fromHome` / `event.fromHandle` — a share naming a room of mine while arriving from a home that room does not belong to is refused `not-inviter`, and the recorded `sharedFrom` identity only ever follows the transport, so an envelope can be displayed but never re-write whose room it is. Only `op: 'join` projects a room: a `line` envelope for an unknown remote room is refused `unknown-room` instead of opening one named by its own payload. A line keeps its own `who`, or the sender when it names nobody, never the inviter; its `authorId` follows the payload only when the handle traces to the transport sender, a remote seat of that room, or its inviter **from the home that invited the room** (#918 — the seat branch and the inviter branch now ask the same question) — otherwise the line is shown as the Bot that delivered it with `unverified: true`, and it is still kept so a forged claim cannot silence a message. The room match stays on the inviter handle alone, so a line presenting that handle from another home still lands and is only attributed to its sender: a room may match where a signature does not. Which room a share belongs to is recorded by a `join` alone; a `line` follows that record and never rewrites the inviter home or handle, or every message would make its sender the inviter. A room recorded before #873 may hold no inviter home at all, and then the only signature it keeps is the transport naming the sender itself (`claim === sender`); the next `join` records the home and the room stops being legacy. `reach()` allows a local member to message a remote that already sits in that room when `roomId` is on the send. Still not team mode. #904: the projection now records the host's side too. `recordInboundSharedRoom` keeps the guest's own Bot **and** one remote participant for the Bot that carried the join (`draft.inviterHandle`, with `admittedBy: 'ticket'` / `roomOnly` when a link seated it) — `invitedHandle` on this side names one of the guest's own Bots, so it is never recorded as a remote, and the friend path keeps its one remote. The rest of the host's room rides the `join` as `roster` and `notice`: `notifyRoomShare` sends `{ handle, home, name, ownerName }` for every Bot of this home in the room, and `{ name, ownerName, connected: false }` for one with no Bot Social identity; nothing of another guest's home is published, and the roster is re-sent whenever the host adds or removes a Bot of its own. `applyInboundRoomShare` reads a roster only when the envelope names this mirror's room (`data.roomId` = `sharedFrom.remoteRoomId`) and comes from its bound inviter, and merges it into the participants: one remote participant per entry (`fromRoster`, `roomOnly`), keyed by `(home, handle)` so two same-named Bots of different homes stay distinct, `verified` only for the seat or a published friend Bot, an unverified entry displayed but never addressed, an identity-less one shown as not connected. A roster is the whole list, so a `join` that drops an entry marks it left (soft leave: the entry stays with `leftSeq`, history still resolves) and a re-sent `join` clears the mark; an empty `roster: []` clears the list, while a `join` without the field changes nothing. The notice is replaced by the next `join` that carries one; an explicit empty string clears it and a missing field leaves it. #923: the one pair a room link opened is enumerated even when the seat record predates it. A seat a link admitted is a pair through its own `admittedBy: 'ticket'` / `ticket`, and, when that record is older, through the link's own `homes` — the home-to-handles map the accept writes and keeps — reached from the Bot whose handle it knocked on, or from the room's lead when the seat did not even keep that. So a group envelope from this home reaches that guest as the room's traffic (`sendTicketShare` names the room the link was minted for) instead of falling back to a friendship the two homes never had; a friend's Bot added by hand is named by no link and stays out. This is FlyBot's half of the pair: Bot Social still decides at send time, so a room-started conversation it refuses needs the server-side fix. -- A Bot named in a shared room answers in that room (#825). `fanoutRoomLine` (`src/host/room-membership.js`) puts the handles a line calls in the envelope's `calls` (resolved where the room's mentions are resolved, so a fenced or quoted `@name` is literal text), plus its `hop`: a person's line is hop 0 and a Bot's answer is one hop on. The receiving `applyInboundRoomShare` keeps only the calls that name this room's own Bot (`localCalls`), opens **one** turn for each through `rt.startInboundTurn` (`groups.js`: `participant.open` + `participant.runTurn` + `appendRoom`), and skips it when the line's hop has reached the room's `maxHops` (kept on the room, `DEFAULT_MAX_HOPS` 3) or when the same remote line was already answered — the last 200 keys `remoteRoomId:seq:botId` live in `room.answered`. That turn's answer is an ordinary room line, so `fanoutRoomLine` sends it back to the inviter as an `op: 'line'` envelope under the *inviter's* `roomId`, one hop on; an answer its owner's approval holds or refuses still shows as one `notice` line in the room instead of silence. Remote text stays inside the `fencedUntrusted` fence. +- Bot Social conversations are 1:1; it does not list a FlyBot group. When a room gains a granted remote Bot (create-room, addParticipants, or 新建群聊 picking a friend's chat), the host fans out a `FLYBOT-ROOM-SHARE` envelope (`src/shared/room-share.js`) through `flybotSend` (`via: 'room-share'` for join/leave, `via: 'room'` for lines, `meta.roomId` set) so the Bot's owner's FlyBot can project it immediately — including a room with no chat lines yet. Receive is `rt.applyInboundRoomShare` (`origin: 'inbound-share'`, `recordInboundSharedRoom`), labelled **Shared room from \** with the room name stored. The envelope names the person who invited the Bot (#848): a human participant in the room when there is one, else the owner-level `ownerName` / `home` the social plugin keeps on that Bot's member record (read from `whoami`), so a group made from the sidebar no longer says `a friend`; a human participant whose name is this home's own label `You` (an owner with no name yet) is skipped for that owner-level name, so the far side never reads itself as the inviter (#883); a room projected in from an inbound share forwards its own `sharedFrom` inviter instead of this home's owner, so re-sharing never renames the person who invited it. A leave envelope, hide, or grant revoke marks it left/hidden. A projection is **named** from the envelope and **matched** by the transport (#873, #909): `applyInboundRoomShare` still shows `data.inviterName` (so a room carried here by a Chief's Bot reads after the person who invited it), but which room a share may touch is decided by `event.fromHome` / `event.fromHandle` — a share naming a room of mine while arriving from a home that room does not belong to is refused `not-inviter`, and the recorded `sharedFrom` identity only ever follows the transport, so an envelope can be displayed but never re-write whose room it is. Only `op: 'join` projects a room: a `line` envelope for an unknown remote room is refused `unknown-room` instead of opening one named by its own payload. A line keeps its own `who`, or the sender when it names nobody, never the inviter; its `authorId` follows the payload only when the handle traces to the transport sender, a remote seat of that room, or its inviter **from the home that invited the room** (#918 — the seat branch and the inviter branch now ask the same question) — otherwise the line is shown as the Bot that delivered it with `unverified: true`, and it is still kept so a forged claim cannot silence a message. The room match stays on the inviter handle alone, so a line presenting that handle from another home still lands and is only attributed to its sender: a room may match where a signature does not. Which room a share belongs to is recorded by a `join` alone; a `line` follows that record and never rewrites the inviter home or handle, or every message would make its sender the inviter. A room recorded before #873 may hold no inviter home at all, and then the only signature it keeps is the transport naming the sender itself (`claim === sender`); the next `join` records the home and the room stops being legacy. `reach()` allows a local member to message a remote that already sits in that room when `roomId` is on the send. Still not team mode. #904: the projection now records the host's side too. `recordInboundSharedRoom` keeps the guest's own Bot **and** one remote participant for the Bot that carried the join (`draft.inviterHandle`, with `admittedBy: 'ticket'` / `roomOnly` when a link seated it) — `invitedHandle` on this side names one of the guest's own Bots, so it is never recorded as a remote, and the friend path keeps its one remote. The rest of the host's room rides the `join` as `roster` and `notice`: `notifyRoomShare` sends `{ handle, home, name, ownerName }` for every Bot of this home in the room, and `{ name, ownerName, connected: false }` for one with no Bot Social identity; nothing of another guest's home is published, and the roster is re-sent whenever the host adds or removes a Bot of its own. `applyInboundRoomShare` reads a roster only when the envelope names this mirror's room (`data.roomId` = `sharedFrom.remoteRoomId`) and comes from its bound inviter, and merges it into the participants: one remote participant per entry (`fromRoster`, `roomOnly`), keyed by `(home, handle)` so two same-named Bots of different homes stay distinct, `verified` only for the seat or a published friend Bot. Verification is a signature, not a mention: an unverified entry is displayed and is not who a line may claim to be, an identity-less one is shown as not connected, and an @ of either — the title the @ menu inserts, or the roster name when it has no handle — still starts that Bot's turn on its own home (#949). A roster is the whole list, so a `join` that drops an entry marks it left (soft leave: the entry stays with `leftSeq`, history still resolves) and a re-sent `join` clears the mark; an empty `roster: []` clears the list, while a `join` without the field changes nothing. The notice is replaced by the next `join` that carries one; an explicit empty string clears it and a missing field leaves it. #923: the one pair a room link opened is enumerated even when the seat record predates it. A seat a link admitted is a pair through its own `admittedBy: 'ticket'` / `ticket`, and, when that record is older, through the link's own `homes` — the home-to-handles map the accept writes and keeps — reached from the Bot whose handle it knocked on, or from the room's lead when the seat did not even keep that. So a group envelope from this home reaches that guest as the room's traffic (`sendTicketShare` names the room the link was minted for) instead of falling back to a friendship the two homes never had; a friend's Bot added by hand is named by no link and stays out. This is FlyBot's half of the pair: Bot Social still decides at send time, so a room-started conversation it refuses needs the server-side fix. +- A Bot named in a shared room answers in that room (#825). `fanoutRoomLine` (`src/host/room-membership.js`) puts the calls a line makes in the envelope's `calls` (resolved where the room's mentions are resolved: the social handle, the roster name, and the title the @ menu inserts, so `@Chief (owner)` and `@Data (Friend)` name the same Bot a bare name would; a Bot with no social handle is called by that roster name, and a `not-connected:` id is never a token — #949). A fenced or quoted `@name` stays literal text. The envelope also carries the line's `hop`: a person's line is hop 0 and a Bot's answer is one hop on. The receiving `applyInboundRoomShare` keeps only the calls that name this room's own Bot (`localCalls`), opens **one** turn for each through `rt.startInboundTurn` (`groups.js`: `participant.open` + `participant.runTurn` + `appendRoom`), and skips it when the line's hop has reached the room's `maxHops` (kept on the room, `DEFAULT_MAX_HOPS` 3) or when the same remote line was already answered — the last 200 keys `remoteRoomId:seq:botId` live in `room.answered`. That turn's answer is an ordinary room line, so `fanoutRoomLine` sends it back to the inviter as an `op: 'line'` envelope under the *inviter's* `roomId`, one hop on; an answer its owner's approval holds or refuses still shows as one `notice` line in the room instead of silence. Remote text stays inside the `fencedUntrusted` fence. ### A local room's invite link (#852) diff --git a/patches/ds-bot/files/src/host/room-membership.js b/patches/ds-bot/files/src/host/room-membership.js index cabb1103..a2c79092 100644 --- a/patches/ds-bot/files/src/host/room-membership.js +++ b/patches/ds-bot/files/src/host/room-membership.js @@ -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' @@ -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)) @@ -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) } @@ -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 ?? {} 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 66025fb6..201c3b90 100644 --- a/patches/ds-bot/files/test/room-ticket-seat.test.js +++ b/patches/ds-bot/files/test/room-ticket-seat.test.js @@ -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, @@ -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' })