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 @@ -8,6 +8,10 @@ All notable changes to FlyBot (earlier: DS Bot) are listed here. The format foll

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

### Fixed

- A chat with a friend's Bot that Bot Social has paused (turn budget reached, loop suspicion, emergency stop) now says so and points at the Bot Social console, instead of failing with a bare "Not sent: …" card. A paused conversation is no longer retried with the friendship's other Bots. ([#72](https://github.com/ThinkFlowLab/FlyBot/issues/72))

## [0.1.0] - 2026-10-14

First public FlyBot-branded cut (core package remains `0.1.5-flybot.1`).
Expand Down
1 change: 1 addition & 0 deletions docs/subsystems/social.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers

1. Social code stays inside `plugins/social/`.
2. Uses documented plugin APIs only.
3. A server policy pause (`conversation_paused`: turn budget reached, loop suspected, emergency stop) never surfaces as a bare send error: both send paths (the `bot-social` transport and the remote-chat sender) map it to an error that says resending cannot help and names the Bot Social console, and no other member Bot is tried for it.

## Tests

Expand Down
15 changes: 14 additions & 1 deletion plugins/social/src/social.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,9 @@
const TEXTUAL = new Set(['text/plain', 'text/markdown', 'text/csv', 'text/tab-separated-values', 'application/json'])
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

Check warning on line 43 in plugins/social/src/social.js

View workflow job for this annotation

GitHub Actions / plugins (social)

eslint(no-control-regex)

Unexpected control characters
// 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)

Check warning on line 45 in plugins/social/src/social.js

View workflow job for this annotation

GitHub Actions / plugins (social)

eslint(no-control-regex)

Unexpected control characters
// Bot Social list_approvals card → the Friends' Bots queue (outbound drafts only).
export function approvalView(item, { localName } = {}) {
if (!item || typeof item !== 'object') return undefined
Expand Down Expand Up @@ -72,7 +72,7 @@
}
const SEEN_KEPT = 1000
const CREDENTIAL_NAME = /(^id_(rsa|dsa|ecdsa|ed25519)|\.(pem|key|p12|pfx|kdbx|keychain)$|credential|secret|token|password|passwd|^flybot-social.*\.json$|^\.?env$|^\.netrc$|^known_hosts$|^authorized_keys$)/i
const safeName = name => String(name ?? 'file').replace(/[\u0000-\u001f\u007f/\\:]/g, '_').replace(/^\.+/, '_').slice(0, 120) || 'file'

Check warning on line 75 in plugins/social/src/social.js

View workflow job for this annotation

GitHub Actions / plugins (social)

eslint(no-control-regex)

Unexpected control characters
// The text Copy puts on the clipboard for a per-Bot invite: what to do with the link, for a
// person in FlyBot or for their Grok bot.
export function inviteText({ handle, name, home, url, expiresAt }) {
Expand Down Expand Up @@ -352,9 +352,20 @@
}))

// ------------------------------------------------------------ transport
// Bot Social pauses conversations by policy (turn budget reached, loop suspected,
// emergency stop) and rejects sends with `conversation_paused`. The pause belongs to
// the conversation, not to the member that hit it: resending or switching members
// cannot help, so both send paths turn the error into one that says what does.
const isPaused = error => error?.code === 'conversation_paused' || /conversation_paused|turn budget exhausted|loop detected|conversation paused/i.test(String(error?.message ?? error))
const mapSendError = (error) => {
if (!isPaused(error)) return error
const reason = String(error?.message ?? error).replace(/^Bot Social\s+\S+:\s*/, '')
const at = config.server ? ` at ${String(config.server).replace(/\/+$/, '')}/ui` : ''
return new Error(`Bot Social paused this conversation (${reason}). Resending cannot help: approve pending messages or extend the conversation in the Bot Social console${at}, or remove and re-add this friend to start a new one.`)
}
disposers.push(flybot.transports.add('bot-social', {
async send(msg) {
try { return await sendOut(msg) } finally { dropStaged(msg.attachments) }
try { return await sendOut(msg) } catch (error) { throw mapSendError(error) } finally { dropStaged(msg.attachments) }
},
}))
async function sendOut(msg) {
Expand Down Expand Up @@ -415,6 +426,8 @@
}
return ''
} catch (error) {
// A pause belongs to the conversation, not to this member: another Bot cannot send either.
if (isPaused(error)) throw mapSendError(error)
lastError = error
if (!/no_relationship|no active relationship/i.test(String(error?.message ?? error))) throw error
}
Expand Down Expand Up @@ -665,7 +678,7 @@
disposers.push(flybot.api.handle('social/remote-bots', async payload => remoteBots({ fresh: payload?.fresh === true }), { read: true }))
disposers.push(flybot.api.handle('social/friends', async payload => (await remoteBots({ fresh: payload?.fresh === true })).homes.flatMap(entry => entry.bots), { read: true }))
disposers.push(flybot.api.handle('social/auto-approve', async ({ botId, home: friendHome, on }) => {
flybot.fields.set(botId, 'autoApprove', { ...(flybot.fields.get(botId, 'autoApprove') ?? {}), [norm(friendHome)]: on === null ? undefined : Boolean(on) })

Check warning on line 681 in plugins/social/src/social.js

View workflow job for this annotation

GitHub Actions / plugins (social)

unicorn(no-useless-fallback-in-spread)

Empty fallbacks in spreads are unnecessary
return flybot.fields.get(botId, 'autoApprove')
}))
// Auto-accept per friend home: FlyBot's own hold, and Bot Social's auto-approve for the
Expand Down
26 changes: 26 additions & 0 deletions plugins/social/test/social.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -843,3 +843,29 @@ test('Friends\' Bots (#69): no cue when status reports no pending approvals', ()
assert.equal(all.some(n => n.props['data-flybot'] === 'pending-badge' || n.props['data-flybot'] === 'pending-approvals'), false)
assert.equal(plugin.PendingApprovals({ items: [] }), null)
})

test('a paused conversation explains the pause and names the console, without retrying other members', async (t) => {
const { ctx, stop } = await team()
t.after(stop)
const server = mockServer()
// Like the server's owner MCP: a failed tool call carries the platform error's code and message.
const base = server.fetch
server.fetch = async (url, init) => {
const body = JSON.parse(init.body)
if (body.params?.name === 'send_as_member') {
server.calls.push({ url, name: body.params.name, args: body.params.arguments })
return new Response(JSON.stringify({ jsonrpc: '2.0', id: body.id, result: { isError: true, content: [{ type: 'text', text: JSON.stringify({ code: 'conversation_paused', message: 'turn budget exhausted; owners can extend the conversation' }) }] } }), { status: 200 })
}
return base(url, init)
}
const social = createSocial(ctx.flybot, { server: 'https://social.test', 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()
// The user's direct chat: the error says the conversation is paused and what does help,
// and the pause is not retried with the friendship's other member Bots.
await assert.rejects(social.sendChat({ handle: '@coder.bob.home', text: 'hello there' }),
/Bot Social paused this conversation \(turn budget exhausted.*https:\/\/social\.test\/ui/)
assert.equal(server.calls.filter(c => c.name === 'send_as_member').length, 1)
// The same for a Bot's send through the transport (message_friend_bot).
await assert.rejects(ctx.flybot.bot.send({ from: 'a', to: '@coder.bob.home', text: 'hello from chief' }), /paused this conversation/)
})
Loading