diff --git a/CHANGELOG.md b/CHANGELOG.md index 18bfc9099..07e1de3ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`). diff --git a/README.md b/README.md index 212fe5caf..c1f17322a 100644 --- a/README.md +++ b/README.md @@ -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 /v1/onboard/flybot`, creates your home (`@.home`), and stores credentials in `$DSH_HOME/bot/flybot-social.json` (mode 600). - **Members.** Each local Bot becomes `@.` 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). @@ -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 diff --git a/README.zh.md b/README.zh.md index 50558cf9a..e9ab08036 100644 --- a/README.zh.md +++ b/README.zh.md @@ -63,7 +63,7 @@ npx -y @deepseek-ai/dsh@0.2.0-rc.2 plugin --profile web add "github:ThinkFlowLab - **一键入驻。** 配置好 `server` 后首次启动会调用 `POST /v1/onboard/flybot`,创建家(`@.home`),凭证写在 `$DSH_HOME/bot/flybot-social.json`(权限 600)。 - **成员。** 每个本地 Bot 在服务器上是 `@.`;新建自动加入,删除会撤销。 - **「好友的 Bot」侧栏。** 按家分组,带远端标记;点开就是普通对话。本地 Bot 右键 → **复制邀请链接**(或带说明的邀请),或用 **+ 添加** 粘贴好友的链接加入。 -- **审批。** 可按好友家自动接受或暂扣;暂扣的发出消息走审批卡;文件一律暂扣。入站消息轮询进该 Bot 的远端对话,并标为不可信文本。 +- **审批。** 好友发来的消息会立刻送达。你的 Bot 发出的回复可能停在 **「好友的 Bot」**(角标 + 批准 / 拒绝),除非对该好友开了自动接受。FlyBot 本地也会暂扣 Bot 自己发起的发送;文件一律先审批。 - **工具。** 每个本地 Bot 都有 `list_friend_bots` 与 `message_friend_bot`(可带附件)。 在指向 Bot Social 服务器之前,社交插件会保持空闲(见下方快速开始)。 @@ -113,6 +113,10 @@ export PORT=8787 displayName: 你的名字 ``` +首次启动会登记你家和每个本地 Bot。添加好友:右键某个 Bot → **复制邀请链接**,对方在 **「好友的 Bot」→ + 添加** 里粘贴。 + +好友发来的消息会立刻送达。你的 Bot 回过去的内容可能停在 **「好友的 Bot」** 里等你批准(或拒绝),除非对该好友开了自动接受。如果对方只看见自己的话,先看侧栏的 **待批准** 角标。 + 未配置 `server` 时,社交插件会故意保持空闲。树插件不依赖 Bot Social。 ## 架构简述 diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 3d0d2b5e9..42ae7ebe0 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -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 @@ -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 diff --git a/plugins/social/README.md b/plugins/social/README.md index e64883500..6bad3106c 100644 --- a/plugins/social/README.md +++ b/plugins/social/README.md @@ -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. @@ -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. diff --git a/plugins/social/client.js b/plugins/social/client.js index e41c0e5c8..c12bc5218 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -72,6 +72,10 @@ window.__ModuleLoader__.load({ .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(() => { @@ -84,6 +88,23 @@ window.__ModuleLoader__.load({ } 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 } = {}) { @@ -95,9 +116,22 @@ window.__ModuleLoader__.load({ 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) + timer?.unref?.() + return () => clearInterval(timer) + }, []) const homes = data?.homes ?? [] const count = homes.reduce((n, entry) => n + entry.bots.length, 0) const chats = remoteChats ?? [] @@ -105,14 +139,23 @@ window.__ModuleLoader__.load({ 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, @@ -282,6 +325,7 @@ window.__ModuleLoader__.load({ errorText, canInvite, InviteToBot, + PendingApprovals, apply(ctx) { let offs = [] let tries = 0 diff --git a/plugins/social/src/social.js b/plugins/social/src/social.js index 6c760d028..743b5fdaf 100644 --- a/plugins/social/src/social.js +++ b/plugins/social/src/social.js @@ -14,7 +14,8 @@ // the friend home's setting (config.approval.homes[home] / default), overridden per // local Bot by the field social.autoApprove[home]. 'hold' → FlyBot approval card. // - Inbound: each member's server inbox is polled; messages reach that member's chat -// as untrusted text from the remote Bot. +// as untrusted text from the remote Bot (no owner approval). Outbound drafts the +// server holds (pending_approval) show in Friends' Bots until Approve / Reject. // - Files: a Bot (message_friend_bot files) or the owner (composer) attaches files; the // held send's approval card lists names and sizes, and only after approval are the // bytes uploaded (hash re-checked) and sent as signed refs. Incoming files show as @@ -40,6 +41,29 @@ const TEXTUAL = new Set(['text/plain', 'text/markdown', 'text/csv', 'text/tab-se const sizeLabel = n => (n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`) // Remote strings that reach model-visible text: one line, no brackets or markup, capped. export const cleanText = (value, fallback = '') => String(value ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ').replace(/[[\]"<>`]/g, '').replace(/\s+/g, ' ').trim().slice(0, 60) || fallback +// Owner-only draft body: keep punctuation the friend will see; strip controls and cap. +export const draftText = (value) => String(value ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 800) +// Bot Social list_approvals card → the Friends' Bots queue (outbound drafts only). +export function approvalView(item, { localName } = {}) { + if (!item || typeof item !== 'object') return undefined + const id = String(item.approval_id ?? item.id ?? '') + if (!id) return undefined + const to = item.message?.to + const payload = item.message?.payload + const text = draftText(payload && typeof payload === 'object' ? payload.text : payload) || draftText(item.summary) + return { + id, + kind: String(item.kind ?? ''), + status: String(item.status ?? 'pending'), + from: typeof localName === 'function' ? (localName(item.bot_id) || cleanText(item.bot_id)) : cleanText(item.bot_id), + to: to?.handle ? norm(to.handle) : undefined, + toName: cleanText(to?.display_name), + text, + summary: cleanText(item.summary), + createdAt: item.created_at, + attachments: (item.message?.attachments ?? []).filter(file => file?.name).map(file => ({ name: safeName(file.name), size: Number(file.size) || 0 })), + } +} // Fence markers are removed until none is left (a nested fragment cannot rebuild one). export const stripMarkers = (text) => { let out = String(text) @@ -341,6 +365,7 @@ export function createSocial(service, config = {}, { client: given, home: givenH if (!member) throw new Error('the sending Bot has no Bot Social identity') const attachments = await uploadAll(msg.attachments, member.bot_id) const sent = await client.callTool('send_as_member', { from_bot_id: member.bot_id, to: bot.handle, text: msg.text, ...(attachments.length ? { attachments } : {}) }) + if (sent?.message?.status === 'pending_approval') pendingCache.at = 0 return { exchangeId: sent?.message?.message_id ?? msg.exchangeId } } // Legacy per-bot identity. @@ -384,7 +409,10 @@ export function createSocial(service, config = {}, { client: given, home: givenH try { const sent = await client.callTool('send_as_member', { from_bot_id: member.bot_id, to: target, text }) if (settings.via[target] !== bot.id) { settings.via[target] = bot.id; saveSettings() } - if (sent?.message?.status === 'pending_approval') return `Sent, but Bot Social holds it until you approve it there: turn on Auto-accept for this friend in Friends' Bots.` + if (sent?.message?.status === 'pending_approval') { + pendingCache.at = 0 + return `Sent, but Bot Social holds it until you approve it in Friends' Bots. Turn on Auto-accept for this friend to skip the hold on later messages.` + } return '' } catch (error) { lastError = error @@ -573,12 +601,19 @@ export function createSocial(service, config = {}, { client: given, home: givenH } disposers.push(flybot.api.handle('social/create-invite', createBotInvite)) disposers.push(flybot.api.handle('social.createInvite', createBotInvite)) - // What the browser needs to offer invites: connected or not, and which local Bots can be invited to. - disposers.push(flybot.api.handle('social/status', async () => ({ - connected: Boolean(home), - home: home ? norm(home.handle) : undefined, - bots: home ? flybot.bot.list().map(bot => ({ id: bot.id, name: bot.name, isMain: bot.isMain === true, handle: memberOf(bot.id)?.handle ? norm(memberOf(bot.id).handle) : undefined })) : [], - }), { read: true })) + // What the browser needs to offer invites: connected or not, which local Bots can be + // invited to, and any outbound drafts Bot Social is still holding for this owner. + async function socialStatus() { + const approvals = await pendingApprovals() + return { + connected: Boolean(home), + home: home ? norm(home.handle) : undefined, + bots: home ? flybot.bot.list().map(bot => ({ id: bot.id, name: bot.name, isMain: bot.isMain === true, handle: memberOf(bot.id)?.handle ? norm(memberOf(bot.id).handle) : undefined })) : [], + pendingApprovals: approvals.length, + approvals, + } + } + disposers.push(flybot.api.handle('social/status', socialStatus, { read: true })) // Joining a friend's invite (code or link). If the server asks this owner to approve // the request too, the click counts as that approval. // The friendship is between the invited Bot and the Bot that joins (botId, else the Main @@ -661,6 +696,27 @@ export function createSocial(service, config = {}, { client: given, home: givenH } } } + // Bot Social's outbound hold (pending_approval). Distinct from FlyBot's local approval + // cards: the draft already left this process; the friend sees nothing until the owner + // decides here. Cached briefly so status + the Friends' Bots poll share one fetch. + let pendingCache = { at: 0, items: [] } + const localNameOf = botId => flybot.bot.list().find(bot => memberOf(bot.id)?.bot_id === botId)?.name ?? cleanText(botId) + async function pendingApprovals({ fresh = false } = {}) { + if (flybot.readOnly?.()) return [] + if (!fresh && now() - pendingCache.at < 5_000) return pendingCache.items + try { + const result = await client.callTool('list_approvals', { status: 'pending' }) + const raw = result?.approvals ?? (Array.isArray(result) ? result : []) + const items = raw + .map(item => approvalView(item, { localName: localNameOf })) + .filter(item => item && item.kind === 'outbound_message' && (item.status === 'pending' || item.status === 'pending_approval')) + pendingCache = { at: now(), items } + return items + } catch (error) { + warn('list_approvals failed', error) + return pendingCache.items + } + } const homeOfPeer = peerHandle => { const parts = norm(peerHandle).slice(1).split('.'); return parts.length > 2 ? `@${parts.slice(1).join('.')}` : norm(peerHandle) } async function setAutoAccept(friendHome, on) { const key = norm(friendHome) @@ -684,12 +740,26 @@ export function createSocial(service, config = {}, { client: given, home: givenH })) disposers.push(flybot.api.handle('social/settings', async () => ({ autoAccept: Object.fromEntries(remote.homes.map(entry => [entry.home, autoAcceptOf(entry.home)])) }), { read: true })) disposers.push(flybot.api.handle('social/poll', async () => ({ delivered: await pollInbox() }))) + disposers.push(flybot.api.handle('social/approvals', async () => ({ approvals: await pendingApprovals({ fresh: true }) }), { read: true })) + // Owner click is the consent, same as Accept / Join: request and use the confirm code here. + disposers.push(flybot.api.handle('social/decide-approval', async ({ approvalId, decision } = {}) => { + const id = String(approvalId ?? '').trim() + if (!id) throw new Error('which approval?') + const mapped = decision === 'approve' || decision === 'approved' ? 'approved' + : decision === 'reject' || decision === 'rejected' ? 'rejected' + : undefined + if (!mapped) throw new Error('decision is approve or reject') + const result = await confirmedCall('decide_approval', { approval_id: id, decision: mapped }) + pendingCache = { at: 0, items: [] } + return { approvalId: result?.approval_id ?? id, status: result?.decision ?? mapped } + })) return { home, registerAll, remoteBots, pollInbox, + pendingApprovals, approvalFor, sendChat, setAutoAccept, diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index d28e043cc..16109cc38 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -8,7 +8,7 @@ import { test } from 'node:test' import { apply as applyDsBot } from 'ds-bot' import { fakeContext } from './fake-ctx.js' import { inject, name } from '../index.js' -import { createSocial, startSocial } from '../src/social.js' +import { approvalView, createSocial, draftText, startSocial } from '../src/social.js' import { createMcpClient, untrustedText } from '../src/mcp-client.js' // A mocked Bot Social server: the FlyBot onboarding route and the owner MCP tools. @@ -25,7 +25,8 @@ function mockServer({ token = 'owner-token', friends = true } = {}) { ? { relationship_id: 'r9', status: 'pending_requester_owner', awaiting: 'your_owner_approval', approval_id: 'apr9', inviter: { handle: 'eve.home', display_name: 'Eve' } } : { relationship_id: 'r8', status: 'active', awaiting: null, inviter: { handle: 'bob.home', display_name: 'Bob' } }), respond_friend_request: a => (a.confirm_code === undefined ? { status: 'confirmation_required', confirm_code: 'C0DE01' } : a.confirm_code === 'C0DE01' ? { relationship_id: a.relationship_id, status: a.decision === 'accept' ? 'active' : 'declined' } : { error: 'bad code' }), - decide_approval: a => (a.confirm_code === undefined ? { status: 'confirmation_required', confirm_code: 'C0DE02' } : { approval_id: a.approval_id, status: 'approved' }), + decide_approval: a => (a.confirm_code === undefined ? { status: 'confirmation_required', confirm_code: 'C0DE02' } : { approval_id: a.approval_id, decision: a.decision, status: a.decision }), + list_approvals: () => ({ approvals: [] }), upload_attachment: (a) => { const bytes = Buffer.from(a.content_base64, 'base64') const attachment = { id: `att_${uploads.size + 1}`, name: a.name, size: bytes.length, mime: a.mime, sha256: createHash('sha256').update(bytes).digest('hex') } @@ -698,3 +699,147 @@ test('a friendship scoped to one Bot: the roster shows only the granted Bots and const chiefId = [...server.members.values()].find(m => m.handle === '@chief.danyang.home').bot_id assert.deepEqual(server.calls.filter(c => c.name === 'send_as_member').at(-1).args, { from_bot_id: chiefId, to: '@coder.bob.home', text: 'hello from chief' }) }) + +const heldOutbound = { + approval_id: 'apr-hold-1', + kind: 'outbound_message', + status: 'pending', + summary: 'To @hsliu.bot: I can review that', + bot_id: 'm1', + message: { + message_id: 'msg-1', + to: { handle: '@hsliu.bot', display_name: 'hsliu' }, + payload: { text: 'I can review that' }, + status: 'pending_approval', + }, +} + +test('status (#69): pending outbound approvals from Bot Social are listed', async (t) => { + const { ctx, call, stop } = await team() + t.after(stop) + const server = mockServer() + server.tools.list_approvals = () => ({ approvals: [heldOutbound, { approval_id: 'apr-friend', kind: 'relationship_request', status: 'pending' }] }) + 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 social.registerAll() + const status = (await call('flybot/social/status', {})).value + assert.equal(status.pendingApprovals, 1) + assert.equal(status.approvals.length, 1) + assert.equal(status.approvals[0].id, 'apr-hold-1') + assert.equal(status.approvals[0].to, '@hsliu.bot') + assert.equal(status.approvals[0].text, 'I can review that') + assert.equal(status.approvals[0].from, 'Chief') + const listed = (await call('flybot/social/approvals', {})).value + assert.deepEqual(listed.approvals.map(item => item.id), ['apr-hold-1']) +}) + +test('decide-approval (#69): owner click approves or rejects via Bot Social', async (t) => { + const { ctx, call, stop } = await team() + t.after(stop) + const server = mockServer() + let queue = [heldOutbound] + server.tools.list_approvals = () => ({ approvals: queue }) + server.tools.decide_approval = a => { + if (a.confirm_code === undefined) return { status: 'confirmation_required', confirm_code: 'C0DE02' } + queue = queue.filter(item => item.approval_id !== a.approval_id) + return { approval_id: a.approval_id, decision: a.decision } + } + 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 social.registerAll() + assert.equal((await call('flybot/social/status', {})).value.pendingApprovals, 1) + const decided = (await call('flybot/social/decide-approval', { approvalId: 'apr-hold-1', decision: 'approve' })).value + assert.deepEqual(decided, { approvalId: 'apr-hold-1', status: 'approved' }) + assert.deepEqual(server.calls.filter(c => c.name === 'decide_approval').map(c => c.args.confirm_code), [undefined, 'C0DE02']) + assert.equal((await call('flybot/social/status', {})).value.pendingApprovals, 0) + assert.equal((await call('flybot/social/decide-approval', { approvalId: 'apr-hold-1', decision: 'maybe' })).ok, false) +}) + +test('approvalView: outbound draft text is sanitized, friend requests stay out', () => { + const view = approvalView({ + approval_id: 'apr1', + kind: 'outbound_message', + status: 'pending', + summary: 'To @coder.bob.home: hi\n[x]', + bot_id: 'm1', + message: { to: { handle: 'Coder.Bob.Home', display_name: 'Coder\n