diff --git a/changelog.d/137-bot-avatars.added.md b/changelog.d/137-bot-avatars.added.md new file mode 100644 index 000000000..bbe516819 --- /dev/null +++ b/changelog.d/137-bot-avatars.added.md @@ -0,0 +1 @@ +Owner-chosen Bot avatar and color sync to Bot Social (`update_bot_profile`) and show on friends' Bots, groups, and face-to-face rooms. Peers without a look keep the handle-seeded palette. ([#137](https://github.com/ThinkFlowLab/FlyBot/issues/137)) diff --git a/changelog.d/139-decline-grant.fixed.md b/changelog.d/139-decline-grant.fixed.md new file mode 100644 index 000000000..edb5ffad4 --- /dev/null +++ b/changelog.d/139-decline-grant.fixed.md @@ -0,0 +1 @@ +Removing a friend's Bot declines that grant on Bot Social so it leaves the owner's Manage access; an older server still hides it locally. Re-join after a decline can restore access (`regranted`). ([#139](https://github.com/ThinkFlowLab/FlyBot/issues/139)) diff --git a/core/client.js b/core/client.js index ba880469e..d6d94cd3a 100644 --- a/core/client.js +++ b/core/client.js @@ -1540,10 +1540,10 @@ var lookSeed = (bot) => typeof bot?.remote?.handle === "string" && bot.remote.ha 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; + if (chat) return Object.keys(extra).length ? { ...chat, ...extra } : chat; 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 }; @@ -1555,6 +1555,7 @@ function lookOf(bot) { return { shape: hasShape(avatar.shape) ? avatar.shape : seed !== void 0 ? defaultShape(seed) : FALLBACK_SHAPE, image: avatar.image, + ...typeof avatar.emoji === "string" && avatar.emoji ? { emoji: avatar.emoji } : {}, color: colorOf(bot) }; } @@ -1662,7 +1663,7 @@ function BotMark({ bot, look: given, size = 36, state = "idle", live = false, ga const common = { ref, className: poked ? "bt-mark bt-mark-poke" : "bt-mark", - "data-shape": look.image ? "image" : shapeId(look.shape), + "data-shape": look.image ? "image" : look.emoji ? "emoji" : shapeId(look.shape), "data-state": STATES.includes(state) ? state : "idle", "data-live": live ? "" : void 0, "data-fidget": live ? FIDGETS[fnv1a(`${bot?.id ?? ""}:fidget`) % FIDGETS.length] : void 0, @@ -1678,6 +1679,12 @@ function BotMark({ bot, look: given, size = 36, state = "idle", live = false, ga } : void 0 }; if (look.image) return (0, import_react.createElement)("span", common, (0, import_react.createElement)("img", { className: "bt-mark-img", src: look.image, alt: "", draggable: false })); + if (look.emoji) { + return (0, import_react.createElement)("span", common, (0, import_react.createElement)("span", { + className: "bt-mark-emoji", + style: { fontSize: Math.max(12, Math.round(size * 0.62)), lineHeight: 1 } + }, look.emoji)); + } const box = shapeOf(look.shape).size; return (0, import_react.createElement)("span", common, (0, import_react.createElement)("svg", { viewBox: `0 0 ${box} ${box}`, dangerouslySetInnerHTML: { __html: characterMarkup(look.shape, look.color).replaceAll(MASK_ID, maskId.current) } })); } @@ -2574,6 +2581,7 @@ ${Object.entries(INKS).map(([name, [, dark]]) => `body[data-ds-dark-theme]{--bt- .bt-mark{display:inline-block;flex:none;position:relative;line-height:0} .bt-mark svg{width:100%;height:100%;overflow:visible} .bt-mark-img{width:100%;height:100%;border-radius:50%;object-fit:cover;display:block;box-shadow:inset 0 0 0 1px var(--bt-line)} +.bt-mark-emoji{width:100%;height:100%;border-radius:50%;display:flex;align-items:center;justify-content:center;line-height:1;user-select:none} .bt-mark .bt-b,.bt-mark .bt-eye,.bt-mark .bt-eyes,.bt-mark .bt-gaze{transform-box:fill-box} .bt-mark .bt-b{transform-origin:50% 100%} .bt-mark .bt-eye,.bt-mark .bt-eyes{transform-origin:center} diff --git a/core/src/client/characters.js b/core/src/client/characters.js index 5ff2b5679..4e548bbb0 100644 --- a/core/src/client/characters.js +++ b/core/src/client/characters.js @@ -76,10 +76,11 @@ const lookSeed = bot => (typeof bot?.remote?.handle === 'string' && bot.remote.h 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 + // A face-room members[] look (or a published roster look) wins over the chat record. + if (chat) return Object.keys(extra).length ? { ...chat, ...extra } : chat 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 } @@ -94,6 +95,7 @@ export function lookOf(bot) { return { shape: hasShape(avatar.shape) ? avatar.shape : seed !== undefined ? defaultShape(seed) : FALLBACK_SHAPE, image: avatar.image, + ...(typeof avatar.emoji === 'string' && avatar.emoji ? { emoji: avatar.emoji } : {}), color: colorOf(bot), } } diff --git a/core/src/client/mark.js b/core/src/client/mark.js index 1e54ba70a..d516bc20e 100644 --- a/core/src/client/mark.js +++ b/core/src/client/mark.js @@ -56,7 +56,7 @@ export function BotMark({ bot, look: given, size = 36, state = 'idle', live = fa const common = { ref, className: poked ? 'bt-mark bt-mark-poke' : 'bt-mark', - 'data-shape': look.image ? 'image' : shapeId(look.shape), + 'data-shape': look.image ? 'image' : look.emoji ? 'emoji' : shapeId(look.shape), 'data-state': STATES.includes(state) ? state : 'idle', 'data-live': live ? '' : undefined, 'data-fidget': live ? FIDGETS[fnv1a(`${bot?.id ?? ''}:fidget`) % FIDGETS.length] : undefined, @@ -70,6 +70,12 @@ export function BotMark({ bot, look: given, size = 36, state = 'idle', live = fa onAnimationEnd: poked ? (event) => { if (/^bt-poke/.test(event.animationName)) setPoked(false) } : undefined, } if (look.image) return h('span', common, h('img', { className: 'bt-mark-img', src: look.image, alt: '', draggable: false })) + if (look.emoji) { + return h('span', common, h('span', { + className: 'bt-mark-emoji', + style: { fontSize: Math.max(12, Math.round(size * 0.62)), lineHeight: 1 }, + }, look.emoji)) + } const box = shapeOf(look.shape).size return h('span', common, h('svg', { viewBox: `0 0 ${box} ${box}`, dangerouslySetInnerHTML: { __html: characterMarkup(look.shape, look.color).replaceAll(MASK_ID, maskId.current) } })) } diff --git a/core/src/client/styles.js b/core/src/client/styles.js index 5b3919bb4..cca577561 100644 --- a/core/src/client/styles.js +++ b/core/src/client/styles.js @@ -20,6 +20,7 @@ ${Object.entries(INKS).map(([name, [, dark]]) => `body[data-ds-dark-theme]{--bt- .bt-mark{display:inline-block;flex:none;position:relative;line-height:0} .bt-mark svg{width:100%;height:100%;overflow:visible} .bt-mark-img{width:100%;height:100%;border-radius:50%;object-fit:cover;display:block;box-shadow:inset 0 0 0 1px var(--bt-line)} +.bt-mark-emoji{width:100%;height:100%;border-radius:50%;display:flex;align-items:center;justify-content:center;line-height:1;user-select:none} .bt-mark .bt-b,.bt-mark .bt-eye,.bt-mark .bt-eyes,.bt-mark .bt-gaze{transform-box:fill-box} .bt-mark .bt-b{transform-origin:50% 100%} .bt-mark .bt-eye,.bt-mark .bt-eyes{transform-origin:center} diff --git a/core/src/host/hooks.js b/core/src/host/hooks.js index b4fc36fe7..9239915b4 100644 --- a/core/src/host/hooks.js +++ b/core/src/host/hooks.js @@ -131,6 +131,8 @@ export function install(rt) { id: bot.id, name: bot.name, role: bot.role, isMain: rt.isMain(bot.id), createdBy: bot.createdBy ?? null, granted: rt.hasAnyGrant?.(bot.id) === true, systemPrompt: bot.systemPrompt ?? '', fields: clone(bot.fields ?? {}), + ...(typeof bot.color === 'string' ? { color: bot.color } : {}), + ...(bot.avatar && typeof bot.avatar === 'object' ? { avatar: clone(bot.avatar) } : {}), }) const persist = () => rt.save().catch(error => warn('flybot: save failed: %s', error)) @@ -660,12 +662,32 @@ export function install(rt) { state.flybot.remote ??= {} return state.flybot.remote } + const cleanRemoteLook = (bot) => { + const out = {} + const color = typeof bot?.color === 'string' ? bot.color.trim().slice(0, 32) : '' + if (color && !/[\n<>]/.test(color)) out.color = color + const src = bot?.avatar && typeof bot.avatar === 'object' ? bot.avatar : {} + const avatar = {} + if (typeof src.shape === 'string' && /^[a-z0-9][a-z0-9_-]{0,31}$/.test(src.shape)) avatar.shape = src.shape + if (typeof src.image === 'string') { + const image = src.image.trim() + if (image.length <= 65536 && /^(https?:\/\/|data:image\/)/i.test(image) && !/[\s<>]/.test(image)) avatar.image = image + } + if (typeof src.emoji === 'string') { + const emoji = src.emoji.trim() + if (emoji && !/[\n<>]/.test(emoji)) avatar.emoji = emoji.slice(0, 16) + } + if (Object.keys(avatar).length) out.avatar = avatar + const at = typeof bot?.avatarUpdatedAt === 'string' ? bot.avatarUpdatedAt.trim() : '' + if (at && !/[\n<>]/.test(at)) out.avatarUpdatedAt = at.slice(0, 40) + return out + } const cleanRemote = (plugin, bot) => { const remote = toRemote(bot.handle) const name = cleanPublishedName(bot.name) || cleanPublishedName(remote.handle) || 'a remote Bot' const homeName = cleanPublishedName(bot.homeName) const owner = cleanPublishedName(bot.owner) - return { handle: remote.handle, home: bot.home ? toRemote(bot.home).handle : remote.home, name, ...(homeName ? { homeName } : {}), ...(owner ? { owner } : {}), plugin, remote: true } + return { handle: remote.handle, home: bot.home ? toRemote(bot.home).handle : remote.home, name, ...(homeName ? { homeName } : {}), ...(owner ? { owner } : {}), plugin, remote: true, ...cleanRemoteLook(bot) } } const remoteMembers = { // Replaces this plugin's list; saves only when it changed. diff --git a/core/src/host/remote-chat.js b/core/src/host/remote-chat.js index 99f80f1a3..c94131991 100644 --- a/core/src/host/remote-chat.js +++ b/core/src/host/remote-chat.js @@ -115,8 +115,29 @@ export function install(rt) { } let opening = Promise.resolve() - // Creates the chat for a remote Bot (once), or refreshes its name and home label. - function open({ handle, name, home, homeName, plugin }) { + function applyPublishedLook(bot, member) { + if (!bot || !member) return false + const at = typeof member.avatarUpdatedAt === 'string' ? member.avatarUpdatedAt : '' + if (at && bot.avatarUpdatedAt === at) return false + let changed = false + if (typeof member.color === 'string' && member.color && bot.color !== member.color) { + bot.color = member.color + changed = true + } + if (member.avatar && typeof member.avatar === 'object') { + if (JSON.stringify(bot.avatar) !== JSON.stringify(member.avatar)) { + bot.avatar = { ...member.avatar } + changed = true + } + } + if (at && bot.avatarUpdatedAt !== at) { + bot.avatarUpdatedAt = at + changed = true + } + return changed + } + // Creates the chat for a remote Bot (once), or refreshes its name, home label, and look. + function open({ handle, name, home, homeName, plugin, color, avatar, avatarUpdatedAt }) { if (isHomeLevelRemote({ handle, home })) return Promise.resolve(undefined) const run = opening.then(async () => { await rt.load() @@ -124,7 +145,8 @@ export function install(rt) { if (existing !== undefined) { const next = { ...existing.remote, home, ...(homeName ? { homeName } : {}), plugin, gone: undefined } delete next.gone - if (JSON.stringify(next) !== JSON.stringify(existing.remote) || (name && existing.name !== name)) { + const lookChanged = applyPublishedLook(existing, { color, avatar, avatarUpdatedAt }) + if (JSON.stringify(next) !== JSON.stringify(existing.remote) || (name && existing.name !== name) || lookChanged) { existing.remote = next if (name) existing.name = String(name).slice(0, 40) await rt.save() @@ -134,6 +156,7 @@ export function install(rt) { pending.set(handle, name) try { const bot = await rt.createBot({ name: String(name || handle).slice(0, 40), model: `${REMOTE_PROVIDER}/${handle}`, remote: { handle, home, ...(homeName ? { homeName } : {}), plugin } }) + if (applyPublishedLook(bot, { color, avatar, avatarUpdatedAt })) await rt.save() return bot.id } finally { pending.delete(handle) } }) diff --git a/core/test/inbound-ui.test.js b/core/test/inbound-ui.test.js index a17f6bc0e..531bf0a04 100644 --- a/core/test/inbound-ui.test.js +++ b/core/test/inbound-ui.test.js @@ -113,6 +113,12 @@ test('lookBotForRemote seeds a distinct look from the handle when there is no ch 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) + const overlaid = lookBotForRemote({ + handle: '@bot.friend.home', displayName: 'εŠ©ζ‰‹', color: '#2a92fe', avatar: { shape: 'star' }, + }, { bots: [chat] }) + assert.equal(overlaid.color, '#2a92fe') + assert.equal(overlaid.avatar.shape, 'star') + assert.equal(lookOf({ id: 'e', avatar: { emoji: '🌟' } }).emoji, '🌟') }) test('room avatars include live remote face-to-face members', () => { diff --git a/core/types/flybot.d.ts b/core/types/flybot.d.ts index 14bf33033..5e8fa936a 100644 --- a/core/types/flybot.d.ts +++ b/core/types/flybot.d.ts @@ -38,6 +38,10 @@ export interface BotView { systemPrompt: string /** Every plugin's fields: fields[plugin][key]. Stored as state.bots[id].fields. */ fields: Record> + /** Owner-chosen character color (named id or #rrggbb). Omitted when unset. */ + color?: string + /** Owner-chosen avatar ({ shape }, { image }, or { emoji }). Omitted when unset. */ + avatar?: { shape?: string; image?: string; emoji?: string } } /** 'change' payload. Delivered for the listener's own plugin unless it subscribed with { all: true }. */ @@ -395,7 +399,7 @@ export interface FlyBotClientRegistry { } /** Host Bot look: same component the sidebar / session list / chat header render. */ -export type FlyBotBotMark = (props: { bot?: any; look?: { shape?: string; color?: string; image?: string }; size?: number; [key: string]: unknown }) => any +export type FlyBotBotMark = (props: { bot?: any; look?: { shape?: string; color?: string; image?: string; emoji?: string }; size?: number; [key: string]: unknown }) => any export type FlyBotBotAvatar = (props: { bot?: any; size?: number; badge?: boolean; live?: boolean; [key: string]: unknown }) => any export type FlyBotLookOf = (bot?: any) => { shape: string; color: string; image?: string } diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 8a23d4b2f..2aab25b52 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -58,7 +58,7 @@ The default card (`flybot-slots.js`) shows where a held request came from: `send ### 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` / `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 remote Bot's generated look is seeded by its handle (`remoteSeed` in `src/client/characters.js`), never by its session id. When Bot Social sends `avatar` / `color` / `avatar_updated_at`, `flybot.remote.publish` stores them on the remote-chat record (cached by `avatar_updated_at`). `lookBotForRemote` overlays a person payload's look (face-room `members[]` is the source for room avatars). A stored `color`/`avatar` still wins over the seed; missing look stays handle-seeded (#137). - 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 ea02dc28c..dabee51c1 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -86,6 +86,7 @@ A tracked request with a stable id (`task-`) and a small state machine, own - Channel `view()` puts `pendingCount` on a friend's chat (`bot.remote`) from `rt.listChatApprovals`, the same gold wait badge rooms already use. Local team Bots stay at 0. The social plugin supplies the list via `flybot.chatApprovals.setProvider` ([plugin-runtime](plugin-runtime.md)). Still not team mode. - `remoteTurn` (`src/host/remote-chat.js`) finds what the user typed on a friend's remote chat by skipping host-appended `role:'user'` context: forms `snapshot`, `instructions` (the AGENTS.md baseline on a chat's first turn) and `notice`, and the time-context kinds. Match by form, not by a whitelist of senders, or the first message of a new chat is dropped (#66). +- Remote-chat `open` copies optional published `color` / `avatar` / `avatarUpdatedAt` onto the chat record (cached by `avatarUpdatedAt`) so headers, the picker, and group lists match Friends' Bots. A missing look stays handle-seeded (#137). - `0002-flybot-api-host` `cleanRemote` sanitizes those peer-controlled `name` / `homeName` / `owner` labels (strip `@` and controls/newlines, 60-char cap) before they are stored. Details in [plugin-runtime](plugin-runtime.md) (#75). - A friend's Bot @-mentioned in a group never has its reply delivered back into that group (it lands in the friend's Bot's own chat), so the host posts one receipt line into the room per mention (`src/host/hooks.js` `mentionReceipt`, source role `mention`) and the client renders it as a muted event line (`src/client/cells.js`) (#80, #81). The role stays outside the room's round trigger (`groups.js` `opens`) and outside `RELAY_ROLES`, so the receipt opens no round and folds no turns. diff --git a/docs/subsystems/plugin-runtime.md b/docs/subsystems/plugin-runtime.md index 5a4bc9161..1eeb73240 100644 --- a/docs/subsystems/plugin-runtime.md +++ b/docs/subsystems/plugin-runtime.md @@ -48,7 +48,7 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots 4. Plugins report a host-visible failure with `flybot.warn` (Settings β†’ Feedback / `diagnostics.jsonl`). They must not depend on `ctx.logger`: DSH may export none, and `flybot.warn` delegates to `rt.warn`. 5. Reach (#90): the core's owner policy (`reach.js`) is not a middleware. `hooks.js` runs it before the `bot.send` chain, on approval release and on every deliver that carries a sender or a remote origin; a plugin's `allow` or `bypass` never skips it, and plugins see its denial as `{ action: 'deny', by: 'owner-policy' }`. Plugins read grants through `flybot.bot.hasGrant` / `hasAnyGrant`, `BotView.granted`, `flybot.grants` and the `bot.grants` event. 6. `flybot.roomApprovals.setProvider` lets a plugin (social) attach pending outbound holds to a shared-room view. It does not enable team mode. -7. `cleanRemote` is the record boundary for a friend's granted Bot: `name`, `homeName` and `owner` strip `@` (so they cannot look like a mention of another Bot in `nameMentions` chips/previews) and control characters/newlines, then cap at 60 characters. Handles stay on the `handle` field. Those labels then flow into delivery `fromName` (still `cleanName`'d and the body fenced) and into `list_bots` friends lines via the remote-chat roster. Group room logs use local team names, not peer labels. +7. `cleanRemote` is the record boundary for a friend's granted Bot: `name`, `homeName` and `owner` strip `@` (so they cannot look like a mention of another Bot in `nameMentions` chips/previews) and control characters/newlines, then cap at 60 characters. Handles stay on the `handle` field. Optional look fields (`color`, `avatar` object, `avatarUpdatedAt`) pass through when present so remote chats, group lists, and headers can render a peer's chosen look; missing look stays handle-seeded. Those labels then flow into delivery `fromName` (still `cleanName`'d and the body fenced) and into `list_bots` friends lines via the remote-chat roster. Group room logs use local team names, not peer labels. 8. A user's @ of a friend's Bot in a group gets a receipt line in that group (`hooks.js` `mentionReceipt`, relay role `mention`): where the reply lands, or why the send was denied, held, or failed. The receipt's role stays outside the room round trigger (`groups.js` `opens`), so it never opens a round; peer names go through `cleanName`. 9. `flybot.rooms` hosts a Bot Social face-to-face matching room as a native room (`upsertFace` / `applyFaceLine` / `ofFace` / `dropFace` / `onFaceLine` / `postNotice` / `postRemote`). Slot `flybot.room.overview` is a block at the top of a group's overview: while joining, a tile grid (avatar above name + owner) with **Leave** and **Enter chat** on one row; once open, compact **Leave** and Auto-send β€” not Open/Start/Leave together. Slot `flybot.new-chat.action` adds a row to the sidebar **+** short action menu (next to Create group chat; `label` / `hint` go through the same `t()` map). Still not team mode. 10. Held sends are owner-bound (#52): a hold stamps server-derived provenance on the record β€” `requestId`, a sha256 `version` over the request content, `origin` (`via`/`hop`/audience) and the sender Bot's `ownerId`; message claims (`meta`, text) never confer authority. `flybot/decide` passes authz for any signed-in actor and hooks binds the decision to the sender Bot's owner's session; `release` revalidates and marks records `stale` (changed request, deleted sender/target Bot, changed owner β€” `deleteBot` invalidates eagerly; a demoted Chief's sender-side holds are invalidated eagerly on `setMain`, not inside `staleReason`). `release()` re-checks immediately before dispatch so an in-flight approve cannot go out after a concurrent invalidation. Unknown `invalidateFor` field values throw. The pending list is filtered per actor under member enforcement. The default approval card shows `senderName`/`targetName` and a delegated marker (`Approval.origin`). @@ -72,7 +72,7 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots ### Host Bot look for classic-script plugins -- Classic-script plugins cannot import `src/client/mark.js`. The host client `registry()` therefore also sets `globalThis.__flybot__.BotAvatar`, `BotMark` and `lookOf` to the same components the sidebar / session list / chat header use (configured image / shape / color, else a stable look from the Bot id or a remote handle). Plugins typically render `BotAvatar` at 24px with `badge: false`; face-to-face tiles pass 48. A stored `color` / `avatar` on the record still wins; a remote Bot without one is seeded by `remoteSeed(handle)`. +- Classic-script plugins cannot import `src/client/mark.js`. The host client `registry()` therefore also sets `globalThis.__flybot__.BotAvatar`, `BotMark` and `lookOf` to the same components the sidebar / session list / chat header use (configured image / shape / color / emoji, else a stable look from the Bot id or a remote handle). Plugins typically render `BotAvatar` at 24px with `badge: false`; face-to-face tiles pass 48. A stored `color` / `avatar` on the record still wins; a remote Bot without one is seeded by `remoteSeed(handle)`. A face-room `members[]` look overlays the chat record (`lookBotForRemote`). ## Tests diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 9ca023dca..e9b9d8cae 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 approved in the open chat (`flybot.session.dock`). Friends' Bots shows a badge and a one-line jump list. 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). **Face-to-face group**, **Add friend**, **Accept invite**, and **Copy invite link** are those rows; Friends' Bots has no **+ Add** button. **Face-to-face group** opens the same 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; waiting tiles use the server owner label, e.g. `Chief Β· BoxB`); **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 in-chat Approve / Reject path. **Auto-send** is one compact checkbox (tooltip) on the Face-to-face rooms header, via 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); 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). +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). **Face-to-face group**, **Add friend**, **Accept invite**, and **Copy invite link** are those rows; Friends' Bots has no **+ Add** button. **Face-to-face group** opens the same 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 published look or a handle-seeded look, plus any avatar face-room `members[]` carries). Waiting-room and picker Bots are a wrapping tile grid (avatar on top, name centered below; yours a tiny accent dot; waiting tiles use the server owner label, e.g. `Chief Β· BoxB`); **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 in-chat Approve / Reject path. **Auto-send** is one compact checkbox (tooltip) on the Face-to-face rooms header, via 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); 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** declines that grant on Bot Social (`decline_bot_grant`) so it leaves the owner's Manage access; an older server falls back to a local hide. The row drops immediately; room leave and a roster refresh continue in the background. A local hide is forgotten only after two consecutive successful `list_remote_bots` results without that handle, or an explicit re-share / `regranted` join. 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 @@ -28,7 +28,8 @@ 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/mcp-client.js` | Owner MCP client plus isolated grant/invite REST fallbacks (`listGrants`, `revokeGrant`, `updateBotProfile`, `declineGrant`, invites). | +| `plugins/social/src/look.js` | Map Bot Social profile look fields to FlyBot `{ color, avatar }` (optional on older servers). | | `plugins/social/src/face-rooms.js` | Face-to-face REST client (join/list/get/start/leave/messages), error mapping, event rendering, peer-string sanitization. Auto-send is MCP in `social.js`. | | `plugins/social/client.js` | Friends' Bots: badge, Remove this Bot, cached roster / Refreshing…, header without + Add; sidebar + Face-to-face group / Add friend / Accept invite / Copy invite link (tile grid, 6-digit join, Enter chat + Leave footer), one-line face-room rows + Past rooms, in-chat Approve / Reject dock, waiting `flybot.room.overview` tile grid then Leave / Auto-send, Bot settings Invite / Manage access, Add friend mint-and-copy. | @@ -67,9 +68,9 @@ Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers ### Onboarding, grants, hide, and approval (#136–#140) - **#136:** A taken `displayName` at onboard retries `Name2` … rather than refusing to start. Friends' Bots shows `@your.home` with Copy. -- **#137 (deferred):** The owner's chosen Bot avatar/color does not cross homes. Live Bot Social `update_bot_profile` only has `display_name` / `bot_id`; face-room join POST is `{ code, bots: [handle] }`. Peers keep the handle-seeded look until the server grows profile look fields. +- **#137:** Owner-chosen Bot avatar/color is sent with MCP `update_bot_profile { bot_id?, display_name?, avatar?, color? }` (REST `PATCH /v1/bots/{id}/profile`) whenever a local Bot's look changes and on startup if not yet synced. `avatar` is a preset id, a single emoji, or `data:image/(png|jpeg|webp);base64` ≀ 64KiB; `color` is `#rrggbb`. Remote look is read from `list_remote_bots` / `list_friends` peer / `list_bot_grants` / face-room `members[]` (`avatar`, `avatar_kind`, `color`, `avatar_updated_at`) and shown in Friends' Bots, group member lists, waiting tiles, the picker, and chat headers. Cache key is `avatar_updated_at`; missing look falls back to the handle-seeded palette. Face-room events carry no look β€” `members[]` is the source. All fields are optional so an older Bot Social is unchanged. - **#138:** Accepting a Helper invite grants Helper only. The joiner picks a talk-through Bot (not shared). Success copy is `{person} shared {Bot} with you.` Manage access lists `SelfB1935 (@selfb1935.home)`. Core grants store a canonical `@handle` (never decorated `Chief@…`). On upgrade, remotes already on `list_remote_bots` are seeded into `inboundBots` once so reciprocal friends are not dropped. Face-room remotes leave Friends' Bots when the room is left; existing friend handles are not reclassified as room-only. -- **#139:** **Remove this Bot** asks once (`Remove Helper? Bob can share it again.`). Hide stays local (A's Manage access still shows the grant until A revokes β€” an incoming un-grant needs Bot Social). Re-joining an existing friendship unhides only that invite's Bot/peer home (not every hidden remote); used invites say β€œThis invite was already used.” +- **#139:** **Remove this Bot** asks once (`Remove Helper? Bob can share it again.`) and calls `decline_bot_grant { grant_id, bot_id }` (`DELETE /v1/remote-bots/{grantId}`) so the owner's Manage access drops it. `grant_not_found` is treated as already removed. An older server (404 on the tool/endpoint) falls back to the local hide. Re-joining after a decline may return `regranted: true` on the existing friendship; live access still answers `409 relationship_exists`; a spent invite is `410 invite_unavailable`. Copy stays English (β€œYou're already friends…”, β€œThis invite was already used.”). Re-joining an existing friendship unhides only that invite's Bot/peer home. - **#140:** Auto-send defaults off. Accepting a friend request uses that relationship's peer home for Auto-send, not the first roster entry. The owner sees Approve / Reject in the chat, not β€œWaiting for the owner's approval.” Composer drafts are labelled You. - **#136 (suffix):** Onboard retries truncate the sanitized handle body so `Name2` / `Name3` still change within the 32-character `homeHandleOf` limit. diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index b2b0ce058..2ab48161a 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -887,6 +887,14 @@ diff --git a/src/client/sidebar.js b/src/client/sidebar.js diff --git a/src/client/styles.js b/src/client/styles.js --- a/src/client/styles.js +++ b/src/client/styles.js +@@ -20,6 +20,7 @@ ${Object.entries(INKS).map(([name, [, dark]]) => `body[data-ds-dark-theme]{--bt- + .bt-mark{display:inline-block;flex:none;position:relative;line-height:0} + .bt-mark svg{width:100%;height:100%;overflow:visible} + .bt-mark-img{width:100%;height:100%;border-radius:50%;object-fit:cover;display:block;box-shadow:inset 0 0 0 1px var(--bt-line)} ++.bt-mark-emoji{width:100%;height:100%;border-radius:50%;display:flex;align-items:center;justify-content:center;line-height:1;user-select:none} + .bt-mark .bt-b,.bt-mark .bt-eye,.bt-mark .bt-eyes,.bt-mark .bt-gaze{transform-box:fill-box} + .bt-mark .bt-b{transform-origin:50% 100%} + .bt-mark .bt-eye,.bt-mark .bt-eyes{transform-origin:center} @@ -38,6 +38,14 @@ body[data-bt-motion=lively] .bt-mark[data-live][data-state=idle][data-fidget=loo @keyframes bt-fidget-look{0%,22%,100%{transform:none}3%,9%{transform:translateX(-14%)}12%,19%{transform:translateX(14%)}} body[data-bt-motion=lively] .bt-badge-alert{right:auto;bottom:auto;left:50%;top:calc(var(--bt-size,36px) * -.14);translate:-50% 0;animation:bt-q-ride 1.3s cubic-bezier(.3,.7,.4,1) infinite} diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index 654bd8b46..d45f664d2 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,36 @@ export function defaultShape(id) { +@@ -63,13 +63,39 @@ export function defaultShape(id) { return DEFAULT_SHAPES[((value ^ (value >>> 16)) >>> 0) % DEFAULT_SHAPES.length] } @@ -22,10 +22,11 @@ diff --git a/src/client/characters.js b/src/client/characters.js +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 ++ // A face-room members[] look (or a published roster look) wins over the chat record. ++ if (chat) return Object.keys(extra).length ? { ...chat, ...extra } : chat + 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 } @@ -41,8 +42,10 @@ diff --git a/src/client/characters.js b/src/client/characters.js - shape: hasShape(avatar.shape) ? avatar.shape : bot?.id ? defaultShape(String(bot.id)) : FALLBACK_SHAPE, + shape: hasShape(avatar.shape) ? avatar.shape : seed !== undefined ? defaultShape(seed) : FALLBACK_SHAPE, image: avatar.image, ++ ...(typeof avatar.emoji === 'string' && avatar.emoji ? { emoji: avatar.emoji } : {}), color: colorOf(bot), } + } diff --git a/src/client/mark.js b/src/client/mark.js --- a/src/client/mark.js +++ b/src/client/mark.js @@ -57,6 +60,28 @@ diff --git a/src/client/mark.js b/src/client/mark.js // ------------------------------------------------------------------------- // Bot mark: the Bot's character (or its uploaded photo), looked up by lookOf. +@@ -56,7 +56,7 @@ export function BotMark({ bot, look: given, size = 36, state = 'idle', live = fa + const common = { + ref, + className: poked ? 'bt-mark bt-mark-poke' : 'bt-mark', +- 'data-shape': look.image ? 'image' : shapeId(look.shape), ++ 'data-shape': look.image ? 'image' : look.emoji ? 'emoji' : shapeId(look.shape), + 'data-state': STATES.includes(state) ? state : 'idle', + 'data-live': live ? '' : undefined, + 'data-fidget': live ? FIDGETS[fnv1a(`${bot?.id ?? ''}:fidget`) % FIDGETS.length] : undefined, +@@ -70,6 +70,12 @@ export function BotMark({ bot, look: given, size = 36, state = 'idle', live = fa + onAnimationEnd: poked ? (event) => { if (/^bt-poke/.test(event.animationName)) setPoked(false) } : undefined, + } + if (look.image) return h('span', common, h('img', { className: 'bt-mark-img', src: look.image, alt: '', draggable: false })) ++ if (look.emoji) { ++ return h('span', common, h('span', { ++ className: 'bt-mark-emoji', ++ style: { fontSize: Math.max(12, Math.round(size * 0.62)), lineHeight: 1 }, ++ }, look.emoji)) ++ } + const box = shapeOf(look.shape).size + return h('span', common, h('svg', { viewBox: `0 0 ${box} ${box}`, dangerouslySetInnerHTML: { __html: characterMarkup(look.shape, look.color).replaceAll(MASK_ID, maskId.current) } })) + } @@ -130,10 +130,42 @@ function clusterSeats(count, frame) { return [[0, 0, half], [half, 0, half], [0, half, half], [half, half, half]] } diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 78a2d5bbf..a1cf2b156 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -542,7 +542,7 @@ diff --git a/client.js b/client.js // src/client/inks.js var INKS = { deepseek: ["#4D6BFE", "#6A84FF"], -@@ -1177,11 +1177,25 @@ function defaultShape(id) { +@@ -1177,12 +1177,27 @@ function defaultShape(id) { value = Math.imul(value ^ value >>> 13, 3266489909); return DEFAULT_SHAPES[((value ^ value >>> 16) >>> 0) % DEFAULT_SHAPES.length]; } @@ -552,10 +552,10 @@ diff --git a/client.js b/client.js +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; ++ if (chat) return Object.keys(extra).length ? { ...chat, ...extra } : chat; + 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 }; @@ -568,8 +568,10 @@ diff --git a/client.js b/client.js - shape: hasShape(avatar.shape) ? avatar.shape : bot?.id ? defaultShape(String(bot.id)) : FALLBACK_SHAPE, + shape: hasShape(avatar.shape) ? avatar.shape : seed !== void 0 ? defaultShape(seed) : FALLBACK_SHAPE, image: avatar.image, ++ ...typeof avatar.emoji === "string" && avatar.emoji ? { emoji: avatar.emoji } : {}, color: colorOf(bot) }; + } @@ -1206,8 +1206,8 @@ function bodyOf(def, paint, flat = false) { return ""; } @@ -581,7 +583,7 @@ diff --git a/client.js b/client.js var maskBox = (size) => `maskUnits="userSpaceOnUse" x="${-size}" y="${-size}" width="${size * 3}" height="${size * 3}"`; var holes = (def, eyes) => `${eyes}`; var MASK_ID = "%MASK%"; -@@ -1242,6 +1242,350 @@ function maskOf(shape) { +@@ -1242,6 +1242,356 @@ function maskOf(shape) { } var DEFAULT_MASK = maskOf(FALLBACK_SHAPE); @@ -633,7 +635,7 @@ diff --git a/client.js b/client.js + const common = { + ref, + className: poked ? "bt-mark bt-mark-poke" : "bt-mark", -+ "data-shape": look.image ? "image" : shapeId(look.shape), ++ "data-shape": look.image ? "image" : look.emoji ? "emoji" : shapeId(look.shape), + "data-state": STATES.includes(state) ? state : "idle", + "data-live": live ? "" : void 0, + "data-fidget": live ? FIDGETS[fnv1a(`${bot?.id ?? ""}:fidget`) % FIDGETS.length] : void 0, @@ -649,6 +651,12 @@ diff --git a/client.js b/client.js + } : void 0 + }; + if (look.image) return (0, import_react.createElement)("span", common, (0, import_react.createElement)("img", { className: "bt-mark-img", src: look.image, alt: "", draggable: false })); ++ if (look.emoji) { ++ return (0, import_react.createElement)("span", common, (0, import_react.createElement)("span", { ++ className: "bt-mark-emoji", ++ style: { fontSize: Math.max(12, Math.round(size * 0.62)), lineHeight: 1 } ++ }, look.emoji)); ++ } + const box = shapeOf(look.shape).size; + return (0, import_react.createElement)("span", common, (0, import_react.createElement)("svg", { viewBox: `0 0 ${box} ${box}`, dangerouslySetInnerHTML: { __html: characterMarkup(look.shape, look.color).replaceAll(MASK_ID, maskId.current) } })); +} @@ -1116,6 +1124,14 @@ diff --git a/client.js b/client.js if (value) { setKept(value); return void 0; +@@ -1838,6 +1838,7 @@ ${Object.entries(INKS).map(([name, [, dark]]) => `body[data-ds-dark-theme]{--bt- + .bt-mark{display:inline-block;flex:none;position:relative;line-height:0} + .bt-mark svg{width:100%;height:100%;overflow:visible} + .bt-mark-img{width:100%;height:100%;border-radius:50%;object-fit:cover;display:block;box-shadow:inset 0 0 0 1px var(--bt-line)} ++.bt-mark-emoji{width:100%;height:100%;border-radius:50%;display:flex;align-items:center;justify-content:center;line-height:1;user-select:none} + .bt-mark .bt-b,.bt-mark .bt-eye,.bt-mark .bt-eyes,.bt-mark .bt-gaze{transform-box:fill-box} + .bt-mark .bt-b{transform-origin:50% 100%} + .bt-mark .bt-eye,.bt-mark .bt-eyes{transform-origin:center} @@ -1856,6 +1856,14 @@ body[data-bt-motion=lively] .bt-mark[data-live][data-state=idle][data-fidget=loo @keyframes bt-fidget-look{0%,22%,100%{transform:none}3%,9%{transform:translateX(-14%)}12%,19%{transform:translateX(14%)}} body[data-bt-motion=lively] .bt-badge-alert{right:auto;bottom:auto;left:50%;top:calc(var(--bt-size,36px) * -.14);translate:-50% 0;animation:bt-q-ride 1.3s cubic-bezier(.3,.7,.4,1) infinite} diff --git a/patches/ds-bot/files/src/host/hooks.js b/patches/ds-bot/files/src/host/hooks.js index b4fc36fe7..9239915b4 100644 --- a/patches/ds-bot/files/src/host/hooks.js +++ b/patches/ds-bot/files/src/host/hooks.js @@ -131,6 +131,8 @@ export function install(rt) { id: bot.id, name: bot.name, role: bot.role, isMain: rt.isMain(bot.id), createdBy: bot.createdBy ?? null, granted: rt.hasAnyGrant?.(bot.id) === true, systemPrompt: bot.systemPrompt ?? '', fields: clone(bot.fields ?? {}), + ...(typeof bot.color === 'string' ? { color: bot.color } : {}), + ...(bot.avatar && typeof bot.avatar === 'object' ? { avatar: clone(bot.avatar) } : {}), }) const persist = () => rt.save().catch(error => warn('flybot: save failed: %s', error)) @@ -660,12 +662,32 @@ export function install(rt) { state.flybot.remote ??= {} return state.flybot.remote } + const cleanRemoteLook = (bot) => { + const out = {} + const color = typeof bot?.color === 'string' ? bot.color.trim().slice(0, 32) : '' + if (color && !/[\n<>]/.test(color)) out.color = color + const src = bot?.avatar && typeof bot.avatar === 'object' ? bot.avatar : {} + const avatar = {} + if (typeof src.shape === 'string' && /^[a-z0-9][a-z0-9_-]{0,31}$/.test(src.shape)) avatar.shape = src.shape + if (typeof src.image === 'string') { + const image = src.image.trim() + if (image.length <= 65536 && /^(https?:\/\/|data:image\/)/i.test(image) && !/[\s<>]/.test(image)) avatar.image = image + } + if (typeof src.emoji === 'string') { + const emoji = src.emoji.trim() + if (emoji && !/[\n<>]/.test(emoji)) avatar.emoji = emoji.slice(0, 16) + } + if (Object.keys(avatar).length) out.avatar = avatar + const at = typeof bot?.avatarUpdatedAt === 'string' ? bot.avatarUpdatedAt.trim() : '' + if (at && !/[\n<>]/.test(at)) out.avatarUpdatedAt = at.slice(0, 40) + return out + } const cleanRemote = (plugin, bot) => { const remote = toRemote(bot.handle) const name = cleanPublishedName(bot.name) || cleanPublishedName(remote.handle) || 'a remote Bot' const homeName = cleanPublishedName(bot.homeName) const owner = cleanPublishedName(bot.owner) - return { handle: remote.handle, home: bot.home ? toRemote(bot.home).handle : remote.home, name, ...(homeName ? { homeName } : {}), ...(owner ? { owner } : {}), plugin, remote: true } + return { handle: remote.handle, home: bot.home ? toRemote(bot.home).handle : remote.home, name, ...(homeName ? { homeName } : {}), ...(owner ? { owner } : {}), plugin, remote: true, ...cleanRemoteLook(bot) } } const remoteMembers = { // Replaces this plugin's list; saves only when it changed. diff --git a/patches/ds-bot/files/src/host/remote-chat.js b/patches/ds-bot/files/src/host/remote-chat.js index 99f80f1a3..c94131991 100644 --- a/patches/ds-bot/files/src/host/remote-chat.js +++ b/patches/ds-bot/files/src/host/remote-chat.js @@ -115,8 +115,29 @@ export function install(rt) { } let opening = Promise.resolve() - // Creates the chat for a remote Bot (once), or refreshes its name and home label. - function open({ handle, name, home, homeName, plugin }) { + function applyPublishedLook(bot, member) { + if (!bot || !member) return false + const at = typeof member.avatarUpdatedAt === 'string' ? member.avatarUpdatedAt : '' + if (at && bot.avatarUpdatedAt === at) return false + let changed = false + if (typeof member.color === 'string' && member.color && bot.color !== member.color) { + bot.color = member.color + changed = true + } + if (member.avatar && typeof member.avatar === 'object') { + if (JSON.stringify(bot.avatar) !== JSON.stringify(member.avatar)) { + bot.avatar = { ...member.avatar } + changed = true + } + } + if (at && bot.avatarUpdatedAt !== at) { + bot.avatarUpdatedAt = at + changed = true + } + return changed + } + // Creates the chat for a remote Bot (once), or refreshes its name, home label, and look. + function open({ handle, name, home, homeName, plugin, color, avatar, avatarUpdatedAt }) { if (isHomeLevelRemote({ handle, home })) return Promise.resolve(undefined) const run = opening.then(async () => { await rt.load() @@ -124,7 +145,8 @@ export function install(rt) { if (existing !== undefined) { const next = { ...existing.remote, home, ...(homeName ? { homeName } : {}), plugin, gone: undefined } delete next.gone - if (JSON.stringify(next) !== JSON.stringify(existing.remote) || (name && existing.name !== name)) { + const lookChanged = applyPublishedLook(existing, { color, avatar, avatarUpdatedAt }) + if (JSON.stringify(next) !== JSON.stringify(existing.remote) || (name && existing.name !== name) || lookChanged) { existing.remote = next if (name) existing.name = String(name).slice(0, 40) await rt.save() @@ -134,6 +156,7 @@ export function install(rt) { pending.set(handle, name) try { const bot = await rt.createBot({ name: String(name || handle).slice(0, 40), model: `${REMOTE_PROVIDER}/${handle}`, remote: { handle, home, ...(homeName ? { homeName } : {}), plugin } }) + if (applyPublishedLook(bot, { color, avatar, avatarUpdatedAt })) await rt.save() return bot.id } finally { pending.delete(handle) } }) diff --git a/patches/ds-bot/files/test/inbound-ui.test.js b/patches/ds-bot/files/test/inbound-ui.test.js index a17f6bc0e..531bf0a04 100644 --- a/patches/ds-bot/files/test/inbound-ui.test.js +++ b/patches/ds-bot/files/test/inbound-ui.test.js @@ -113,6 +113,12 @@ test('lookBotForRemote seeds a distinct look from the handle when there is no ch 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) + const overlaid = lookBotForRemote({ + handle: '@bot.friend.home', displayName: 'εŠ©ζ‰‹', color: '#2a92fe', avatar: { shape: 'star' }, + }, { bots: [chat] }) + assert.equal(overlaid.color, '#2a92fe') + assert.equal(overlaid.avatar.shape, 'star') + assert.equal(lookOf({ id: 'e', avatar: { emoji: '🌟' } }).emoji, '🌟') }) test('room avatars include live remote face-to-face members', () => { diff --git a/patches/ds-bot/files/types/flybot.d.ts b/patches/ds-bot/files/types/flybot.d.ts index 14bf33033..5e8fa936a 100644 --- a/patches/ds-bot/files/types/flybot.d.ts +++ b/patches/ds-bot/files/types/flybot.d.ts @@ -38,6 +38,10 @@ export interface BotView { systemPrompt: string /** Every plugin's fields: fields[plugin][key]. Stored as state.bots[id].fields. */ fields: Record> + /** Owner-chosen character color (named id or #rrggbb). Omitted when unset. */ + color?: string + /** Owner-chosen avatar ({ shape }, { image }, or { emoji }). Omitted when unset. */ + avatar?: { shape?: string; image?: string; emoji?: string } } /** 'change' payload. Delivered for the listener's own plugin unless it subscribed with { all: true }. */ @@ -395,7 +399,7 @@ export interface FlyBotClientRegistry { } /** Host Bot look: same component the sidebar / session list / chat header render. */ -export type FlyBotBotMark = (props: { bot?: any; look?: { shape?: string; color?: string; image?: string }; size?: number; [key: string]: unknown }) => any +export type FlyBotBotMark = (props: { bot?: any; look?: { shape?: string; color?: string; image?: string; emoji?: string }; size?: number; [key: string]: unknown }) => any export type FlyBotBotAvatar = (props: { bot?: any; size?: number; badge?: boolean; live?: boolean; [key: string]: unknown }) => any export type FlyBotLookOf = (bot?: any) => { shape: string; color: string; image?: string } diff --git a/plugins/social/README.md b/plugins/social/README.md index 56312a184..ad16d699c 100644 --- a/plugins/social/README.md +++ b/plugins/social/README.md @@ -22,7 +22,10 @@ browser: `client.js`. Design: see "Social design: homes and members" in the root the handle and drops `roomOnly`. **Manage access** on the Bot lists who has it and **Revoke**. - **Remote roster**: `list_remote_bots` β†’ friends' Bots grouped by home; sidebar section `flybot.sidebar.section#social` with a remote badge, `@` rows via - `flybot.composer.mention#social`. Remote Bots never enter `bot.list()`. + `flybot.composer.mention#social`. Remote Bots never enter `bot.list()`. Each row uses + the published `avatar` / `color` when the server sends them, else a handle-seeded look. + **Remove this Bot** declines that grant (`decline_bot_grant`) so it leaves the owner's + Manage access; an older server hides it locally. - **Send middleware `social`**: a remote target must be a Bot of a friend home; approval is per friend home (`approval: { default: 'hold', homes: { '@bob.home': 'auto' } }`), overridden per local Bot by field `social.autoApprove[home]` (UI endpoint @@ -37,11 +40,14 @@ browser: `client.js`. Design: see "Social design: homes and members" in the root Server side (Bot Social, local only): `/v1/onboard/flybot`, `/v1/homes/:id/members`, `/v1/homes/:id/remote-bots`, `/v1/homes/:id/send`, grant REST (`GET /v1/relationships/{id}/grants`, -`DELETE /v1/grants/{id}`), and owner MCP tools `add_home_member`, +`DELETE /v1/grants/{id}`), `PATCH /v1/bots/{id}/profile`, `DELETE /v1/remote-bots/{grantId}`, +and owner MCP tools `add_home_member`, `remove_home_member`, `list_home_members`, `list_remote_bots`, `send_as_member`, -`create_invite`, `join_invite`, `list_grants`, `revoke_grant`. Grant/invite calls are isolated +`create_invite`, `join_invite`, `list_grants`, `revoke_grant`, `update_bot_profile`, +`decline_bot_grant`. Grant/invite/look calls are isolated in `src/mcp-client.js` (`createSocialApi`) so a later server can add tools without touching -the plugin's grant bookkeeping. +the plugin's grant bookkeeping. Look fields and `decline_bot_grant` are optional: an older +server is unchanged (local hide; handle-seeded avatars). Tests (`test/social.test.js`) use a mocked server. diff --git a/plugins/social/client.js b/plugins/social/client.js index b88ae8dea..7c4632db1 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -28,7 +28,7 @@ window.__ModuleLoader__.load({ } const message = String(e?.message ?? e ?? '') if (/relationship_exists/i.test(message)) return "You're already friends. That Bot is in Friends' Bots again." - if (/invite is used|invite_used|already used/i.test(message)) return 'This invite was already used. Ask your friend for a new one.' + if (/invite_unavailable|invite is used|invite_used|already used/i.test(message)) return 'This invite was already used. Ask your friend for a new one.' return message } @@ -761,7 +761,7 @@ window.__ModuleLoader__.load({ chat && typeof renderBot === 'function' ? renderBot(chat) : h('div', { className: 'fs-row', title: bot.handle }, - h(FaceAvatar, { bot: faceLook({ member: { handle: bot.handle, displayName: bot.name } }), size: 24 }), + h(FaceAvatar, { bot: faceLook({ member: { handle: bot.handle, displayName: bot.name, color: bot.color, avatar: bot.avatar } }), 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', diff --git a/plugins/social/src/face-rooms.js b/plugins/social/src/face-rooms.js index 031596e4f..c647829ab 100644 --- a/plugins/social/src/face-rooms.js +++ b/plugins/social/src/face-rooms.js @@ -10,6 +10,7 @@ // (joined / left / started) is sanitized the same way as a display name. import { socialFetch } from './mcp-client.js' +import { lookFromServer } from './look.js' export const FACE_JOIN_CODE = /^\d{6}$/ export const FACE_MAX_MEMBERS = 8 @@ -305,18 +306,21 @@ export function platformStartedText(reason) { // Peer look is optional: only pass through a stored color / avatar when the payload // actually has one. Image URLs stay one line, no markup; a missing look is fine β€” the // client then uses the host BotMark seeded by handle. +const HEX_ONLY = /^#[0-9a-f]{6}$/i export function faceMemberLook(member) { if (!member || typeof member !== 'object') return {} const out = {} - const color = typeof member.color === 'string' ? member.color.trim().slice(0, 32) : '' + const fromServer = lookFromServer(member) + const color = fromServer.color + || (typeof member.color === 'string' && !HEX_ONLY.test(member.color) ? member.color.trim().slice(0, 32) : '') if (color && !/[\n<>]/.test(color)) out.color = color const src = member.avatar && typeof member.avatar === 'object' ? member.avatar : {} - const avatar = {} + const avatar = { ...(fromServer.avatar ?? {}) } if (typeof src.shape === 'string' && /^[a-z][\w-]{0,31}$/.test(src.shape)) avatar.shape = src.shape const image = typeof src.image === 'string' ? src.image.trim() : (typeof member.image === 'string' ? member.image.trim() : '') - if (image && image.length < 2048 && /^(https?:\/\/|data:image\/)/i.test(image) && !/[\s<>]/.test(image)) avatar.image = image + if (image && image.length <= 65536 && /^(https?:\/\/|data:image\/)/i.test(image) && !/[\s<>]/.test(image)) avatar.image = image const emoji = typeof src.emoji === 'string' ? src.emoji.trim() : (typeof member.emoji === 'string' ? member.emoji.trim() : '') - if (emoji && !/[\n<>]/.test(emoji)) avatar.emoji = emoji.slice(0, 8) + if (emoji && !/[\n<>]/.test(emoji)) avatar.emoji = emoji.slice(0, 16) if (Object.keys(avatar).length) out.avatar = avatar return out } diff --git a/plugins/social/src/look.js b/plugins/social/src/look.js new file mode 100644 index 000000000..56e24485a --- /dev/null +++ b/plugins/social/src/look.js @@ -0,0 +1,80 @@ +// Map Bot Social profile look fields (optional on older servers) to FlyBot's +// { color, avatar: { shape | emoji | image } }. Server avatar is a preset id, +// a single emoji, or a data:image/(png|jpeg|webp);base64 string <= 64KiB. +// Color is #rrggbb. Peers without a look keep the handle-seeded palette. + +export const AVATAR_MAX = 64 * 1024 +const PRESET = /^[a-z0-9][a-z0-9_-]{0,31}$/ +const HEX = /^#[0-9a-f]{6}$/i +const DATA_IMAGE = /^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/]+=*$/i + +// Light ink of each FlyBot named color (core/src/client/inks.js), lowercase #rrggbb. +export const COLOR_HEX = { + deepseek: '#4d6bfe', charcoal: '#141416', graphite: '#2d2d2d', + rose: '#ec2f6b', sky: '#0aa8d6', tangerine: '#ff6900', cobalt: '#082dff', + black: '#000000', brown: '#a27952', red: '#ff3e51', orange: '#ff781c', + yellow: '#ffaf38', green: '#00c972', cyan: '#1cc3b0', blue: '#2a92fe', + violet: '#a97efe', magenta: '#ff5eb1', gray: '#959595', aurora: '#00b8d4', +} + +export function hexOfColor(color) { + const value = String(color ?? '').trim() + if (HEX.test(value)) return value.toLowerCase() + return COLOR_HEX[value] || undefined +} + +function isEmojiAvatar(value) { + const text = String(value ?? '').trim() + if (!text || text.length > 16 || /[\n<>#]/.test(text)) return false + if (PRESET.test(text) || /^data:/i.test(text) || /^https?:/i.test(text)) return false + return true +} + +export function lookFromServer(row = {}) { + const out = {} + const rawColor = String(row.color ?? '').trim() + if (HEX.test(rawColor)) out.color = rawColor.toLowerCase() + const raw = row.avatar + const kind = String(row.avatar_kind ?? '') + const avatar = {} + if (typeof raw === 'string') { + const text = raw.trim() + if ((kind === 'image' || (!kind && DATA_IMAGE.test(text))) && text.length <= AVATAR_MAX && DATA_IMAGE.test(text)) { + avatar.image = text + } else if ((kind === 'preset' || (!kind && PRESET.test(text))) && PRESET.test(text)) { + avatar.shape = text + } else if ((kind === 'emoji' || (!kind && isEmojiAvatar(text))) && isEmojiAvatar(text)) { + avatar.emoji = text.slice(0, 16) + } + } else if (raw && typeof raw === 'object') { + if (typeof raw.shape === 'string' && PRESET.test(raw.shape)) avatar.shape = raw.shape + if (typeof raw.image === 'string' && raw.image.length <= AVATAR_MAX && /^(https?:\/\/|data:image\/)/i.test(raw.image.trim()) && !/[\s<>]/.test(raw.image)) { + avatar.image = raw.image.trim() + } + if (typeof raw.emoji === 'string' && isEmojiAvatar(raw.emoji)) avatar.emoji = raw.emoji.trim().slice(0, 16) + } + if (Object.keys(avatar).length) out.avatar = avatar + const at = String(row.avatar_updated_at ?? row.avatarUpdatedAt ?? '').trim() + if (at && !/[\n<>]/.test(at)) out.avatarUpdatedAt = at.slice(0, 40) + return out +} + +export function lookToServer(bot = {}) { + const patch = {} + const color = hexOfColor(bot.color) + if (color) patch.color = color + const avatar = bot.avatar && typeof bot.avatar === 'object' ? bot.avatar : {} + if (typeof avatar.image === 'string' && DATA_IMAGE.test(avatar.image) && avatar.image.length <= AVATAR_MAX) { + patch.avatar = avatar.image + } else if (typeof avatar.shape === 'string' && PRESET.test(avatar.shape)) { + patch.avatar = avatar.shape + } else if (typeof avatar.emoji === 'string' && isEmojiAvatar(avatar.emoji)) { + patch.avatar = avatar.emoji.trim() + } + return patch +} + +export function lookFingerprint(bot) { + const patch = lookToServer(bot) + return JSON.stringify({ avatar: patch.avatar ?? null, color: patch.color ?? null }) +} diff --git a/plugins/social/src/mcp-client.js b/plugins/social/src/mcp-client.js index ec1477f3a..2903a56fc 100644 --- a/plugins/social/src/mcp-client.js +++ b/plugins/social/src/mcp-client.js @@ -152,10 +152,24 @@ function parseBody(text, type) { return JSON.parse(text) } -function isUnknownTool(error) { +export function isUnknownTool(error) { const text = String(error?.message ?? error ?? '') const code = String(error?.code ?? '') - return code === 'unknown' || /unknown tool|no tool|not found|method not found|unrecognized/i.test(text) + const blob = `${code} ${text}` + if (/grant_not_found/i.test(blob)) return false + return code === 'unknown' || /unknown tool|no tool|method not found|unrecognized tool/i.test(text) +} + +export function isGrantNotFound(error) { + const blob = `${error?.code ?? ''} ${error?.reason ?? ''} ${error?.message ?? error ?? ''}` + return /grant_not_found/i.test(blob) +} + +export function isMissingEndpoint(error) { + if (isGrantNotFound(error)) return false + const code = String(error?.code ?? '') + const text = String(error?.message ?? error ?? '') + return code === 'http_404' || code === 'http_405' || /HTTP 404|HTTP 405/i.test(text) } function asList(value) { @@ -248,7 +262,17 @@ export function createSocialApi({ server, token, fetch: fetchImpl = socialFetch, const text = await response.text() 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}`) + if (!response.ok) { + const reason = data?.code ?? data?.error?.code + const err = new BotSocialError( + `Bot Social ${method} ${path}: HTTP ${response.status}${reason ? ` ${reason}` : ''}`, + reason ? String(reason) : `http_${response.status}`, + ) + err.status = response.status + err.detail = data + err.reason = reason + throw err + } // 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. @@ -299,6 +323,67 @@ export function createSocialApi({ server, token, fetch: fetchImpl = socialFetch, return tryTool('revoke_bot_grant', args, () => tryTool('revoke_grant', args, viaRestOrUnfriend)) } + async function updateBotProfile({ botId, displayName, avatar, color } = {}) { + const args = { + ...(botId ? { bot_id: botId } : {}), + ...(displayName !== undefined ? { display_name: displayName } : {}), + ...(avatar !== undefined ? { avatar } : {}), + ...(color !== undefined ? { color } : {}), + } + const body = { + ...(displayName !== undefined ? { display_name: displayName } : {}), + ...(avatar !== undefined ? { avatar } : {}), + ...(color !== undefined ? { color } : {}), + } + const viaRest = async (error) => { + if (!botId || !base) { + if (isUnknownTool(error) || isMissingEndpoint(error)) return { unavailable: true } + throw error ?? new BotSocialError('Bot Social update_bot_profile: which Bot?', 'invalid') + } + try { + return await rest('PATCH', `/v1/bots/${encodeURIComponent(botId)}/profile`, body) + } catch (restErr) { + if (isUnknownTool(restErr) || isMissingEndpoint(restErr)) return { unavailable: true } + throw restErr + } + } + try { + return await tryTool('update_bot_profile', args, viaRest) + } catch (error) { + if (isUnknownTool(error) || isMissingEndpoint(error)) return { unavailable: true } + throw error + } + } + + async function declineGrant({ grantId, botId } = {}) { + if (!grantId) throw new BotSocialError('Bot Social decline_bot_grant: which grant?', 'invalid') + const args = { + grant_id: grantId, + ...(botId ? { bot_id: botId } : {}), + } + const viaRest = async (error) => { + if (!base) { + if (isUnknownTool(error) || isMissingEndpoint(error)) return { unavailable: true } + throw error ?? new BotSocialError('Bot Social decline_bot_grant: no server URL', 'no_server') + } + const suffix = botId ? `?bot_id=${encodeURIComponent(botId)}` : '' + try { + return await rest('DELETE', `/v1/remote-bots/${encodeURIComponent(grantId)}${suffix}`) + } catch (restErr) { + if (isGrantNotFound(restErr)) return { grant_id: grantId, status: 'declined', gone: true } + if (isUnknownTool(restErr) || isMissingEndpoint(restErr)) return { unavailable: true } + throw restErr + } + } + try { + return await tryTool('decline_bot_grant', args, viaRest) + } catch (error) { + if (isGrantNotFound(error)) return { grant_id: grantId, status: 'declined', gone: true } + if (isUnknownTool(error) || isMissingEndpoint(error)) return { unavailable: true } + throw error + } + } + return { callTool: (name, args) => mcp.callTool(name, args), rest, @@ -320,6 +405,8 @@ export function createSocialApi({ server, token, fetch: fetchImpl = socialFetch, sendAsMember: args => mcp.callTool('send_as_member', args), listGrants, revokeGrant, + updateBotProfile, + declineGrant, } } diff --git a/plugins/social/src/social.js b/plugins/social/src/social.js index aa566cc57..cd7163ec9 100644 --- a/plugins/social/src/social.js +++ b/plugins/social/src/social.js @@ -31,6 +31,7 @@ import { readFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { basename, dirname, extname, isAbsolute, join, sep } from 'node:path' import { createSocialApi, untrustedText, mapPool, GRANT_CONCURRENCY, grantPersonView, memberHandleOf } from './mcp-client.js' +import { lookFromServer, lookToServer, lookFingerprint } from './look.js' import { createFaceRoomApi, isJoinCode, @@ -235,8 +236,9 @@ export function mapJoinInviteError(error) { : {}), }) } - if (/invite is used|invite_used|already used|max_uses|used_up/i.test(blob)) { - return Object.assign(new Error('This invite was already used. Ask your friend for a new one.'), { code: 'invite_used' }) + if (/invite_unavailable|invite is used|invite_used|already used|max_uses|used_up/i.test(blob)) { + const code = /invite_unavailable/i.test(blob) ? 'invite_unavailable' : 'invite_used' + return Object.assign(new Error('This invite was already used. Ask your friend for a new one.'), { code }) } return error } @@ -504,6 +506,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp handle: member.handle, ...(forFace ? { roomOnly: true } : {}), }) + void syncBotLook(bot).catch(error => warn('look not synced', error)) return memberOf(bot.id) } // Existing identities and granted Bots at startup, once ds-bot's team is loaded. @@ -530,6 +533,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp } } await syncOutgoingGrants().catch(error => warn('grants not synced', error)) + void syncLocalLooks().catch(error => warn('looks not synced', error)) } disposers.push(flybot.on('bot.created', bot => { if (hasPersonGrant(bot.id)) void register(bot, { forInvite: true }).catch(error => warn('register failed', error)) @@ -546,9 +550,56 @@ export function createSocial(service, config = {}, { client: given, api: givenAp if (home && member) void client.callTool('remove_home_member', { home: home.id, member: member.bot_id }).catch(error => warn('revoke failed', error)) })) + async function syncBotLook(bot) { + const member = memberOf(bot?.id) + if (!member?.bot_id || typeof api.updateBotProfile !== 'function') return + const patch = lookToServer(bot) + const fingerprint = lookFingerprint(bot) + if (settings.lookSynced?.[bot.id] === fingerprint) return + const empty = !('avatar' in patch) && !('color' in patch) + if (empty && !settings.lookSynced?.[bot.id]) return + const body = empty ? { avatar: null, color: null } : patch + try { + const result = await api.updateBotProfile({ botId: member.bot_id, ...body }) + if (result?.unavailable) return + } catch (error) { + if (/invalid_avatar|invalid_color|invalid_request/i.test(`${error?.code ?? ''} ${error?.message ?? ''}`)) { + settings.lookSynced = { ...(settings.lookSynced ?? {}), [bot.id]: fingerprint } + saveSettings() + return + } + throw error + } + settings.lookSynced = { ...(settings.lookSynced ?? {}), [bot.id]: fingerprint } + saveSettings() + } + async function syncLocalLooks() { + for (const bot of flybot.bot.list()) { + try { await syncBotLook(bot) } catch (error) { warn(`${bot.name} look not synced`, error) } + } + } + // ------------------------------------------------------------ remote roster let remote = { homes: [] } let fetchedAt = 0 + // Handles we declined this session. Not persisted (that is the old-server local hide). + // Keeps a stale grant re-filter or a mock/lagging list_remote_bots from putting the + // Bot back before the server drops it. Cleared when the handle is absent from a + // successful home fetch, or on an explicit re-share / regranted join. + const declinedRemotes = new Set() + function unhideDeclined(handles) { + for (const handle of handles ?? []) { + const key = norm(handle) + if (key && key !== '@') declinedRemotes.delete(key) + } + } + function forgetDeclinedIfGone(homes) { + if (!declinedRemotes.size) return + const present = new Set(presentRemoteHandles(homes)) + for (const key of [...declinedRemotes]) { + if (!present.has(key)) declinedRemotes.delete(key) + } + } function rememberInbound(handle) { const key = norm(handle) if (!key || key === '@') return @@ -618,24 +669,37 @@ export function createSocial(service, config = {}, { client: given, api: givenAp function keepGrantedRemote(bot) { const key = norm(bot.handle) if (!key) return false + if (declinedRemotes.has(key)) return false if (settings.inboundBots?.[key] || settings.inboundBots?.[bot.home]) return true if (outgoingPeerHandles().has(key)) return false if (settings.roomBots?.[key]) return false return true } + function mapRemoteBot(bot, homeHandle) { + const look = lookFromServer({ + ...bot, + ...(bot.peer && typeof bot.peer === 'object' ? bot.peer : {}), + }) + return { + handle: norm(bot.handle), + name: cleanText(bot.display_name, norm(bot.handle)), + home: homeHandle, + remote: true, + ...(bot.grant_id || bot.grantId ? { grantId: String(bot.grant_id || bot.grantId) } : {}), + ...(bot.bot_id ? { botId: String(bot.bot_id) } : {}), + ...look, + } + } function mapRemoteHomes(entries) { return (entries ?? []).map(entry => { const homeHandle = norm(entry.home.handle) + const homeLook = lookFromServer(entry.home) return { home: homeHandle, name: cleanText(entry.home.display_name), owner: cleanText(entry.home.owner_display_name), - bots: (entry.bots ?? []).map(bot => ({ - handle: norm(bot.handle), - name: cleanText(bot.display_name, norm(bot.handle)), - home: homeHandle, - remote: true, - })).filter(bot => !isHomeLevelHandle(bot.handle, homeHandle)), + ...(homeLook.color ? { color: homeLook.color } : {}), + bots: (entry.bots ?? []).map(bot => mapRemoteBot(bot, homeHandle)).filter(bot => !isHomeLevelHandle(bot.handle, homeHandle)), // The friendship covers only these local members (an invite made for one Bot, or joined // as one Bot); empty when it covers this whole home. Only they can talk to this friend. via: (entry.via ?? []).map(norm), @@ -643,12 +707,20 @@ export function createSocial(service, config = {}, { client: given, api: givenAp }) } function listedRemoteBots() { - return remote.homes.flatMap(entry => entry.bots.map(bot => ({ handle: bot.handle, name: bot.name, home: entry.home, homeName: entry.name, owner: entry.owner }))) + return remote.homes.flatMap(entry => entry.bots.map(bot => ({ + handle: bot.handle, name: bot.name, home: entry.home, homeName: entry.name, owner: entry.owner, + ...(bot.color ? { color: bot.color } : {}), + ...(bot.avatar ? { avatar: bot.avatar } : {}), + ...(bot.avatarUpdatedAt ? { avatarUpdatedAt: bot.avatarUpdatedAt } : {}), + }))) } function applyRemoteRoster(unfiltered, { source, ok }) { const next = forgetHiddenIfGone(settings, presentRemoteHandles(unfiltered.homes), { source, ok, homes: unfiltered.homes }) if (next !== settings) { settings = next; saveSettings() } - remote = { homes: filterHiddenHomes(unfiltered.homes, settings) } + const dropped = declinedRemotes.size + ? unfiltered.homes.map(entry => ({ ...entry, bots: (entry.bots ?? []).filter(bot => !declinedRemotes.has(norm(bot.handle))) })) + : unfiltered.homes + remote = { homes: filterHiddenHomes(dropped, settings) } // Hidden and home-level handles stay out of publish, so remote-chat sync marks those // sessions gone/hidden instead of putting them back in the picker and sidebar. return flybot.remote?.publish(listedRemoteBots()) ?? remote @@ -662,6 +734,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp if (home) { result = await client.callTool('list_remote_bots', { home: home.id }) const mapped = mapRemoteHomes(result?.homes) + forgetDeclinedIfGone(mapped) backfillInboundFromRoster(mapped) unfiltered = { homes: mapped.map(entry => ({ ...entry, bots: entry.bots.filter(bot => keepGrantedRemote(bot)) })) } } else { @@ -670,7 +743,14 @@ export function createSocial(service, config = {}, { client: given, api: givenAp unfiltered = { homes: (overview?.friends ?? []).filter(item => item.status === 'active').map(item => ({ home: norm(item.peer?.handle), name: cleanText(item.peer?.display_name), owner: cleanText(item.peer?.owner_display_name), - bots: [{ handle: norm(item.peer?.handle), name: cleanText(item.peer?.display_name, norm(item.peer?.handle)), home: norm(item.peer?.handle), remote: true, conversationId: item.last_conversation?.status === 'active' ? item.last_conversation.conversation_id : undefined }], + bots: [{ + handle: norm(item.peer?.handle), + name: cleanText(item.peer?.display_name, norm(item.peer?.handle)), + home: norm(item.peer?.handle), + remote: true, + conversationId: item.last_conversation?.status === 'active' ? item.last_conversation.conversation_id : undefined, + ...lookFromServer(item.peer), + }], })), } } @@ -687,11 +767,13 @@ export function createSocial(service, config = {}, { client: given, api: givenAp void syncOutgoingGrants().then(() => { if (fetchedAt !== rosterAt || !home || !result) return const mapped = mapRemoteHomes(result.homes) + forgetDeclinedIfGone(mapped) applyRemoteRoster({ homes: mapped.map(entry => ({ ...entry, bots: entry.bots.filter(bot => keepGrantedRemote(bot)) })) }, { source, ok: true }) }).catch(error => warn('grants not synced', error)) const published = applyRemoteRoster(unfiltered, { source, ok: true }) if (published && typeof published.then === 'function') await published.catch(error => warn('remote chats not synced', error)) void leaveMissingRemotes().catch(error => warn('unpublished remotes not left', error)) + void syncLocalLooks().catch(error => warn('looks not synced', error)) return remote } const remoteBot = async handle => (await remoteBots()).homes.flatMap(entry => entry.bots).find(bot => bot.handle === norm(handle)) @@ -701,9 +783,10 @@ export function createSocial(service, config = {}, { client: given, api: givenAp const parts = key.slice(1).split('.') return parts.length > 2 ? `@${parts.slice(1).join('.')}` : key } - // Hide a friend's Bot on this install only: persist, leave every room it is in, do not unfriend. - // It stays hidden across refresh while it remains granted. If the owner revokes the grant, the - // hide is forgotten; an explicit re-grant (it appears on list_remote_bots again) shows it. + // Hide a friend's Bot: decline the grant on Bot Social when we have a grant_id so the + // owner's Manage access drops it; an older server (no decline tool) falls back to a + // local hide. It stays hidden across refresh while it remains granted. If the owner + // revokes, or we declined, a later re-share (list_remote_bots / regranted join) shows it. function roomsHolding(key) { const listed = [] for (const [faceId, meta] of Object.entries(settings.faceRooms ?? {})) { @@ -759,14 +842,32 @@ export function createSocial(service, config = {}, { client: given, api: givenAp async function hideRemote(handle) { const key = norm(handle) if (!key || key === '@') throw new Error('which Bot?') - settings = hideRemoteInSettings(settings, key, now()) - saveSettings() - remote = { homes: filterHiddenHomes(remote.homes, settings) } + const listed = remote.homes.flatMap(entry => entry.bots).find(bot => bot.handle === key) + let localHide = true + if (listed?.grantId && typeof api.declineGrant === 'function') { + try { + const result = await api.declineGrant({ grantId: listed.grantId, botId: listed.botId }) + if (result && result.unavailable !== true) { + localHide = false + declinedRemotes.add(key) + } + } catch (error) { + throw Object.assign(new Error('Could not remove that Bot. Try again.'), { cause: error }) + } + } + if (localHide) { + settings = hideRemoteInSettings(settings, key, now()) + saveSettings() + remote = { homes: filterHiddenHomes(remote.homes, settings) } + } else { + remote = { homes: remote.homes.map(entry => ({ ...entry, bots: entry.bots.filter(bot => bot.handle !== key) })) } + } + const homes = remote.homes const published = flybot.remote?.publish(listedRemoteBots()) if (published && typeof published.then === 'function') await published.catch(error => warn('hidden remote chat not synced', error)) const leftRooms = roomsHolding(key) void afterHideRemote(key).catch(error => warn('hide-remote follow-up failed', error)) - return { handle: key, hidden: true, leftRooms, homes: remote.homes } + return { handle: key, hidden: true, leftRooms, homes } } async function leaveMissingRemotes() { if (typeof flybot.rooms?.list !== 'function') return @@ -998,6 +1099,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp return polling } async function pollOnce() { + void syncLocalLooks().catch(error => warn('looks not synced', error)) const targets = home ? flybot.bot.list().filter(bot => activeMember(bot.id)).map(bot => ({ local: bot, botId: memberOf(bot.id).bot_id })) : [{ local: (config.localBot ? flybot.bot.find(config.localBot) : undefined) ?? flybot.bot.list().find(bot => bot.isMain), botId: config.botId }] @@ -1256,6 +1358,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp return h === focus || h === homeKey || homeOfPeer(h) === homeKey }) : [] + if (handles.length) unhideDeclined(handles) const next = handles.length ? revealHiddenIfPresent(settings, handles) : settings if (next !== settings) { settings = next; saveSettings() } fetchedAt = 0 @@ -1271,11 +1374,23 @@ export function createSocial(service, config = {}, { client: given, api: givenAp await confirmedCall('decide_approval', { approval_id: joined.approval_id, decision: 'approved' }) status = 'pending_target' } + const offered = joined.bot ?? joined.inviter?.bot ?? joined.offered_bot + const offeredHandle = offered?.handle ? norm(offered.handle) : '' + if (offeredHandle) unhideDeclined([offeredHandle]) + const unhide = revealHiddenIfPresent(settings, offeredHandle ? [offeredHandle] : []) + if (unhide !== settings) { settings = unhide; saveSettings() } fetchedAt = 0 const roster = await remoteBots({ fresh: true }).catch(() => remote) const added = (roster?.homes ?? []).flatMap(entry => entry.bots).filter(bot => !before.has(bot.handle)) for (const bot of added) rememberInbound(bot.handle) - const offered = joined.bot ?? joined.inviter?.bot ?? joined.offered_bot + unhideDeclined(added.map(bot => bot.handle)) + const again = revealHiddenIfPresent(settings, added.map(bot => bot.handle)) + if (again !== settings) { + settings = again + saveSettings() + fetchedAt = 0 + await remoteBots({ fresh: true }).catch(() => {}) + } if (offered?.handle) rememberInbound(offered.handle) const inviterHome = joined.inviter?.handle ? homeOfPeer(joined.inviter.handle) : added[0]?.home const inviterName = cleanText(joined.inviter?.display_name || joined.inviter?.owner_display_name || added[0]?.owner || added[0]?.homeName) @@ -1289,6 +1404,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp inviterName: inviterName || undefined, ...(inviterBotName ? { inviterBotName } : {}), ...(joinedAs ? { joinedAs } : {}), + ...(joined.regranted ? { regranted: true } : {}), } })) disposers.push(flybot.api.handle('social/respond', async ({ relationshipId, decision } = {}) => { diff --git a/plugins/social/test/face-rooms.test.js b/plugins/social/test/face-rooms.test.js index 8ccf5ee2b..79456fb4c 100644 --- a/plugins/social/test/face-rooms.test.js +++ b/plugins/social/test/face-rooms.test.js @@ -349,6 +349,14 @@ test('viewFaceMember keeps a friend Bot avatar when the payload has one', () => assert.equal(pictured.color, 'green') assert.equal(faceMemberLook({ image: 'https://example.test/x.png' }).avatar.image, 'https://example.test/x.png') assert.equal(faceMemberLook({ avatar: { image: 'javascript:alert(1)' } }).avatar, undefined) + const preset = viewFaceMember({ + handle: '@coder.bob.home', display_name: 'Coder', + avatar: 'star', avatar_kind: 'preset', color: '#2a92fe', + }) + assert.equal(preset.avatar.shape, 'star') + assert.equal(preset.color, '#2a92fe') + const emoji = faceMemberLook({ avatar: '🌟', avatar_kind: 'emoji' }) + assert.equal(emoji.avatar.emoji, '🌟') }) test('viewMe maps auto_approve_face_rooms; face-room REST still works when /v1/me is 403', async () => { diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index f11857f7a..85128d199 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -9,7 +9,8 @@ import { apply as applyDsBot } from 'ds-bot' import { fakeContext } from './fake-ctx.js' import { apply as applySocial, inject, name } from '../index.js' import { approvalView, createSocial, draftText, homeHandleOf, onboard, onboardHome, nextOnboardDisplayName, mapJoinInviteError, registerUnavailable, startSocial, unavailableMessage } from '../src/social.js' -import { createMcpClient, createSocialApi, untrustedText, mapPool, socialHttpAgent, fetchOptions, grantPersonView, memberHandleOf, personHomeOf, normalizeGrant } from '../src/mcp-client.js' +import { createMcpClient, createSocialApi, untrustedText, mapPool, socialHttpAgent, fetchOptions, grantPersonView, memberHandleOf, personHomeOf, normalizeGrant, isUnknownTool, isGrantNotFound, isMissingEndpoint } from '../src/mcp-client.js' +import { lookFromServer, lookToServer, lookFingerprint, hexOfColor } from '../src/look.js' import { encodeRoomShare } from '../../../core/src/shared/room-share.js' import { encodeFaceHuman } from '../src/face-rooms.js' import { @@ -31,7 +32,9 @@ function mockServer({ token = 'owner-token', friends = true } = {}) { create_invite: a => ({ code: 'INV123', url: 'https://s/i/INV123', status: 'active', expires_at: '2026-10-21T00:00:00Z', bot: a.bot_id, auto_accept: a.auto_accept }), join_invite: a => { if (a.code === 'USED') throw Object.assign(new Error('Bot Social join_invite: invite is used'), { code: 'invite_used' }) + if (a.code === 'UNAVAIL') throw Object.assign(new Error('Bot Social join_invite: invite_unavailable'), { code: 'invite_unavailable' }) if (a.code === 'EXISTS') throw Object.assign(new Error('Bot Social join_invite: relationship_exists'), { code: 'relationship_exists', peer: { handle: '@bob.home', display_name: 'Bob' }, bot: { handle: '@coder.bob.home', display_name: 'Coder' } }) + if (a.code === 'REGRANT') return { relationship_id: 'r1', status: 'active', regranted: true, inviter: { handle: 'bob.home', display_name: 'Bob' }, bot: { handle: '@coder.bob.home', display_name: 'Coder' } } return a.code === 'NEEDSOK' ? { relationship_id: 'r9', status: 'pending_requester_owner', awaiting: 'your_owner_approval', approval_id: 'apr9', inviter: { handle: 'eve.home', display_name: 'Eve' }, bot: { handle: '@helper.eve.home', display_name: 'Helper' } } : { relationship_id: 'r8', status: 'active', awaiting: null, inviter: { handle: 'bob.home', display_name: 'Bob' }, bot: { handle: '@coder.bob.home', display_name: 'Coder' } } @@ -93,6 +96,14 @@ function mockServer({ token = 'owner-token', friends = true } = {}) { calls.push({ url, name: 'REST DELETE /v1/grants', args: { id: String(url).split('/').pop() } }) return new Response(JSON.stringify({ ok: true }), { status: 200 }) } + if (/\/v1\/remote-bots\//.test(String(url)) && init.method === 'DELETE') { + calls.push({ url, name: 'REST DELETE /v1/remote-bots', args: { id: String(url).split('/').pop()?.split('?')[0] } }) + return new Response(JSON.stringify({ error: { code: 'not_found', message: 'no such route' } }), { status: 404 }) + } + if (/\/v1\/bots\/[^/]+\/profile/.test(String(url)) && init.method === 'PATCH') { + calls.push({ url, name: 'REST PATCH /v1/bots/profile', args: init.body ? JSON.parse(init.body) : {} }) + return new Response(JSON.stringify({ error: { code: 'not_found', message: 'no such route' } }), { status: 404 }) + } 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 }) @@ -3243,6 +3254,10 @@ test('#139: join errors are English and relationship_exists unhides', async (t) assert.equal(used.ok, false) assert.match(used.error.message, /already used/i) assert.doesNotMatch(used.error.message, /invite is used/) + const unavailable = await call('flybot/social/join-invite', { code: 'UNAVAIL' }) + assert.equal(unavailable.ok, false) + assert.match(unavailable.error.message, /already used/i) + assert.doesNotMatch(unavailable.error.message, /invite_unavailable/) const exists = await call('flybot/social/join-invite', { code: 'EXISTS' }) assert.equal(exists.ok, false) assert.match(exists.error.message, /already friends/i) @@ -3409,4 +3424,186 @@ test('#138: join success copy names the person and the Bot', () => { assert.match(src, /not shared unless you invite them to it/) }) +test('#137: look helpers map preset, emoji, data URL, and named color', () => { + assert.equal(hexOfColor('blue'), '#2a92fe') + assert.equal(hexOfColor('#ABCDef'), '#abcdef') + assert.deepEqual(lookFromServer({ + avatar: 'star', avatar_kind: 'preset', color: '#2a92fe', avatar_updated_at: '2026-10-16T00:00:00Z', + }), { color: '#2a92fe', avatar: { shape: 'star' }, avatarUpdatedAt: '2026-10-16T00:00:00Z' }) + assert.equal(lookFromServer({ avatar: '🌟', avatar_kind: 'emoji' }).avatar.emoji, '🌟') + const data = 'data:image/png;base64,AAAA' + assert.equal(lookFromServer({ avatar: data, avatar_kind: 'image' }).avatar.image, data) + assert.equal(lookFromServer({}).color, undefined) + assert.equal(lookFromServer({ color: 'green' }).color, undefined) + const patch = lookToServer({ color: 'deepseek', avatar: { shape: 'whale' } }) + assert.equal(patch.color, '#4d6bfe') + assert.equal(patch.avatar, 'whale') + assert.equal(lookToServer({ avatar: { image: 'https://example.test/x.png' } }).avatar, undefined) + assert.ok(lookFingerprint({ color: 'blue', avatar: { shape: 'star' } })) +}) + +test('#137: list_remote_bots look fields are published and cached by avatar_updated_at', async (t) => { + const { ctx, call, stop } = await team() + t.after(stop) + const server = mockServer() + const at = '2026-10-16T12:00:00Z' + server.tools.list_remote_bots = () => ({ + homes: [{ + relationship_id: 'r1', + home: { bot_id: 'h2', handle: '@bob.home', display_name: 'Bob', owner_display_name: 'Bob' }, + bots: [{ + bot_id: 'x1', handle: '@coder.bob.home', display_name: 'Coder', grant_id: 'gr_1', + avatar: 'star', avatar_kind: 'preset', color: '#2a92fe', avatar_updated_at: at, + }], + }], + }) + const social = createSocial(ctx.flybot, { autoAccept: false, inboxPollMs: 0 }, { + 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) + await social.registerAll() + const listed = (await call('flybot/social/remote-bots', { fresh: true })).value + const coder = listed.homes[0].bots.find(bot => bot.handle === '@coder.bob.home') + assert.equal(coder.color, '#2a92fe') + assert.equal(coder.avatar.shape, 'star') + assert.equal(coder.avatarUpdatedAt, at) + assert.equal(coder.grantId, 'gr_1') +}) + +test('#137: owner look is sent with update_bot_profile; old servers are skipped', async (t) => { + const { ctx, call, home, stop } = await team() + t.after(stop) + const painted = await call('update-bot', { id: 'a', color: 'deepseek', avatar: { shape: 'whale' } }) + assert.equal(painted.ok, true, painted.error?.message) + const server = mockServer() + const settingsPath = join(home, 'flybot-social-settings.json') + const social = createSocial(ctx.flybot, { autoAccept: false, inboxPollMs: 0, settingsPath }, { + 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) + await social.registerAll() + await new Promise(resolve => setTimeout(resolve, 40)) + let saved = {} + try { saved = JSON.parse(readFileSync(settingsPath, 'utf8')) } catch { /* none */ } + assert.equal(saved.lookSynced?.a, undefined, 'unknown tool is not a successful sync') + const probed = server.calls.filter(c => c.name === 'update_bot_profile').length + server.tools.update_bot_profile = (a) => ({ bot_id: a.bot_id, avatar: a.avatar, color: a.color, avatar_kind: 'preset', avatar_updated_at: '2026-10-16T00:00:00Z' }) + await social.registerAll() + await new Promise(resolve => setTimeout(resolve, 40)) + const sent = server.calls.filter(c => c.name === 'update_bot_profile') + assert.ok(sent.length > probed) + assert.equal(sent.at(-1).args.avatar, 'whale') + assert.equal(sent.at(-1).args.color, '#4d6bfe') + assert.ok(JSON.parse(readFileSync(settingsPath, 'utf8')).lookSynced?.a) +}) + +test('#139: decline_bot_grant on Remove; grant_not_found is already gone', async (t) => { + const { ctx, call, home, stop } = await team() + t.after(stop) + const server = mockServer() + const declined = [] + server.tools.list_remote_bots = () => ({ + homes: [{ + relationship_id: 'r1', + home: { bot_id: 'h2', handle: '@bob.home', display_name: 'Bob', owner_display_name: 'Bob' }, + bots: [ + { bot_id: 'x1', handle: '@coder.bob.home', display_name: 'Coder', grant_id: 'gr_coder' }, + { bot_id: 'x2', handle: '@writer.bob.home', display_name: 'Writer', grant_id: 'gr_writer' }, + ], + }], + }) + server.tools.decline_bot_grant = (a) => { + declined.push(a) + if (a.grant_id === 'gr_missing') throw Object.assign(new Error('Bot Social decline_bot_grant: grant_not_found'), { code: 'grant_not_found' }) + return { grant_id: a.grant_id, status: 'declined' } + } + const settingsPath = join(home, 'flybot-social-settings.json') + const social = createSocial(ctx.flybot, { autoAccept: false, inboxPollMs: 0, settingsPath }, { + 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) + await social.registerAll() + await call('flybot/social/remote-bots', { fresh: true }) + const hidden = (await call('flybot/social/hide-remote', { handle: '@coder.bob.home' })).value + assert.equal(hidden.hidden, true) + assert.equal(hidden.homes[0].bots.some(b => b.handle === '@coder.bob.home'), false) + assert.deepEqual(declined[0], { grant_id: 'gr_coder', bot_id: 'x1' }) + const saved = JSON.parse(readFileSync(settingsPath, 'utf8')) + assert.equal(saved.hiddenRemotes?.['@coder.bob.home'], undefined, 'a declined grant is not a local hide') + const still = (await call('flybot/social/remote-bots', { fresh: true })).value + assert.equal(still.homes[0].bots.some(b => b.handle === '@coder.bob.home'), false, 'declined Bot stays off the roster until the server lists it again') + server.tools.decline_bot_grant = (a) => { + declined.push(a) + throw Object.assign(new Error('Bot Social decline_bot_grant: grant_not_found'), { code: 'grant_not_found' }) + } + const gone = (await call('flybot/social/hide-remote', { handle: '@writer.bob.home' })).value + assert.equal(gone.homes[0].bots.some(b => b.handle === '@writer.bob.home'), false) + assert.equal(JSON.parse(readFileSync(settingsPath, 'utf8')).hiddenRemotes?.['@writer.bob.home'], undefined) +}) + +test('#139: old-server decline 404 falls back to local hide', async (t) => { + const { ctx, call, home, stop } = await team() + t.after(stop) + const server = mockServer() + server.tools.list_remote_bots = () => ({ + homes: [{ + relationship_id: 'r1', + home: { bot_id: 'h2', handle: '@bob.home', display_name: 'Bob', owner_display_name: 'Bob' }, + bots: [{ bot_id: 'x1', handle: '@coder.bob.home', display_name: 'Coder', grant_id: 'gr_coder' }], + }], + }) + const settingsPath = join(home, 'flybot-social-settings.json') + const social = createSocial(ctx.flybot, { autoAccept: false, inboxPollMs: 0, settingsPath }, { + 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) + await social.registerAll() + await call('flybot/social/remote-bots', { fresh: true }) + const hidden = (await call('flybot/social/hide-remote', { handle: '@coder.bob.home' })).value + assert.equal(hidden.hidden, true) + assert.ok(JSON.parse(readFileSync(settingsPath, 'utf8')).hiddenRemotes['@coder.bob.home']) +}) + +test('#139: regranted join unhides the Bot', async (t) => { + const { ctx, call, home, stop } = await team() + t.after(stop) + const server = mockServer() + const settingsPath = join(home, 'flybot-social-settings.json') + const social = createSocial(ctx.flybot, { autoAccept: false, inboxPollMs: 0, settingsPath }, { + 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) + await social.registerAll() + await call('flybot/social/remote-bots', { fresh: true }) + await call('flybot/social/hide-remote', { handle: '@coder.bob.home' }) + const joined = (await call('flybot/social/join-invite', { code: 'REGRANT' })).value + assert.equal(joined.regranted, true) + const listed = (await call('flybot/social/remote-bots', { fresh: true })).value + assert.equal(listed.homes[0].bots.some(bot => bot.handle === '@coder.bob.home'), true) +}) + +test('createSocialApi updateBotProfile and declineGrant fall back on unknown tools', async () => { + const server = mockServer() + const api = createSocialApi({ server: 'https://s', token: 'owner-token', fetch: server.fetch }) + const profile = await api.updateBotProfile({ botId: 'm1', avatar: 'star', color: '#2a92fe' }) + assert.equal(profile.unavailable, true) + const declined = await api.declineGrant({ grantId: 'gr_1', botId: 'x1' }) + assert.equal(declined.unavailable, true) + assert.equal(isUnknownTool(Object.assign(new Error('no tool'), { code: 'unknown' })), true) + assert.equal(isGrantNotFound(Object.assign(new Error('grant_not_found'), { code: 'grant_not_found' })), true) + assert.equal(isUnknownTool(Object.assign(new Error('grant_not_found'), { code: 'grant_not_found' })), false) + assert.equal(isMissingEndpoint(Object.assign(new Error('Bot Social DELETE /x: HTTP 404'), { code: 'http_404' })), true) +}) + +