From 71c86e6b7a222934c955ad0ef017f5cf83028d03 Mon Sep 17 00:00:00 2001 From: Birfy <15378933+Birfy@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:27:01 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(social):=20show=20'Name=20is=20thinkin?= =?UTF-8?q?g=E2=80=A6'=20for=20friends'=20Bots=20in=20DMs=20and=20face=20r?= =?UTF-8?q?ooms=20(#297)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bots announce their turns through Bot Social's ephemeral presence signal; the open friend chat or face room polls it and draws one quiet dock line. Gated on the server's 'presence' feature, so an older Bot Social changes nothing. Fixes #297 --- changelog.d/297-peer-thinking.added.md | 1 + docs/subsystems/social.md | 10 +- plugins/social/README.md | 4 + plugins/social/client.js | 59 ++++++ plugins/social/index.js | 1 + plugins/social/src/presence.js | 184 +++++++++++++++++ plugins/social/src/social.js | 32 ++- plugins/social/test/presence.test.js | 262 +++++++++++++++++++++++++ plugins/social/test/social.test.js | 75 +++++++ 9 files changed, 626 insertions(+), 2 deletions(-) create mode 100644 changelog.d/297-peer-thinking.added.md create mode 100644 plugins/social/src/presence.js create mode 100644 plugins/social/test/presence.test.js diff --git a/changelog.d/297-peer-thinking.added.md b/changelog.d/297-peer-thinking.added.md new file mode 100644 index 000000000..0e1c7df57 --- /dev/null +++ b/changelog.d/297-peer-thinking.added.md @@ -0,0 +1 @@ +A friend's Bot now shows **Name is thinking…** above the composer while it works on an answer, in a direct chat with it and in a face-to-face room for Bots on other homes, and the line ends when its message arrives. It uses Bot Social's new ephemeral `presence` signal (handle, name and state only; never text, never stored) and only between Bots that may already talk; against a Bot Social without it nothing is sent or shown. Needs the Bot Social build that lists `presence` in `GET /v1/platform`. ([#297](https://github.com/ThinkFlowLab/FlyBot/issues/297)) diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 2d1a0be2d..52c19b199 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -31,8 +31,9 @@ Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers | `plugins/social/src/mcp-client.js` | Owner MCP client plus isolated grant/invite REST fallbacks (`listGrants`, `revokeGrant`, `updateBotProfile`, `declineGrant`, invites). | | `plugins/social/src/look.js` | Map Bot Social profile look fields to FlyBot `{ color, avatar }` (optional on older servers). | | `plugins/social/src/face-rooms.js` | Face-to-face REST client (join/list/get/start/leave/messages), error mapping, event rendering, peer-string sanitization. Auto-send is MCP in `social.js`. | +| `plugins/social/src/presence.js` | "Name is thinking…" for friends' Bots (#297): sends `set_presence` for my Bots' turns that answer a friend or run in an open face room (`turn.start` / `turn.end`, heartbeat), polls `list_presence` only while a social chat asks (`social/typing`), clears a line when that Bot's message arrives. Gated on the `presence` feature. | | `plugins/social/src/face-title.js` | Face-to-face room titles (`Face-to-face · Alice, Bob · 482913`). Ships in the plugin so a packaged install can import (#153); core keeps its own copy for hosted rooms. | -| `plugins/social/client.js` | Social strip: **N to approve** badge, Add friend / Face-to-face panels from +, Past rooms; sidebar + Face-to-face group / Add friend (own handle line, per-Bot **Copy invite link**); in-chat Approve / Reject dock, waiting `flybot.room.overview` tile grid (6-digit code, then Leave / Enter chat); Bot settings Invite / Manage access, or Auto-send + Remove this Bot on a friend's Bot. | +| `plugins/social/client.js` | `PeerThinking` dock line (`social-thinking`, order 60: one `bt-shimmer` line `Name is thinking…`). Social strip: **N to approve** badge, Add friend / Face-to-face panels from +, Past rooms; sidebar + Face-to-face group / Add friend (own handle line, per-Bot **Copy invite link**); in-chat Approve / Reject dock, waiting `flybot.room.overview` tile grid (6-digit code, then Leave / Enter chat); Bot settings Invite / Manage access, or Auto-send + Remove this Bot on a friend's Bot. | ## Entry points @@ -116,6 +117,13 @@ pins the list. The authority for the list is Bot Social's README and `docs/FACE_ - **#140:** Auto-send defaults off. Accepting a friend request uses that relationship's peer home for Auto-send, not the first roster entry. The owner sees Approve / Reject in the chat, not “Waiting for the owner's approval.” Composer drafts are labelled You. - **#136 (suffix):** Onboard retries truncate the sanitized handle body so `Name2` / `Name3` still change within the 32-character `homeHandleOf` limit. +### Thinking status for friends' Bots (#297) + +- **Signal.** Bot Social's `presence` feature (`GET /v1/platform` `features`; the same probe as `typed_by_owner` / `mentions`, `serverPresence()`): MCP `set_presence { bot_id, state: thinking|idle, to | room_id }` and `list_presence`. Ephemeral and content-free: handle, display name and state only, TTL 20 s, 30 writes/min per Bot, never stored. The server allows it only between Bots with an active relationship (direct) or members of an open room, and shows an entry only to the other side. Plugin option `presence: false` turns it off here. Against an older server (feature absent, or an unknown tool) nothing is sent, polled or shown. +- **Send (`src/presence.js`).** On the feed's `turn.start`, a turn in a Bot's own chat triggered by a friend's line (`trigger.from.kind === 'remote'`, to that Bot's handle) or a turn of a Bot in an **open** face-to-face room (to that room's server id) says `thinking` for the Bot's Bot Social identity (`social.member.bot_id`), repeats every 10 s (cap 60 beats), and says `idle` at `turn.end`. A turn the user started, a turn in a local group, a Bot with no identity, a read-only host, and a turn that ended while the feature probe ran send nothing. A refused signal (`no_relationship`, `face_room_not_found`, …) stops that turn's heartbeat; `rate_limited` is skipped. +- **Receive.** `social/typing` (host endpoint) is asked every 2 s by the `PeerThinking` dock of an open friend chat or open face room; each ask keeps a 2.5 s `list_presence` poll alive for 15 s, so nothing polls when no social chat is on screen. Expiry is measured on this host's clock from the server's remaining time. A Bot's message (`remote.chats.deliver`, face-room `postRemote`) ends its line at once and the lagging server entry is ignored for 4 s. +- **UI.** One quiet line above the composer (`flybot.session.dock` `social-thinking`, below the approval card): `Coder is thinking…`, `A and B are thinking…`, `A, B and 2 more are thinking…`, in the core `bt-shimmer` style of the activity row. In a direct chat it uses the chat's own Bot name; in a room the participant's name, with ` · Owner` when two share one. It shows only for that chat's Bot or that room, never for a left/closed/candidate room. Permissions are unchanged: the signal neither grants nor bypasses anything. + ### Past rooms, unique titles, poll, and + (#129–#133) - Expired matching (`candidate` whose `expiresAt` has passed) and a peer leaving the last remote seat move the hosted room to **Past rooms** on the next poll (`rememberPastFace`, status `closed` / `left`). The local room stays so **Open** still works; **Leave** is not shown on those cards. Face-room names are `Face-to-face · Alice, Bob · 482913` (people plus the 6-digit code, or `Oct 16 21:40` when there is no code) so two rooms with the same people stay distinct. The literal `Face-to-face room` is not a fallback. diff --git a/plugins/social/README.md b/plugins/social/README.md index f02e1fbc9..c3fba463c 100644 --- a/plugins/social/README.md +++ b/plugins/social/README.md @@ -20,6 +20,10 @@ browser: `client.js`. Design: see "Social design: homes and members" in the root When you leave the last face room or the room closes, the plugin revokes it (`remove_home_member`). A failed join that created a new room-only identity is revoked too. A later person-grant reuses the handle and drops `roomOnly`. **Manage access** on the Bot lists who has it and **Revoke**. +- **Thinking status** (#297): a friend's Bot shows `Name is thinking…` above the composer in its chat and in a + face-to-face room while it works, through Bot Social's ephemeral `presence` signal (`set_presence` / `list_presence`; + handle, name and state only). Sent only for turns that answer a friend or run in an open room; polled only while such a + chat is on screen; nothing happens against a Bot Social that does not list `presence`. Option `presence: false` turns it off. - **Remote roster**: `list_remote_bots` → friends' Bots published into the main Bot list (globe mark). Each row uses the published `avatar` / `color` when the server sends them, else a handle-seeded look. Sidebar section `flybot.sidebar.section#social` keeps **N to approve**, diff --git a/plugins/social/client.js b/plugins/social/client.js index 331d49e6a..cfc16cbde 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -98,6 +98,7 @@ window.__ModuleLoader__.load({ .fs-status[data-tone=error]{color:#e5484d} .fs-pending-tag{background:var(--bt-accent,#4d6bfe);color:#fff} .fs-refresh{flex:none;font-size:11px;line-height:14px;color:var(--bt-ink-3,rgba(20,20,20,.36));font-weight:400} +.fs-thinking{margin:0 12px 6px;padding-inline-start:8px;font-size:13px;line-height:20px;min-height:20px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .fs-room-end{margin:6px 12px;padding:8px 10px;border-radius:8px;background:var(--bt-hover,rgba(119,119,119,.09));color:var(--bt-ink-2,rgba(20,20,20,.6));font-size:12px;line-height:16px} /* A left or closed face room is read-only (#197): its end line replaces the composer. */ [class*="_composerStack"]:has([data-flybot="face-room-end"]) [class*="_root"]:has(> [data-composer-card]){display:none!important} @@ -964,6 +965,60 @@ window.__ModuleLoader__.load({ return text ? h('div', { className: 'fs-room-end', 'data-flybot': 'face-room-end', role: 'status' }, text) : null } + + // "Name is thinking…" for friends' Bots (#297): one quiet line above the composer of a friend's chat or an + // open face room. The host polls Bot Social only while this is mounted (`social/typing`); an older + // server has no signal, so the line never shows and nothing else changes. + function thinkingLine(names) { + const list = names.filter(Boolean) + if (list.length === 0) return '' + if (list.length === 1) return `${list[0]} is thinking…` + if (list.length === 2) return `${list[0]} and ${list[1]} are thinking…` + return `${list[0]}, ${list[1]} and ${list.length - 2} more are thinking…` + } + // Which entries belong to this chat, and what to call them: the chat's own name for a friend's Bot, the + // room's participant name (with the owner when two share a name) in a face room. + function thinkingNames(items, { room, bot } = {}) { + const handle = typeof bot?.remote?.handle === 'string' ? bot.remote.handle.trim().toLowerCase() : '' + const faceId = room?.origin === 'face-to-face' && room.face?.status === 'open' ? room.face.roomId : '' + const seen = new Set() + const out = [] + for (const item of Array.isArray(items) ? items : []) { + const mine = faceId + ? item.scope === 'face_room' && item.roomId === faceId + : handle !== '' && item.scope === 'direct' && String(item.handle).toLowerCase() === handle + if (!mine || seen.has(item.handle)) continue + seen.add(item.handle) + if (!faceId) { out.push(String(bot.name || item.name || '').trim()); continue } + const people = (room.participants ?? []).filter(p => p.remote) + const own = people.find(p => String(p.handle ?? '').toLowerCase() === String(item.handle).toLowerCase()) + const name = String(own?.displayName || own?.name || item.name || '').trim() + const clash = own && people.filter(p => String(p.displayName || p.name || '').trim() === name).length > 1 + out.push(clash && own.ownerName ? `${name} · ${own.ownerName}` : name) + } + return out + } + function PeerThinking({ room, bot } = {}) { + useStyles() + const [items, setItems] = useState([]) + const watching = Boolean((typeof bot?.remote?.handle === 'string' && bot.remote.handle) || (room?.origin === 'face-to-face' && room.face?.roomId && room.face.status === 'open')) + useEffect(() => { + if (!watching) { setItems([]); return undefined } + let live = true + const load = () => { + if (typeof document !== 'undefined' && document.hidden) return + call('social/typing', {}).then(value => { if (live) setItems(value?.supported === false ? [] : (Array.isArray(value?.typing) ? value.typing : [])) }, () => { if (live) setItems([]) }) + } + load() + if (typeof setInterval !== 'function') return () => { live = false } + const timer = setInterval(load, 2000) + timer?.unref?.() + return () => { live = false; clearInterval(timer) } + }, [watching, bot?.remote?.handle, room?.id, room?.face?.roomId, room?.face?.status]) + const text = thinkingLine(thinkingNames(items, { room, bot })) + return text ? h('div', { className: 'fs-thinking', 'data-flybot': 'peer-thinking', role: 'status', 'aria-live': 'polite' }, h('span', { className: 'bt-shimmer' }, text)) : null + } + function ChatApprovalDock({ sessionId, roster, room, bot, actions, claim } = {}) { useStyles() const [items, setItems] = useState([]) @@ -1611,6 +1666,9 @@ window.__ModuleLoader__.load({ friendLabel, MyHandle, FaceRoomStatus, + PeerThinking, + thinkingLine, + thinkingNames, FriendBotRow, FriendChatSettings, FaceToFaceGroup, @@ -1652,6 +1710,7 @@ window.__ModuleLoader__.load({ slots().register('flybot.room.overview', { id: 'social-face', order: 50, component: FaceRoomOverview }), slots().register('flybot.session.dock', { id: 'social-room-end', order: 40, component: FaceRoomStatus }), slots().register('flybot.session.dock', { id: 'social-approval', order: 50, policy: 'social', component: ChatApprovalDock }), + slots().register('flybot.session.dock', { id: 'social-thinking', order: 60, component: PeerThinking }), ] // For other plugins (the tree's per-node "Copy invite link"): ctx.flybot.social.canInvite(botId) // is synchronous; flybot.call('social.createInvite', { botId }) returns { link, text, ... }. diff --git a/plugins/social/index.js b/plugins/social/index.js index 0b5ed455d..f674e730a 100644 --- a/plugins/social/index.js +++ b/plugins/social/index.js @@ -11,6 +11,7 @@ // approval { default: 'hold' | 'auto', homes: { '@bob.home': 'auto' } } // inboxPollMs poll members' inboxes this often while idle; 0 = off (default 15000) // facePollMs faster poll while a live face-to-face room is open (default 4000) +// presence false turns off "Name is thinking…" for friends' Bots (Bot Social presence signal, #297; default on) // token / botId legacy per-bot identity (no home): owner connector token + bot id import { join } from 'node:path' import { registerUnavailable, startFailureReason, startSocial } from './src/social.js' diff --git a/plugins/social/src/presence.js b/plugins/social/src/presence.js new file mode 100644 index 000000000..66302c766 --- /dev/null +++ b/plugins/social/src/presence.js @@ -0,0 +1,184 @@ +// "Thinking" status for other people's Bots (#297). Bot Social's presence signal is ephemeral and +// content-free: `set_presence { state, to | room_id }` says one of my Bots is working toward a friend's +// Bot or a face room, `list_presence` says who is working toward me. Both need the server to list the +// `presence` feature (GET /v1/platform); against an older server nothing is sent or polled. +// +// Send: a local turn that answers a friend (`turn.start` whose trigger is a friend's line) or that runs in +// a face room announces `thinking` and repeats it while the turn runs; `turn.end` says `idle`. +// Receive: the host polls only while a social chat is on screen asking for it (`touch`), keeps the answer in +// memory and clears an entry when that Bot's message arrives. Text never travels: handle, name and state. + +export const BEAT_MS = 10_000 +export const POLL_MS = 2_500 +export const WANTED_MS = 15_000 +export const MAX_BEATS = 60 +// A message from a Bot ends its thinking line; the server may still list it until the turn's `idle` lands. +export const AFTER_MESSAGE_MS = 4_000 +const RECHECK_MS = 10 * 60_000 + +export function createPresence({ + flybot, api, supported, memberId, normHandle, cleanName, + now = Date.now, every = globalThis.setInterval, clear = globalThis.clearInterval, warn = () => {}, +} = {}) { + const sending = new Map() + const disposers = [] + let entries = [] + let wantedUntil = 0 + let pollTimer + let polling + let offUntil = 0 + const quiet = new Map() + + const unknown = error => /unknown tool|no tool|method not found|unrecognized tool|tool .* not found/i.test(`${error?.code ?? ''} ${error?.message ?? error ?? ''}`) || error?.code === 'unknown' + const gone = error => /no_relationship|face_room_not_found|room_not_started|room_closed|forbidden|bot_not_found|bot_frozen/.test(`${error?.code ?? ''} ${error?.message ?? ''}`) + + // ------------------------------------------------------------ send + function targetOf(event) { + if (event?.place === 'chat' && event.trigger?.from?.kind === 'remote') { + const handle = normHandle(event.trigger.from.handle) + return handle ? { to: handle } : undefined + } + if (event?.place === 'group' && event.roomId) { + const room = flybot.rooms?.list?.().find(item => item.id === event.roomId) + if (room?.origin === 'face-to-face' && room.face?.roomId && room.face.status === 'open') return { room_id: room.face.roomId } + } + return undefined + } + + async function beat(entry, state) { + try { + await api.callTool('set_presence', { bot_id: entry.botId, state, ...entry.target }) + } catch (error) { + if (unknown(error)) { offUntil = now() + RECHECK_MS; stop(entry, false); return } + // A refused signal (no longer friends, left the room) or a rate limit is not worth retrying. + if (gone(error) || error?.code === 'rate_limited') { if (gone(error)) stop(entry, false); return } + warn('presence not sent', error) + } + } + + function stop(entry, say = true) { + if (entry.timer) clear(entry.timer) + entry.timer = undefined + if (sending.get(entry.turnId) === entry) sending.delete(entry.turnId) + // `idle` only follows a `thinking` that went out. + if (say && !entry.closed && entry.beats > 0) { entry.closed = true; void beat(entry, 'idle') } + entry.closed = true + } + + async function onTurnStart(event) { + if (flybot.readOnly?.() || !event?.turnId || sending.has(event.turnId)) return + const target = targetOf(event) + const botId = event.botId ? memberId(event.botId) : undefined + if (!target || !botId || now() < offUntil) return + const entry = { turnId: event.turnId, botId, target, beats: 0, closed: false, timer: undefined } + sending.set(event.turnId, entry) + let ok = false + try { ok = await supported() } catch { ok = false } + // The turn may have ended while the probe ran. + if (!ok || entry.closed || sending.get(event.turnId) !== entry) { sending.delete(event.turnId); return } + const send = () => { + if (entry.closed) return + if (++entry.beats > MAX_BEATS) { stop(entry); return } + void beat(entry, 'thinking') + } + send() + entry.timer = every(send, BEAT_MS) + entry.timer?.unref?.() + } + + function onTurnEnd(event) { + const entry = sending.get(event?.turnId) + if (entry) stop(entry) + } + + if (typeof flybot.on === 'function') { + disposers.push(flybot.on('turn.start', event => { void onTurnStart(event).catch(error => warn('presence start failed', error)) })) + disposers.push(flybot.on('turn.end', onTurnEnd)) + } + + // ------------------------------------------------------------ receive + async function pollOnce() { + if (polling) return polling + polling = (async () => { + try { + if (flybot.readOnly?.() || now() < offUntil || !(await supported())) { entries = []; return } + const answer = await api.callTool('list_presence', {}) + const base = Date.parse(answer?.server_time) + const at = now() + const next = [] + for (const item of Array.isArray(answer?.presence) ? answer.presence : []) { + const handle = normHandle(item?.bot?.handle) + if (!handle || item.state !== 'thinking') continue + const left = Date.parse(item.expires_at) - (Number.isFinite(base) ? base : at) + // Remaining time on this host's clock, so a skewed server clock does not matter. + const until = at + Math.max(0, Math.min(Number.isFinite(left) ? left : 20_000, 60_000)) + next.push({ scope: item.scope === 'face_room' ? 'face_room' : 'direct', handle, name: cleanName(item.bot?.display_name, 'Bot'), ...(item.room_id ? { roomId: String(item.room_id) } : {}), until }) + } + entries = next + } catch (error) { + entries = [] + if (unknown(error)) offUntil = now() + RECHECK_MS + else warn('presence poll failed', error) + } finally { + polling = undefined + } + })() + return polling + } + + function sweep() { + const at = now() + entries = entries.filter(item => item.until > at) + for (const [key, until] of quiet) if (until <= at) quiet.delete(key) + } + const keyOf = (handle, roomId) => `${roomId ?? ''}|${handle}` + + function arm() { + if (pollTimer) return + pollTimer = every(() => { + if (now() >= wantedUntil) { clear(pollTimer); pollTimer = undefined; entries = []; return } + void pollOnce() + }, POLL_MS) + pollTimer?.unref?.() + } + + // The chat on screen asks; polling goes on for WANTED_MS after the last ask. + function touch() { + wantedUntil = now() + WANTED_MS + if (!pollTimer) { arm(); void pollOnce() } + sweep() + return view() + } + + function view() { + const at = now() + return { + supported: now() >= offUntil, + typing: entries + .filter(item => item.until > at && !((quiet.get(keyOf(item.handle, item.roomId)) ?? 0) > at)) + .map(item => ({ scope: item.scope, handle: item.handle, name: item.name, ...(item.roomId ? { roomId: item.roomId } : {}), expiresIn: item.until - at })), + } + } + + // That Bot's message arrived: its thinking line ends now. + function arrived({ handle, roomId } = {}) { + const key = handle ? normHandle(handle) : '' + if (!key) return + entries = entries.filter(item => !(item.handle === key && item.roomId === roomId)) + quiet.set(keyOf(key, roomId), now() + AFTER_MESSAGE_MS) + } + + return { + touch, + arrived, + view, + pollOnce, + sending: () => sending.size, + dispose() { + for (const off of disposers.splice(0)) off?.() + for (const entry of [...sending.values()]) stop(entry, false) + if (pollTimer) clear(pollTimer) + pollTimer = undefined + }, + } +} diff --git a/plugins/social/src/social.js b/plugins/social/src/social.js index 50144b285..5c00aee85 100644 --- a/plugins/social/src/social.js +++ b/plugins/social/src/social.js @@ -32,6 +32,7 @@ import { tmpdir } from 'node:os' import { basename, dirname, extname, isAbsolute, join, sep } from 'node:path' import { createSocialApi, untrustedText, mapPool, GRANT_CONCURRENCY, grantPersonView, memberHandleOf, personHomeOf } from './mcp-client.js' import { lookFromServer, lookToServer, lookFingerprint } from './look.js' +import { createPresence } from './presence.js' import { createFaceRoomApi, isJoinCode, @@ -45,6 +46,7 @@ import { faceMemberLabel, viewMe, faceOutbound, + cleanFaceName, platformJoinText, humanFaceWho, faceRoomTitle, @@ -1171,7 +1173,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, mentions: false, probedAt: 0, probeFailed: false, probing: undefined } + const typed = { supported: false, forbidden: false, mentions: false, presence: 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. @@ -1190,6 +1192,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp typed.probeFailed = features === undefined if (features?.includes('typed_by_owner')) typed.supported = true if (features?.includes('mentions')) typed.mentions = true + if (features?.includes('presence')) typed.presence = true return typed.supported } finally { typed.probing = undefined @@ -1205,6 +1208,15 @@ export function createSocial(service, config = {}, { client: given, api: givenAp else if (!typed.probedAt || now() - typed.probedAt >= (typed.probeFailed ? TYPED_RETRY_MS : TYPED_RECHECK_MS)) await (typed.probing = probeTypedSupport()) return typed.mentions } + // Whether this server carries the presence signal (Bot Social `presence`, #297): the same feature probe. + // An older server, or one that has not answered, sends and polls nothing. + async function serverPresence() { + if (config.presence === false || !faceApi?.features) return false + if (typed.presence) return true + 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.presence + } 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 @@ -1255,6 +1267,22 @@ export function createSocial(service, config = {}, { client: given, api: givenAp } if (flybot.remote?.chats) disposers.push(flybot.remote.chats.onSend(sendChat)) + // "Name is thinking…" for friends' Bots (#297): my Bots announce their turns, the chat on screen asks for theirs. + const presence = createPresence({ + flybot, + api: client, + supported: serverPresence, + memberId: botId => memberOf(botId)?.bot_id, + normHandle: norm, + cleanName: (value, fallback) => cleanFaceName(value, fallback), + now, + every, + clear, + warn, + }) + disposers.push(() => presence.dispose()) + disposers.push(flybot.api.handle('social/typing', async () => presence.touch())) + // ------------------------------------------------------------ inbound // Cursors and delivered event ids are saved with the settings, so a restart neither // replays nor skips. The server cursor is only peeked: ours advances one event at a @@ -1329,6 +1357,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp // The user's own chat with this friend's Bot (it went out from this Bot's identity): // the answer lands in that chat, written back as the friend's Bot's reply. if (settings.via[handle] === local.id && flybot.remote?.chats) { + presence.arrived({ handle }) await flybot.remote.chats.deliver({ handle, home: fromHome, name: cleanText(event.from?.display_name, handle), text, ...(typedBy ? { human: true, ownerName: typedBy } : {}) }) delivered += 1 done() @@ -2235,6 +2264,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp } else { const local = locals.some(bot => bot.handle && bot.handle === line.authorId) if (local) continue + presence.arrived({ handle: line.authorId, roomId: snapshot.roomId }) void flybot.rooms.postRemote?.(room.id, { handle: line.authorId, name: line.who, text: line.text }) } if (event.eventCursor) settings.faceRooms[snapshot.roomId].cursor = Math.max(settings.faceRooms[snapshot.roomId].cursor, event.eventCursor) diff --git a/plugins/social/test/presence.test.js b/plugins/social/test/presence.test.js new file mode 100644 index 000000000..5e00468f8 --- /dev/null +++ b/plugins/social/test/presence.test.js @@ -0,0 +1,262 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { createPresence, BEAT_MS, POLL_MS, WANTED_MS, MAX_BEATS, AFTER_MESSAGE_MS } from '../src/presence.js' + +const norm = handle => { const body = String(handle ?? '').trim().replace(/^@+/, '').toLowerCase(); return body ? `@${body}` : '' } +const cleanName = (value, fallback) => String(value ?? '').replace(/[<>@]/g, '').trim() || fallback + +// A clock and timers the test steers. +function clock() { + let at = 1_000_000 + const timers = new Set() + return { + now: () => at, + every: (fn, ms) => { const timer = { fn, ms, next: at + ms, unref() {} }; timers.add(timer); return timer }, + clear: timer => { timers.delete(timer) }, + count: () => timers.size, + async advance(ms) { + const end = at + ms + for (;;) { + const due = [...timers].filter(timer => timer.next <= end).sort((a, b) => a.next - b.next)[0] + if (!due) break + at = due.next + due.next += due.ms + due.fn() + await new Promise(resolve => setImmediate(resolve)) + } + at = end + await new Promise(resolve => setImmediate(resolve)) + }, + } +} + +function fixture({ supported = true, members = { a: 'm_a', b: 'm_b' }, rooms = [], readOnly = false, tools = {} } = {}) { + const c = clock() + const handlers = new Map() + const calls = [] + const flybot = { + readOnly: () => readOnly, + rooms: { list: () => rooms }, + on: (name, fn) => { handlers.set(name, fn); return () => handlers.delete(name) }, + } + const state = { supported, server: [], serverTime: new Date(c.now()).toISOString() } + const api = { + callTool: async (name, args) => { + calls.push({ name, args }) + if (tools[name]) return tools[name](args) + if (name === 'list_presence') return { presence: state.server, server_time: new Date(c.now()).toISOString() } + return { ok: true } + }, + } + const presence = createPresence({ flybot, api, supported: async () => state.supported, memberId: id => members[id], normHandle: norm, cleanName, now: c.now, every: c.every, clear: c.clear }) + const start = (event) => handlers.get('turn.start')?.(event) + const end = (event) => handlers.get('turn.end')?.(event) + const sent = () => calls.filter(call => call.name === 'set_presence').map(call => call.args) + return { c, presence, calls, state, start, end, sent, handlers } +} +const settle = () => new Promise(resolve => setImmediate(resolve)) +const friendTurn = (extra = {}) => ({ place: 'chat', botId: 'a', sessionId: 's', turnId: 's#1', turn: 1, trigger: { exchangeId: 'x', from: { kind: 'remote', handle: '@Coder.Bob.home' }, role: 'remote', hop: 0 }, ...extra }) + +test('send: a turn that answers a friend says thinking toward that Bot, repeats while it runs, and says idle when it ends', async (t) => { + const f = fixture() + t.after(() => f.presence.dispose()) + f.start(friendTurn()) + await settle() + assert.deepEqual(f.sent(), [{ bot_id: 'm_a', state: 'thinking', to: '@coder.bob.home' }]) + await f.c.advance(BEAT_MS * 2 + 10) + assert.equal(f.sent().length, 3) + f.end({ turnId: 's#1' }) + await settle() + assert.deepEqual(f.sent().at(-1), { bot_id: 'm_a', state: 'idle', to: '@coder.bob.home' }) + assert.equal(f.c.count(), 0, 'the heartbeat stops with the turn') + await f.c.advance(BEAT_MS * 3) + assert.equal(f.sent().length, 4) + assert.equal(f.presence.sending(), 0) +}) + +test('send: nothing leaves for a turn the user started, an unknown Bot, a Bot without an identity, or a turn in a local group', async (t) => { + const f = fixture({ rooms: [{ id: 'g1', origin: 'local' }] }) + t.after(() => f.presence.dispose()) + f.start(friendTurn({ turnId: 'u#1', trigger: { exchangeId: 'u', from: { kind: 'user' }, role: 'user', hop: 0 } })) + f.start(friendTurn({ turnId: 'n#1', trigger: undefined })) + f.start(friendTurn({ turnId: 'z#1', botId: 'zzz' })) + f.start({ place: 'group', botId: 'a', roomId: 'g1', turnId: 'g#1', trigger: { from: { kind: 'room', roomId: 'g1' } } }) + f.start({ place: 'remote', botId: 'a', turnId: 'r#1', remote: { handle: '@x.home' } }) + await settle() + assert.deepEqual(f.sent(), []) +}) + +test('send: a face room turn is announced to that room, only while the room is open', async (t) => { + const open = { id: 'g1', origin: 'face-to-face', face: { roomId: 'frm_1', status: 'open' } } + const f = fixture({ rooms: [open, { id: 'g2', origin: 'face-to-face', face: { roomId: 'frm_2', status: 'candidate' } }, { id: 'g3', origin: 'face-to-face', face: { roomId: 'frm_3', status: 'left' } }] }) + t.after(() => f.presence.dispose()) + f.start({ place: 'group', botId: 'a', roomId: 'g1', turnId: 'g1#1' }) + f.start({ place: 'group', botId: 'b', roomId: 'g1', turnId: 'g1b#1' }) + f.start({ place: 'group', botId: 'a', roomId: 'g2', turnId: 'g2#1' }) + f.start({ place: 'group', botId: 'a', roomId: 'g3', turnId: 'g3#1' }) + await settle() + assert.deepEqual(f.sent(), [{ bot_id: 'm_a', state: 'thinking', room_id: 'frm_1' }, { bot_id: 'm_b', state: 'thinking', room_id: 'frm_1' }]) + f.end({ turnId: 'g1#1' }) + await settle() + assert.deepEqual(f.sent().at(-1), { bot_id: 'm_a', state: 'idle', room_id: 'frm_1' }) + assert.equal(f.presence.sending(), 1) +}) + +test('send: an older server (no presence feature) or a read-only host sends nothing', async (t) => { + const old = fixture({ supported: false }) + t.after(() => old.presence.dispose()) + old.start(friendTurn()) + await settle() + assert.deepEqual(old.sent(), []) + assert.equal(old.c.count(), 0) + const ro = fixture({ readOnly: true }) + t.after(() => ro.presence.dispose()) + ro.start(friendTurn()) + await settle() + assert.deepEqual(ro.sent(), []) +}) + +test('send: a late probe does not announce a turn that already ended', async (t) => { + const c = clock() + const handlers = {} + const calls = [] + let release + const presence = createPresence({ + flybot: { rooms: { list: () => [] }, on: (name, fn) => { handlers[name] = fn; return () => {} } }, + api: { callTool: async (name, args) => { calls.push({ name, args }); return {} } }, + supported: () => new Promise(resolve => { release = resolve }), + memberId: () => 'm_a', normHandle: norm, cleanName, now: c.now, every: c.every, clear: c.clear, + }) + t.after(() => presence.dispose()) + handlers['turn.start'](friendTurn()) + await settle() + handlers['turn.end']({ turnId: 's#1' }) + release(true) + await settle() + assert.deepEqual(calls, []) + assert.equal(c.count(), 0) +}) + +test('send: a refused signal (no longer friends, left the room) stops the heartbeat; a rate limit does not end the turn; a runaway turn stops after a cap', async (t) => { + const refused = fixture({ tools: { set_presence: () => { throw Object.assign(new Error('Bot Social set_presence: no active relationship'), { code: 'no_relationship' }) } } }) + t.after(() => refused.presence.dispose()) + refused.start(friendTurn()) + await settle() + assert.equal(refused.c.count(), 0) + assert.equal(refused.sent().length, 1) + + let limited = 0 + const busy = fixture({ tools: { set_presence: () => { limited += 1; throw Object.assign(new Error('rate'), { code: 'rate_limited' }) } } }) + t.after(() => busy.presence.dispose()) + busy.start(friendTurn()) + await settle() + await busy.c.advance(BEAT_MS * 2 + 5) + assert.equal(limited, 3) + + const runaway = fixture() + t.after(() => runaway.presence.dispose()) + runaway.start(friendTurn()) + await settle() + await runaway.c.advance(BEAT_MS * (MAX_BEATS + 5)) + assert.equal(runaway.sent().filter(entry => entry.state === 'thinking').length, MAX_BEATS) + assert.equal(runaway.sent().at(-1).state, 'idle') +}) + +test('send: an unknown tool (an old server that still passed the probe) switches the signal off for a while', async (t) => { + const f = fixture({ tools: { set_presence: () => { throw Object.assign(new Error('Bot Social set_presence: Tool set_presence not found'), { code: 'rpc' }) } } }) + t.after(() => f.presence.dispose()) + f.start(friendTurn()) + await settle() + f.start(friendTurn({ turnId: 's#2' })) + await settle() + assert.equal(f.sent().length, 1) + assert.equal(f.presence.view().supported, false) +}) + +test('receive: nothing is polled until a chat asks; asking polls and answers with who is thinking and for how long', async (t) => { + const f = fixture() + t.after(() => f.presence.dispose()) + await f.c.advance(POLL_MS * 4) + assert.equal(f.calls.length, 0) + f.state.server = [ + { scope: 'direct', bot: { handle: '@Coder.bob.home', display_name: 'Coder' }, to: '@chief.danyang.home', state: 'thinking', expires_at: new Date(f.c.now() + 12_000).toISOString() }, + { scope: 'face_room', room_id: 'frm_1', bot: { handle: '@chief.carol.home', display_name: 'Chief ' }, state: 'thinking', expires_at: new Date(f.c.now() + 5_000).toISOString() }, + { scope: 'direct', bot: { handle: '@gone.home', display_name: 'Gone' }, state: 'idle', expires_at: new Date(f.c.now() + 5_000).toISOString() }, + ] + f.presence.touch() + await settle() + assert.deepEqual(f.presence.view().typing.map(item => [item.scope, item.handle, item.name, item.roomId, item.expiresIn]), [ + ['direct', '@coder.bob.home', 'Coder', undefined, 12_000], + ['face_room', '@chief.carol.home', 'Chief b', 'frm_1', 5_000], + ]) + // Entries end on their own (server TTL), judged on this host's clock. + await f.c.advance(6_000) + f.state.server = f.state.server.slice(0, 1) + f.presence.touch() + await settle() + assert.deepEqual(f.presence.view().typing.map(item => item.handle), ['@coder.bob.home']) + f.state.server = [] + await f.c.advance(POLL_MS + 5) + assert.deepEqual(f.presence.view().typing, []) +}) + +test('receive: polling goes on only while the chat keeps asking, then stops', async (t) => { + const f = fixture() + t.after(() => f.presence.dispose()) + f.presence.touch() + await settle() + await f.c.advance(POLL_MS * 2 + 5) + const polls = () => f.calls.filter(call => call.name === 'list_presence').length + assert.ok(polls() >= 3) + await f.c.advance(WANTED_MS + POLL_MS * 2) + const after = polls() + await f.c.advance(POLL_MS * 6) + assert.equal(polls(), after) + assert.equal(f.c.count(), 0) +}) + +test('receive: a Bot\'s message ends its line at once and the server\'s lagging entry does not bring it back', async (t) => { + const f = fixture() + t.after(() => f.presence.dispose()) + f.state.server = [ + { scope: 'direct', bot: { handle: '@coder.bob.home', display_name: 'Coder' }, state: 'thinking', expires_at: new Date(f.c.now() + 20_000).toISOString() }, + { scope: 'face_room', room_id: 'frm_1', bot: { handle: '@coder.bob.home', display_name: 'Coder' }, state: 'thinking', expires_at: new Date(f.c.now() + 20_000).toISOString() }, + ] + f.presence.touch() + await settle() + assert.equal(f.presence.view().typing.length, 2) + f.presence.arrived({ handle: '@Coder.bob.home' }) + assert.deepEqual(f.presence.view().typing.map(item => item.scope), ['face_room'], 'only the direct chat line ends') + await f.c.advance(POLL_MS + 5) + assert.deepEqual(f.presence.view().typing.map(item => item.scope), ['face_room']) + f.presence.arrived({ handle: '@coder.bob.home', roomId: 'frm_1' }) + assert.deepEqual(f.presence.view().typing, []) + await f.c.advance(AFTER_MESSAGE_MS + POLL_MS) + assert.equal(f.presence.view().typing.length, 2, 'if it is still thinking after the quiet window, the line returns') +}) + +test('receive: an older server shows nothing and is not polled; an unknown tool switches polling off', async (t) => { + const old = fixture({ supported: false }) + t.after(() => old.presence.dispose()) + old.presence.touch() + await settle() + await old.c.advance(POLL_MS * 3) + assert.equal(old.calls.length, 0) + assert.deepEqual(old.presence.view().typing, []) + + const unknown = fixture({ tools: { list_presence: () => { throw Object.assign(new Error('Bot Social list_presence: unknown tool'), { code: 'unknown' }) } } }) + t.after(() => unknown.presence.dispose()) + unknown.presence.touch() + await settle() + await unknown.c.advance(POLL_MS * 4) + assert.equal(unknown.calls.length, 1) + assert.equal(unknown.presence.view().supported, false) +}) + +test('a read-only host neither sends nor polls', async (t) => { + const f = fixture({ readOnly: true }) + t.after(() => f.presence.dispose()) + f.presence.touch() + await settle() + assert.equal(f.calls.length, 0) +}) diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index b6dfee07d..f836551de 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -4585,6 +4585,81 @@ test('#191: an ended face room says so in plain view and names the person who le assert.equal(walk(plugin.FaceRoomStatus({ room: { origin: 'face-to-face', face: { status: 'left' }, participants: [{ remote: true, ownerName: 'Bob' }] } })).find(n => n.props['data-flybot'] === 'face-room-end').children.join(''), 'You left this room.') }) +test('#297: a friend\'s Bot thinking shows as one quiet line above the composer; nothing for other chats, rooms or no signal', () => { + const { walk, pluginOf } = loadSocialClient(async () => ({})) + const plugin = pluginOf([[]]) + const line = (items, props) => walk(pluginOf([items]).PeerThinking(props)).find(n => n.props?.['data-flybot'] === 'peer-thinking') + const dm = { bot: { id: 'r1', name: "Bob's Coder", remote: { handle: '@Coder.bob.home' } } } + const items = [ + { scope: 'direct', handle: '@coder.bob.home', name: 'Coder' }, + { scope: 'direct', handle: '@other.bob.home', name: 'Other' }, + { scope: 'face_room', handle: '@chief.carol.home', name: 'Chief', roomId: 'frm_1' }, + ] + const shown = line(items, dm) + assert.equal(shown.children.flat().map(n => n.children?.join?.('') ?? n).join(''), "Bob's Coder is thinking…") + assert.equal(shown.props.role, 'status') + assert.equal(walk(shown).find(n => n.props?.className === 'bt-shimmer')?.type, 'span') + assert.equal(line([], dm), undefined) + assert.equal(line(items, { bot: { id: 'r2', name: 'Other chat', remote: { handle: '@nobody.home' } } }), undefined) + assert.equal(line(items, { bot: { id: 'a', name: 'Chief' } }), undefined, 'a local Bot chat has its own activity row') + const room = status => ({ id: 'g1', origin: 'face-to-face', face: { roomId: 'frm_1', status }, participants: [ + { remote: false, displayName: 'Chief' }, + { remote: true, handle: '@chief.carol.home', displayName: 'Chief', ownerName: 'Carol' }, + { remote: true, handle: '@chief.bob.home', displayName: 'Chief', ownerName: 'Bob' }, + ] }) + assert.equal(line(items, { room: room('open') }).children.flat().map(n => n.children?.join?.('') ?? n).join(''), 'Chief · Carol is thinking…', 'two Bots with one name keep their owner') + assert.equal(line(items, { room: room('left') }), undefined) + assert.equal(line(items, { room: room('candidate') }), undefined) + assert.equal(line(items, { room: { ...room('open'), face: { roomId: 'frm_2', status: 'open' } } }), undefined, 'another room\'s Bots never show here') + assert.equal(plugin.thinkingLine([]), '') + assert.equal(plugin.thinkingLine(['A']), 'A is thinking…') + assert.equal(plugin.thinkingLine(['A', 'B']), 'A and B are thinking…') + assert.equal(plugin.thinkingLine(['A', 'B', 'C', 'D']), 'A, B and 2 more are thinking…') +}) + +test('#297: the thinking line is a plain session dock below the approval card, styled like the other quiet notes', () => { + const source = readFileSync(new URL('../client.js', import.meta.url), 'utf8') + assert.match(source, /register\('flybot\.session\.dock', \{ id: 'social-thinking', order: 60, component: PeerThinking \}\)/) + assert.match(source, /\.fs-thinking\{[^}]*font-size:13px/) + assert.match(source, /className: 'bt-shimmer'/) +}) + +test('#297: social/typing answers from Bot Social only when the server lists presence, and a friend\'s message clears the line', async (t) => { + const withPresence = attestingFaceServer({ features: ['typed_by_owner', 'presence'] }) + const stamp = () => new Date().toISOString() + withPresence.tools.list_presence = () => ({ + server_time: stamp(), + presence: [ + { scope: 'face_room', room_id: 'frm_123456', bot: { handle: '@chief.bob.home', display_name: 'Chief' }, state: 'thinking', expires_at: new Date(Date.now() + 20_000).toISOString() }, + { scope: 'direct', bot: { handle: '@coder.bob.home', display_name: 'Coder' }, to: '@chief.danyang.home', state: 'thinking', expires_at: new Date(Date.now() + 20_000).toISOString() }, + ], + }) + const { call } = await faceFixture(t, withPresence) + const first = await call('flybot/social/typing', {}) + assert.equal(first.ok, true) + assert.equal(first.value.supported, true) + await waitUntil(() => withPresence.calls.some(c => c.name === 'list_presence')) + const answer = (await call('flybot/social/typing', {})).value + assert.deepEqual(answer.typing.map(item => [item.scope, item.handle, item.name, item.roomId]).sort(), [ + ['direct', '@coder.bob.home', 'Coder', undefined], + ['face_room', '@chief.bob.home', 'Chief', 'frm_123456'], + ]) + assert.ok(answer.typing.every(item => item.expiresIn > 0 && item.expiresIn <= 20_000)) + assert.deepEqual(Object.keys(answer.typing[0]).sort(), ['expiresIn', 'handle', 'name', 'scope', ...(answer.typing[0].roomId ? ['roomId'] : [])].sort(), 'no text, no owner, only who and where') + // That Bot's room line arrives: its thinking line in that room ends at once. + pushRoomEvent(withPresence, {}, 'here is my answer') + await call('flybot/social/face-rooms', { poll: true }) + const cleared = (await call('flybot/social/typing', {})).value + assert.deepEqual(cleared.typing.map(item => item.scope), ['direct']) + + const older = attestingFaceServer({ features: ['typed_by_owner'] }) + older.tools.list_presence = () => { throw new Error('must not be called') } + const fixture = await faceFixture(t, older) + assert.deepEqual((await fixture.call('flybot/social/typing', {})).value.typing, []) + await settle() + assert.equal(older.calls.some(c => c.name === 'list_presence'), false, 'an older server is never asked') +}) + test('#195: accepting a Bot invite says what was added and closes the panel; the button reads Accept invite', async () => { const calls = [] const { walk, pluginOf } = loadSocialClient(async (endpoint) => { From afee9ab1cd1ed87dea799fed71c36bcd8441dbe8 Mon Sep 17 00:00:00 2001 From: Birfy <15378933+Birfy@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:31:31 +0800 Subject: [PATCH 2/2] test(social): expect the thinking dock in the client registry (#297) --- plugins/social/test/social.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index f836551de..b5cc50a48 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -517,7 +517,7 @@ test('client.js registers a sidebar section and @ rows into the FlyBot registry' runInNewContext(readFileSync(new URL('../client.js', import.meta.url), 'utf8'), sandbox) const plugin = registered[0].factory(() => ({ createElement: () => null, useEffect() {}, useState: v => [v, () => {}] })) plugin.apply({}) - assert.deepEqual(entries.map(([slot, entry]) => `${slot}#${entry.id}`), ['flybot.sidebar.section#social', 'flybot.new-chat.action#social-face-group', 'flybot.new-chat.action#social-add-friend', 'flybot.composer.mention#social', 'flybot.account.header#social-handle', 'flybot.bot.details#social-invite', 'flybot.room.overview#social-face', 'flybot.session.dock#social-room-end', 'flybot.session.dock#social-approval']) + assert.deepEqual(entries.map(([slot, entry]) => `${slot}#${entry.id}`), ['flybot.sidebar.section#social', 'flybot.new-chat.action#social-face-group', 'flybot.new-chat.action#social-add-friend', 'flybot.composer.mention#social', 'flybot.account.header#social-handle', 'flybot.bot.details#social-invite', 'flybot.room.overview#social-face', 'flybot.session.dock#social-room-end', 'flybot.session.dock#social-approval', 'flybot.session.dock#social-thinking']) // Transport errors are worded in English from their code, never the localized host text. assert.match(plugin.errorText(Object.assign(new Error('请求失败(HTTP 404)'), { code: 'HTTP_404' })), /^The FlyBot host did not answer \(HTTP 404\)/) assert.equal(plugin.errorText(Object.assign(new Error('x'), { code: 'bot/refused' })), 'x')