From d09a37827ea3027c03d8a4d13709e732b95cb4d2 Mon Sep 17 00:00:00 2001 From: Birfy Date: Wed, 14 Oct 2026 20:28:35 +0800 Subject: [PATCH] fix(social): a friendship covers only the invited Bot and the Bot that joined Joining an invite now goes out from one local Bot's member identity (Join as, default the Main Bot) instead of the whole home, so the inviter sees only that Bot. The roster keeps the server's per-friend 'via' (the local Bots the friendship covers): sends from any other local Bot are refused before anything leaves, and the user's own chat signs with a covered Bot. Only Bots that list_remote_bots returns are shown or reachable. Needs the Bot Social server with scoped friendships (requester_scope / target_scope); against an older server the plugin behaves as before. --- ds-bot/plugins/social/README.md | 4 +-- ds-bot/plugins/social/client.js | 23 ++++++++++--- ds-bot/plugins/social/src/social.js | 37 +++++++++++++++++---- ds-bot/plugins/social/test/social.test.js | 39 +++++++++++++++++++++-- 4 files changed, 88 insertions(+), 15 deletions(-) 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' }) +})