diff --git a/changelog.d/293-face-mention-list.changed.md b/changelog.d/293-face-mention-list.changed.md new file mode 100644 index 000000000..5b5b068e7 --- /dev/null +++ b/changelog.d/293-face-mention-list.changed.md @@ -0,0 +1 @@ +Face-to-face rooms: a line you type now carries the Bots your @ picked as an explicit list (Bot Social `mentions`), and your friend's FlyBot calls exactly those Bots by handle instead of guessing from the text. It only applies when the server supports it and the line is attested as typed by you; with an older server or FlyBot nothing changes and the labels in the text are still read as before. (#293) diff --git a/core/src/host/face-mentions.js b/core/src/host/face-mentions.js index 184fcbe70..c3376e8ec 100644 --- a/core/src/host/face-mentions.js +++ b/core/src/host/face-mentions.js @@ -9,6 +9,11 @@ import { currentParticipants, isRemoteParticipant } from './room-membership.js' const clean = value => String(value ?? '').trim() +// A handle as one comparable key ("@Chief.Bob" and "chief.bob" are the same Bot), or ''. +export const handleKey = value => clean(value).replace(/^@+/, '').toLowerCase() +// The handle a mention list carries: lowercase, with the "@" Bot Social writes. +const wireHandle = value => (handleKey(value) === '' ? '' : `@${handleKey(value)}`) + // Every name one participant answers to, longest first is left to mentionSpans. export function faceAliases(participant, name) { const owner = clean(participant.ownerName) @@ -33,7 +38,7 @@ export function faceCandidates(room, botOf, { fromFriend = false } = {}) { } const bot = botOf(participant.id) if (bot === undefined || !(room.members ?? []).includes(bot.id)) continue - for (const name of faceAliases(participant, bot.name)) if (!(fromFriend && theirs.has(name.toLowerCase()))) local.push({ name, bot }) + for (const name of faceAliases(participant, bot.name)) if (!(fromFriend && theirs.has(name.toLowerCase()))) local.push({ name, bot, participant }) } // A local member the room has no participant record for still answers to its own name. for (const id of room.members ?? []) { @@ -47,11 +52,35 @@ export function faceMentions(room, text, botOf, { fromFriend = false, ...options const { local, remote } = faceCandidates(room, botOf, { fromFriend }) const bots = [] const friends = [] + const handles = [] let all = false for (const span of mentionSpans(text, [...local, ...remote], options)) { - if (span.all) all = true - else if (span.bot.bot !== undefined) { if (!bots.includes(span.bot.bot)) bots.push(span.bot.bot) } + if (span.all) { all = true; continue } + if (span.bot.bot !== undefined) { if (!bots.includes(span.bot.bot)) bots.push(span.bot.bot) } else if (!friends.includes(span.bot.participant)) friends.push(span.bot.participant) + const handle = wireHandle(span.bot.participant?.handle) + if (handle !== '' && !handles.includes(handle)) handles.push(handle) + } + // `handles`: the server handle of every Bot the line @-names, mine and friends' alike: the explicit + // mention list a sender attaches (#293). @all is not a Bot and has no handle. + return { all, bots, friends, handles, unmatched: unmatchedMentions(text, [...local, ...remote], options) } +} + +// The receiving end of an explicit mention list (#293): resolve the sender's handles against the +// room's current participants. `bots` are the local Bots called; `known` counts the handles that +// are in this room at all (mine or a friend's), so a list that names only friends' Bots calls +// nobody here and a list of handles this room has never seen can fall back to reading the text. +export function mentionedByHandle(room, handles, botOf) { + const wanted = new Set((Array.isArray(handles) ? handles : []).map(handleKey).filter(Boolean)) + const bots = [] + let known = 0 + if (wanted.size === 0) return { bots, known } + for (const participant of currentParticipants(room)) { + if (participant.kind !== 'bot' || !wanted.has(handleKey(participant.handle))) continue + known += 1 + if (isRemoteParticipant(participant)) continue + const bot = typeof botOf === 'function' ? botOf(participant.id) : undefined + if (bot !== undefined && (room.members ?? []).includes(bot.id) && !bots.includes(bot)) bots.push(bot) } - return { all, bots, friends, unmatched: unmatchedMentions(text, [...local, ...remote], options) } + return { bots, known } } diff --git a/core/src/host/hooks.js b/core/src/host/hooks.js index ec61b3031..21271c721 100644 --- a/core/src/host/hooks.js +++ b/core/src/host/hooks.js @@ -16,7 +16,7 @@ import { isHomeLevelRemote } from '../shared/remote-label.js' import { cleanName, stripSessionChips } from './delivery.js' import { SOURCE_KIND } from './constants.js' import { currentParticipants, isFaceRoom } from './room-membership.js' -import { faceMentions } from './face-mentions.js' +import { faceMentions, mentionedByHandle } from './face-mentions.js' import { FEED_EVENTS, installFeed } from './flybot-feed.js' import { defineTool } from '@deepseek-ai/dsh-tools' @@ -925,9 +925,17 @@ export function install(rt) { }, ofFace: serverId => Object.values(state.rooms || {}).find(room => room.face?.roomId === serverId), dropFace: roomId => rt.dropFaceRoom?.(roomId), + // `fn(room, line, { mentions })`: for the room owner's own typed line, `mentions` is the server handle of + // every Bot its "@" names (the picker's labels read against this room), so the transport can send the + // explicit list with the line (#293). Bot lines carry none. onFaceLine(fn) { - rt.faceRoomFanout = typeof fn === 'function' ? fn : undefined - return () => { if (rt.faceRoomFanout === fn) rt.faceRoomFanout = undefined } + if (typeof fn !== 'function') { rt.faceRoomFanout = undefined; return () => {} } + const wrapped = (room, line) => { + const typed = line?.authorKind === 'human' && line.remote !== true && !line.kind && typeof rt.botOf === 'function' + return fn(room, line, { mentions: typed ? faceMentions(room, String(line.text ?? ''), rt.botOf).handles : [] }) + } + rt.faceRoomFanout = wrapped + return () => { if (rt.faceRoomFanout === wrapped) rt.faceRoomFanout = undefined } }, postNotice: (roomId, text) => { const room = rt.roomOf(roomId) @@ -935,13 +943,19 @@ export function install(rt) { return rt.postToRoom(room, { id: 'face-notice', name: 'Room' }, String(text ?? ''), { quiet: true, notice: true }) }, // Another member's own typed line: a plain chat bubble from that person, no Bot turn (#180). - postHuman: (roomId, { authorId, name, text } = {}) => { + postHuman: (roomId, { authorId, name, text, mentions } = {}) => { const room = rt.roomOf(roomId) if (!room || typeof rt.postToRoom !== 'function') return const who = String(name || 'Someone') - // The friend's own typed line @-calls my Bots by the labels they see ("'s Chief", handle): - // only those local Bots answer (#287). A line that calls nobody here stays a bubble. - const callIds = isFaceRoom(room) && typeof rt.botOf === 'function' ? faceMentions(room, String(text ?? ''), rt.botOf, { fromFriend: true }).bots.map(bot => bot.id) : [] + // The friend's own typed line calls my Bots (#287). When the server carried the sender's explicit mention + // list (`mentions`: handles, #293) the handles decide, matched against this room's Bots; a list that names + // nobody this room knows, or no list (an older server or client), falls back to the labels they see + // ("'s Chief", handle) read from the text. Only attested lines carry a list here, so a Bot cannot + // call another home's Bots this way. A line that calls nobody here stays a bubble. + const listed = isFaceRoom(room) && typeof rt.botOf === 'function' && Array.isArray(mentions) && mentions.length > 0 ? mentionedByHandle(room, mentions, rt.botOf) : undefined + const callIds = listed !== undefined && listed.known > 0 + ? listed.bots.map(bot => bot.id) + : isFaceRoom(room) && typeof rt.botOf === 'function' ? faceMentions(room, String(text ?? ''), rt.botOf, { fromFriend: true }).bots.map(bot => bot.id) : [] return rt.postToRoom(room, { id: 'face-notice', name: who }, String(text ?? ''), { quiet: true, notice: true, human: { authorId: String(authorId || who), name: who }, ...(callIds.length > 0 ? { callIds } : {}) }) }, postRemote: (roomId, { handle, name, text } = {}) => { diff --git a/core/test/face-mentions.test.js b/core/test/face-mentions.test.js index 42df2752c..c3e794d2a 100644 --- a/core/test/face-mentions.test.js +++ b/core/test/face-mentions.test.js @@ -2,7 +2,7 @@ // is @chief.friend.home and its other Bot is Writer. Danyang's Chief lives on the other home. import assert from 'node:assert/strict' import test from 'node:test' -import { faceMentions } from '../src/host/face-mentions.js' +import { faceMentions, handleKey, mentionedByHandle } from '../src/host/face-mentions.js' import { mentionSpans, unmatchedMentions } from '../src/shared/mentions.js' const bots = { @@ -58,3 +58,25 @@ test("a friend's bare @Chief is theirs when their Chief is in the room; a label assert.deepEqual(fromFriend('@chief.friend.home hi').bots.map(bot => bot.id), ['chief']) assert.deepEqual(fromFriend('@技术文档 hi').bots.map(bot => bot.id), ['writer']) }) + +test('#293: a line\'s mention list is the handle of every Bot its @ names, mine and friends\', never @all', () => { + assert.deepEqual(called("@Danyang's Chief and @技术文档 · Friend hi").handles, ['@chief.danyang.home', '@writer.friend.home']) + assert.deepEqual(called('@chief.friend.home @Chief.Friend.Home again').handles, ['@chief.friend.home']) + assert.deepEqual(called('@all hi').handles, []) + assert.deepEqual(called('plain line').handles, []) +}) + +test('#293: the receiving end resolves handles to my Bots, counts the ones this room knows, and ignores strangers', () => { + assert.equal(handleKey(' @Chief.Friend.Home '), 'chief.friend.home') + const byHandle = handles => mentionedByHandle(room, handles, botOf) + assert.deepEqual(byHandle(['@chief.friend.home']).bots.map(bot => bot.id), ['chief']) + assert.deepEqual(byHandle(['writer.friend.home', '@CHIEF.friend.home']).bots.map(bot => bot.id), ['chief', 'writer']) + // A friend's own Bot is known to the room but calls nobody here. + assert.deepEqual(byHandle(['@chief.danyang.home']), { bots: [], known: 1 }) + assert.deepEqual(byHandle(['@ghost.nowhere']), { bots: [], known: 0 }) + assert.deepEqual(byHandle([]), { bots: [], known: 0 }) + assert.deepEqual(byHandle(undefined), { bots: [], known: 0 }) + // A participant that left is not in the room any more. + const left = { ...room, participants: room.participants.map(p => p.id === 'writer' ? { ...p, leftSeq: 3 } : p) } + assert.deepEqual(mentionedByHandle(left, ['@writer.friend.home'], botOf), { bots: [], known: 0 }) +}) diff --git a/core/test/flybot.test.js b/core/test/flybot.test.js index 70e8acfc0..f9aa5fb06 100644 --- a/core/test/flybot.test.js +++ b/core/test/flybot.test.js @@ -635,3 +635,58 @@ test('deleting a friend\'s Bot\'s chat reopens a fresh one at once, so its row n assert.equal(again.name, '助手') assert.equal(again.plugin, 'social') }) + +// The explicit mention list of a face-room line (#293): the sender's host names the Bots a typed line +// "@"s by server handle; the receiving host calls those first and reads the text only without a list. +const mentionRoom = async (fb) => fb.rooms.upsertFace({ + roomId: 'frm_men', + localId: 'face-men', + status: 'open', + members: [ + { handle: '@chief.me.home', displayName: 'Chief', ownerName: 'Me', published: false }, + { handle: '@writer.me.home', displayName: 'Writer', ownerName: 'Me', published: false }, + { handle: '@chief.bob.home', displayName: 'Chief', ownerName: 'Bob', published: false }, + ], + localBots: [{ id: 'a', handle: '@chief.me.home', name: 'Chief' }, { id: 'b', handle: '@writer.me.home', name: 'Writer' }], +}) + +test('#293: onFaceLine tells the transport which handles a typed line @-names (mine and friends\'), and none for Bot lines or @all', async (t) => { + const { fb, call, stop } = await start() + t.after(stop) + const room = await mentionRoom(fb) + const seen = [] + fb.rooms.onFaceLine((_room, line, extra) => { seen.push([line.text, extra.mentions]) }) + await call('room-post', { roomId: room.id, text: "@Bob's Chief and @Writer, please look" }) + await call('room-post', { roomId: room.id, text: 'no at-sign here' }) + await call('room-post', { roomId: room.id, text: '@all hello' }) + await call('room-post', { roomId: room.id, text: '@chief.bob.home twice @chief.bob.home' }) + assert.deepEqual(seen, [ + ["@Bob's Chief and @Writer, please look", ['@chief.bob.home', '@writer.me.home']], + ['no at-sign here', []], + ['@all hello', []], + ['@chief.bob.home twice @chief.bob.home', ['@chief.bob.home']], + ]) +}) + +test('#293: postHuman calls the Bots a friend\'s mention list names by handle, not the ones the text happens to name', async (t) => { + const { fb, agents, stop } = await start() + t.after(stop) + const room = await mentionRoom(fb) + const calls = () => (agents.get(room.id)?.calls ?? []).map(([, message]) => message.source).filter(source => source?.human) + const said = async (text, mentions) => { + const before = calls().length + await fb.rooms.postHuman(room.id, { authorId: '@bob.home', name: 'Bob', text, ...(mentions ? { mentions } : {}) }) + return calls().slice(before).map(source => source.callIds) + } + // The list decides: Writer is named in the list, the text only says "Chief". + assert.deepEqual(await said('@Chief what do you think', ['@WRITER.me.home']), [['b']]) + // Handles with or without "@", any case. + assert.deepEqual(await said('hello', ['chief.me.home', '@Chief.Me.Home']), [['a']]) + // A list that only names the friend's own Bot calls nobody here, even if the text names mine. + assert.deepEqual(await said("@Me's Chief hi", ['@chief.bob.home']), [undefined]) + // A list of handles this room has never seen falls back to reading the text... + assert.deepEqual(await said("@Me's Chief hi", ['@ghost.nowhere']), [['a']]) + // ...and so does no list at all (an older server or client). + assert.deepEqual(await said("@Me's Chief hi"), [['a']]) + assert.deepEqual(await said('no @ at all', []), [undefined]) +}) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index cd556991a..84c00a5a2 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -39,7 +39,7 @@ The server side of the patched `ds-bot` core (`core/`): the part of [ds-bot-core | `core/index.js` | Server entry. | | `core/src/host/` | Host-side helpers (non-hooks). | | `core/src/host/room-membership.js` | Typed room participants (human / local bot / granted remote), authorship, membership-window history (#50); rooms with friends' Bots or other owners take granted local Bots only and evict a Bot that stops qualifying (#90). Face-to-face rooms (#83) reuse this model (`origin: 'face-to-face'`, room-only remotes without a person-grant, `upsertFaceRoom` / `syncFaceParticipants` / `applyFaceRoomLine`); a face-room local Bot is `viaCode` (brought by number), `evictUnshared` skips face rooms (membership is the server's), and a Bot that returns to the member list drops the empty local `left` line (#236). | -| `core/src/host/face-mentions.js` | Who "@" calls in a face-to-face room (#287): every alias of a participant (bare name, the picker label `'s Chief` / ` · 's Bot`, ` · `, handle), local and friend's Bots told apart. | +| `core/src/host/face-mentions.js` | Who "@" calls in a face-to-face room (#287): every alias of a participant (bare name, the picker label `'s Chief` / ` · 's Bot`, ` · `, handle), local and friend's Bots told apart. `faceMentions().handles` is the handle of every Bot a line names (the explicit mention list a sender attaches, #293) and `mentionedByHandle` resolves a received list to my Bots (`known` counts handles the room has). | | `core/src/host/reach.js` | Per-Bot grants and the owner-policy `reach()` check that hooks.js runs outside the plugin chain (#90); `rt.setGrants`, `rt.hasGrant`, `rt.hasAnyGrant`. | | `core/src/shared/remote-label.js` | Owner-badge labels for a friend's Bot and “Shared room from …” titles. `isListedRemoteChat` / `listedChatBots` keep gone, hidden, and home-level (`@something.home`) remotes out of the sidebar, the **发给** picker, mentions, and `list_bots` friends lines; history stays on disk. | diff --git a/docs/subsystems/plugin-runtime.md b/docs/subsystems/plugin-runtime.md index ca7739482..d2a786a3e 100644 --- a/docs/subsystems/plugin-runtime.md +++ b/docs/subsystems/plugin-runtime.md @@ -54,7 +54,7 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots 6. `flybot.roomApprovals.setProvider` lets a plugin (social) attach pending outbound holds to a shared-room view. It does not enable team mode. 7. `cleanRemote` is the record boundary for a friend's granted Bot: `name`, `homeName` and `owner` strip `@` (so they cannot look like a mention of another Bot in `nameMentions` chips/previews) and control characters/newlines, then cap at 60 characters. Handles stay on the `handle` field. Optional look fields (`color`, `avatar` object, `avatarUpdatedAt`) pass through when present so remote chats, group lists, and headers can render a peer's chosen look; missing look stays handle-seeded. Those labels then flow into delivery `fromName` (still `cleanName`'d and the body fenced) and into `list_bots` friends lines via the remote-chat roster. Group room logs use local team names, not peer labels. 8. A user's @ of a friend's Bot in a group gets a receipt line in that group (`hooks.js` `mentionReceipt`, relay role `mention`): where the reply lands, or why the send was denied, held, or failed. The receipt's role stays outside the room round trigger (`groups.js` `opens`), so it never opens a round; peer names go through `cleanName`. A human line that names a friend's Bot already in that room is **not** 1:1-relayed as the group's admin (Chief): the room message is the delivery, so Chief's missing grant does not block it (#143). A Chief-originated `bot.send` to an ungranted friend Bot is still deny. A remaining grant deny on a mention that is *not* in the room says to invite the Bot or add it to the group. -9. `flybot.rooms` hosts a Bot Social face-to-face matching room as a native room (`upsertFace` / `applyFaceLine` / `ofFace` / `dropFace` / `onFaceLine` / `postNotice` / `postHuman` (quiet; it reads the friend's line for `@` of a local Bot by label or handle and passes `callIds`, so only those Bots answer, #287) / `postRemote`; `postHuman` draws another member's typed line as a chat bubble from that person, #180). Slot `flybot.room.overview` is a block at the top of a group's overview: while joining, a tile grid (avatar above name + owner) with **Leave** and **Enter chat** on one row; once open, compact **Leave** and Auto-send — not Open/Start/Leave together. Slot `flybot.new-chat.action` adds a row to the sidebar **+** short action menu (next to Create group chat; `label` / `hint` go through the same `t()` map). Still not team mode. +9. `flybot.rooms` hosts a Bot Social face-to-face matching room as a native room (`upsertFace` / `applyFaceLine` / `ofFace` / `dropFace` / `onFaceLine` (`fn(room, line, { mentions })`: for the owner's typed line, the handles its `@` names, #293) / `postNotice` / `postHuman` (quiet; it reads the friend's line for `@` of a local Bot by label or handle and passes `callIds`, so only those Bots answer, #287; an optional `mentions` handle list from an attested line is resolved first, #293) / `postRemote`; `postHuman` draws another member's typed line as a chat bubble from that person, #180). Slot `flybot.room.overview` is a block at the top of a group's overview: while joining, a tile grid (avatar above name + owner) with **Leave** and **Enter chat** on one row; once open, compact **Leave** and Auto-send — not Open/Start/Leave together. Slot `flybot.new-chat.action` adds a row to the sidebar **+** short action menu (next to Create group chat; `label` / `hint` go through the same `t()` map). Still not team mode. 10. Held sends are owner-bound (#52): a hold stamps server-derived provenance on the record — `requestId`, a sha256 `version` over the request content, `origin` (`via`/`hop`/audience) and the sender Bot's `ownerId`; message claims (`meta`, text) never confer authority. `flybot/decide` passes authz for any signed-in actor and hooks binds the decision to the sender Bot's owner's session; `release` revalidates and marks records `stale` (changed request, deleted sender/target Bot, changed owner — `deleteBot` invalidates eagerly; a demoted Chief's sender-side holds are invalidated eagerly on `setMain`, not inside `staleReason`). `release()` re-checks immediately before dispatch so an in-flight approve cannot go out after a concurrent invalidation. Unknown `invalidateFor` field values throw. The pending list is filtered per actor under member enforcement. The default approval card shows `senderName`/`targetName` and a delegated marker (`Approval.origin`). 11. Task lifecycle (#54): `bot.send` stamps `meta.taskId` from the working task (`rt.taskIdForSend`); a held send then calls `rt.taskHeld` (blocks **that** task). The owner's `flybot/decide` calls `rt.taskDecided` (approve resumes, reject cancels); `deleteBot` of a local Bot calls `rt.taskBotDeleted` (open tasks fail). Incoming holds stay the owner's. The state machine lives in `tasks.js`; hooks only fire those three seams. Clarification and `workBotId` wait on #55. 12. Activity feed (#241): plugins that want to see where team time goes subscribe through `flybot.on(...)` and never to raw DSH `session/event` (the maintainer decision on #241). Events: `turn.start` / `turn.end` (a Bot's own chat `place: 'chat'`, its hidden group Session `'group'`, the group's own Session `'room'`, whose turn is one round, or a friend's Bot chat `'remote'` (#248: its Session runs no model and only carries lines to and from the friend, so those turns are transport, not a team Bot's work; `remote: {handle, name}` names the friend as the sidebar does (`Helper · Bob's Bot`, `Bob's Chief`; a `{kind: 'remote'}` party carries the same `name` when the roster knows it), and the friend's line into the chat is an `exchange.delivered` with `role: 'remote'`)), `tool.start` / `tool.end` (paired by `callId`, closed `interrupted` when their turn ends first) and `exchange.delivered` (a message arrived in a Session's log: `exchangeId` as in `state.exchanges`, `replyTo` of the answer, `from` a user / Bot / group / friend's Bot). The feed is metadata only: message text, tool arguments, tool output and model output never leave `flybot-feed.js`, so a consumer that stores it cannot persist a secret. Strings from outside the team (tool names, remote handles) go through `cleanLabel` (which also removes `untrusted` fence markers when spaced out or split), error outcomes carry an upper-case code (`RATE_LIMIT`, else `UNKNOWN`) and no message. Delivered means "in the recipient's log", not read; the feed cannot see read. Host nudges (retry, resume, kickoff, deferred and re-sent deliveries) are not deliveries. Not replayed: a listener hears events after it subscribes. Listeners get copies and a throwing one cannot reach the Session or the other listeners. Events fire in a read-only host too, since they only observe. diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 6649d8694..6bb320580 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -84,7 +84,7 @@ pins the list. The authority for the list is Bot Social's README and `docs/FACE_ ### Face-room human turns and UI polish (#166, #167) -- A plain human line in an open face-to-face room is logged and fanned out once, and starts one local Bot (the admin, else the first member) without an @ (#287; core `openingSpeakers`, see [core-host](core-host.md)). A friend's attested line starts only the Bot(s) it names by the label the picker shows (`Friend's Chief`) or by handle; `plugins/social/test` has the two-home case. +- A plain human line in an open face-to-face room is logged and fanned out once, and starts one local Bot (the admin, else the first member) without an @ (#287; core `openingSpeakers`, see [core-host](core-host.md)). A friend's attested line starts only the Bot(s) it names by the label the picker shows (`Friend's Chief`) or by handle; `plugins/social/test` has the two-home case. **Explicit mention list (#293):** the sender's host resolves the picker's labels against the room (`faceMentions().handles`; core `onFaceLine(fn)` passes `{ mentions }` for the owner's typed line) and `faceApi.send` puts the handles in `mentions` next to `typed_by_owner` only when `GET /v1/platform` `features` lists `mentions` (the one feature probe, `serverMentions()`); never on the legacy envelope, Bot lines or an older server. The receiver reads `mentions` (`cleanMentions`, at most 8 handles) only from a line the server attests `typed_by_owner` (`renderFaceEvent`), and `postHuman` resolves the handles against the room first (`mentionedByHandle`): only my own Bots in the list answer; a list naming nobody the room knows, or no list, falls back to the label/name reading above. The composer's `@` picker inserts the label text, so the handles are derived from it at send, not tracked in the composer. - A held room line belongs to one room (#188): the send path records `settings.holdRooms[approvalId] = faceRoomId` when Bot Social's card does not name the room, and `pendingApprovals` drops holds with no known room (legacy) or whose room is left / closed / past here, so no other room shows the card and no waiting dot stays on an old row. The room scope in the client, the `roomApprovals` provider and `hidden-remotes` match the room id strictly. - Face-room holds (`face_room_message` / `roomId`) attach `pendingCount` to that room only. They do not badge an unrelated friend row via `from`/`to` handle. The social strip no longer draws an **N to approve** tag under the last sidebar row; the gold wait dot on the owning RoomRow / 1:1 remains. - Approval preview strips `To face room:` / `To the room:` and wraps on word boundaries (`overflow-wrap:break-word; word-break:normal`), with **Show more** when long. diff --git a/patches/ds-bot/files/src/host/face-mentions.js b/patches/ds-bot/files/src/host/face-mentions.js index 184fcbe70..c3376e8ec 100644 --- a/patches/ds-bot/files/src/host/face-mentions.js +++ b/patches/ds-bot/files/src/host/face-mentions.js @@ -9,6 +9,11 @@ import { currentParticipants, isRemoteParticipant } from './room-membership.js' const clean = value => String(value ?? '').trim() +// A handle as one comparable key ("@Chief.Bob" and "chief.bob" are the same Bot), or ''. +export const handleKey = value => clean(value).replace(/^@+/, '').toLowerCase() +// The handle a mention list carries: lowercase, with the "@" Bot Social writes. +const wireHandle = value => (handleKey(value) === '' ? '' : `@${handleKey(value)}`) + // Every name one participant answers to, longest first is left to mentionSpans. export function faceAliases(participant, name) { const owner = clean(participant.ownerName) @@ -33,7 +38,7 @@ export function faceCandidates(room, botOf, { fromFriend = false } = {}) { } const bot = botOf(participant.id) if (bot === undefined || !(room.members ?? []).includes(bot.id)) continue - for (const name of faceAliases(participant, bot.name)) if (!(fromFriend && theirs.has(name.toLowerCase()))) local.push({ name, bot }) + for (const name of faceAliases(participant, bot.name)) if (!(fromFriend && theirs.has(name.toLowerCase()))) local.push({ name, bot, participant }) } // A local member the room has no participant record for still answers to its own name. for (const id of room.members ?? []) { @@ -47,11 +52,35 @@ export function faceMentions(room, text, botOf, { fromFriend = false, ...options const { local, remote } = faceCandidates(room, botOf, { fromFriend }) const bots = [] const friends = [] + const handles = [] let all = false for (const span of mentionSpans(text, [...local, ...remote], options)) { - if (span.all) all = true - else if (span.bot.bot !== undefined) { if (!bots.includes(span.bot.bot)) bots.push(span.bot.bot) } + if (span.all) { all = true; continue } + if (span.bot.bot !== undefined) { if (!bots.includes(span.bot.bot)) bots.push(span.bot.bot) } else if (!friends.includes(span.bot.participant)) friends.push(span.bot.participant) + const handle = wireHandle(span.bot.participant?.handle) + if (handle !== '' && !handles.includes(handle)) handles.push(handle) + } + // `handles`: the server handle of every Bot the line @-names, mine and friends' alike: the explicit + // mention list a sender attaches (#293). @all is not a Bot and has no handle. + return { all, bots, friends, handles, unmatched: unmatchedMentions(text, [...local, ...remote], options) } +} + +// The receiving end of an explicit mention list (#293): resolve the sender's handles against the +// room's current participants. `bots` are the local Bots called; `known` counts the handles that +// are in this room at all (mine or a friend's), so a list that names only friends' Bots calls +// nobody here and a list of handles this room has never seen can fall back to reading the text. +export function mentionedByHandle(room, handles, botOf) { + const wanted = new Set((Array.isArray(handles) ? handles : []).map(handleKey).filter(Boolean)) + const bots = [] + let known = 0 + if (wanted.size === 0) return { bots, known } + for (const participant of currentParticipants(room)) { + if (participant.kind !== 'bot' || !wanted.has(handleKey(participant.handle))) continue + known += 1 + if (isRemoteParticipant(participant)) continue + const bot = typeof botOf === 'function' ? botOf(participant.id) : undefined + if (bot !== undefined && (room.members ?? []).includes(bot.id) && !bots.includes(bot)) bots.push(bot) } - return { all, bots, friends, unmatched: unmatchedMentions(text, [...local, ...remote], options) } + return { bots, known } } diff --git a/patches/ds-bot/files/src/host/hooks.js b/patches/ds-bot/files/src/host/hooks.js index ec61b3031..21271c721 100644 --- a/patches/ds-bot/files/src/host/hooks.js +++ b/patches/ds-bot/files/src/host/hooks.js @@ -16,7 +16,7 @@ import { isHomeLevelRemote } from '../shared/remote-label.js' import { cleanName, stripSessionChips } from './delivery.js' import { SOURCE_KIND } from './constants.js' import { currentParticipants, isFaceRoom } from './room-membership.js' -import { faceMentions } from './face-mentions.js' +import { faceMentions, mentionedByHandle } from './face-mentions.js' import { FEED_EVENTS, installFeed } from './flybot-feed.js' import { defineTool } from '@deepseek-ai/dsh-tools' @@ -925,9 +925,17 @@ export function install(rt) { }, ofFace: serverId => Object.values(state.rooms || {}).find(room => room.face?.roomId === serverId), dropFace: roomId => rt.dropFaceRoom?.(roomId), + // `fn(room, line, { mentions })`: for the room owner's own typed line, `mentions` is the server handle of + // every Bot its "@" names (the picker's labels read against this room), so the transport can send the + // explicit list with the line (#293). Bot lines carry none. onFaceLine(fn) { - rt.faceRoomFanout = typeof fn === 'function' ? fn : undefined - return () => { if (rt.faceRoomFanout === fn) rt.faceRoomFanout = undefined } + if (typeof fn !== 'function') { rt.faceRoomFanout = undefined; return () => {} } + const wrapped = (room, line) => { + const typed = line?.authorKind === 'human' && line.remote !== true && !line.kind && typeof rt.botOf === 'function' + return fn(room, line, { mentions: typed ? faceMentions(room, String(line.text ?? ''), rt.botOf).handles : [] }) + } + rt.faceRoomFanout = wrapped + return () => { if (rt.faceRoomFanout === wrapped) rt.faceRoomFanout = undefined } }, postNotice: (roomId, text) => { const room = rt.roomOf(roomId) @@ -935,13 +943,19 @@ export function install(rt) { return rt.postToRoom(room, { id: 'face-notice', name: 'Room' }, String(text ?? ''), { quiet: true, notice: true }) }, // Another member's own typed line: a plain chat bubble from that person, no Bot turn (#180). - postHuman: (roomId, { authorId, name, text } = {}) => { + postHuman: (roomId, { authorId, name, text, mentions } = {}) => { const room = rt.roomOf(roomId) if (!room || typeof rt.postToRoom !== 'function') return const who = String(name || 'Someone') - // The friend's own typed line @-calls my Bots by the labels they see ("'s Chief", handle): - // only those local Bots answer (#287). A line that calls nobody here stays a bubble. - const callIds = isFaceRoom(room) && typeof rt.botOf === 'function' ? faceMentions(room, String(text ?? ''), rt.botOf, { fromFriend: true }).bots.map(bot => bot.id) : [] + // The friend's own typed line calls my Bots (#287). When the server carried the sender's explicit mention + // list (`mentions`: handles, #293) the handles decide, matched against this room's Bots; a list that names + // nobody this room knows, or no list (an older server or client), falls back to the labels they see + // ("'s Chief", handle) read from the text. Only attested lines carry a list here, so a Bot cannot + // call another home's Bots this way. A line that calls nobody here stays a bubble. + const listed = isFaceRoom(room) && typeof rt.botOf === 'function' && Array.isArray(mentions) && mentions.length > 0 ? mentionedByHandle(room, mentions, rt.botOf) : undefined + const callIds = listed !== undefined && listed.known > 0 + ? listed.bots.map(bot => bot.id) + : isFaceRoom(room) && typeof rt.botOf === 'function' ? faceMentions(room, String(text ?? ''), rt.botOf, { fromFriend: true }).bots.map(bot => bot.id) : [] return rt.postToRoom(room, { id: 'face-notice', name: who }, String(text ?? ''), { quiet: true, notice: true, human: { authorId: String(authorId || who), name: who }, ...(callIds.length > 0 ? { callIds } : {}) }) }, postRemote: (roomId, { handle, name, text } = {}) => { diff --git a/patches/ds-bot/files/test/face-mentions.test.js b/patches/ds-bot/files/test/face-mentions.test.js index 42df2752c..c3e794d2a 100644 --- a/patches/ds-bot/files/test/face-mentions.test.js +++ b/patches/ds-bot/files/test/face-mentions.test.js @@ -2,7 +2,7 @@ // is @chief.friend.home and its other Bot is Writer. Danyang's Chief lives on the other home. import assert from 'node:assert/strict' import test from 'node:test' -import { faceMentions } from '../src/host/face-mentions.js' +import { faceMentions, handleKey, mentionedByHandle } from '../src/host/face-mentions.js' import { mentionSpans, unmatchedMentions } from '../src/shared/mentions.js' const bots = { @@ -58,3 +58,25 @@ test("a friend's bare @Chief is theirs when their Chief is in the room; a label assert.deepEqual(fromFriend('@chief.friend.home hi').bots.map(bot => bot.id), ['chief']) assert.deepEqual(fromFriend('@技术文档 hi').bots.map(bot => bot.id), ['writer']) }) + +test('#293: a line\'s mention list is the handle of every Bot its @ names, mine and friends\', never @all', () => { + assert.deepEqual(called("@Danyang's Chief and @技术文档 · Friend hi").handles, ['@chief.danyang.home', '@writer.friend.home']) + assert.deepEqual(called('@chief.friend.home @Chief.Friend.Home again').handles, ['@chief.friend.home']) + assert.deepEqual(called('@all hi').handles, []) + assert.deepEqual(called('plain line').handles, []) +}) + +test('#293: the receiving end resolves handles to my Bots, counts the ones this room knows, and ignores strangers', () => { + assert.equal(handleKey(' @Chief.Friend.Home '), 'chief.friend.home') + const byHandle = handles => mentionedByHandle(room, handles, botOf) + assert.deepEqual(byHandle(['@chief.friend.home']).bots.map(bot => bot.id), ['chief']) + assert.deepEqual(byHandle(['writer.friend.home', '@CHIEF.friend.home']).bots.map(bot => bot.id), ['chief', 'writer']) + // A friend's own Bot is known to the room but calls nobody here. + assert.deepEqual(byHandle(['@chief.danyang.home']), { bots: [], known: 1 }) + assert.deepEqual(byHandle(['@ghost.nowhere']), { bots: [], known: 0 }) + assert.deepEqual(byHandle([]), { bots: [], known: 0 }) + assert.deepEqual(byHandle(undefined), { bots: [], known: 0 }) + // A participant that left is not in the room any more. + const left = { ...room, participants: room.participants.map(p => p.id === 'writer' ? { ...p, leftSeq: 3 } : p) } + assert.deepEqual(mentionedByHandle(left, ['@writer.friend.home'], botOf), { bots: [], known: 0 }) +}) diff --git a/patches/ds-bot/files/test/flybot.test.js b/patches/ds-bot/files/test/flybot.test.js index 70e8acfc0..f9aa5fb06 100644 --- a/patches/ds-bot/files/test/flybot.test.js +++ b/patches/ds-bot/files/test/flybot.test.js @@ -635,3 +635,58 @@ test('deleting a friend\'s Bot\'s chat reopens a fresh one at once, so its row n assert.equal(again.name, '助手') assert.equal(again.plugin, 'social') }) + +// The explicit mention list of a face-room line (#293): the sender's host names the Bots a typed line +// "@"s by server handle; the receiving host calls those first and reads the text only without a list. +const mentionRoom = async (fb) => fb.rooms.upsertFace({ + roomId: 'frm_men', + localId: 'face-men', + status: 'open', + members: [ + { handle: '@chief.me.home', displayName: 'Chief', ownerName: 'Me', published: false }, + { handle: '@writer.me.home', displayName: 'Writer', ownerName: 'Me', published: false }, + { handle: '@chief.bob.home', displayName: 'Chief', ownerName: 'Bob', published: false }, + ], + localBots: [{ id: 'a', handle: '@chief.me.home', name: 'Chief' }, { id: 'b', handle: '@writer.me.home', name: 'Writer' }], +}) + +test('#293: onFaceLine tells the transport which handles a typed line @-names (mine and friends\'), and none for Bot lines or @all', async (t) => { + const { fb, call, stop } = await start() + t.after(stop) + const room = await mentionRoom(fb) + const seen = [] + fb.rooms.onFaceLine((_room, line, extra) => { seen.push([line.text, extra.mentions]) }) + await call('room-post', { roomId: room.id, text: "@Bob's Chief and @Writer, please look" }) + await call('room-post', { roomId: room.id, text: 'no at-sign here' }) + await call('room-post', { roomId: room.id, text: '@all hello' }) + await call('room-post', { roomId: room.id, text: '@chief.bob.home twice @chief.bob.home' }) + assert.deepEqual(seen, [ + ["@Bob's Chief and @Writer, please look", ['@chief.bob.home', '@writer.me.home']], + ['no at-sign here', []], + ['@all hello', []], + ['@chief.bob.home twice @chief.bob.home', ['@chief.bob.home']], + ]) +}) + +test('#293: postHuman calls the Bots a friend\'s mention list names by handle, not the ones the text happens to name', async (t) => { + const { fb, agents, stop } = await start() + t.after(stop) + const room = await mentionRoom(fb) + const calls = () => (agents.get(room.id)?.calls ?? []).map(([, message]) => message.source).filter(source => source?.human) + const said = async (text, mentions) => { + const before = calls().length + await fb.rooms.postHuman(room.id, { authorId: '@bob.home', name: 'Bob', text, ...(mentions ? { mentions } : {}) }) + return calls().slice(before).map(source => source.callIds) + } + // The list decides: Writer is named in the list, the text only says "Chief". + assert.deepEqual(await said('@Chief what do you think', ['@WRITER.me.home']), [['b']]) + // Handles with or without "@", any case. + assert.deepEqual(await said('hello', ['chief.me.home', '@Chief.Me.Home']), [['a']]) + // A list that only names the friend's own Bot calls nobody here, even if the text names mine. + assert.deepEqual(await said("@Me's Chief hi", ['@chief.bob.home']), [undefined]) + // A list of handles this room has never seen falls back to reading the text... + assert.deepEqual(await said("@Me's Chief hi", ['@ghost.nowhere']), [['a']]) + // ...and so does no list at all (an older server or client). + assert.deepEqual(await said("@Me's Chief hi"), [['a']]) + assert.deepEqual(await said('no @ at all', []), [undefined]) +}) diff --git a/plugins/social/src/face-rooms.js b/plugins/social/src/face-rooms.js index 6d07edb6d..55c82b2c5 100644 --- a/plugins/social/src/face-rooms.js +++ b/plugins/social/src/face-rooms.js @@ -397,6 +397,20 @@ const INBOX_TYPES = { message: 'room.message', } +// The sender's explicit mention list as the server delivers it (Bot Social `mentions`, #293): at most 8 +// handles, each normalised to lowercase "@handle"; anything malformed is dropped, never trusted. +const MENTION_HANDLE = /^@?[a-z0-9._-]{2,40}$/i +export function cleanMentions(value) { + if (!Array.isArray(value)) return [] + const out = [] + for (const item of value.slice(0, 8)) { + if (typeof item !== 'string' || !MENTION_HANDLE.test(item.trim())) continue + const handle = `@${item.trim().replace(/^@/, '').toLowerCase()}` + if (!out.includes(handle)) out.push(handle) + } + return out +} + export function viewFaceEvent(event) { if (!event || typeof event !== 'object') return undefined const raw = String(event.type || event.event_type || '') @@ -419,6 +433,9 @@ export function viewFaceEvent(event) { // Stamped by Bot Social (never read from the text): the owner typed this line themselves. A viewed // event is viewed again by renderFaceEvent, hence the camelCase form. ...(mapped === 'room.message' && (event.typed_by_owner === true || event.typedByOwner === true) ? { typedByOwner: true } : {}), + // The sender's own list of whom the line is for (#293). Data from the sender's host, read by + // renderFaceEvent only on a line the server attests as typed by that person. + ...(mapped === 'room.message' && cleanMentions(event.mentions).length > 0 ? { mentions: cleanMentions(event.mentions) } : {}), } } @@ -467,6 +484,9 @@ export function renderFaceEvent(event, { attested = false } = {}) { trust: 'peer_untrusted', eventId: viewed.eventId, eventCursor: viewed.eventCursor, + // Honoured only on a line the server attests as typed by the person (#293): a Bot's line never + // calls another home's Bots by a list it wrote, and an unattested server has no such list to trust. + ...(attested && human && viewed.typedByOwner === true && viewed.mentions ? { mentions: viewed.mentions } : {}), } } const kind = viewed.type === 'member.left' ? 'left' : viewed.type === 'room.started' ? 'started' : 'joined' @@ -578,11 +598,14 @@ export function createFaceRoomApi({ server, token, fetch: fetchImpl = socialFetc }, // `typedByOwner`: the person typed this line. Bot Social stamps it for the receiver only when this // token may attest; otherwise it answers 403 typed_attestation_forbidden. - async send(roomId, { text, botId, typedByOwner } = {}) { + // `mentions`: the Bot handles the line @-names (#293); sent only when the server lists the feature. + async send(roomId, { text, botId, typedByOwner, mentions } = {}) { + const listed = cleanMentions(mentions) const body = await request('POST', `/${encodeURIComponent(String(roomId))}/messages`, { text: String(text ?? ''), ...(botId ? { bot_id: botId } : {}), ...(typedByOwner === true ? { typed_by_owner: true } : {}), + ...(listed.length > 0 ? { mentions: listed } : {}), }) return { typedByOwner: body.typed_by_owner === true, diff --git a/plugins/social/src/social.js b/plugins/social/src/social.js index 76a87e966..32ad5e714 100644 --- a/plugins/social/src/social.js +++ b/plugins/social/src/social.js @@ -1168,7 +1168,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp // The probe is shared: while one is in flight every caller awaits its result (#234) — an early // "not attesting" from a probe that has not finished would render forgeable envelopes as person // lines and mark the events seen, with no self-heal once the probe lands. - const typed = { supported: false, forbidden: false, probedAt: 0, probeFailed: false, probing: undefined } + const typed = { supported: false, forbidden: false, mentions: false, probedAt: 0, probeFailed: false, probing: undefined } const TYPED_RECHECK_MS = 10 * 60_000 // A probe that got no answer (features() → undefined: an unreachable or pre-platform server) is // not a "no": it is retried soon, not held for the recheck window. @@ -1186,12 +1186,22 @@ export function createSocial(service, config = {}, { client: given, api: givenAp typed.probedAt = now() typed.probeFailed = features === undefined if (features?.includes('typed_by_owner')) typed.supported = true + if (features?.includes('mentions')) typed.mentions = true return typed.supported } finally { typed.probing = undefined } } const canAttest = async () => !typed.forbidden && await serverAttests() + // Whether this server carries the explicit mention list (Bot Social `mentions`, #293): learned from the + // same feature probe, shared with it. An older server, or one that has not answered, gets no field, + // and the other home reads the @ labels from the text as before. + async function serverMentions() { + if (typed.mentions || !faceApi?.features) return typed.mentions + if (typed.probing) await typed.probing + else if (!typed.probedAt || now() - typed.probedAt >= (typed.probeFailed ? TYPED_RETRY_MS : TYPED_RECHECK_MS)) await (typed.probing = probeTypedSupport()) + return typed.mentions + } const isAttestRefused = error => error?.code === 'typed_attestation_forbidden' || /typed_attestation_forbidden/.test(String(error?.message ?? '')) // The owner's own typed line, as Bot Social's send_as_member with the attestation; falls back // to a plain send when this token may not attest. Deliberately not gated on the feature probe @@ -2216,7 +2226,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp if (local) continue const who = line.who || 'Someone' flybot.rooms.applyFaceLine?.(room.id, { ...line, who, remote: true, authorKind: 'human' }) - if (flybot.rooms.postHuman) void flybot.rooms.postHuman(room.id, { authorId: line.authorId, name: who, text: line.text }) + if (flybot.rooms.postHuman) void flybot.rooms.postHuman(room.id, { authorId: line.authorId, name: who, text: line.text, ...(line.mentions ? { mentions: line.mentions } : {}) }) else void flybot.rooms.postNotice?.(room.id, `${who}: ${line.text}`) } else { const local = locals.some(bot => bot.handle && bot.handle === line.authorId) @@ -2403,7 +2413,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp return viewHostedFace(room, { status: 'open' }) } if (flybot.rooms?.onFaceLine) { - disposers.push(flybot.rooms.onFaceLine(async (room, line) => { + disposers.push(flybot.rooms.onFaceLine(async (room, line, { mentions = [] } = {}) => { const serverId = room?.face?.roomId if (!serverId || !faceApi) return const outbound = faceOutbound(line, { @@ -2418,7 +2428,9 @@ export function createSocial(service, config = {}, { client: given, api: givenAp let sent if (outbound.human && outbound.plain !== undefined && await canAttest()) { try { - sent = await faceApi.send(serverId, { text: outbound.plain, botId: outbound.botId, typedByOwner: true }) + // The Bots the person's "@" picked go along as an explicit list when the server carries it (#293). + const listed = mentions.length > 0 && await serverMentions() ? mentions : [] + sent = await faceApi.send(serverId, { text: outbound.plain, botId: outbound.botId, typedByOwner: true, ...(listed.length > 0 ? { mentions: listed } : {}) }) } catch (error) { if (!isAttestRefused(error)) throw error typed.forbidden = true diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index 7d5ebff2d..4849ab1c2 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -5001,13 +5001,13 @@ test('typed_by_owner: a stamped incoming direct line is the person\'s; the same ]) }) -function attestingFaceServer({ refuse = false } = {}) { +function attestingFaceServer({ refuse = false, features = ['typed_by_owner'] } = {}) { const server = withFaceRooms(mockServer()) const inner = server.fetch server.fetch = async (url, init) => { if (String(url).endsWith('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/v1/platform')) { server.calls.push({ url, name: 'REST GET /v1/platform' }) - return new Response(JSON.stringify({ name: 'Bot Social', features: ['typed_by_owner'] }), { status: 200 }) + return new Response(JSON.stringify({ name: 'Bot Social', features }), { status: 200 }) } if (refuse && init?.method === 'POST' && String(url).endsWith('/messages') && JSON.parse(init.body).typed_by_owner) { server.calls.push({ url, name: 'REST POST /messages (refused)', args: JSON.parse(init.body) }) @@ -5038,7 +5038,7 @@ async function faceFixture(t, server, tweak, opts = {}) { await grantTeam(ctx.flybot) await social.registerAll() await call('flybot/social/face-join', { code: '123456' }) - return { ctx, call, sources, room: ctx.flybot.rooms.ofFace('frm_123456'), fanout: (line) => fanout(ctx.flybot.rooms.ofFace('frm_123456'), line) } + return { ctx, call, sources, room: ctx.flybot.rooms.ofFace('frm_123456'), fanout: (line, extra) => fanout(ctx.flybot.rooms.ofFace('frm_123456'), line, extra) } } const pushRoomEvent = (server, extra, text, roomId = 'frm_123456') => { const body = server.face.rooms.get(roomId) @@ -5101,6 +5101,69 @@ test('cross-home @ (#287): a friend\'s attested line calls my Bot by the label t assert.equal(calledNow().every(source => source.quiet === true && source.human?.name === 'Bob'), true) }) +test('mentions (#293): a typed line carries its @ handles when the server lists the feature; never on the legacy form, Bot lines or an older server', async (t) => { + const posts = server => server.calls.filter(c => String(c.name).includes('/messages')) + const typedLine = { authorKind: 'human', who: 'Danyang', text: "@Bob's Chief please look", authorId: 'owner-1' } + // A server with the feature: the list goes out next to typed_by_owner, plain text only. + const withFeature = attestingFaceServer({ features: ['typed_by_owner', 'mentions'] }) + const a = await faceFixture(t, withFeature) + await a.fanout(typedLine, { mentions: ['@chief.bob.home'] }) + assert.deepEqual(posts(withFeature)[0].args, { text: typedLine.text, bot_id: posts(withFeature)[0].args.bot_id, typed_by_owner: true, mentions: ['@chief.bob.home'] }) + await a.fanout({ ...typedLine, text: 'no at-sign' }, { mentions: [] }) + assert.equal('mentions' in posts(withFeature)[1].args, false, 'an empty list is not sent') + await a.fanout({ ...typedLine, text: 'junk list' }, { mentions: ['not a handle!', 7, '@Chief.Bob.Home', '@chief.bob.home'] }) + assert.deepEqual(posts(withFeature)[2].args.mentions, ['@chief.bob.home'], 'cleaned, lowercased, de-duplicated') + await a.fanout({ authorKind: 'bot', botId: 'a', authorId: 'a', who: 'Chief', text: "bot says @Bob's Chief" }, { mentions: ['@chief.bob.home'] }) + assert.equal('mentions' in posts(withFeature)[3].args, false, 'a Bot\'s line never carries a list') + // A server without it (older): the field is never sent, the line goes out as before. + const older = attestingFaceServer() + const b = await faceFixture(t, older) + await b.fanout(typedLine, { mentions: ['@chief.bob.home'] }) + assert.equal(posts(older)[0].args.typed_by_owner, true) + assert.equal('mentions' in posts(older)[0].args, false) + // A token that may not attest: the legacy envelope, and no list with it. + const refusing = attestingFaceServer({ refuse: true, features: ['typed_by_owner', 'mentions'] }) + const c = await faceFixture(t, refusing) + await c.fanout(typedLine, { mentions: ['@chief.bob.home'] }) + const legacy = posts(refusing).at(-1).args + assert.match(legacy.text, /FLYBOT-FACE-HUMAN/) + assert.equal('mentions' in legacy, false) +}) + +test('mentions (#293): a friend\'s attested line calls my Bots by its handle list first; text is read only without one, and an unattested line never calls', async (t) => { + const server = attestingFaceServer({ features: ['typed_by_owner', 'mentions'] }) + const { call, sources, room } = await faceFixture(t, server) + // Bob's own Chief is in the room, so "@Chief" in the text is ambiguous to me. + server.face.rooms.get('frm_123456').members.push({ bot_id: 'bot_bob', handle: '@chief.bob.home', display_name: 'Chief', owner_name: 'Bob', published: false, label: "Bob's Chief" }) + await call('flybot/social/face-rooms', { poll: true }) + const calledNow = () => sources.get(room.id)?.filter(source => Array.isArray(source?.callIds)) ?? [] + // The list decides, whatever the text says. + pushRoomEvent(server, { typed_by_owner: true, mentions: ['@chief.danyang.home'] }, 'whoever is free, answer please') + // Only Bob's own Bot is named: nobody here answers, though the text names mine. + pushRoomEvent(server, { typed_by_owner: true, mentions: ['@chief.bob.home'] }, "@Danyang's Chief but the list says Bob's") + // A list this room has never heard of falls back to the text. + pushRoomEvent(server, { typed_by_owner: true, mentions: ['@ghost.nowhere'] }, "@Danyang's Chief by label") + // No list at all: the label in the text, as before. + pushRoomEvent(server, { typed_by_owner: true }, "@Danyang's Chief again") + // A list on a line the server does not attest as typed is not honoured. + pushRoomEvent(server, { mentions: ['@chief.danyang.home'] }, 'a Bot trying to call mine') + // Malformed lists are dropped. + pushRoomEvent(server, { typed_by_owner: true, mentions: 'chief.danyang.home' }, 'not an array') + await call('flybot/social/face-rooms', { poll: true }) + await settle() + assert.deepEqual(calledNow().map(source => source.callIds), [['a'], ['a'], ['a']]) + assert.deepEqual(calledNow().map(source => source.human?.name), ['Bob', 'Bob', 'Bob']) +}) + +test('mentions (#293): a server without attestation never lets a mention list call my Bots', async (t) => { + const server = attestingFaceServer({ features: [] }) + const { call, sources, room } = await faceFixture(t, server) + pushRoomEvent(server, { typed_by_owner: true, mentions: ['@chief.danyang.home'] }, 'please answer') + await call('flybot/social/face-rooms', { poll: true }) + await settle() + assert.equal((sources.get(room.id) ?? []).some(source => Array.isArray(source?.callIds)), false) +}) + test('typed_by_owner (face room): a token that may not attest falls back to the legacy envelope', async (t) => { const server = attestingFaceServer({ refuse: true }) const { fanout } = await faceFixture(t, server)