diff --git a/changelog.d/face-room-auto-send-avatars.fixed.md b/changelog.d/face-room-auto-send-avatars.fixed.md new file mode 100644 index 00000000..c110bf38 --- /dev/null +++ b/changelog.d/face-room-auto-send-avatars.fixed.md @@ -0,0 +1 @@ +**Face-to-face Auto-send and friend avatars.** Auto-send of room messages is read and saved through Bot Social MCP (`whoami` / `set_auto_approve` with the owner's click as consent), so a connector token no longer hits REST `/v1/me` (403). Face-room join/list/messages stay on the REST routes that allow that token. A friend's Bot in the room member list, message headers, waiting grid, and Friends' Bots uses its chat look or a stable handle-seeded avatar — never the shared letter badge. Bot Social does not store profile images today; `avatar` on a member payload is used when present. diff --git a/core/client.js b/core/client.js index b2e321e9..ba26aba0 100644 --- a/core/client.js +++ b/core/client.js @@ -1500,6 +1500,17 @@ function defaultShape(id) { } var remoteSeed = (handle) => `remote:${String(handle ?? "").trim().replace(/^@+/, "").toLowerCase()}`; var lookSeed = (bot) => typeof bot?.remote?.handle === "string" && bot.remote.handle.trim() !== "" ? remoteSeed(bot.remote.handle) : bot?.id !== void 0 ? String(bot.id) : void 0; +function lookBotForRemote(person, roster) { + const handle = String(person?.handle || person?.remote?.handle || "").trim(); + const chat = handle ? (roster?.bots ?? []).find((bot) => bot.remote?.handle === handle) : void 0; + if (chat) return chat; + const extra = {}; + if (typeof person?.color === "string") extra.color = person.color; + if (person?.avatar && typeof person.avatar === "object") extra.avatar = person.avatar; + const name = person?.label || person?.displayName || person?.name || handle || "Bot"; + if (handle) return { id: remoteSeed(handle), name, remote: { handle }, ...extra }; + return { id: person?.id || "remote", name, ...extra }; +} var colorOf = (bot) => isColor(bot?.color) ? bot.color : lookSeed(bot) !== void 0 ? defaultColor(lookSeed(bot)) : "black"; function lookOf(bot) { const avatar = bot?.avatar ?? {}; @@ -4570,7 +4581,12 @@ function remoteSpeaker(source, roster) { handle, isChief: member?.isChief ?? chat?.remote?.isChief }); - const bot = chat ?? { id: remoteSeed(handle), name, color: member?.color, shape: member?.shape }; + const bot = chat ?? lookBotForRemote({ + handle, + displayName: name, + color: member?.color, + avatar: member?.avatar ?? (member?.shape ? { shape: member.shape } : void 0) + }, roster); return { bot, member, name, handle, chatId: chat ? currentPart(chat) : void 0 }; } function RemoteMessage({ source, text, roster, actions, sessionId, msgKey, useUi }) { @@ -9816,6 +9832,7 @@ function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) { (0, import_react24.createElement)( "span", { className: "bt-gs-who" }, + (0, import_react24.createElement)(BotAvatar, { bot: lookBotForRemote(person, roster), size: 26, badge: false, live: false }), (0, import_react24.createElement)("span", { className: "bt-row-title" }, person.label || remoteBotTitle(person)), room2.origin === "face-to-face" ? null : (0, import_react24.createElement)("span", { className: "bt-tag" }, t("Friend's Bot")) ), @@ -9847,6 +9864,7 @@ function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) { setAdding(false); void update({ addParticipants: [{ kind: "bot", remote: true, handle: bot.handle }] }); } }, + (0, import_react24.createElement)(BotAvatar, { bot: lookBotForRemote({ handle: bot.handle, displayName: bot.name, avatar: bot.avatar, color: bot.color }, roster), size: 22, badge: false, live: false }), (0, import_react24.createElement)("span", { className: "bt-set-copy" }, (0, import_react24.createElement)("span", { className: "bt-bs-label" }, remoteBotTitle({ displayName: bot.name, ownerName: bot.homeName || bot.owner, handle: bot.handle, isChief: bot.isChief }))), (0, import_react24.createElement)("span", { className: "bt-set-hint" }, t("Add friend's Bot")) )), @@ -12301,7 +12319,12 @@ function GroupOverview({ room: room2, roster, actions, usage, useWork, openSetti (0, import_react32.createElement)( "div", { className: "bt-member" }, - (0, import_react32.createElement)("span", { className: "bt-member-name" }, (0, import_react32.createElement)("span", null, person.label || remoteBotTitle(person))), + (0, import_react32.createElement)( + "span", + { className: "bt-member-name" }, + (0, import_react32.createElement)(BotAvatar, { bot: lookBotForRemote(person, roster), size: 26, badge: false, live: false }), + (0, import_react32.createElement)("span", null, person.label || remoteBotTitle(person)) + ), face ? null : (0, import_react32.createElement)("span", { className: "bt-chip" }, t("Friend's Bot")) ) )), diff --git a/core/src/client/cells.js b/core/src/client/cells.js index a9784b6f..808beb18 100644 --- a/core/src/client/cells.js +++ b/core/src/client/cells.js @@ -4,7 +4,7 @@ import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' import { inkText } from './inks.js' import { currentPart, linkMentions, mentionTarget, dayLabel, contentText, stripHeader, stripTrace, isHiddenTurn, isRelayContext, relaysBefore, relayPart, commRunOf, toolName, toolArgs, reminderPrompts } from './text.js' import { SmileIcon, ReplyIcon, DotsIcon, CheckIcon, DownloadIcon, CopyIcon, MemoryIcon } from './icons.js' -import { colorOf, remoteSeed, toolState, BotMark, RoomAvatar } from './mark.js' +import { colorOf, lookBotForRemote, toolState, BotMark, RoomAvatar } from './mark.js' import { AnsweredCell, SettledQuestion, PendingQuestion } from './questions.js' import { SecretToolCell } from './secret-card.js' import { dateLocale, t } from './i18n.js' @@ -201,7 +201,10 @@ export function remoteSpeaker(source, roster) { }) // The sidebar's Bot record when there is one; else a synthetic one whose id seeds the // same handle-derived look the record would get (characters.js remoteSeed). - const bot = chat ?? { id: remoteSeed(handle), name, color: member?.color, shape: member?.shape } + const bot = chat ?? lookBotForRemote({ + handle, displayName: name, color: member?.color, + avatar: member?.avatar ?? (member?.shape ? { shape: member.shape } : undefined), + }, roster) return { bot, member, name, handle, chatId: chat ? currentPart(chat) : undefined } } diff --git a/core/src/client/characters.js b/core/src/client/characters.js index 57039663..5ff2b567 100644 --- a/core/src/client/characters.js +++ b/core/src/client/characters.js @@ -71,6 +71,20 @@ const lookSeed = bot => (typeof bot?.remote?.handle === 'string' && bot.remote.h ? remoteSeed(bot.remote.handle) : bot?.id !== undefined ? String(bot.id) : undefined) +// A friend's Bot as a look record: its sidebar chat when we have one, else a synthetic +// Bot whose lookOf seed is the handle (never a shared generic gumdrop / first letter). +export function lookBotForRemote(person, roster) { + const handle = String(person?.handle || person?.remote?.handle || '').trim() + const chat = handle ? (roster?.bots ?? []).find(bot => bot.remote?.handle === handle) : undefined + if (chat) return chat + const extra = {} + if (typeof person?.color === 'string') extra.color = person.color + if (person?.avatar && typeof person.avatar === 'object') extra.avatar = person.avatar + const name = person?.label || person?.displayName || person?.name || handle || 'Bot' + if (handle) return { id: remoteSeed(handle), name, remote: { handle }, ...extra } + return { id: person?.id || 'remote', name, ...extra } +} + // A stored shape or color wins; then the seed (a remote Bot's handle, else its id). // With no Bot it is a black gumdrop. export const colorOf = bot => (isColor(bot?.color) ? bot.color : lookSeed(bot) !== undefined ? defaultColor(lookSeed(bot)) : 'black') diff --git a/core/src/client/details-drawer.js b/core/src/client/details-drawer.js index cd435dde..105b372e 100644 --- a/core/src/client/details-drawer.js +++ b/core/src/client/details-drawer.js @@ -2,7 +2,7 @@ import { createElement as h, Fragment, useEffect, useRef, useState } from 'react import { isMainOf } from './sources.js' import { HEADER, currentPart, stripHeader, usageOf } from './text.js' import { PlusIcon, ShareIcon, CloseIcon, ChevronLeftIcon, GearIcon } from './icons.js' -import { botState, BotMark, BotAvatar, RoomAvatar } from './mark.js' +import { botState, BotMark, BotAvatar, RoomAvatar, lookBotForRemote } from './mark.js' import { GlobeIcon, Favicon } from './browser-pane.js' import { useEscape } from './overlay-hooks.js' import { BotSettingsForm } from './bot-settings.js' @@ -208,7 +208,9 @@ function GroupOverview({ room, roster, actions, usage, useWork, openSettings }) person.role === 'admin' ? h('span', { className: 'bt-chip bt-chip-admin' }, t('Admin')) : null))), ...remotes.map(person => h('div', { key: person.handle || person.id, role: 'listitem' }, h('div', { className: 'bt-member' }, - h('span', { className: 'bt-member-name' }, h('span', null, person.label || remoteBotTitle(person))), + h('span', { className: 'bt-member-name' }, + h(BotAvatar, { bot: lookBotForRemote(person, roster), size: 26, badge: false, live: false }), + h('span', null, person.label || remoteBotTitle(person))), face ? null : h('span', { className: 'bt-chip' }, t('Friend\'s Bot'))))), ...members.map((member) => { const entry = entries.find(item => item.id === member.id) diff --git a/core/src/client/group-settings.js b/core/src/client/group-settings.js index b4b973f9..a58a6264 100644 --- a/core/src/client/group-settings.js +++ b/core/src/client/group-settings.js @@ -1,7 +1,7 @@ import { createElement as h, useEffect, useState } from 'react' import { isMainOf } from './sources.js' import { PlusIcon, ChevronRightIcon } from './icons.js' -import { AdminStar, BotAvatar, BotMark, RoomAvatar } from './mark.js' +import { AdminStar, BotAvatar, BotMark, RoomAvatar, lookBotForRemote } from './mark.js' import { Card, Row, InlineText, SwitchRow } from './bot-settings.js' import { Segmented } from './motion.js' import { FolderMenu } from './folder-menu.js' @@ -102,6 +102,7 @@ export function GroupSettingsForm({ room, roster, actions, run, onDeleted }) { }), remotes.map((person) => h('div', { key: person.handle || person.id, className: 'bt-set-row bt-bs-row bt-gs-member' }, h('span', { className: 'bt-gs-who' }, + h(BotAvatar, { bot: lookBotForRemote(person, roster), size: 26, badge: false, live: false }), h('span', { className: 'bt-row-title' }, person.label || remoteBotTitle(person)), room.origin === 'face-to-face' ? null : h('span', { className: 'bt-tag' }, t('Friend\'s Bot'))), room.origin === 'face-to-face' || members.length + remotes.length <= 2 @@ -116,6 +117,7 @@ export function GroupSettingsForm({ room, roster, actions, run, onDeleted }) { h('span', { className: 'bt-set-copy' }, h('span', { className: 'bt-bs-label' }, bot.name)), h('span', { className: 'bt-set-hint' }, t('Add')))), ...outsideRemotes.map(bot => h('button', { key: bot.handle, type: 'button', className: 'bt-set-row bt-bs-row bt-gs-pick', onClick: () => { setAdding(false); void update({ addParticipants: [{ kind: 'bot', remote: true, handle: bot.handle }] }) } }, + h(BotAvatar, { bot: lookBotForRemote({ handle: bot.handle, displayName: bot.name, avatar: bot.avatar, color: bot.color }, roster), size: 22, badge: false, live: false }), h('span', { className: 'bt-set-copy' }, h('span', { className: 'bt-bs-label' }, remoteBotTitle({ displayName: bot.name, ownerName: bot.homeName || bot.owner, handle: bot.handle, isChief: bot.isChief }))), h('span', { className: 'bt-set-hint' }, t('Add friend\'s Bot')))), h('button', { key: 'cancel', type: 'button', className: 'bt-set-row bt-bs-row bt-gs-add', onClick: () => setAdding(false) }, t('Cancel'))] diff --git a/core/src/client/mark.js b/core/src/client/mark.js index 16a6a43f..82d57521 100644 --- a/core/src/client/mark.js +++ b/core/src/client/mark.js @@ -2,7 +2,7 @@ import { createElement as h, useEffect, useRef, useState } from 'react' import { fnv1a, lookOf, shapeId, shapeOf, MASK_ID, nextMarkSerial, characterMarkup } from './characters.js' import { t } from './i18n.js' -export { colorOf, lookOf, remoteSeed } from './characters.js' +export { colorOf, lookOf, remoteSeed, lookBotForRemote } from './characters.js' // ------------------------------------------------------------------------- // Bot mark: the Bot's character (or its uploaded photo), looked up by lookOf. diff --git a/core/src/host/room-membership.js b/core/src/host/room-membership.js index 3710ec17..c75157c0 100644 --- a/core/src/host/room-membership.js +++ b/core/src/host/room-membership.js @@ -864,6 +864,13 @@ export function install(rt) { return bot?.fields?.social?.member?.handle || p?.handle } + function faceLookFields(member) { + const out = {} + if (typeof member?.color === 'string' && member.color.trim()) out.color = member.color.trim().slice(0, 32) + if (member?.avatar && typeof member.avatar === 'object') out.avatar = member.avatar + return out + } + function faceRemoteRecord(member, joinedSeq, at) { const handle = String(member.handle || member.id || '') const displayName = cleanPeerName(member.displayName || member.name, handle) @@ -881,6 +888,7 @@ export function install(rt) { role: 'member', joinedSeq, joinedAt: at, + ...faceLookFields(member), } } @@ -920,6 +928,9 @@ export function install(rt) { existing.displayName = cleanPeerName(member.displayName || member.name, existing.displayName) existing.ownerName = cleanPeerName(member.ownerName, existing.ownerName) existing.roomOnly = member.roomOnly === true || member.published !== true + const look = faceLookFields(member) + if (look.color) existing.color = look.color + if (look.avatar) existing.avatar = look.avatar } else if (member.ownerName) { existing.ownerName = cleanPeerName(member.ownerName, existing.ownerName) } diff --git a/core/test/inbound-ui.test.js b/core/test/inbound-ui.test.js index c25e41e1..543e4873 100644 --- a/core/test/inbound-ui.test.js +++ b/core/test/inbound-ui.test.js @@ -7,7 +7,7 @@ import { build } from 'esbuild' // The inbound friend conversation's display: trace lines, the thinking row, the remote speaker. const { outputFiles } = await build({ stdin: { - contents: "export { stripTrace, previewOf, nameMentions } from './src/client/text.js'\nexport { remoteSpeaker } from './src/client/cells.js'\nexport { lookOf } from './src/client/characters.js'", + contents: "export { stripTrace, previewOf, nameMentions } from './src/client/text.js'\nexport { remoteSpeaker } from './src/client/cells.js'\nexport { lookOf, lookBotForRemote, remoteSeed } from './src/client/characters.js'", resolveDir: fileURLToPath(new URL('..', import.meta.url)), }, bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent', @@ -25,7 +25,7 @@ runInNewContext(outputFiles[0].text, { throw new Error(`Unexpected platform module ${name}`) }, }) -const { stripTrace, previewOf, nameMentions, remoteSpeaker, lookOf } = module.exports +const { stripTrace, previewOf, nameMentions, remoteSpeaker, lookOf, lookBotForRemote, remoteSeed } = module.exports test('a reply opening with a delegation trace shows without it', () => { assert.equal(stripTrace('Chief → 助手\n\nHi Chief! Good to hear from you.'), 'Hi Chief! Good to hear from you.') @@ -59,7 +59,8 @@ test("an inbound friend's message uses the friend's sidebar Bot (avatar, name), assert.equal(got.chatId, 'session-x') const other = remoteSpeaker({ remote: { handle: '@x.home' }, senderName: 'X' }, { bots: [], remoteBots: [] }) assert.equal(other.name, "X · x's Bot") - assert.notEqual(other.bot.color, 'gray') + assert.equal(other.bot.remote.handle, '@x.home') + assert.notEqual(lookOf(other.bot).color, 'gray') assert.equal(other.chatId, undefined) }) @@ -94,6 +95,26 @@ test("a friend Bot's generated look is seeded by its handle: with or without its assert.equal(kept.color, fallback.color) }) +test('lookBotForRemote seeds a distinct look from the handle when there is no chat record', () => { + const person = { handle: '@bot.friend.home', displayName: '助手', label: "助手 · Friend's Bot" } + const synthetic = lookBotForRemote(person, { bots: [] }) + assert.equal(synthetic.remote.handle, '@bot.friend.home') + assert.equal(synthetic.id, remoteSeed('@bot.friend.home')) + const look = lookOf(synthetic) + assert.notEqual(look.shape, undefined) + assert.notEqual(look.color, 'gray') + assert.notEqual(look.color, 'black') + const other = lookOf(lookBotForRemote({ handle: '@other.friend.home', displayName: 'Other' }, { bots: [] })) + assert.notEqual(look.shape + look.color, other.shape + other.color) + const pictured = lookBotForRemote({ + handle: '@bot.friend.home', displayName: '助手', + avatar: { image: 'https://example.test/friend.png' }, + }, { bots: [] }) + assert.equal(lookOf(pictured).image, 'https://example.test/friend.png') + const chat = { id: 'session-f', name: '助手', remote: { handle: '@bot.friend.home' }, color: 'green' } + assert.equal(lookBotForRemote(person, { bots: [chat] }), chat) +}) + test('handles equal modulo @ and case share one look; a stored look still wins', () => { const at = lookOf({ id: 'a', remote: { handle: '@Bot.X.home' } }) const bare = lookOf({ id: 'b', remote: { handle: 'bot.x.home' } }) diff --git a/core/test/remote-room.test.js b/core/test/remote-room.test.js index 74fb19fa..71ec2532 100644 --- a/core/test/remote-room.test.js +++ b/core/test/remote-room.test.js @@ -259,7 +259,7 @@ test('face-to-face room hosts room-only remotes and skips friend fanout', async expiresAt: '2026-10-15T10:10:00.000Z', members: [ { handle: '@writer.alice.home', displayName: 'Writer', ownerName: 'Alice', published: false }, - { handle: '@coder.bob.home', displayName: 'Coder', ownerName: 'Bob', published: false }, + { handle: '@coder.bob.home', displayName: 'Coder', ownerName: 'Bob', published: false, color: 'green', avatar: { image: 'https://example.test/coder.png' } }, ], localBots: [{ id: 'spec-a', handle: '@writer.alice.home', name: 'Writer A' }], }) @@ -270,6 +270,8 @@ test('face-to-face room hosts room-only remotes and skips friend fanout', async const remote = live.find(p => p.handle === '@coder.bob.home') assert.equal(remote.remote, true) assert.equal(remote.roomOnly, true) + assert.equal(remote.color, 'green') + assert.equal(remote.avatar.image, 'https://example.test/coder.png') const shown = presentRoom(room, { memberId: 'owner-1' }, rt.state) assert.equal(shown.viewerRole, 'member') assert.doesNotMatch(shown.displayName || '', /Shared room/) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index c24495ae..75b804c7 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -42,7 +42,7 @@ Add a new invariant under the heading it belongs to (or a new heading), not at t ### Remote Bot display - A friend's remote Bot is stored and delivered by its handle (`@.` — what the @ menu inserts and `sendUserMentions` matches). Where the user reads it — mention chips (`src/client/user-message.js`) and sidebar previews (`nameMentions` in `src/client/text.js`) — the client rewrites the handle to the display name taken from the local roster (`roster.bots[].remote`, `roster.remoteBots`), never from message text, so a remote-controlled name reaches the UI only through the sanitized roster records (#71; display names, #9). -- A remote Bot's generated look is seeded by its handle (`remoteSeed` in `src/client/characters.js`), never by its session id: the chat record (`src/host/remote-chat.js`) carries no look, and every consumer funnels through `lookOf`/`colorOf`, so the record and `remoteSpeaker`'s no-record fallback (`src/client/cells.js`) render one stable look per handle — across surfaces, after a delete/re-add, and identically in the friend's home. A stored `color`/`avatar` on the record still wins. Carrying the friend side's real look over the wire needs Bot Social member-profile fields (#84). +- A remote Bot's generated look is seeded by its handle (`remoteSeed` in `src/client/characters.js`), never by its session id: the chat record (`src/host/remote-chat.js`) carries no look, and every consumer funnels through `lookOf`/`colorOf` / `lookBotForRemote`, so the record, `remoteSpeaker`'s no-record fallback (`src/client/cells.js`), and the group overview / settings member rows (`details-drawer.js`, `group-settings.js`) render one stable look per handle — across surfaces, after a delete/re-add, and identically in the friend's home. A stored `color`/`avatar` on the record still wins. Bot Social `publicBot` / `faceMemberPublic` / `get_bot_profile` do not store an image today; when a member payload carries `avatar`/`color` the client passes them through. Persistently carrying the friend side's uploaded photo still needs Bot Social member-profile fields (#84). - A friend's Bot the owner hid, a remote chat marked `gone`/`hidden`, and a home-level handle (`@friend.home`, not `@bot.friend.home`) must not reappear in the sidebar, the **发给 / 和谁聊…** picker, `@` mentions, or `list_bots` friends lines (`isListedRemoteChat`). `flybot.remote.publish` skips those handles so remote-chat sync marks the persisted session gone/hidden instead of opening it again; history is kept. Handle comparison is case-insensitive (`normRemoteHandle`). ### FlyBot product chrome diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 1cf5dbbe..b718fb3e 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -50,6 +50,10 @@ Add a new invariant under the heading it belongs to (or a new heading), not at t - The saved team (`state.json`) is versioned. Version 2 (#47) carries the identity schema — `members` records, a per-Bot `ownerId`, the owner's `designatedChiefId`. A legacy version-1 file is migrated on load with a one-time `state.json.v1` backup; an existing backup counts only when it is a readable file holding a parseable version-1 state, and anything else (or any malformed ownership) fails closed (read-only). An older binary refuses a newer version read-only. This is schema foundation only — team mode stays disabled, see [team](team.md). On top of it, member auth (#48) adds host module `src/host/member-auth.js`, installed after `store` in `MODULES` (`core/index.js`): `invitations` and `authSessions` maps live on state (no `STATE_VERSION` bump), the channel resolves the actor from the `x-flybot-member-session` header, and endpoints are `auth-invite` / `auth-redeem` / `auth-revoke` / `auth-whoami` / `auth-remove-member` / `auth-revoke-invite` with `body.actorId` ignored; `auth-invite` alone also opens for an anonymous caller on a host with no sessions yet (bootstrap first invite), `auth-revoke-invite` kills an unredeemed invitation (redeeming it afterwards fails with `revoked`), `removeMember` refuses a member that still owns a Bot (`has_bots`), and the auth mutations are serialized behind an in-process lock so concurrent `removeMember` calls cannot both pass the last-owner check. Sign-in surface only — still not team mode; details in [team](team.md). On top of it, owner-scoped access (#49) adds host module `src/host/authz.js`, installed after member-auth: when any `authSessions` exist, the channel `authorize()` default-denies cross-owner Bot/room/session access, blocks shared non-isolatable resources (secrets, connectors, folder picks, browser bridge, update-install, diagnostics, set-prefs, `add-agent` — the DSH Agent is one per deployment and carries no owner) with `bot/blocked-shared` when more than one member exists, stamps `create-bot` with `actor.memberId`, and filters `view()`/`live()` to the actor; with no sessions the single-human behavior is unchanged. Session targets resolve through the store's owner maps — a Bot id via `botOf`, current chats and group Sessions and earlier parts via `selfOf`/`groupOwners`/`pastOwners` (a shared room Session also resolves for its current participants, #50) — and unresolvable ids deny. `approval-answer` gates on the approval's own Bot or room (resolved by approval id), never on caller-supplied session fields. `flybot/*` plugin endpoints (src/host/hooks.js) reach any signed-in actor for reads; plugin writes follow the shared rule. `view()`/`live()` narrowing (`rt.ownedView`/`rt.ownedLive` in authz.js) keeps per-session entries (`questions`, `exchangeTurns`) the actor owns, and deployment-wide surfaces (agents, secrets, connectors, memory reviews) only while a single member exists — the same rule as the shared writes; rooms the actor belongs to and the Bots in them (#50) stay visible. `create-room` refuses a request with neither a member Bot nor any participant. On top of it, room membership (#50) adds host module `src/host/room-membership.js`, installed after groups: a room may carry typed `participants` (`human` / local `bot` / granted remote `bot` with `handle` + owner fields, roles `admin` / `member`, `joinedSeq` / `leftSeq`); log lines carry `authorId` + `authorKind`; history is the membership window (current members and the owner of an invited Bot see `seq >=` their interval; leave withdraws access; rejoin starts a new interval); local cross-owner rooms accept each side's *granted* Bots (#90: default deny, Chief included); a friend's Bot from `social/remote-bots` (`cleanRemote`) may be added and ungranted Bots stay private, and a room with a friend's Bot holds only granted local Bots (a Bot that joined by room code, `viaCode`, only for that room); `view()` labels remotes with an owner badge and shows an invited owner **Shared room from \**. Face-to-face rooms (#83) reuse the same `participants` / membership-window / owner-badge presentation with `origin: 'face-to-face'`: room-only remotes may sit in the room without a person-grant or a friendship, `presentRoom` treats the viewer as a member (not Shared room from), leave skips the two-Bot floor, and `fanoutRoomLine` goes to `rt.faceRoomFanout` instead of friend `flybotSend`. Still not team mode — see [team](team.md). Per-Bot grants (#90) add host module `src/host/reach.js`, installed after room-membership: `state.grants[botId]` is the list of independent grants (one Bot → one grantee, optionally room-scoped); default deny, Chief included; a Bot whose owner is not a member has none. Grants are set with `rt.setGrants`, exposed as `BotView.granted`, `flybot.bot.hasGrant` / `hasAnyGrant`, `flybot.grants` and the `bot.grants` event. `reach()` is the owner policy: a friend's Bot may reach only local Bots granted to that sender, only a granted Bot (or one code-joined in that room) may message a friend's Bot, another owner's Bot is reached only if both sides have matching grants or both are members of the room the message belongs to. It is not a middleware: hooks.js runs it before the `bot.send` chain, again on approval release, and on every deliver with a sender or remote origin (resumed, retried, deferred, re-sent), so no verdict or `bypass` can skip it and a queued message is judged by what is true when it is delivered. Revoking a grant, or replacing its Chief, removes it from rooms that have friends' Bots or several owners at once (`rt.evictUnshared`). +### Face-to-face participant look + +- `syncFaceParticipants` / `faceRemoteRecord` keep optional `color` / `avatar` from the Bot Social member payload on the remote participant. The client seeds a distinct generated look from the handle when those fields are absent (see [core-client](core-client.md)). Face-room REST still does not require a person-grant. + ### FlyBot display copy - Host-facing copy the user reads says FlyBot: workspace title, store / read-only messages, diagnostics table row, secrets descriptions, tools settings hints, parts notes, and update errors as **FlyBot core**. `update.js` still reads `package.json` `name` / `repository` for the check and GitHub links. diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 412876ad..16c2182f 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -10,7 +10,7 @@ The social plugin talks to a [Bot Social](https://github.com/Birfy/bot-social) s Bot Social plugin for chatting with friends' Bots (send middleware `social` with per-home approval) lives entirely under `plugins/social/`. Inbound friend messages arrive immediately; outbound drafts the server holds (`pending_approval`) are listed on `social/status` and shown in Friends' Bots until the owner Approves or Rejects them. The same queue is also attached to a native shared room via `flybot.roomApprovals.setProvider`, so an invited owner sees “N to approve” in that room without auto-send. -Face-to-face rooms (#83, #92) also live here: the primary entry is the sidebar **+** menu (slot `flybot.new-chat.action` on the core new-chat picker, next to Create group chat; `label` / `hint` use the same `t()` map) and Friends' Bots **+ Add** both open the same **Face-to-face group** screen (pick one or more local Bots, six-digit number, join with no preview, live waiting room, **Enter chat**). The pick list and waiting roster draw each Bot with the host `BotAvatar` (team roster look for yours; a friend's Bot uses its chat record or a handle-seeded look, plus any avatar the member payload carries). Waiting-room and picker Bots are a wrapping tile grid (avatar on top, name centered below; yours a tiny accent dot, no owner subtitle); **Leave** and **Enter chat** share a fixed footer while a long grid scrolls under the countdown. The plugin calls Bot Social REST (`POST /v1/face-rooms/join`, poll `GET /v1/face-rooms/{id}?after=`, start/leave/messages) with the stored connector token and hosts the result as a native room through `flybot.rooms` (`origin: 'face-to-face'`). Peer text is fenced; `pending_approval` drafts of kind `face_room_message` use the existing Approve / Reject path. **Auto-send my Bot's room messages** (room header and pending-approval) maps to `PATCH /v1/me { auto_approve_face_rooms }` so the owner's Bots can post without asking each time; default off. **Invite to this Bot** creates a per-Bot grant when the friend accepts; **Manage access** lists who has that Bot, in which groups, and **Revoke**. There is no published toggle. Bringing my own Bot by number is an implicit room-scoped grant: if it has no identity yet, join registers it room-only (`add_home_member`) and revokes that identity (`remove_home_member`) when it leaves its last face room. Friends' Bots lists only Bots granted to me. Held replies show in the chat (owner Approve / Reject). **Remove this Bot** hides a friend's Bot locally without unfriending: the hide is saved and the row drops immediately; room leave and a roster refresh continue in the background. A hide is forgotten only after two consecutive successful `list_remote_bots` results without that handle; failed/legacy fetches must not un-hide it. Hidden, gone, and home-level remotes stay out of `flybot.remote.publish` and the new-chat picker. Friends' Bots paints a cached roster immediately and refreshes in the background (keep-alive HTTP, parallel mount calls). +Face-to-face rooms (#83, #92) also live here: the primary entry is the sidebar **+** menu (slot `flybot.new-chat.action` on the core new-chat picker, next to Create group chat; `label` / `hint` use the same `t()` map) and Friends' Bots **+ Add** both open the same **Face-to-face group** screen (pick one or more local Bots, six-digit number, join with no preview, live waiting room, **Enter chat**). The pick list and waiting roster draw each Bot with the host `BotAvatar` (team roster look for yours; a friend's Bot uses its chat record or a handle-seeded look, plus any avatar the member payload carries). Waiting-room and picker Bots are a wrapping tile grid (avatar on top, name centered below; yours a tiny accent dot, no owner subtitle); **Leave** and **Enter chat** share a fixed footer while a long grid scrolls under the countdown. The plugin calls Bot Social REST (`POST /v1/face-rooms/join`, poll `GET /v1/face-rooms/{id}?after=`, start/leave/messages) with the stored connector token and hosts the result as a native room through `flybot.rooms` (`origin: 'face-to-face'`). Those face-room routes accept a connector token; owner settings do not (`GET`/`PATCH /v1/me` returns 403 `connector_token_rest_forbidden`). Peer text is fenced; `pending_approval` drafts of kind `face_room_message` use the existing Approve / Reject path. **Auto-send my Bot's room messages** (room header and pending-approval) is MCP `whoami` (read) and `set_auto_approve { auto_approve_face_rooms }` (write; the owner's click requests and uses `confirm_code` in the same step) so the owner's Bots can post without asking each time; default off. A friend's Bot in the room card, waiting grid, Friends' Bots rows, and core group overview uses the host `BotAvatar` (chat record, optional member `avatar`, else a handle-seeded look). **Invite to this Bot** creates a per-Bot grant when the friend accepts; **Manage access** lists who has that Bot, in which groups, and **Revoke**. There is no published toggle. Bringing my own Bot by number is an implicit room-scoped grant: if it has no identity yet, join registers it room-only (`add_home_member`) and revokes that identity (`remove_home_member`) when it leaves its last face room. Friends' Bots lists only Bots granted to me. Held replies show in the chat (owner Approve / Reject). **Remove this Bot** hides a friend's Bot locally without unfriending: the hide is saved and the row drops immediately; room leave and a roster refresh continue in the background. A hide is forgotten only after two consecutive successful `list_remote_bots` results without that handle; failed/legacy fetches must not un-hide it. Hidden, gone, and home-level remotes stay out of `flybot.remote.publish` and the new-chat picker. Friends' Bots paints a cached roster immediately and refreshes in the background (keep-alive HTTP, parallel mount calls). ## Owns @@ -29,7 +29,7 @@ Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers | `plugins/social/index.js` | Plugin entry. | | `plugins/social/src/social.js` | Host: roster, grants, inbox, `social/status` pending-approval queue, Approve / Reject, hide-remote, Manage access, shared-room approvals provider, face-to-face join/poll/leave/start/send and `social/face-auto`. | | `plugins/social/src/mcp-client.js` | Owner MCP client plus isolated grant/invite REST fallbacks (`listGrants`, `revokeGrant`, invites). | -| `plugins/social/src/face-rooms.js` | Face-to-face REST client, error mapping, event rendering, peer-string sanitization, GET/PATCH `/v1/me` auto-approve (#83). | +| `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/client.js` | Friends' Bots: badge, pending section, Approve / Reject, Remove this Bot, cached roster / Refreshing…, + Add and sidebar + Face-to-face group (Bot picker and waiting room as a wrapping tile grid of each Bot's own avatar, 6-digit join, Enter chat + Leave footer), face-room cards, `flybot.room.overview` Leave / Start / Auto-send, Bot settings Invite / Manage access. | ## Entry points @@ -43,7 +43,7 @@ Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers 3. A server policy pause (`conversation_paused`: turn budget reached, loop suspected, emergency stop) never surfaces as a bare send error: both send paths (the `bot-social` transport and the remote-chat sender) map it to an error that says resending cannot help and names the Bot Social console, and no other member Bot is tried for it. 4. Access is a per-Bot **grant** (#90): default deny, Chief included. `register` / `registerAll` register a Bot when it is invited, when it joins an invite, when it already has a person-grant or a saved identity, or at face-to-face join (`forFace`, `roomOnly`). `pollOnce` reads inboxes of non-room-only members only. Friends' Bots is `list_remote_bots` (Bots granted to me). Accepting **Invite to this Bot** is the grant; `social/access` + `social/revoke-grant` (via `mcp-client.js` `listGrants` / `revokeGrant`, MCP then REST) let the owner see where the Bot is in use and revoke one person without touching other grants. Core `reach()` / `flybot.grants` are the unbypassable gate. **Face-to-face join (#83 / #89):** bringing my own Bot by number is an implicit room-scoped grant. If it has no identity, join registers it room-only; friends' `list_remote_bots`, invites, polling and `reach()` outside the room stay denied. When that Bot leaves its last face room or the room closes, the plugin revokes the room-only identity (`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`. 5. When start fails (including Bot Social onboarding HTTP 409 `handle_taken`), Friends' Bots never shows the host's `Unknown FlyBot endpoint social/…` message. `apply` registers the social UI endpoints with a friendly `Social is not running: ` refusal, `flybot.warn` records the failure in diagnostics, and a 409 names the taken handle (`@.home`) and says to change `displayName` in the flybot-social config. The client also maps `bot/not-found` on `social/*` to that idle state. -6. Face-to-face join (#83, #92) is direct (no preview). The owner picks one or more local Bots in the Face-to-face group screen (sidebar **+** or Friends' Bots + Add); each picker tile and each waiting-room member uses the host Bot avatar (`__flybot__.BotAvatar`), not a shared first-letter badge. The client calls `social/face-join` once per Bot with the same code and shows partial failures. If a chosen Bot has no identity, the plugin registers it first (see invariant 4); registration failures (`handle_taken`, rate limit, timeout) map to English copy and leave no leftover member. Failed join responses never leak `members` / `events` / `room_id`. Every FACE_ROOMS.md error code maps to English copy (`rate_limited` / `locked_out` include `retry_after`). The waiting room polls `social/face-rooms` (which refreshes from Bot Social) and marks the owner's Bots with a tiny accent dot on the tile; **Leave** and **Enter chat** share the footer. **Enter chat** is `social/face-start` then `openSession(roomId)`, and the screen follows if someone else starts. Peer `room.message` text is untrusted: strip fence markers and wrap in `` before anything model-visible. Bot replies POST `/v1/face-rooms/{id}/messages`; `pending_approval` shows in the existing queue and in the chat (owner Approve / Reject). **Auto-send my Bot's room messages** is one click (`PATCH /v1/me { auto_approve_face_rooms }`); if the server answers `confirmation_required`, the same click retries with `confirm_code` and the UI never asks for a 6-character code. New members only receive events from their join point (`after` cursor; the server also floors it). Join does not grant the Bot to friends. The UI never says published. +6. Face-to-face join (#83, #92) is direct (no preview). The owner picks one or more local Bots in the Face-to-face group screen (sidebar **+** or Friends' Bots + Add); each picker tile and each waiting-room member uses the host Bot avatar (`__flybot__.BotAvatar`), not a shared first-letter badge. The client calls `social/face-join` once per Bot with the same code and shows partial failures. If a chosen Bot has no identity, the plugin registers it first (see invariant 4); registration failures (`handle_taken`, rate limit, timeout) map to English copy and leave no leftover member. Failed join responses never leak `members` / `events` / `room_id`. Every FACE_ROOMS.md error code maps to English copy (`rate_limited` / `locked_out` include `retry_after`). The waiting room polls `social/face-rooms` (which refreshes from Bot Social) and marks the owner's Bots with a tiny accent dot on the tile; **Leave** and **Enter chat** share the footer. **Enter chat** is `social/face-start` then `openSession(roomId)`, and the screen follows if someone else starts. Peer `room.message` text is untrusted: strip fence markers and wrap in `` before anything model-visible. Bot replies POST `/v1/face-rooms/{id}/messages`; `pending_approval` shows in the existing queue and in the chat (owner Approve / Reject). **Auto-send my Bot's room messages** is one click (`whoami` / MCP `set_auto_approve { auto_approve_face_rooms }`); if the server answers `confirmation_required`, the same click retries with `confirm_code` and the UI never asks for a 6-character code. REST `/v1/me` is not used with the connector token. A friend's Bot in the room member list and waiting grid uses the host Bot avatar (handle-seeded when Bot Social sends no image). New members only receive events from their join point (`after` cursor; the server also floors it). Join does not grant the Bot to friends. The UI never says published. ## Tests diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index 9bc0c74d..755985ff 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -94,7 +94,7 @@ diff --git a/src/client/cells.js b/src/client/cells.js +import { currentPart, linkMentions, mentionTarget, dayLabel, contentText, stripHeader, stripTrace, isHiddenTurn, isRelayContext, relaysBefore, relayPart, commRunOf, toolName, toolArgs, reminderPrompts } from './text.js' import { SmileIcon, ReplyIcon, DotsIcon, CheckIcon, DownloadIcon, CopyIcon, MemoryIcon } from './icons.js' -import { colorOf, toolState, BotMark, RoomAvatar } from './mark.js' -+import { colorOf, remoteSeed, toolState, BotMark, RoomAvatar } from './mark.js' ++import { colorOf, lookBotForRemote, toolState, BotMark, RoomAvatar } from './mark.js' import { AnsweredCell, SettledQuestion, PendingQuestion } from './questions.js' import { SecretToolCell } from './secret-card.js' import { dateLocale, t } from './i18n.js' @@ -126,7 +126,7 @@ diff --git a/src/client/cells.js b/src/client/cells.js const running = data.status === 'running' // A streaming reply stands in for the activity row. useLiveNote(actions, sessionId, `text:${node.key ?? data.seq}`, !hidden && running && texts.length > 0 ? { text: true } : null) -@@ -180,6 +180,72 @@ function GroupMessage({ bot, text, room, roster, actions, msgKey, useUi }) { +@@ -180,6 +180,75 @@ function GroupMessage({ bot, text, room, roster, actions, msgKey, useUi }) { h(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room?.id, msgKey, useUi, lead })) } @@ -145,7 +145,10 @@ diff --git a/src/client/cells.js b/src/client/cells.js + }) + // The sidebar's Bot record when there is one; else a synthetic one whose id seeds the + // same handle-derived look the record would get (characters.js remoteSeed). -+ const bot = chat ?? { id: remoteSeed(handle), name, color: member?.color, shape: member?.shape } ++ const bot = chat ?? lookBotForRemote({ ++ handle, displayName: name, color: member?.color, ++ avatar: member?.avatar ?? (member?.shape ? { shape: member.shape } : undefined), ++ }, roster) + return { bot, member, name, handle, chatId: chat ? currentPart(chat) : undefined } +} + diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index 72647e92..cafc3f73 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -3,7 +3,7 @@ FlyBot patch 0005-social-seams on Fei2411/DeepSeek-Bot@2b10c70 (ds-bot/). Genera diff --git a/src/client/characters.js b/src/client/characters.js --- a/src/client/characters.js +++ b/src/client/characters.js -@@ -63,12 +63,22 @@ export function defaultShape(id) { +@@ -63,12 +63,36 @@ export function defaultShape(id) { return DEFAULT_SHAPES[((value ^ (value >>> 16)) >>> 0) % DEFAULT_SHAPES.length] } @@ -17,6 +17,20 @@ diff --git a/src/client/characters.js b/src/client/characters.js + ? remoteSeed(bot.remote.handle) + : bot?.id !== undefined ? String(bot.id) : undefined) + ++// A friend's Bot as a look record: its sidebar chat when we have one, else a synthetic ++// Bot whose lookOf seed is the handle (never a shared generic gumdrop / first letter). ++export function lookBotForRemote(person, roster) { ++ const handle = String(person?.handle || person?.remote?.handle || '').trim() ++ const chat = handle ? (roster?.bots ?? []).find(bot => bot.remote?.handle === handle) : undefined ++ if (chat) return chat ++ const extra = {} ++ if (typeof person?.color === 'string') extra.color = person.color ++ if (person?.avatar && typeof person.avatar === 'object') extra.avatar = person.avatar ++ const name = person?.label || person?.displayName || person?.name || handle || 'Bot' ++ if (handle) return { id: remoteSeed(handle), name, remote: { handle }, ...extra } ++ return { id: person?.id || 'remote', name, ...extra } ++} ++ +// A stored shape or color wins; then the seed (a remote Bot's handle, else its id). +// With no Bot it is a black gumdrop. +export const colorOf = bot => (isColor(bot?.color) ? bot.color : lookSeed(bot) !== undefined ? defaultColor(lookSeed(bot)) : 'black') @@ -37,7 +51,7 @@ diff --git a/src/client/mark.js b/src/client/mark.js import { t } from './i18n.js' -export { colorOf, lookOf } from './characters.js' -+export { colorOf, lookOf, remoteSeed } from './characters.js' ++export { colorOf, lookOf, remoteSeed, lookBotForRemote } from './characters.js' // ------------------------------------------------------------------------- // Bot mark: the Bot's character (or its uploaded photo), looked up by lookOf. diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index d241db66..030d1e6e 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -493,13 +493,24 @@ diff --git a/client.js b/client.js // src/client/inks.js var INKS = { deepseek: ["#4D6BFE", "#6A84FF"], -@@ -1177,11 +1177,14 @@ function defaultShape(id) { +@@ -1177,11 +1177,25 @@ function defaultShape(id) { value = Math.imul(value ^ value >>> 13, 3266489909); return DEFAULT_SHAPES[((value ^ value >>> 16) >>> 0) % DEFAULT_SHAPES.length]; } -var colorOf = (bot) => isColor(bot?.color) ? bot.color : bot?.id ? defaultColor(String(bot.id)) : "black"; +var remoteSeed = (handle) => `remote:${String(handle ?? "").trim().replace(/^@+/, "").toLowerCase()}`; +var lookSeed = (bot) => typeof bot?.remote?.handle === "string" && bot.remote.handle.trim() !== "" ? remoteSeed(bot.remote.handle) : bot?.id !== void 0 ? String(bot.id) : void 0; ++function lookBotForRemote(person, roster) { ++ const handle = String(person?.handle || person?.remote?.handle || "").trim(); ++ const chat = handle ? (roster?.bots ?? []).find((bot) => bot.remote?.handle === handle) : void 0; ++ if (chat) return chat; ++ const extra = {}; ++ if (typeof person?.color === "string") extra.color = person.color; ++ if (person?.avatar && typeof person.avatar === "object") extra.avatar = person.avatar; ++ const name = person?.label || person?.displayName || person?.name || handle || "Bot"; ++ if (handle) return { id: remoteSeed(handle), name, remote: { handle }, ...extra }; ++ return { id: person?.id || "remote", name, ...extra }; ++} +var colorOf = (bot) => isColor(bot?.color) ? bot.color : lookSeed(bot) !== void 0 ? defaultColor(lookSeed(bot)) : "black"; function lookOf(bot) { const avatar = bot?.avatar ?? {}; @@ -1081,11 +1092,10 @@ diff --git a/client.js b/client.js .bt-think-text{white-space:pre-wrap;margin-top:6px;padding-left:10px;border-left:2px solid var(--bt-line-2)} .bt-event{display:flex;justify-content:center;align-items:center;gap:2px;min-height:24px;margin:2px 0;font-size:12px;line-height:16px;color:var(--bt-ink-2);flex-wrap:wrap} .bt-event>span{padding:0 2px} -@@ -2826,255 +2826,137 @@ function installCompiledStyles(text, tag) { - for (const sheet of document.styleSheets) { +@@ -2827,254 +2827,136 @@ function installCompiledStyles(text, tag) { if (sheet.ownerNode?.dataset?.dshBot) continue; let list = cache.get(sheet); -- if (!list) { + if (!list) { - list = sheetClassNames(sheet); - cache.set(sheet, list); - } @@ -1239,7 +1249,6 @@ diff --git a/client.js b/client.js - } - const half = frame / 2; - return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]]; -+ if (!list) { + list = sheetClassNames(sheet); + cache.set(sheet, list); + } @@ -2279,7 +2288,7 @@ diff --git a/client.js b/client.js key: relay.seq, prefix: source.role === "stopped" ? t("Stopped by") : source.role === "reply" ? t("Reply from") : t("Message from"), bots: [sender], -@@ -3940,61 +3940,131 @@ function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, use +@@ -3940,61 +3940,136 @@ function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, use whole: true }); }), @@ -2359,7 +2368,12 @@ diff --git a/client.js b/client.js + handle, + isChief: member?.isChief ?? chat?.remote?.isChief + }); -+ const bot = chat ?? { id: remoteSeed(handle), name, color: member?.color, shape: member?.shape }; ++ const bot = chat ?? lookBotForRemote({ ++ handle, ++ displayName: name, ++ color: member?.color, ++ avatar: member?.avatar ?? (member?.shape ? { shape: member.shape } : void 0) ++ }, roster); + return { bot, member, name, handle, chatId: chat ? currentPart(chat) : void 0 }; +} +function RemoteMessage({ source, text, roster, actions, sessionId, msgKey, useUi }) { @@ -6739,7 +6753,7 @@ diff --git a/client.js b/client.js key: id, type: "button", "aria-pressed": mode === id, -@@ -8999,86 +8999,128 @@ function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) { +@@ -8999,86 +8999,130 @@ function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) { if (mode !== id) void update({ mode: id }); } }, t(label)))), @@ -6810,6 +6824,7 @@ diff --git a/client.js b/client.js + (0, import_react24.createElement)( + "span", + { className: "bt-gs-who" }, ++ (0, import_react24.createElement)(BotAvatar, { bot: lookBotForRemote(person, roster), size: 26, badge: false, live: false }), + (0, import_react24.createElement)("span", { className: "bt-row-title" }, person.label || remoteBotTitle(person)), + room2.origin === "face-to-face" ? null : (0, import_react24.createElement)("span", { className: "bt-tag" }, t("Friend's Bot")) + ), @@ -6844,6 +6859,7 @@ diff --git a/client.js b/client.js + setAdding(false); + void update({ addParticipants: [{ kind: "bot", remote: true, handle: bot.handle }] }); + } }, ++ (0, import_react24.createElement)(BotAvatar, { bot: lookBotForRemote({ handle: bot.handle, displayName: bot.name, avatar: bot.avatar, color: bot.color }, roster), size: 22, badge: false, live: false }), + (0, import_react24.createElement)("span", { className: "bt-set-copy" }, (0, import_react24.createElement)("span", { className: "bt-bs-label" }, remoteBotTitle({ displayName: bot.name, ownerName: bot.homeName || bot.owner, handle: bot.handle, isChief: bot.isChief }))), + (0, import_react24.createElement)("span", { className: "bt-set-hint" }, t("Add friend's Bot")) )), @@ -8807,7 +8823,7 @@ diff --git a/client.js b/client.js type: "button", className: "bt-bcard-x", "aria-label": t("Close tab"), -@@ -11367,71 +11367,181 @@ function BrowserCards({ bot, sessionId, roster, actions, useBrowserTabs, useBrow +@@ -11367,71 +11367,186 @@ function BrowserCards({ bot, sessionId, roster, actions, useBrowserTabs, useBrow void actions.browserCloseTab(bot.id, tabId); } }, "×"), @@ -8974,7 +8990,17 @@ diff --git a/client.js b/client.js + (0, import_react32.createElement)( + "div", + { className: "bt-member" }, -+ (0, import_react32.createElement)("span", { className: "bt-member-name" }, (0, import_react32.createElement)("span", null, person.label || remoteBotTitle(person))), ++ (0, import_react32.createElement)( ++ "span", ++ { className: "bt-member-name" }, ++ (0, import_react32.createElement)(BotAvatar, { bot: lookBotForRemote(person, roster), size: 26, badge: false, live: false }), ++ (0, import_react32.createElement)("span", null, person.label || remoteBotTitle(person)) + ), +- room2.admin === member.id ? (0, import_react31.createElement)("span", { className: "bt-chip bt-chip-admin" }, t("Admin")) : null +- ), +- entry ? (0, import_react31.createElement)(MemberWork, { entry, bot: member, room: room2, roster, actions, now }) : null +- ); +- })) + face ? null : (0, import_react32.createElement)("span", { className: "bt-chip" }, t("Friend's Bot")) + ) + )), @@ -8993,12 +9019,7 @@ diff --git a/client.js b/client.js + (0, import_react32.createElement)("span", null, member.name) + ), + room2.admin === member.id ? (0, import_react32.createElement)("span", { className: "bt-chip bt-chip-admin" }, t("Admin")) : null - ), -- room2.admin === member.id ? (0, import_react31.createElement)("span", { className: "bt-chip bt-chip-admin" }, t("Admin")) : null -- ), -- entry ? (0, import_react31.createElement)(MemberWork, { entry, bot: member, room: room2, roster, actions, now }) : null -- ); -- })) ++ ), + entry ? (0, import_react32.createElement)(MemberWork, { entry, bot: member, room: room2, roster, actions, now }) : null + ); + }) diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index af6948e1..dcac8b38 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -47,6 +47,15 @@ diff --git a/src/client/composer.js b/src/client/composer.js diff --git a/src/client/details-drawer.js b/src/client/details-drawer.js --- a/src/client/details-drawer.js +++ b/src/client/details-drawer.js +@@ -2,7 +2,7 @@ import { createElement as h, Fragment, useEffect, useRef, useState } from 'react + import { isMainOf } from './sources.js' + import { HEADER, currentPart, stripHeader, usageOf } from './text.js' + import { PlusIcon, ShareIcon, CloseIcon, ChevronLeftIcon, GearIcon } from './icons.js' +-import { botState, BotMark, BotAvatar, RoomAvatar } from './mark.js' ++import { botState, BotMark, BotAvatar, RoomAvatar, lookBotForRemote } from './mark.js' + import { GlobeIcon, Favicon } from './browser-pane.js' + import { useEscape } from './overlay-hooks.js' + import { BotSettingsForm } from './bot-settings.js' @@ -14,6 +14,9 @@ import { ScheduleSummary } from './schedules.js' import { Segmented, useGlide, useMorph } from './motion.js' import { MemberWork, useMemberWork } from './member-status.js' @@ -57,7 +66,7 @@ diff --git a/src/client/details-drawer.js b/src/client/details-drawer.js function transcriptOf(list, id, name, hiddenTurns = []) { const outline = list.projectionsBySession?.[id]?.values?.turnOutline ?? list.byId[id]?.projectionValues?.turnOutline -@@ -113,16 +113,101 @@ function About({ text, title = 'About' }) { +@@ -113,16 +113,103 @@ function About({ text, title = 'About' }) { // A group's overview: what members read, who is in it, and who answers. Changes are // made in its settings. @@ -158,7 +167,9 @@ diff --git a/src/client/details-drawer.js b/src/client/details-drawer.js + person.role === 'admin' ? h('span', { className: 'bt-chip bt-chip-admin' }, t('Admin')) : null))), + ...remotes.map(person => h('div', { key: person.handle || person.id, role: 'listitem' }, + h('div', { className: 'bt-member' }, -+ h('span', { className: 'bt-member-name' }, h('span', null, person.label || remoteBotTitle(person))), ++ h('span', { className: 'bt-member-name' }, ++ h(BotAvatar, { bot: lookBotForRemote(person, roster), size: 26, badge: false, live: false }), ++ h('span', null, person.label || remoteBotTitle(person))), + face ? null : h('span', { className: 'bt-chip' }, t('Friend\'s Bot'))))), + ...members.map((member) => { const entry = entries.find(item => item.id === member.id) @@ -176,7 +187,13 @@ diff --git a/src/client/details-drawer.js b/src/client/details-drawer.js diff --git a/src/client/group-settings.js b/src/client/group-settings.js --- a/src/client/group-settings.js +++ b/src/client/group-settings.js -@@ -6,6 +6,7 @@ import { Card, Row, InlineText, SwitchRow } from './bot-settings.js' +@@ -1,11 +1,12 @@ + import { createElement as h, useEffect, useState } from 'react' + import { isMainOf } from './sources.js' + import { PlusIcon, ChevronRightIcon } from './icons.js' +-import { AdminStar, BotAvatar, BotMark, RoomAvatar } from './mark.js' ++import { AdminStar, BotAvatar, BotMark, RoomAvatar, lookBotForRemote } from './mark.js' + import { Card, Row, InlineText, SwitchRow } from './bot-settings.js' import { Segmented } from './motion.js' import { FolderMenu } from './folder-menu.js' import { t } from './i18n.js' @@ -206,7 +223,7 @@ diff --git a/src/client/group-settings.js b/src/client/group-settings.js const admin = roster.byId[room.admin] const mode = shownMode(room, roster) const modeHint = ROOM_MODES.find(([id]) => id === mode)?.[2] ?? '' -@@ -65,26 +65,48 @@ export function GroupSettingsForm({ room, roster, actions, run, onDeleted }) { +@@ -65,26 +65,50 @@ export function GroupSettingsForm({ room, roster, actions, run, onDeleted }) { h('div', { className: 'bt-set-row bt-bs-row' }, h('span', { className: 'bt-set-copy' }, h('span', { className: 'bt-bs-label' }, t('Folder'))), h(FolderMenu, { value: room.workDir, group: true, actions, run, onPick: workDir => update({ workDir }) }))), @@ -238,6 +255,7 @@ diff --git a/src/client/group-settings.js b/src/client/group-settings.js - ? [...outside.map(bot => h('button', { key: bot.id, type: 'button', className: 'bt-set-row bt-bs-row bt-gs-pick', onClick: () => { setAdding(false); void update({ add: [bot.id] }) } }, + remotes.map((person) => h('div', { key: person.handle || person.id, className: 'bt-set-row bt-bs-row bt-gs-member' }, + h('span', { className: 'bt-gs-who' }, ++ h(BotAvatar, { bot: lookBotForRemote(person, roster), size: 26, badge: false, live: false }), + h('span', { className: 'bt-row-title' }, person.label || remoteBotTitle(person)), + room.origin === 'face-to-face' ? null : h('span', { className: 'bt-tag' }, t('Friend\'s Bot'))), + room.origin === 'face-to-face' || members.length + remotes.length <= 2 @@ -252,6 +270,7 @@ diff --git a/src/client/group-settings.js b/src/client/group-settings.js h('span', { className: 'bt-set-copy' }, h('span', { className: 'bt-bs-label' }, bot.name)), h('span', { className: 'bt-set-hint' }, t('Add')))), + ...outsideRemotes.map(bot => h('button', { key: bot.handle, type: 'button', className: 'bt-set-row bt-bs-row bt-gs-pick', onClick: () => { setAdding(false); void update({ addParticipants: [{ kind: 'bot', remote: true, handle: bot.handle }] }) } }, ++ h(BotAvatar, { bot: lookBotForRemote({ handle: bot.handle, displayName: bot.name, avatar: bot.avatar, color: bot.color }, roster), size: 22, badge: false, live: false }), + h('span', { className: 'bt-set-copy' }, h('span', { className: 'bt-bs-label' }, remoteBotTitle({ displayName: bot.name, ownerName: bot.homeName || bot.owner, handle: bot.handle, isChief: bot.isChief }))), + h('span', { className: 'bt-set-hint' }, t('Add friend\'s Bot')))), h('button', { key: 'cancel', type: 'button', className: 'bt-set-row bt-bs-row bt-gs-add', onClick: () => setAdding(false) }, t('Cancel'))] diff --git a/patches/ds-bot/files/src/host/room-membership.js b/patches/ds-bot/files/src/host/room-membership.js index 3710ec17..c75157c0 100644 --- a/patches/ds-bot/files/src/host/room-membership.js +++ b/patches/ds-bot/files/src/host/room-membership.js @@ -864,6 +864,13 @@ export function install(rt) { return bot?.fields?.social?.member?.handle || p?.handle } + function faceLookFields(member) { + const out = {} + if (typeof member?.color === 'string' && member.color.trim()) out.color = member.color.trim().slice(0, 32) + if (member?.avatar && typeof member.avatar === 'object') out.avatar = member.avatar + return out + } + function faceRemoteRecord(member, joinedSeq, at) { const handle = String(member.handle || member.id || '') const displayName = cleanPeerName(member.displayName || member.name, handle) @@ -881,6 +888,7 @@ export function install(rt) { role: 'member', joinedSeq, joinedAt: at, + ...faceLookFields(member), } } @@ -920,6 +928,9 @@ export function install(rt) { existing.displayName = cleanPeerName(member.displayName || member.name, existing.displayName) existing.ownerName = cleanPeerName(member.ownerName, existing.ownerName) existing.roomOnly = member.roomOnly === true || member.published !== true + const look = faceLookFields(member) + if (look.color) existing.color = look.color + if (look.avatar) existing.avatar = look.avatar } else if (member.ownerName) { existing.ownerName = cleanPeerName(member.ownerName, existing.ownerName) } diff --git a/patches/ds-bot/files/test/inbound-ui.test.js b/patches/ds-bot/files/test/inbound-ui.test.js index c25e41e1..543e4873 100644 --- a/patches/ds-bot/files/test/inbound-ui.test.js +++ b/patches/ds-bot/files/test/inbound-ui.test.js @@ -7,7 +7,7 @@ import { build } from 'esbuild' // The inbound friend conversation's display: trace lines, the thinking row, the remote speaker. const { outputFiles } = await build({ stdin: { - contents: "export { stripTrace, previewOf, nameMentions } from './src/client/text.js'\nexport { remoteSpeaker } from './src/client/cells.js'\nexport { lookOf } from './src/client/characters.js'", + contents: "export { stripTrace, previewOf, nameMentions } from './src/client/text.js'\nexport { remoteSpeaker } from './src/client/cells.js'\nexport { lookOf, lookBotForRemote, remoteSeed } from './src/client/characters.js'", resolveDir: fileURLToPath(new URL('..', import.meta.url)), }, bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent', @@ -25,7 +25,7 @@ runInNewContext(outputFiles[0].text, { throw new Error(`Unexpected platform module ${name}`) }, }) -const { stripTrace, previewOf, nameMentions, remoteSpeaker, lookOf } = module.exports +const { stripTrace, previewOf, nameMentions, remoteSpeaker, lookOf, lookBotForRemote, remoteSeed } = module.exports test('a reply opening with a delegation trace shows without it', () => { assert.equal(stripTrace('Chief → 助手\n\nHi Chief! Good to hear from you.'), 'Hi Chief! Good to hear from you.') @@ -59,7 +59,8 @@ test("an inbound friend's message uses the friend's sidebar Bot (avatar, name), assert.equal(got.chatId, 'session-x') const other = remoteSpeaker({ remote: { handle: '@x.home' }, senderName: 'X' }, { bots: [], remoteBots: [] }) assert.equal(other.name, "X · x's Bot") - assert.notEqual(other.bot.color, 'gray') + assert.equal(other.bot.remote.handle, '@x.home') + assert.notEqual(lookOf(other.bot).color, 'gray') assert.equal(other.chatId, undefined) }) @@ -94,6 +95,26 @@ test("a friend Bot's generated look is seeded by its handle: with or without its assert.equal(kept.color, fallback.color) }) +test('lookBotForRemote seeds a distinct look from the handle when there is no chat record', () => { + const person = { handle: '@bot.friend.home', displayName: '助手', label: "助手 · Friend's Bot" } + const synthetic = lookBotForRemote(person, { bots: [] }) + assert.equal(synthetic.remote.handle, '@bot.friend.home') + assert.equal(synthetic.id, remoteSeed('@bot.friend.home')) + const look = lookOf(synthetic) + assert.notEqual(look.shape, undefined) + assert.notEqual(look.color, 'gray') + assert.notEqual(look.color, 'black') + const other = lookOf(lookBotForRemote({ handle: '@other.friend.home', displayName: 'Other' }, { bots: [] })) + assert.notEqual(look.shape + look.color, other.shape + other.color) + const pictured = lookBotForRemote({ + handle: '@bot.friend.home', displayName: '助手', + avatar: { image: 'https://example.test/friend.png' }, + }, { bots: [] }) + assert.equal(lookOf(pictured).image, 'https://example.test/friend.png') + const chat = { id: 'session-f', name: '助手', remote: { handle: '@bot.friend.home' }, color: 'green' } + assert.equal(lookBotForRemote(person, { bots: [chat] }), chat) +}) + test('handles equal modulo @ and case share one look; a stored look still wins', () => { const at = lookOf({ id: 'a', remote: { handle: '@Bot.X.home' } }) const bare = lookOf({ id: 'b', remote: { handle: 'bot.x.home' } }) diff --git a/patches/ds-bot/files/test/remote-room.test.js b/patches/ds-bot/files/test/remote-room.test.js index 74fb19fa..71ec2532 100644 --- a/patches/ds-bot/files/test/remote-room.test.js +++ b/patches/ds-bot/files/test/remote-room.test.js @@ -259,7 +259,7 @@ test('face-to-face room hosts room-only remotes and skips friend fanout', async expiresAt: '2026-10-15T10:10:00.000Z', members: [ { handle: '@writer.alice.home', displayName: 'Writer', ownerName: 'Alice', published: false }, - { handle: '@coder.bob.home', displayName: 'Coder', ownerName: 'Bob', published: false }, + { handle: '@coder.bob.home', displayName: 'Coder', ownerName: 'Bob', published: false, color: 'green', avatar: { image: 'https://example.test/coder.png' } }, ], localBots: [{ id: 'spec-a', handle: '@writer.alice.home', name: 'Writer A' }], }) @@ -270,6 +270,8 @@ test('face-to-face room hosts room-only remotes and skips friend fanout', async const remote = live.find(p => p.handle === '@coder.bob.home') assert.equal(remote.remote, true) assert.equal(remote.roomOnly, true) + assert.equal(remote.color, 'green') + assert.equal(remote.avatar.image, 'https://example.test/coder.png') const shown = presentRoom(room, { memberId: 'owner-1' }, rt.state) assert.equal(shown.viewerRole, 'member') assert.doesNotMatch(shown.displayName || '', /Shared room/) diff --git a/plugins/social/README.md b/plugins/social/README.md index be2b4f8e..bf1b0bbd 100644 --- a/plugins/social/README.md +++ b/plugins/social/README.md @@ -29,7 +29,7 @@ browser: `client.js`. Design: see "Social design: homes and members" in the root `flybot/social/auto-approve`). Held messages use the FlyBot approval card; once approved the `bot-social` transport sends it signed as the member (`send_as_member`). - **Inbound**: each member's inbox is polled; messages reach that Bot's chat as untrusted text (no owner approval). -- **Outbound hold (Bot Social)**: a draft the server keeps as `pending_approval` is listed on `social/status` (`pendingApprovals`, `approvals`) and in **Friends' Bots** (badge + Approve / Reject). The owner's click calls `decide_approval` (confirm code used in the same step). This is separate from FlyBot's local approval card. Auto-accept on a friendship skips the hold for later messages. Room messages (`face_room_message`) use the same queue; **Auto-send my Bot's room messages** (`social/face-auto` → `PATCH /v1/me { auto_approve_face_rooms }`) lets later room posts skip the hold. +- **Outbound hold (Bot Social)**: a draft the server keeps as `pending_approval` is listed on `social/status` (`pendingApprovals`, `approvals`) and in **Friends' Bots** (badge + Approve / Reject). The owner's click calls `decide_approval` (confirm code used in the same step). This is separate from FlyBot's local approval card. Auto-accept on a friendship skips the hold for later messages. Room messages (`face_room_message`) use the same queue; **Auto-send my Bot's room messages** (`social/face-auto` → MCP `whoami` / `set_auto_approve { auto_approve_face_rooms }`) lets later room posts skip the hold. Connector tokens cannot `GET`/`PATCH /v1/me`. - **Face-to-face rooms**: people next to each other join one group by typing the same 6-digit number. See Quick Start below. - **Tools** (every local Bot; an ungranted Bot's send is refused by core `reach()`): `list_friend_bots`, `message_friend_bot`. - **Legacy**: with `token` + `botId` and no home, the per-bot identity path @@ -86,6 +86,6 @@ Each Bot's settings also has **Invite to this Bot** (slot `flybot.bot.details`). - Host endpoint `social/status`: `{ connected, home, bots: [{ id, name, isMain, handle }], pendingApprovals, approvals, autoApproveFaceRooms }`. `approvals` is the outbound drafts Bot Social is holding; `pendingApprovals` is their count; `autoApproveFaceRooms` is the owner's Bot Social `auto_approve_face_rooms` flag. - `social/approvals` (read): `{ approvals }` — same queue, always freshly fetched. - `social/decide-approval` (owner click): `{ approvalId, decision: 'approve' | 'reject' }` → `{ approvalId, status }`. -- `social/face-auto` (owner click): `{ on: true | false }` → `{ autoApproveFaceRooms }` (`PATCH /v1/me`). Omit `on` to read the current flag. If Bot Social answers `confirmation_required`, the same click retries with `confirm_code`; the UI never asks for a 6-character code. +- `social/face-auto` (owner click): `{ on: true | false }` → `{ autoApproveFaceRooms }` (MCP `set_auto_approve`; omit `on` to read via `whoami`). If Bot Social answers `confirmation_required`, the same click retries with `confirm_code`; the UI never asks for a 6-character code. These are owner UI actions only and are never model tools. The server's one-time confirm code is requested and used in the same click. diff --git a/plugins/social/client.js b/plugins/social/client.js index 6bed73a6..af984d20 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -92,7 +92,8 @@ window.__ModuleLoader__.load({ .fs-digits:focus{border-color:var(--bt-accent,#4d6bfe)} .fs-face{margin:4px 4px 6px;padding:8px 10px;border:1px solid var(--bt-line-2,rgba(20,20,20,.15));border-radius:10px;background:var(--bt-card,#fff);display:flex;flex-direction:column;gap:6px} .fs-face-members{margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:2px} -.fs-face-members li{font-size:13px;line-height:18px;padding:2px 0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.fs-face-members li{display:flex;align-items:center;gap:8px;font-size:13px;line-height:18px;padding:2px 0;min-width:0} +.fs-face-members .fs-member-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .fs-face-actions{display:flex;gap:6px;flex-wrap:wrap} .fs-face-auto-wrap{display:flex;flex-direction:column;gap:2px;padding:2px 0} .fs-face-auto{white-space:normal;font-size:13px;line-height:18px;color:var(--bt-ink,#141414);align-items:flex-start} @@ -336,7 +337,7 @@ window.__ModuleLoader__.load({ hasFace && !autoSend ? h('div', { className: 'fs-face-actions' }, h('button', { type: 'button', className: 'fs-btn fs-btn-sm', 'data-flybot': 'face-auto-send-on', disabled: locked, onClick: () => setAuto(true) }, 'Turn on auto-send')) : null, items.map(item => h('div', { key: item.id, className: 'fs-row', 'data-flybot': 'pending-item' }, - h('span', { className: 'fs-avatar', 'aria-hidden': true }, initial(item.from ?? item.toName ?? item.to)), + h(FaceAvatar, { bot: faceLook({ member: { handle: item.to, displayName: item.from || item.toName || item.to } }), size: 24 }), h('span', { className: 'fs-text' }, h('span', { className: 'fs-name' }, item.from ? `${item.from} → ${item.toName || item.to || 'friend'}` : (item.toName || item.to || 'Outbound reply')), item.text ? h('span', { className: 'fs-draft' }, item.text) : (item.summary ? h('span', { className: 'fs-sub fs-msg' }, item.summary) : null), @@ -559,7 +560,7 @@ window.__ModuleLoader__.load({ status ? h('div', { className: 'fs-status', role: 'status', 'data-tone': status.tone }, status.text) : null))) } - function FaceRoomCard({ room, actions, onChange, autoSend, autoBusy, autoError, onAutoSend }) { + function FaceRoomCard({ room, actions, onChange, autoSend, autoBusy, autoError, onAutoSend, roster, remoteChats } = {}) { const [busy, setBusy] = useState('') const [note, setNote] = useState(null) const run = async (label, fn) => { @@ -577,7 +578,14 @@ window.__ModuleLoader__.load({ h('div', { className: 'fs-name' }, room.name || 'Face-to-face room'), candidate && room.expiresAt ? h('div', { className: 'fs-sub' }, countdown(room.expiresAt)) : h('div', { className: 'fs-sub' }, 'The number is no longer valid.'), h('ul', { className: 'fs-face-members', 'aria-label': 'Members' }, - members.map(m => h('li', { key: m.handle || m.id }, m.label || m.displayName || m.handle))), + members.map(m => h('li', { key: m.handle || m.id, 'data-flybot': 'face-card-member', 'data-handle': m.handle }, + h(FaceAvatar, { + bot: m.remote === false || m.id && roster?.byId?.[m.id] + ? faceLook({ roster, local: roster?.byId?.[m.id] || { id: m.id, name: m.displayName } }) + : faceLook({ roster, remoteChats, member: m }), + size: 24, + }), + h('span', { className: 'fs-member-name' }, m.label || m.displayName || m.handle)))), h(FaceAutoSend, { on: autoSend, busy: autoBusy, onChange: onAutoSend, error: autoError }), h('div', { className: 'fs-face-actions' }, h('button', { type: 'button', className: 'fs-btn fs-btn-sm', disabled: !!busy, onClick: () => { if (room.roomId) actions?.openSession?.(room.roomId) } }, 'Open'), @@ -691,7 +699,9 @@ window.__ModuleLoader__.load({ }, chat && typeof renderBot === 'function' ? renderBot(chat) - : h('div', { className: 'fs-hint', title: bot.handle }, `${bot.name} · opening its chat…`), + : h('div', { className: 'fs-row', title: bot.handle }, + h(FaceAvatar, { bot: faceLook({ member: { handle: bot.handle, displayName: bot.name } }), size: 24 }), + h('span', { className: 'fs-text' }, h('span', { className: 'fs-name' }, bot.name), h('span', { className: 'fs-sub' }, bot.handle))), h('button', { type: 'button', className: 'fs-remove', @@ -717,7 +727,7 @@ window.__ModuleLoader__.load({ return seconds === 1 ? '1 second left to join' : `${seconds} seconds left to join` } - function FaceRooms({ actions, enabled = true } = {}) { + function FaceRooms({ actions, enabled = true, roster, remoteChats } = {}) { const [rooms, setRooms] = useState([]) const [autoSend, setAutoSend] = useState(false) const [autoBusy, setAutoBusy] = useState(false) @@ -753,7 +763,7 @@ window.__ModuleLoader__.load({ h('div', { className: 'fs-label' }, 'Face-to-face rooms'), rooms.map(room => h(FaceRoomCard, { key: room.faceRoomId || room.roomId, room, actions, onChange: load, - autoSend, autoBusy, autoError, onAutoSend: toggle, + autoSend, autoBusy, autoError, onAutoSend: toggle, roster, remoteChats, }))) } @@ -911,7 +921,7 @@ window.__ModuleLoader__.load({ unavailable ? null : h('button', { type: 'button', className: 'fs-icon', 'aria-expanded': adding, title: 'Add a friend', onClick: () => { setAdding(!adding); setOpen(true) } }, adding ? 'Done' : '+ Add')), open ? h('div', { className: 'fs-body' }, h(PendingApprovals, { items: pending, busy: saving, onDecide: decide }), - unavailable ? null : h(FaceRooms, { actions, enabled: open }), + unavailable ? null : h(FaceRooms, { actions, enabled: open, roster, remoteChats }), adding && !unavailable ? h('div', { className: 'fs-card', 'data-flybot': 'face-group-entry' }, h('button', { type: 'button', className: 'fs-btn fs-btn-primary', 'data-flybot': 'face-group-open', onClick: () => setFaceOpen(true) }, 'Face-to-face group'), h('div', { className: 'fs-hint' }, 'Meet the people next to you. Pick your Bots and type the same 6-digit number.')) : null, @@ -1002,7 +1012,7 @@ window.__ModuleLoader__.load({ incoming.length ? h('div', { className: 'bt-social-requests' }, h('div', { className: 'fs-label' }, 'Friend requests', h('span', { className: 'fs-count' }, String(incoming.length))), incoming.map(item => h('div', { key: item.relationshipId, className: 'fs-row bt-social-row' }, - h('span', { className: 'fs-avatar', 'aria-hidden': true }, initial(item.name ?? item.handle)), + h(FaceAvatar, { bot: faceLook({ member: { handle: item.handle, displayName: item.name } }), size: 24 }), h('span', { className: 'fs-text' }, h('span', { className: 'fs-name' }, item.name ?? item.handle), h('span', { className: 'fs-sub' }, item.handle), @@ -1013,7 +1023,7 @@ window.__ModuleLoader__.load({ outgoing.length ? h('div', { className: 'bt-social-outgoing' }, h('div', { className: 'fs-label' }, 'Waiting for them to accept'), outgoing.map(item => h('div', { key: item.relationshipId ?? item.handle, className: 'fs-row' }, - h('span', { className: 'fs-avatar', 'aria-hidden': true }, initial(item.name ?? item.handle)), + h(FaceAvatar, { bot: faceLook({ member: { handle: item.handle, displayName: item.name } }), size: 24 }), h('span', { className: 'fs-text' }, h('span', { className: 'fs-name' }, item.name ?? item.handle), item.name ? h('span', { className: 'fs-sub' }, item.handle) : null), @@ -1145,6 +1155,7 @@ window.__ModuleLoader__.load({ FaceToFaceGroup, FaceAvatar, FaceRoomOverview, + FaceRoomCard, FaceAutoSend, apply(ctx) { let offs = [] diff --git a/plugins/social/src/face-rooms.js b/plugins/social/src/face-rooms.js index 9c6e08dd..d9cb3e02 100644 --- a/plugins/social/src/face-rooms.js +++ b/plugins/social/src/face-rooms.js @@ -1,7 +1,10 @@ // Face-to-face rooms (#83): join by a 6-digit number on Bot Social. This module is the -// client contract — REST against docs/FACE_ROOMS.md — plus error mapping, event rendering -// and sanitization of peer-controlled strings. The host (social.js) calls the server with -// the connector token; the browser shows the friendly copy these helpers return. +// client contract — REST against docs/FACE_ROOMS.md for room join/list/get/start/leave/ +// messages (those routes accept the connector token) — plus error mapping, event rendering +// and sanitization of peer-controlled strings. Owner settings (`auto_approve_face_rooms`) +// go through MCP `whoami` / `set_auto_approve` in social.js: REST GET/PATCH /v1/me rejects +// connector tokens with 403 `connector_token_rest_forbidden`. The host (social.js) calls +// the server with the connector token; the browser shows the friendly copy these helpers return. // // Peer text is untrusted: fence it before anything model-visible. Platform system copy // (joined / left / started) is sanitized the same way as a display name. @@ -95,7 +98,7 @@ const FRIENDLY = { room_closed: 'This room has closed.', not_a_member: 'You are no longer in this room.', dlp_blocked: 'That message was blocked and was not sent.', - connector_token_rest_forbidden: 'Bot Social would not save that setting with this connection. Restart FlyBot or reconnect in Friends\' Bots.', + connector_token_rest_forbidden: 'This Bot Social connection cannot use that request. Try again, or reconnect in Friends\' Bots.', confirmation_required: 'Bot Social asked for a confirmation. Try the toggle again.', handle_taken: 'That Bot Social handle is already taken. Rename the Bot and try again.', } @@ -160,19 +163,6 @@ export function viewMe(body) { return { autoApproveFaceRooms: body.auto_approve_face_rooms === true } } -function confirmationCode(value) { - if (!value || typeof value !== 'object') return '' - if (value instanceof FaceRoomError) return String(value.confirmCode || '') - if (value.status === 'confirmation_required' || value.code === 'confirmation_required') { - return String(value.confirm_code || value.confirmCode || '') - } - const err = value.error - if (err && typeof err === 'object' && err.code === 'confirmation_required') { - return String(err.confirm_code || err.confirmCode || value.confirm_code || '') - } - return '' -} - // Face join of my own Bot is a room-scoped grant. True when this Bot has no person-grant. export function roomOnlyForJoin(bot, hasAnyGrant) { if (typeof hasAnyGrant === 'function') return hasAnyGrant(typeof bot === 'string' ? bot : bot?.id) !== true @@ -398,10 +388,6 @@ export function createFaceRoomApi({ server, token, fetch: fetchImpl = socialFetc return callHttp(method, `${base}${path}`, body) } - async function patchMe(payload) { - return callHttp('PATCH', `${origin}/v1/me`, payload) - } - return { async join({ code, bots } = {}) { const digits = String(code ?? '').trim() @@ -446,27 +432,5 @@ export function createFaceRoomApi({ server, token, fetch: fetchImpl = socialFetc from: viewFaceMember(body.from), } }, - async me() { - return viewMe(await callHttp('GET', `${origin}/v1/me`)) ?? { autoApproveFaceRooms: false } - }, - // Owner click is the consent. If Bot Social answers confirmation_required, the - // confirm_code is requested and used in this same call — the UI never asks for it. - async setAutoApproveFaceRooms(on) { - const payload = { auto_approve_face_rooms: Boolean(on) } - let body - try { - body = await patchMe(payload) - } catch (error) { - const code = confirmationCode(error) - if (!code) throw error - body = await patchMe({ ...payload, confirm_code: code }) - } - const retry = confirmationCode(body) - if (retry) body = await patchMe({ ...payload, confirm_code: retry }) - if (confirmationCode(body)) { - throw new FaceRoomError(FRIENDLY.confirmation_required, 'confirmation_required') - } - return viewMe(body) ?? { autoApproveFaceRooms: Boolean(on) } - }, } } diff --git a/plugins/social/src/mcp-client.js b/plugins/social/src/mcp-client.js index 8b261593..c33e5281 100644 --- a/plugins/social/src/mcp-client.js +++ b/plugins/social/src/mcp-client.js @@ -207,6 +207,9 @@ export function createSocialApi({ server, token, fetch: fetchImpl = socialFetch, let data try { data = text ? JSON.parse(text) : {} } catch { data = { message: text } } if (!response.ok) throw new BotSocialError(`Bot Social ${method} ${path}: HTTP ${response.status}`, `http_${response.status}`) + // Grant REST is a last resort when the MCP tool is missing. Connector tokens are + // forbidden on those /v1 routes (403 connector_token_rest_forbidden); callers catch + // and fall through. Face-room REST lives in face-rooms.js and is allowed. return data } @@ -265,6 +268,7 @@ export function createSocialApi({ server, token, fetch: fetchImpl = socialFetch, removeFriend: args => mcp.callTool('remove_friend', args), listApprovals: args => mcp.callTool('list_approvals', args), decideApproval: args => mcp.callTool('decide_approval', args), + whoami: args => mcp.callTool('whoami', args ?? {}), setAutoApprove: args => mcp.callTool('set_auto_approve', args), addHomeMember: args => mcp.callTool('add_home_member', args), removeHomeMember: args => mcp.callTool('remove_home_member', args), diff --git a/plugins/social/src/social.js b/plugins/social/src/social.js index 35d6d7d7..18150d12 100644 --- a/plugins/social/src/social.js +++ b/plugins/social/src/social.js @@ -41,6 +41,7 @@ import { FaceRoomError, roomOnlyForJoin, faceMemberLabel, + viewMe, } from './face-rooms.js' import { hideRemoteInSettings, @@ -223,22 +224,43 @@ export function createSocial(service, config = {}, { client: given, api: givenAp const warn = (...args) => console.warn('flybot-social:', ...args) let faceAutoCache = { at: 0, on: false, known: false } async function faceAutoApprove({ fresh = false } = {}) { - if (!faceApi) return false + if (!config.server || !token) return false if (!fresh && faceAutoCache.known && now() - faceAutoCache.at < 5_000) return faceAutoCache.on try { - const me = await faceApi.me() - faceAutoCache = { at: now(), on: me?.autoApproveFaceRooms === true, known: true } + const me = await client.callTool('whoami') + faceAutoCache = { at: now(), on: viewMe(me)?.autoApproveFaceRooms === true, known: true } return faceAutoCache.on } catch (error) { warn('face auto-approve status failed', error) return faceAutoCache.known ? faceAutoCache.on : false } } + function mapFaceAutoError(error) { + if (error instanceof FaceRoomError) return error + const code = String(error?.code ?? '') + const msg = String(error?.message ?? error ?? '') + if (code === 'unknown' || /unknown tool|no tool|not found|unrecognized/i.test(msg)) { + return new Error('This Bot Social server cannot save Auto-send yet. Update Bot Social and try again.') + } + if (code === 'http_401' || code === 'unauthorized' || /401|unauthori/i.test(code + msg)) { + return new FaceRoomError('Bot Social signed you out. Reconnect in Friends\' Bots, or restart FlyBot.', 'unauthorized', { status: 401 }) + } + const raw = msg.replace(/^Bot Social\s+\S+:\s*/, '').replace(/\s+/g, ' ').trim().slice(0, 200) + if (/connector_token_rest_forbidden/.test(code + raw)) { + return new Error('Could not save Auto-send. Try again.') + } + return new Error(raw && !/^[a-z_]+$/.test(raw) ? raw : 'Could not save Auto-send. Try again.') + } async function setFaceAutoApprove(on) { - if (!faceApi) throw new Error('Bot Social is not connected') - const result = await faceApi.setAutoApproveFaceRooms(on) - faceAutoCache = { at: now(), on: result?.autoApproveFaceRooms === true, known: true } - return { autoApproveFaceRooms: faceAutoCache.on } + if (!config.server || !token) throw new Error('Bot Social is not connected') + try { + const result = await confirmedCall('set_auto_approve', { auto_approve_face_rooms: Boolean(on) }) + const next = viewMe(result)?.autoApproveFaceRooms === true + faceAutoCache = { at: now(), on: next, known: true } + return { autoApproveFaceRooms: next } + } catch (error) { + throw mapFaceAutoError(error) + } } const maxFileBytes = config.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES const filesDir = config.filesDir ?? join(config.credentialsPath ? dirname(config.credentialsPath) : tmpdir(), 'flybot-social-files') @@ -1303,6 +1325,8 @@ export function createSocial(service, config = {}, { client: given, api: givenAp remote: p.remote === true, roomOnly: p.roomOnly === true || p.published !== true, label: faceMemberLabel({ displayName: p.displayName, ownerName: p.ownerName, handle: p.handle }) || p.displayName || p.id, + ...(p.color ? { color: p.color } : {}), + ...(p.avatar ? { avatar: p.avatar } : {}), })), ...extra, } @@ -1321,6 +1345,8 @@ export function createSocial(service, config = {}, { client: given, api: givenAp displayName: m.displayName, ownerName: m.ownerName, roomOnly: m.roomOnly === true || m.published !== true, + ...(m.color ? { color: m.color } : {}), + ...(m.avatar ? { avatar: m.avatar } : {}), })), localBots: locals, }) diff --git a/plugins/social/test/face-rooms.test.js b/plugins/social/test/face-rooms.test.js index 9d5c3a5c..9c0686d3 100644 --- a/plugins/social/test/face-rooms.test.js +++ b/plugins/social/test/face-rooms.test.js @@ -54,7 +54,7 @@ test('error mapping covers the FACE_ROOMS.md codes', () => { const lock = mapFaceError({ error: { code: 'locked_out', retry_after: 900 } }, 429) assert.equal(lock.message, 'Too many failed attempts. Try again in 15 minutes.') assert.doesNotMatch(mapFaceError({ error: { code: 'nope', message: 'raw_sql' } }, 500).message, /raw_sql/) - assert.match(mapFaceError({ error: { code: 'connector_token_rest_forbidden' } }, 403).message, /would not save that setting/) + assert.match(mapFaceError({ error: { code: 'connector_token_rest_forbidden' } }, 403).message, /cannot use that request/) assert.equal(mapFaceError({ error: { code: 'confirmation_required', confirm_code: 'AB12CD' } }, 403).confirmCode, 'AB12CD') assert.equal(mapFaceError({ error: { code: 'handle_taken' } }, 409).message, 'That Bot Social handle is already taken. Rename the Bot and try again.') const taken = mapRegisterError(Object.assign(new Error('handle taken'), { code: 'handle_taken' }), { name: 'Writer' }) @@ -148,28 +148,13 @@ function faceServer() { const rooms = new Map() const codes = new Map() const calls = [] - const me = { auto_approve_face_rooms: false, confirm: false, confirmError: false, forbidden: false } const fetch = async (url, init) => { calls.push({ url, method: init.method, body: init.body ? JSON.parse(init.body) : undefined, auth: init.headers.authorization }) if (init.headers.authorization !== 'Bearer otk_test') { return new Response(JSON.stringify({ error: { code: 'unauthorized' } }), { status: 401 }) } if (String(url).includes('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/v1/me')) { - if (me.forbidden) { - return new Response(JSON.stringify({ error: { code: 'connector_token_rest_forbidden' } }), { status: 403 }) - } - if (init.method === 'GET') { - return new Response(JSON.stringify({ auto_approve_face_rooms: me.auto_approve_face_rooms }), { status: 200 }) - } - const body = init.body ? JSON.parse(init.body) : {} - if (me.confirm && body.confirm_code === undefined) { - return new Response(JSON.stringify({ status: 'confirmation_required', confirm_code: 'MECODE' }), { status: 200 }) - } - if (me.confirmError && body.confirm_code === undefined) { - return new Response(JSON.stringify({ error: { code: 'confirmation_required', confirm_code: 'MEERR' } }), { status: 403 }) - } - if (typeof body.auto_approve_face_rooms === 'boolean') me.auto_approve_face_rooms = body.auto_approve_face_rooms - return new Response(JSON.stringify({ auto_approve_face_rooms: me.auto_approve_face_rooms }), { status: 200 }) + return new Response(JSON.stringify({ error: { code: 'connector_token_rest_forbidden', message: 'connector tokens only work at /mcp' } }), { status: 403 }) } const path = url.replace(/^https:\/\/s\/v1\/face-rooms/, '') || '' if (init.method === 'POST' && path === '/join') { @@ -250,7 +235,7 @@ function faceServer() { } return new Response(JSON.stringify({ error: { code: 'invalid_request' } }), { status: 400 }) } - return { fetch, calls, rooms, me } + return { fetch, calls, rooms } } test('REST join, poll, start, leave, and pending_approval send', async () => { @@ -304,30 +289,15 @@ test('viewFaceMember keeps a friend Bot avatar when the payload has one', () => assert.equal(faceMemberLook({ avatar: { image: 'javascript:alert(1)' } }).avatar, undefined) }) -test('GET/PATCH /v1/me auto_approve_face_rooms, including in-place confirmation', async () => { - const server = faceServer() - const api = createFaceRoomApi({ server: 'https://s', token: 'otk_test', fetch: server.fetch }) +test('viewMe maps auto_approve_face_rooms; face-room REST still works when /v1/me is 403', async () => { assert.equal(viewMe({ auto_approve_face_rooms: true }).autoApproveFaceRooms, true) assert.equal(viewMe({}).autoApproveFaceRooms, false) - assert.equal((await api.me()).autoApproveFaceRooms, false) - const on = await api.setAutoApproveFaceRooms(true) - assert.equal(on.autoApproveFaceRooms, true) - assert.equal((await api.me()).autoApproveFaceRooms, true) - assert.equal((await api.setAutoApproveFaceRooms(false)).autoApproveFaceRooms, false) - server.me.confirm = true - const confirmed = await api.setAutoApproveFaceRooms(true) - assert.equal(confirmed.autoApproveFaceRooms, true) - const patches = server.calls.filter(c => c.method === 'PATCH' && String(c.url).endsWith('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/v1/me')) - assert.equal(patches.at(-2).body.confirm_code, undefined) - assert.equal(patches.at(-1).body.confirm_code, 'MECODE') - assert.equal(patches.at(-1).body.auto_approve_face_rooms, true) - server.me.confirm = false - server.me.confirmError = true - const viaError = await api.setAutoApproveFaceRooms(false) - assert.equal(viaError.autoApproveFaceRooms, false) - const errPatch = server.calls.filter(c => c.method === 'PATCH' && String(c.url).endsWith('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/v1/me')).at(-1) - assert.equal(errPatch.body.confirm_code, 'MEERR') - server.me.confirmError = false - server.me.forbidden = true - await assert.rejects(() => api.setAutoApproveFaceRooms(true), (e) => e instanceof FaceRoomError && e.code === 'connector_token_rest_forbidden' && /would not save that setting/.test(e.message)) + const server = faceServer() + const api = createFaceRoomApi({ server: 'https://s', token: 'otk_test', fetch: server.fetch }) + assert.equal(typeof api.me, 'undefined') + assert.equal(typeof api.setAutoApproveFaceRooms, 'undefined') + const room = await api.join({ code: '123456', bots: ['@chief.me.home'] }) + assert.equal(room.roomId, 'frm_123456') + assert.equal(server.calls.some(c => String(c.url).includes('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/v1/me')), false) + assert.equal(server.calls.some(c => c.method === 'POST' && String(c.url).includes('/v1/face-rooms/join')), true) }) diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index 88856edc..444b3d62 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -64,6 +64,16 @@ function mockServer({ token = 'owner-token', friends = true } = {}) { ] }), start_conversation: a => ({ conversation: { conversation_id: 'legacy-c' }, message: { message_id: `m-${a.friend}` } }), send_message: a => ({ message_id: `m-${a.conversation_id}` }), + whoami: () => ({ kind: 'owner', auto_approve_face_rooms: me.autoApproveFaceRooms }), + set_auto_approve: a => { + if (typeof a.auto_approve_face_rooms === 'boolean') { + if (me.confirm && a.confirm_code === undefined) return { status: 'confirmation_required', confirm_code: 'MECODE' } + me.autoApproveFaceRooms = a.auto_approve_face_rooms + return { auto_approve_face_rooms: me.autoApproveFaceRooms } + } + if (a.confirm_code === undefined) return { status: 'confirmation_required', confirm_code: 'C0DE03' } + return { relationship_id: a.relationship_id, auto_approve_chat: a.auto_approve_chat } + }, } const fetch = async (url, init) => { if (url.endsWith('/v1/onboard/flybot')) { @@ -80,17 +90,7 @@ function mockServer({ token = 'owner-token', friends = true } = {}) { if (String(url).includes('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/v1/me')) { const meBody = init.body ? JSON.parse(init.body) : {} calls.push({ url, name: `REST ${init.method} /v1/me`, args: meBody }) - if (init.headers.authorization !== `Bearer ${token}`) return new Response(JSON.stringify({ error: { code: 'unauthorized' } }), { status: 401 }) - if (init.method === 'GET') { - return new Response(JSON.stringify({ auto_approve_face_rooms: me.autoApproveFaceRooms }), { status: 200 }) - } - if (init.method === 'PATCH') { - if (me.confirm && meBody.confirm_code === undefined) { - return new Response(JSON.stringify({ status: 'confirmation_required', confirm_code: 'MECODE' }), { status: 200 }) - } - if (typeof meBody.auto_approve_face_rooms === 'boolean') me.autoApproveFaceRooms = meBody.auto_approve_face_rooms - return new Response(JSON.stringify({ auto_approve_face_rooms: me.autoApproveFaceRooms }), { status: 200 }) - } + return new Response(JSON.stringify({ error: { code: 'connector_token_rest_forbidden', message: 'connector tokens only work at /mcp' } }), { status: 403 }) } if (init.headers.authorization !== `Bearer ${token}`) return new Response('{}', { status: 401 }) const body = JSON.parse(init.body) @@ -1381,7 +1381,7 @@ test('the sidebar + menu gets a Face-to-face group action from social', () => { assert.equal(typeof row[1].run, 'function') }) -test('face-to-face auto-send maps to PATCH /v1/me and swallows confirmation', async (t) => { +test('face-to-face auto-send uses MCP whoami / set_auto_approve and ignores REST 403 /v1/me', async (t) => { const { ctx, call, stop } = await team() t.after(stop) const server = withFaceRooms(mockServer()) @@ -1396,12 +1396,15 @@ test('face-to-face auto-send maps to PATCH /v1/me and swallows confirmation', as await social.registerAll() const status = (await call('flybot/social/status', {})).value assert.equal(status.autoApproveFaceRooms, false) + assert.equal(server.calls.some(c => c.name === 'whoami'), true) const on = (await call('flybot/social/face-auto', { on: true })).value assert.equal(on.autoApproveFaceRooms, true) - const patches = server.calls.filter(c => c.name === 'REST PATCH /v1/me') - assert.equal(patches[0].args.auto_approve_face_rooms, true) - assert.equal(patches[0].args.confirm_code, undefined) - assert.equal(patches[1].args.confirm_code, 'MECODE') + const restMe = server.calls.filter(c => String(c.name).includes('REST') && String(c.name).includes('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/v1/me')) + assert.equal(restMe.length, 0) + const writes = server.calls.filter(c => c.name === 'set_auto_approve' && typeof c.args.auto_approve_face_rooms === 'boolean') + assert.equal(writes[0].args.auto_approve_face_rooms, true) + assert.equal(writes[0].args.confirm_code, undefined) + assert.equal(writes[1].args.confirm_code, 'MECODE') assert.equal((await call('flybot/social/status', {})).value.autoApproveFaceRooms, true) server.me.confirm = false const off = (await call('flybot/social/face-auto', { on: false })).value @@ -1410,6 +1413,23 @@ test('face-to-face auto-send maps to PATCH /v1/me and swallows confirmation', as assert.equal(listed.autoApproveFaceRooms, false) }) +test('face-to-face auto-send shows a clear error when MCP write fails', async (t) => { + const { ctx, call, stop } = await team() + t.after(stop) + const server = withFaceRooms(mockServer()) + delete server.tools.set_auto_approve + const social = createSocial(ctx.flybot, { server: 'https://s', token: 'owner-token', fetch: server.fetch, inboxPollMs: 0, autoAccept: false }, { + client: createMcpClient({ server: 'https://s', token: 'owner-token', fetch: server.fetch }), + home: { id: 'h1', handle: '@danyang.home' }, + }) + t.after(() => social.dispose()) + await grantTeam(ctx.flybot) + const failed = await call('flybot/social/face-auto', { on: true }) + assert.equal(failed.ok, false) + assert.match(failed.error.message, /cannot save Auto-send yet/) + assert.equal(server.calls.filter(c => String(c.name).includes('REST') && String(c.name).includes('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/v1/me')).length, 0) +}) + test('Friends\' Bots (#83): auto-send toggle in the room header and pending-approval state', async () => { const registered = [] const entries = [] @@ -1472,6 +1492,41 @@ test('Friends\' Bots (#83): auto-send toggle in the room header and pending-appr assert.deepEqual(decided, [['apr-face-1', 'approve']]) }) +test('Face-to-face room card members use host avatars, not a shared letter badge', () => { + const { walk, pluginOf } = loadSocialClient(async () => ({})) + const roster = { + byId: { a: { id: 'a', name: 'Chief', color: 'blue', avatar: { shape: 'whale' } } }, + bots: [{ id: 'a', name: 'Chief', color: 'blue', avatar: { shape: 'whale' } }], + } + const remoteChats = [{ + id: 'r-coder', name: 'Coder', remote: { handle: '@coder.bob.home' }, + color: 'green', avatar: { image: 'https://example.test/coder.png' }, + }] + const plugin = pluginOf([]) + const tree = walk(plugin.FaceRoomCard({ + room: { + name: 'Face-to-face room', status: 'open', + members: [ + { id: 'a', handle: '@a.me.home', displayName: 'Chief', ownerName: 'Me', remote: false, label: "Me's Chief" }, + { handle: '@coder.bob.home', displayName: 'Coder', ownerName: 'Bob', remote: true, label: "Coder · Bob's Bot" }, + { handle: '@bot.friend.home', displayName: '助手', ownerName: 'Friend', remote: true, label: "助手 · Friend's Bot" }, + ], + }, + roster, remoteChats, + })) + const rows = tree.filter(n => n.props['data-flybot'] === 'face-card-member') + assert.equal(rows.length, 3) + const mine = tree.find(n => n.props['data-flybot'] === 'bot-avatar' && n.props['data-bot-id'] === 'a') + const friendChat = tree.find(n => n.props['data-flybot'] === 'bot-avatar' && n.props['data-handle'] === '@coder.bob.home') + const friendSeed = tree.find(n => n.props['data-flybot'] === 'bot-avatar' && n.props['data-handle'] === '@bot.friend.home') + assert.ok(mine) + assert.equal(mine.props['data-shape'], 'whale') + assert.ok(friendChat) + assert.equal(friendChat.props['data-image'], 'https://example.test/coder.png') + assert.ok(friendSeed, "a friend's Bot without a chat still gets a handle-seeded avatar") + assert.equal(tree.filter(n => n.props.className === 'fs-avatar' && n.props['data-flybot'] === undefined).length, 0) +}) + test('Bot settings: Manage access lists grants and revokes one', async () => { const registered = [] const calls = []