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
1 change: 1 addition & 0 deletions changelog.d/500-share-with-friend.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Share a Bot with an existing friend in one click from Sharing (no invite link); the new share appears in People with access and can be Revoked or declined as usual ([#500](https://github.com/ThinkFlowLab/FlyBot/issues/500)).
8 changes: 4 additions & 4 deletions docs/subsystems/social.md

Large diffs are not rendered by default.

72 changes: 72 additions & 0 deletions plugins/social/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -1999,6 +1999,7 @@ window.__ModuleLoader__.load({
// The host draws the Sharing title (#406).
return h('div', { className: 'fs-sharing', 'data-flybot': 'bot-social-details' },
h(InviteToBot, { bot }),
h(ShareWithFriend, { bot }),
h(ManageAccess, { bot }))
}

Expand Down Expand Up @@ -2030,6 +2031,76 @@ window.__ModuleLoader__.load({
note ? h('div', { className: 'fs-status', 'data-tone': note.tone }, note.text) : null)
}

// #500: share this Bot with an existing friend (no invite link). Lists friends who do not
// already have it; one click each. Invite links for new people stay above.
function ShareWithFriend({ bot }) {
useStyles()
const [friends, setFriends] = useState(null)
const [busy, setBusy] = useState('')
const [note, setNote] = useState(null)
const load = async () => {
if (!bot?.id) return
try {
const next = await call('social/share-friends', { botId: bot.id })
setFriends(Array.isArray(next?.friends) ? next.friends : [])
} catch (e) {
setFriends([])
setNote({ tone: 'error', text: errorText(e) })
}
}
useEffect(() => {
setFriends(null)
setNote(null)
void load()
if (typeof setInterval !== 'function') return undefined
const timer = setInterval(() => { if (typeof document === 'undefined' || !document.hidden) void load() }, 5000)
timer?.unref?.()
return () => clearInterval(timer)
}, [bot?.id])
if (!bot?.id) return null
const share = async (friend) => {
setBusy(friend.home || friend.ownerId || 'share')
setNote(null)
try {
const result = await call('social/share-with-friend', {
botId: bot.id,
home: friend.home,
...(friend.ownerId ? { ownerId: friend.ownerId } : {}),
})
setNote({ tone: 'ok', text: result?.message || `Shared ${bot.name}.` })
const next = await call('social/share-friends', { botId: bot.id }).catch(() => null)
if (next) setFriends(Array.isArray(next.friends) ? next.friends : [])
else setFriends(list => (list ?? []).filter(item => item.home !== friend.home))
} catch (e) {
setNote({ tone: 'error', text: errorText(e) })
} finally { setBusy('') }
}
const list = friends ?? []
const loading = friends === null
return h('div', { className: 'fs fs-card', 'data-flybot': 'share-with-friend' },
h('div', { className: 'fs-name' }, 'Share with a friend'),
h('div', { className: 'fs-hint' }, `One click shares ${bot.name || 'this Bot'} with someone you already know. No invite link. They can decline; you can Revoke in People with access.`),
loading ? h('div', { className: 'fs-hint' }, 'Loading friends…') : null,
!loading && list.length === 0
? h('div', { className: 'fs-hint', 'data-flybot': 'share-with-friend-empty' }, 'No friends left to share with. Invite someone new above, or they already have this Bot.')
: null,
list.map(friend => h('div', { key: friend.home || friend.ownerId, className: 'fs-line', 'data-flybot': 'share-friend-row' },
h('div', { className: 'fs-col' },
h('span', { className: 'fs-name' }, friend.name || friend.home),
friend.home && friend.name && !String(friend.name).includes(String(friend.home).replace(/^@/, ''))
? h('span', { className: 'fs-sub' }, friend.home)
: null),
h('button', {
type: 'button',
className: 'fs-btn fs-btn-sm',
disabled: Boolean(busy),
'data-flybot': 'share-friend',
'aria-label': `Share ${bot.name || 'this Bot'} with ${friend.name || friend.home}`,
onClick: () => { void share(friend) },
}, busy === (friend.home || friend.ownerId || 'share') ? 'Sharing…' : 'Share'))),
note ? h('div', { className: 'fs-status', 'data-tone': note.tone, role: 'status' }, note.text) : null)
}

// A friend chat Bot Social paused (#404): a quiet notice, no buttons. There is no turn limit and
// nothing to extend (owner decision 2026-10-19): the next message opens a fresh chat.
function PausedChat({ bot } = {}) {
Expand Down Expand Up @@ -2190,6 +2261,7 @@ window.__ModuleLoader__.load({
errorText,
canInvite,
InviteToBot,
ShareWithFriend,
ManageAccess,
BotSocialDetails,
SocialServerSettings,
Expand Down
39 changes: 39 additions & 0 deletions plugins/social/src/mcp-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
const lib = parsed.protocol === 'http:' ? http : https
const agent = init.agent ?? agentFor(url)
const method = String(init.method ?? 'GET').toUpperCase()
const headers = { ...(init.headers ?? {}) }

Check warning on line 44 in plugins/social/src/mcp-client.js

View workflow job for this annotation

GitHub Actions / plugins (social)

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

Empty fallbacks in spreads are unnecessary
const signal = init.signal
return new Promise((resolve, reject) => {
const req = lib.request(parsed, { method, headers, agent }, (res) => {
Expand Down Expand Up @@ -76,7 +76,7 @@
const list = [...items]
if (list.length === 0) return []
const cap = Math.max(1, Math.min(limit || GRANT_CONCURRENCY, list.length))
const out = new Array(list.length)

Check warning on line 79 in plugins/social/src/mcp-client.js

View workflow job for this annotation

GitHub Actions / plugins (social)

unicorn(no-new-array)

Do not use `new Array(singleArgument)`.
let next = 0
async function worker() {
for (;;) {
Expand Down Expand Up @@ -410,6 +410,44 @@
}
}

// Owner grants one of THEIR bots to an existing friend (Birfy/bot-social#34 / FlyBot #500).
// Same per-Bot grant an accepted invite creates; no invite code. Idempotent when a live grant
// already exists. Prefer MCP grant_bot_to_friend; REST POST …/grants/to-friend is the fallback.
// friend_owner_id is the companion's preferred key; friend_home is what FlyBot always has.
async function grantBotToFriend({ botId, friendOwnerId, friendHome } = {}) {
if (!botId) throw new BotSocialError('Bot Social grant_bot_to_friend: which Bot?', 'invalid')
if (!friendOwnerId && !friendHome) {
throw new BotSocialError('Bot Social grant_bot_to_friend: which friend?', 'invalid')
}
const args = {
bot_id: botId,
...(friendOwnerId ? { friend_owner_id: friendOwnerId } : {}),
...(friendHome ? { friend_home: friendHome } : {}),
}
const body = {
...(friendOwnerId ? { friend_owner_id: friendOwnerId } : {}),
...(friendHome ? { friend_home: friendHome } : {}),
}
const viaRest = async (error) => {
if (!base) {
if (isUnknownTool(error) || isMissingEndpoint(error)) return { unavailable: true }
throw error ?? new BotSocialError('Bot Social grant_bot_to_friend: no server URL', 'no_server')
}
try {
return await rest('POST', `/v1/bots/${encodeURIComponent(botId)}/grants/to-friend`, body)
} catch (restErr) {
if (isUnknownTool(restErr) || isMissingEndpoint(restErr)) return { unavailable: true }
throw restErr
}
}
try {
return await tryTool('grant_bot_to_friend', args, viaRest)
} catch (error) {
if (isUnknownTool(error) || isMissingEndpoint(error)) return { unavailable: true }
throw error
}
}

async function declineGrant({ grantId, botId } = {}) {
if (!grantId) throw new BotSocialError('Bot Social decline_bot_grant: which grant?', 'invalid')
const args = {
Expand Down Expand Up @@ -460,6 +498,7 @@
sendAsMember: args => mcp.callTool('send_as_member', args),
listGrants,
revokeGrant,
grantBotToFriend,
updateBotProfile,
declineGrant,
}
Expand Down
120 changes: 119 additions & 1 deletion plugins/social/src/social.js
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,7 @@ export const SOCIAL_UI_ENDPOINTS = [
'social/face-auto', 'social/face-lead',
'social/hide-remote',
'social/access', 'social/revoke-grant',
'social/share-friends', 'social/share-with-friend',
]

export function registerUnavailable(service, reason) {
Expand Down Expand Up @@ -1845,7 +1846,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp
return client.callTool(tool, { ...args, confirm_code: first.confirm_code })
}
const homeArgs = () => (home ? { bot_id: home.id } : config.botId ? { bot_id: config.botId } : {})
const peerView = item => ({ relationshipId: item.relationship_id, status: item.status, handle: item.peer?.handle ? norm(item.peer.handle) : undefined, name: cleanText(item.peer?.display_name), owner: cleanText(item.peer?.owner_display_name), message: item.message ?? null, requestedAt: item.requested_at, expiresAt: item.expires_at })
const peerView = item => ({ relationshipId: item.relationship_id, status: item.status, handle: item.peer?.handle ? norm(item.peer.handle) : undefined, name: cleanText(item.peer?.display_name), owner: cleanText(item.peer?.owner_display_name), ...(item.peer?.owner_id || item.friend_owner_id || item.owner_id ? { ownerId: String(item.peer?.owner_id || item.friend_owner_id || item.owner_id) } : {}), message: item.message ?? null, requestedAt: item.requested_at, expiresAt: item.expires_at })
// A friendship may hang on the home or on one member (a per-Bot invite, #420), so the home and every
// member identity are asked, as setAutoAccept does; one relationship is listed once. A member that
// cannot be read costs only its own rows; the home's answer still decides whether this fails.
Expand Down Expand Up @@ -2146,6 +2147,123 @@ export function createSocial(service, config = {}, { client: given, api: givenAp
void flybot.rooms?.leaveInbound?.({ localBotId: bot.id, handle: invited, grantee: target?.grantee }).catch(error => warn('inbound rooms not left after revoke', error))
return { botId: bot.id, revoked: grantId || target?.id || relationshipId, grants: flybot.grants?.of?.(bot.id) ?? next }
}

// #500: friends who do not yet have this Bot — for Sharing → "Share with a friend".
// Uses the active friendships (list_friends) so a friend we invited (and who has not
// shared anything back) still appears. Homes already in People with access are omitted.
async function shareFriendsForBot(botId) {
if (!home) throw new Error('Bot Social is not connected: there is no FlyBot home yet')
const bot = typeof botId === 'string' && botId ? flybot.bot.get(botId) : undefined
if (!bot) throw new Error('Choose one of your Bots to share')
const member = activeMember(bot.id)
if (!member) throw new Error(`${bot.name} has no Bot Social identity yet`)
await syncOutgoingGrants({ only: bot.id }).catch(error => warn('grants not synced for share list', error))
const listed = typeof api.listGrants === 'function'
? await api.listGrants({ botId: member.bot_id }).catch(error => { warn('grants not listed for share', error); return [] })
: []
const grantedHomes = new Set()
for (const grant of listed) {
if (isTalkThroughGrant(member, grant)) continue
const view = grantPersonView(grant)
const key = norm(view.home || (typeof grant.grantee === 'string' ? personHomeOf(grant.grantee) : ''))
if (key) grantedHomes.add(key)
}
const overview = await friendsOverview()
const friends = []
const seen = new Set()
for (const item of overview.friends ?? []) {
const homeKey = personHomeOf(item.handle) || norm(item.handle)
if (!homeKey || homeKey === '@' || seen.has(homeKey) || grantedHomes.has(homeKey)) continue
if (home && homeKey === norm(home.handle)) continue
seen.add(homeKey)
const label = cleanText(item.owner || item.name)
|| String(homeKey).replace(/^@/, '').replace(/\.home$/i, '')
|| homeKey
friends.push({
home: homeKey,
name: label,
...(item.ownerId ? { ownerId: item.ownerId } : {}),
...(item.relationshipId ? { relationshipId: item.relationshipId } : {}),
})
}
friends.sort((a, b) => String(a.name).localeCompare(String(b.name)) || String(a.home).localeCompare(String(b.home)))
return { botId: bot.id, botName: bot.name, friends }
}
disposers.push(flybot.api.handle('social/share-friends', async ({ botId } = {}) => shareFriendsForBot(botId), { read: true }))

function mapShareFriendError(error) {
const raw = String(error?.message ?? error ?? '').replace(/\s+/g, ' ').trim()
const blob = `${raw} ${error?.code ?? ''}`
if (error?.unavailable || /unavailable/i.test(blob)) {
return Object.assign(new Error('Bot Social needs an update to share with a friend. Invite links still work.'), { code: 'unavailable' })
}
if (/not_friends|no_relationship|no active relationship|not friends/i.test(blob)) {
return new Error('You are not friends with them yet. Add them from + Add friend.')
}
if (/already_granted|idempotent|unchanged/i.test(blob)) {
return null
}
if (/bot_not_found|not your bot/i.test(blob)) {
return new Error('Choose one of your Bots to share.')
}
if (/friend_not_found|no such friend|unknown friend/i.test(blob)) {
return new Error('That friend is not on this server.')
}
if (/not connected|no token|no server URL/i.test(blob)) {
return new Error('Bot Social is disconnected. Reconnect under Settings → Bot Social.')
}
const body = raw.replace(/^Bot Social\s+\S+:\s*/i, '').trim()
return new Error(body || 'Could not share with that friend. Try again.')
}

// #500: grant this Bot to an existing friend (Bot Social grant_bot_to_friend). Does not
// replace invite links. After a successful share, People with access re-syncs this Bot.
disposers.push(flybot.api.handle('social/share-with-friend', async ({ botId, home: friendHome, ownerId, friendOwnerId } = {}) => {
if (!home) throw new Error('Bot Social is not connected: there is no FlyBot home yet')
const bot = typeof botId === 'string' && botId ? flybot.bot.get(botId) : undefined
if (!bot) throw new Error('Choose one of your Bots to share')
const member = await register(bot, { forInvite: true })
if (!member) throw new Error(`${bot.name} has no Bot Social identity yet`)
const homeKey = norm(friendHome)
const oid = friendOwnerId || ownerId
if (!homeKey && !oid) throw new Error('Choose a friend to share with')
if (typeof api.grantBotToFriend !== 'function') {
throw Object.assign(new Error('Bot Social needs an update to share with a friend. Invite links still work.'), { code: 'unavailable' })
}
let result
try {
result = await api.grantBotToFriend({ botId: member.bot_id, friendOwnerId: oid, friendHome: homeKey || undefined })
} catch (error) {
const mapped = mapShareFriendError(error)
if (mapped) throw mapped
result = { status: 'unchanged' }
}
if (result?.unavailable) {
throw Object.assign(new Error('Bot Social needs an update to share with a friend. Invite links still work.'), { code: 'unavailable' })
}
await syncOutgoingGrants({ only: bot.id }).catch(error => warn('grants not synced after share', error))
const grants = typeof api.listGrants === 'function'
? await api.listGrants({ botId: member.bot_id }).catch(() => [])
: []
const access = {
botId: bot.id,
botName: bot.name,
grants: accessRows(bot.id, grants),
rooms: (flybot.rooms?.list?.() ?? []).filter(room => (room.bots ?? []).some(id => id === bot.id)).map(room => ({ id: room.id, name: room.name, origin: room.origin })),
}
const who = cleanText(result?.friend?.owner_display_name || result?.friend?.display_name || result?.grantee?.owner_display_name)
|| (homeKey ? (String(homeKey).replace(/^@/, '').replace(/\.home$/i, '') || homeKey) : 'your friend')
return {
botId: bot.id,
botName: bot.name,
home: homeKey || undefined,
status: result?.status || 'active',
grantId: result?.grant_id || result?.grantId,
message: `Shared ${bot.name} with ${who}. They can decline; you can Revoke in People with access.`,
access,
}
}))

// Each Bot carries its owner, so a picker can name it "Coder (Bob)" (#384).
disposers.push(flybot.api.handle('social/friends', async payload => (await remoteBots({ fresh: payload?.fresh === true })).homes.flatMap(entry => entry.bots.map(bot => ({ ...bot, ...(entry.owner ? { owner: entry.owner } : {}), ...(entry.name ? { homeName: entry.name } : {}) }))), { read: true }))
disposers.push(flybot.api.handle('social/auto-approve', async ({ botId, home: friendHome, on }) => {
Expand Down
Loading
Loading