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: 2 additions & 2 deletions ds-bot/plugins/social/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,12 @@ Tests (`test/social.test.js`) use a mocked server.

## Add friends (sidebar)

Invites are **per Bot**; there is no whole-home invite. A friend who joins an invite made for one of your Bots can talk to that Bot only; your other Bots stay hidden unless you invite them too (server: member-scoped friendships, Bot Social `member-scoped-invites`).
Invites are **per Bot**; there is no whole-home invite. A friend who joins an invite made for one of your Bots can talk to that Bot only; your other Bots stay hidden unless you invite them too (server: friendships scoped to the invite's target, Bot Social `requester_scope`/`target_scope`; the roster lists only what `list_remote_bots` returns).

The **Friends' Bots** section (collapsible header with a Bot count, ds-bot `--bt-*` tokens) lists friends' Bots by home, with an empty state. **+ Add** opens:

- **Invite a friend to one of your Bots:** pick the Bot (default: the Main Bot, Chief) and **Create invite link**. It works once and lasts 7 days. The field shows the URL; **Copy link** copies only the bare URL; **Copy invite with instructions** copies a short message naming the Bot ("Add my bot @chief.<home> (Chief) from my FlyBot home <home>. In FlyBot: open Friends' Bots, click + Add, paste this link and click Join. Using a Grok bot? Send it this link and say 'join this FlyBot invite'. Link: <url> (single use, expires <date>)"). The Bot's right-click menu has the same two items. Each clipboard write happens inside the click.
- **Paste a friend's invite link or code** → **Join:** sends a friend request from your home. If the server also needs your own approval, the click counts as that approval.
- **Paste a friend's invite link or code** → **Join:** sends a friend request from one of your Bots (**Join as**, default: the Main Bot). The friendship is between the invited Bot and that Bot only: your friend sees and reaches just it, and only it can message their Bot (the roster's `via`; other local Bots are refused before anything leaves). If the server also needs your own approval, the click counts as that approval.
- **Friend requests:** incoming requests with **Accept** / **Decline**; requests you sent show under *Waiting for them to accept*.

