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/293-face-mention-list.changed.md
Original file line number Diff line number Diff line change
@@ -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)
37 changes: 33 additions & 4 deletions core/src/host/face-mentions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 ?? []) {
Expand All @@ -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 }
}
28 changes: 21 additions & 7 deletions core/src/host/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -925,23 +925,37 @@ 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)
if (!room || typeof rt.postToRoom !== 'function') return
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 ("<owner>'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
// ("<owner>'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 } = {}) => {
Expand Down
24 changes: 23 additions & 1 deletion core/test/face-mentions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 })
})
55 changes: 55 additions & 0 deletions core/test/flybot.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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])
})
2 changes: 1 addition & 1 deletion docs/subsystems/core-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<owner>'s Chief` / `<name> · <owner>'s Bot`, `<name> · <owner>`, 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 `<owner>'s Chief` / `<name> · <owner>'s Bot`, `<name> · <owner>`, 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. |

Expand Down
Loading
Loading