Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/137-bot-avatars.added.md
Original file line number Diff line number Diff line change
@@ -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))
1 change: 1 addition & 0 deletions changelog.d/139-decline-grant.fixed.md
Original file line number Diff line number Diff line change
@@ -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))
12 changes: 10 additions & 2 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -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)
};
}
Expand Down Expand Up @@ -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,
Expand All @@ -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) } }));
}
Expand Down Expand Up @@ -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}
Expand Down
4 changes: 3 additions & 1 deletion core/src/client/characters.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 7 additions & 1 deletion core/src/client/mark.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) } }))
}
Expand Down
1 change: 1 addition & 0 deletions core/src/client/styles.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
24 changes: 23 additions & 1 deletion core/src/host/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down Expand Up @@ -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.
Expand Down
29 changes: 26 additions & 3 deletions core/src/host/remote-chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -115,16 +115,38 @@ 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') {
Comment on lines +123 to +127

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear removed fields when caching a newer remote look

When a peer resets its avatar or color, list_remote_bots can supply a new avatarUpdatedAt while omitting the cleared field. These branches only copy fields that are present, after which the function records the new timestamp; every later refresh then takes the timestamp fast path and the persisted remote chat keeps rendering the old avatar/color forever instead of the handle-seeded fallback. For a versioned payload, clear missing look fields before committing the new version.

Useful? React with 👍 / 👎.

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()
const existing = chatOfHandle(handle)
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()
Expand All @@ -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) }
})
Expand Down
6 changes: 6 additions & 0 deletions core/test/inbound-ui.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
6 changes: 5 additions & 1 deletion core/types/flybot.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ export interface BotView {
systemPrompt: string
/** Every plugin's fields: fields[plugin][key]. Stored as state.bots[id].fields. */
fields: Record<string, Record<string, Json>>
/** 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 }. */
Expand Down Expand Up @@ -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 }

Expand Down
2 changes: 1 addition & 1 deletion docs/subsystems/core-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`@<bot>.<home>` — 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
Expand Down
1 change: 1 addition & 0 deletions docs/subsystems/core-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ A tracked request with a stable id (`task-<hex>`) 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.

Expand Down
Loading
Loading