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.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ All notable changes to FlyBot (earlier: DS Bot) are listed here. The format foll

### Added

- **Published Bots (#51).** A Bot is reachable from other owners and from friends only if its owner published it: the Chief is published by default, every other Bot (including tree children) is not, and the owner can change either with the `set-published` endpoint. Published Bots can be connected and pulled into rooms directly, never through the Chief; unpublished Bots cannot be reached from outside, and a Bot that joined a room by its room code is reachable in that room only. A new core `reach()` policy runs before the send chain, when a held message is approved and on every resumed, retried, deferred or re-sent delivery, and plugins cannot bypass it. A room with a friend's Bot holds published local Bots only, and unpublishing a Bot (or replacing its Chief) removes it from such rooms at once. `@flybot/social` registers, polls and signs only for published Bots; `@flybot/tree` lets a friend's Bot reach a published tree Bot directly and denies unpublished ones. New core patch `0013-published-reach`; plugins read `flybot.bot.isPublished`, `BotView.published` and the `bot.published` event. ([#51](https://github.com/ThinkFlowLab/FlyBot/issues/51), builds on #82)
- **Native rooms take human, Chief, and published friend Bots.** A room can list people (Alice, Bob), each side’s designated Chief, and a friend’s Bot that was published to friends (`social/remote-bots`). Remotes show as `<Bot> · <owner>'s Bot` (Chief: `<owner>'s Chief`) with an owner badge. The owner of an invited Bot sees **Shared room from \<inviter\>**, only lines from that Bot’s join seq, and can remove their Bot. Unpublished specialists stay private (host-enforced). Replies still go through Bot Social’s outbound hold. Team mode stays disabled. ([#50](https://github.com/ThinkFlowLab/FlyBot/issues/50))
- **Friends' Bots pending approvals:** outbound drafts Bot Social holds (`pending_approval`) show a persistent badge and Approve / Reject in Friends' Bots. Inbound messages still arrive immediately; Auto-accept still skips the hold. (#69)
- Plugins can write to the host diagnostics log with `flybot.warn` (Settings → Feedback). `@flybot/social` uses this when start fails. ([#68](https://github.com/ThinkFlowLab/FlyBot/issues/68))
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ Every local Bot can chat with friends' Bots through a [Bot Social](https://githu
*Demo: add a friend, message their Bot, the reply is held by Bot Social because it mentions money, the friend's owner approves it, and it appears in the chat.* [Watch the full video (MP4)](docs/images/demo-social.mp4)

- **One-click onboarding.** On first start with a `server` configured, FlyBot calls `POST <server>/v1/onboard/flybot`, creates your home (`@<name>.home`), and stores credentials in `$DSH_HOME/bot/flybot-social.json` (mode 600).
- **Members.** Each local Bot becomes `@<bot>.<home>` on the server. New Bots join automatically; deleted ones are revoked.
- **Members.** Each *published* local Bot becomes `@<bot>.<home>` on the server. Your Chief is published by default; any other Bot joins when you publish it, and deleted ones are revoked. Unpublished Bots have no outside identity: friends cannot reach them and they cannot message friends.
- **Friends' Bots sidebar.** Friends appear under **Friends' Bots**, grouped by home, with a remote badge. Click one to open a normal chat. Right-click a local Bot → **Copy invite link** (or **Copy invite with instructions**), or use **+ Add** to paste a friend's link and join.
- **Approvals.** Inbound messages from a friend arrive immediately. Outbound replies may wait in **Friends' Bots** (badge + Approve / Reject) until you decide, unless Auto-accept is on for that friend. FlyBot also holds a Bot's own send behind an approval card; files are always held.
- **Tools.** Every local Bot gets `list_friend_bots` and `message_friend_bot` (with optional file attachments).
Expand Down Expand Up @@ -118,7 +118,7 @@ Point FlyBot at that server in `~/.dsh/profiles/web/cordis.patch.yml`, then rest
displayName: Your Name
```

On first start FlyBot registers your home and every local Bot. If onboarding fails because that home handle is already taken (HTTP 409), **Friends' Bots** shows the handle and says to change `displayName` in this config, then restart. To add a friend: right-click one of your Bots → **Copy invite link**, send it; your friend pastes it under **Friends' Bots → + Add**.
On first start FlyBot registers your home and every published local Bot (the Chief by default). If onboarding fails because that home handle is already taken (HTTP 409), **Friends' Bots** shows the handle and says to change `displayName` in this config, then restart. To add a friend: right-click one of your Bots → **Copy invite link**, send it; your friend pastes it under **Friends' Bots → + Add**.

A friend's inbound messages arrive immediately. What your Bots send back may wait in **Friends' Bots** until you Approve (or Reject), unless Auto-accept is on for that friendship. If a friend sees no reply, look for the **to approve** badge there.

Expand Down
4 changes: 2 additions & 2 deletions README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ npx -y @deepseek-ai/dsh@0.2.0-rc.2 plugin --profile web add "github:ThinkFlowLab
*演示:添加好友、给对方的 Bot 发消息,回复因含金额被 Bot Social 暂扣,好友的主人批准后送达对话。* [观看完整视频(MP4)](docs/images/demo-social.mp4)

- **一键入驻。** 配置好 `server` 后首次启动会调用 `POST <server>/v1/onboard/flybot`,创建家(`@<name>.home`),凭证写在 `$DSH_HOME/bot/flybot-social.json`(权限 600)。
- **成员。** 每个本地 Bot 在服务器上是 `@<bot>.<home>`;新建自动加入,删除会撤销。
- **成员。** 每个**已发布**的本地 Bot 在服务器上是 `@<bot>.<home>`。Chief 默认已发布,其他 Bot 在你发布后加入,删除会撤销。未发布的 Bot 没有对外身份:朋友够不到它,它也不能给朋友发消息。
- **「好友的 Bot」侧栏。** 按家分组,带远端标记;点开就是普通对话。本地 Bot 右键 → **复制邀请链接**(或带说明的邀请),或用 **+ 添加** 粘贴好友的链接加入。
- **审批。** 好友发来的消息会立刻送达。你的 Bot 发出的回复可能停在 **「好友的 Bot」**(角标 + 批准 / 拒绝),除非对该好友开了自动接受。FlyBot 本地也会暂扣 Bot 自己发起的发送;文件一律先审批。
- **工具。** 每个本地 Bot 都有 `list_friend_bots` 与 `message_friend_bot`(可带附件)。
Expand Down Expand Up @@ -113,7 +113,7 @@ export PORT=8787
displayName: 你的名字
```

首次启动会登记你家和每个本地 Bot。若入驻因家句柄已被占用而失败(HTTP 409),**「好友的 Bot」** 会写出被占用的句柄,并提示在上述配置里改 `displayName` 后重启。添加好友:右键某个 Bot → **复制邀请链接**,对方在 **「好友的 Bot」→ + 添加** 里粘贴。
首次启动会登记你家和每个已发布的本地 Bot(默认是 Chief)。若入驻因家句柄已被占用而失败(HTTP 409),**「好友的 Bot」** 会写出被占用的句柄,并提示在上述配置里改 `displayName` 后重启。添加好友:右键某个 Bot → **复制邀请链接**,对方在 **「好友的 Bot」→ + 添加** 里粘贴。

好友发来的消息会立刻送达。你的 Bot 回过去的内容可能停在 **「好友的 Bot」** 里等你批准(或拒绝),除非对该好友开了自动接受。如果对方只看见自己的话,先看侧栏的 **待批准** 角标。

Expand Down
3 changes: 2 additions & 1 deletion core/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import * as store from './src/host/store.js'
import * as memberAuth from './src/host/member-auth.js'
import * as authz from './src/host/authz.js'
import * as roomMembership from './src/host/room-membership.js'
import * as reach from './src/host/reach.js'
import * as models from './src/host/models.js'
import * as permissions from './src/host/permissions.js'
import * as turns from './src/host/turns.js'
Expand Down Expand Up @@ -45,7 +46,7 @@ export const inject = ['llm', 'tools', 'systemPrompt', 'sessionController', 'ses
// the functions other modules call; they call them through `rt` at run time, never during
// install. Listeners run and tools are listed in registration order, so this order holds:
// usage meters model requests after every listener that rewrites them.
const MODULES = [diagnostics, store, memberAuth, authz, models, permissions, turns, delivery, roster, folders, agents, prompt, timeContext, tools, chatIndex, groups, roomMembership, approvals, resume, memory, parts, images, secrets, connectors, capabilities, schedules, browser, usage, update, dshUpdate, hooks, remoteChat, channel]
const MODULES = [diagnostics, store, memberAuth, authz, models, permissions, turns, delivery, roster, folders, agents, prompt, timeContext, tools, chatIndex, groups, roomMembership, reach, approvals, resume, memory, parts, images, secrets, connectors, capabilities, schedules, browser, usage, update, dshUpdate, hooks, remoteChat, channel]

export function apply(ctx, config = {}) {
const rt = createRuntime(ctx, config)
Expand Down
8 changes: 5 additions & 3 deletions core/src/host/authz.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
// Install order matters: right after member-auth, so rt.authzEnforced sees the same
// state.authSessions the sign-in surface writes, and before any module it gates.

import { currentParticipants, isDesignatedChief, roomVisibleTo } from './room-membership.js'
import { currentParticipants, roomVisibleTo } from './room-membership.js'
import { isPublished } from './reach.js'

// Endpoints a request without an actor may still reach. auth-invite keeps the
// bootstrap rule in member-auth (first invite signs the installing human in).
Expand All @@ -37,7 +38,7 @@ const BOT_TARGETED = new Set([
'memory-view', 'memory-ask', 'memory-forget',
'schedules', 'schedule-create', 'schedule-delete',
'add-agent', 'agent-session', 'agent-adopt', 'agent-archive', 'agent-rename', 'remove-agent',
'stop-member-work', 'set-main',
'stop-member-work', 'set-main', 'set-published',
])

// Endpoints whose body names a room.
Expand Down Expand Up @@ -92,7 +93,8 @@ export function install(rt) {
if (bot.ownerId === actor.memberId) return true
const owners = new Set(humans)
owners.add(actor.memberId)
return isDesignatedChief(state, botId) && owners.has(bot.ownerId)
// Another owner's Bot joins only if its owner published it (the Chief by default, #51).
return isPublished(state, botId) && owners.has(bot.ownerId)
}

// A sessionId may be a Bot's own chat, an earlier part, or a room Session: resolve
Expand Down
8 changes: 7 additions & 1 deletion core/src/host/channel.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ export function install(rt) {
// `sessionId` is the Bot's current own-chat part; `parts` its earlier ones.
// `waiting`: the Bot holds its greeting until the user picks its model.
bots: Object.values(state.bots).map(({ segments, kickoff, ...bot }) => ({
...bot, sessionId: rt.chatOf(bot.id), parts: (segments ?? []).map(segment => segment.sessionId), ...(kickoff ? { waiting: true } : {}),
// `published`: whether other owners and friends can reach it (#51), the effective value.
...bot, published: rt.isPublished?.(bot.id) === true, sessionId: rt.chatOf(bot.id), parts: (segments ?? []).map(segment => segment.sessionId), ...(kickoff ? { waiting: true } : {}),
})),
// `parts`: the members' hidden group Sessions and their earlier segments, for usage.
rooms: Object.values(state.rooms).map(({ log, seen, botRounds, lateRounds, sessions, segments, ...room }) => {
Expand Down Expand Up @@ -217,6 +218,11 @@ export function install(rt) {
replace: typeof body.replace === 'string' ? body.replace : undefined,
})
return ok(view())
case 'set-published':
// The owner's choice (#51): true, false, or null for the default (the Chief only).
await rt.load()
await rt.setPublished(String(body.id ?? body.botId ?? ''), body.published === undefined ? true : body.published)
return ok(view(actor))
case 'create-room': {
await rt.load()
const room = await rt.createRoom({
Expand Down
31 changes: 28 additions & 3 deletions core/src/host/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export function install(rt) {
const emit = (name, payload) => { try { ctx.emit?.(name, payload) } catch (error) { warn('flybot: %s listener failed: %s', name, error) } }

// ---------------------------------------------------------------- events
const listeners = { 'change': new Set(), 'bot.created': new Set(), 'bot.removed': new Set() }
const listeners = { 'change': new Set(), 'bot.created': new Set(), 'bot.removed': new Set(), 'bot.published': new Set() }
const changes = []
let changeSeq = 0
// A new host process starts a new epoch: the browser resets its watermark on a change.
Expand All @@ -111,7 +111,7 @@ export function install(rt) {
// Cordis-style subscription, returns a disposer. 'change' listeners hear their own
// plugin's fields unless they pass { all: true }; 'bot.removed' hears every removal.
const onFor = plugin => (event, fn, { all = false } = {}) => {
if (!listeners[event]) throw new Error(`flybot: unknown event ${event}; use 'change', 'bot.created', or 'bot.removed'`)
if (!listeners[event]) throw new Error(`flybot: unknown event ${event}; use 'change', 'bot.created', 'bot.removed', or 'bot.published'`)
const listener = event === 'change' && !all ? change => { if (change.plugin === plugin) fn(change) } : fn
listeners[event].add(listener)
return () => listeners[event].delete(listener)
Expand All @@ -122,6 +122,7 @@ export function install(rt) {
const team = () => Object.values(state.bots).filter(bot => bot.remote === undefined)
const view = bot => (bot === undefined ? undefined : {
id: bot.id, name: bot.name, role: bot.role, isMain: rt.isMain(bot.id), createdBy: bot.createdBy ?? null,
published: rt.isPublished?.(bot.id) === true,
systemPrompt: bot.systemPrompt ?? '', fields: clone(bot.fields ?? {}),
})
const persist = () => { void rt.save().catch(error => warn('flybot: save failed: %s', error)) }
Expand Down Expand Up @@ -308,7 +309,10 @@ export function install(rt) {
// then it is dispatched; a failed dispatch stays visible for Retry.
const release = async (approval) => {
let verdict
try { verdict = await evaluate(approval.request, { after: approval.by }) } catch (error) { verdict = { action: 'deny', reason: error?.message ?? String(error) } }
// Approved or not, the owner policy is asked again: the hold may be hours old.
const owner = ownerPolicy(approval.request)
if (owner.action === 'deny') verdict = owner
else try { verdict = await evaluate(approval.request, { after: approval.by }) } catch (error) { verdict = { action: 'deny', reason: error?.message ?? String(error) } }
if (verdict.action === 'deny') {
approval.status = 'denied'
approval.error = verdict.reason
Expand Down Expand Up @@ -371,7 +375,13 @@ export function install(rt) {
outcomes.set(exchangeId, outcome)
if (outcomes.size > 500) outcomes.delete(outcomes.keys().next().value)
}
// The owner policy (reach.js) sits before the chain and is not a middleware: no verdict, bypass
// or priority of a plugin can skip it. It judges the message as it is now, so a Bot that was
// unpublished since it was written is refused.
const ownerPolicy = msg => rt.reach?.(msg) ?? { action: 'allow' }
const run = async (msg) => {
const owner = ownerPolicy(msg)
if (owner.action === 'deny') return { action: 'deny', reason: owner.reason, by: owner.by }
const verdict = await evaluate(msg)
Comment on lines +383 to 385

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Recheck owner reach immediately before dispatch

When a sender or target is unpublished while evaluate() is awaiting an asynchronous send middleware, this pre-chain decision becomes stale before line 395; dispatch() marks local deliveries flybotChecked and calls transports directly, so neither path asks ownerPolicy again and the message can still reach a Bot that is no longer published. Re-run the non-bypassable policy after evaluation and immediately before dispatch, including the approval-release path.

Useful? React with 👍 / 👎.

if (verdict.action === 'deny') return { action: 'deny', reason: verdict.reason, by: verdict.by }
if (verdict.action === 'hold') {
Expand Down Expand Up @@ -437,6 +447,18 @@ export function install(rt) {
&& !args.flybotChecked && !args.resent && !args.resumed && !args.retried && !args.deferred
innerDeliver = rt.deliver
rt.deliver = async (args) => {
// Everything that is not a first, checked send still carries its sender: resumed, retried,
// deferred and re-sent deliveries (and kickoffs, notices) are judged by the owner policy
// now, since the Bot they name may have been unpublished, replaced or removed since.
if (!gated(args) && !args.flybotChecked && (args.remote?.handle || args.fromId !== undefined)) {
const owner = ownerPolicy(args)
if (owner.action === 'deny') {
const exchangeId = args.exchangeId ?? randomUUID()
noteOutcome(exchangeId, { action: 'deny', reason: owner.reason, by: owner.by })
warn('flybot: delivery to %s refused: %s', args.toId, owner.reason)
return exchangeId
}
}
if (!gated(args)) return innerDeliver(args)
const exchangeId = args.exchangeId ?? randomUUID()
const msg = { from: args.fromId, to: args.toId, target: targetOf(args.toId), text: args.text, role: args.role, hop: args.hop ?? 0, via: 'ds-bot', exchangeId, deliverArgs: { ...args, exchangeId } }
Expand Down Expand Up @@ -642,6 +664,8 @@ export function install(rt) {
list: () => team().map(view),
find: name => view(rt.findBot?.(name)),
isRemote,
// Whether the Bot is reachable from another owner (the owner's choice; the Chief by default).
isPublished: id => rt.isPublished?.(id) === true,
of: sessionId => view(rt.botOf(rt.selfOf(sessionId))),
create: spec => create({ plugin, ...spec }),
setSystemPrompt,
Expand Down Expand Up @@ -688,5 +712,6 @@ export function install(rt) {
// A local Bot's answer to a friend's Bot (turns.js): back through bot.send, so the
// plugin's middleware still decides (auto-accept, or the approval hold).
const replyRemote = ({ from, handle, home, text, replyTo }) => send({ from, to: { kind: 'remote', handle, ...(home ? { home } : {}) }, text, role: 'reply', via: 'auto-reply', meta: { replyTo } })
rt.flybotPublishedChanged = botId => { const bot = rt.botOf(botId); if (bot !== undefined && bot.remote === undefined) fire('bot.published', view(bot)) }
Object.assign(rt, { flybot, flybotReplyRemote: replyRemote, flybotEndpoint: endpoint, flybotRemoteMembers: remoteMembers.list, flybotSend: send, flybotIsRead: isReadEndpoint, flybotHideTools: hidePluginTools })
}
Loading
Loading