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/495-share-reply-only.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**People with access** keeps listing a friend after the two of you joined each other's invite, so the owner can still see and **Revoke** that share on both homes; FlyBot now uses Bot Social's `reply_only` mark to tell a real share from a talk-through grant ([#495](https://github.com/ThinkFlowLab/FlyBot/issues/495)).
2 changes: 1 addition & 1 deletion docs/subsystems/social.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ pins the list. The authority for the list is Bot Social's README and `docs/FACE_

- **#136:** A taken `displayName` at onboard retries `Name2` … rather than refusing to start. Your handle is shown in Add friend (#181).
- **#137:** Owner-chosen Bot avatar/color is sent with MCP `update_bot_profile { bot_id?, display_name?, avatar?, color? }` (REST `PATCH /v1/bots/{id}/profile`) whenever a local Bot's look changes, on startup if not yet synced, and again for every new identity of that Bot (the synced record names the identity: a room-only identity is released after its last face room and the next room registers a new one, #182). `avatar` is a preset id, a single emoji, or `data:image/(png|jpeg|webp);base64` ≤ 64KiB; `color` is `#rrggbb`. Remote look is read from `list_remote_bots` / `list_friends` peer / `list_bot_grants` / face-room `members[]` (`avatar`, `avatar_kind`, `color`, `avatar_updated_at`) and shown in the main sidebar list, group member lists, waiting tiles, the picker, and chat headers. Cache key is `avatar_updated_at`; missing look falls back to the handle-seeded palette. What is sent is the look the owner's home draws (`shownLook` in `look.js`): a Bot with no stored shape or color gets the id-picked default of core `characters.js` (`defaultShapeOf` / `defaultColorOf`, kept equal to core by a test), so the other home shows that look and not a handle-seeded one; a stored image or emoji is sent as is, image before emoji before shape as `BotMark` draws them. A new identity gets its look before `registerOnce` returns, so a face-room join never shows peers a seeded look first (#182). In the waiting dialog my own Bot is found by its room record (`remote: false`, local id), the Bot list handle, or the `joinedAs` of this join (`mineBots`), and always uses the roster look (#182). Face-room events carry no look — `members[]` is the source. All fields are optional so an older Bot Social is unchanged.
- **#138 / #161 / #164:** Accepting a Helper invite grants Helper only. The joiner picks a talk-through Bot (not shared). Success copy is `{person} shared {Bot} with you.` Manage access lists `SelfB1935 (@selfb1935.home)`; a label `grantPersonView` already made keeps its display name when `syncOutgoingGrants` reads the normalized grant again (#275), and only a bare handle falls back to the lowercase home. `grantPersonView` / `normalizeGrant` read Bot Social's object-shaped `grantee` (`handle`, `owner_display_name`) and never `String()` it (`[object Object]` → `@objectobject`). Core grants store a canonical `@handle` (never decorated `Chief@…`). Friends' Bots drops reverse-friends of invites this home created (`outgoingPeerHandles`) and room-only remotes, but a handle the joiner explicitly received (`inboundBots[handle]`) always shows (#178): the talk-through grant on the joiner's own Bot names the shared Bot as grantee and must not hide it, nor appear in that Bot's People with access (`isTalkThroughGrant`: grantee in `inboundBots`, and either a relationship this home joined or, for joins from before that record, the home's first relationship with the Bot in `via`). `social/join-invite` records the joined relationship (`settings.joinedRelationships[rid]`), because Bot Social lists a friendship's relationships (`relationship_id` = the first, `relationship_ids` = all) without saying who joined, and both sides of a relationship hold a grant naming the other's Bot: matching any of `relationship_ids` would hide a real share when a Bot shared with us later joins our invite (#383: an already-shared Chief that joined the friend's Helper invite listed the friend twice; a join made before the record, in a second relationship, still shows twice until it is joined again). `social/access` returns **one row per person** (`accessRows`, keyed by the person's home): a row carries `grantIds`, and `social/revoke-grant` with `grantIds` revokes each of them, so two shares of one Bot to one person are one row and one Revoke. A grant to a Bot shared with us in a relationship not recorded as joined (possibly the talk-through side of an older join) keeps a row of its own and is never in another row's `grantIds`, so the person's Revoke cannot break that chat. Every revoke runs in a per-Bot fence: a `syncOutgoingGrants` whose `list_bot_grants` overlapped it does not store that list and resolves `false` (fail closed), and the roster refreshes once after the fence. The joined relationship and the offered Bot are recorded right after `join_invite`, before the owner-approval follow-up. Join copy names the person (`owner_display_name`), not the shared Bot. `social/join-invite` marks the offered Bot inbound **before** its first roster refresh (#228): the grant sync that refresh starts then already sees the talk-through grant as talk-through, so no core grant on the joiner's Bot names the inviter's Bot and the re-filter does not drop the shared Bot until a later refresh (it shows with the success toast). Bot Social's real `join_invite` answer has no `bot` field: `inviter` is the invited Bot itself (a member handle such as `@chief.bob.home`; `joined.bot`, `inviter.bot` and `offered_bot` are the older shapes, with `inviter` at the home). A member-level `inviter` counts as the offered Bot, so it is marked inbound before the refresh and the toast names it (#342; without it #303's awaited sync stored the server's talk-through grant on the joiner's Bot as the joiner sharing back, and the re-filter hid the inviter's Bot and listed the inviter in the joiner's People with access). A talk-through grant is never sharing: the joiner's Bots stay default-deny after a join. A Bot that appears on a roster this home already fetched and is not in `inboundBots` waits for the grant sync before the roster is published (#303; "already fetched" is the `rosterFetched` flag, so an empty roster of a home with no friends yet counts and the first friend's Bot waits too; the very first fetch does not wait, #92, as `registerAll` synced grants at start): the Bot that just joined our invite would otherwise be published for a moment, get a chat record on this home and be marked gone seconds later (the next load opened that dead chat). The awaited sync classifies talk-through grants against the fresh roster (`syncOutgoingGrants({ roster })`), not the previous `remote`, so a refresh that also brings another new Bot cannot store a talk-through grant as a core grant. `syncOutgoingGrants` resolves `false` when any Bot's grants could not be listed; the publish then fails closed: the unseen Bots are withheld from this publish and from the background re-filter (`withheldRemotes`), stay unseen for the next fetch (which syncs again), and appear only after a sync succeeds. Bots already seen and Bots shared with us never wait, so the usual refresh still re-filters in the background. A late grant re-filter of an older roster fetch never overwrites a newer one (fetch counter, not a timestamp). Fetches are numbered when they ask, not when they land (#224): a `list_remote_bots` answer that lands after a newer one (the background refresh `hide-remote` starts, a slow link) is dropped, so it cannot hide a re-shared Bot again; the grant re-filter applies the same answer again and does not count as a second absent fetch toward forgetting a local hide. An open **People with access** re-reads `social/access` every 5 s while visible, and `social/access` syncs only that Bot's grants (`syncOutgoingGrants({ only })`). Bot Social itself still records the talk-through side of the relationship as a grant on the joiner's Bot (`list_bot_grants`); FlyBot filters it on both sides (#161, #178). On upgrade, remotes already on `list_remote_bots` are seeded into `inboundBots` once so reciprocal friends are not dropped — except the Bot that joined our invite and face-room-only remotes. Face-room remotes leave Friends' Bots when the room is left; existing friend handles are not reclassified as room-only.
- **#138 / #161 / #164:** Accepting a Helper invite grants Helper only. The joiner picks a talk-through Bot (not shared). Success copy is `{person} shared {Bot} with you.` Manage access lists `SelfB1935 (@selfb1935.home)`; a label `grantPersonView` already made keeps its display name when `syncOutgoingGrants` reads the normalized grant again (#275), and only a bare handle falls back to the lowercase home. `grantPersonView` / `normalizeGrant` read Bot Social's object-shaped `grantee` (`handle`, `owner_display_name`) and never `String()` it (`[object Object]` → `@objectobject`). Core grants store a canonical `@handle` (never decorated `Chief@…`). Friends' Bots drops reverse-friends of invites this home created (`outgoingPeerHandles`) and room-only remotes, but a handle the joiner explicitly received (`inboundBots[handle]`) always shows (#178): the talk-through grant on the joiner's own Bot names the shared Bot as grantee and must not hide it, nor appear in that Bot's People with access (`isTalkThroughGrant`). Bot Social's `list_bot_grants` marks that grant `reply_only` (bot-social #23), which `normalizeGrant` keeps as `replyOnly` (with `side`); when present it is the only rule (#495): `replyOnly: true` is talk-through and `replyOnly: false` is always a share, so a share in a relationship both homes recorded as joined (two friends joining each other's invite, which Bot Social regrants into the first relationship) stays listed and revocable on both homes, and a confirmed share is never kept apart as a possible talk-through grant in `accessRows`. Only a server that does not send the field falls back to the guess: grantee in `inboundBots`, and either a relationship this home joined or, for joins from before that record, the home's first relationship with the Bot in `via`. `social/join-invite` records the joined relationship (`settings.joinedRelationships[rid]`), because Bot Social lists a friendship's relationships (`relationship_id` = the first, `relationship_ids` = all) without saying who joined, and both sides of a relationship hold a grant naming the other's Bot: matching any of `relationship_ids` would hide a real share when a Bot shared with us later joins our invite (#383: an already-shared Chief that joined the friend's Helper invite listed the friend twice; a join made before the record, in a second relationship, still shows twice until it is joined again). `social/access` returns **one row per person** (`accessRows`, keyed by the person's home): a row carries `grantIds`, and `social/revoke-grant` with `grantIds` revokes each of them, so two shares of one Bot to one person are one row and one Revoke. A grant to a Bot shared with us in a relationship not recorded as joined (possibly the talk-through side of an older join) keeps a row of its own and is never in another row's `grantIds`, so the person's Revoke cannot break that chat. Every revoke runs in a per-Bot fence: a `syncOutgoingGrants` whose `list_bot_grants` overlapped it does not store that list and resolves `false` (fail closed), and the roster refreshes once after the fence. The joined relationship and the offered Bot are recorded right after `join_invite`, before the owner-approval follow-up. Join copy names the person (`owner_display_name`), not the shared Bot. `social/join-invite` marks the offered Bot inbound **before** its first roster refresh (#228): the grant sync that refresh starts then already sees the talk-through grant as talk-through, so no core grant on the joiner's Bot names the inviter's Bot and the re-filter does not drop the shared Bot until a later refresh (it shows with the success toast). Bot Social's real `join_invite` answer has no `bot` field: `inviter` is the invited Bot itself (a member handle such as `@chief.bob.home`; `joined.bot`, `inviter.bot` and `offered_bot` are the older shapes, with `inviter` at the home). A member-level `inviter` counts as the offered Bot, so it is marked inbound before the refresh and the toast names it (#342; without it #303's awaited sync stored the server's talk-through grant on the joiner's Bot as the joiner sharing back, and the re-filter hid the inviter's Bot and listed the inviter in the joiner's People with access). A talk-through grant is never sharing: the joiner's Bots stay default-deny after a join. A Bot that appears on a roster this home already fetched and is not in `inboundBots` waits for the grant sync before the roster is published (#303; "already fetched" is the `rosterFetched` flag, so an empty roster of a home with no friends yet counts and the first friend's Bot waits too; the very first fetch does not wait, #92, as `registerAll` synced grants at start): the Bot that just joined our invite would otherwise be published for a moment, get a chat record on this home and be marked gone seconds later (the next load opened that dead chat). The awaited sync classifies talk-through grants against the fresh roster (`syncOutgoingGrants({ roster })`), not the previous `remote`, so a refresh that also brings another new Bot cannot store a talk-through grant as a core grant. `syncOutgoingGrants` resolves `false` when any Bot's grants could not be listed; the publish then fails closed: the unseen Bots are withheld from this publish and from the background re-filter (`withheldRemotes`), stay unseen for the next fetch (which syncs again), and appear only after a sync succeeds. Bots already seen and Bots shared with us never wait, so the usual refresh still re-filters in the background. A late grant re-filter of an older roster fetch never overwrites a newer one (fetch counter, not a timestamp). Fetches are numbered when they ask, not when they land (#224): a `list_remote_bots` answer that lands after a newer one (the background refresh `hide-remote` starts, a slow link) is dropped, so it cannot hide a re-shared Bot again; the grant re-filter applies the same answer again and does not count as a second absent fetch toward forgetting a local hide. An open **People with access** re-reads `social/access` every 5 s while visible, and `social/access` syncs only that Bot's grants (`syncOutgoingGrants({ only })`). Bot Social itself still records the talk-through side of the relationship as a grant on the joiner's Bot (`list_bot_grants`); FlyBot filters it on both sides (#161, #178). On upgrade, remotes already on `list_remote_bots` are seeded into `inboundBots` once so reciprocal friends are not dropped — except the Bot that joined our invite and face-room-only remotes. Face-room remotes leave Friends' Bots when the room is left; existing friend handles are not reclassified as room-only.
- **#139:** **Remove this Bot** asks once, inline in the friend Bot's settings (`Remove Helper? Bob can share it again.` · **Remove** · **Keep**, #230; never the browser `confirm()`), and the friend's **Auto-send** checkbox says what it covers (`Auto-send my Bots' replies to Bob` with a one-line hint, #417) and calls `decline_bot_grant { grant_id, bot_id }` (`DELETE /v1/remote-bots/{grantId}`) so the owner's Manage access drops it. `grant_not_found` is treated as already removed. An older server (404 on the tool/endpoint) falls back to the local hide. Re-joining after a decline may return `regranted: true` on the existing friendship; live access still answers `409 relationship_exists`; a spent invite is `410 invite_unavailable`. Copy stays English (“You're already friends…”, “This invite was already used.”). Re-joining an existing friendship unhides only that invite's Bot/peer home.
- **#140:** Auto-send defaults off. Accepting a friend request uses that relationship's peer home for Auto-send, not the first roster entry. The owner sees Approve / Reject in the chat, not “Waiting for the owner's approval.” Composer drafts are labelled You.
- **#136 (suffix):** Onboard retries truncate the sanitized handle body so `Name2` / `Name3` still change within the 32-character `homeHandleOf` limit.
Expand Down
4 changes: 4 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 @@ -262,6 +262,10 @@
...(view.member ? { member: view.member } : {}),
...(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
// #23) and which side of the relationship we are; FlyBot no longer has to guess (#495).
...(typeof (item.reply_only ?? item.replyOnly) === 'boolean' ? { replyOnly: item.reply_only ?? item.replyOnly } : {}),
...(item.side === 'joiner' || item.side === 'inviter' ? { side: item.side } : {}),
}
}

Expand Down
10 changes: 9 additions & 1 deletion plugins/social/src/social.js
Original file line number Diff line number Diff line change
Expand Up @@ -881,6 +881,10 @@ export function createSocial(service, config = {}, { client: given, api: givenAp
// relationship, the Bot is one this friendship covers, and the grantee is a Bot the friend
// shared with us. It is not us sharing that Bot (#178).
function isTalkThroughGrant(member, grant, homes = remote.homes) {
// Bot Social marks the talk-through grant `reply_only`; when it says so, that is the only rule, so a
// real share in a relationship both homes joined (a regrant through the second invite) stays listed
// and revocable (#495). The guess below is for servers that do not send the field.
if (typeof grant.replyOnly === 'boolean') return grant.replyOnly
const rid = grant.relationshipId
const grantee = memberHandleOf(grant.grantee)
if (!rid || !grantee || !member?.handle || !settings.inboundBots?.[grantee]) return false
Expand Down Expand Up @@ -1161,6 +1165,8 @@ export function createSocial(service, config = {}, { client: given, api: givenAp
else revoking.delete(botId)
}
}
// Per Bot, the grant ids Bot Social confirmed as full shares (`reply_only: false`) at the last sync (#495).
const confirmedShares = 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 Down Expand Up @@ -1198,6 +1204,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp
}
}).filter(Boolean)
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)
Expand Down Expand Up @@ -2047,7 +2054,8 @@ export function createSocial(service, config = {}, { client: given, api: givenAp
// 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).
const toInbound = Boolean(settings.inboundBots?.[memberHandleOf(grant.grantee)])
// 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 rooms = roomsUsing(botId, view.member || grant.grantee)
Expand Down
Loading
Loading