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/995-access-one-row-per-bot.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**People with access** now lists one row per Bot instead of one per person, named `Bot (Owner)` and carrying that Bot's handle, so a friend who runs two Bots shows two rows and each **Revoke** takes back only the row it sits on — before this, one Revoke removed every grant that friend had ([#995](https://github.com/ThinkFlowLab/FlyBot/issues/995)).
6 changes: 3 additions & 3 deletions docs/subsystems/social.md

Large diffs are not rendered by default.

19 changes: 14 additions & 5 deletions plugins/social/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -2945,7 +2945,7 @@ window.__ModuleLoader__.load({
}

const ACCESS_POLL_MS = 5000
const ACCESS_NOTE = 'One row per person. Revoke anyone without affecting the rest. Default: nobody, Chief included.'
const ACCESS_NOTE = 'One row per Bot. Revoke one without affecting the rest. Default: nobody, Chief included.'
function ManageAccess({ bot, onGrants }) {
const [view, setView] = useState(null)
const [busy, setBusy] = useState('')
Expand Down Expand Up @@ -2994,6 +2994,8 @@ window.__ModuleLoader__.load({
finally { setBusy('') }
}
const grants = view?.grants ?? []
// One Auto-send switch per person, however many of their Bots are rows (#995).
const autoShown = new Set()
// A room the grant rows already named must not come back under "This room only" (#955).
const listedRoomIds = grants.flatMap(grant => roomList(grant.rooms).map(row => row.id))
const roomOnlyUsage = roomHint('only', (view?.rooms ?? []).filter(room => room.origin === 'face-to-face'), listedRoomIds)
Expand All @@ -3003,14 +3005,21 @@ window.__ModuleLoader__.load({
grants.length === 0 ? h('div', { className: 'fs-hint' }, 'Nobody has this Bot yet. Copy an invite link above and send it to a friend.') : null,
grants.map((grant) => {
const grantUsage = roomHint('use', grant.rooms)
// Auto-send is one setting for the pair (this Bot → that person), so a friend whose two Bots
// are two rows still gets one switch (#995), on the first of their rows.
const autoKey = grant.person || grant.home
const showAuto = Boolean(grant.home) && !autoShown.has(autoKey)
if (showAuto) autoShown.add(autoKey)
return h('div', { key: grant.id || grant.grantee, className: 'fs-line', 'data-flybot': 'access-grant' },
h('div', { className: 'fs-col' },
h('span', { className: 'fs-name' }, grant.name || grant.grantee),
grant.home && grant.name && !String(grant.name).includes(grant.home)
? h('span', { className: 'fs-sub' }, grant.home)
: null,
grant.handle && grant.handle !== grant.home
? h('span', { className: 'fs-sub' }, grant.handle)
: grant.home && grant.name && !String(grant.name).includes(grant.home)
? h('span', { className: 'fs-sub' }, grant.home)
: null,
grantUsage ? h('span', { className: 'fs-hint', title: grantUsage.title }, grantUsage.text) : null),
grant.home ? h('label', { className: 'fs-auto fs-access-auto', 'data-flybot': 'access-auto-send', title: `${bot.name || 'This Bot'}'s replies to ${grant.person || grant.home} go out without asking you first.` },
showAuto ? h('label', { className: 'fs-auto fs-access-auto', 'data-flybot': 'access-auto-send', title: `${bot.name || 'This Bot'}'s replies to ${grant.person || grant.home} go out without asking you first.` },
h('input', {
type: 'checkbox', checked: grant.autoSend === true, disabled: Boolean(busy),
'aria-label': `Auto-send ${bot.name || 'this Bot'} → ${grant.person || grant.home}`,
Expand Down
16 changes: 16 additions & 0 deletions plugins/social/src/mcp-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,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 46 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 @@ -85,7 +85,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 88 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 @@ -257,6 +257,18 @@
return { home, member, person, label }
}

// #995: a per-Bot grant names the Bot, so People with access can name it too. Bot Social sends the
// grantee as that Bot (handle + display_name + owner_display_name); a server that decorates the handle
// instead ("Chief@chief.bob.home") still carries the Bot name in front of it.
function grantBotNames(item, grantee) {
const source = [grantee, item?.bot, item?.peer].find(value => value && typeof value === 'object')
const pick = value => (typeof value === 'string' ? value.trim() : '')
const botName = pick(source?.display_name) || pick(source?.displayName)
|| (typeof grantee === 'string' ? (grantee.match(/^(.+?)\s*@[^@\s]+$/) ?? [])[1]?.trim() : '')
const ownerName = pick(source?.owner_display_name) || pick(source?.ownerName)
return { ...(botName ? { botName } : {}), ...(ownerName ? { ownerName } : {}) }
}

export function normalizeGrant(item) {
if (!item || typeof item !== 'object') return undefined
const grantee = item.grantee ?? item.peer ?? item.handle ?? item.home
Expand All @@ -269,13 +281,17 @@
const view = grantPersonView({ grantee, name })
const canonical = view.member || view.home || memberHandleOf(grantee)
if (!canonical) return undefined
// Only a grant that names a Bot (the member, not just the person's home) has a Bot name to show.
const perBot = Boolean(view.member && view.member !== view.home)
const names = perBot ? grantBotNames(item, grantee) : {}
return {
...(id ? { id: String(id) } : {}),
// Canonical @handle for core grants.set / normGrantee (not decorated Chief@…).
grantee: canonical,
...(view.label ? { name: view.label } : {}),
...(view.home ? { home: view.home } : {}),
...(view.member ? { member: view.member } : {}),
...names,
...(item.relationship_id || item.relationshipId ? { relationshipId: String(item.relationship_id ?? item.relationshipId) } : {}),
...(item.room_id || item.roomId ? { roomId: String(item.room_id ?? item.roomId) } : {}),
// Bot Social says which grants only let the grantee answer (the join's talk-through grant, bot-social
Expand Down
34 changes: 30 additions & 4 deletions plugins/social/src/social.js
Original file line number Diff line number Diff line change
Expand Up @@ -1364,6 +1364,10 @@ export function createSocial(service, config = {}, { client: given, api: givenAp
}
// Per Bot, the grant ids Bot Social confirmed as full shares (`reply_only: false`) at the last sync (#495).
const confirmedShares = new Map()
// Per Bot handle, the name Bot Social gave in the grant list. The host's grants store keeps only
// 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()
// `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.
Expand All @@ -1385,6 +1389,14 @@ export function createSocial(service, config = {}, { client: given, api: givenAp
// the sync as not fresh so a roster refresh withholds unseen Bots (fail closed); the revoke's
// own refresh lists again once it is done (Codex P1/P2 on #387).
if (revoking.get(bot.id) || grantEpoch(bot.id) !== epoch) { ok = false; return }
for (const grant of listed) {
const view = grantPersonView(grant)
const handle = norm(view.member)
if (!handle || handle === norm(view.home)) continue
const botName = typeof grant.botName === 'string' ? grant.botName : ''
const ownerName = typeof grant.ownerName === 'string' ? grant.ownerName : ''
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 view = grantPersonView(grant)
Expand Down Expand Up @@ -2501,34 +2513,48 @@ export function createSocial(service, config = {}, { client: given, api: givenAp
const entry = pausedChats.get(norm(handle))
return { paused: entry ? { reason: entry.reason, at: entry.at } : null }
}, { read: true }))
// People with access is one row per person (#383): two grants of one Bot to the same person
// (two invites, joined by two of their Bots) are one row, and its Revoke removes both.
// People with access is one row per grant (#383), and one row per Bot since sharing is per Bot
// (#995): a friend who runs two Bots used to be a single row, so one Revoke took both grants. A
// grant that names a Bot (the member, not the person's home) gets its own row, labelled the way the
// share list labels it (`Bot (Owner)`, #973), and its Revoke takes that grant alone. A grant that
// names only the person — and #387's unsure talk-through grant — keep the row they had.
function accessRows(botId, grants) {
const rows = new Map()
for (const grant of grants) {
const view = grantPersonView(grant)
const home = view.home || grant.home || (typeof grant.grantee === 'string' ? personHomeOf(grant.grantee) : '')
const grantee = home || view.member || (typeof grant.grantee === 'string' ? grant.grantee : '')
const member = norm(view.member)
const perBot = Boolean(member && member !== norm(home))
// A grant to a Bot shared with us, in a relationship not recorded as joined here, may be a
// talk-through grant from a join made before that record (#383): it keeps a row of its own, so
// Revoke on the person's row never takes it along and breaks that chat (Codex P1 on #387).
// A grant Bot Social confirmed as a full share is never unsure (#495).
const toInbound = Boolean(settings.inboundBots?.[memberHandleOf(grant.grantee)]) && !confirmedShares.get(botId)?.has(grant.id)
const unsure = toInbound && !(grant.relationshipId && settings.joinedRelationships?.[grant.relationshipId])
const key = unsure ? `grant:${grant.id}` : (norm(grantee) || String(grant.id))
const key = unsure ? `grant:${grant.id}` : perBot ? `bot:${member}` : (norm(grantee) || String(grant.id))
const rooms = roomsUsing(botId, view.member || grant.grantee)
const row = rows.get(key)
if (row) {
row.grantIds.push(grant.id)
for (const room of rooms) if (!row.rooms.some(seen => seen.id === room.id)) row.rooms.push(room)
continue
}
// The Bot's own name is known only from the grant list the sync just read (#995); without it the
// row still names the Bot by the handle the grant carries, never two identical person rows.
const named = perBot ? botNames.get(member) : undefined
const owner = named?.owner || view.person
const botLabel = named?.bot ? (owner && owner !== named.bot ? `${named.bot} (${owner})` : named.bot) : ''
const personLabel = view.label || (typeof grant.name === 'string' ? grant.name : '')
rows.set(key, {
id: grant.id,
grantIds: [grant.id],
grantee,
name: view.label || (typeof grant.name === 'string' ? grant.name : ''),
name: perBot
? botLabel || `${owner || member.replace(/^@/, '').replace(/\.home$/i, '')} (${member})`
: personLabel,
home,
...(perBot ? { handle: member } : {}),
// Auto-send for this pair (this Bot → that person), shown and changed on the row (#417).
...(home ? { person: view.person || home, autoSend: approvalFor(botId, home) === 'auto' } : {}),
relationshipId: grant.relationshipId,
Expand Down
Loading
Loading