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/1128-share-removed-bot.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**Share with a friend** used to report success for a Bot the other home no longer runs. A row whose Bot is gone from the roster that home is still listed in now says so — `<Bot> was removed from that home. Sharing with it cannot reach anyone.` — and its **Share** is disabled; a click that still reaches the host is refused with `bot/removed-bot` and sends no grant, so the list stops claiming a share that would reach nobody. The row is judged on both the peer id and the handle (`list_remote_bots` entries carry both), and a home missing from the cached roster, or a roster read that failed or came back empty, decides nothing — it shares as before rather than guessing (#1128).
4 changes: 4 additions & 0 deletions docs/subsystems/social.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,10 @@ Creating a group opens its Session with zero turns, and the host draws no sessio

Settings → **Bot Social** lists friends and shares once the server is connected (`state: 'connected'`). Until then the page is only the server form: a missing, connecting, or failed server draws neither section. **Friends** reads `social/overview` and shows each accepted friend (name, handle), each incoming request (accept it in Add friend), and each outgoing request that is still waiting. A name is one line: controls and angle brackets are stripped and the text is capped at 80, so a value that skipped the host's `cleanText` still cannot break the row. A failed read says so and is not drawn as an empty list. **Add friend** closes Settings and opens the sidebar panel the **+** menu already opens (`openAddFriend`); that **+** row stays. **Sharing** lists this home's Bots from `social/status` (the roster's own Bots, never a friend's, when that read fails or comes back empty) with the Main Bot first. Each **People with access** button opens that Bot's settings (`openSettings('bots', id)`) and scrolls to `ManageAccess`, where Revoke stays; opening Bot settings any other way does not scroll. Tests: `plugins/social/test/social.test.js` (`#1074`).

### A share to a Bot the other home removed (#1128)

**Share with a friend** reads the roster it already paints from (`remoteBots()`), and a row whose Bot is no longer in it stays drawn with `removed: true` and the reason `<name> was removed from that home. Sharing with it cannot reach anyone.`; its **Share** is disabled, and a click that still reaches `social/share-with-friend` is refused with `bot/removed-bot` before any grant is sent (a `bot/` code is the only shape the channel passes through unchanged, `core/src/host/channel.js:24`, so the refusal reaches the panel as a code and not as `bot/refused`), so the panel never reports a share that reaches nobody. `removedOnRoster` decides it: both the row's `peerId` and its `handle` must be known (a home missing from the cached roster, or an empty `bots` list, decides nothing and shares as before rather than guessing), and a hit on either the id or the handle means the Bot is still there, because one id alone cannot tell an old server's renumbering from a removal. No extra round trip — it reads the roster the panel already paints from. Tests: `plugins/social/test/social.test.js` (`#1128`).

### Add friend invite input (#310)

`social/join-invite` runs the box text through `parseInviteInput` (`social.js`) before `join_invite`. It takes `https://host/i/CODE`, the same without the scheme, a link whose `/i/` is missing (host plus one bare token), a bare code, or the whole copied invite text (the first token with `/i/` or a URL wins), and sends only the code: Bot Social's `join_invite` looks up a code (it strips just a leading `…/i/`), so no host is ever built or added. Copy invite link hands out the server's `invite.url` through `inviteLink`, which rebuilds `origin/i/CODE` if a server ever answered a url without `/i/`; Bot Social itself always emits `/i/CODE` (`inviteUrl`) and serves only that route, so a link without `/i/` comes from hand-trimming or a client that dropped it. A pasted host that is not `config.server` is kept as `host`; a not-found then says the link may come from another server. `mapJoinInviteError` turns `invite_not_found`, expired / revoked / used invites, an own invite, `blocked` and `rate_limited` into one short sentence each ("This invite link wasn't found. It may be incomplete, expired, or already used. Ask your friend for a new link."); anything else passes through unchanged. The client (`plugins/social/client.js`) shows those sentences as written (#347): `errorText` relabels a message as "This invite was already used" only for the raw server words (`invite_unavailable`, `invite is used`, `invite_used`), never for the not-found sentence, which itself mentions "already used" as one possible cause; `run()` keeps its "… failed:" prefix for every other message and drops it only for the "already used" sentence; the neutral already-have sentence shows without error styling (#496).
Expand Down
12 changes: 11 additions & 1 deletion plugins/social/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -3464,13 +3464,18 @@ window.__ModuleLoader__.load({
// its busy flag, and what a fallback refresh removes. A friend without a Bot id keys on the person.
const friendKey = friend => friend?.peerBotId || friend?.handle || friend?.home || friend?.ownerId || 'share'
const share = async (friend) => {
// #1128: the button is disabled; a click that still arrives says why instead of posting a grant.
if (friend.removed === true) { setNote({ tone: 'error', text: friend.reason || 'That Bot was removed from that home.' }); return }
setBusy(friendKey(friend))
setNote(null)
try {
const result = await call('social/share-with-friend', {
botId: bot.id,
home: friend.home,
...(friend.peerBotId ? { friendBotId: friend.peerBotId } : {}),
// #1128: the row's handle goes with the click, so the host can tell "this Bot is gone from
// that home" (absent from the roster under both names) from an ordinary share.
...(friend.handle ? { friendHandle: friend.handle } : {}),
// #994: the receipt says who got it; the label the row already shows goes with the click.
...(friend.name ? { friendName: friend.name } : {}),
...(friend.ownerId ? { ownerId: friend.ownerId } : {}),
Expand Down Expand Up @@ -3498,11 +3503,16 @@ window.__ModuleLoader__.load({
h('span', { className: 'fs-name' }, friend.name || friend.handle || friend.home),
friend.handle || friend.home
? h('span', { className: 'fs-sub' }, friend.handle || friend.home)
: null,
friend.removed === true
? h('span', { className: 'fs-sub', 'data-flybot': 'share-friend-removed' }, friend.reason || 'That Bot was removed from that home.')
: null),
h('button', {
type: 'button',
className: 'fs-btn fs-btn-sm',
disabled: busy === friendKey(friend),
// #1128: a row whose Bot the friend's home no longer lists stays visible, with the reason, and
// is not clickable: the grant would reach nobody.
disabled: busy === friendKey(friend) || friend.removed === true,
'data-flybot': 'share-friend',
'aria-label': `Share ${bot.name || 'this Bot'} with ${friend.name || friend.home}`,
onClick: () => { void share(friend) },
Expand Down
37 changes: 36 additions & 1 deletion plugins/social/src/social.js
Original file line number Diff line number Diff line change
Expand Up @@ -3118,6 +3118,24 @@ export function createSocial(service, config = {}, { client: given, api: givenAp
// #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.
// #1128: the row names a Bot the friend's home no longer has — they removed it there — so a grant
// would name a Bot nothing can receive while the panel still printed "Shared …". The evidence is the
// roster this panel already reads (\`list_remote_bots\`): the Bot is absent from a home the cache holds,
// under both names we know (its Bot id and its handle). A home the cache does not hold, or a roster
// with no ids, is not evidence, so nothing here refuses on a guess.
function removedOnRoster(roster, homeKey, { peerId, handle } = {}) {
// Both names, or no answer: one id alone cannot be told from an older server that numbered its Bots
// differently, so a click without the row's handle is shared as before rather than refused.
if (!homeKey || !peerId || !handle) return false
const entry = (roster?.homes ?? []).find(item => item.home === norm(homeKey))
if (!entry) return false
const bots = entry.bots ?? []
if (!bots.length) return false
if (peerId && bots.some(bot => bot.botId && String(bot.botId) === String(peerId))) return false
if (handle && bots.some(bot => bot.handle && norm(bot.handle) === norm(handle))) return false
return true
}

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
Expand Down Expand Up @@ -3149,6 +3167,10 @@ export function createSocial(service, config = {}, { client: given, api: givenAp
grantsByHome.set(key, entry)
}
const overview = await friendsOverview()
// #1128: what the friends' homes list right now; \`remoteBots()\` is the panel's own cached read, so a
// warm cache is no round trip and a cold one is the fetch the panel needed anyway.
const roster = await remoteBots().catch(error => { warn('roster not read for the share list', error); return null })
const rowGone = (homeKey, item) => removedOnRoster(roster, homeKey, { peerId: item.peerBotId, handle: item.handle })
const friends = []
const seen = new Set()
for (const item of overview.friends ?? []) {
Expand All @@ -3171,9 +3193,13 @@ export function createSocial(service, config = {}, { client: given, api: givenAp
const label = botName && ownerName ? `${botName} (${ownerName})` : botName || ownerName
|| String(homeKey).replace(/^@/, '').replace(/\.home$/i, '')
|| homeKey
// One roster read for the whole list, and only when a row has a Bot to look up: it is the same
// cached lookup the roster draws from, so an ordinary share costs no round trip (#1128).
const gone = rowGone(homeKey, item)
friends.push({
home: homeKey,
name: label,
...(gone ? { removed: true, reason: `${label || 'That Bot'} was removed from that home. Sharing with it cannot reach anyone.` } : {}),
...(item.handle ? { handle: item.handle } : {}),
...(item.peerBotId ? { peerBotId: item.peerBotId } : {}),
...(item.ownerId ? { ownerId: item.ownerId } : {}),
Expand Down Expand Up @@ -3220,7 +3246,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp

// #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, friendBotId, peerBotId, friendName } = {}) => {
disposers.push(flybot.api.handle('social/share-with-friend', async ({ botId, home: friendHome, ownerId, friendOwnerId, friendBotId, peerBotId, friendName, friendHandle } = {}) => {
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')
Expand All @@ -3232,6 +3258,15 @@ export function createSocial(service, config = {}, { client: given, api: givenAp
// never listed for a friend.
const peerId = friendBotId || peerBotId
if (!homeKey && !oid && !peerId) throw new Error('Choose a friend to share with')
// #1128: a Bot the friend's home no longer lists cannot receive this grant. Refuse instead of printing
// a success line over a grant nothing can use, and name what happened rather than "try again".
if (peerId) {
const roster = await remoteBots().catch(error => { warn('roster not read before sharing', error); return null })
if (removedOnRoster(roster, homeKey, { peerId, handle: friendHandle })) {
const who = cleanText(friendName) || 'That Bot'
throw Object.assign(new Error(`${who} was removed from that home, so there is nothing there to share with. Ask them to add it back, then share again.`), { code: 'bot/removed-bot' })
}
}
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' })
}
Expand Down
Loading
Loading