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/863-promotion-keeps-name.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A room-only Bot Social identity that is promoted by a person grant keeps the owner-level name and home the share envelope reads, instead of dropping them and leaving the next group invitation saying `a friend` (with an empty `inviterHome`) until the process restarts. When the record never had either, promotion fills them from `whoami` right away ([#863](https://github.com/ThinkFlowLab/FlyBot/issues/863)).
2 changes: 1 addition & 1 deletion docs/subsystems/social.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers
1. Social code stays inside `plugins/social/`. Runtime sources (`index.js`, `client.js`, `src/`) import only files in that package, Node builtins, or declared dependencies (`ds-bot`). They must not reach `core/` or another plugin by relative path: that path is missing after `plugin add …&path:/plugins/social` (#153). Tests may import from `core/` for fixtures. `scripts/lint-plugins.mjs` fails the leak and packs the plugin then imports it.
2. Uses documented plugin APIs only.
3. A server policy pause (`conversation_paused`: loop suspected between the Bots, emergency stop, or an older server's turn budget) never surfaces as a bare send error: both send paths (the `bot-social` transport and the remote-chat sender) map it to *"Bot Social paused that chat (reason). Send your message again: it starts a new chat."* (structured: `code: 'conversation_paused'`, `paused`, `resend`), because `send_as_member` opens a fresh conversation for the pair's next message. It never points to the Bot Social website (`/ui`), which a FlyBot owner cannot sign in to, and never names Extend or turns (there is no turn limit, owner decision 2026-10-19, Bot Social #28; see below) and no other member Bot is tried for it. Other failures end in *Try again.*, never in a console pointer. Other `sendChat` failures are one short English line with a next step (reconnect, invite, try again) — never `Bot Social send_as_member: …`. A human's own message in a face-to-face or shared room to a friend's Bot that is already a member of that room is delivered as that human in the room (`onFaceLine` / room log); it is not signed or grant-checked as Chief (#143). Chief-originated `bot.send` / `message_friend_bot` to an ungranted friend Bot stays denied. The refusal says it the right way round and names the one setting that exists (#273): the sending Bot is not shared with that friend (*"Chief is not shared with that friend, so it cannot message their Bots. To allow it, share Chief with them in its settings → Sharing."*), or the target is not a Bot a friend shared with you; `message_friend_bot` tells the model to relay that reason as written.
4. Access is a per-Bot **grant** (#90, #138): default deny, Chief included. `register` / `registerAll` register a Bot when it is invited, when it joins an invite, when it already has a person-grant or a saved identity, or at face-to-face join (`forFace`, `roomOnly`). `pollOnce` reads inboxes of non-room-only members only. Friends' Bots is `list_remote_bots` (Bots granted **to me**), minus reverse-friends of invites I created and minus face-room-only remotes. Accepting **Sharing → Copy invite link** is the grant the other way; **Share with a friend** (`social/share-friends` + `social/share-with-friend`, via `grantBotToFriend` / `grant_bot_to_friend`) is the same grant for someone who is already a friend, with no invite code; both endpoints register the Bot's Bot Social identity on demand (`register(bot, { forInvite: true })`) when it has none yet, so the share list works on a Bot that was never invited or shared. Joining does not grant my talk-through Bot back. Join offers **Talk through** only when I have more than one Bot (default Chief; tooltip: not shared unless I invite them to it, #228). `social/access` + `social/revoke-grant` (via `mcp-client.js` `listGrants` / `revokeGrant`, preferring `list_bot_grants` / `revoke_bot_grant`) list people as `Name (@home)` and revoke one person. Core `reach()` / `flybot.grants` are the unbypassable gate. **Face-to-face join (#83 / #89):** bringing my own Bot by number is an implicit room-scoped grant. If it has no identity, join registers it room-only; friends' `list_remote_bots`, invites, polling and `reach()` outside the room stay denied. When that Bot leaves its last face room or the room closes, the plugin revokes the room-only identity (`remove_home_member`) and drops room-scoped remotes from Friends' Bots. A failed join that created a new room-only identity is revoked too. A later person-grant reuses the handle and drops `roomOnly`. Registration also records the owner-level `ownerName` and `home` on that member identity (#848) from one `whoami`: top-level `display_name` and `home`, else `connector_identity.bot_handles[0]`, else the `flybot-home` entry in `bots[]` (given the `@`), and `registerAll` fills them into an identity saved before this without registering it again; the group room-share envelope reads them to name the inviter.
4. Access is a per-Bot **grant** (#90, #138): default deny, Chief included. `register` / `registerAll` register a Bot when it is invited, when it joins an invite, when it already has a person-grant or a saved identity, or at face-to-face join (`forFace`, `roomOnly`). `pollOnce` reads inboxes of non-room-only members only. Friends' Bots is `list_remote_bots` (Bots granted **to me**), minus reverse-friends of invites I created and minus face-room-only remotes. Accepting **Sharing → Copy invite link** is the grant the other way; **Share with a friend** (`social/share-friends` + `social/share-with-friend`, via `grantBotToFriend` / `grant_bot_to_friend`) is the same grant for someone who is already a friend, with no invite code; both endpoints register the Bot's Bot Social identity on demand (`register(bot, { forInvite: true })`) when it has none yet, so the share list works on a Bot that was never invited or shared. Joining does not grant my talk-through Bot back. Join offers **Talk through** only when I have more than one Bot (default Chief; tooltip: not shared unless I invite them to it, #228). `social/access` + `social/revoke-grant` (via `mcp-client.js` `listGrants` / `revokeGrant`, preferring `list_bot_grants` / `revoke_bot_grant`) list people as `Name (@home)` and revoke one person. Core `reach()` / `flybot.grants` are the unbypassable gate. **Face-to-face join (#83 / #89):** bringing my own Bot by number is an implicit room-scoped grant. If it has no identity, join registers it room-only; friends' `list_remote_bots`, invites, polling and `reach()` outside the room stay denied. When that Bot leaves its last face room or the room closes, the plugin revokes the room-only identity (`remove_home_member`) and drops room-scoped remotes from Friends' Bots. A failed join that created a new room-only identity is revoked too. A later person-grant reuses the handle and drops `roomOnly`, keeping the stored `ownerName`/`home` and filling either from `whoami` when the room-only record never had it (#863). Registration also records the owner-level `ownerName` and `home` on that member identity (#848) from one `whoami`: top-level `display_name` and `home`, else `connector_identity.bot_handles[0]`, else the `flybot-home` entry in `bots[]` (given the `@`), and `registerAll` fills them into an identity saved before this without registering it again; the group room-share envelope reads them to name the inviter.
5. First start retries a taken home handle (`FlyBot`, `FlyBot2`, …) instead of failing for a YAML edit (#136). If every retry is still taken, the social UI never shows the host's `Unknown FlyBot endpoint social/…` message: `apply` registers the social UI endpoints with a friendly `Social is not running: <reason>` refusal and `flybot.warn` records the failure. The client also maps `bot/not-found` on `social/*` to that idle state. Add friend shows `You are @your.home` (#181).
6. Face-to-face join (#83, #92, #158) is a waiting room, not an instant chat: the owner picks local Bots, types the 6-digit number, then waits with the same code shown large until someone **Enter chat**s. Copy never says there is no preview. Each picker tile and each waiting-room member uses the host Bot avatar (`__flybot__.BotAvatar`), not a shared first-letter badge. The client calls `social/face-join` once per Bot with the same code and shows partial failures. If a chosen Bot has no identity, the plugin registers it first (see invariant 4); registration failures (`handle_taken`, rate limit, timeout) map to English copy and leave no leftover member. Failed join responses never leak `members` / `events` / `room_id`. Every FACE_ROOMS.md error code maps to English copy (`rate_limited` / `locked_out` include `retry_after`). The waiting room polls `social/face-rooms` (which refreshes from Bot Social, including `joinCode`) and draws one tile per person (#457: `FacePersonTile`, the person's name, `Me (you)` for the owner, with the Bots they brought underneath; the owner's tile has a tiny accent dot); **Leave** and **Enter chat** share the footer. **Enter chat** is `social/face-start` then `openSession(roomId)`, and the screen follows if someone else starts. Peer `room.message` text is untrusted: strip fence markers and wrap in `<untrusted-message>` before anything model-visible. Bot replies POST `/v1/face-rooms/{id}/messages`; `pending_approval` shows in the existing queue and in the chat (owner Approve / Reject). Owner-typed `FLYBOT-FACE-HUMAN` lines stay on that card until Approve / Always approve / Reject unless face **Auto-send** is on; the card and chat never show the raw envelope (#159). Face-room **Auto-send** is one switch per Bot of mine in the room (#457, `Auto-send Chief → room`, `social/face-auto { botId, on }`, kept in this home's `settings.faceAutoSend`, off by default): when that Bot's line comes back `pending_approval` and Bot Social's hold is the blanket `mvp.all_outbound_requires_approval` and nothing else (`holdIsBlanket`), the host approves it at once (`decide_approval`, the switch is the owner's consent), so the room sees it; a flagged line and other Bots' lines still wait for the owner. The older account-wide `auto_approve_face_rooms` (`whoami` / MCP `set_auto_approve`) reads as every switch on; the first per-Bot change keeps the others on here and turns the account-wide one off (a `confirmation_required` answer is retried with `confirm_code` in the same click; the UI never asks for a code). REST `/v1/me` is not used with the connector token. A friend's Bot in the room member list and waiting grid uses the host Bot avatar (handle-seeded when Bot Social sends no image). New members only receive events from their join point (`after` cursor; the server also floors it). Join does not grant the Bot to friends. The UI never says published. A number nobody else is using opens no room (#458): `social/face-join` without `start` answers `{ nobody: true, faceRoomId }` when the server's room holds only this home's Bots, and the host keeps that fresh candidate off the sidebar and Past rooms (an in-memory hold, dropped on the next poll after 2 minutes). The dialog shows "Nobody is using … yet." with **Cancel** (`social/face-leave { faceRoomId }` leaves it on Bot Social and releases a new room-only identity) and **Start a room** (`start: true` adopts the held room through `GET`, no second create). A room nobody from another home ever joined leaves no Past rooms entry and no kept transcript, whether the owner left it or the joining window ran out (the host then leaves it on Bot Social). The room stays open while two or more homes are in it and closes here only when this home is the last; the closed room's dock reads "… left the room. Room closed." with one **Close**. Leave and Close go back to the last non-face chat this browser showed (else the Main Bot) and close the details panel. Countdowns use the server's `expires_at` corrected by the Bot Social clock offset (from the `Date` header, applied only at 3 s or more) and catch up on `visibilitychange`. Join stays disabled until 6 digits, an outside click closes the dialog like Escape, and Enter chat steps aside while Leave asks. Core's sidebar leads a face-room row with its number and keeps face-room turns out of a Bot row's group-work line.
7. Every import in shipped sources stays inside the plugin package: an installed plugin cannot reach into other packages' files (a repo-relative `../..` into `core/` breaks the installed bundle).
Expand Down
28 changes: 24 additions & 4 deletions plugins/social/src/social.js
Original file line number Diff line number Diff line change
Expand Up @@ -655,12 +655,20 @@ export function createSocial(service, config = {}, { client: given, api: givenAp
...(ownerName ? { ownerName } : {}),
...(roomOnly ? { roomOnly: true } : {}),
})
function register(bot, { forFace = false, forInvite = false } = {}) {
async function register(bot, { forFace = false, forInvite = false } = {}) {
if (!bot) return Promise.resolve(undefined)
const existing = memberOf(bot.id)
if (existing) {
if (forInvite && existing.roomOnly) flybot.fields.set(bot.id, 'member', memberFields(existing))
return Promise.resolve(memberOf(bot.id))
// A person grant promotes a room-only identity (#863): keep the owner-level name and home the
// share envelope reads, and fill either one from whoami only when the record never had it.
if (forInvite && existing.roomOnly) {
const me = await meIdentity()
flybot.fields.set(bot.id, 'member', memberFields(existing, {
home: existing.home || home?.handle || me.home,
ownerName: existing.ownerName || me.ownerName,
}))
}
return memberOf(bot.id)
}
if (!forFace && !forInvite && !hasPersonGrant(bot.id)) return Promise.resolve(undefined)
if (!home || flybot.readOnly?.()) return Promise.resolve(undefined)
Expand Down Expand Up @@ -751,7 +759,19 @@ export function createSocial(service, config = {}, { client: given, api: givenAp
disposers.push(flybot.on?.('bot.grants', bot => {
if (!bot.granted) return
const member = memberOf(bot.id)
if (member?.roomOnly) flybot.fields.set(bot.id, 'member', { bot_id: member.bot_id, handle: member.handle })
if (member?.roomOnly) {
// Promoting out of room-only must not drop the owner-level name and home (#863): the share
// envelope reads them, and losing them left it saying "a friend" until the next restart.
void (async () => {
const me = await meIdentity()
flybot.fields.set(bot.id, 'member', memberFields(member, {
home: member.home || home?.handle || me.home,
ownerName: member.ownerName || me.ownerName,
}))
await register(bot, { forInvite: true })
})().catch(error => warn('register failed', error))
return
}
void register(bot, { forInvite: true }).catch(error => warn('register failed', error))
}) ?? (() => {}))
disposers.push(flybot.on('bot.removed', ({ bot }) => {
Expand Down
34 changes: 34 additions & 0 deletions plugins/social/test/social.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,40 @@ test('#848: a whoami that fails once is read again, so the name is not lost unti
assert.equal(scope.fields.get('a', 'member').home, '@selftesta2.home')
})

test('#863: a person grant promotes a room-only identity without dropping its name and home', async (t) => {
const { ctx, stop } = await team()
t.after(stop)
const server = mockServer()
// whoami would fill these in, so give it different answers: the record's own values must win.
server.tools.whoami = () => ({ kind: 'owner', display_name: 'Whoami Name', auto_approve_face_rooms: false, connector_identity: { bot_handles: ['@whoami.home'] } })
const social = createSocial(ctx.flybot, { autoAccept: false, inboxPollMs: 0 }, { client: createMcpClient({ server: 'https://s', token: 'owner-token', fetch: server.fetch }), home: { id: 'h1', handle: '@danyang.home' } })
t.after(() => social.dispose())
const scope = ctx.flybot.scope('social')
scope.fields.set('a', 'member', { bot_id: 'm1', handle: '@chief.danyang.home', home: '@alice.home', ownerName: 'Alice', roomOnly: true })
await ctx.flybot.grants.set('a', [{ grantee: '@bob.home' }])
await settle()
const member = scope.fields.get('a', 'member')
assert.equal(member.home, '@alice.home', 'the stored home survives the promotion')
assert.equal(member.ownerName, 'Alice', 'the stored owner name survives the promotion')
assert.equal(member.roomOnly, undefined, 'only roomOnly is dropped')
})

test('#863: promotion fills a name the room-only record never had, instead of waiting for a restart', async (t) => {
const { ctx, stop } = await team()
t.after(stop)
const server = mockServer()
server.tools.whoami = () => ({ kind: 'owner', display_name: 'SelfTest A2', auto_approve_face_rooms: false, connector_identity: { bot_handles: ['@selftesta2.home'] } })
const social = createSocial(ctx.flybot, { autoAccept: false, inboxPollMs: 0 }, { client: createMcpClient({ server: 'https://s', token: 'owner-token', fetch: server.fetch }), home: { id: 'h1', handle: '@danyang.home' } })
t.after(() => social.dispose())
const scope = ctx.flybot.scope('social')
scope.fields.set('a', 'member', { bot_id: 'm1', handle: '@chief.danyang.home', home: '@selftesta2.home', roomOnly: true })
await social.register(ctx.flybot.bot.get('a'), { forInvite: true })
const member = scope.fields.get('a', 'member')
assert.equal(member.ownerName, 'SelfTest A2', 'the owner name comes from whoami at promotion time')
assert.equal(member.home, '@selftesta2.home', 'the stored home is kept')
assert.equal(member.roomOnly, undefined, 'only roomOnly is dropped')
})

test('any local Bot can message any Bot of a friend home; the send is held, then signed as the member', async (t) => {
const { ctx, tool, stop } = await team()
t.after(stop)
Expand Down
Loading