diff --git a/ds-bot/plugins/social/README.md b/ds-bot/plugins/social/README.md index 09275b319..8f8a44b2a 100644 --- a/ds-bot/plugins/social/README.md +++ b/ds-bot/plugins/social/README.md @@ -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. (Chief) from my FlyBot 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: (single use, expires )"). 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`). diff --git a/ds-bot/plugins/social/client.js b/ds-bot/plugins/social/client.js index faac80883..e41c0e5c8 100644 --- a/ds-bot/plugins/social/client.js +++ b/ds-bot/plugins/social/client.js @@ -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) @@ -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 () => { @@ -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))), diff --git a/ds-bot/plugins/social/src/social.js b/ds-bot/plugins/social/src/social.js index dcc87ab80..1dd737a7a 100644 --- a/ds-bot/plugins/social/src/social.js +++ b/ds-bot/plugins/social/src/social.js @@ -6,8 +6,9 @@ // the credentials file (never in state.json). // - Every local Bot is a member identity @., 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 @@ -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 { @@ -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) => { @@ -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)) { + 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' } @@ -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) { @@ -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 } + 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' }) @@ -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') diff --git a/ds-bot/plugins/social/test/social.test.js b/ds-bot/plugins/social/test/social.test.js index 27723bb34..8ea673d40 100644 --- a/ds-bot/plugins/social/test/social.test.js +++ b/ds-bot/plugins/social/test/social.test.js @@ -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') @@ -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' }) +})