Each Bot's settings also has **Invite to this Bot** (slot `flybot.bot.details`).
Expand Down
23 changes: 19 additions & 4 deletions ds-bot/plugins/social/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -144,8 +144,18 @@ window.__ModuleLoader__.load({
const [code, setCode] = useState('')
const [status, setStatus] = useState(null)
const [busy, setBusy] = useState(false)
// Which of my Bots joins: the friend sees and reaches only that one (default: the Main Bot).
const [mine, setMine] = useState([])
const [joiner, setJoiner] = useState('')
const refresh = () => call('social/overview', {}).then(setOverview, e => setStatus({ tone: 'error', text: errorText(e) }))
useEffect(() => { refresh() }, [])
useEffect(() => {
refresh()
call('social/status', {}).then(value => {
const bots = value?.bots ?? []
setMine(bots)
setJoiner(current => current || (bots.find(bot => bot.isMain) ?? bots[0])?.id || '')
}, () => {})
}, [])
const run = async (label, fn) => {
setBusy(true)
setStatus(null)
Expand All @@ -163,9 +173,10 @@ window.__ModuleLoader__.load({
const join = e => {
e.preventDefault()
return run('Joining', async () => {
const r = await call('social/join-invite', { code })
const r = await call('social/join-invite', { code, ...(joiner ? { botId: joiner } : {}) })
setCode('')
return r.status === 'active' ? `You are now friends with ${r.inviterName ?? r.inviter}.` : `Request sent to ${r.inviterName ?? r.inviter}. They show up here once they accept.`
const as = r.joinedAs ? ` as ${r.joinedAs.botName}` : ''
return r.status === 'active' ? `You are now friends with ${r.inviterName ?? r.inviter}${as}.` : `Request sent to ${r.inviterName ?? r.inviter}${as}. They show up here once they accept.`
})
}
const respond = (item, decision) => run(decision === 'accept' ? 'Accepting' : 'Declining', async () => {
Expand All @@ -180,7 +191,11 @@ window.__ModuleLoader__.load({
h('div', { className: 'fs-hint' }, 'Got a link from a friend?'),
h('div', { className: 'fs-line' },
h('input', { className: 'fs-input', value: code, placeholder: 'Paste invite link or code', 'aria-label': 'Invite link or code', onChange: e => setCode(e.target.value) }),
h('button', { type: 'submit', className: 'fs-btn', disabled: busy || !code.trim() }, 'Join'))),
h('button', { type: 'submit', className: 'fs-btn', disabled: busy || !code.trim() }, 'Join')),
mine.length > 1 ? h('label', { className: 'fs-line fs-hint', 'data-flybot': 'join-as' }, 'Join as ',
h('select', { className: 'fs-input', value: joiner, 'aria-label': 'Which of your Bots joins', onChange: e => setJoiner(e.target.value) },
mine.map(bot => h('option', { key: bot.id, value: bot.id }, bot.name))),
' (your friend sees only this Bot)') : null),
status ? h('div', { className: 'fs-status', role: 'status', 'data-tone': status.tone, style: { padding: '2px 10px' } }, status.text) : null,
incoming.length ? h('div', { className: 'bt-social-requests' },
h('div', { className: 'fs-label' }, 'Friend requests', h('span', { className: 'fs-count' }, String(incoming.length))),
Expand Down
37 changes: 31 additions & 6 deletions ds-bot/plugins/social/src/social.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@
// the credentials file (never in state.json).
// - Every local Bot is a member identity @<bot>.<home>, keys custodied by the server.
// Bots are registered on start and on 'bot.created', revoked on 'bot.removed'.
// - Friendship is home-to-home: once a friend home is accepted, every local Bot can
// message every Bot of that home, and those remote Bots show in the roster (grouped by
// - Friendship is Bot-to-Bot: an invite names one local Bot and the friend joins as one of
// theirs; each side sees and reaches only the Bots the friendship covers (the server's
// list_remote_bots, with `via` naming the local Bots allowed to talk), shown in the roster (grouped by
// home, never in bot.list()).
// - Middleware 'social': remote targets must belong to a friend home; then approval:
// the friend home's setting (config.approval.homes[home] / default), overridden per
Expand Down Expand Up @@ -268,6 +269,9 @@ export function createSocial(service, config = {}, { client: given, home: givenH
name: cleanText(entry.home.display_name),
owner: cleanText(entry.home.owner_display_name),
bots: entry.bots.map(bot => ({ handle: norm(bot.handle), name: cleanText(bot.display_name, norm(bot.handle)), home: norm(entry.home.handle), remote: true })),
// The friendship covers only these local members (an invite made for one Bot, or joined
// as one Bot); empty when it covers this whole home. Only they can talk to this friend.
via: (entry.via ?? []).map(norm),
})),
}
} else {
Expand All @@ -287,6 +291,9 @@ export function createSocial(service, config = {}, { client: given, home: givenH
return remote
}
const remoteBot = async handle => (await remoteBots()).homes.flatMap(entry => entry.bots).find(bot => bot.handle === norm(handle))
const friendEntry = async friendHome => (await remoteBots()).homes.find(entry => entry.home === norm(friendHome))
// Whether this local Bot is one the friendship with that friend home covers.
const covered = (entry, botId) => !entry?.via?.length || entry.via.includes(norm(memberOf(botId)?.handle ?? ''))

// ------------------------------------------------------------ approval policy
const approvalFor = (fromBotId, friendHome) => {
Expand All @@ -307,6 +314,9 @@ export function createSocial(service, config = {}, { client: given, home: givenH
if (msg.target?.remote !== true || (msg.to?.transport ?? 'bot-social') !== 'bot-social') return undefined
const bot = await remoteBot(msg.target.handle)
if (bot === undefined) return { action: 'deny', reason: `${msg.target.handle} is not a Bot of a friend on Bot Social` }
if (home && typeof msg.from === 'string' && !covered(await friendEntry(bot.home), msg.from)) {

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 Keep coverage tied to the target relationship

When two Bot-scoped friendships point at the same friend home (for example, Chief↔Coder and Writer↔Researcher), list_remote_bots can contain relationship entries with different bots/via values. remoteBot() finds the requested Bot, but this home-only lookup then selects the first entry for that home, so Writer is denied access to Researcher while Chief may get a cross-relationship send as far as the server. Preserve the entry containing the target Bot and run covered against that entry.

Useful? React with 👍 / 👎.

return { action: 'deny', reason: `${flybot.bot.get(msg.from)?.name ?? 'this Bot'} is not friends with ${bot.home}: that friendship is with ${(await friendEntry(bot.home)).via.join(', ')} only` }
}
// A Bot's files are always shown to the user first, whatever auto-accept says;
// files the user picked in the composer are the user's own send.
if (msg.attachments?.length && msg.via !== 'composer') return { action: 'hold', reason: 'a Bot attaches files: check them before they leave' }
Expand Down Expand Up @@ -359,7 +369,9 @@ export function createSocial(service, config = {}, { client: given, home: givenH
if (!home) throw new Error('Bot Social is not connected')
const target = norm(handle)
const team = flybot.bot.list()
const order = [team.find(bot => bot.id === settings.via[target]), team.find(bot => bot.isMain), ...team].filter(Boolean)
// Only the Bots the friendship covers can sign; the one used before goes first.
const entry = (await remoteBots()).homes.find(item => item.bots.some(bot => bot.handle === target))
const order = [team.find(bot => bot.id === settings.via[target]), team.find(bot => bot.isMain), ...team].filter(bot => bot && covered(entry, bot.id))
const tried = new Set()
let lastError
for (const bot of order) {
Expand Down Expand Up @@ -567,10 +579,23 @@ export function createSocial(service, config = {}, { client: given, home: givenH
}), { 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.
disposers.push(flybot.api.handle('social/join-invite', async ({ code } = {}) => {
// The friendship is between the invited Bot and the Bot that joins (botId, else the Main
// Bot): the friend sees and reaches only that one of this home's Bots.
disposers.push(flybot.api.handle('social/join-invite', async ({ code, botId } = {}) => {
const value = String(code ?? '').trim()
if (!value) throw new Error('paste an invite link or code')
const joined = await client.callTool('join_invite', { code: value.replace(/^.*\/i\//, '').replace(/[?#].*$/, ''), ...(home ? { from_bot_id: home.id } : config.botId ? { from_bot_id: config.botId } : {}) })
let from = config.botId ? { from_bot_id: config.botId } : {}
let joinedAs
if (home) {
const team = flybot.bot.list()
const bot = typeof botId === 'string' && botId ? flybot.bot.get(botId) : team.find(item => item.isMain) ?? team[0]
if (!bot) throw new Error('Choose which of your Bots joins')
const member = await register(bot)
if (!member) throw new Error(`${bot.name} has no Bot Social identity yet`)
from = { from_bot_id: member.bot_id }

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 Include member-scoped joins in the overview

When this selected member's join remains pending_target (a state handled just below), the relationship now belongs to that member, but friendsOverview() still queries list_friends only with home.id; setAutoAccept() already enumerates each member identity because member relationships are not returned by that home query. Consequently the Add-friend panel's refresh or reopen omits this request from pending_outgoing, so users cannot track it while waiting for acceptance; aggregate the member identity overviews too.

Useful? React with 👍 / 👎.

joinedAs = { botId: bot.id, botName: bot.name, handle: norm(member.handle) }
}
const joined = await client.callTool('join_invite', { code: value.replace(/^.*\/i\//, '').replace(/[?#].*$/, ''), ...from })
let status = joined.status
if (joined.awaiting === 'your_owner_approval' && joined.approval_id) {
await confirmedCall('decide_approval', { approval_id: joined.approval_id, decision: 'approved' })
Expand All @@ -581,7 +606,7 @@ export function createSocial(service, config = {}, { client: given, home: givenH
// A new friendship starts with auto-accept on (switchable per friend in Friends' Bots).
const inviterHome = joined.inviter?.handle ? homeOfPeer(joined.inviter.handle) : undefined
if (joined.relationship_id && (inviterHome === undefined || autoAcceptOf(inviterHome))) await serverAutoApprove(joined.relationship_id, true)
return { relationshipId: joined.relationship_id, status, inviter: joined.inviter?.handle ? norm(joined.inviter.handle) : undefined, inviterName: joined.inviter?.display_name }
return { relationshipId: joined.relationship_id, status, inviter: joined.inviter?.handle ? norm(joined.inviter.handle) : undefined, inviterName: joined.inviter?.display_name, ...(joinedAs ? { joinedAs } : {}) }
}))
disposers.push(flybot.api.handle('social/respond', async ({ relationshipId, decision } = {}) => {
if (decision !== 'accept' && decision !== 'decline') throw new Error('decision is accept or decline')
Expand Down
39 changes: 36 additions & 3 deletions ds-bot/plugins/social/test/social.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -336,10 +336,17 @@ test('add friend from the UI: invite link, join by link, and answer requests (ow
const status = (await call('flybot/social/status', {})).value
assert.equal(status.connected, true)
assert.deepEqual(status.bots.map(b => [b.id, b.isMain]), [['a', true], ['b', false]])
// Joining by full link: the code is taken from it and the request comes from the home.
// Joining by full link: the code is taken from it and the request comes from one Bot (the
// Main Bot by default), never the whole home: the friend sees only that Bot.
const chiefId = [...server.members.values()].find(m => m.handle === '@chief.danyang.home').bot_id
const writerId = [...server.members.values()].find(m => m.handle === '@writer.danyang.home').bot_id
const joined = (await call('flybot/social/join-invite', { code: ' https://other.example/i/ABC?x=1 ' })).value
assert.deepEqual(joined, { relationshipId: 'r8', status: 'active', inviter: '@bob.home', inviterName: 'Bob' })
assert.deepEqual(server.calls.find(c => c.name === 'join_invite').args, { code: 'ABC', from_bot_id: 'h1' })
assert.deepEqual(joined, { relationshipId: 'r8', status: 'active', inviter: '@bob.home', inviterName: 'Bob', joinedAs: { botId: 'a', botName: 'Chief', handle: '@chief.danyang.home' } })
assert.deepEqual(server.calls.find(c => c.name === 'join_invite').args, { code: 'ABC', from_bot_id: chiefId })
const asWriter = (await call('flybot/social/join-invite', { code: 'DEF', botId: 'b' })).value
assert.equal(asWriter.joinedAs.handle, '@writer.danyang.home')
assert.deepEqual(server.calls.filter(c => c.name === 'join_invite').at(-1).args, { code: 'DEF', from_bot_id: writerId })
assert.notEqual(server.calls.filter(c => c.name === 'join_invite').at(-1).args.from_bot_id, 'h1')
// A join the server wants this owner to approve is approved by the same click.
const pending = (await call('flybot/social/join-invite', { code: 'NEEDSOK' })).value
assert.equal(pending.status, 'pending_target')
Expand Down Expand Up @@ -665,3 +672,29 @@ test('a friendship made after Auto-accept was turned on gets server auto-approve
const confirmed = server.calls.filter(c => c.name === 'set_auto_approve' && c.args.confirm_code === 'C0DE03')
assert.deepEqual(confirmed.map(c => [c.args.relationship_id, c.args.auto_approve_chat]), [['r1', true], ['r7', true]])
})

test('a friendship scoped to one Bot: the roster shows only the granted Bots and only the covered local Bot may send', async (t) => {
const { ctx, tool, stop } = await team()
t.after(stop)
const server = mockServer()
// Bob invited us to Coder only and we joined as Chief: Bob's Writer is not listed by the server.
server.tools.list_remote_bots = () => ({ homes: [{ relationship_id: 'r1', home: { bot_id: 'h2', handle: '@bob.home', display_name: 'Bob', owner_display_name: 'Bob' }, bots: [{ bot_id: 'x1', handle: '@coder.bob.home', display_name: 'Coder' }], scope: 'member', via: ['@chief.danyang.home'] }] })
const social = createSocial(ctx.flybot, { 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 listed = await tool('list_friend_bots', {}, 'a')
assert.match(listed, /@coder\.bob\.home/)
assert.doesNotMatch(listed, /@writer\.bob\.home/)
// Writer is not part of that friendship: refused locally, nothing reaches the server.
const before = server.calls.filter(c => c.name === 'send_as_member').length
const refused = await ctx.flybot.bot.send({ from: 'b', to: '@coder.bob.home', text: 'hello from writer' })
assert.equal(refused.action, 'deny')
assert.match(refused.reason, /Writer is not friends with @bob\.home.*@chief\.danyang\.home/)
assert.equal(server.calls.filter(c => c.name === 'send_as_member').length, before)
// A Bot the server does not list is not reachable at all.
assert.equal((await ctx.flybot.bot.send({ from: 'a', to: '@writer.bob.home', text: 'hi' })).action, 'deny')
// Chief, the covered Bot, goes through.
assert.equal((await ctx.flybot.bot.send({ from: 'a', to: '@coder.bob.home', text: 'hello from chief' })).action, 'allow')
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' })
})
Loading