From 0350ff9e62548c47a37b5203e818c67243124ee2 Mon Sep 17 00:00:00 2001 From: Birfy <15378933+Birfy@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:46:07 +0000 Subject: [PATCH 1/3] feat(rooms): Invite by link in the Members card, with its QR, and Join for a group link (#876) --- changelog.d/876-members-invite-link.added.md | 1 + core/client.js | 14 +- core/src/client/flybot-registry.js | 2 +- core/src/client/flybot-slots.js | 12 ++ core/src/client/group-settings.js | 3 + core/test/face-people.test.js | 2 +- core/test/face-person-first.test.js | 2 +- core/test/flybot-client.test.js | 2 +- core/test/group-bot-label.test.js | 2 +- core/test/room-members-slot.test.js | 60 +++++++ core/types/flybot.d.ts | 3 + docs/subsystems/core-client.md | 1 + docs/subsystems/plugin-runtime.md | 2 +- docs/subsystems/social.md | 2 +- patches/ds-bot/0008-client-bundle.patch | 109 +++++++------ patches/ds-bot/0012-room-membership.patch | 9 +- .../files/src/client/flybot-registry.js | 2 +- .../ds-bot/files/src/client/flybot-slots.js | 12 ++ patches/ds-bot/files/test/face-people.test.js | 2 +- .../files/test/face-person-first.test.js | 2 +- .../ds-bot/files/test/flybot-client.test.js | 2 +- .../ds-bot/files/test/group-bot-label.test.js | 2 +- .../files/test/room-members-slot.test.js | 60 +++++++ patches/ds-bot/files/types/flybot.d.ts | 3 + plugins/social/client.js | 152 +++++++++++++++++- plugins/social/test/social.test.js | 118 ++++++++++++-- 26 files changed, 496 insertions(+), 85 deletions(-) create mode 100644 changelog.d/876-members-invite-link.added.md create mode 100644 core/test/room-members-slot.test.js create mode 100644 patches/ds-bot/files/test/room-members-slot.test.js diff --git a/changelog.d/876-members-invite-link.added.md b/changelog.d/876-members-invite-link.added.md new file mode 100644 index 000000000..94844bacc --- /dev/null +++ b/changelog.d/876-members-invite-link.added.md @@ -0,0 +1 @@ +A local group’s Members card has an **Invite by link** row: make a link, show it as a QR code, copy it, watch its countdown, or withdraw it. Someone who is not a friend can paste or scan that link in the Face-to-face dialog, see the group first, and join it with the Bots they pick. diff --git a/core/client.js b/core/client.js index 2b707bb32..615fec9cd 100644 --- a/core/client.js +++ b/core/client.js @@ -1539,7 +1539,7 @@ function faceAliases(participant, name) { } // src/client/flybot-registry.js -var SLOTS = ["flybot.sidebar.section", "flybot.sidebar.structure", "flybot.composer.mention", "flybot.bot.details", "flybot.approvals", "flybot.room.overview", "flybot.session.dock", "flybot.new-chat.action", "flybot.account.header", "flybot.settings.page"]; +var SLOTS = ["flybot.sidebar.section", "flybot.sidebar.structure", "flybot.composer.mention", "flybot.bot.details", "flybot.approvals", "flybot.room.overview", "flybot.room.members", "flybot.session.dock", "flybot.new-chat.action", "flybot.account.header", "flybot.settings.page"]; function createRegistry() { const entries2 = new Map(SLOTS.map((name) => [name, []])); const listeners2 = /* @__PURE__ */ new Set(); @@ -2261,6 +2261,7 @@ var call2 = (endpoint, payload) => call(`flybot/${endpoint}`, payload); function registry2() { const slots = registry(); globalThis.__flybot__.call ??= call2; + globalThis.__flybot__.host ??= call; globalThis.__flybot__.BotMark ??= BotMark; globalThis.__flybot__.BotAvatar ??= BotAvatar; globalThis.__flybot__.lookOf ??= lookOf; @@ -2353,6 +2354,15 @@ function FlyBotRoomOverview({ room: room2, roster, actions }) { (0, import_react2.createElement)(Safe, { entry, props: { room: room2, roster, actions, call: call2 } }) ))); } +function FlyBotRoomMembers({ room: room2, roster, actions }) { + const entries2 = useSlot("flybot.room.members"); + if (entries2.length === 0) return null; + return (0, import_react2.createElement)(import_react2.Fragment, null, entries2.map((entry) => (0, import_react2.createElement)( + "div", + { key: entry.id, className: "bt-flybot-room-members", "data-flybot": entry.id }, + (0, import_react2.createElement)(Safe, { entry, props: { room: room2, roster, actions, call: call2, host: call } }) + ))); +} function FlyBotAccountHeader({ actions }) { const entries2 = useSlot("flybot.account.header"); if (entries2.length === 0) return null; @@ -12556,6 +12566,8 @@ function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) { )), (0, import_react30.createElement)("button", { key: "cancel", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(false) }, t("Cancel")) ] : outside.length + outsidePeople.length + outsideRemotes.length > 0 ? (0, import_react30.createElement)("button", { type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(true) }, (0, import_react30.createElement)(PlusIcon), t("Add member")) : null, + // #876: a plugin's rows for this room (Invite by link), only in a group that lives here. + room2.origin === "face-to-face" || room2.origin === "inbound-share" ? null : (0, import_react30.createElement)(FlyBotRoomMembers, { room: room2, roster, actions }), (0, import_react30.createElement)("div", { className: "bt-set-row bt-gs-note" }, (0, import_react30.createElement)( "span", { className: "bt-set-hint" }, diff --git a/core/src/client/flybot-registry.js b/core/src/client/flybot-registry.js index 68521d58a..3f88eef1f 100644 --- a/core/src/client/flybot-registry.js +++ b/core/src/client/flybot-registry.js @@ -1,6 +1,6 @@ // The FlyBot slot registry, free of React so the "@" menu and tests can use it. // See flybot-slots.js for what each slot carries. -export const SLOTS = ['flybot.sidebar.section', 'flybot.sidebar.structure', 'flybot.composer.mention', 'flybot.bot.details', 'flybot.approvals', 'flybot.room.overview', 'flybot.session.dock', 'flybot.new-chat.action', 'flybot.account.header', 'flybot.settings.page'] +export const SLOTS = ['flybot.sidebar.section', 'flybot.sidebar.structure', 'flybot.composer.mention', 'flybot.bot.details', 'flybot.approvals', 'flybot.room.overview', 'flybot.room.members', 'flybot.session.dock', 'flybot.new-chat.action', 'flybot.account.header', 'flybot.settings.page'] export function createRegistry() { const entries = new Map(SLOTS.map(name => [name, []])) diff --git a/core/src/client/flybot-slots.js b/core/src/client/flybot-slots.js index 44e1df97c..39268aabb 100644 --- a/core/src/client/flybot-slots.js +++ b/core/src/client/flybot-slots.js @@ -15,6 +15,8 @@ // `inline: true` with `policy`: instead, the buttons drawn on a held reply's own bubble in the // chat (#418), component({ held, decide, call, actions }); held = { id, by, status, to, toName, from } // flybot.room.overview { id, order, component({ room, roster, actions, call }) } top of a group's overview +// flybot.room.members { id, order, component({ room, roster, actions, call, host }) } rows at the foot of a local group's +// Members card (#876: Invite by link); `host(endpoint, payload)` calls a core endpoint, which `call` cannot // flybot.session.dock { id, order, policy?, component({ sessionId, roster, room, bot, actions, call, claim, t }) } above the composer; // a dock that draws the cards of one approval policy sets `policy` and calls claim(ids) with the // approval ids it shows: the overlay then draws no second card for them (#260) @@ -50,6 +52,8 @@ export const call = (endpoint, payload) => callHost(`flybot/${endpoint}`, payloa export function registry() { const slots = baseRegistry() globalThis.__flybot__.call ??= call + // A core endpoint by its own name (`room-invite-mint`), for a plugin row in a core card (#876). + globalThis.__flybot__.host ??= callHost // Classic-script plugins cannot import mark.js; they render the same Bot look // the sidebar / session list / chat header use via these host components. globalThis.__flybot__.BotMark ??= BotMark @@ -159,6 +163,14 @@ export function FlyBotRoomOverview({ room, roster, actions }) { h(Safe, { entry, props: { room, roster, actions, call } })))) } +// Rows a plugin adds at the foot of a local group's Members card (#876: Invite by link). +export function FlyBotRoomMembers({ room, roster, actions }) { + const entries = useSlot('flybot.room.members') + if (entries.length === 0) return null + return h(Fragment, null, entries.map(entry => h('div', { key: entry.id, className: 'bt-flybot-room-members', 'data-flybot': entry.id }, + h(Safe, { entry, props: { room, roster, actions, call, host: callHost } })))) +} + export function FlyBotAccountHeader({ actions }) { const entries = useSlot('flybot.account.header') if (entries.length === 0) return null diff --git a/core/src/client/group-settings.js b/core/src/client/group-settings.js index f92d8d2e7..8dd5ba865 100644 --- a/core/src/client/group-settings.js +++ b/core/src/client/group-settings.js @@ -7,6 +7,7 @@ import { Segmented } from './motion.js' import { FolderMenu } from './folder-menu.js' import { t } from './i18n.js' import { StandingApprovalsCard } from './standing-approvals.js' +import { FlyBotRoomMembers } from './flybot-slots.js' import { remoteBotTitle } from '../shared/remote-label.js' export const ROOM_MODES = [ @@ -205,6 +206,8 @@ export function GroupSettingsForm({ room, roster, actions, run, onDeleted }) { : outside.length + outsidePeople.length + outsideRemotes.length > 0 ? h('button', { type: 'button', className: 'bt-set-row bt-bs-row bt-gs-add', onClick: () => setAdding(true) }, h(PlusIcon), t('Add member')) : null, + // #876: a plugin's rows for this room (Invite by link), only in a group that lives here. + room.origin === 'face-to-face' || room.origin === 'inbound-share' ? null : h(FlyBotRoomMembers, { room, roster, actions }), h('div', { className: 'bt-set-row bt-gs-note' }, h('span', { className: 'bt-set-hint' }, // The host checks for two members on every change, even a rename. members.length < 2 ? t('Add a Bot first: a group chat needs at least two.') diff --git a/core/test/face-people.test.js b/core/test/face-people.test.js index 64a90cf08..d5c808a55 100644 --- a/core/test/face-people.test.js +++ b/core/test/face-people.test.js @@ -20,7 +20,7 @@ async function load(entry) { require: (name) => { const platform = { react: { - createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }), + Component: class {}, createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }), useState: initial => [typeof initial === 'function' ? initial() : initial, () => {}], useEffect: () => {}, useRef: current => ({ current }), useLayoutEffect: () => {}, useMemo: make => make(), useCallback: fn => fn, useSyncExternalStore: (sub, get) => get(), Fragment: 'fragment', }, diff --git a/core/test/face-person-first.test.js b/core/test/face-person-first.test.js index 4696ace12..d5b90c612 100644 --- a/core/test/face-person-first.test.js +++ b/core/test/face-person-first.test.js @@ -20,7 +20,7 @@ async function load(entry) { require: (name) => { const platform = { react: { - createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }), + Component: class {}, createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }), useState: initial => [typeof initial === 'function' ? initial() : initial, () => {}], useEffect: () => {}, useRef: current => ({ current }), useLayoutEffect: () => {}, useMemo: make => make(), useCallback: fn => fn, useSyncExternalStore: (sub, get) => get(), Fragment: 'fragment', }, diff --git a/core/test/flybot-client.test.js b/core/test/flybot-client.test.js index 6325b66de..f13e06c9e 100644 --- a/core/test/flybot-client.test.js +++ b/core/test/flybot-client.test.js @@ -15,7 +15,7 @@ test('the client registry orders entries, replaces by id, and notifies', () => { // The replaced entry stays: disposing the old one does not remove its successor. assert.deepEqual(slots.entries('flybot.sidebar.section').map(e => e.id), ['b', 'a']) assert.throws(() => slots.register('nope', { id: 'x' }), /unknown slot/) - assert.deepEqual(SLOTS, ['flybot.sidebar.section', 'flybot.sidebar.structure', 'flybot.composer.mention', 'flybot.bot.details', 'flybot.approvals', 'flybot.room.overview', 'flybot.session.dock', 'flybot.new-chat.action', 'flybot.account.header', 'flybot.settings.page']) + assert.deepEqual(SLOTS, ['flybot.sidebar.section', 'flybot.sidebar.structure', 'flybot.composer.mention', 'flybot.bot.details', 'flybot.approvals', 'flybot.room.overview', 'flybot.room.members', 'flybot.session.dock', 'flybot.new-chat.action', 'flybot.account.header', 'flybot.settings.page']) }) test('new-chat action rows sit after New group chat and skip a failing plugin', () => { diff --git a/core/test/group-bot-label.test.js b/core/test/group-bot-label.test.js index 8ac4529b9..08ae245fd 100644 --- a/core/test/group-bot-label.test.js +++ b/core/test/group-bot-label.test.js @@ -24,7 +24,7 @@ async function load(entry) { require: (name) => { const platform = { react: { - createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }), + Component: class {}, createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }), useState: initial => [typeof initial === 'function' ? initial() : initial, () => {}], useEffect: () => {}, useRef: current => ({ current }), useLayoutEffect: () => {}, useMemo: make => make(), useCallback: fn => fn, useSyncExternalStore: (sub, get) => get(), Fragment: 'fragment', }, diff --git a/core/test/room-members-slot.test.js b/core/test/room-members-slot.test.js new file mode 100644 index 000000000..20adcbe1f --- /dev/null +++ b/core/test/room-members-slot.test.js @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' + +// #876: the Members card of a group that lives here draws a plugin's rows (the social plugin's +// Invite by link), and hands them `host` for core endpoints, which the plugin's `call` cannot reach. + +async function loadSlots() { + const bundle = await build({ + entryPoints: [fileURLToPath(new URL('../src/client/flybot-slots.js', import.meta.url))], + bundle: true, format: 'cjs', platform: 'node', write: false, + external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'], + }) + const module = { exports: {} } + const sandbox = { + module, exports: module.exports, setTimeout, clearTimeout, console, + require: (name) => { + const platform = { + react: { + Component: class {}, createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }), + useState: initial => [typeof initial === 'function' ? initial() : initial, () => {}], useEffect: () => {}, useRef: current => ({ current }), + useLayoutEffect: () => {}, useMemo: make => make(), useCallback: fn => fn, useSyncExternalStore: (sub, get) => get(), Fragment: 'fragment', + }, + 'react-dom': { createPortal: node => node }, + '@deepseek-ai/dsh-client-ui-primitives': { MarkdownText: function MarkdownText() { return null }, projectUserText: text => text }, + } + if (!(name in platform)) throw new Error(`no platform module ${name}`) + return platform[name] + }, + } + sandbox.globalThis = sandbox + runInNewContext(bundle.outputFiles[0].text, sandbox) + return { ...module.exports, sandbox } +} + +test('#876: a plugin row in the Members card gets the room and a host call for core endpoints', async () => { + const { FlyBotRoomMembers, registry, sandbox } = await loadSlots() + const slots = registry() + assert.equal(typeof sandbox.__flybot__.host, 'function', 'the registry also carries the core call') + const Row = () => null + slots.register('flybot.room.members', { id: 'social-room-invite', order: 50, component: Row }) + const room = { id: 'r1', name: 'Trip plan' } + const tree = FlyBotRoomMembers({ room, roster: {}, actions: {} }) + const wrap = tree.children[0] + assert.equal(wrap.props['data-flybot'], 'social-room-invite') + const safe = wrap.children[0] + assert.equal(safe.props.entry.component, Row) + assert.equal(safe.props.props.room, room) + assert.equal(typeof safe.props.props.host, 'function') + assert.equal(typeof safe.props.props.call, 'function') +}) + +test('#876: the Members card draws the plugin rows only in a group that lives here', () => { + const source = readFileSync(new URL('../src/client/group-settings.js', import.meta.url), 'utf8') + assert.match(source, /room\.origin === 'face-to-face' \|\| room\.origin === 'inbound-share' \? null : h\(FlyBotRoomMembers, \{ room, roster, actions \}\)/, + 'a face room has its own invite card and a shared room is not ours to invite to') +}) diff --git a/core/types/flybot.d.ts b/core/types/flybot.d.ts index f178923c9..dc9cfaffb 100644 --- a/core/types/flybot.d.ts +++ b/core/types/flybot.d.ts @@ -544,6 +544,8 @@ export type FlyBotCall = (endpoint: string, payload?: unknown) => Promise * one middleware (`policy`). * - `flybot.room.overview`: a block at the top of a group's overview (Leave / Start for a * face-to-face room). + * - `flybot.room.members`: rows at the foot of a local group's Members card (the social plugin's + * **Invite by link**: mint, QR, copy, countdown, withdraw). `host` calls a core endpoint by its own name. * - `flybot.session.dock`: a card above the composer in a Bot or group chat (held Bot * Social replies waiting for approval). * - `flybot.account.header`: a compact block at the top of the account menu (the social @@ -570,6 +572,7 @@ export interface FlyBotSlots { /** policy: only approvals held by this middleware name; omit for all. */ 'flybot.approvals': { id: string; order?: number; policy?: string; component(props: ApprovalCardProps): any } 'flybot.room.overview': { id: string; order?: number; component(props: { room: any; roster: any; actions: any; call: FlyBotCall }): any } + 'flybot.room.members': { id: string; order?: number; component(props: { room: any; roster: any; actions: any; call: FlyBotCall; host: FlyBotCall }): any } 'flybot.session.dock': { id: string; order?: number; /** The `by` of the approvals this dock draws; the overlay then draws no second card for the ids it claims. */ policy?: string; component(props: { sessionId?: string; roster?: any; room?: any; bot?: any; actions?: any; call: FlyBotCall; /** Report the approval ids this dock shows (a plugin's own policy). */ claim?: (ids: string[]) => void }): any } 'flybot.account.header': { id: string; order?: number; component(props: { actions?: any; call: FlyBotCall }): any } 'flybot.settings.page': { diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 0d5c4227a..c1a4e8153 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -71,6 +71,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second ### Face-to-face room lead +- A local group's **Members · N** card (`group-settings.js`) ends with the plugin rows of slot `flybot.room.members` (`FlyBotRoomMembers` in `flybot-slots.js`), never in a face or shared (`inbound-share`) room; the rows get `host`, the core endpoint call (also `__flybot__.host`), for `room-invite` / `room-invite-mint` / `room-invite-revoke` (#876, the social plugin's **Invite by link**). Covered by `core/test/room-members-slot.test.js`. - The face room's details (`FaceMembers` in `details-drawer.js`) open with `FaceLead` (`group-settings.js`, #476): the current lead as `Bot (person)`, *Default* until the creator picked one. Only when `room.face.lead.canPick` (this home made the room and Bot Social stores a lead) does it show **Change**, a **Make lead** button per Bot of every home and **Use the default lead**; each calls `social/face-lead`. Everyone else sees it read-only. `FacePersonRow` marks the lead's Bot with a *Lead* tag (`isLeadSeat`, by handle). Never a chat command. ### Room avatars in face-to-face chats diff --git a/docs/subsystems/plugin-runtime.md b/docs/subsystems/plugin-runtime.md index 715d47a55..66906da63 100644 --- a/docs/subsystems/plugin-runtime.md +++ b/docs/subsystems/plugin-runtime.md @@ -58,7 +58,7 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots An `allow` verdict may say `autoSent: true` (with `recipient`) when it skips a hold only because the owner turned Auto-send on; the host then marks an auto-reply's turn Auto-sent (#417). It confers nothing. A `hold` verdict may carry `recipient`, who gets the message as the owner knows them (a person's name, trimmed, at most 120 characters); the record keeps it as `recipientName` and the cards name it (#418). A Bot's held sends are also visible to Bot coordination: `list_bots` tags the waiting Bot with `N to approve` (a count only, `src/host/tools.js`, #587). A `flybot.approvals` entry with `inline: true` and a `policy` is not an overlay card: it draws the decision on a held reply's own bubble in the chat (`held-reply.js`, props `{ held, decide, call, actions }`), and the overlay skips it. Approval claims (`claimApprovals`, `chatOpened`) live in `approval-claims.js` and are re-exported by `flybot-slots.js`. 7. `cleanRemote` is the record boundary for a friend's granted Bot: `name`, `homeName` and `owner` strip `@` (so they cannot look like a mention of another Bot in `nameMentions` chips/previews) and control characters/newlines, then cap at 60 characters. Handles stay on the `handle` field. Optional look fields (`color`, `avatar` object, `avatarUpdatedAt`) pass through when present so remote chats, group lists, and headers can render a peer's chosen look; missing look stays handle-seeded. Those labels then flow into delivery `fromName` (still `cleanName`'d and the body fenced) and into `list_bots` friends lines via the remote-chat roster. Group room logs use local team names, not peer labels. 8. A user's @ of a friend's Bot in a group is answered in that group (#825): the line reaches the Bot as a room line and its reply comes back as one, which is why `mentionReceipt` (`hooks.js`, relay role `mention`) posts only what went wrong — why the send was denied, held, or failed — and no "sent" line at all. The receipt's role stays outside the room round trigger (`groups.js` `opens`), so it never opens a round; peer names go through `cleanName`. A human line that names a friend's Bot already in that room is **not** 1:1-relayed as the group's admin (Chief): the room message is the delivery, so Chief's missing grant does not block it (#143). A Chief-originated `bot.send` to an ungranted friend Bot is still deny. A remaining grant deny on a mention that is *not* in the room says to invite the Bot or add it to the group. -9. `flybot.rooms` hosts a Bot Social face-to-face matching room as a native room (`upsertFace` / `applyFaceLine` / `ofFace` / `dropFace` / `onFaceLine` (`fn(room, line, { mentions })`: for the owner's typed line, the handles its `@` names, #293) / `postNotice` / `postHuman` (quiet; it reads the friend's line for `@` of a local Bot by label or handle and passes `callIds`, so only those Bots answer, #287; an optional `mentions` handle list from an attested line is resolved first, #293; `attested: true`, passed only for a line the server stamps `typed_by_owner`, lets a plain or `@all` line start this home's Bots in one capped pass, #295; a plain line is answered by the Bots that judge it theirs, `@all` is not routed, #299) / `postRemote`; `postHuman` draws another member's typed line as a chat bubble from that person, #180). Slot `flybot.room.overview` is a block at the top of a group's overview: while joining, a tile grid (avatar above name + owner) with **Leave** and **Enter chat** on one row; once open, compact **Leave** and Auto-send — not Open/Start/Leave together. Slot `flybot.new-chat.action` adds a row to the sidebar **+** short action menu (after New group chat; `label` / `hint` go through the same `t()` map). Still not team mode. `upsertFace` also takes `lead` (`{ handle, picked, canPick, supported }`, #476) and a member `role` label. +9. `flybot.rooms` hosts a Bot Social face-to-face matching room as a native room (`upsertFace` / `applyFaceLine` / `ofFace` / `dropFace` / `onFaceLine` (`fn(room, line, { mentions })`: for the owner's typed line, the handles its `@` names, #293) / `postNotice` / `postHuman` (quiet; it reads the friend's line for `@` of a local Bot by label or handle and passes `callIds`, so only those Bots answer, #287; an optional `mentions` handle list from an attested line is resolved first, #293; `attested: true`, passed only for a line the server stamps `typed_by_owner`, lets a plain or `@all` line start this home's Bots in one capped pass, #295; a plain line is answered by the Bots that judge it theirs, `@all` is not routed, #299) / `postRemote`; `postHuman` draws another member's typed line as a chat bubble from that person, #180). Slot `flybot.room.overview` is a block at the top of a group's overview: while joining, a tile grid (avatar above name + owner) with **Leave** and **Enter chat** on one row; once open, compact **Leave** and Auto-send — not Open/Start/Leave together. Slot `flybot.room.members` adds rows at the foot of a local group's Members card (not a face or shared room), with `host(endpoint, payload)` for core endpoints (#876). Slot `flybot.new-chat.action` adds a row to the sidebar **+** short action menu (after New group chat; `label` / `hint` go through the same `t()` map). Still not team mode. `upsertFace` also takes `lead` (`{ handle, picked, canPick, supported }`, #476) and a member `role` label. 10. Held sends are owner-bound (#52): a hold stamps server-derived provenance on the record — `requestId`, a sha256 `version` over the request content, `origin` (`via`/`hop`/audience) and the sender Bot's `ownerId`; message claims (`meta`, text) never confer authority. `flybot/decide` passes authz for any signed-in actor and hooks binds the decision to the sender Bot's owner's session; `release` revalidates and marks records `stale` (changed request, deleted sender/target Bot, changed owner — `deleteBot` invalidates eagerly; a demoted Chief's sender-side holds are invalidated eagerly on `setMain`, not inside `staleReason`). `release()` re-checks immediately before dispatch so an in-flight approve cannot go out after a concurrent invalidation. Unknown `invalidateFor` field values throw. The pending list is filtered per actor under member enforcement. The default approval card shows `senderName`/`targetName` and a delegated marker (`Approval.origin`). 11. Task lifecycle (#54): `bot.send` stamps `meta.taskId` from the working task (`rt.taskIdForSend`); a held send then calls `rt.taskHeld` (blocks **that** task). The owner's `flybot/decide` calls `rt.taskDecided` (approve resumes, reject cancels); `deleteBot` of a local Bot calls `rt.taskBotDeleted` (open tasks fail). Incoming holds stay the owner's. The state machine lives in `tasks.js`; hooks only fire those three seams. Clarification and `workBotId` wait on #55. 12. Activity feed (#241): plugins that want to see where team time goes subscribe through `flybot.on(...)` and never to raw DSH `session/event` (the maintainer decision on #241). Events: `turn.start` / `turn.end` (a Bot's own chat `place: 'chat'`, its hidden group Session `'group'`, the group's own Session `'room'`, whose turn is one round, or a friend's Bot chat `'remote'` (#248: its Session runs no model and only carries lines to and from the friend, so those turns are transport, not a team Bot's work; `remote: {handle, name}` names the friend as the sidebar does (`Helper · Bob's Bot`, `Bob's Chief`; a `{kind: 'remote'}` party carries the same `name` when the roster knows it), and the friend's line into the chat is an `exchange.delivered` with `role: 'remote'`)), `tool.start` / `tool.end` (paired by `callId`, closed `interrupted` when their turn ends first) and `exchange.delivered` (a message arrived in a Session's log: `exchangeId` as in `state.exchanges`, `replyTo` of the answer, `from` a user / Bot / group / friend's Bot). The feed is metadata only: message text, tool arguments, tool output and model output never leave `flybot-feed.js`, so a consumer that stores it cannot persist a secret. Strings from outside the team (tool names, remote handles) go through `cleanLabel` (which also removes `untrusted` fence markers when spaced out or split), error outcomes carry an upper-case code (`RATE_LIMIT`, else `UNKNOWN`) and no message. `turn.end` carries `model` (`provider/model`, cleaned) when the turn made a model call (#366): the model of the Session's latest `request/header` (DSH logs one only when it changes, so the last one seen applies; before the feed saw one, the Session's own `requestHeader()`). A turn with no step, and every `'remote'` turn, has none; `turn.start` never has one, since the model is known only once the first request goes out. Delivered means "in the recipient's log", not read; the feed cannot see read. Host nudges (retry, resume, deferred and re-sent deliveries) are not deliveries. Not replayed: a listener hears events after it subscribes. Listeners get copies and a throwing one cannot reach the Session or the other listeners. Events fire in a read-only host too, since they only observe. A friend's Bot in a feed entry is named as the sidebar names it, `Helper (Bob)` (`remoteTitle` → `remoteBotTitle`, #384); the approval overlay's recipient uses the same name. One event is not folded from a Session: `decision` (#396) is fired directly by `decisions.js` through `rt.fireFeed` when the host enables `config.decisions`, carrying `{ botId, option, choice, why?, time }` for one considered-and-rejected choice. Every new local Bot is announced with `flybot.on('bot.created', …)`: the first Main Bot onboarding creates (on the first `state` request) and a `duplicate-bot` copy included, so a plugin that adopts new Bots (the tree root) never misses one; `bot.removed` fires after a Bot is deleted. diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index e4daf2259..1beedb7dc 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -34,7 +34,7 @@ Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers | `plugins/social/src/face-rooms.js` | Face-to-face REST client (join/list/get/start/leave/messages, mint/revoke/preview/join invites, `parseInviteLink` / `parseFaceInviteLink`), error mapping, event rendering, peer-string sanitization. Auto-send is MCP in `social.js`. | | `plugins/social/src/presence.js` | "Name is thinking…" for friends' Bots (#297): sends `set_presence` for my Bots' turns that answer a friend or run in an open face room (`turn.start` / `turn.end`, heartbeat), polls `list_presence` only while a social chat asks (`social/typing`), clears a line when that Bot's message arrives. Gated on the `presence` feature. | | `plugins/social/src/face-title.js` | Face-to-face room titles (`Face-to-face · Alice, Bob · 482913`). Ships in the plugin so a packaged install can import (#153); core keeps its own copy for hosted rooms. | -| `plugins/social/client.js` | Settings → **Bot Social** (`SocialServerSettings`, slot `flybot.settings.page`); `PeerThinking` dock line (`social-thinking`, order 60: one `bt-shimmer` line `Name is thinking…`). Social strip: **N to approve** badge, Add friend / Face-to-face panels from +, Past rooms; sidebar + Face-to-face room / Add friend (own handle line, per-Bot **Copy invite link**); in-chat Approve / Reject dock, waiting `flybot.room.overview` tile grid (6-digit code and its QR, then Leave / Enter chat), open room **Invite by link** card (link QR, Copy link, Withdraw link); Bot settings Sharing (Copy invite link) / Share with a friend / People with access (inside the host's titled **Sharing** card, so `BotSocialDetails` draws no title of its own, #406), or Auto-send + Remove this Bot on a friend's Bot. | +| `plugins/social/client.js` | Settings → **Bot Social** (`SocialServerSettings`, slot `flybot.settings.page`); `PeerThinking` dock line (`social-thinking`, order 60: one `bt-shimmer` line `Name is thinking…`). Social strip: **N to approve** badge, Add friend / Face-to-face panels from +, Past rooms; sidebar + Face-to-face room / Add friend (own handle line, per-Bot **Copy invite link**); in-chat Approve / Reject dock, waiting `flybot.room.overview` tile grid (6-digit code and its QR, then Leave / Enter chat), open room **Invite by link** card (link QR, Copy link, Withdraw link); a local group's Members card **Invite by link** row (`LocalInviteRow`, slot `flybot.room.members`: Create link / New link, the link's QR, Copy link, m:ss countdown, Withdraw link, through the core `room-invite*` endpoints via `__flybot__.host`); the Face-to-face dialog's link field and scan also take a group link of another FlyBot (`/r/@handle/ticket`): `room-invite-preview` → the shared preview card (**Join group**) → `room-invite-ask` with the picked Bots, one sentence per refusal (#876); Bot settings Sharing (Copy invite link) / Share with a friend / People with access (inside the host's titled **Sharing** card, so `BotSocialDetails` draws no title of its own, #406), or Auto-send + Remove this Bot on a friend's Bot. | ## Entry points diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index a9cdede98..2f3b18ef5 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -819,7 +819,7 @@ diff --git a/client.js b/client.js +} + +// src/client/flybot-registry.js -+var SLOTS = ["flybot.sidebar.section", "flybot.sidebar.structure", "flybot.composer.mention", "flybot.bot.details", "flybot.approvals", "flybot.room.overview", "flybot.session.dock", "flybot.new-chat.action", "flybot.account.header", "flybot.settings.page"]; ++var SLOTS = ["flybot.sidebar.section", "flybot.sidebar.structure", "flybot.composer.mention", "flybot.bot.details", "flybot.approvals", "flybot.room.overview", "flybot.room.members", "flybot.session.dock", "flybot.new-chat.action", "flybot.account.header", "flybot.settings.page"]; +function createRegistry() { + const entries2 = new Map(SLOTS.map((name) => [name, []])); + const listeners2 = /* @__PURE__ */ new Set(); @@ -1089,7 +1089,7 @@ diff --git a/client.js b/client.js var maskBox = (size) => `maskUnits="userSpaceOnUse" x="${-size}" y="${-size}" width="${size * 3}" height="${size * 3}"`; var holes = (def, eyes) => `${eyes}`; var MASK_ID = "%MASK%"; -@@ -1242,162 +1242,610 @@ function maskOf(shape) { +@@ -1242,162 +1242,620 @@ function maskOf(shape) { } var DEFAULT_MASK = maskOf(FALLBACK_SHAPE); @@ -1565,6 +1565,7 @@ diff --git a/client.js b/client.js +function registry2() { + const slots = registry(); + globalThis.__flybot__.call ??= call2; ++ globalThis.__flybot__.host ??= call; + globalThis.__flybot__.BotMark ??= BotMark; + globalThis.__flybot__.BotAvatar ??= BotAvatar; + globalThis.__flybot__.lookOf ??= lookOf; @@ -1664,6 +1665,15 @@ diff --git a/client.js b/client.js + (0, import_react2.createElement)(Safe, { entry, props: { room: room2, roster, actions, call: call2 } }) + ))); +} ++function FlyBotRoomMembers({ room: room2, roster, actions }) { ++ const entries2 = useSlot("flybot.room.members"); ++ if (entries2.length === 0) return null; ++ return (0, import_react2.createElement)(import_react2.Fragment, null, entries2.map((entry) => (0, import_react2.createElement)( ++ "div", ++ { key: entry.id, className: "bt-flybot-room-members", "data-flybot": entry.id }, ++ (0, import_react2.createElement)(Safe, { entry, props: { room: room2, roster, actions, call: call2, host: call } }) ++ ))); ++} +function FlyBotAccountHeader({ actions }) { + const entries2 = useSlot("flybot.account.header"); + if (entries2.length === 0) return null; @@ -1984,7 +1994,7 @@ diff --git a/client.js b/client.js } return ""; } -@@ -1624,33 +1624,212 @@ function usageOf(list, ids) { +@@ -1624,32 +1624,211 @@ function usageOf(list, ids) { const hit = input > 0 ? Math.round(cacheRead / input * 100) : 0; return t("{tokens} tok · Cache hit {hit}%", { tokens: formatTokens(input + output), hit }); } @@ -2052,7 +2062,6 @@ diff --git a/client.js b/client.js -var SPRING_TRAIL = springEasing(289, 29.2); -var QUICK_EDGES = { lead: springEasing(900, 51), trail: springEasing(400, 34) }; -var spring = (curve, ...properties) => properties.map((property) => `${property} ${curve.duration}ms ${curve.easing}`).join(","); --function liveSprings(apply2) { +var EMPTY_ROSTER = { revision: -1, mainBotId: null, mainBotIds: [], bots: [], rooms: [], humans: [], self: null, agents: [], exchangeTurns: {}, remoteTypedTurns: {}, remoteAwaiting: {}, questions: {}, secrets: [], capabilities: [], capabilityStatuses: {}, secretRequests: {}, models: [], defaultModel: null, modelsKey: null, prefs: {}, byId: {}, roomsById: {}, agentsById: {}, agentOf: {}, ready: false }; +var isMainOf = (roster, id) => id !== void 0 && (roster.mainBotIds ?? []).includes(id); +function indexRoster(value) { @@ -2217,10 +2226,9 @@ diff --git a/client.js b/client.js +var SPRING_TRAIL = springEasing(289, 29.2); +var QUICK_EDGES = { lead: springEasing(900, 51), trail: springEasing(400, 34) }; +var spring = (curve, ...properties) => properties.map((property) => `${property} ${curve.duration}ms ${curve.easing}`).join(","); -+function liveSprings(apply2) { + function liveSprings(apply2) { const values = /* @__PURE__ */ new Map(); let frame = 0; - let last = 0; @@ -1659,12 +1659,12 @@ function liveSprings(apply2) { const dt = Math.min(0.5, Math.max(1e-3, (now - last) / 1e3)); last = now; @@ -2751,10 +2759,6 @@ diff --git a/client.js b/client.js } -function StarIcon({ size = 8 }) { - return (0, import_react2.createElement)( -- "svg", -- { width: size, height: size, viewBox: "0 0 10 10", "aria-hidden": true }, -- (0, import_react2.createElement)("path", { d: "M5 .6l1.3 2.8 3 .3-2.3 2 .7 3L5 7.2 2.3 8.7l.7-3L.7 3.7l3-.3z", fill: "#fff" }) -- ); +function installBrandChrome() { + if (typeof document === "undefined") return () => { + }; @@ -2780,21 +2784,35 @@ diff --git a/client.js b/client.js + observer?.disconnect(); + link.remove(); + }; - } --function AdminStar() { -- return (0, import_react2.createElement)("span", { className: "bt-admin-star", role: "img", "aria-label": t("Admin") }, (0, import_react2.createElement)(StarIcon, { size: 7 })); ++} + +// src/client/icons.js +var import_react4 = require("react"); +function PlusIcon() { + return (0, import_react4.createElement)("svg", { width: 18, height: 18, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react4.createElement)("path", { d: "M10 3.5v13M3.5 10h13", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); - } --function MainStar() { -- return (0, import_react2.createElement)( ++} +function SearchIcon() { + return (0, import_react4.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react4.createElement)("circle", { cx: 8.5, cy: 8.5, r: 5.5, stroke: "currentColor", strokeWidth: 1.6, fill: "none" }), (0, import_react4.createElement)("path", { d: "M13 13l4 4", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); +} +function PeopleIcon() { ++ return (0, import_react4.createElement)( + "svg", +- { width: size, height: size, viewBox: "0 0 10 10", "aria-hidden": true }, +- (0, import_react2.createElement)("path", { d: "M5 .6l1.3 2.8 3 .3-2.3 2 .7 3L5 7.2 2.3 8.7l.7-3L.7 3.7l3-.3z", fill: "#fff" }) ++ { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", "aria-hidden": true }, ++ (0, import_react4.createElement)("circle", { cx: 7.5, cy: 7, r: 3 }), ++ (0, import_react4.createElement)("path", { d: "M2 16.5c.6-2.8 2.8-4.5 5.5-4.5s4.9 1.7 5.5 4.5" }), ++ (0, import_react4.createElement)("path", { d: "M13 4.3a3 3 0 0 1 0 5.4M15 12.4c1.6.6 2.6 2 3 4.1" }) + ); + } +-function AdminStar() { +- return (0, import_react2.createElement)("span", { className: "bt-admin-star", role: "img", "aria-label": t("Admin") }, (0, import_react2.createElement)(StarIcon, { size: 7 })); ++function ArrowUpIcon() { ++ return (0, import_react4.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react4.createElement)("path", { d: "M10 16V4M5 9l5-5 5 5", stroke: "currentColor", strokeWidth: 1.8, fill: "none", strokeLinecap: "round", strokeLinejoin: "round" })); + } +-function MainStar() { +- return (0, import_react2.createElement)( ++function MicIcon({ size = 16 }) { + return (0, import_react4.createElement)( "svg", - { viewBox: "0 0 20 20", "aria-hidden": true }, @@ -2891,23 +2909,18 @@ diff --git a/client.js b/client.js - (0, import_react3.createElement)("circle", { cx: 7.5, cy: 7, r: 3 }), - (0, import_react3.createElement)("path", { d: "M2 16.5c.6-2.8 2.8-4.5 5.5-4.5s4.9 1.7 5.5 4.5" }), - (0, import_react3.createElement)("path", { d: "M13 4.3a3 3 0 0 1 0 5.4M15 12.4c1.6.6 2.6 2 3 4.1" }) -+ { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", "aria-hidden": true }, -+ (0, import_react4.createElement)("circle", { cx: 7.5, cy: 7, r: 3 }), -+ (0, import_react4.createElement)("path", { d: "M2 16.5c.6-2.8 2.8-4.5 5.5-4.5s4.9 1.7 5.5 4.5" }), -+ (0, import_react4.createElement)("path", { d: "M13 4.3a3 3 0 0 1 0 5.4M15 12.4c1.6.6 2.6 2 3 4.1" }) - ); - } - function ArrowUpIcon() { +- ); +-} +-function ArrowUpIcon() { - return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M10 16V4M5 9l5-5 5 5", stroke: "currentColor", strokeWidth: 1.8, fill: "none", strokeLinecap: "round", strokeLinejoin: "round" })); -+ return (0, import_react4.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react4.createElement)("path", { d: "M10 16V4M5 9l5-5 5 5", stroke: "currentColor", strokeWidth: 1.8, fill: "none", strokeLinecap: "round", strokeLinejoin: "round" })); - } - function MicIcon({ size = 16 }) { +-} +-function MicIcon({ size = 16 }) { - return (0, import_react3.createElement)( -+ return (0, import_react4.createElement)( - "svg", - { width: size, height: size, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", "aria-hidden": true }, +- "svg", +- { width: size, height: size, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", "aria-hidden": true }, - (0, import_react3.createElement)("rect", { x: 7, y: 2.5, width: 6, height: 10, rx: 3, fill: "currentColor", stroke: "none" }), - (0, import_react3.createElement)("path", { d: "M4.5 9.5a5.5 5.5 0 0 0 11 0M10 15v2.5" }) ++ { width: size, height: size, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", "aria-hidden": true }, + (0, import_react4.createElement)("rect", { x: 7, y: 2.5, width: 6, height: 10, rx: 3, fill: "currentColor", stroke: "none" }), + (0, import_react4.createElement)("path", { d: "M4.5 9.5a5.5 5.5 0 0 0 11 0M10 15v2.5" }) ); @@ -3990,7 +4003,7 @@ diff --git a/client.js b/client.js key: relay.seq, prefix: source.role === "stopped" ? t("Stopped by") : source.role === "reply" ? t("Reply from") : t("Message from"), bots: [sender], -@@ -3940,79 +3940,248 @@ function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, use +@@ -3940,64 +3940,233 @@ function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, use whole: true }); }), @@ -4193,29 +4206,22 @@ diff --git a/client.js b/client.js - { className: "bt-event" }, - (0, import_react6.createElement)("span", null, prefix), - room2 ? (0, import_react6.createElement)("button", { type: "button", onClick: () => actions.openSession(room2.id) }, (0, import_react6.createElement)(RoomAvatar, { room: room2, roster, size: 16 }), room2.name) : name ? (0, import_react6.createElement)("span", null, name) : null +- ); +function FirstContact({ source, roster, sessionId }) { + const person = typeof source.remote.typedBy === "string" ? source.remote.typedBy : ""; + const who = person || remoteSpeaker(source, roster).name; + const bot = roster.byId[sessionId]?.name ?? t("your Bot"); + return (0, import_react8.createElement)("div", { className: "bt-event", "data-first-contact": source.remote.handle }, (0, import_react8.createElement)("span", null, t("{name} is talking to {bot} through Bot Social", { name: who, bot }))); -+} + } +-function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { +function GlobeMark() { + return (0, import_react8.createElement)( + "svg", + { width: 10, height: 10, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", "aria-hidden": true }, + (0, import_react8.createElement)("circle", { cx: 12, cy: 12, r: 9 }), + (0, import_react8.createElement)("path", { d: "M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" }) - ); - } --function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { -- if (!/^(Saved to|Updated in|Removed from) /.test(result)) return null; -- const self = roster.byId[sessionId]; -- const team = /^(team|团队|共享)$/i.test(String(scope ?? "").trim()); -- const other = team || !scope ? void 0 : findBot(scope); -- const target = other && other.id !== self?.id ? other : self; -- if (!target) return null; -- const label = team ? t("Team memory updated") : target === self ? t("Memory updated") : t("Updated {name}'s memory", { name: target.name }); -- return (0, import_react6.createElement)( ++ ); ++} +function RemoteFiles({ files, plugin, botId }) { + const [busy, setBusy] = (0, import_react8.useState)(""); + const [error, setError] = (0, import_react8.useState)(""); @@ -4256,13 +4262,14 @@ diff --git a/client.js b/client.js + ); +} +function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { -+ if (!/^(Saved to|Updated in|Removed from) /.test(result)) return null; -+ const self = roster.byId[sessionId]; -+ const team = /^(team|团队|共享)$/i.test(String(scope ?? "").trim()); -+ const other = team || !scope ? void 0 : findBot(scope); -+ const target = other && other.id !== self?.id ? other : self; -+ if (!target) return null; -+ const label = team ? t("Team memory updated") : target === self ? t("Memory updated") : t("Updated {name}'s memory", { name: target.name }); + if (!/^(Saved to|Updated in|Removed from) /.test(result)) return null; + const self = roster.byId[sessionId]; + const team = /^(team|团队|共享)$/i.test(String(scope ?? "").trim()); +@@ -4005,14 +4005,14 @@ function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { + const target = other && other.id !== self?.id ? other : self; + if (!target) return null; + const label = team ? t("Team memory updated") : target === self ? t("Memory updated") : t("Updated {name}'s memory", { name: target.name }); +- return (0, import_react6.createElement)( + return (0, import_react8.createElement)( "div", { className: "bt-event" }, @@ -10629,7 +10636,7 @@ diff --git a/client.js b/client.js key: id, type: "button", "aria-pressed": mode === id, -@@ -8999,86 +8999,136 @@ function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) { +@@ -8999,86 +8999,138 @@ function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) { if (mode !== id) void update({ mode: id }); } }, t(label)))), @@ -10749,6 +10756,8 @@ diff --git a/client.js b/client.js - (0, import_react23.createElement)("div", { className: "bt-set-row bt-gs-note" }, (0, import_react23.createElement)( + (0, import_react30.createElement)("button", { key: "cancel", type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(false) }, t("Cancel")) + ] : outside.length + outsidePeople.length + outsideRemotes.length > 0 ? (0, import_react30.createElement)("button", { type: "button", className: "bt-set-row bt-bs-row bt-gs-add", onClick: () => setAdding(true) }, (0, import_react30.createElement)(PlusIcon), t("Add member")) : null, ++ // #876: a plugin's rows for this room (Invite by link), only in a group that lives here. ++ room2.origin === "face-to-face" || room2.origin === "inbound-share" ? null : (0, import_react30.createElement)(FlyBotRoomMembers, { room: room2, roster, actions }), + (0, import_react30.createElement)("div", { className: "bt-set-row bt-gs-note" }, (0, import_react30.createElement)( "span", { className: "bt-set-hint" }, diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index f8c1bd6c2..61b6aa740 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -279,7 +279,7 @@ diff --git a/src/client/details-drawer.js b/src/client/details-drawer.js diff --git a/src/client/group-settings.js b/src/client/group-settings.js --- a/src/client/group-settings.js +++ b/src/client/group-settings.js -@@ -1,11 +1,13 @@ +@@ -1,11 +1,14 @@ import { createElement as h, useEffect, useState } from 'react' import { isMainOf } from './sources.js' import { PlusIcon, ChevronRightIcon } from './icons.js' @@ -290,6 +290,7 @@ diff --git a/src/client/group-settings.js b/src/client/group-settings.js import { FolderMenu } from './folder-menu.js' import { t } from './i18n.js' +import { StandingApprovalsCard } from './standing-approvals.js' ++import { FlyBotRoomMembers } from './flybot-slots.js' +import { remoteBotTitle } from '../shared/remote-label.js' export const ROOM_MODES = [ @@ -402,7 +403,7 @@ diff --git a/src/client/group-settings.js b/src/client/group-settings.js const admin = roster.byId[room.admin] const mode = shownMode(room, roster) const modeHint = ROOM_MODES.find(([id]) => id === mode)?.[2] ?? '' -@@ -65,26 +65,52 @@ export function GroupSettingsForm({ room, roster, actions, run, onDeleted }) { +@@ -65,37 +65,66 @@ export function GroupSettingsForm({ room, roster, actions, run, onDeleted }) { h('div', { className: 'bt-set-row bt-bs-row' }, h('span', { className: 'bt-set-copy' }, h('span', { className: 'bt-bs-label' }, t('Folder'))), h(FolderMenu, { value: room.workDir, group: true, actions, run, onPick: workDir => update({ workDir }) }))), @@ -459,8 +460,10 @@ diff --git a/src/client/group-settings.js b/src/client/group-settings.js + : outside.length + outsidePeople.length + outsideRemotes.length > 0 ? h('button', { type: 'button', className: 'bt-set-row bt-bs-row bt-gs-add', onClick: () => setAdding(true) }, h(PlusIcon), t('Add member')) : null, ++ // #876: a plugin's rows for this room (Invite by link), only in a group that lives here. ++ room.origin === 'face-to-face' || room.origin === 'inbound-share' ? null : h(FlyBotRoomMembers, { room, roster, actions }), h('div', { className: 'bt-set-row bt-gs-note' }, h('span', { className: 'bt-set-hint' }, -@@ -92,10 +92,11 @@ export function GroupSettingsForm({ room, roster, actions, run, onDeleted }) { + // The host checks for two members on every change, even a rename. members.length < 2 ? t('Add a Bot first: a group chat needs at least two.') : admin ? t('{name} leads this group: it can call members with @Name, change the group, and post here.', { name: admin.name }) : t('No admin yet. An admin can call members with @Name, change the group, and post here.')))), diff --git a/patches/ds-bot/files/src/client/flybot-registry.js b/patches/ds-bot/files/src/client/flybot-registry.js index 68521d58a..3f88eef1f 100644 --- a/patches/ds-bot/files/src/client/flybot-registry.js +++ b/patches/ds-bot/files/src/client/flybot-registry.js @@ -1,6 +1,6 @@ // The FlyBot slot registry, free of React so the "@" menu and tests can use it. // See flybot-slots.js for what each slot carries. -export const SLOTS = ['flybot.sidebar.section', 'flybot.sidebar.structure', 'flybot.composer.mention', 'flybot.bot.details', 'flybot.approvals', 'flybot.room.overview', 'flybot.session.dock', 'flybot.new-chat.action', 'flybot.account.header', 'flybot.settings.page'] +export const SLOTS = ['flybot.sidebar.section', 'flybot.sidebar.structure', 'flybot.composer.mention', 'flybot.bot.details', 'flybot.approvals', 'flybot.room.overview', 'flybot.room.members', 'flybot.session.dock', 'flybot.new-chat.action', 'flybot.account.header', 'flybot.settings.page'] export function createRegistry() { const entries = new Map(SLOTS.map(name => [name, []])) diff --git a/patches/ds-bot/files/src/client/flybot-slots.js b/patches/ds-bot/files/src/client/flybot-slots.js index 44e1df97c..39268aabb 100644 --- a/patches/ds-bot/files/src/client/flybot-slots.js +++ b/patches/ds-bot/files/src/client/flybot-slots.js @@ -15,6 +15,8 @@ // `inline: true` with `policy`: instead, the buttons drawn on a held reply's own bubble in the // chat (#418), component({ held, decide, call, actions }); held = { id, by, status, to, toName, from } // flybot.room.overview { id, order, component({ room, roster, actions, call }) } top of a group's overview +// flybot.room.members { id, order, component({ room, roster, actions, call, host }) } rows at the foot of a local group's +// Members card (#876: Invite by link); `host(endpoint, payload)` calls a core endpoint, which `call` cannot // flybot.session.dock { id, order, policy?, component({ sessionId, roster, room, bot, actions, call, claim, t }) } above the composer; // a dock that draws the cards of one approval policy sets `policy` and calls claim(ids) with the // approval ids it shows: the overlay then draws no second card for them (#260) @@ -50,6 +52,8 @@ export const call = (endpoint, payload) => callHost(`flybot/${endpoint}`, payloa export function registry() { const slots = baseRegistry() globalThis.__flybot__.call ??= call + // A core endpoint by its own name (`room-invite-mint`), for a plugin row in a core card (#876). + globalThis.__flybot__.host ??= callHost // Classic-script plugins cannot import mark.js; they render the same Bot look // the sidebar / session list / chat header use via these host components. globalThis.__flybot__.BotMark ??= BotMark @@ -159,6 +163,14 @@ export function FlyBotRoomOverview({ room, roster, actions }) { h(Safe, { entry, props: { room, roster, actions, call } })))) } +// Rows a plugin adds at the foot of a local group's Members card (#876: Invite by link). +export function FlyBotRoomMembers({ room, roster, actions }) { + const entries = useSlot('flybot.room.members') + if (entries.length === 0) return null + return h(Fragment, null, entries.map(entry => h('div', { key: entry.id, className: 'bt-flybot-room-members', 'data-flybot': entry.id }, + h(Safe, { entry, props: { room, roster, actions, call, host: callHost } })))) +} + export function FlyBotAccountHeader({ actions }) { const entries = useSlot('flybot.account.header') if (entries.length === 0) return null diff --git a/patches/ds-bot/files/test/face-people.test.js b/patches/ds-bot/files/test/face-people.test.js index 64a90cf08..d5c808a55 100644 --- a/patches/ds-bot/files/test/face-people.test.js +++ b/patches/ds-bot/files/test/face-people.test.js @@ -20,7 +20,7 @@ async function load(entry) { require: (name) => { const platform = { react: { - createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }), + Component: class {}, createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }), useState: initial => [typeof initial === 'function' ? initial() : initial, () => {}], useEffect: () => {}, useRef: current => ({ current }), useLayoutEffect: () => {}, useMemo: make => make(), useCallback: fn => fn, useSyncExternalStore: (sub, get) => get(), Fragment: 'fragment', }, diff --git a/patches/ds-bot/files/test/face-person-first.test.js b/patches/ds-bot/files/test/face-person-first.test.js index 4696ace12..d5b90c612 100644 --- a/patches/ds-bot/files/test/face-person-first.test.js +++ b/patches/ds-bot/files/test/face-person-first.test.js @@ -20,7 +20,7 @@ async function load(entry) { require: (name) => { const platform = { react: { - createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }), + Component: class {}, createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }), useState: initial => [typeof initial === 'function' ? initial() : initial, () => {}], useEffect: () => {}, useRef: current => ({ current }), useLayoutEffect: () => {}, useMemo: make => make(), useCallback: fn => fn, useSyncExternalStore: (sub, get) => get(), Fragment: 'fragment', }, diff --git a/patches/ds-bot/files/test/flybot-client.test.js b/patches/ds-bot/files/test/flybot-client.test.js index 6325b66de..f13e06c9e 100644 --- a/patches/ds-bot/files/test/flybot-client.test.js +++ b/patches/ds-bot/files/test/flybot-client.test.js @@ -15,7 +15,7 @@ test('the client registry orders entries, replaces by id, and notifies', () => { // The replaced entry stays: disposing the old one does not remove its successor. assert.deepEqual(slots.entries('flybot.sidebar.section').map(e => e.id), ['b', 'a']) assert.throws(() => slots.register('nope', { id: 'x' }), /unknown slot/) - assert.deepEqual(SLOTS, ['flybot.sidebar.section', 'flybot.sidebar.structure', 'flybot.composer.mention', 'flybot.bot.details', 'flybot.approvals', 'flybot.room.overview', 'flybot.session.dock', 'flybot.new-chat.action', 'flybot.account.header', 'flybot.settings.page']) + assert.deepEqual(SLOTS, ['flybot.sidebar.section', 'flybot.sidebar.structure', 'flybot.composer.mention', 'flybot.bot.details', 'flybot.approvals', 'flybot.room.overview', 'flybot.room.members', 'flybot.session.dock', 'flybot.new-chat.action', 'flybot.account.header', 'flybot.settings.page']) }) test('new-chat action rows sit after New group chat and skip a failing plugin', () => { diff --git a/patches/ds-bot/files/test/group-bot-label.test.js b/patches/ds-bot/files/test/group-bot-label.test.js index 8ac4529b9..08ae245fd 100644 --- a/patches/ds-bot/files/test/group-bot-label.test.js +++ b/patches/ds-bot/files/test/group-bot-label.test.js @@ -24,7 +24,7 @@ async function load(entry) { require: (name) => { const platform = { react: { - createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }), + Component: class {}, createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }), useState: initial => [typeof initial === 'function' ? initial() : initial, () => {}], useEffect: () => {}, useRef: current => ({ current }), useLayoutEffect: () => {}, useMemo: make => make(), useCallback: fn => fn, useSyncExternalStore: (sub, get) => get(), Fragment: 'fragment', }, diff --git a/patches/ds-bot/files/test/room-members-slot.test.js b/patches/ds-bot/files/test/room-members-slot.test.js new file mode 100644 index 000000000..20adcbe1f --- /dev/null +++ b/patches/ds-bot/files/test/room-members-slot.test.js @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' +import { runInNewContext } from 'node:vm' +import { build } from 'esbuild' + +// #876: the Members card of a group that lives here draws a plugin's rows (the social plugin's +// Invite by link), and hands them `host` for core endpoints, which the plugin's `call` cannot reach. + +async function loadSlots() { + const bundle = await build({ + entryPoints: [fileURLToPath(new URL('../src/client/flybot-slots.js', import.meta.url))], + bundle: true, format: 'cjs', platform: 'node', write: false, + external: ['react', 'react-dom', '@deepseek-ai/dsh-client-ui-primitives'], + }) + const module = { exports: {} } + const sandbox = { + module, exports: module.exports, setTimeout, clearTimeout, console, + require: (name) => { + const platform = { + react: { + Component: class {}, createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }), + useState: initial => [typeof initial === 'function' ? initial() : initial, () => {}], useEffect: () => {}, useRef: current => ({ current }), + useLayoutEffect: () => {}, useMemo: make => make(), useCallback: fn => fn, useSyncExternalStore: (sub, get) => get(), Fragment: 'fragment', + }, + 'react-dom': { createPortal: node => node }, + '@deepseek-ai/dsh-client-ui-primitives': { MarkdownText: function MarkdownText() { return null }, projectUserText: text => text }, + } + if (!(name in platform)) throw new Error(`no platform module ${name}`) + return platform[name] + }, + } + sandbox.globalThis = sandbox + runInNewContext(bundle.outputFiles[0].text, sandbox) + return { ...module.exports, sandbox } +} + +test('#876: a plugin row in the Members card gets the room and a host call for core endpoints', async () => { + const { FlyBotRoomMembers, registry, sandbox } = await loadSlots() + const slots = registry() + assert.equal(typeof sandbox.__flybot__.host, 'function', 'the registry also carries the core call') + const Row = () => null + slots.register('flybot.room.members', { id: 'social-room-invite', order: 50, component: Row }) + const room = { id: 'r1', name: 'Trip plan' } + const tree = FlyBotRoomMembers({ room, roster: {}, actions: {} }) + const wrap = tree.children[0] + assert.equal(wrap.props['data-flybot'], 'social-room-invite') + const safe = wrap.children[0] + assert.equal(safe.props.entry.component, Row) + assert.equal(safe.props.props.room, room) + assert.equal(typeof safe.props.props.host, 'function') + assert.equal(typeof safe.props.props.call, 'function') +}) + +test('#876: the Members card draws the plugin rows only in a group that lives here', () => { + const source = readFileSync(new URL('../src/client/group-settings.js', import.meta.url), 'utf8') + assert.match(source, /room\.origin === 'face-to-face' \|\| room\.origin === 'inbound-share' \? null : h\(FlyBotRoomMembers, \{ room, roster, actions \}\)/, + 'a face room has its own invite card and a shared room is not ours to invite to') +}) diff --git a/patches/ds-bot/files/types/flybot.d.ts b/patches/ds-bot/files/types/flybot.d.ts index f178923c9..dc9cfaffb 100644 --- a/patches/ds-bot/files/types/flybot.d.ts +++ b/patches/ds-bot/files/types/flybot.d.ts @@ -544,6 +544,8 @@ export type FlyBotCall = (endpoint: string, payload?: unknown) => Promise * one middleware (`policy`). * - `flybot.room.overview`: a block at the top of a group's overview (Leave / Start for a * face-to-face room). + * - `flybot.room.members`: rows at the foot of a local group's Members card (the social plugin's + * **Invite by link**: mint, QR, copy, countdown, withdraw). `host` calls a core endpoint by its own name. * - `flybot.session.dock`: a card above the composer in a Bot or group chat (held Bot * Social replies waiting for approval). * - `flybot.account.header`: a compact block at the top of the account menu (the social @@ -570,6 +572,7 @@ export interface FlyBotSlots { /** policy: only approvals held by this middleware name; omit for all. */ 'flybot.approvals': { id: string; order?: number; policy?: string; component(props: ApprovalCardProps): any } 'flybot.room.overview': { id: string; order?: number; component(props: { room: any; roster: any; actions: any; call: FlyBotCall }): any } + 'flybot.room.members': { id: string; order?: number; component(props: { room: any; roster: any; actions: any; call: FlyBotCall; host: FlyBotCall }): any } 'flybot.session.dock': { id: string; order?: number; /** The `by` of the approvals this dock draws; the overlay then draws no second card for the ids it claims. */ policy?: string; component(props: { sessionId?: string; roster?: any; room?: any; bot?: any; actions?: any; call: FlyBotCall; /** Report the approval ids this dock shows (a plugin's own policy). */ claim?: (ids: string[]) => void }): any } 'flybot.account.header': { id: string; order?: number; component(props: { actions?: any; call: FlyBotCall }): any } 'flybot.settings.page': { diff --git a/plugins/social/client.js b/plugins/social/client.js index f6cc51987..ec11812fe 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -884,7 +884,28 @@ window.__ModuleLoader__.load({ // Two shapes in one field (#852): the local one names the Bot to knock on, and its ticket is not a // face-room invite. Same regex as the host's parseInviteLink; the sentence is shown here too. const localInviteHandle = (text) => String(text ?? '').trim().match(/^(?:https?:)?\/\/[^\s/]+\/r\/(@[A-Za-z0-9._-]{1,120})\/[A-Za-z0-9_-]{16,64}\/?(?:[?#].*)?$/i)?.[1] ?? '' - const LOCAL_INVITE_COPY = 'That link opens a room on another FlyBot. It does not join a face-to-face room.' + // A core endpoint by its own name (#876): the local room's link lives in the host, not in this plugin. + const host = (endpoint, payload) => { + const fn = globalThis.__flybot__?.host + if (typeof fn !== 'function') return Promise.reject(Object.assign(new Error('This FlyBot cannot make room links yet.'), { code: 'bot/not-found' })) + return fn(endpoint, payload) + } + // The host keeps a deadline in milliseconds; the countdown and the card read an ISO time. + const isoAt = (ms) => (Number.isFinite(Number(ms)) && Number(ms) > 0 ? new Date(Number(ms)).toISOString() : '') + // One sentence per way a room link is refused (#876), never the generic "not an invite link". + const LOCAL_REFUSAL_COPY = { + expired: 'That link has expired. Ask for a new one.', + revoked: 'That link was withdrawn. Ask for a new one.', + 'claimed-elsewhere': 'Someone else already joined with that link. Ask for one of your own.', + 'own-invite': 'That link is for this FlyBot\u2019s own room.', + busy: 'Too many tries for now. Wait a minute and try again.', + unknown: 'That link does not open any room. Check that it was copied whole.', + 'lookup-failed': 'Bot Social could not look up that link right now. Try again.', + consumed: 'Someone else already joined with that link. Ask for one of your own.', + 'not-addable': 'That room cannot take guests right now.', + 'no-sender': 'This FlyBot has no Bot Social identity yet.', + } + const localRefusal = (code) => LOCAL_REFUSAL_COPY[String(code ?? '')] ?? LOCAL_REFUSAL_COPY['lookup-failed'] // The preview card names a room the person has not entered (#798). `name` is that room's own // string and the host passes it through as sent, so the last place before a person reads it // cleans it: one line, no controls, brackets, markup or @, capped — the same rules as the @@ -1024,6 +1045,73 @@ window.__ModuleLoader__.load({ h('button', { type: 'button', className: 'fs-link', 'data-flybot': 'face-invite-revoke', disabled: !!busy, onClick: revoke }, 'Withdraw link')) : null) } + // #876: Invite by link in a local group's Members card. The host makes the link (Bot Social records + // it, so a stranger can join); this row mints, shows the QR and the link, counts down and withdraws. + // Several people may each get a link (#878): the newest is shown, and a joined one stops showing. + function LocalInviteRow({ room } = {}) { + useStyles() + const roomId = room?.id + const [invite, setInvite] = useState(null) + const [note, setNote] = useState(null) + const [busy, setBusy] = useState(false) + const [copied, setCopied] = useState(false) + const [, setTick] = useState(0) + useEffect(() => { + if (!roomId) return undefined + let gone = false + host('room-invite', { roomId }).then(value => { if (!gone && value?.invite?.url) setInvite(value.invite) }, () => {}) + return () => { gone = true } + }, [roomId]) + useEffect(() => (invite?.expiresAt ? everySecond(() => setTick(n => n + 1)) : undefined), [invite?.expiresAt]) + if (!roomId) return null + const run = async (fn) => { + setBusy(true) + setNote(null) + try { await fn() } catch (e) { setNote({ tone: 'error', text: errorText(e) }) } finally { setBusy(false) } + } + const mint = () => run(async () => { + const value = await host('room-invite-mint', { roomId }) + if (value?.invite?.url) setInvite(value.invite) + else setNote({ tone: 'error', text: 'No link came back. Try again.' }) + }) + const revoke = () => run(async () => { + await host('room-invite-revoke', { roomId, ticket: invite?.ticket }) + setInvite(null) + setNote({ tone: 'hint', text: 'The link was withdrawn. Nobody can join with it now.' }) + }) + const copy = async () => { + const clip = typeof navigator !== 'undefined' ? navigator.clipboard : undefined + try { + await clip?.writeText?.(invite.url) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } catch { /* clipboard denied: the link is on screen to select */ } + } + const at = isoAt(invite?.expiresAt) + const live = Boolean(invite?.url) && qrJoinable(at, Date.now()) + return h('div', { className: 'fs-invite', 'data-flybot': 'room-invite' }, + h('div', { className: 'fs-invite-head' }, + h('span', { className: 'fs-label-inline' }, 'Invite by link'), + invite?.url && !live ? h('span', { className: 'fs-hint', 'data-flybot': 'room-invite-ended' }, 'This link has expired.') : null, + h('button', { + type: 'button', className: 'fs-btn fs-btn-sm', 'data-flybot': 'room-invite-mint', disabled: busy, onClick: mint, + }, live ? 'New link' : 'Create link')), + live ? null : h('div', { className: 'fs-hint', 'data-flybot': 'room-invite-about' }, + 'Anyone you give the link to can join with their Bot, for 10 minutes. They do not become a friend.'), + note ? h('div', { className: 'fs-hint', 'data-flybot': 'room-invite-note', 'data-tone': note.tone, role: 'status' }, note.text) : null, + live + ? h('div', { className: 'fs-invite-live', 'data-flybot': 'room-invite-live' }, + h(QrImage, { + text: invite.url, kind: 'room-invite-qr', at, attrs: { 'data-url': invite.url }, + label: 'QR code for this group invite', hint: 'Scan with FlyBot to join this group', + }), + h('div', { className: 'fs-invite-link' }, + h('code', { className: 'fs-invite-url', 'data-flybot': 'room-invite-url' }, invite.url), + h('button', { type: 'button', className: 'fs-link', 'data-flybot': 'room-invite-copy', onClick: copy }, copied ? 'Copied' : 'Copy link')), + h('div', { className: 'fs-hint', 'data-flybot': 'room-invite-count' }, countdown(at)), + h('button', { type: 'button', className: 'fs-link', 'data-flybot': 'room-invite-revoke', disabled: busy, onClick: revoke }, 'Withdraw link')) + : null) + } function FaceWaitCode({ code, qrAt, qrOffsetMs } = {}) { const digits = String(code || '').replace(/\D/g, '').slice(0, 6) if (digits.length !== 6) return null @@ -1369,7 +1457,7 @@ window.__ModuleLoader__.load({ const previewLink = async (typed) => { const text = String(typed ?? invite.link ?? '').trim() if (localInviteHandle(text)) { - setInvite({ link: text, status: { tone: 'error', text: LOCAL_INVITE_COPY } }) + await previewLocal(text) return } const ticket = faceInviteTicket(text) @@ -1405,6 +1493,57 @@ window.__ModuleLoader__.load({ } await joinTicket(text, ticket) } + // #876: a room link of another FlyBot. The host reads Bot Social's record first (a stranger can), + // and Join claims it for the picked Bots; the room appears here when the inviter's home seats them. + const previewLocal = async (text) => { + if (invite.preview?.local && invite.preview.link === text) return + setBusy(true) + setFailures([]) + setInvite({ link: text, status: { tone: 'hint', text: 'Looking up that room…' } }) + let seen = null + try { + seen = (await host('room-invite-preview', { link: text }))?.preview ?? null + } catch (e) { + setBusy(false) + setInvite({ link: text, status: { tone: 'error', text: errorText(e) } }) + return + } + setBusy(false) + if (seen?.refused) { + setInvite({ link: text, status: { tone: 'error', text: localRefusal(seen.refused) } }) + return + } + const card = invitePreview({ name: seen?.name, lead: seen?.owner ? `${seen.lead || 'Bot'} (${seen.owner})` : seen?.lead, expiresAt: isoAt(seen?.expiresAt) }) + if (seen?.claimed) { + setInvite({ link: text, status: { tone: 'error', text: localRefusal('claimed-elsewhere') } }) + return + } + if (!card) { + // An older server has no record to read: the friend's knock is still the way in. + setInvite({ link: text, status: null, preview: { name: 'A group on another FlyBot', lead: '', people: '', expiresAt: '', clockOffsetMs: 0, local: true, link: text } }) + return + } + setInvite({ link: text, status: null, preview: { ...card, local: true, link: text } }) + } + const joinLocal = async (text) => { + if (!selected.length) { + setInvite(current => ({ ...current, status: { tone: 'error', text: 'Pick a Bot first.' } })) + return + } + setBusy(true) + setInvite(current => ({ ...current, status: { tone: 'hint', text: 'Joining with that link…' } })) + try { + const result = await host('room-invite-ask', { link: text, accept: true, bots: selected }) + if (result?.refused) { + setInvite({ link: text, status: { tone: 'error', text: localRefusal(result.refused) } }) + return + } + const name = invite.preview?.name && invite.preview.name !== 'A group on another FlyBot' ? `"${invite.preview.name}"` : 'the group' + setInvite({ link: '', status: { tone: 'ok', text: `Asked to join ${name}. It opens in your sidebar as soon as their FlyBot seats your Bots.` } }) + } catch (e) { + setInvite({ link: text, status: { tone: 'error', text: errorText(e) } }) + } finally { setBusy(false) } + } // One sentence per way a scan can fail, and the number field is untouched either way. const beginScan = () => { setScan({ tone: 'hint', text: 'Point the camera at a FlyBot room code.' }) @@ -1415,7 +1554,8 @@ window.__ModuleLoader__.load({ } const raw = String(result?.raw ?? '') if (localInviteHandle(raw)) { - setScan({ tone: 'error', text: LOCAL_INVITE_COPY }) + setScan({ tone: 'ok', text: 'Scanned a group link.' }) + void previewLocal(raw) return } if (faceInviteTicket(raw)) { @@ -1523,7 +1663,8 @@ window.__ModuleLoader__.load({ preview ? h(InvitePreviewCard, { preview, busy, kind: 'face-link-preview', onClear: clearInvite, - onJoin: () => joinTicket(preview.link ?? invite.link, preview.ticket), + joinLabel: preview.local ? 'Join group' : 'Join', + onJoin: () => (preview.local ? joinLocal(preview.link) : joinTicket(preview.link ?? invite.link, preview.ticket)), }) : null, h('div', { className: 'fs-scan-row' }, h('button', { @@ -3132,6 +3273,7 @@ window.__ModuleLoader__.load({ FriendBotRow, FriendChatSettings, FaceToFaceGroup, + LocalInviteRow, FaceAvatar, FaceRoomOverview, FaceRoomCard, @@ -3175,6 +3317,8 @@ window.__ModuleLoader__.load({ slots().register('flybot.approvals', { id: 'social-held-reply', order: 50, policy: 'social', inline: true, component: HeldReplyCard }), slots().register('flybot.settings.page', { id: 'social', order: 100, label: 'Bot Social', icon: SocialPageIcon, component: SocialServerSettings }), slots().register('flybot.room.overview', { id: 'social-face', order: 50, component: FaceRoomOverview }), + // An older host has no Members-card slot: the row is simply not drawn there. + (() => { try { return slots().register('flybot.room.members', { id: 'social-room-invite', order: 50, component: LocalInviteRow }) } catch { return () => {} } })(), slots().register('flybot.session.dock', { id: 'social-room-end', order: 40, component: FaceRoomStatus }), slots().register('flybot.session.dock', { id: 'social-approval', order: 50, policy: 'social', component: ChatApprovalDock }), slots().register('flybot.session.dock', { id: 'social-thinking', order: 60, component: PeerThinking }), diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index 4073eb297..15594a6ab 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -740,7 +740,7 @@ test('client.js registers a sidebar section and @ rows into the FlyBot registry' runInNewContext(readFileSync(new URL('../client.js', import.meta.url), 'utf8'), sandbox) const plugin = registered[0].factory(() => ({ createElement: () => null, useEffect() {}, useState: v => [v, () => {}] })) plugin.apply({}) - assert.deepEqual(entries.map(([slot, entry]) => `${slot}#${entry.id}`), ['flybot.sidebar.section#social', 'flybot.new-chat.action#social-face-group', 'flybot.new-chat.action#social-add-friend', 'flybot.composer.mention#social', 'flybot.account.header#social-handle', 'flybot.bot.details#social-invite', 'flybot.approvals#social-held-reply', 'flybot.settings.page#social', 'flybot.room.overview#social-face', 'flybot.session.dock#social-room-end', 'flybot.session.dock#social-approval', 'flybot.session.dock#social-thinking', 'flybot.session.dock#social-paused']) + assert.deepEqual(entries.map(([slot, entry]) => `${slot}#${entry.id}`), ['flybot.sidebar.section#social', 'flybot.new-chat.action#social-face-group', 'flybot.new-chat.action#social-add-friend', 'flybot.composer.mention#social', 'flybot.account.header#social-handle', 'flybot.bot.details#social-invite', 'flybot.approvals#social-held-reply', 'flybot.settings.page#social', 'flybot.room.overview#social-face', 'flybot.room.members#social-room-invite', 'flybot.session.dock#social-room-end', 'flybot.session.dock#social-approval', 'flybot.session.dock#social-thinking', 'flybot.session.dock#social-paused']) // Transport errors are worded in English from their code, never the localized host text. assert.match(plugin.errorText(Object.assign(new Error('请求失败(HTTP 404)'), { code: 'HTTP_404' })), /^The FlyBot host did not answer \(HTTP 404\)/) assert.equal(plugin.errorText(Object.assign(new Error('x'), { code: 'bot/refused' })), 'x') @@ -9879,14 +9879,19 @@ test('#876: the invite preview markup lives in the core card, not a second copy assert.ok(tree.length >= 0) }) -test('#852: a link into a room on another FlyBot is answered in one sentence and calls nobody', async () => { - const LOCAL_URL = 'https://bot-social.example.com/r/@chief.alice.home/AbCdEfGhIjKlMnOpQrStUv' +// ---- #876: a group link of another FlyBot, in the same paste field and scan ----------------- +const LOCAL_URL = 'https://bot-social.example.com/r/@chief.alice.home/AbCdEfGhIjKlMnOpQrStUv' +// The Face-to-face dialog with a typed local link: `host` answers core endpoints; every setInvite is kept. +function localLinkDialog(hostImpl, invite = { link: LOCAL_URL, status: null }) { const calls = [] - const statuses = [] + const hostCalls = [] + const invites = [] let index = 0 - const bots = [{ id: 'a', name: 'Chief', isMain: true }] - const states = ['setup', bots, ['a'], '', false, null, [], null, 0, null, { link: LOCAL_URL, status: null }] - const { registered, walk } = loadSocialClient(async (endpoint, payload) => { calls.push([endpoint, payload]); return {} }) + let current = invite + const bots = [{ id: 'a', name: 'Chief', isMain: true }, { id: 'b', name: 'Scout' }] + const states = ['setup', bots, ['a', 'b'], '', false, null, [], null, 0, null] + const { registered, walk, sandbox } = loadSocialClient(async (endpoint, payload) => { calls.push([endpoint, payload]); return {} }) + sandbox.__flybot__.host = async (endpoint, payload) => { hostCalls.push([endpoint, payload]); return hostImpl(endpoint, payload) } const plugin = registered[0].factory(() => ({ createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat() }), Fragment: 'F', @@ -9894,16 +9899,99 @@ test('#852: a link into a room on another FlyBot is answered in one sentence and useMemo: fn => fn(), useRef: value => ({ current: value }), useState: value => { - const at = index++ - const initial = at < states.length ? states[at] : value - return [initial, next => { if (at === 10) statuses.push(next) }] + const at = index++ % 11 + if (at === 10) return [current, next => { current = typeof next === 'function' ? next(current) : next; invites.push(current) }] + return [at < states.length ? states[at] : value, () => {}] }, })) - const tree = walk(plugin.FaceToFaceGroup({})) - tree.find(n => n.props['data-flybot'] === 'face-link-join-go').props.onClick() - await new Promise(resolve => setImmediate(resolve)) - assert.equal(statuses.at(-1).status.text, 'That link opens a room on another FlyBot. It does not join a face-to-face room.') - assert.deepEqual(calls, [], 'no preview and no join: the ticket is not this server\'s') + const render = () => { index = 0; return walk(plugin.FaceToFaceGroup({})) } + return { calls, hostCalls, invites, render, last: () => current } +} +const plain = value => JSON.parse(JSON.stringify(value)) +const settleUi = () => new Promise(resolve => setImmediate(resolve)) + +test('#876: a pasted group link of another FlyBot shows its room, and Join brings the picked Bots', async () => { + const d = localLinkDialog(async (endpoint) => { + if (endpoint === 'room-invite-preview') return { preview: { handle: '@chief.alice.home', ticket: 'AbCdEfGhIjKlMnOpQrStUv', server: true, name: 'Trip plan', lead: 'Chief', owner: 'Alice', expiresAt: Date.now() + 300_000, claimed: false } } + if (endpoint === 'room-invite-ask') return { sent: 'allow', claimed: true, bots: ['@chief.bob.home', '@scout.bob.home'] } + return {} + }) + d.render().find(n => n.props['data-flybot'] === 'face-link-join-go').props.onClick() + await settleUi() + assert.deepEqual(plain(d.hostCalls[0]), ['room-invite-preview', { link: LOCAL_URL }]) + const preview = d.last().preview + assert.equal(preview.name, 'Trip plan') + assert.equal(preview.lead, 'Chief (Alice)') + assert.equal(preview.local, true) + const tree = d.render() + const card = tree.find(n => n.props['data-flybot'] === 'face-link-preview') + assert.ok(card, 'the one shared preview card is drawn') + const join = tree.find(n => n.props['data-flybot'] === 'face-link-preview-join') + assert.equal(join.children.join(''), 'Join group') + join.props.onClick() + await settleUi() + assert.deepEqual(plain(d.hostCalls[1]), ['room-invite-ask', { link: LOCAL_URL, accept: true, bots: ['a', 'b'] }], 'both picked Bots, by their ids here') + assert.match(d.last().status.text, /^Asked to join "Trip plan"\. It opens in your sidebar/) + assert.deepEqual(d.calls, [], 'no face-room route is asked: the ticket is not a face-room invite') +}) + +test('#876: each refusal of a group link is its own sentence, never "not an invite link"', async () => { + const seen = new Set() + for (const code of ['expired', 'revoked', 'claimed-elsewhere', 'own-invite', 'busy', 'unknown', 'lookup-failed']) { + const d = localLinkDialog(async () => ({ preview: { handle: '@chief.alice.home', ticket: 'AbCdEfGhIjKlMnOpQrStUv', refused: code } })) + d.render().find(n => n.props['data-flybot'] === 'face-link-join-go').props.onClick() + await settleUi() + const text = d.last().status.text + assert.equal(d.last().status.tone, 'error') + assert.doesNotMatch(text, /not an invite link/) + assert.equal(d.last().preview, undefined, `${code}: no card to join from`) + seen.add(text) + } + assert.equal(seen.size, 7, 'seven refusals, seven sentences') + // A link someone already claimed is said before anyone presses Join. + const taken = localLinkDialog(async () => ({ preview: { name: 'Trip plan', lead: 'Chief', expiresAt: Date.now() + 60_000, claimed: true } })) + taken.render().find(n => n.props['data-flybot'] === 'face-link-join-go').props.onClick() + await settleUi() + assert.match(taken.last().status.text, /Someone else already joined with that link/) + // And a refusal on Join (the claim) is said the same way. + const late = localLinkDialog(async (endpoint) => (endpoint === 'room-invite-ask' ? { refused: 'expired' } : {}), { link: LOCAL_URL, status: null, preview: { name: 'Trip plan', local: true, link: LOCAL_URL } }) + late.render().find(n => n.props['data-flybot'] === 'face-link-preview-join').props.onClick() + await settleUi() + assert.equal(late.last().status.text, 'That link has expired. Ask for a new one.') +}) + +test('#876: the Members card row makes a link with its QR, copy, countdown and withdraw', async () => { + const hostCalls = [] + const { registered, walk, sandbox } = loadSocialClient(async () => ({})) + const url = 'https://social.example.com/r/@chief.alice.home/AbCdEfGhIjKlMnOpQrStUv' + sandbox.__flybot__.host = async (endpoint, payload) => { + hostCalls.push([endpoint, payload]) + if (endpoint === 'room-invite-mint') return { invite: { ticket: 'AbCdEfGhIjKlMnOpQrStUv', handle: '@chief.alice.home', url, expiresAt: Date.now() + 600_000, server: true } } + return { revoked: true } + } + let invite = null + let i = 0 + const plugin = registered[0].factory(() => ({ + createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat() }), + Fragment: 'F', useEffect() {}, useMemo: fn => fn(), useRef: value => ({ current: value }), + useState: value => { const at = i++ % 5; return at === 0 ? [invite, next => { invite = next }] : [value, () => {}] }, + })) + const draw = () => { i = 0; return walk(plugin.LocalInviteRow({ room: { id: 'r1', name: 'Trip plan' } })) } + let tree = draw() + assert.equal(tree.find(n => n.props['data-flybot'] === 'room-invite-qr'), undefined, 'no QR before a link exists') + assert.match(tree.find(n => n.props['data-flybot'] === 'room-invite-about').children.join(''), /They do not become a friend/) + await tree.find(n => n.props['data-flybot'] === 'room-invite-mint').props.onClick() + assert.deepEqual(plain(hostCalls[0]), ['room-invite-mint', { roomId: 'r1' }]) + tree = draw() + const qr = tree.find(n => n.props['data-flybot'] === 'room-invite-qr') + assert.equal(qr.props['data-url'], url, 'the QR is the link itself') + assert.ok(Number(qr.props['data-modules']) >= 21) + assert.equal(tree.find(n => n.props['data-flybot'] === 'room-invite-url').children.join(''), url) + assert.match(tree.find(n => n.props['data-flybot'] === 'room-invite-count').children.join(''), /^(10:00|9:5\d) left to join$/) + assert.ok(tree.find(n => n.props['data-flybot'] === 'room-invite-copy')) + await tree.find(n => n.props['data-flybot'] === 'room-invite-revoke').props.onClick() + assert.deepEqual(plain(hostCalls[1]), ['room-invite-revoke', { roomId: 'r1', ticket: 'AbCdEfGhIjKlMnOpQrStUv' }]) + assert.equal(invite, null) }) test('#773: scanning a room invite link joins that room with its ticket', async () => { From 5d64f6490e918598547720e25aed630087b4f775 Mon Sep 17 00:00:00 2001 From: Birfy <15378933+Birfy@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:01:34 +0000 Subject: [PATCH 2/3] fix(rooms): what the live two-home run found: the seat stays, the guest writes, one fence, claimed joins only (#884, #883) --- changelog.d/884-link-room-live-fixes.fixed.md | 1 + core/src/host/room-membership.js | 30 +++++++++-- core/test/room-ticket-seat.test.js | 54 ++++++++++++++++++- docs/subsystems/core-host.md | 2 +- docs/subsystems/social.md | 2 +- .../ds-bot/files/src/host/room-membership.js | 30 +++++++++-- .../files/test/room-ticket-seat.test.js | 54 ++++++++++++++++++- plugins/social/src/social.js | 19 +++++++ plugins/social/test/social.test.js | 10 ++++ 9 files changed, 188 insertions(+), 14 deletions(-) create mode 100644 changelog.d/884-link-room-live-fixes.fixed.md diff --git a/changelog.d/884-link-room-live-fixes.fixed.md b/changelog.d/884-link-room-live-fixes.fixed.md new file mode 100644 index 000000000..f03e421e7 --- /dev/null +++ b/changelog.d/884-link-room-live-fixes.fixed.md @@ -0,0 +1 @@ +In a group joined by link, the guest can write too, under their own name, and lines arrive as written. The guest’s Bot no longer drops out of the inviter’s group after the first message. A ticket the guest never claimed opens nothing. diff --git a/core/src/host/room-membership.js b/core/src/host/room-membership.js index f250315e1..6e8cb6f61 100644 --- a/core/src/host/room-membership.js +++ b/core/src/host/room-membership.js @@ -38,7 +38,7 @@ import { cleanPeerName, disambiguatedRemoteTitle, isChiefRecord, remoteBotTitle, import { formatFaceRoomTitle, isAutoFaceTitle, faceRoomCreatedAt } from '../shared/face-title.js' import { encodeRoomShare, LOCAL_INVITE_TTL_MS, parseRoomInviteLink, parseRoomShare, roomInviteUrl } from '../shared/room-share.js' import { cleanFaceLead, cleanRoleLabel, faceLeadOf } from './face-lead.js' -import { cleanName, fenceUntrusted, isFencedUntrusted } from './delivery.js' +import { cleanName, fenceUntrusted, isFencedUntrusted, unwrapUntrusted } from './delivery.js' import { hasAnyGrant, mayShareRoom } from './reach.js' import { mentionSpans } from '../shared/mentions.js' @@ -912,6 +912,10 @@ export function install(rt) { } } + // A line as written, for a link's seat (#884): the far home fences it once with `who`, so a fence + // stored here is opened first, never nested (a nested one reads as markup to the person). + const seatText = line => (!line.text ? `[${cleanName(line.kind || 'event')}]` : isFencedUntrusted(line.text) ? unwrapUntrusted(line.text) : String(line.text)) + // #884: a seat a ticket admitted is reached from the Bot its link named, under that room's scope only, // and is told about the room by that Bot. const isTicketSeat = p => p?.remote === true && p.admittedBy === 'ticket' @@ -1547,17 +1551,22 @@ export function install(rt) { const seatedAuthor = room.sharedFrom?.ticket && author && currentParticipants(room).some(p => p.kind === 'bot' && !p.remote && p.id === author) ? author : undefined const sender = seatedAuthor ?? room.sharedFrom?.localBotId ?? shareSpeaker(room) if (!sender) return + // A link's room (#884): the owner's own line is said under their Bot Social name, never this + // home's "You", and the text goes as written: the inviter's home fences it once, on arrival. + const ticketed = Boolean(room.sharedFrom?.ticket) + const ownLine = ticketed && line.authorKind === 'human' && line.remote !== true + const sayer = ownLine ? cleanPeerName(state.bots?.[sender]?.fields?.social?.member?.ownerName, who) : who const result = await sendShareTo(room, { handle: inviter, id: inviter }, { op: 'line', roomId: room.sharedFrom?.remoteRoomId, name: room.name, invitedHandle: inviter, seq: line.seq, - who, + who: sayer, kind: line.kind || 'message', authorKind: line.authorKind, authorId: line.authorId, - text: `${who}: ${body}`, + text: ticketed ? seatText(line) : `${who}: ${body}`, hop: Number(line.hop) || ((Number(room.hop) || 0) + 1), calls: callsIn(room, line.text), ...shareInviter(room), @@ -1592,7 +1601,7 @@ export function install(rt) { kind: line.kind || 'message', authorKind: line.authorKind, authorId: line.authorId, - text: `${who}: ${body}`, + text: seatText(line), hop: Number(line.hop) || (line.authorKind === 'bot' && line.remote !== true ? (Number(room.hop) || 0) + 1 : 0), calls: callsIn(room, line.text), ...seat.from, @@ -1775,6 +1784,14 @@ export function install(rt) { } } const ticket = typeof data.ticket === 'string' && TICKET_SHAPE.test(data.ticket) ? data.ticket : undefined + // #884: a join that says it comes through a link is one this home claimed: that inviter, that room, + // and a Bot the claim brought. A ticket nobody here claimed opens nothing. + if (ticket && data.op === 'join') { + const claim = state.roomClaims?.[ticket] + const same = (a, b) => String(a ?? '').toLowerCase() === String(b ?? '').toLowerCase() && String(a ?? '') !== '' + if (!claim || !same(claim.inviterHandle, event.fromHandle) || String(claim.roomId ?? '') !== String(data.roomId ?? '') + || (localBotId && !(claim.bots ?? []).includes(localBotId))) return { handled: true, refused: 'unclaimed' } + } if (data.op === 'leave') { if (!room) return { handled: true, room: undefined } // #884: a seat of a room that holds several Bots of mine ends for the Bot it names; the room @@ -1917,6 +1934,11 @@ export function install(rt) { inviteNotice: true, maxHops: Number.isFinite(Number(draft.maxHops)) ? Math.max(0, Math.floor(Number(draft.maxHops))) : DEFAULT_MAX_HOPS, participants: [ + // A link admits the person with their Bots (#878): the owner can write in the room it opened. + ...(typeof draft.ticket === 'string' && draft.ticket ? [{ + kind: 'human', id: ownerId, role: 'member', displayName: displayNameFor(state, ownerId, 'You'), + joinedSeq: draft.joinedSeq ?? 0, joinedAt: draft.invitedAt ?? at, + }] : []), ...(localBotId ? [{ kind: 'bot', id: localBotId, diff --git a/core/test/room-ticket-seat.test.js b/core/test/room-ticket-seat.test.js index 2aa5d47c5..0a2db795b 100644 --- a/core/test/room-ticket-seat.test.js +++ b/core/test/room-ticket-seat.test.js @@ -11,7 +11,8 @@ import { install as installMemberAuth } from '../src/host/member-auth.js' import { install as installAuthz } from '../src/host/authz.js' import { install as installRooms, publishedRemoteOf } from '../src/host/room-membership.js' import { install as installReach, mayShareRoom } from '../src/host/reach.js' -import { parseRoomShare } from '../src/shared/room-share.js' +import { encodeRoomShare, parseRoomShare } from '../src/shared/room-share.js' +import { unwrapUntrusted } from '../src/host/delivery.js' const ALICE = { version: 2, @@ -234,7 +235,7 @@ test('#884: a stranger claims a room link with two of its Bots, both are seated, const mirror = guestRoomOf(b.rt) assert.ok(mirror, 'the joins projected the room on the guest side') assert.equal(Object.values(b.rt.state.rooms).filter(r => r.origin === 'inbound-share').length, 1, 'one room for both seats') - assert.deepEqual(mirror.participants.filter(p => p.leftSeq === undefined).map(p => p.id).sort(), ['chief-b', 'helper-b']) + assert.deepEqual(mirror.participants.filter(p => p.leftSeq === undefined && p.kind === 'bot').map(p => p.id).sort(), ['chief-b', 'helper-b']) assert.equal(mirror.sharedFrom.remoteRoomId, 'war') assert.equal(mirror.sharedFrom.ticket, invite.ticket) assert.equal(mirror.sharedFrom.inviterName, 'Alice', 'the guest reads the owner, never the host’s own "You"') @@ -384,3 +385,52 @@ test('#884: a home with no Bot Social identity yet gets one for the link it make delete b.rt.state.bots['chief-b'].fields await assert.rejects(() => b.rt.askLocalInvite(invite.url, { accept: true, bots: ['chief-b'] }, { memberId: 'owner-1' }), /could not give this Bot an identity/) }) + +test('#884: the guest writes in the room the link opened, under its own name, and no line nests a fence', async (t) => { + const { a, b, settle } = await twoHomes(t) + const invite = await a.rt.mintRoomInvite('war', { memberId: 'owner-1' }) + await b.rt.askLocalInvite(invite.url, { accept: true, bots: ['helper-b'] }, { memberId: 'owner-1' }) + await settle() + const room = a.rt.state.rooms.war + const mirror = guestRoomOf(b.rt) + // One link admits the person with their Bot (#878): Bob is in the room it opened on his side. + assert.ok(mirror.participants.some(p => p.kind === 'human' && p.id === 'owner-1' && p.leftSeq === undefined), 'the guest owner has a seat to write from') + + const line = await a.rt.postRoomMessage('war', { text: 'Ship on Friday?', authorId: 'owner-1', authorKind: 'human', displayName: 'Alice' }) + await settle() + const arrived = mirror.log.find(entry => String(entry.text).includes('Ship on Friday?')) + assert.equal(unwrapUntrusted(arrived.text), 'Ship on Friday?', 'fenced once on arrival, with nothing nested or prefixed (live run: a garbled fence)') + assert.equal((String(arrived.text).match(/untrusted-message/g) ?? []).length, 2) + assert.ok(line) + + const own = await b.rt.postRoomMessage(mirror.id, { text: 'See you there.', authorId: 'owner-1', authorKind: 'human' }) + assert.ok(own, 'the guest owner may post in the mirror') + await settle() + const landed = room.log.find(entry => String(entry.text).includes('See you there.')) + assert.ok(landed, "the guest's own line reached the host room") + assert.equal(landed.who, 'Bob', 'said under his Bot Social name, never his home\'s "You"') + assert.equal(landed.authorKind, 'human') + assert.equal(landed.authorId, '@helper.bob.home#owner-1') + assert.equal(unwrapUntrusted(landed.text), 'See you there.') +}) + +test('#884: a join that names a ticket this home never claimed opens nothing', async (t) => { + const { a, b } = await twoHomes(t) + const before = Object.keys(b.rt.state.rooms).length + const forged = encodeRoomShare({ op: 'join', roomId: 'war', name: 'Free stuff', invitedHandle: '@helper.bob.home', ticket: 'TktZZZZAbCdEfGhIjKlMnO', inviterHandle: '@chief.alice.home', inviterName: 'Alice' }) + const out = await b.rt.applyInboundRoomShare({ text: forged, fromHandle: '@chief.alice.home', fromHome: '@alice.home', fromName: 'Bot', localBotId: 'helper-b' }) + assert.equal(out.refused, 'unclaimed') + assert.equal(Object.keys(b.rt.state.rooms).length, before, 'no room was projected') + // A real claim, but a join from another Bot of the inviter's home, or for another room, is refused too. + const invite = await a.rt.mintRoomInvite('war', { memberId: 'owner-1' }) + b.rt.flybotSend = async () => ({ action: 'sent' }) + await b.rt.askLocalInvite(invite.url, { accept: true, bots: ['helper-b'] }, { memberId: 'owner-1' }) + const join = extra => encodeRoomShare({ op: 'join', roomId: 'war', name: 'Launch plan', invitedHandle: '@helper.bob.home', ticket: invite.ticket, inviterHandle: '@chief.alice.home', inviterName: 'Alice', ...extra }) + assert.equal((await b.rt.applyInboundRoomShare({ text: join(), fromHandle: '@writer.alice.home', fromHome: '@alice.home', fromName: 'Bot', localBotId: 'helper-b' })).refused, 'unclaimed') + assert.equal((await b.rt.applyInboundRoomShare({ text: join({ roomId: 'other' }), fromHandle: '@chief.alice.home', fromHome: '@alice.home', fromName: 'Bot', localBotId: 'helper-b' })).refused, 'unclaimed') + assert.equal((await b.rt.applyInboundRoomShare({ text: join(), fromHandle: '@chief.alice.home', fromHome: '@alice.home', fromName: 'Bot', localBotId: 'chief-b' })).refused, 'unclaimed', 'a Bot the claim did not bring') + assert.equal(Object.keys(b.rt.state.rooms).length, before) + const ok = await b.rt.applyInboundRoomShare({ text: join(), fromHandle: '@chief.alice.home', fromHome: '@alice.home', fromName: 'Bot', localBotId: 'helper-b' }) + assert.equal(ok.refused, undefined) + assert.equal(Object.keys(b.rt.state.rooms).length, before + 1, 'the claimed join projects the room') +}) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 3fbac0620..b168a5d17 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -154,7 +154,7 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi - The room record is the only authority: `room.localInvites [{ ticket, handle, botId, expiresAt, mintedAt, mintedBy, server?, url?, acceptedBy?, acceptedAt?, seats? }]`, ten minutes each (`LOCAL_INVITE_TTL_MS`, the face-room window). Several may be live at once, one per person invited (#878); a record written before the list (`room.localInvite`) is moved into it on first read. `rt.mintRoomInvite(roomId, actor)` adds one (dead links that seated nobody are dropped then); `rt.localInvitesOf(roomId)` lists the open ones (unexpired, unspent) and `rt.localInviteOf(roomId)` is the newest; `rt.roomOfLocalInvite(ticket)` resolves the room from the ticket; `rt.revokeRoomInvite(roomId, ticket?)` withdraws one link, or without a ticket every unused one. A shared (`inbound-share`) or face-to-face room refuses to mint — it has its own invitation. - Three steps on the same envelope, answered by the room record: the invitee's host sends `{ op: 'ask', ticket }`; the inviter answers `{ op: 'invite', ticket, roomId, name, lead, expiresAt }`, which is information only (it consumes nothing and seats nobody); the accepting knock `{ op: 'ask', ticket, accept: true }` runs verify → consume → add → the existing `join`, which `addRoomParticipant` sends. `applyInboundRoomShare` handles both ops **before** the branch that projects a room for an unmatched `join` / `line`, so a knock never conjures a room; `parseRoomShare` accepts `ask` without a `roomId` and requires one on `invite`. The asker is `event.fromHandle` (the transport's sender), never a field of the payload. `acceptedBy` is written once, at accept: the same home retrying is idempotent success (the lost `join` reply is sent again) and another home is refused; an add the room refuses rolls the claim back, so no refusal burns the ticket. Refusals are local on the inviter's host (`unknown` / `expired` / `consumed` / `no-sender` / `not-addable`) and change nothing. The UI half — the Members card's **Invite by link** row and the dialog the offer draws — is still to come. Covered by `core/test/remote-room.test.js` (#852). - The endpoints the UI calls (#876): `room-invite` reads the live link (`rt.localInviteOf`, what the Members card copies and counts down), `room-invite-mint` and `room-invite-revoke` are the room owner's or a human admin's call (`authz.js`), and `room-invite-ask` is the invitee's side: `rt.askLocalInvite` reads the handle and ticket out of the link and sends `{ op: 'ask', ticket }` from one of the actor's own Bots whose home has a Bot Social identity — the link cannot name a sender, so the transport does, as on the answering side. Join is the same call with `accept: true`, and the answer arrives as `invite` through `applyInboundRoomShare` (`rt.onLocalInviteOffer`). A face-room link, a bare ticket and a ticket outside the 16–64 shape are refused in one sentence and send nothing; the knock carries no `roomId`, so an invitee can never name a room it has not seen. -- Strangers (#884, #878): when the social plugin registers Bot Social's ticket record (`flybot.rooms.serveInvites({ identify, mint, preview, claim, verify, revoke })`, `rt.roomInviteServer`), a mint goes to the server (`server: true`; a server that fails refuses the mint — it is never swapped for a local ticket — and one without `local_room_invites` answers `undefined`, which keeps the friend-only local ticket). The link knocks on `invite.botId`, the room lead when it has a Bot Social identity; a lead with none gets one first through the seam's `identify` (the link is the owner's consent, as accepting a friend's invite is), and so do the Bots a guest brings on Join. On the invitee's side, Join (`room-invite-ask` with `accept: true` and `bots`, the invitee's own Bot ids) claims the ticket at the server for those Bots, all or none, then the first of them sends `{ op: 'ask', ticket, accept: true, roomId, bots }`; `room-invite-preview` (`rt.previewLocalInvite`) reads the server's public record before Join. On the inviter's side the knock is only a request: `seatByTicket` reads the claim back with this home's own credential (`verify`) and seats exactly the claim's Bots, from the claiming home, in this room, or nothing — refusals `unclaimed` / `claimed-elsewhere` / `wrong-room` / `lookup-failed` / `expired` / `revoked` / `not-addable`, each its own. A seat is `{ remote: true, admittedBy: 'ticket', ticket, hostBotId }`: no published friend (`publishedRemoteOf` stays empty, nothing in the sidebar), and `notifyRoomShare` / `fanoutRoomLine` reach it from `hostBotId` only, with `meta.scope` = the room id so the transport sends it under that room (Bot Social's room reach). A Bot Social link is the minting owner's consent to show the room as it is: while a link is open or holds a seat, that owner's Bots pass `mayShareRoom` for the room (`reach.js openedByLink`). Lines and leaves a remote seat sends to a room of this home are ingested into it (`ownSeatOf` → `ingestSeatShare`: logged, fanned out to the other seats but not back to the sender's, called local Bots answer through `rt.startInboundTurn`); a `line` for a room of this home from anyone it does not seat is dropped (`not-seated`) and never projects a copy. A seat ends when its link is withdrawn (`unseatTicket`), when the host removes it or its home leaves (the ticket is spent and revoked at the server once its last seat is gone, `settleTicket`), and when the room is deleted (`rt.dropRoomInvites`, called by `deleteRoom`). On the guest's side one projected room holds every Bot a ticket seated (`sharedFrom.ticket`; each `join` adds or revives only the Bot it names), a seated Bot's line goes from that Bot under the inviter's room id, and the last of its Bots leaving gives the claim back at the server. Reach: `reach.js ticketPeers(state)` lists the only pairs a link opens — `{ from, to, scope, ticket }` for a seat here (from its `hostBotId`), for a room of another home that seated Bots of mine (from those Bots to its inviter), and for a claim this home made (`state.roomClaims[ticket]`, the accepting knock's way out, dropped when the last seated Bot leaves or after a day unanswered). `reach()` allows a local → remote send that matches one exactly under `meta.scope` (the exact handle, never any Bot of that home), and the social plugin reads the same list (`flybot.rooms.ticketPeers()`) for its transport. Covered by `core/test/room-ticket-seat.test.js`. +- Strangers (#884, #878): when the social plugin registers Bot Social's ticket record (`flybot.rooms.serveInvites({ identify, mint, preview, claim, verify, revoke })`, `rt.roomInviteServer`), a mint goes to the server (`server: true`; a server that fails refuses the mint — it is never swapped for a local ticket — and one without `local_room_invites` answers `undefined`, which keeps the friend-only local ticket). The link knocks on `invite.botId`, the room lead when it has a Bot Social identity; a lead with none gets one first through the seam's `identify` (the link is the owner's consent, as accepting a friend's invite is), and so do the Bots a guest brings on Join. On the invitee's side, Join (`room-invite-ask` with `accept: true` and `bots`, the invitee's own Bot ids) claims the ticket at the server for those Bots, all or none, then the first of them sends `{ op: 'ask', ticket, accept: true, roomId, bots }`; `room-invite-preview` (`rt.previewLocalInvite`) reads the server's public record before Join. On the inviter's side the knock is only a request: `seatByTicket` reads the claim back with this home's own credential (`verify`) and seats exactly the claim's Bots, from the claiming home, in this room, or nothing — refusals `unclaimed` / `claimed-elsewhere` / `wrong-room` / `lookup-failed` / `expired` / `revoked` / `not-addable`, each its own. A seat is `{ remote: true, admittedBy: 'ticket', ticket, hostBotId }`: no published friend (`publishedRemoteOf` stays empty, nothing in the sidebar), and `notifyRoomShare` / `fanoutRoomLine` reach it from `hostBotId` only, with `meta.scope` = the room id so the transport sends it under that room (Bot Social's room reach). A Bot Social link is the minting owner's consent to show the room as it is: while a link is open or holds a seat, that owner's Bots pass `mayShareRoom` for the room (`reach.js openedByLink`). Lines and leaves a remote seat sends to a room of this home are ingested into it (`ownSeatOf` → `ingestSeatShare`: logged, fanned out to the other seats but not back to the sender's, called local Bots answer through `rt.startInboundTurn`); a `line` for a room of this home from anyone it does not seat is dropped (`not-seated`) and never projects a copy. A seat ends when its link is withdrawn (`unseatTicket`), when the host removes it or its home leaves (the ticket is spent and revoked at the server once its last seat is gone, `settleTicket`), and when the room is deleted (`rt.dropRoomInvites`, called by `deleteRoom`). On the guest's side a `join` naming a ticket projects only when this home claimed it: `state.roomClaims[ticket]` names that inviter (the transport's sender), that room, and the Bot the join names, else `unclaimed` and nothing is made. One projected room holds every Bot a ticket seated (`sharedFrom.ticket`; each `join` adds or revives only the Bot it names) and the owner as a human participant, so the person the link admitted can write there; their own line goes under their Bot Social owner name, not this home's `You`. Ticket lines carry the text as written (`seatText`: a stored fence is opened, never nested) and the receiving home fences it once. a seated Bot's line goes from that Bot under the inviter's room id, and the last of its Bots leaving gives the claim back at the server. Reach: `reach.js ticketPeers(state)` lists the only pairs a link opens — `{ from, to, scope, ticket }` for a seat here (from its `hostBotId`), for a room of another home that seated Bots of mine (from those Bots to its inviter), and for a claim this home made (`state.roomClaims[ticket]`, the accepting knock's way out, dropped when the last seated Bot leaves or after a day unanswered). `reach()` allows a local → remote send that matches one exactly under `meta.scope` (the exact handle, never any Bot of that home), and the social plugin reads the same list (`flybot.rooms.ticketPeers()`) for its transport. Covered by `core/test/room-ticket-seat.test.js`. ### Face-to-face owner send diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 1beedb7dc..73e730909 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -189,7 +189,7 @@ pins the list. The authority for the list is Bot Social's README and `docs/FACE_ ### A local room's link for a stranger (#883, #884) - Bot Social #60 keeps a local room's invite tickets (`/v1/local-room-invites`, feature `local_room_invites`). `face-rooms.js` `createFaceRoomApi().localInvites` has `mint` / `preview` (with this home's token the answer names the claimer to the minting or claiming owner only) / `claim` / `revoke`, on the same connector token as the face-room routes; a refusal keeps Bot Social's code (`expired`, `revoked`, `already_claimed`, `own_invite`, `invite_not_found`, …). `social.js` registers them as the host's seam (`flybot.rooms.serveInvites`); `verify` is the preview read, and `mint` answers `undefined` on a server without the feature, so the host keeps its friend-only link. -- The connector exception is one pair at a time. `ticketPair(msg)` passes a send only when it is a room share (`via` `room` / `room-share`, never a plain line, no files), from the exact local Bot, to the exact handle, under the room id in `meta.scope`, as `flybot.rooms.ticketPeers()` lists it (a seat here from the Bot its link named; on the guest side, the seated Bots to the room's inviter, and the claim's accepting knock). Such a send skips the roster and the hold (the link is the owner's consent, like adding a friend's Bot to a group) and goes out as `send_as_member` with `room_id` = that scope, which Bot Social's room reach carries and refuses once the link is withdrawn or given back. Every other send to someone off the roster keeps the deny sentence. Covered by `social.test.js` (#883) and `face-rooms.test.js` (#884). +- The connector exception is one pair at a time. `ticketPair(msg)` passes a send only when it is a room share (`via` `room` / `room-share`, never a plain line, no files), from the exact local Bot, to the exact handle, under the room id in `meta.scope`, as `flybot.rooms.ticketPeers()` lists it (a seat here from the Bot its link named; on the guest side, the seated Bots to the room's inviter, and the claim's accepting knock), and only when the text is the host's whole envelope (`FLYBOT-ROOM-SHARE\n{json}`) for that same room id. The roster sync (`leaveMissingRemotes`) never drops a seat a link admitted (`admittedBy: 'ticket'`): its link decides, not the friend list. Such a send skips the roster and the hold (the link is the owner's consent, like adding a friend's Bot to a group) and goes out as `send_as_member` with `room_id` = that scope, which Bot Social's room reach carries and refuses once the link is withdrawn or given back. Every other send to someone off the roster keeps the deny sentence. Covered by `social.test.js` (#883) and `face-rooms.test.js` (#884). ### Discarded promise `.catch` (#213) diff --git a/patches/ds-bot/files/src/host/room-membership.js b/patches/ds-bot/files/src/host/room-membership.js index f250315e1..6e8cb6f61 100644 --- a/patches/ds-bot/files/src/host/room-membership.js +++ b/patches/ds-bot/files/src/host/room-membership.js @@ -38,7 +38,7 @@ import { cleanPeerName, disambiguatedRemoteTitle, isChiefRecord, remoteBotTitle, import { formatFaceRoomTitle, isAutoFaceTitle, faceRoomCreatedAt } from '../shared/face-title.js' import { encodeRoomShare, LOCAL_INVITE_TTL_MS, parseRoomInviteLink, parseRoomShare, roomInviteUrl } from '../shared/room-share.js' import { cleanFaceLead, cleanRoleLabel, faceLeadOf } from './face-lead.js' -import { cleanName, fenceUntrusted, isFencedUntrusted } from './delivery.js' +import { cleanName, fenceUntrusted, isFencedUntrusted, unwrapUntrusted } from './delivery.js' import { hasAnyGrant, mayShareRoom } from './reach.js' import { mentionSpans } from '../shared/mentions.js' @@ -912,6 +912,10 @@ export function install(rt) { } } + // A line as written, for a link's seat (#884): the far home fences it once with `who`, so a fence + // stored here is opened first, never nested (a nested one reads as markup to the person). + const seatText = line => (!line.text ? `[${cleanName(line.kind || 'event')}]` : isFencedUntrusted(line.text) ? unwrapUntrusted(line.text) : String(line.text)) + // #884: a seat a ticket admitted is reached from the Bot its link named, under that room's scope only, // and is told about the room by that Bot. const isTicketSeat = p => p?.remote === true && p.admittedBy === 'ticket' @@ -1547,17 +1551,22 @@ export function install(rt) { const seatedAuthor = room.sharedFrom?.ticket && author && currentParticipants(room).some(p => p.kind === 'bot' && !p.remote && p.id === author) ? author : undefined const sender = seatedAuthor ?? room.sharedFrom?.localBotId ?? shareSpeaker(room) if (!sender) return + // A link's room (#884): the owner's own line is said under their Bot Social name, never this + // home's "You", and the text goes as written: the inviter's home fences it once, on arrival. + const ticketed = Boolean(room.sharedFrom?.ticket) + const ownLine = ticketed && line.authorKind === 'human' && line.remote !== true + const sayer = ownLine ? cleanPeerName(state.bots?.[sender]?.fields?.social?.member?.ownerName, who) : who const result = await sendShareTo(room, { handle: inviter, id: inviter }, { op: 'line', roomId: room.sharedFrom?.remoteRoomId, name: room.name, invitedHandle: inviter, seq: line.seq, - who, + who: sayer, kind: line.kind || 'message', authorKind: line.authorKind, authorId: line.authorId, - text: `${who}: ${body}`, + text: ticketed ? seatText(line) : `${who}: ${body}`, hop: Number(line.hop) || ((Number(room.hop) || 0) + 1), calls: callsIn(room, line.text), ...shareInviter(room), @@ -1592,7 +1601,7 @@ export function install(rt) { kind: line.kind || 'message', authorKind: line.authorKind, authorId: line.authorId, - text: `${who}: ${body}`, + text: seatText(line), hop: Number(line.hop) || (line.authorKind === 'bot' && line.remote !== true ? (Number(room.hop) || 0) + 1 : 0), calls: callsIn(room, line.text), ...seat.from, @@ -1775,6 +1784,14 @@ export function install(rt) { } } const ticket = typeof data.ticket === 'string' && TICKET_SHAPE.test(data.ticket) ? data.ticket : undefined + // #884: a join that says it comes through a link is one this home claimed: that inviter, that room, + // and a Bot the claim brought. A ticket nobody here claimed opens nothing. + if (ticket && data.op === 'join') { + const claim = state.roomClaims?.[ticket] + const same = (a, b) => String(a ?? '').toLowerCase() === String(b ?? '').toLowerCase() && String(a ?? '') !== '' + if (!claim || !same(claim.inviterHandle, event.fromHandle) || String(claim.roomId ?? '') !== String(data.roomId ?? '') + || (localBotId && !(claim.bots ?? []).includes(localBotId))) return { handled: true, refused: 'unclaimed' } + } if (data.op === 'leave') { if (!room) return { handled: true, room: undefined } // #884: a seat of a room that holds several Bots of mine ends for the Bot it names; the room @@ -1917,6 +1934,11 @@ export function install(rt) { inviteNotice: true, maxHops: Number.isFinite(Number(draft.maxHops)) ? Math.max(0, Math.floor(Number(draft.maxHops))) : DEFAULT_MAX_HOPS, participants: [ + // A link admits the person with their Bots (#878): the owner can write in the room it opened. + ...(typeof draft.ticket === 'string' && draft.ticket ? [{ + kind: 'human', id: ownerId, role: 'member', displayName: displayNameFor(state, ownerId, 'You'), + joinedSeq: draft.joinedSeq ?? 0, joinedAt: draft.invitedAt ?? at, + }] : []), ...(localBotId ? [{ kind: 'bot', id: localBotId, diff --git a/patches/ds-bot/files/test/room-ticket-seat.test.js b/patches/ds-bot/files/test/room-ticket-seat.test.js index 2aa5d47c5..0a2db795b 100644 --- a/patches/ds-bot/files/test/room-ticket-seat.test.js +++ b/patches/ds-bot/files/test/room-ticket-seat.test.js @@ -11,7 +11,8 @@ import { install as installMemberAuth } from '../src/host/member-auth.js' import { install as installAuthz } from '../src/host/authz.js' import { install as installRooms, publishedRemoteOf } from '../src/host/room-membership.js' import { install as installReach, mayShareRoom } from '../src/host/reach.js' -import { parseRoomShare } from '../src/shared/room-share.js' +import { encodeRoomShare, parseRoomShare } from '../src/shared/room-share.js' +import { unwrapUntrusted } from '../src/host/delivery.js' const ALICE = { version: 2, @@ -234,7 +235,7 @@ test('#884: a stranger claims a room link with two of its Bots, both are seated, const mirror = guestRoomOf(b.rt) assert.ok(mirror, 'the joins projected the room on the guest side') assert.equal(Object.values(b.rt.state.rooms).filter(r => r.origin === 'inbound-share').length, 1, 'one room for both seats') - assert.deepEqual(mirror.participants.filter(p => p.leftSeq === undefined).map(p => p.id).sort(), ['chief-b', 'helper-b']) + assert.deepEqual(mirror.participants.filter(p => p.leftSeq === undefined && p.kind === 'bot').map(p => p.id).sort(), ['chief-b', 'helper-b']) assert.equal(mirror.sharedFrom.remoteRoomId, 'war') assert.equal(mirror.sharedFrom.ticket, invite.ticket) assert.equal(mirror.sharedFrom.inviterName, 'Alice', 'the guest reads the owner, never the host’s own "You"') @@ -384,3 +385,52 @@ test('#884: a home with no Bot Social identity yet gets one for the link it make delete b.rt.state.bots['chief-b'].fields await assert.rejects(() => b.rt.askLocalInvite(invite.url, { accept: true, bots: ['chief-b'] }, { memberId: 'owner-1' }), /could not give this Bot an identity/) }) + +test('#884: the guest writes in the room the link opened, under its own name, and no line nests a fence', async (t) => { + const { a, b, settle } = await twoHomes(t) + const invite = await a.rt.mintRoomInvite('war', { memberId: 'owner-1' }) + await b.rt.askLocalInvite(invite.url, { accept: true, bots: ['helper-b'] }, { memberId: 'owner-1' }) + await settle() + const room = a.rt.state.rooms.war + const mirror = guestRoomOf(b.rt) + // One link admits the person with their Bot (#878): Bob is in the room it opened on his side. + assert.ok(mirror.participants.some(p => p.kind === 'human' && p.id === 'owner-1' && p.leftSeq === undefined), 'the guest owner has a seat to write from') + + const line = await a.rt.postRoomMessage('war', { text: 'Ship on Friday?', authorId: 'owner-1', authorKind: 'human', displayName: 'Alice' }) + await settle() + const arrived = mirror.log.find(entry => String(entry.text).includes('Ship on Friday?')) + assert.equal(unwrapUntrusted(arrived.text), 'Ship on Friday?', 'fenced once on arrival, with nothing nested or prefixed (live run: a garbled fence)') + assert.equal((String(arrived.text).match(/untrusted-message/g) ?? []).length, 2) + assert.ok(line) + + const own = await b.rt.postRoomMessage(mirror.id, { text: 'See you there.', authorId: 'owner-1', authorKind: 'human' }) + assert.ok(own, 'the guest owner may post in the mirror') + await settle() + const landed = room.log.find(entry => String(entry.text).includes('See you there.')) + assert.ok(landed, "the guest's own line reached the host room") + assert.equal(landed.who, 'Bob', 'said under his Bot Social name, never his home\'s "You"') + assert.equal(landed.authorKind, 'human') + assert.equal(landed.authorId, '@helper.bob.home#owner-1') + assert.equal(unwrapUntrusted(landed.text), 'See you there.') +}) + +test('#884: a join that names a ticket this home never claimed opens nothing', async (t) => { + const { a, b } = await twoHomes(t) + const before = Object.keys(b.rt.state.rooms).length + const forged = encodeRoomShare({ op: 'join', roomId: 'war', name: 'Free stuff', invitedHandle: '@helper.bob.home', ticket: 'TktZZZZAbCdEfGhIjKlMnO', inviterHandle: '@chief.alice.home', inviterName: 'Alice' }) + const out = await b.rt.applyInboundRoomShare({ text: forged, fromHandle: '@chief.alice.home', fromHome: '@alice.home', fromName: 'Bot', localBotId: 'helper-b' }) + assert.equal(out.refused, 'unclaimed') + assert.equal(Object.keys(b.rt.state.rooms).length, before, 'no room was projected') + // A real claim, but a join from another Bot of the inviter's home, or for another room, is refused too. + const invite = await a.rt.mintRoomInvite('war', { memberId: 'owner-1' }) + b.rt.flybotSend = async () => ({ action: 'sent' }) + await b.rt.askLocalInvite(invite.url, { accept: true, bots: ['helper-b'] }, { memberId: 'owner-1' }) + const join = extra => encodeRoomShare({ op: 'join', roomId: 'war', name: 'Launch plan', invitedHandle: '@helper.bob.home', ticket: invite.ticket, inviterHandle: '@chief.alice.home', inviterName: 'Alice', ...extra }) + assert.equal((await b.rt.applyInboundRoomShare({ text: join(), fromHandle: '@writer.alice.home', fromHome: '@alice.home', fromName: 'Bot', localBotId: 'helper-b' })).refused, 'unclaimed') + assert.equal((await b.rt.applyInboundRoomShare({ text: join({ roomId: 'other' }), fromHandle: '@chief.alice.home', fromHome: '@alice.home', fromName: 'Bot', localBotId: 'helper-b' })).refused, 'unclaimed') + assert.equal((await b.rt.applyInboundRoomShare({ text: join(), fromHandle: '@chief.alice.home', fromHome: '@alice.home', fromName: 'Bot', localBotId: 'chief-b' })).refused, 'unclaimed', 'a Bot the claim did not bring') + assert.equal(Object.keys(b.rt.state.rooms).length, before) + const ok = await b.rt.applyInboundRoomShare({ text: join(), fromHandle: '@chief.alice.home', fromHome: '@alice.home', fromName: 'Bot', localBotId: 'helper-b' }) + assert.equal(ok.refused, undefined) + assert.equal(Object.keys(b.rt.state.rooms).length, before + 1, 'the claimed join projects the room') +}) diff --git a/plugins/social/src/social.js b/plugins/social/src/social.js index 5098621da..392e904d2 100644 --- a/plugins/social/src/social.js +++ b/plugins/social/src/social.js @@ -1297,6 +1297,9 @@ export function createSocial(service, config = {}, { client: given, api: givenAp if (room.origin === 'face-to-face' || room.origin === 'inbound-share') continue for (const p of room.participants ?? []) { if (!p.remote || p.leftSeq !== undefined || !p.handle) continue + // A seat a room link admitted is never a friend's Bot (#884): the roster does not list it, and + // its link, not the roster, decides when it goes. + if (p.admittedBy === 'ticket') continue const key = norm(p.handle) if (live.has(key) || isHiddenRemote(settings, key)) continue try { @@ -1546,9 +1549,25 @@ export function createSocial(service, config = {}, { client: given, api: givenAp if (msg.via !== 'room' && msg.via !== 'room-share') return undefined const scope = typeof msg.meta?.scope === 'string' ? msg.meta.scope : '' if (!scope || typeof msg.from !== 'string' || msg.attachments?.length) return undefined + // The text must be the host's own envelope for that same room, whole: a plain line wearing the + // `via` and the scope is not room traffic. + const share = roomShareEnvelope(msg.text) + if (!share || String(share.roomId ?? '') !== scope) return undefined const to = norm(msg.target?.handle) return (flybot.rooms?.ticketPeers?.() ?? []).find(peer => peer.from === msg.from && peer.scope === scope && norm(peer.to) === to) } + // `FLYBOT-ROOM-SHARE\n{json}` exactly as core's encodeRoomShare writes it (src/shared/room-share.js). + function roomShareEnvelope(text) { + const raw = String(text ?? '') + const head = 'FLYBOT-ROOM-SHARE\n' + if (!raw.startsWith(head)) return undefined + try { + const data = JSON.parse(raw.slice(head.length)) + return data && data.v === 1 && ['join', 'leave', 'line', 'ask', 'invite'].includes(data.op) ? data : undefined + } catch { + return undefined + } + } // Sent under the room's id: Bot Social's room reach (the claim's grant pair) carries it, and refuses // it once the link is withdrawn or given back. async function sendTicketShare(msg, pair) { diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index 15594a6ab..ca6793ac8 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -10184,10 +10184,20 @@ test('#883: a room share reaches a seat a link opened, under that room only, and ['no scope', { from: 'a', to: '@guest.eve.home', text: share, via: 'room', meta: { roomId: 'war' } }], ['another Bot of mine', { from: 'b', to: '@guest.eve.home', text: share, via: 'room', meta: { roomId: 'war', scope: 'war' } }], ['another Bot of theirs', { from: 'a', to: '@other.eve.home', text: share, via: 'room', meta: { roomId: 'war', scope: 'war' } }], + // The text itself must be the host's envelope for that room, whole. + ['plain text wearing the room via', { from: 'a', to: '@guest.eve.home', text: 'hello there', via: 'room', meta: { roomId: 'war', scope: 'war' } }], + ['an envelope for another room', { from: 'a', to: '@guest.eve.home', text: share.replace('"war"', '"other"'), via: 'room', meta: { roomId: 'war', scope: 'war' } }], + ['the marker inside a plain line', { from: 'a', to: '@guest.eve.home', text: `look: ${share}`, via: 'room', meta: { roomId: 'war', scope: 'war' } }], ] for (const [why, msg] of refused) { const out = await ctx.flybot.bot.send(msg) assert.equal(out.action, 'deny', `${why}: ${JSON.stringify(out)}`) } assert.equal(sends().length, 1, 'none of those reached Bot Social') + // The roster sync drops friends' Bots that are no longer listed; a seat is not one (live two-home run: + // the guest's Bot "left" the room right after the first line). + await social.remoteBots({ fresh: true }) + for (let i = 0; i < 5; i += 1) await new Promise(resolve => setImmediate(resolve)) + const seat = ctx.flybot.rooms.list().find(r => r.id === 'war').participants.find(p => p.handle === '@guest.eve.home') + assert.equal(seat.leftSeq, undefined, 'the seat stays: its link decides, not the roster') }) From 57fcbc9c81c865e241f4ce3f9d4be658ed9a633f Mon Sep 17 00:00:00 2001 From: Birfy <15378933+Birfy@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:07:54 +0000 Subject: [PATCH 3/3] fix(rooms): a person with no name here is said by their Bot Social name on a link's seat (#884) --- core/src/host/room-membership.js | 12 +++++++++--- core/test/room-ticket-seat.test.js | 3 ++- docs/subsystems/core-host.md | 2 +- patches/ds-bot/files/src/host/room-membership.js | 12 +++++++++--- patches/ds-bot/files/test/room-ticket-seat.test.js | 3 ++- 5 files changed, 23 insertions(+), 9 deletions(-) diff --git a/core/src/host/room-membership.js b/core/src/host/room-membership.js index 6e8cb6f61..c07a27e39 100644 --- a/core/src/host/room-membership.js +++ b/core/src/host/room-membership.js @@ -914,6 +914,13 @@ export function install(rt) { // A line as written, for a link's seat (#884): the far home fences it once with `who`, so a fence // stored here is opened first, never nested (a nested one reads as markup to the person). + // Who a person's own line is said by on a link's seat: a name they gave stays; this home's own + // placeholder (`You`, or the bare member id) becomes the owner's Bot Social name. + const seatSayer = (line, who, botId) => { + if (line.authorKind !== 'human' || line.remote === true) return who + if (who !== 'You' && who !== cleanName(String(line.authorId ?? ''))) return who + return cleanPeerName(state.bots?.[botId]?.fields?.social?.member?.ownerName, who) + } const seatText = line => (!line.text ? `[${cleanName(line.kind || 'event')}]` : isFencedUntrusted(line.text) ? unwrapUntrusted(line.text) : String(line.text)) // #884: a seat a ticket admitted is reached from the Bot its link named, under that room's scope only, @@ -1554,8 +1561,7 @@ export function install(rt) { // A link's room (#884): the owner's own line is said under their Bot Social name, never this // home's "You", and the text goes as written: the inviter's home fences it once, on arrival. const ticketed = Boolean(room.sharedFrom?.ticket) - const ownLine = ticketed && line.authorKind === 'human' && line.remote !== true - const sayer = ownLine ? cleanPeerName(state.bots?.[sender]?.fields?.social?.member?.ownerName, who) : who + const sayer = ticketed ? seatSayer(line, who, sender) : who const result = await sendShareTo(room, { handle: inviter, id: inviter }, { op: 'line', roomId: room.sharedFrom?.remoteRoomId, @@ -1597,7 +1603,7 @@ export function install(rt) { name: room.name, invitedHandle: remote.handle, seq: line.seq, - who, + who: seatSayer(line, who, seat.botId), kind: line.kind || 'message', authorKind: line.authorKind, authorId: line.authorId, diff --git a/core/test/room-ticket-seat.test.js b/core/test/room-ticket-seat.test.js index 0a2db795b..08990ce58 100644 --- a/core/test/room-ticket-seat.test.js +++ b/core/test/room-ticket-seat.test.js @@ -396,9 +396,10 @@ test('#884: the guest writes in the room the link opened, under its own name, an // One link admits the person with their Bot (#878): Bob is in the room it opened on his side. assert.ok(mirror.participants.some(p => p.kind === 'human' && p.id === 'owner-1' && p.leftSeq === undefined), 'the guest owner has a seat to write from') - const line = await a.rt.postRoomMessage('war', { text: 'Ship on Friday?', authorId: 'owner-1', authorKind: 'human', displayName: 'Alice' }) + const line = await a.rt.postRoomMessage('war', { text: 'Ship on Friday?', authorId: 'owner-1', authorKind: 'human' }) await settle() const arrived = mirror.log.find(entry => String(entry.text).includes('Ship on Friday?')) + assert.equal(arrived.who, 'Alice', 'the host owner with no name here is said by their Bot Social name, not "owner-1" or "You"') assert.equal(unwrapUntrusted(arrived.text), 'Ship on Friday?', 'fenced once on arrival, with nothing nested or prefixed (live run: a garbled fence)') assert.equal((String(arrived.text).match(/untrusted-message/g) ?? []).length, 2) assert.ok(line) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index b168a5d17..dd74e5830 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -154,7 +154,7 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi - The room record is the only authority: `room.localInvites [{ ticket, handle, botId, expiresAt, mintedAt, mintedBy, server?, url?, acceptedBy?, acceptedAt?, seats? }]`, ten minutes each (`LOCAL_INVITE_TTL_MS`, the face-room window). Several may be live at once, one per person invited (#878); a record written before the list (`room.localInvite`) is moved into it on first read. `rt.mintRoomInvite(roomId, actor)` adds one (dead links that seated nobody are dropped then); `rt.localInvitesOf(roomId)` lists the open ones (unexpired, unspent) and `rt.localInviteOf(roomId)` is the newest; `rt.roomOfLocalInvite(ticket)` resolves the room from the ticket; `rt.revokeRoomInvite(roomId, ticket?)` withdraws one link, or without a ticket every unused one. A shared (`inbound-share`) or face-to-face room refuses to mint — it has its own invitation. - Three steps on the same envelope, answered by the room record: the invitee's host sends `{ op: 'ask', ticket }`; the inviter answers `{ op: 'invite', ticket, roomId, name, lead, expiresAt }`, which is information only (it consumes nothing and seats nobody); the accepting knock `{ op: 'ask', ticket, accept: true }` runs verify → consume → add → the existing `join`, which `addRoomParticipant` sends. `applyInboundRoomShare` handles both ops **before** the branch that projects a room for an unmatched `join` / `line`, so a knock never conjures a room; `parseRoomShare` accepts `ask` without a `roomId` and requires one on `invite`. The asker is `event.fromHandle` (the transport's sender), never a field of the payload. `acceptedBy` is written once, at accept: the same home retrying is idempotent success (the lost `join` reply is sent again) and another home is refused; an add the room refuses rolls the claim back, so no refusal burns the ticket. Refusals are local on the inviter's host (`unknown` / `expired` / `consumed` / `no-sender` / `not-addable`) and change nothing. The UI half — the Members card's **Invite by link** row and the dialog the offer draws — is still to come. Covered by `core/test/remote-room.test.js` (#852). - The endpoints the UI calls (#876): `room-invite` reads the live link (`rt.localInviteOf`, what the Members card copies and counts down), `room-invite-mint` and `room-invite-revoke` are the room owner's or a human admin's call (`authz.js`), and `room-invite-ask` is the invitee's side: `rt.askLocalInvite` reads the handle and ticket out of the link and sends `{ op: 'ask', ticket }` from one of the actor's own Bots whose home has a Bot Social identity — the link cannot name a sender, so the transport does, as on the answering side. Join is the same call with `accept: true`, and the answer arrives as `invite` through `applyInboundRoomShare` (`rt.onLocalInviteOffer`). A face-room link, a bare ticket and a ticket outside the 16–64 shape are refused in one sentence and send nothing; the knock carries no `roomId`, so an invitee can never name a room it has not seen. -- Strangers (#884, #878): when the social plugin registers Bot Social's ticket record (`flybot.rooms.serveInvites({ identify, mint, preview, claim, verify, revoke })`, `rt.roomInviteServer`), a mint goes to the server (`server: true`; a server that fails refuses the mint — it is never swapped for a local ticket — and one without `local_room_invites` answers `undefined`, which keeps the friend-only local ticket). The link knocks on `invite.botId`, the room lead when it has a Bot Social identity; a lead with none gets one first through the seam's `identify` (the link is the owner's consent, as accepting a friend's invite is), and so do the Bots a guest brings on Join. On the invitee's side, Join (`room-invite-ask` with `accept: true` and `bots`, the invitee's own Bot ids) claims the ticket at the server for those Bots, all or none, then the first of them sends `{ op: 'ask', ticket, accept: true, roomId, bots }`; `room-invite-preview` (`rt.previewLocalInvite`) reads the server's public record before Join. On the inviter's side the knock is only a request: `seatByTicket` reads the claim back with this home's own credential (`verify`) and seats exactly the claim's Bots, from the claiming home, in this room, or nothing — refusals `unclaimed` / `claimed-elsewhere` / `wrong-room` / `lookup-failed` / `expired` / `revoked` / `not-addable`, each its own. A seat is `{ remote: true, admittedBy: 'ticket', ticket, hostBotId }`: no published friend (`publishedRemoteOf` stays empty, nothing in the sidebar), and `notifyRoomShare` / `fanoutRoomLine` reach it from `hostBotId` only, with `meta.scope` = the room id so the transport sends it under that room (Bot Social's room reach). A Bot Social link is the minting owner's consent to show the room as it is: while a link is open or holds a seat, that owner's Bots pass `mayShareRoom` for the room (`reach.js openedByLink`). Lines and leaves a remote seat sends to a room of this home are ingested into it (`ownSeatOf` → `ingestSeatShare`: logged, fanned out to the other seats but not back to the sender's, called local Bots answer through `rt.startInboundTurn`); a `line` for a room of this home from anyone it does not seat is dropped (`not-seated`) and never projects a copy. A seat ends when its link is withdrawn (`unseatTicket`), when the host removes it or its home leaves (the ticket is spent and revoked at the server once its last seat is gone, `settleTicket`), and when the room is deleted (`rt.dropRoomInvites`, called by `deleteRoom`). On the guest's side a `join` naming a ticket projects only when this home claimed it: `state.roomClaims[ticket]` names that inviter (the transport's sender), that room, and the Bot the join names, else `unclaimed` and nothing is made. One projected room holds every Bot a ticket seated (`sharedFrom.ticket`; each `join` adds or revives only the Bot it names) and the owner as a human participant, so the person the link admitted can write there; their own line goes under their Bot Social owner name, not this home's `You`. Ticket lines carry the text as written (`seatText`: a stored fence is opened, never nested) and the receiving home fences it once. a seated Bot's line goes from that Bot under the inviter's room id, and the last of its Bots leaving gives the claim back at the server. Reach: `reach.js ticketPeers(state)` lists the only pairs a link opens — `{ from, to, scope, ticket }` for a seat here (from its `hostBotId`), for a room of another home that seated Bots of mine (from those Bots to its inviter), and for a claim this home made (`state.roomClaims[ticket]`, the accepting knock's way out, dropped when the last seated Bot leaves or after a day unanswered). `reach()` allows a local → remote send that matches one exactly under `meta.scope` (the exact handle, never any Bot of that home), and the social plugin reads the same list (`flybot.rooms.ticketPeers()`) for its transport. Covered by `core/test/room-ticket-seat.test.js`. +- Strangers (#884, #878): when the social plugin registers Bot Social's ticket record (`flybot.rooms.serveInvites({ identify, mint, preview, claim, verify, revoke })`, `rt.roomInviteServer`), a mint goes to the server (`server: true`; a server that fails refuses the mint — it is never swapped for a local ticket — and one without `local_room_invites` answers `undefined`, which keeps the friend-only local ticket). The link knocks on `invite.botId`, the room lead when it has a Bot Social identity; a lead with none gets one first through the seam's `identify` (the link is the owner's consent, as accepting a friend's invite is), and so do the Bots a guest brings on Join. On the invitee's side, Join (`room-invite-ask` with `accept: true` and `bots`, the invitee's own Bot ids) claims the ticket at the server for those Bots, all or none, then the first of them sends `{ op: 'ask', ticket, accept: true, roomId, bots }`; `room-invite-preview` (`rt.previewLocalInvite`) reads the server's public record before Join. On the inviter's side the knock is only a request: `seatByTicket` reads the claim back with this home's own credential (`verify`) and seats exactly the claim's Bots, from the claiming home, in this room, or nothing — refusals `unclaimed` / `claimed-elsewhere` / `wrong-room` / `lookup-failed` / `expired` / `revoked` / `not-addable`, each its own. A seat is `{ remote: true, admittedBy: 'ticket', ticket, hostBotId }`: no published friend (`publishedRemoteOf` stays empty, nothing in the sidebar), and `notifyRoomShare` / `fanoutRoomLine` reach it from `hostBotId` only, with `meta.scope` = the room id so the transport sends it under that room (Bot Social's room reach). A Bot Social link is the minting owner's consent to show the room as it is: while a link is open or holds a seat, that owner's Bots pass `mayShareRoom` for the room (`reach.js openedByLink`). Lines and leaves a remote seat sends to a room of this home are ingested into it (`ownSeatOf` → `ingestSeatShare`: logged, fanned out to the other seats but not back to the sender's, called local Bots answer through `rt.startInboundTurn`); a `line` for a room of this home from anyone it does not seat is dropped (`not-seated`) and never projects a copy. A seat ends when its link is withdrawn (`unseatTicket`), when the host removes it or its home leaves (the ticket is spent and revoked at the server once its last seat is gone, `settleTicket`), and when the room is deleted (`rt.dropRoomInvites`, called by `deleteRoom`). On the guest's side a `join` naming a ticket projects only when this home claimed it: `state.roomClaims[ticket]` names that inviter (the transport's sender), that room, and the Bot the join names, else `unclaimed` and nothing is made. One projected room holds every Bot a ticket seated (`sharedFrom.ticket`; each `join` adds or revives only the Bot it names) and the owner as a human participant, so the person the link admitted can write there; a person's own line goes under their Bot Social owner name when this home only knows them as `You` or their member id (`seatSayer`, both directions). Ticket lines carry the text as written (`seatText`: a stored fence is opened, never nested) and the receiving home fences it once. a seated Bot's line goes from that Bot under the inviter's room id, and the last of its Bots leaving gives the claim back at the server. Reach: `reach.js ticketPeers(state)` lists the only pairs a link opens — `{ from, to, scope, ticket }` for a seat here (from its `hostBotId`), for a room of another home that seated Bots of mine (from those Bots to its inviter), and for a claim this home made (`state.roomClaims[ticket]`, the accepting knock's way out, dropped when the last seated Bot leaves or after a day unanswered). `reach()` allows a local → remote send that matches one exactly under `meta.scope` (the exact handle, never any Bot of that home), and the social plugin reads the same list (`flybot.rooms.ticketPeers()`) for its transport. Covered by `core/test/room-ticket-seat.test.js`. ### Face-to-face owner send diff --git a/patches/ds-bot/files/src/host/room-membership.js b/patches/ds-bot/files/src/host/room-membership.js index 6e8cb6f61..c07a27e39 100644 --- a/patches/ds-bot/files/src/host/room-membership.js +++ b/patches/ds-bot/files/src/host/room-membership.js @@ -914,6 +914,13 @@ export function install(rt) { // A line as written, for a link's seat (#884): the far home fences it once with `who`, so a fence // stored here is opened first, never nested (a nested one reads as markup to the person). + // Who a person's own line is said by on a link's seat: a name they gave stays; this home's own + // placeholder (`You`, or the bare member id) becomes the owner's Bot Social name. + const seatSayer = (line, who, botId) => { + if (line.authorKind !== 'human' || line.remote === true) return who + if (who !== 'You' && who !== cleanName(String(line.authorId ?? ''))) return who + return cleanPeerName(state.bots?.[botId]?.fields?.social?.member?.ownerName, who) + } const seatText = line => (!line.text ? `[${cleanName(line.kind || 'event')}]` : isFencedUntrusted(line.text) ? unwrapUntrusted(line.text) : String(line.text)) // #884: a seat a ticket admitted is reached from the Bot its link named, under that room's scope only, @@ -1554,8 +1561,7 @@ export function install(rt) { // A link's room (#884): the owner's own line is said under their Bot Social name, never this // home's "You", and the text goes as written: the inviter's home fences it once, on arrival. const ticketed = Boolean(room.sharedFrom?.ticket) - const ownLine = ticketed && line.authorKind === 'human' && line.remote !== true - const sayer = ownLine ? cleanPeerName(state.bots?.[sender]?.fields?.social?.member?.ownerName, who) : who + const sayer = ticketed ? seatSayer(line, who, sender) : who const result = await sendShareTo(room, { handle: inviter, id: inviter }, { op: 'line', roomId: room.sharedFrom?.remoteRoomId, @@ -1597,7 +1603,7 @@ export function install(rt) { name: room.name, invitedHandle: remote.handle, seq: line.seq, - who, + who: seatSayer(line, who, seat.botId), kind: line.kind || 'message', authorKind: line.authorKind, authorId: line.authorId, diff --git a/patches/ds-bot/files/test/room-ticket-seat.test.js b/patches/ds-bot/files/test/room-ticket-seat.test.js index 0a2db795b..08990ce58 100644 --- a/patches/ds-bot/files/test/room-ticket-seat.test.js +++ b/patches/ds-bot/files/test/room-ticket-seat.test.js @@ -396,9 +396,10 @@ test('#884: the guest writes in the room the link opened, under its own name, an // One link admits the person with their Bot (#878): Bob is in the room it opened on his side. assert.ok(mirror.participants.some(p => p.kind === 'human' && p.id === 'owner-1' && p.leftSeq === undefined), 'the guest owner has a seat to write from') - const line = await a.rt.postRoomMessage('war', { text: 'Ship on Friday?', authorId: 'owner-1', authorKind: 'human', displayName: 'Alice' }) + const line = await a.rt.postRoomMessage('war', { text: 'Ship on Friday?', authorId: 'owner-1', authorKind: 'human' }) await settle() const arrived = mirror.log.find(entry => String(entry.text).includes('Ship on Friday?')) + assert.equal(arrived.who, 'Alice', 'the host owner with no name here is said by their Bot Social name, not "owner-1" or "You"') assert.equal(unwrapUntrusted(arrived.text), 'Ship on Friday?', 'fenced once on arrival, with nothing nested or prefixed (live run: a garbled fence)') assert.equal((String(arrived.text).match(/untrusted-message/g) ?? []).length, 2) assert.ok(line)