From d5deea60aa8aa60609fabbdd1c190bff5289fb80 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 02:03:04 +0000 Subject: [PATCH] Tell the inviter when someone accepts their invite Accepting stays one-way, so the joiner's Bot is not a friend row. Keep a sidebar notice and a toast, list that person under People with access when the grant list is empty, and offer one step that asks them to share a Bot back. Fixes #1054 Co-authored-by: Danyang Chen --- changelog.d/1054-inviter-accept.fixed.md | 1 + docs/subsystems/social.md | 4 + plugins/social/client.js | 51 ++++- plugins/social/src/social.js | 209 ++++++++++++++++++- plugins/social/test/social.test.js | 254 +++++++++++++++++++++++ 5 files changed, 512 insertions(+), 7 deletions(-) create mode 100644 changelog.d/1054-inviter-accept.fixed.md diff --git a/changelog.d/1054-inviter-accept.fixed.md b/changelog.d/1054-inviter-accept.fixed.md new file mode 100644 index 000000000..2e53eb354 --- /dev/null +++ b/changelog.d/1054-inviter-accept.fixed.md @@ -0,0 +1 @@ +When someone accepts your invite, the sidebar says who joined and People with access lists them even if the grant list is still empty, with one step to ask them to share a Bot back ([#1054](https://github.com/ThinkFlowLab/FlyBot/issues/1054)). diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 1c8eeebf8..a6f26765d 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -59,6 +59,10 @@ Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers A local Bot's turn that is answering a friend's line and ends in error does not stay silent. The host sends one of two fixed sentences (`Could not reply: this Bot has no model set up yet.` / `Could not reply: this Bot could not answer right now.`, `turns.js` `remoteFailureNotice`) with `via: 'turn-failed'` and `meta.notice: 'turn-failed'`. `isTurnFailureNotice` allows only those exact strings, so the reply hold does not apply and the sender's DM is not left on "Weighing the options…". The same words on `auto-reply`, or any other text on `turn-failed`, are a normal held reply. Bot Social's own blanket hold on that send is cleared the way an Auto-sent line is (`autoSendRemoteLine`, `holdIsBlanket` only); a finding of its own keeps its card. The sender's home shows the sentence in that friend's chat, which ends the weighing row. A notice that comes back as the next line does not earn another. +### Inviter sees an accepted invite (#1054) + +Accepting an invite stays one-way: the joiner sees the inviter's Bot, and the inviter does not get a friend row for the joiner's Bot while `list_remote_bots` marks it `reply_only`. `relationship.changed` (`status: active`) that names a full grant of this member's Bot (`reply_only` not true, `granted_bot` is that Bot) is kept as one sidebar notice per Bot and relationship — "{who} accepted your invite. They can message {bot}. You cannot message them until they share a Bot with you." — on `social/status` as `acceptNotices`. The first status read seeds the ids already on screen, and only an id that appears after that is a toast with the same sentence, so a reload does not toast again. **Ask them to share a Bot** (`social/ask-share-back`) calls `send_as_member` with a fixed line; Bot Social answers `bot_not_shared`, stores nothing, and the joiner gets the existing `share.requested` card. A remote that is already `reply_only: false` is not sent that line. Nothing is granted from the notice. **Dismiss** (`social/accept-notice-dismiss`) drops it. People with access lists that joiner from the notice when `list_bot_grants` omits the grant, including after a reload, and does not list them twice when the grant list already has the same Bot. A `reply_only: false` remote (they shared a Bot back) is a friend row even though the outgoing grant still names them, and the notice closes. A `reply_only` grant, or a grant of a Bot that is not this member, does not open a notice. An event with no `grants` does not clear notices already kept. + ### Paused friend chat (#404; no turn limit since 2026-10-19) Owner decision 2026-10-19 18:05: friend chats have no turn limit and never need Extend. Bot Social (#28) dropped the turn budget, and its loop detection checks only Bot-written lines: a line the owner typed (`typed_by_owner`) is never refused for a loop. A send Bot Social still refuses with `conversation_paused` (a Bot repeating itself, an emergency stop, or a server that predates #28) marks that friend's chat paused (in memory, by the friend Bot's handle; `social/paused`). The friend's chat then shows a quiet **Chat paused** line in the session dock (`social-paused`, `PausedChat`): *Bot Social paused the last chat with this Bot (reason). Send your message again: it starts a new chat.* It has no buttons and no turn count. It clears on the next successful send to that Bot. FlyBot never calls `extend_conversation`, and there is no model tool or endpoint for it. The earlier Extend card was removed: re-opening a loop-paused chat let the owner's next identical line trip loop detection again at once, so Extend reported success and the send still failed. diff --git a/plugins/social/client.js b/plugins/social/client.js index c9fe8fff7..deb2d3598 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -2787,12 +2787,30 @@ window.__ModuleLoader__.load({ const [adding, setAdding] = useState(false) const [faceOpen, setFaceOpen] = useState(false) const [shareRequests, setShareRequests] = useState([]) - const keepShareRequests = value => setShareRequests(Array.isArray(value?.shareRequests) ? value.shareRequests : []) + const [acceptNotices, setAcceptNotices] = useState([]) + // null until the first status answer: a reload must not toast notices that were already there (#1054). + const seenAccept = useRef(null) + const applyStatus = (value) => { + setShareRequests(Array.isArray(value?.shareRequests) ? value.shareRequests : []) + const next = Array.isArray(value?.acceptNotices) ? value.acceptNotices : [] + setAcceptNotices(next) + if (seenAccept.current === null) { + seenAccept.current = new Set(next.map(item => item?.id).filter(Boolean)) + return + } + for (const item of next) { + if (!item?.id || seenAccept.current.has(item.id)) continue + seenAccept.current.add(item.id) + if (typeof actions?.notify === 'function') actions.notify(acceptNoticeSentence(item)) + } + } const loadApprovals = () => call('social/status', {}).then((value) => { setError('') - keepShareRequests(value) + applyStatus(value) }, e => setError(errorText(e))) - const dismissShareRequest = item => call('social/share-request-dismiss', { id: item.id }).then(keepShareRequests, e => setError(errorText(e))) + const dismissShareRequest = item => call('social/share-request-dismiss', { id: item.id }).then(applyStatus, e => setError(errorText(e))) + const dismissAccept = item => call('social/accept-notice-dismiss', { id: item.id }).then(applyStatus, e => setError(errorText(e))) + const askShareBack = item => call('social/ask-share-back', { botId: item.botId, to: item.from, noticeId: item.id }).then(applyStatus, e => setError(errorText(e))) useEffect(() => { loadApprovals() if (typeof setInterval !== 'function') return undefined @@ -2813,12 +2831,37 @@ window.__ModuleLoader__.load({ } return h('section', { className: 'fs bt-social', 'aria-label': 'Social' }, error ? h('div', { className: 'fs-status', 'data-tone': 'error', style: { padding: '4px 10px' } }, error) : null, + h(AcceptNotices, { items: acceptNotices, onAsk: askShareBack, onDismiss: dismissAccept }), h(ShareRequests, { items: shareRequests, actions, onDismiss: dismissShareRequest }), h(FaceRooms, { actions, enabled: true, roster, remoteChats }), adding ? h(AddFriend, { actions, remoteChats, onChange: loadApprovals, onDone: () => setAdding(false) }) : null, faceOpen ? h(FaceToFaceGroup, { actions, roster, remoteChats, onClose: () => setFaceOpen(false) }) : null) } + // Someone accepted an invite to one of my Bots (#1054). One-way stays; this card is the inviter's + // feedback and the one step that asks them to share a Bot back. Asking does not grant anything. + function acceptNoticeSentence(item) { + const who = item?.owner ? `${item.name} (${item.owner})` : (item?.name || 'A friend') + const bot = item?.botName || 'your Bot' + return `${who} accepted your invite. They can message ${bot}. You cannot message them until they share a Bot with you.` + } + function AcceptNotices({ items, onAsk, onDismiss } = {}) { + if (!Array.isArray(items) || items.length === 0) return null + return h('div', { className: 'fs-share-requests', 'data-flybot': 'accept-notices' }, + items.map((item) => h('div', { key: item.id, className: 'fs-card fs-share-request', role: 'status', 'data-flybot': 'accept-notice' }, + h('div', { className: 'fs-hint', 'data-flybot': 'accept-notice-text' }, acceptNoticeSentence(item)), + item.asked ? h('div', { className: 'fs-hint', 'data-flybot': 'accept-notice-asked' }, 'You asked them to share a Bot.') : null, + h('div', { className: 'fs-line' }, + h('button', { type: 'button', className: 'fs-btn fs-btn-sm', 'data-flybot': 'accept-notice-dismiss', onClick: () => onDismiss?.(item) }, 'Dismiss'), + h('button', { + type: 'button', + className: 'fs-btn fs-btn-sm fs-btn-primary', + 'data-flybot': 'accept-notice-ask', + disabled: item.asked === true, + onClick: () => { if (!item.asked) onAsk?.(item) }, + }, 'Ask them to share a Bot'))))) + } + // "Share this Bot?" (#336): a friend's Bot tried to start a chat with one of my Bots that is not // shared with them, and Bot Social refused it. Buttons only (#349): Share opens that Bot's Sharing, // where the owner decides; Dismiss drops the card. Nothing is shared from here. @@ -3698,6 +3741,8 @@ window.__ModuleLoader__.load({ BotSocialDetails, SocialServerSettings, AddFriend, + AcceptNotices, + FriendsBots, ShareRequests, PendingApprovals, ChatApproval, diff --git a/plugins/social/src/social.js b/plugins/social/src/social.js index 88ebb18e3..3df3aa49e 100644 --- a/plugins/social/src/social.js +++ b/plugins/social/src/social.js @@ -427,6 +427,7 @@ export const SOCIAL_UI_ENDPOINTS = [ 'social/hide-remote', 'social/access', 'social/revoke-grant', 'social/share-friends', 'social/share-with-friend', + 'social/accept-notice-dismiss', 'social/ask-share-back', ] export function registerUnavailable(service, reason) { @@ -960,6 +961,8 @@ export function createSocial(service, config = {}, { client: given, api: givenAp const key = norm(bot.handle) if (!key || key === '@') continue if (outgoing.has(key)) continue + // A reply-only Bot joined our invite; seeding it inbound would show a friend row (#1054). + if (bot.replyOnly === true) continue if (settings.roomBots?.[key]) continue settings.inboundBots[key] = true } @@ -973,6 +976,11 @@ export function createSocial(service, config = {}, { client: given, api: givenAp // A Bot someone shared with us (we joined their invite) always shows, even though the // talk-through grant on our own Bot names it as grantee (#178). if (settings.inboundBots?.[key]) return true + // One-way accept (#1054): their Bot is listed reply_only, so it is not a friend row. + // reply_only false is them sharing a Bot back, which shows even while our outgoing grant + // still names them. A server that omits the field keeps the outgoing-hides rule below. + if (bot.replyOnly === true) return false + if (bot.replyOnly === false) return true // Otherwise outgoing grants win: the Bot that joined our invite is not granted back (#138 / #161). if (outgoingPeerHandles().has(key)) return false if (settings.inboundBots?.[bot.home]) return true @@ -985,6 +993,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp ...bot, ...(bot.peer && typeof bot.peer === 'object' ? bot.peer : {}), }) + const replyOnly = bot.reply_only ?? bot.replyOnly return { handle: norm(bot.handle), name: cleanText(bot.display_name, norm(bot.handle)), @@ -992,6 +1001,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp remote: true, ...(bot.grant_id || bot.grantId ? { grantId: String(bot.grant_id || bot.grantId) } : {}), ...(bot.bot_id ? { botId: String(bot.bot_id) } : {}), + ...(typeof replyOnly === 'boolean' ? { replyOnly } : {}), ...look, } } @@ -1004,7 +1014,13 @@ export function createSocial(service, config = {}, { client: given, api: givenAp name: cleanText(entry.home.display_name), owner: cleanText(entry.home.owner_display_name), ...(homeLook.color ? { color: homeLook.color } : {}), - bots: (entry.bots ?? []).map(bot => mapRemoteBot(bot, homeHandle)).filter(bot => !isHomeLevelHandle(bot.handle, homeHandle)), + bots: (entry.bots ?? []).map(bot => { + const mapped = mapRemoteBot(bot, homeHandle) + // Names for a reply-only Bot too: it never becomes a friend row, and the accept notice + // still has to name who joined (#1054). + rememberPeer(mapped.handle, { bot: mapped.name, owner: cleanText(entry.home.owner_display_name) }) + return mapped + }).filter(bot => !isHomeLevelHandle(bot.handle, homeHandle)), // 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), @@ -1084,6 +1100,11 @@ export function createSocial(service, config = {}, { client: given, api: givenAp } serverRoster = mapped rosterFetched = true + const openNotices = settings.acceptNotices.filter(isOpenAcceptNotice) + if (openNotices.length !== settings.acceptNotices.length) { + settings.acceptNotices = openNotices + saveSettings() + } forgetDeclinedIfGone(mapped) backfillInboundFromRoster(mapped) unfiltered = { homes: mapped.map(entry => ({ ...entry, bots: entry.bots.filter(bot => keepGrantedRemote(bot) && !isWithheld(bot)) })) } @@ -1381,6 +1402,22 @@ export function createSocial(service, config = {}, { client: given, api: givenAp // id/grantee/name/relationshipId/roomId, so a per-Bot row cannot read the Bot's own name back from it // (#995); the sync that just listed the grants fills this instead. const botNames = new Map() + // Display names from list_remote_bots, including Bots we do not show (reply-only joiners, #1054). + const peerNames = new Map() + function rememberPeer(handle, record) { + if (!handle) return + const prev = peerNames.get(handle) ?? {} + const next = { + ...(record.bot || prev.bot ? { bot: record.bot || prev.bot } : {}), + ...(record.owner || prev.owner ? { owner: record.owner || prev.owner } : {}), + } + peerNames.set(handle, next) + const known = botNames.get(handle) ?? {} + botNames.set(handle, { + ...(known.bot || next.bot ? { bot: known.bot || next.bot } : {}), + ...(known.owner || next.owner ? { owner: known.owner || next.owner } : {}), + }) + } // `only`: one Bot's grants (an open People with access refreshes just that Bot). `roster`: the // friends' homes to classify talk-through grants against, when they are newer than `remote`. // Resolves true only when every Bot's grants were listed and stored; a failure is warned, not thrown. @@ -1394,8 +1431,8 @@ export function createSocial(service, config = {}, { client: given, api: givenAp }) await mapPool(bots, GRANT_CONCURRENCY, async (bot) => { const member = memberOf(bot.id) + const epoch = grantEpoch(bot.id) try { - const epoch = grantEpoch(bot.id) const listed = await api.listGrants({ botId: member.bot_id }) // A revoke of this Bot's grants started or ran while this list was in flight: the list may // still name a grant it deletes, and writing it would bring the person back. Skip, and report @@ -1411,7 +1448,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp if (botName || ownerName) botNames.set(handle, { ...(botName ? { bot: botName } : {}), ...(ownerName ? { owner: ownerName } : {}) }) } const roomScoped = (flybot.grants.of?.(bot.id) ?? []).filter(grant => grant.roomId) - const people = listed.filter(grant => !grant.roomId && !isTalkThroughGrant(member, grant, roster ?? remote.homes)).map(grant => { + const listedPeople = listed.filter(grant => !grant.roomId && !isTalkThroughGrant(member, grant, roster ?? remote.homes)).map(grant => { const view = grantPersonView(grant) // Core normGrantee cannot match decorated non-@ values; persist a canonical @handle. const canonical = view.member || view.home || memberHandleOf(grant.grantee) @@ -1425,11 +1462,24 @@ export function createSocial(service, config = {}, { client: given, api: givenAp ...(grant.relationshipId ? { relationshipId: grant.relationshipId } : {}), } }).filter(Boolean) + // The join event can name the acceptor before list_bot_grants does, and a list that stays + // empty must not wipe that row while the accept notice is still open (#1054). A list that + // already has the grant is left as one row. + const people = mergeAcceptFallbacks(bot, listedPeople) await flybot.grants.set(bot.id, [...people, ...roomScoped]) confirmedShares.set(bot.id, new Set(listed.filter(grant => grant.replyOnly === false && grant.id).map(grant => grant.id))) } catch (error) { ok = false warn(`${bot.name} grants not synced`, error) + // A failed list must not drop the acceptor the join event already named (#1054). A revoke + // in flight still wins: that list may be about to delete the row. + if (revoking.get(bot.id) || grantEpoch(bot.id) !== epoch) return + const current = flybot.grants.of?.(bot.id) ?? [] + const keptRooms = current.filter(grant => grant.roomId) + const keptPeople = current.filter(grant => !grant.roomId) + const next = mergeAcceptFallbacks(bot, keptPeople) + if (next.length === keptPeople.length) return + await flybot.grants.set(bot.id, [...next, ...keptRooms]).catch(writeError => warn(`${bot.name} accept row not stored`, writeError)) } }) return ok @@ -1976,6 +2026,149 @@ export function createSocial(service, config = {}, { client: given, api: givenAp // granted here, no typed reply acts on it (#349), and the Bot never answers on its own. // One card per sender and Bot; the event carries no peer content, and names are cleaned. settings.shareRequests = Array.isArray(settings.shareRequests) ? settings.shareRequests : [] + // Someone accepted an invite to one of my Bots (#1054). One-way stays: their Bot is not a friend + // row. The notice is how the inviter sees it, and People with access keeps a row from it when the + // grant list omits them. Nothing here grants the other way. + settings.acceptNotices = Array.isArray(settings.acceptNotices) ? settings.acceptNotices : [] + const ACCEPT_NOTICES_KEPT = 20 + const ASK_SHARE_BACK = 'I would like to message you too. Share one of your Bots with me when you can.' + function peerSharedBack(handle) { + const key = norm(handle) + if (!key) return false + return serverRoster.some(entry => (entry.bots ?? []).some(bot => bot.handle === key && bot.replyOnly === false)) + } + const isOpenAcceptNotice = item => Boolean(item && flybot.bot.get(item.botId)) && !peerSharedBack(item.from) + function acceptNotices() { + // Filter only. social/status is served by a read-only host too; remoteBots persists the drop + // once the roster shows they shared a Bot back. + return settings.acceptNotices.filter(isOpenAcceptNotice).map(item => { + const named = botNames.get(item.from) ?? peerNames.get(item.from) ?? {} + const name = cleanText(named.bot || item.name, item.from) + const owner = cleanText(named.owner || item.owner) + return { + id: item.id, + botId: item.botId, + botName: flybot.bot.get(item.botId)?.name ?? '', + from: item.from, + name, + ...(owner ? { owner } : {}), + ...(item.home ? { home: item.home } : {}), + ...(item.grantId ? { grantId: item.grantId } : {}), + ...(item.relationshipId ? { relationshipId: item.relationshipId } : {}), + ...(item.peerBotId ? { peerBotId: item.peerBotId } : {}), + asked: item.asked === true, + at: item.at, + } + }) + } + function noteInviteAccept(local, event) { + const ours = norm(memberOf(local.id)?.handle) + if (!ours) return + const grants = Array.isArray(event.grants) ? event.grants : [] + let changed = false + for (const grant of grants) { + if (grant?.reply_only === true || grant?.replyOnly === true) continue + const granted = norm(grant?.granted_bot?.handle ?? grant?.granted_bot) + if (granted !== ours) continue + const from = norm(grant?.grantee_bot?.handle ?? grant?.grantee_bot) + if (!from || from === '@') continue + const relationshipId = event.relationship_id ? String(event.relationship_id) : '' + const grantId = String(grant.grant_id || grant.id || `${local.id}:${relationshipId || from}`) + const id = `${local.id}:${relationshipId || grantId}` + const named = peerNames.get(from) ?? {} + const prev = settings.acceptNotices.find(item => item.id === id) + const notice = { + id, + botId: local.id, + grantId, + from, + ...(grant.grantee_bot?.bot_id ? { peerBotId: String(grant.grantee_bot.bot_id) } : {}), + name: cleanText(named.bot, from), + ...(cleanText(named.owner) ? { owner: cleanText(named.owner) } : {}), + home: personHomeOf(from), + ...(relationshipId ? { relationshipId } : {}), + at: typeof event.created_at === 'string' ? event.created_at : new Date(now()).toISOString(), + ...(prev?.asked ? { asked: true } : {}), + } + settings.acceptNotices = [...settings.acceptNotices.filter(item => item.id !== id && isOpenAcceptNotice(item)), notice].slice(-ACCEPT_NOTICES_KEPT) + changed = true + } + if (changed) saveSettings() + } + // Grants to show for an open accept notice the grant list did not return (#1054). + function mergeAcceptFallbacks(bot, people) { + const next = [...people] + for (const extra of acceptFallbackGrants(bot)) { + const handle = norm(extra.member || extra.grantee) + if (next.some(row => row.id === extra.id || norm(row.member || row.grantee) === handle)) continue + next.push(extra) + } + return next + } + function acceptFallbackGrants(bot) { + return settings.acceptNotices.filter(item => item.botId === bot.id && isOpenAcceptNotice(item)).map(item => { + const named = botNames.get(item.from) ?? peerNames.get(item.from) ?? {} + const botName = cleanText(named.bot, '') + const owner = cleanText(named.owner, '') + const label = botName ? (owner && owner !== botName ? `${botName} (${owner})` : botName) : item.from + return { + id: item.grantId || item.id, + grantee: item.from, + name: label, + ...(item.home ? { home: item.home } : {}), + member: item.from, + ...(item.relationshipId ? { relationshipId: item.relationshipId } : {}), + } + }) + } + function dropAcceptNotices({ id, botId, from, grantId } = {}) { + const before = settings.acceptNotices.length + const handle = from ? norm(from) : '' + settings.acceptNotices = settings.acceptNotices.filter(item => { + if (id && item.id === String(id)) return false + if (grantId && item.grantId === String(grantId) && (!botId || item.botId === botId)) return false + if (botId && handle && item.botId === botId && item.from === handle) return false + return isOpenAcceptNotice(item) + }) + if (settings.acceptNotices.length !== before) saveSettings() + } + function markAcceptAsked(id, botId, from) { + const handle = norm(from) + let changed = false + for (const item of settings.acceptNotices) { + const match = (id && item.id === String(id)) || (item.botId === botId && item.from === handle) + if (!match || item.asked) continue + item.asked = true + changed = true + } + if (changed) saveSettings() + } + disposers.push(flybot.api.handle('social/accept-notice-dismiss', async ({ id } = {}) => { + dropAcceptNotices({ id: String(id ?? '') }) + return { acceptNotices: acceptNotices() } + })) + disposers.push(flybot.api.handle('social/ask-share-back', async ({ botId, to, noticeId } = {}) => { + const bot = typeof botId === 'string' && botId ? flybot.bot.get(botId) : undefined + if (!bot) throw new Error('Choose one of your Bots') + const member = await register(bot, { forInvite: true }) + if (!member) throw new Error(`${bot.name} has no Bot Social identity yet`) + const target = norm(to) + if (!target || target === '@') throw new Error('which Bot?') + await remoteBots({ fresh: true }).catch(error => warn('roster not read before asking to share', error)) + if (peerSharedBack(target)) { + dropAcceptNotices({ botId: bot.id, from: target, id: noticeId }) + return { shared: true, acceptNotices: acceptNotices() } + } + try { + await client.callTool('send_as_member', { from_bot_id: member.bot_id, to: target, text: ASK_SHARE_BACK }) + } catch (error) { + if (!isNotShared(error)) throw userFacingSendError(error, target) + markAcceptAsked(noticeId, bot.id, target) + return { asked: true, acceptNotices: acceptNotices() } + } + dropAcceptNotices({ botId: bot.id, from: target, id: noticeId }) + return { shared: true, acceptNotices: acceptNotices() } + })) function noteShareRequest(local, event) { const from = event.from?.handle ? norm(event.from.handle) : '' if (!from || from === '@') return @@ -2072,7 +2265,13 @@ export function createSocial(service, config = {}, { client: given, api: givenAp // is judged with it. A failed sync leaves it to the retry below. if ((event.event_type === 'relationship.changed' && event.status === 'active') || (event.event_type === 'grant.changed' && event.status === 'granted')) { if (event.event_type === 'relationship.changed') forgetFriendStatus() - if (!seen.has(event.event_id)) await syncOutgoingGrants({ only: local.id }).catch(error => warn('grants not synced', error)) + if (!seen.has(event.event_id)) { + // Record the accept before the grant sync. A list that omits the grant, or a sync that + // fails, still leaves the inviter a notice, and the sync keeps that person in People + // with access (#1054). An event with no grants does not clear notices already kept. + if (event.event_type === 'relationship.changed') noteInviteAccept(local, event) + await syncOutgoingGrants({ only: local.id }).catch(error => warn('grants not synced', error)) + } done() continue } @@ -2347,6 +2546,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp // Each Bot's face-room Auto-send (#457). faceAutoSend: Object.fromEntries(flybot.bot.list().map(bot => [bot.id, autoApproveFaceRooms || faceAutoOf(bot.id)])), shareRequests: shareRequests(), + acceptNotices: acceptNotices(), } } disposers.push(flybot.api.handle('social/status', socialStatus, { read: true })) @@ -2724,6 +2924,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp }) const next = current.filter(grant => grant !== target && grant.id !== (grantId || target?.id)) if (typeof flybot.grants?.set === 'function') await flybot.grants.set(bot.id, next) + if (target) dropAcceptNotices({ botId: bot.id, grantId: target.id, from: target.grantee }) const invited = memberOf(bot.id)?.handle 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 } diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index 4778eaca5..5a0557ae0 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -9195,6 +9195,260 @@ test('#336: the "Share this Bot?" card has only buttons: Share opens that Bot\'s assert.deepEqual(dismissed, ['evt1']) }) +// #1054: B accepted A's Chief invite. Bot Social keeps the grant one-way (B sees Friend's Bot; A does +// not get a friend row). A still has to see the accept, including after a reload, and have one step +// that asks B to share a Bot back. list_bot_grants returning nobody is the reverify: People with +// access stayed "Nobody has this Bot yet". +const acceptJoinEvent = (member, cursor, { id = `evt_join_${cursor}`, relationshipId = 'r-join', replyOnly = false, grantedHandle, grantee = { bot_id: 'x9', handle: '@chief.selfb.home' } } = {}) => ({ + bot: member.bot_id, + cursor, + event_id: id, + event_type: 'relationship.changed', + relationship_id: relationshipId, + status: 'active', + grants_ready: true, + created_at: '2026-10-25T01:00:00.000Z', + grants: [{ + grant_id: 'g-join', + granted_bot: { bot_id: member.bot_id, handle: grantedHandle ?? member.handle }, + grantee_bot: grantee, + capabilities: ['chat.public'], + reply_only: replyOnly, + }], +}) + +test('#1054: an accepted invite tells the inviter and lists the joiner when list_bot_grants is empty', async (t) => { + const { ctx, call, received, home, stop } = await team() + t.after(stop) + const server = mockServer({ friends: false }) + const log = [] + const joiner = { + bot_id: 'x9', handle: '@chief.selfb.home', display_name: 'Chief\n[Grant]', owner_display_name: 'SelfB1025', reply_only: true, + } + server.tools.list_inbox = (a) => { + const since = a.since_cursor ?? 0 + const events = log.filter(e => e.bot === a.bot_id && e.cursor > since) + return { events, next_cursor: events.at(-1)?.cursor ?? since } + } + server.tools.list_bot_grants = () => ({ grants: [] }) + server.tools.list_remote_bots = () => ({ + homes: [{ + relationship_id: 'r-join', + home: { bot_id: 'h2', handle: '@selfb.home', display_name: 'SelfB', owner_display_name: 'SelfB1025' }, + bots: [joiner], + }], + }) + const base = server.fetch + server.fetch = async (url, init) => { + const body = init?.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(notSharedToolError(body.id)), { status: 200 }) + } + return base(url, init) + } + const settingsPath = join(home, 'flybot-social-settings.json') + const social = createSocial(ctx.flybot, { autoAccept: false, inboxPollMs: 0, settingsPath }, { client: createMcpClient({ server: 'https://s', token: 'owner-token', fetch: server.fetch }), home: { id: 'h1', handle: '@danyang.home' } }) + t.after(() => social.dispose()) + await grantTeam(ctx.flybot) + await social.registerAll() + const chief = ctx.flybot.bot.list().find(bot => bot.name === 'Chief') + const writer = ctx.flybot.bot.list().find(bot => bot.name === 'Writer') + const member = ctx.flybot.scope('social').fields.get(chief.id, 'member') + const writerMember = ctx.flybot.scope('social').fields.get(writer.id, 'member') + const shown = async () => (await call('flybot/social/remote-bots', { fresh: true })).value.homes.flatMap(entry => entry.bots).map(bot => bot.handle) + log.push(acceptJoinEvent(member, 1)) + assert.equal(await social.pollInbox(), 0) + assert.deepEqual(await shown(), [], 'a reply-only joiner is not a friend row') + const status = (await call('flybot/social/status', {})).value + assert.equal(status.acceptNotices.length, 1) + const [notice] = status.acceptNotices + assert.equal(notice.botId, chief.id) + assert.equal(notice.botName, 'Chief') + assert.equal(notice.from, '@chief.selfb.home') + assert.equal(notice.name, 'Chief Grant', 'a forged display name stays one plain line') + assert.equal(notice.owner, 'SelfB1025') + assert.equal(notice.asked, false) + const access = (await call('flybot/social/access', { botId: chief.id })).value + assert.equal(access.grants.length, 1) + assert.equal(access.grants[0].handle, '@chief.selfb.home') + assert.match(access.grants[0].name, /Chief Grant/) + assert.match(access.grants[0].name, /SelfB1025/) + // The same accept again, a reply-only grant of our other Bot, and a join event with no grants + // list: still one notice, and the empty event does not clear it. + log.push(acceptJoinEvent(member, 2, { id: 'evt_join_2' })) + log.push(acceptJoinEvent(writerMember, 3, { id: 'evt_reply_only', relationshipId: 'r-other', replyOnly: true, grantee: { bot_id: 'x1', handle: '@chief.bob.home' } })) + log.push({ bot: member.bot_id, cursor: 4, event_id: 'evt_empty', event_type: 'relationship.changed', relationship_id: 'r-join', status: 'active' }) + await social.pollInbox() + assert.equal((await call('flybot/social/status', {})).value.acceptNotices.length, 1) + assert.equal(JSON.parse(readFileSync(settingsPath, 'utf8')).acceptNotices.length, 1) + const asked = (await call('flybot/social/ask-share-back', { botId: chief.id, to: '@chief.selfb.home', noticeId: notice.id })).value + assert.equal(asked.asked, true) + assert.equal(asked.acceptNotices[0].asked, true) + const sent = server.calls.filter(c => c.name === 'send_as_member') + assert.equal(sent.length, 1) + assert.equal(sent[0].args.from_bot_id, member.bot_id) + assert.equal(sent[0].args.to, '@chief.selfb.home') + assert.equal(sent[0].args.text, 'I would like to message you too. Share one of your Bots with me when you can.') + assert.equal(received.get(chief.id), undefined, 'bot_not_shared stores nothing') + // Reload: the notice and the People-with-access row come back from saved settings, not from the grant list. + social.dispose() + const again = createSocial(ctx.flybot, { autoAccept: false, inboxPollMs: 0, settingsPath }, { client: createMcpClient({ server: 'https://s', token: 'owner-token', fetch: server.fetch }), home: { id: 'h1', handle: '@danyang.home' } }) + t.after(() => again.dispose()) + const reloaded = (await call('flybot/social/access', { botId: chief.id })).value + assert.equal(reloaded.grants.length, 1) + assert.equal(reloaded.grants[0].handle, '@chief.selfb.home') + assert.equal((await call('flybot/social/status', {})).value.acceptNotices.length, 1) + const dropped = (await call('flybot/social/accept-notice-dismiss', { id: notice.id })).value + assert.deepEqual(dropped.acceptNotices, []) + assert.deepEqual((await call('flybot/social/access', { botId: chief.id })).value.grants, []) +}) + +test('#1054: a grant list that throws still leaves the notice and the People-with-access row', async (t) => { + const { ctx, call, home, stop } = await team() + t.after(stop) + const server = mockServer({ friends: false }) + const log = [] + server.tools.list_inbox = (a) => { + const since = a.since_cursor ?? 0 + const events = log.filter(e => e.bot === a.bot_id && e.cursor > since) + return { events, next_cursor: events.at(-1)?.cursor ?? since } + } + server.tools.list_bot_grants = () => { throw new Error('Bot Social list_bot_grants: unavailable') } + server.tools.list_remote_bots = () => ({ + homes: [{ + relationship_id: 'r-join', + home: { bot_id: 'h2', handle: '@selfb.home', display_name: 'SelfB', owner_display_name: 'SelfB1025' }, + bots: [{ bot_id: 'x9', handle: '@chief.selfb.home', display_name: 'Chief', owner_display_name: 'SelfB1025', reply_only: true }], + }], + }) + const social = createSocial(ctx.flybot, { autoAccept: false, inboxPollMs: 0, settingsPath: join(home, 'flybot-social-settings.json') }, { client: createMcpClient({ server: 'https://s', token: 'owner-token', fetch: server.fetch }), home: { id: 'h1', handle: '@danyang.home' } }) + t.after(() => social.dispose()) + await grantTeam(ctx.flybot) + await social.registerAll() + const chief = ctx.flybot.bot.list().find(bot => bot.name === 'Chief') + const member = ctx.flybot.scope('social').fields.get(chief.id, 'member') + log.push(acceptJoinEvent(member, 1)) + await social.pollInbox() + const shown = (await call('flybot/social/remote-bots', { fresh: true })).value.homes.flatMap(entry => entry.bots).map(bot => bot.handle) + assert.equal(shown.includes('@chief.selfb.home'), false) + assert.equal((await call('flybot/social/status', {})).value.acceptNotices.length, 1) + const access = (await call('flybot/social/access', { botId: chief.id })).value + assert.equal(access.grants.some(grant => grant.handle === '@chief.selfb.home'), true) +}) + +test('#1054: a grant list that already names the joiner is not doubled, and a share-back shows their Bot', async (t) => { + const { ctx, call, home, stop } = await team() + t.after(stop) + const server = mockServer({ friends: false }) + const log = [] + const joiner = { bot_id: 'x9', handle: '@chief.selfb.home', display_name: 'Chief', owner_display_name: 'SelfB', reply_only: true } + let listed = [{ grant_id: 'g-join', grantee: { handle: '@chief.selfb.home', display_name: 'Chief', owner_display_name: 'SelfB' }, relationship_id: 'r-join', side: 'inviter', reply_only: false }] + let refuse = true + server.tools.list_inbox = (a) => { + const since = a.since_cursor ?? 0 + const events = log.filter(e => e.bot === a.bot_id && e.cursor > since) + return { events, next_cursor: events.at(-1)?.cursor ?? since } + } + server.tools.list_bot_grants = () => ({ grants: listed }) + server.tools.list_remote_bots = () => ({ + homes: [{ relationship_id: 'r-join', home: { bot_id: 'h2', handle: '@selfb.home', display_name: 'SelfB', owner_display_name: 'SelfB' }, bots: [joiner] }], + }) + const base = server.fetch + server.fetch = async (url, init) => { + const body = init?.body ? JSON.parse(init.body) : {} + if (body.params?.name === 'send_as_member' && refuse) { + server.calls.push({ url, name: body.params.name, args: body.params.arguments }) + return new Response(JSON.stringify(notSharedToolError(body.id)), { status: 200 }) + } + return base(url, init) + } + const social = createSocial(ctx.flybot, { autoAccept: false, inboxPollMs: 0, settingsPath: join(home, 'flybot-social-settings.json') }, { client: createMcpClient({ server: 'https://s', token: 'owner-token', fetch: server.fetch }), home: { id: 'h1', handle: '@danyang.home' } }) + t.after(() => social.dispose()) + await grantTeam(ctx.flybot) + await social.registerAll() + const chief = ctx.flybot.bot.list().find(bot => bot.name === 'Chief') + const member = ctx.flybot.scope('social').fields.get(chief.id, 'member') + const shown = async () => (await call('flybot/social/remote-bots', { fresh: true })).value.homes.flatMap(entry => entry.bots).map(bot => bot.handle) + log.push(acceptJoinEvent(member, 1)) + await social.pollInbox() + assert.deepEqual(await shown(), []) + const access = (await call('flybot/social/access', { botId: chief.id })).value + assert.equal(access.grants.length, 1, 'the listed grant and the accept notice are one row') + assert.equal(access.grants[0].handle, '@chief.selfb.home') + assert.equal((await call('flybot/social/status', {})).value.acceptNotices.length, 1) + joiner.reply_only = false + assert.deepEqual(await shown(), ['@chief.selfb.home'], 'they shared a Bot back') + assert.deepEqual((await call('flybot/social/status', {})).value.acceptNotices, []) + const before = server.calls.filter(c => c.name === 'send_as_member').length + const shared = (await call('flybot/social/ask-share-back', { botId: chief.id, to: '@chief.selfb.home', noticeId: 'a:r-join' })).value + assert.equal(shared.shared, true) + assert.equal(server.calls.filter(c => c.name === 'send_as_member').length, before, 'a full share is not sent a chat line') + assert.equal((await call('flybot/social/access', { botId: chief.id })).value.grants.length, 1) +}) + +test('#1054: the accept card asks them to share a Bot, and a notice that arrives after the first look is the one that toasts', async () => { + const notice = { id: 'a:r-join', botId: 'a', botName: 'Chief', from: '@chief.selfb.home', name: 'Chief Grant', owner: 'SelfB1025', asked: false } + const registered = [] + const notes = [] + const calls = [] + let status = { connected: true, shareRequests: [], acceptNotices: [notice], approvals: [], bots: [] } + const timers = [] + const sandbox = { + window: { __ModuleLoader__: { load: r => registered.push(r) } }, + setTimeout, clearTimeout, console, + setInterval: (fn, ms) => { timers.push({ fn, ms }); return { unref() {} } }, + clearInterval: () => {}, + document: { hidden: false, getElementById: () => ({}), createElement: () => ({}), head: { appendChild() {} } }, + } + sandbox.globalThis = sandbox + sandbox.__flybot__ = { + slots: { register: () => () => {} }, + call: async (endpoint, payload) => { + calls.push([endpoint, payload]) + if (endpoint === 'social/status') return status + if (endpoint === 'social/ask-share-back') return { asked: true, acceptNotices: [{ ...notice, asked: true }] } + if (endpoint === 'social/accept-notice-dismiss') return { acceptNotices: [] } + return {} + }, + } + runInNewContext(readFileSync(new URL('../client.js', import.meta.url), 'utf8'), sandbox) + const h = (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat() }) + const walk = (node, all = []) => { + if (!node || typeof node !== 'object') return all + if (typeof node.type === 'function') return walk(node.type(node.props), all) + all.push(node) + for (const child of node.children ?? []) walk(child, all) + return all + } + const plugin = registered[0].factory(() => ({ + createElement: h, Fragment: 'F', useEffect(fn) { fn?.() }, useState: v => [v, () => {}], useRef: v => ({ current: v }), useMemo: f => f(), + })) + const tree = walk(plugin.AcceptNotices({ + items: [notice], + onAsk: item => plugin && calls.push(['ask', item.id]), + onDismiss: item => calls.push(['dismiss', item.id]), + })) + const text = tree.find(n => n.props['data-flybot'] === 'accept-notice-text') + assert.equal(text.children.join(''), 'Chief Grant (SelfB1025) accepted your invite. They can message Chief. You cannot message them until they share a Bot with you.') + const buttons = tree.filter(n => n.type === 'button') + assert.deepEqual(buttons.map(n => n.children.join('')), ['Dismiss', 'Ask them to share a Bot']) + buttons[1].props.onClick() + buttons[0].props.onClick() + assert.deepEqual(calls.filter(c => c[0] === 'ask' || c[0] === 'dismiss'), [['ask', notice.id], ['dismiss', notice.id]]) + const askedTree = walk(plugin.AcceptNotices({ items: [{ ...notice, asked: true }] })) + assert.equal(askedTree.find(n => n.props['data-flybot'] === 'accept-notice-asked').children.join(''), 'You asked them to share a Bot.') + assert.equal(askedTree.find(n => n.props['data-flybot'] === 'accept-notice-ask').props.disabled, true) + calls.length = 0 + walk(plugin.FriendsBots({ actions: { notify: message => notes.push(message) }, roster: { bots: [] } })) + await new Promise(resolve => setTimeout(resolve, 20)) + assert.deepEqual(notes, [], 'a notice already there on the first look is not toasted again after a reload') + status = { ...status, acceptNotices: [notice, { ...notice, id: 'a:r-2', name: 'Writer', owner: '' }] } + timers.find(timer => timer.ms === 15_000).fn() + await new Promise(resolve => setTimeout(resolve, 20)) + assert.deepEqual(notes, ['Writer accepted your invite. They can message Chief. You cannot message them until they share a Bot with you.']) +}) + // #404 / owner decision 2026-10-19 18:05: Bot Social pauses a conversation (a loop between the Bots, or // an older turn budget), and send_as_member opens a fresh conversation for the pair's next message // (Birfy/bot-social src/platform.ts `sendAsMember` picks only open / awaiting_approval chats). So the first