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

## [Unreleased]

### Added

- **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)

## [0.1.0] - 2026-10-14

First public FlyBot-branded cut (core package remains `0.1.5-flybot.1`).
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ Every local Bot can chat with friends' Bots through a [Bot Social](https://githu
- **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.
- **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.** Per friend home you can auto-accept or hold. Held outbound sends show an approval card; files are always held. Inbound messages are polled into that Bot's remote chat as untrusted text.
- **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).

The social plugin stays idle until you point it at a Bot Social server (see [Quick Start](#quick-start) below).
Expand Down Expand Up @@ -120,6 +120,8 @@ Point FlyBot at that server in `~/.dsh/profiles/web/cordis.patch.yml`, then rest

On first start FlyBot registers your home and every local Bot. 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.

Without a `server`, the social plugin stays idle by design. Tree works without Bot Social.

## Built on
Expand Down
6 changes: 5 additions & 1 deletion README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ npx -y @deepseek-ai/dsh@0.2.0-rc.2 plugin --profile web add "github:ThinkFlowLab
- **一键入驻。** 配置好 `server` 后首次启动会调用 `POST <server>/v1/onboard/flybot`,创建家(`@<name>.home`),凭证写在 `$DSH_HOME/bot/flybot-social.json`(权限 600)。
- **成员。** 每个本地 Bot 在服务器上是 `@<bot>.<home>`;新建自动加入,删除会撤销。
- **「好友的 Bot」侧栏。** 按家分组,带远端标记;点开就是普通对话。本地 Bot 右键 → **复制邀请链接**(或带说明的邀请),或用 **+ 添加** 粘贴好友的链接加入。
- **审批。** 可按好友家自动接受或暂扣;暂扣的发出消息走审批卡;文件一律暂扣。入站消息轮询进该 Bot 的远端对话,并标为不可信文本。
- **审批。** 好友发来的消息会立刻送达。你的 Bot 发出的回复可能停在 **「好友的 Bot」**(角标 + 批准 / 拒绝),除非对该好友开了自动接受。FlyBot 本地也会暂扣 Bot 自己发起的发送;文件一律先审批。
- **工具。** 每个本地 Bot 都有 `list_friend_bots` 与 `message_friend_bot`(可带附件)。

在指向 Bot Social 服务器之前,社交插件会保持空闲(见下方快速开始)。
Expand Down Expand Up @@ -113,6 +113,10 @@ export PORT=8787
displayName: 你的名字
```

首次启动会登记你家和每个本地 Bot。添加好友:右键某个 Bot → **复制邀请链接**,对方在 **「好友的 Bot」→ + 添加** 里粘贴。

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

未配置 `server` 时,社交插件会故意保持空闲。树插件不依赖 Bot Social。

## 架构简述
Expand Down
4 changes: 3 additions & 1 deletion docs/subsystems/social.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ owned_paths:

The social plugin talks to a [Bot Social](https://github.com/Birfy/bot-social) server that you self-host. See the Bot Social README for install and configuration.

Bot Social plugin for chatting with friends' Bots (send middleware `social` with per-home approval) lives entirely under `plugins/social/`.
Bot Social plugin for chatting with friends' Bots (send middleware `social` with per-home approval) lives entirely under `plugins/social/`. Inbound friend messages arrive immediately; outbound drafts the server holds (`pending_approval`) are listed on `social/status` and shown in Friends' Bots until the owner Approves or Rejects them.

## Owns

Expand All @@ -25,6 +25,8 @@ Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers
| File | Role |
| --- | --- |
| `plugins/social/index.js` | Plugin entry. |
| `plugins/social/src/social.js` | Host: roster, inbox, `social/status` pending-approval queue, Approve / Reject. |
| `plugins/social/client.js` | Friends' Bots: badge, pending section, Approve / Reject. |

## Entry points

Expand Down
7 changes: 5 additions & 2 deletions plugins/social/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ browser: `client.js`. Design: see "Social design: homes and members" in the root
overridden per local Bot by field `social.autoApprove[home]` (UI endpoint
`flybot/social/auto-approve`). Held messages use the FlyBot approval card; once approved
the `bot-social` transport sends it signed as the member (`send_as_member`).
- **Inbound**: each member's inbox is polled; messages reach that Bot's chat as untrusted text.
- **Inbound**: each member's inbox is polled; messages reach that Bot's chat as untrusted text (no owner approval).
- **Outbound hold (Bot Social)**: a draft the server keeps as `pending_approval` is listed on `social/status` (`pendingApprovals`, `approvals`) and in **Friends' Bots** (badge + Approve / Reject). The owner's click calls `decide_approval` (confirm code used in the same step). This is separate from FlyBot's local approval card. Auto-accept on a friendship skips the hold for later messages.
- **Tools** (every local Bot): `list_friend_bots`, `message_friend_bot`.
- **Legacy**: with `token` + `botId` and no home, the per-bot identity path
(`start_conversation` / `send_message`) still works.
Expand Down Expand Up @@ -51,6 +52,8 @@ Each Bot's settings also has **Invite to this Bot** (slot `flybot.bot.details`).

- Host endpoint `social/create-invite` (alias `social.createInvite`), payload `{ botId }` (required, a local Bot id): returns `{ botId, botName, handle, home, link, url, code, expiresAt, status, text }`; `text` is the copy message above. Browser: `flybot.call('social.createInvite', { botId })` or `ctx.flybot.social.createInvite(botId)`.
- `ctx.flybot.social.canInvite(botId)` (also `globalThis.__flybot__.social`): synchronous; true when Bot Social is connected and the Bot is one of this team's. `ctx.flybot.social` is absent when the social plugin is not installed.
- Host endpoint `social/status`: `{ connected, home, bots: [{ id, name, isMain, handle }] }`.
- Host endpoint `social/status`: `{ connected, home, bots: [{ id, name, isMain, handle }], pendingApprovals, approvals }`. `approvals` is the outbound drafts Bot Social is holding; `pendingApprovals` is their count.
- `social/approvals` (read): `{ approvals }` — same queue, always freshly fetched.
- `social/decide-approval` (owner click): `{ approvalId, decision: 'approve' | 'reject' }` → `{ approvalId, status }`.

These are owner UI actions only and are never model tools. The server's one-time confirm code is requested and used in the same click.
48 changes: 46 additions & 2 deletions plugins/social/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@
.fs-link:hover{text-decoration:underline}
.fs-status{padding:0;font-size:12px;line-height:17px;color:var(--bt-ink-2,rgba(20,20,20,.6));overflow-wrap:anywhere}
.fs-status[data-tone=error]{color:#e5484d}
.fs-pending-tag{background:var(--bt-accent,#4d6bfe);color:#fff}
.fs-pending{margin:4px 4px 6px;padding:8px 10px;border:1px solid var(--bt-line-2,rgba(20,20,20,.15));border-radius:10px;background:var(--bt-card,#fff);display:flex;flex-direction:column;gap:6px}
.fs-pending .fs-label{padding:0}
.fs-draft{font-size:13px;line-height:18px;color:var(--bt-ink,#141414);white-space:pre-wrap;overflow-wrap:anywhere;max-height:96px;overflow:auto}
`
function useStyles() {
useEffect(() => {
Expand All @@ -84,6 +88,23 @@
}
const initial = name => (String(name ?? '?').replace(/^@/, '').trim()[0] ?? '?').toUpperCase()

// Outbound drafts Bot Social is holding: badge + Approve / Reject. Inbound does not wait.
function PendingApprovals({ items, busy, onDecide }) {
if (!items?.length) return null
return h('div', { className: 'fs-pending', 'data-flybot': 'pending-approvals', role: 'region', 'aria-label': 'Messages waiting for your approval' },
h('div', { className: 'fs-label' }, 'Waiting for your approval', h('span', { className: 'fs-count' }, String(items.length))),
h('div', { className: 'fs-hint' }, 'A friend already sees their own message. These replies stay here until you Approve or Reject them. Turn on Auto-accept for that friend to skip the hold next time.'),
items.map(item => h('div', { key: item.id, className: 'fs-row', 'data-flybot': 'pending-item' },
h('span', { className: 'fs-avatar', 'aria-hidden': true }, initial(item.from ?? item.toName ?? item.to)),
h('span', { className: 'fs-text' },
h('span', { className: 'fs-name' }, item.from ? `${item.from} → ${item.toName || item.to || 'friend'}` : (item.toName || item.to || 'Outbound reply')),
item.text ? h('span', { className: 'fs-draft' }, item.text) : (item.summary ? h('span', { className: 'fs-sub fs-msg' }, item.summary) : null),
item.attachments?.length ? h('span', { className: 'fs-sub' }, item.attachments.map(file => file.name).join(', ')) : null),
h('span', { className: 'fs-actions' },
h('button', { type: 'button', className: 'fs-btn fs-btn-sm', disabled: busy === item.id, onClick: () => onDecide(item, 'approve') }, 'Approve'),
h('button', { type: 'button', className: 'fs-btn fs-btn-sm', disabled: busy === item.id, onClick: () => onDecide(item, 'reject') }, 'Reject')))))
}

// Friends' Bots grouped by their home (one collapsible group each). Each Bot is drawn by
// ds-bot's own Bot row (renderBot: avatar, preview, unread, click opens its chat).
function FriendsBots({ renderBot, remoteChats } = {}) {
Expand All @@ -95,24 +116,46 @@
const [closed, setClosed] = useState({})
const [auto, setAuto] = useState({})
const [saving, setSaving] = useState('')
const [pending, setPending] = useState([])
const load = () => call('social/remote-bots', { fresh: true }).then(value => { setData(value); setError('') }, e => setError(errorText(e)))
const loadSettings = () => call('social/settings', {}).then(value => setAuto(value?.autoAccept ?? {}), () => {})
useEffect(() => { load().then(loadSettings) }, [])
const loadApprovals = () => call('social/status', {}).then(value => {
const items = Array.isArray(value?.approvals) ? value.approvals : []
setPending(items)
if (items.length) setOpen(true)
}, () => {})
useEffect(() => {
load().then(loadSettings)
loadApprovals()
if (typeof setInterval !== 'function') return undefined
const timer = setInterval(loadApprovals, 5000)

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 Coalesce approval polls while a request is pending

When Bot Social stops responding, social/status can remain pending for the MCP client's 30-second timeout, but this five-second interval starts another loadApprovals call without tracking the previous promise. Each mounted panel can therefore accumulate roughly six concurrent list_approvals requests and keep launching more as they time out, adding avoidable host load and allowing older responses to overwrite newer state; reuse an in-flight poll or schedule the next poll only after the current one settles.

Useful? React with 👍 / 👎.

timer?.unref?.()
return () => clearInterval(timer)
}, [])
const homes = data?.homes ?? []
const count = homes.reduce((n, entry) => n + entry.bots.length, 0)
const chats = remoteChats ?? []
const toggleAuto = async (home, on) => {
setSaving(home)
try { const r = await call('social/auto-accept', { home, on }); setAuto(value => ({ ...value, [home]: r.autoAccept })) } catch (e) { setError(errorText(e)) } finally { setSaving('') }
}
const decide = async (item, decision) => {
setSaving(item.id)
try {
await call('social/decide-approval', { approvalId: item.id, decision })
await loadApprovals()
} catch (e) { setError(errorText(e)) } finally { setSaving('') }
}
return h('section', { className: 'fs bt-social', 'aria-label': 'Friends\' Bots' },
h('div', { className: 'fs-head' },
h('button', { type: 'button', className: 'fs-head-main', 'aria-expanded': open, onClick: () => setOpen(!open) },
h('span', { className: 'fs-chev', 'aria-hidden': true }, '▾'),
h('span', { className: 'fs-title' }, 'Friends\' Bots'),
data ? h('span', { className: 'fs-count', title: `${count} ${count === 1 ? 'Bot' : 'Bots'}` }, String(count)) : null),
data ? h('span', { className: 'fs-count', title: `${count} ${count === 1 ? 'Bot' : 'Bots'}` }, String(count)) : null,
pending.length ? h('span', { className: 'fs-tag fs-pending-tag', 'data-flybot': 'pending-badge', title: `${pending.length} outbound ${pending.length === 1 ? 'reply' : 'replies'} waiting for your approval` }, `${pending.length} to approve`) : null),
h('button', { type: 'button', className: 'fs-icon', 'aria-expanded': adding, title: 'Add a friend', onClick: () => { setAdding(!adding); setOpen(true) } }, adding ? 'Done' : '+ Add')),
open ? h('div', { className: 'fs-body' },
h(PendingApprovals, { items: pending, busy: saving, onDecide: decide }),
adding ? h(AddFriend, { onChange: () => load().then(loadSettings) }) : null,
error ? h('div', { className: 'fs-status', 'data-tone': 'error', style: { padding: '4px 10px' } }, error) : null,
data === null && !error ? h('div', { className: 'fs-hint' }, 'Loading…') : null,
Expand Down Expand Up @@ -226,7 +269,7 @@
statusTask ??= call('social/status', {}).then(value => {
const changed = JSON.stringify(value) !== JSON.stringify(status.value)
status = { at: Date.now(), value }
if (changed) for (const fn of [...statusListeners]) { try { fn() } catch (error) { console.error('flybot-social: status listener failed', error) } }

Check warning on line 272 in plugins/social/client.js

View workflow job for this annotation

GitHub Actions / plugins (social)

unicorn(no-useless-spread)

Using a spread operator here creates a new array unnecessarily.
return value
}).finally(() => { statusTask = undefined })
return statusTask
Expand Down Expand Up @@ -282,6 +325,7 @@
errorText,
canInvite,
InviteToBot,
PendingApprovals,
apply(ctx) {
let offs = []
let tries = 0
Expand Down
Loading
Loading