From 4fafe2c5ce8c6082be1831e1247d530baca916fd Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Fri, 23 Oct 2026 22:11:36 +0800 Subject: [PATCH 1/2] feat(social): a local room's invite card, and the endpoints its dialog calls (#876) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Members card and the join dialog need the link to be something the host answers for, not just the envelope layer. `room-invite` reads the live link, `room-invite-mint` and `room-invite-revoke` are the room owner's or a human admin's call (`authz.js`), and `room-invite-ask` knocks at a link someone else handed you: `rt.askLocalInvite` sends `{ op: 'ask', ticket }` and the same knock with `accept: true` on Join, 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. A face-room link, a bare ticket and a ticket outside the 16–64 shape are refused in one sentence and send nothing, and a knock carries no `roomId`, so an invitee can never name a room it has not seen. The invite preview card moves into core as one component: `invitePreviewOf` says only what a server reply can honestly say (a name cleaned at fixed point, the lead, the seat count, the deadline), `inviteCountdown` words the deadline, and `RoomInvitePreview` draws the tree from a caller-supplied `h`. Both surfaces render it — the face-room paste flow (`face-link-preview`) and the member-invite dialog (`room-invite-preview`) — through `__flybot__`, because a classic-script plugin cannot import a core module; `kind` renames only the `data-flybot` hooks, so the two surfaces draw the same tree instead of two copies of the markup. Docs: core-host (the endpoints), core-client and plugin-runtime (the shared card), social (the card's one owner). changelog.d/876. --- .../876-room-invite-endpoints.added.md | 1 + core/client.js | 84 +++++ core/src/client/flybot-slots.js | 6 + core/src/client/room-invite-preview.js | 111 +++++++ core/src/host/authz.js | 21 +- core/src/host/channel.js | 20 ++ core/src/host/room-membership.js | 36 ++- core/test/authz.test.js | 11 + core/test/remote-room.test.js | 49 +++ core/test/room-invite-preview.test.js | 79 +++++ core/types/flybot.d.ts | 7 + docs/subsystems/core-client.md | 1 + docs/subsystems/core-host.md | 1 + docs/subsystems/plugin-runtime.md | 2 +- docs/subsystems/social.md | 2 +- patches/ds-bot/0005-social-seams.patch | 22 +- patches/ds-bot/0008-client-bundle.patch | 291 ++++++++++++------ .../ds-bot/files/src/client/flybot-slots.js | 6 + .../files/src/client/room-invite-preview.js | 111 +++++++ patches/ds-bot/files/src/host/authz.js | 21 +- .../ds-bot/files/src/host/room-membership.js | 36 ++- patches/ds-bot/files/test/authz.test.js | 11 + patches/ds-bot/files/test/remote-room.test.js | 49 +++ .../files/test/room-invite-preview.test.js | 79 +++++ patches/ds-bot/files/types/flybot.d.ts | 7 + plugins/social/client.js | 51 +-- plugins/social/test/social.test.js | 19 ++ 27 files changed, 989 insertions(+), 145 deletions(-) create mode 100644 changelog.d/876-room-invite-endpoints.added.md create mode 100644 core/src/client/room-invite-preview.js create mode 100644 core/test/room-invite-preview.test.js create mode 100644 patches/ds-bot/files/src/client/room-invite-preview.js create mode 100644 patches/ds-bot/files/test/room-invite-preview.test.js diff --git a/changelog.d/876-room-invite-endpoints.added.md b/changelog.d/876-room-invite-endpoints.added.md new file mode 100644 index 00000000..b00fb65c --- /dev/null +++ b/changelog.d/876-room-invite-endpoints.added.md @@ -0,0 +1 @@ +A room's invite link now has the endpoints the Members card works through: read the live link, mint one, withdraw it, and knock at a link someone else handed you — first for the preview, then again with `accept: true` when Join is clicked ([#876](https://github.com/ThinkFlowLab/FlyBot/issues/876)). The knock leaves from one of your own Bots and names the Bot to knock on from the link, never from the request body; a link into a room on another FlyBot, a bare ticket, and a ticket outside the 16–64 character shape are each refused in one sentence and send nothing. diff --git a/core/client.js b/core/client.js index 2b5aa444..5265dff4 100644 --- a/core/client.js +++ b/core/client.js @@ -2142,6 +2142,87 @@ function RoomAvatar({ room: room2, roster, size = 36 }) { ); } +// src/client/room-invite-preview.js +var INVITE_PREVIEW_NAME_MAX = 60; +function stripInviteMarkers(text) { + let out = String(text ?? ""); + for (let previous; previous !== out; ) { + previous = out; + out = out.replace(/<\/?\s*untrusted-(?:file|message)/gi, ""); + } + return out; +} +function cleanInviteName(value, fallback = "") { + return stripInviteMarkers(value).replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, " ").replace(/[[\]"<>`@]/g, "").replace(/\s+/g, " ").trim().slice(0, INVITE_PREVIEW_NAME_MAX) || fallback; +} +function inviteMemberCount(value) { + if (typeof value === "number") return Number.isFinite(value) ? value : NaN; + if (typeof value !== "string") return NaN; + const text = value.trim(); + if (!text) return NaN; + const count = Number(text); + return Number.isFinite(count) ? count : NaN; +} +function invitePreviewOf(seen) { + if (!seen || typeof seen !== "object" || seen.unsupported === true) return null; + const name = cleanInviteName(seen.name); + const lead = cleanInviteName(seen.lead); + const members = inviteMemberCount(seen.members); + const maxMembers = inviteMemberCount(seen.maxMembers); + const people2 = Number.isFinite(members) && Number.isFinite(maxMembers) ? `${members} / ${maxMembers} in the room` : Number.isFinite(members) ? `${members} in the room` : ""; + const expiresAt = typeof seen.expiresAt === "string" ? seen.expiresAt : ""; + if (!name && !lead && !people2 && !expiresAt) return null; + return { name, lead, people: people2, expiresAt, clockOffsetMs: Number(seen.clockOffsetMs) || 0 }; +} +function inviteCountdown(expiresAt, offsetMs = 0) { + const at = Date.parse(expiresAt) - (Number(offsetMs) || 0); + if (!Number.isFinite(at)) return "Open while the room is open"; + const left = Math.max(0, at - Date.now()); + if (left <= 0) return "This link has expired"; + const total = Math.max(1, Math.ceil(left / 1e3)); + return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")} left to join`; +} +function RoomInvitePreview({ h: h42, preview, kind = "face-link-preview", busy = false, joinLabel = "Join", clearLabel = "Clear", showClear = true, onJoin, onClear, now } = {}) { + if (!preview || typeof h42 !== "function") return null; + const at = `${kind}`; + const child = (name) => `${at}-${name}`; + const countdown = now === void 0 ? inviteCountdown(preview.expiresAt, preview.clockOffsetMs) : inviteCountdownAt(preview.expiresAt, preview.clockOffsetMs, now); + return h42( + "div", + { className: "fs-preview", "data-flybot": at, role: "status" }, + h42("div", { className: "fs-preview-name", "data-flybot": child("name") }, preview.name || "Face-to-face room"), + preview.lead ? h42("div", { className: "fs-preview-meta", "data-flybot": child("lead") }, `Lead: ${preview.lead}`) : null, + preview.people ? h42("div", { className: "fs-preview-meta", "data-flybot": child("members") }, preview.people) : null, + preview.expiresAt ? h42("div", { className: "fs-preview-meta", "data-flybot": child("expiry") }, countdown) : null, + onJoin || onClear ? h42( + "div", + { className: "fs-preview-foot", "data-flybot": child("foot") }, + showClear && onClear ? h42("button", { + type: "button", + className: "fs-btn fs-btn-sm", + "data-flybot": child("clear"), + disabled: !!busy, + onClick: onClear + }, clearLabel) : null, + onJoin ? h42("button", { + type: "button", + className: "fs-btn fs-btn-sm fs-btn-primary", + "data-flybot": child("join"), + disabled: !!busy, + onClick: onJoin + }, joinLabel) : null + ) : null + ); +} +function inviteCountdownAt(expiresAt, offsetMs, now) { + const at = Date.parse(expiresAt) - (Number(offsetMs) || 0); + if (!Number.isFinite(at)) return "Open while the room is open"; + const left = Math.max(0, at - Number(now || 0)); + if (left <= 0) return "This link has expired"; + const total = Math.max(1, Math.ceil(left / 1e3)); + return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")} left to join`; +} + // src/client/approval-claims.js var claims = /* @__PURE__ */ new Map(); var claimListeners = /* @__PURE__ */ new Set(); @@ -2183,6 +2264,9 @@ function registry2() { globalThis.__flybot__.BotMark ??= BotMark; globalThis.__flybot__.BotAvatar ??= BotAvatar; globalThis.__flybot__.lookOf ??= lookOf; + globalThis.__flybot__.RoomInvitePreview ??= RoomInvitePreview; + globalThis.__flybot__.invitePreviewOf ??= invitePreviewOf; + globalThis.__flybot__.inviteCountdown ??= inviteCountdown; globalThis.__flybot__.names ??= { friendBotTitle, friendBotOwner }; return slots; } diff --git a/core/src/client/flybot-slots.js b/core/src/client/flybot-slots.js index 097a9903..44e1df97 100644 --- a/core/src/client/flybot-slots.js +++ b/core/src/client/flybot-slots.js @@ -34,6 +34,7 @@ import { call as callHost } from './api.js' import { friendBotOwner, friendBotTitle, isListedRemoteChat } from '../shared/remote-label.js' import { registry as baseRegistry } from './flybot-registry.js' import { BotAvatar, BotMark, lookOf } from './mark.js' +import { RoomInvitePreview, inviteCountdown, invitePreviewOf } from './room-invite-preview.js' import { t } from './i18n.js' import { chatOpened, claimApprovals, claimedIds, onClaims } from './approval-claims.js' import { isApprovalWaiting, WAITING_STATUSES } from '../shared/approval-waiting.js' @@ -54,6 +55,11 @@ export function registry() { globalThis.__flybot__.BotMark ??= BotMark globalThis.__flybot__.BotAvatar ??= BotAvatar globalThis.__flybot__.lookOf ??= lookOf + // One room-invite preview card for every surface that shows an invite (#876): the face-room paste + // flow and the member-invite dialog render this, so the markup is not written twice. + globalThis.__flybot__.RoomInvitePreview ??= RoomInvitePreview + globalThis.__flybot__.invitePreviewOf ??= invitePreviewOf + globalThis.__flybot__.inviteCountdown ??= inviteCountdown // The same for a friend's Bot's one name and its owner (#498), so the social plugin says it as the sidebar does. globalThis.__flybot__.names ??= { friendBotTitle, friendBotOwner } return slots diff --git a/core/src/client/room-invite-preview.js b/core/src/client/room-invite-preview.js new file mode 100644 index 00000000..ec9500a6 --- /dev/null +++ b/core/src/client/room-invite-preview.js @@ -0,0 +1,111 @@ +// One room-invite preview card, drawn wherever an invite is shown (#876). The face-room paste flow +// and the member-invite dialog both render this component, so the markup exists once: a change here +// is visible in both, and neither surface keeps a copy. +// +// It is a plain function, not a hook component: the caller passes its own `createElement` as `h`, +// which is what lets the core bundle and a classic-script plugin (which gets `h` from `require`) +// draw the same card. Everything the card says comes from a server reply, so the strings are +// cleaned here, at the last place before a person reads them — the same rules the server applies to +// a room name (`cleanFaceName`): one line, no controls, brackets, markup or @, capped. +export const INVITE_PREVIEW_NAME_MAX = 60 + +// Untrusted fence markers are stripped to a fixed point: one pass can remove a match and assemble a +// new one, so a nested `` would otherwise leave a complete +// `` in the card a person reads (#822). +export function stripInviteMarkers(text) { + let out = String(text ?? '') + for (let previous; previous !== out;) { + previous = out + out = out.replace(/<\/?\s*untrusted-(?:file|message)/gi, '') + } + return out +} + +export function cleanInviteName(value, fallback = '') { + return stripInviteMarkers(value) + .replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ') + .replace(/[[\]"<>`@]/g, '') + .replace(/\s+/g, ' ') + .trim() + .slice(0, INVITE_PREVIEW_NAME_MAX) || fallback +} + +// A count is only what reads as one (#823): `Number('')` is 0, which would draw an unknown room as +// empty, so an empty or non-numeric string is "not sent", like null. +export function inviteMemberCount(value) { + if (typeof value === 'number') return Number.isFinite(value) ? value : NaN + if (typeof value !== 'string') return NaN + const text = value.trim() + if (!text) return NaN + const count = Number(text) + return Number.isFinite(count) ? count : NaN +} + +// What the card can honestly say, and nothing else: a reply with no field worth showing is no card +// at all, so the caller keeps its own path (a paste that previews nothing still joins as before). +export function invitePreviewOf(seen) { + if (!seen || typeof seen !== 'object' || seen.unsupported === true) return null + const name = cleanInviteName(seen.name) + const lead = cleanInviteName(seen.lead) + const members = inviteMemberCount(seen.members) + const maxMembers = inviteMemberCount(seen.maxMembers) + const people = Number.isFinite(members) && Number.isFinite(maxMembers) + ? `${members} / ${maxMembers} in the room` + : Number.isFinite(members) ? `${members} in the room` : '' + const expiresAt = typeof seen.expiresAt === 'string' ? seen.expiresAt : '' + if (!name && !lead && !people && !expiresAt) return null + return { name, lead, people, expiresAt, clockOffsetMs: Number(seen.clockOffsetMs) || 0 } +} + +// Every home counts down to the server's one deadline (#458): `offsetMs` is how far the server's +// clock runs ahead of this one (0 unless the skew is real). m:ss, so the line visibly ticks. +export function inviteCountdown(expiresAt, offsetMs = 0) { + const at = Date.parse(expiresAt) - (Number(offsetMs) || 0) + if (!Number.isFinite(at)) return 'Open while the room is open' + const left = Math.max(0, at - Date.now()) + if (left <= 0) return 'This link has expired' + const total = Math.max(1, Math.ceil(left / 1000)) + return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')} left to join` +} + +// `kind` prefixes every `data-flybot` name, so each surface keeps the hook its tests and its CSS +// already use while sharing this one tree of elements. +export function RoomInvitePreview({ h, preview, kind = 'face-link-preview', busy = false, joinLabel = 'Join', clearLabel = 'Clear', showClear = true, onJoin, onClear, now } = {}) { + if (!preview || typeof h !== 'function') return null + const at = `${kind}` + const child = (name) => `${at}-${name}` + const countdown = now === undefined + ? inviteCountdown(preview.expiresAt, preview.clockOffsetMs) + : inviteCountdownAt(preview.expiresAt, preview.clockOffsetMs, now) + return h('div', { className: 'fs-preview', 'data-flybot': at, role: 'status' }, + h('div', { className: 'fs-preview-name', 'data-flybot': child('name') }, preview.name || 'Face-to-face room'), + preview.lead ? h('div', { className: 'fs-preview-meta', 'data-flybot': child('lead') }, `Lead: ${preview.lead}`) : null, + preview.people ? h('div', { className: 'fs-preview-meta', 'data-flybot': child('members') }, preview.people) : null, + preview.expiresAt ? h('div', { className: 'fs-preview-meta', 'data-flybot': child('expiry') }, countdown) : null, + onJoin || onClear + ? h('div', { className: 'fs-preview-foot', 'data-flybot': child('foot') }, + showClear && onClear + ? h('button', { + type: 'button', className: 'fs-btn fs-btn-sm', 'data-flybot': child('clear'), + disabled: !!busy, onClick: onClear, + }, clearLabel) + : null, + onJoin + ? h('button', { + type: 'button', className: 'fs-btn fs-btn-sm fs-btn-primary', 'data-flybot': child('join'), + disabled: !!busy, onClick: onJoin, + }, joinLabel) + : null) + : null) +} + +// The same text as `inviteCountdown`, measured against a clock the caller already has — a render is +// then reproducible in a test without freezing the machine's clock. +export function inviteCountdownAt(expiresAt, offsetMs, now) { + const at = Date.parse(expiresAt) - (Number(offsetMs) || 0) + if (!Number.isFinite(at)) return 'Open while the room is open' + const left = Math.max(0, at - Number(now || 0)) + if (left <= 0) return 'This link has expired' + const total = Math.max(1, Math.ceil(left / 1000)) + return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')} left to join` +} diff --git a/core/src/host/authz.js b/core/src/host/authz.js index e75c860f..9ac1c622 100644 --- a/core/src/host/authz.js +++ b/core/src/host/authz.js @@ -47,7 +47,10 @@ const BOT_TARGETED = new Set([ ]) // Endpoints whose body names a room. -const ROOM_TARGETED = new Set(['update-room', 'delete-room', 'room-progress', 'create-room', 'room-history', 'room-post']) +const ROOM_TARGETED = new Set(['update-room', 'delete-room', 'room-progress', 'create-room', 'room-history', 'room-post', + // #876: the room's own link. Reading it is a member's read; minting and withdrawing hand out + // or kill a way in, so they follow the room's admin rule. + 'room-invite', 'room-invite-mint', 'room-invite-revoke']) // Endpoints whose body names a Session. const SESSION_TARGETED = new Set(['earlier', 'send', 'answer-question', 'dismiss-question', 'approval-answer', 'exchange']) @@ -334,6 +337,19 @@ export function install(rt) { const id = body?.id ?? body?.roomId return roomMemberOf(actor, id) || roomOwnedBy(actor, id) ? ALLOW : deny('bot/forbidden', 'You are not a member of that room') } + if (ep === 'room-invite') { + const id = body?.id ?? body?.roomId + const room = state.rooms?.[id] + const me = currentParticipants(room).find(p => p.kind === 'human' && p.id === actor.memberId) + return roomOwnedBy(actor, id) || roomMemberOf(actor, id) || me?.role === 'admin' ? ALLOW : deny('bot/forbidden', 'You are not a member of that room') + } + if (ep === 'room-invite-mint' || ep === 'room-invite-revoke') { + const id = body?.id ?? body?.roomId + if (roomOwnedBy(actor, id)) return ALLOW + const room = state.rooms?.[id] + const me = currentParticipants(room).find(p => p.kind === 'human' && p.id === actor.memberId) + return me?.role === 'admin' ? ALLOW : deny('bot/forbidden', 'You do not own that room') + } if (ep === 'delete-room') { const id = body?.id ?? body?.roomId if (roomOwnedBy(actor, id)) return ALLOW @@ -370,6 +386,9 @@ export function install(rt) { // Sharing a member Bot's brief is the Bot owner's decision, checked on the record // in room-membership (#53); the endpoint itself only needs a signed-in actor. if (ep === 'share-brief') return ALLOW + // #876: knocking at another FlyBot's room link is not authority over anything here. The knock + // carries a ticket that the inviter's record decides on, and the sender is the actor's own Bot. + if (ep === 'room-invite-ask') return ALLOW // Task transitions are requester-checked on the record in tasks.js (#54) — the // endpoint only needs a signed-in actor, like held-send decisions. Create is // grant-aware there (owner, matching grant, or the named room). Explicit set: diff --git a/core/src/host/channel.js b/core/src/host/channel.js index 08faebd2..7874dd53 100644 --- a/core/src/host/channel.js +++ b/core/src/host/channel.js @@ -383,6 +383,26 @@ export function install(rt) { }, actor) return ok(line) } + // #876: the room's own link. Reading it is what the Members card shows (copy and the + // countdown); minting replaces the live ticket, withdrawing stops answering at once. + case 'room-invite': { + await rt.load() + const invite = rt.localInviteOf(String(body.roomId ?? body.id ?? '')) + return ok({ invite: invite ?? null }) + } + case 'room-invite-mint': { + await rt.load() + return ok({ invite: await rt.mintRoomInvite(String(body.roomId ?? body.id ?? '')) }) + } + case 'room-invite-revoke': { + await rt.load() + return ok(await rt.revokeRoomInvite(String(body.roomId ?? body.id ?? ''))) + } + // The invitee's side: a knock at the link (and, on Join, the accepting knock). + case 'room-invite-ask': { + await rt.load() + return ok(await rt.askLocalInvite(String(body.link ?? body.url ?? ''), { accept: body.accept === true }, actor)) + } case 'folders': case 'folder-pick': case 'folder-list': diff --git a/core/src/host/room-membership.js b/core/src/host/room-membership.js index 8bfe44e7..471d122d 100644 --- a/core/src/host/room-membership.js +++ b/core/src/host/room-membership.js @@ -36,7 +36,7 @@ import { randomBytes } from 'node:crypto' import { cleanPeerName, disambiguatedRemoteTitle, isChiefRecord, remoteBotTitle, sharedRoomTitle } from '../shared/remote-label.js' import { formatFaceRoomTitle, isAutoFaceTitle, faceRoomCreatedAt } from '../shared/face-title.js' -import { encodeRoomShare, LOCAL_INVITE_TTL_MS, parseRoomShare, roomInviteUrl } from '../shared/room-share.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 { hasAnyGrant, mayShareRoom } from './reach.js' @@ -1012,6 +1012,40 @@ export function install(rt) { return { revoked: true } } + // #876: the invitee's side of a local link. The link names the Bot to knock on, and the knock + // carries the ticket and nothing else; the accepting click is the same knock with `accept: true`. + // Nothing here decides the seat: the inviter's room record answers, and its offer comes back as + // `invite` through applyInboundRoomShare (rt.onLocalInviteOffer). The knock goes out from a Bot of + // the actor's whose home has a Bot Social identity — the link cannot name the sender, so the + // transport does, exactly as on the answering side. + function localSpeaker(actor) { + const usable = Object.values(state.bots ?? {}).filter(bot => bot && bot.remote === undefined + && typeof bot?.fields?.social?.member?.handle === 'string' + && bot.fields.social.member.handle.startsWith('@')) + const mine = actor?.memberId ? usable.filter(bot => bot.ownerId === actor.memberId) : usable + const list = mine.length > 0 ? mine : usable + const chief = actor?.memberId ? state.members?.[actor.memberId]?.designatedChiefId : undefined + return (chief !== undefined ? list.find(bot => bot.id === chief) : undefined)?.id ?? list[0]?.id + } + + rt.askLocalInvite = async function askLocalInvite(link, opts = {}, actor) { + const parsed = parseRoomInviteLink(typeof link === 'string' ? link : '') + if (!parsed || parsed.kind !== 'local') throw invalid('That is not a room link on another FlyBot') + if (!rt.flybotSend) throw invalid('Bot Social is not connected on this machine yet') + const from = localSpeaker(actor) + if (from === undefined) throw invalid('This machine has no Bot Social identity yet') + const accept = opts?.accept === true + const sent = await rt.flybotSend({ + from, + to: parsed.handle, + text: encodeRoomShare({ op: 'ask', ticket: parsed.ticket, ...(accept ? { accept: true } : {}) }), + role: 'request', + via: 'room-share', + meta: { share: 'ask', ticket: parsed.ticket }, + }) + return { sent: sent?.action ?? 'sent', handle: parsed.handle, ticket: parsed.ticket, accept } + } + // #852: a knock at a local room's link. The invitee's host sends `ask` (the accepting knock carries // `accept: true`); this side answers from the room record only, and a knock never creates a room, // adds a participant, or writes a line. The asker is the transport's sender, never a field of the diff --git a/core/test/authz.test.js b/core/test/authz.test.js index f67b1130..db81a421 100644 --- a/core/test/authz.test.js +++ b/core/test/authz.test.js @@ -104,6 +104,17 @@ test('owner cannot touch another owner Bot or room', async (t) => { assert.equal(gate.code, 'bot/forbidden', ep) } + // #876: a room's link is a member's read, and minting or withdrawing it is the room admin's call. + for (const ep of ['room-invite', 'room-invite-mint', 'room-invite-revoke']) { + assert.equal(rt.authorize(actorA, ep, { id: 'ra' }).ok, true, ep) + const gate = rt.authorize(actorA, ep, { id: 'rb' }) + assert.equal(gate.ok, false, ep) + assert.equal(gate.code, 'bot/forbidden', ep) + } + // A knock at another FlyBot's link carries no authority here, so a signed-in actor may always ask. + assert.equal(rt.authorize(actorA, 'room-invite-ask', { link: 'https://social.botawaken.com/r/@chief.b.home/AbCdEfGhIjKlMnOpQrStUv' }).ok, true) + assert.equal(rt.authorize(null, 'room-invite-ask', {}).ok, false, 'a signed-in actor is still required') + const mixed = rt.authorize(actorA, 'create-room', { members: ['a1', 'b1'] }) assert.equal(mixed.ok, false) assert.equal(mixed.code, 'bot/forbidden') diff --git a/core/test/remote-room.test.js b/core/test/remote-room.test.js index 58525273..ea918898 100644 --- a/core/test/remote-room.test.js +++ b/core/test/remote-room.test.js @@ -1337,6 +1337,55 @@ test('#852: a local link is not a face-room link, and a shared or face room has room.face = { roomId: 'frm_1', members: [] } await assert.rejects(() => rt.mintRoomInvite('war'), /its own invitation/) }) +test('#876: the invitee knocks on the link, and Join is the same knock with accept', async (t) => { + const { rt, sent } = host(t, TEAM) + await rt.load() + const { bob } = await signAliceAndBob(rt) + const actorB = await rt.authenticate(bob.sessionToken) + rt.state.bots['chief-b'].fields = { social: { member: { home: '@bob.home', handle: '@chief.b.home' } } } + const link = 'https://social.botawaken.com/r/@chief.a.home/AbCdEfGhIjKlMnOpQrStUv' + + // The preview knock: the ticket and the Bot to knock on, and no room id — this home has no room. + const knock = await rt.askLocalInvite(link + '/', {}, actorB) + assert.deepEqual( + { handle: knock.handle, ticket: knock.ticket, accept: knock.accept, sent: knock.sent }, + { handle: '@chief.a.home', ticket: 'AbCdEfGhIjKlMnOpQrStUv', accept: false, sent: 'hold' }, + ) + const first = sent.at(-1) + assert.equal(first.to, '@chief.a.home', 'the link names who is knocked on') + assert.equal(first.from, 'chief-b', "the knock leaves from the actor's own Bot, never a body field") + assert.equal(first.via, 'room-share') + const asked = parseRoomShare(first.text) + assert.equal(asked.op, 'ask') + assert.equal(asked.ticket, 'AbCdEfGhIjKlMnOpQrStUv') + assert.equal(asked.accept, undefined, 'the preview asks without accepting') + assert.equal(asked.roomId, undefined, 'the invitee cannot name a room it has never seen') + + // Join is the same knock with the one flag the answering side reads. + const accepted = await rt.askLocalInvite(link, { accept: true }, actorB) + assert.equal(accepted.accept, true) + assert.equal(parseRoomShare(sent.at(-1).text).accept, true) + assert.equal(parseRoomShare(sent.at(-1).text).ticket, asked.ticket) + + // A face-room link and a bare ticket are not this knock: one sentence, and nothing is sent. + const before = sent.length + await assert.rejects(() => rt.askLocalInvite('https://social.botawaken.com/r/AbCdEfGhIjKlMnOpQrStUv', {}, actorB), /not a room link on another FlyBot/) + await assert.rejects(() => rt.askLocalInvite('AbCdEfGhIjKlMnOpQrStUv', {}, actorB), /not a room link on another FlyBot/) + await assert.rejects(() => rt.askLocalInvite('https://social.botawaken.com/r/@chief.a.home/short', {}, actorB), /not a room link on another FlyBot/) + assert.equal(sent.length, before, 'a refused link knocks on nothing') + + // No Bot Social identity on this side: the refusal names that, not the link. + const kept = rt.state.bots['chief-b'].fields + delete rt.state.bots['chief-b'].fields + await assert.rejects(() => rt.askLocalInvite(link, {}, actorB), /no Bot Social identity yet/) + rt.state.bots['chief-b'].fields = kept + // Without a transport installed there is nothing to knock with. + const send = rt.flybotSend + rt.flybotSend = undefined + await assert.rejects(() => rt.askLocalInvite(link, {}, actorB), /Bot Social is not connected/) + rt.flybotSend = send +}) + test('#852: the knock asks the room record, and only the accept admits one home', async (t) => { const { rt, sent } = host(t, TEAM) await rt.load() diff --git a/core/test/room-invite-preview.test.js b/core/test/room-invite-preview.test.js new file mode 100644 index 00000000..73e99e9f --- /dev/null +++ b/core/test/room-invite-preview.test.js @@ -0,0 +1,79 @@ +// #876: the room-invite preview card is one component in the core client, drawn by both surfaces +// that show an invite. These tests pin what it says, what it refuses to say, and that the markup a +// surface gets depends only on `kind` — not on which surface asked. +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { + RoomInvitePreview, cleanInviteName, inviteCountdown, inviteCountdownAt, + inviteMemberCount, invitePreviewOf, +} from '../src/client/room-invite-preview.js' + +const h = (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat() }) +const walk = (node) => { + if (!node || typeof node !== 'object') return [] + if (typeof node.type === 'function') return walk(node.type(node.props)) + return [node, ...(node.children ?? []).flatMap(walk)] +} +const byKind = (node, kind) => walk(node).filter(n => n.props?.['data-flybot'] === kind) + +const preview = { name: 'Saturday room', lead: 'Chief', people: '3 / 8 in the room', expiresAt: '2099-01-01T00:00:00.000Z', clockOffsetMs: 0 } + +test('#876: the card names the room, its lead, its people and the deadline', () => { + const tree = RoomInvitePreview({ h, preview, onJoin() {}, onClear() {} }) + assert.equal(tree.props['data-flybot'], 'face-link-preview') + assert.equal(tree.children[0].children.join(''), 'Saturday room') + assert.equal(tree.children[1].children.join(''), 'Lead: Chief') + assert.equal(tree.children[2].children.join(''), '3 / 8 in the room') + // The deadline line is the one countdown text, not a per-surface copy of the rule. + assert.equal(tree.children[3].children.join(''), inviteCountdownAt(preview.expiresAt, 0, Date.now())) + const foot = tree.children[4] + assert.deepEqual(foot.children.map(button => button.children[0]), ['Clear', 'Join']) +}) + +test('#876: `kind` only renames the hooks, so both surfaces draw this one tree', () => { + const face = RoomInvitePreview({ h, preview, kind: 'face-link-preview', onJoin() {}, onClear() {} }) + const dialog = RoomInvitePreview({ h, preview, kind: 'room-invite-preview', onJoin() {}, onClear() {} }) + const shape = (node) => JSON.stringify(node, (key, value) => { + if (key === 'data-flybot' && typeof value === 'string') return value.replace(/^face-link-preview/, 'KIND').replace(/^room-invite-preview/, 'KIND') + return typeof value === 'function' ? 'fn' : value + }) + assert.equal(shape(face), shape(dialog), 'same elements, same order: nothing is written twice') + assert.equal(byKind(dialog, 'room-invite-preview').length, 1) + assert.equal(byKind(dialog, 'room-invite-preview-name').length, 1) + assert.equal(byKind(face, 'face-link-preview-name').length, 1) +}) + +test('#876: a name a stranger wrote reaches the card as plain text', () => { + const seen = { + name: 'Room [x] @chief\u0000 now', + lead: 'Chief @home', + members: 2, + } + const card = invitePreviewOf(seen) + assert.equal(card.name, 'Room x chief now', 'fence markers removed to a fixed point, then brackets and @') + assert.equal(card.lead, 'Chief ihome/i', 'markup characters are not kept') + assert.equal(card.people, '2 in the room') + const tree = RoomInvitePreview({ h, preview: card, onJoin() {} }) + assert.equal(walk(tree).every(n => !String(n.props?.children ?? '').includes('<')), true) +}) + +test('#876: nothing worth saying is no card, and a count is only what reads as one', () => { + assert.equal(invitePreviewOf(undefined), null) + assert.equal(invitePreviewOf({ unsupported: true }), null) + assert.equal(invitePreviewOf({}), null, 'an empty reply draws no card') + assert.equal(RoomInvitePreview({ h, preview: null }), null) + assert.equal(inviteMemberCount(''), Number.NaN, 'an empty string is not zero members (#823)') + assert.equal(inviteMemberCount(' 3 '), 3) + assert.equal(inviteMemberCount('three'), Number.NaN) + assert.equal(invitePreviewOf({ members: '', maxMembers: '' }), null, 'no count line at all, so no card') + assert.equal(invitePreviewOf({ members: 3 }).people, '3 in the room') + assert.equal(invitePreviewOf({ members: 3, maxMembers: '' }).people, '3 in the room') +}) + +test('#876: the deadline line is shared copy, and an expired one says so', () => { + const future = new Date(Date.now() + 61_000).toISOString() + assert.match(inviteCountdownAt(future, 0, Date.now()), /^1:0\d left to join$/) + assert.equal(inviteCountdownAt('2000-01-01T00:00:00.000Z', 0, Date.now()), 'This link has expired') + assert.equal(inviteCountdown('not a date'), 'Open while the room is open') + assert.equal(cleanInviteName(' ', 'a room'), 'a room') +}) diff --git a/core/types/flybot.d.ts b/core/types/flybot.d.ts index 11502295..f178923c 100644 --- a/core/types/flybot.d.ts +++ b/core/types/flybot.d.ts @@ -614,6 +614,9 @@ export interface FlyBotClientRegistry { export type FlyBotBotMark = (props: { bot?: any; look?: { shape?: string; color?: string; image?: string; emoji?: string }; size?: number; [key: string]: unknown }) => any export type FlyBotBotAvatar = (props: { bot?: any; size?: number; badge?: boolean; live?: boolean; [key: string]: unknown }) => any export type FlyBotLookOf = (bot?: any) => { shape: string; color: string; image?: string } +/** One room-invite preview card, for every surface that shows an invite (#876). */ +export type FlyBotRoomInvitePreview = (props: any) => any +export type FlyBotInvitePreview = (seen?: any) => { name: string; lead: string; people: string; expiresAt: string; clockOffsetMs: number } | null declare global { // eslint-disable-next-line no-var @@ -626,6 +629,10 @@ declare global { /** Sidebar avatar (mark plus optional badges). Prefer this at 24px with badge: false. */ BotAvatar?: FlyBotBotAvatar lookOf?: FlyBotLookOf + /** The invite card the face-room paste flow and the member-invite dialog both render (#876). */ + RoomInvitePreview?: FlyBotRoomInvitePreview + invitePreviewOf?: FlyBotInvitePreview + inviteCountdown?: (expiresAt: string, offsetMs?: number) => string /** A friend's Bot chat (a roster Bot with `remote`): its one name, "Chief (Bob)", and its owner, "Bob" (#498). */ names?: { friendBotTitle(bot?: any): string; friendBotOwner(bot?: any): string } } | undefined diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 53f712fd..40ccb97e 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -26,6 +26,7 @@ The browser side of the patched `ds-bot` core (`core/`): sources in `core/src/cl | `core/src/client/` | Client sources → build to `core/client.js`. | | `core/src/client/new-chat.js` | Sidebar **+** picker. Plugin rows via `flybot.new-chat.action` (`newChatActionRows` in `flybot-registry.js`); social adds Face-to-face room and Add friend (copy actions live inside Add friend, #181). The DSH Agent row reads **Add coding agent** (hint "A plain DSH session, not a Bot") so it is not mistaken for **Create new Bot** (#181). The short + menu does not list individual Bots. A group the host refuses because one of my Bots is not shared with the friend whose Bot is in it (`error.details.reason === 'ungranted-local'`, put on the error by `client/api.js`) is drawn as a card, not a red line: it names the Bot(s) and friend(s), and offers **Copy invite link for {Bot}** (`__flybot__.social.createInvite`; shown only when social is loaded and can invite that Bot, nothing is granted by the click; it says *copied* only when `copiedToClipboard` in `shared/remote-label.js` reports the write took, else it shows the link in a read-only, select-on-focus field to copy by hand) and **Leave {Bot} out** (`ungrantedLocalFix`, `shared/remote-label.js`). | | `core/src/client/stats-dock.js` | The shell's run-stats footer under the composer (`conversation.composer.dock`) re-registered one priority lower with the shell's own component, inject and `chat` locale, so the English counts read **1 turn** / **1 step** instead of `1 turns 1 steps` (`pluralStats` rewrites only the exact English template, #419). The shell ids differ by version — one `stats` footer in 0.2.0-rc.2, the counts split into an `activity` pill next to `usage` in 0.2.1-alpha.1 — so `statsDocks` registers one wrapper per id and the wrapper whose id the shell does not publish draws nothing (#659); `usage` stays hidden. Still hidden in a friend's Bot chat by CSS (#419). Tests: `core/test/stats-dock.test.js`. | +| `core/src/client/room-invite-preview.js` | The one room-invite preview card (#876). `invitePreviewOf` says only what a server reply can honestly say (a name cleaned at fixed point, the lead, `n / m in the room`, the deadline), `inviteCountdown` words the deadline, `RoomInvitePreview` draws the card from a caller-supplied `h`; `kind` renames the `data-flybot` hooks, so the face-room paste flow (`face-link-preview`) and the member-invite dialog (`room-invite-preview`) render the same tree instead of two copies of the markup. Registered on `__flybot__` in `flybot-slots.js`, because a classic-script plugin cannot import it. Tests: `core/test/room-invite-preview.test.js`. | | `core/src/client/face-at-filter.js` | A face room's `@` menu without DSH's Sessions rows (#305): `planFaceMenu` (rows to hide, where the highlight goes, when to dismiss), `faceMenuCss`, `watchFaceMenu`. Tests: `core/test/face-at-filter.test.js`. | | `core/test/new-chat-menu.test.js` | **+** menu rows (#283): an action row's description is its own line under the title, an inline Bot hint is capped, so hovering never hides a title. | | `core/test/new-chat.test.js` | Picker filters and the **+** short action menu (`data-menu`; New chat, New Bot, New group chat, plugin actions, then Add coding agent; #385 marks, verbs and hints). `ungrantedLocalFix`, `copiedToClipboard` (no clipboard or a refused write is a failure) and the refusal card (invite and leave-out actions, the copy-by-hand link field, no grant written by the client). | diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 91cc79c0..f87c3056 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -153,6 +153,7 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi - A room that lives in FlyBot can hand out one link: `https://social.botawaken.com/r//`. `core/src/shared/room-share.js` `parseRoomInviteLink` reads both shapes in the one invite field and says which one it saw (`{ kind: 'local', handle, ticket }` / `{ kind: 'face', ticket }`), and `roomInviteUrl` builds the local one. The ticket is `randomBytes(16)` in base64url — 22 characters of `[A-Za-z0-9_-]`, inside the 64-character shape `FACE_INVITE_TICKET` already accepts. The handle is its own path segment because `send_as_member` addresses a Bot by handle, and a ticket cannot carry one inside that limit. - The room record is the only authority: `room.localInvite { ticket, handle, expiresAt, mintedAt }`, ten minutes (`LOCAL_INVITE_TTL_MS`, the face-room window). `rt.mintRoomInvite(roomId)` replaces the previous ticket, so there is one live ticket per room; `rt.localInviteOf(roomId)` answers only while it is live; `rt.roomOfLocalInvite(ticket)` resolves the room from the ticket; `rt.revokeRoomInvite(roomId)` and deleting the room clear it. 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. ### Face-to-face owner send diff --git a/docs/subsystems/plugin-runtime.md b/docs/subsystems/plugin-runtime.md index 55829928..890a9659 100644 --- a/docs/subsystems/plugin-runtime.md +++ b/docs/subsystems/plugin-runtime.md @@ -89,7 +89,7 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots ### Host Bot look for classic-script plugins -- Classic-script plugins cannot import `src/client/mark.js`. The host client `registry()` therefore also sets `globalThis.__flybot__.BotAvatar`, `BotMark` and `lookOf` to the same components the sidebar / session list / chat header use (configured image / shape / color / emoji, else a stable look from the Bot id or a remote handle). Plugins typically render `BotAvatar` at 24px with `badge: false`; face-to-face tiles pass 48. A stored `color` / `avatar` on the record still wins; a remote Bot without one is seeded by `remoteSeed(handle)`. A face-room `members[]` look overlays the chat record (`lookBotForRemote`). For the same reason `registry()` sets `globalThis.__flybot__.names` to `{ friendBotTitle, friendBotOwner }` from `shared/remote-label.js` (typed in `types/flybot.d.ts`), so a plugin names a friend's Bot (`Chief (SelfB1233)`) or its owner exactly as the sidebar and approval cards do (#498). +- Classic-script plugins cannot import `src/client/mark.js`. The host client `registry()` therefore also sets `globalThis.__flybot__.BotAvatar`, `BotMark` and `lookOf` to the same components the sidebar / session list / chat header use (configured image / shape / color / emoji, else a stable look from the Bot id or a remote handle). Plugins typically render `BotAvatar` at 24px with `badge: false`; face-to-face tiles pass 48. A stored `color` / `avatar` on the record still wins; a remote Bot without one is seeded by `remoteSeed(handle)`. A face-room `members[]` look overlays the chat record (`lookBotForRemote`). For the same reason `registry()` sets `globalThis.__flybot__.names` to `{ friendBotTitle, friendBotOwner }` from `shared/remote-label.js` (typed in `types/flybot.d.ts`), so a plugin names a friend's Bot (`Chief (SelfB1233)`) or its owner exactly as the sidebar and approval cards do (#498). For the same reason `registry()` sets `globalThis.__flybot__.RoomInvitePreview`, `invitePreviewOf` and `inviteCountdown` from `src/client/room-invite-preview.js` (#876): the invite card is one component for the face-room paste flow and the member-invite dialog, and a classic-script plugin renders it instead of keeping a second copy of the markup. `kind` only renames the `data-flybot` hooks (`face-link-preview` / `room-invite-preview`), and `invitePreviewOf` says only what the server reply can honestly say (a name cleaned at fixed point, the lead, the seat count, the deadline). ### A plugin's sanctioned model call (#450) diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 7bbb1790..e710ca34 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -108,7 +108,7 @@ pins the list. The authority for the list is Bot Social's README and `docs/FACE_ - Face-to-face room cards use a short status chip (`data-flybot=face-status`): live rooms read **open**; expired matching windows under **Past rooms** read **ended**; candidates show a countdown (`12m` / `45s`); closed/left keep their labels (#166). - Join → waiting → leave (#306): **Join** is enabled as soon as a Bot is picked (a short number answers with the inline `Enter a 6-digit number.`; no Bot picked shows `Pick a Bot first.`). The waiting countdown reads `m:ss left to join` and ticks every second. The dialog overlay (`.fs-dialog-back`, z-index above the details panel and the Bot pane) covers the whole window, and while it waits in a room the details panel draws no second waiting card for it (`faceDialog`); my own Bots' waiting tiles use the roster look in both places. Every **Leave** (waiting dialog, waiting overview, open room) asks once (**Leave** / **Stay**, `ConfirmLeave`) and lands on the main Bot (`openMainBot`). In an open room **Leave** sits on the status-chip line, a row above the **Auto-send** toggle. The room subtitle counts what it means: `2 people · 2 Bots` (`faceRoomCount` in core `group-settings.js`). - QR for a room number (#773): the waiting dialog and the room panel draw the 6-digit number as a QR under it (`data-flybot=face-qr`, one `path` in an `svg`), so a phone camera or another FlyBot can scan instead of typing. The image is computed in the browser — byte mode, error correction M, version 1–10 picking the smallest that holds the text, every module of the matrix — with no request, no third-party image service and no runtime dependency. The payload is `FLYBOT-` plus the six digits and nothing else: a deadline in the image would re-encode every second and a screenshot would lie, so the countdown line beside it keeps ticking. The code goes when joining ends and as soon as the room opens, so a number on screen is never one that no longer works. Scanning reads the pixels with the browser's `BarcodeDetector` and the camera with `getUserMedia`; a browser without the decoder says so **before** it asks for the camera, a refused camera gets one sentence, and a QR from anywhere else is `That is not a FlyBot room code. Ask for the 6-digit number instead.` Typing the number always stays, and a successful scan ends in the same `social/face-join` the number field uses — it only fills the field. -- Invites into a room that already exists (#773, Bot Social #39): an open room shows **Invite by link** on its overview (`data-flybot=face-invite`), and **Create link** mints one with `POST /v1/face-rooms/{id}/invites` → `{ ticket, url, expires_at }`. What it draws is the URL itself as a QR, so a phone camera opens the room and FlyBot's own scan joins with the same ticket; nothing is minted in the browser. The card carries the link as text, **Copy link**, a countdown to the server deadline, and **Withdraw link** (`DELETE /v1/face-rooms/{id}/invites/{ticket}`), which drops the image and says nobody can join with it any more. The link expires with the room, so the code goes with the deadline. The input field and the scanner read one field with two shapes (`parseInviteLink`): this server's `https://…/r/`, and a link into a room on another FlyBot, `https://…/r/@handle/` (#852). The face-room path only ever answers the first — `parseFaceInviteLink` returns the ticket for the face shape and nothing for the local one — and a local link is refused by name, in the dialog, in the scanner and at the host route (`social/face-join-invite` answers before the `face_room_invites` gate, since that ticket was never this server's): `That link opens a room on another FlyBot. It does not join a face-to-face room.` Anything that is neither shape is `That is not an invite link. It looks like https://…/r/…`, and the 6-digit number stays the way in. Joining is `POST /v1/face-rooms/join-invite`, and each refusal keeps its own sentence: `invite_not_found`, `expired` and `revoked` each ask for a new one, `room_full`, `ended` → the room has closed, `already_member` → you are already in that room. Both the mint and the join paths are behind the same gate, so an older server never receives a request it cannot answer. The whole card is behind the `face_room_invites` entry in `GET /v1/platform` `features`: on a server without it the endpoints answer `{ unsupported: true }` without calling a route that is not there, and the card says `Invites need a newer Bot Social. The 6-digit number still works.` Those endpoints (`social/face-invite`, `social/face-invite-revoke`, `social/face-invite-preview`, `social/face-join-invite`) are listed in `SOCIAL_UI_ENDPOINTS`, so the UI cannot call what the host does not serve. A pasted or scanned invite asks the room first (#798): `social/face-invite-preview` answers `{ roomId, name, lead, members, maxMembers, expiresAt }` and the dialog draws a card whose first line is the name (a room Bot Social has no name for, which is every room FlyBot opens, reads `Face-to-face room` instead of reporting the missing field, #859), the lead, `members / maxMembers` when both are numbers (the count alone when only one is), and the deadline as a countdown; **Join** on that card runs the join above and **Clear** empties the field. Pressing **Join with link** again while that card is already up for the link in the field asks the host for nothing and keeps the card, countdown and all (#824); the guard compares the parsed ticket, not the typed text, while a field edit or **Clear** drops the card so the next press looks the link up once. The preview is information, never a gate: a 404 or a timeout drops the card and the same join runs with the same sentence; `{ unsupported: true }` is the one answer that stops there, because the invite feature is off on that server and a join would only reach the same sentence through a route that is not there. The card cleans `name` and `lead` itself with the same rules as `cleanFaceName`, so a stranger's brackets and markup reach the dialog as plain text, and it strips the untrusted fence markers until the string stops changing, as the server's `stripFaceMarkers` does: one pass can remove a match and assemble a new one, so a nested `` would otherwise leave a complete `` in the card a person reads (#822). The lead arrives from Bot Social as the member object (`{ handle, display_name, owner_name }`), not as a string, so the host renders it with the same `faceMemberLabel` rule the member list uses — `Lead: Chief (SelfTest A2)` — while a string answer keeps the `cleanFaceName` path, and an object that names nobody leaves the lead line out (#846). Every Bot picked in the dialog joins (#817): the link path asks the host once per selected Bot, exactly as the 6-digit number does. It has its own stop table, because the invite refusals do not look like the number ones: a ticket that is unknown, expired or withdrawn, a room that has ended and a room that is full stop the loop after the first refusal (those answers cannot change for the next Bot), while a refusal about one Bot alone — already in the room, its token revoked, not found, forbidden — keeps the other Bots going. A Bot that cannot join is listed by name while the Bots that did bring the room up, so one failure never hides the others. +- Invites into a room that already exists (#773, Bot Social #39): an open room shows **Invite by link** on its overview (`data-flybot=face-invite`), and **Create link** mints one with `POST /v1/face-rooms/{id}/invites` → `{ ticket, url, expires_at }`. What it draws is the URL itself as a QR, so a phone camera opens the room and FlyBot's own scan joins with the same ticket; nothing is minted in the browser. The card carries the link as text, **Copy link**, a countdown to the server deadline, and **Withdraw link** (`DELETE /v1/face-rooms/{id}/invites/{ticket}`), which drops the image and says nobody can join with it any more. The link expires with the room, so the code goes with the deadline. The input field and the scanner read one field with two shapes (`parseInviteLink`): this server's `https://…/r/`, and a link into a room on another FlyBot, `https://…/r/@handle/` (#852). The face-room path only ever answers the first — `parseFaceInviteLink` returns the ticket for the face shape and nothing for the local one — and a local link is refused by name, in the dialog, in the scanner and at the host route (`social/face-join-invite` answers before the `face_room_invites` gate, since that ticket was never this server's): `That link opens a room on another FlyBot. It does not join a face-to-face room.` Anything that is neither shape is `That is not an invite link. It looks like https://…/r/…`, and the 6-digit number stays the way in. Joining is `POST /v1/face-rooms/join-invite`, and each refusal keeps its own sentence: `invite_not_found`, `expired` and `revoked` each ask for a new one, `room_full`, `ended` → the room has closed, `already_member` → you are already in that room. Both the mint and the join paths are behind the same gate, so an older server never receives a request it cannot answer. The whole card is behind the `face_room_invites` entry in `GET /v1/platform` `features`: on a server without it the endpoints answer `{ unsupported: true }` without calling a route that is not there, and the card says `Invites need a newer Bot Social. The 6-digit number still works.` Those endpoints (`social/face-invite`, `social/face-invite-revoke`, `social/face-invite-preview`, `social/face-join-invite`) are listed in `SOCIAL_UI_ENDPOINTS`, so the UI cannot call what the host does not serve. A pasted or scanned invite asks the room first (#798): `social/face-invite-preview` answers `{ roomId, name, lead, members, maxMembers, expiresAt }` and the dialog draws a card whose first line is the name (a room Bot Social has no name for, which is every room FlyBot opens, reads `Face-to-face room` instead of reporting the missing field, #859), the lead, `members / maxMembers` when both are numbers (the count alone when only one is), and the deadline as a countdown; **Join** on that card runs the join above and **Clear** empties the field. Pressing **Join with link** again while that card is already up for the link in the field asks the host for nothing and keeps the card, countdown and all (#824); the guard compares the parsed ticket, not the typed text, while a field edit or **Clear** drops the card so the next press looks the link up once. The preview is information, never a gate: a 404 or a timeout drops the card and the same join runs with the same sentence; `{ unsupported: true }` is the one answer that stops there, because the invite feature is off on that server and a join would only reach the same sentence through a route that is not there. The card cleans `name` and `lead` itself with the same rules as `cleanFaceName`, so a stranger's brackets and markup reach the dialog as plain text, and it strips the untrusted fence markers until the string stops changing, as the server's `stripFaceMarkers` does: one pass can remove a match and assemble a new one, so a nested `` would otherwise leave a complete `` in the card a person reads (#822). Since #876 that card is not markup of this plugin's own: `RoomInvitePreview` (`core/src/client/room-invite-preview.js`, registered on `__flybot__` because a classic-script plugin cannot import it) draws it from the same `invitePreviewOf` / `inviteCountdown` rules, and `kind` renames only the `data-flybot` hooks (`face-link-preview` here, `room-invite-preview` in the member-invite dialog), so the two surfaces render one tree instead of two copies. The lead arrives from Bot Social as the member object (`{ handle, display_name, owner_name }`), not as a string, so the host renders it with the same `faceMemberLabel` rule the member list uses — `Lead: Chief (SelfTest A2)` — while a string answer keeps the `cleanFaceName` path, and an object that names nobody leaves the lead line out (#846). Every Bot picked in the dialog joins (#817): the link path asks the host once per selected Bot, exactly as the 6-digit number does. It has its own stop table, because the invite refusals do not look like the number ones: a ticket that is unknown, expired or withdrawn, a room that has ended and a room that is full stop the loop after the first refusal (those answers cannot change for the next Bot), while a refusal about one Bot alone — already in the room, its token revoked, not found, forbidden — keeps the other Bots going. A Bot that cannot join is listed by name while the Bots that did bring the room up, so one failure never hides the others. - Status chips on room details and Past rooms (`data-flybot=face-status`) read **Open** / **Closed** in sentence case with readable contrast, and the past row keeps its own spacing between avatars, title, time and chip (#170). - One card-placement rule (#418): a held message's decision sits where that message is. A Bot's reply to a friend has its buttons on the held reply bubble in place (no copy in the dock of that chat or the friend's chat); a message with no bubble of its own (a Bot's `message_friend_bot`, a typed composer line held by policy, a face-room line) has its card in the dock of the chat it belongs to, as below. - In-chat approval cards (`flybot.session.dock`) are right-aligned bubbles capped at ~460px: **Waiting for approval**, one `from → to` line, a word-wrapped preview with **Show more**, and **Approve** / **Reject** (one right-hand group) with a quiet **Always approve** link at the left of the same row, alone above them when the card is narrow (#166, #260, #271). One place per approval (#278): a Bot's direct message to a friend is drawn in the friend's chat once that chat exists (the sending Bot's own chat only until then), never in a Face-to-face room that has the friend in it; a Face-to-face room shows, counts as waiting, and gets a "Waiting for the owner's approval" notice for just the lines held for its own room id. The client also hides that old notice in a Face-to-face room's history. The held text shows once (#306): a Bot line held in a Face-to-face room keeps its text in the card, and the room draws no bubble for it until it is approved (`GroupMessage`, `core/src/client/cells.js`, matched by `room.heldLines`, #456, and by `room.pendingApprovals` as a fallback); a line the owner typed is never held and stays a plain bubble. A Bot's message to a friend (`message_friend_bot`) has no bubble in either chat, so the card is already its only place. #456: the held room line lives only on its card (greyed preview) until **Approve**: `onFaceLine` answers `{ held: true, approvalId }` for a Bot line Bot Social holds, `social/decide-approval` calls `flybot.rooms.settleHeld`, Approve makes it a room bubble and Reject a grey ` · Not sent` line, with no `Rejected.` note in the face room. A decided card leaves the dock at once (no **Approved** / **Rejected** stub, any card kind). A face card's `from` is the room's own #384 label for that local Bot (`Researcher (Alice) → the room`, `faceFromLabel`). `applyFaceSnapshot` posts a human or system event for a round only when the core room logged it now (`applyFaceLine` refuses an event id it has), so an event read twice starts no second round. diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index 7d49cbd3..096eaf8f 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -567,7 +567,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js return ok(room) } case 'update-room': { -@@ -175,8 +175,28 @@ export function install(rt) { +@@ -175,8 +175,48 @@ export function install(rt) { add: Array.isArray(body.add) ? body.add : undefined, remove: Array.isArray(body.remove) ? body.remove : undefined, workDir: body.workDir, @@ -595,6 +595,26 @@ diff --git a/src/host/channel.js b/src/host/channel.js + displayName: body.displayName, + }, actor) + return ok(line) ++ } ++ // #876: the room's own link. Reading it is what the Members card shows (copy and the ++ // countdown); minting replaces the live ticket, withdrawing stops answering at once. ++ case 'room-invite': { ++ await rt.load() ++ const invite = rt.localInviteOf(String(body.roomId ?? body.id ?? '')) ++ return ok({ invite: invite ?? null }) ++ } ++ case 'room-invite-mint': { ++ await rt.load() ++ return ok({ invite: await rt.mintRoomInvite(String(body.roomId ?? body.id ?? '')) }) ++ } ++ case 'room-invite-revoke': { ++ await rt.load() ++ return ok(await rt.revokeRoomInvite(String(body.roomId ?? body.id ?? ''))) ++ } ++ // The invitee's side: a knock at the link (and, on Join, the accepting knock). ++ case 'room-invite-ask': { ++ await rt.load() ++ return ok(await rt.askLocalInvite(String(body.link ?? body.url ?? ''), { accept: body.accept === true }, actor)) } case 'folders': case 'folder-pick': diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 10f0e7f0..c00c5dfb 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -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,526 @@ function maskOf(shape) { +@@ -1242,162 +1242,610 @@ function maskOf(shape) { } var DEFAULT_MASK = maskOf(FALLBACK_SHAPE); @@ -1439,6 +1439,87 @@ diff --git a/client.js b/client.js + ); +} + ++// src/client/room-invite-preview.js ++var INVITE_PREVIEW_NAME_MAX = 60; ++function stripInviteMarkers(text) { ++ let out = String(text ?? ""); ++ for (let previous; previous !== out; ) { ++ previous = out; ++ out = out.replace(/<\/?\s*untrusted-(?:file|message)/gi, ""); ++ } ++ return out; ++} ++function cleanInviteName(value, fallback = "") { ++ return stripInviteMarkers(value).replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, " ").replace(/[[\]"<>`@]/g, "").replace(/\s+/g, " ").trim().slice(0, INVITE_PREVIEW_NAME_MAX) || fallback; ++} ++function inviteMemberCount(value) { ++ if (typeof value === "number") return Number.isFinite(value) ? value : NaN; ++ if (typeof value !== "string") return NaN; ++ const text = value.trim(); ++ if (!text) return NaN; ++ const count = Number(text); ++ return Number.isFinite(count) ? count : NaN; ++} ++function invitePreviewOf(seen) { ++ if (!seen || typeof seen !== "object" || seen.unsupported === true) return null; ++ const name = cleanInviteName(seen.name); ++ const lead = cleanInviteName(seen.lead); ++ const members = inviteMemberCount(seen.members); ++ const maxMembers = inviteMemberCount(seen.maxMembers); ++ const people2 = Number.isFinite(members) && Number.isFinite(maxMembers) ? `${members} / ${maxMembers} in the room` : Number.isFinite(members) ? `${members} in the room` : ""; ++ const expiresAt = typeof seen.expiresAt === "string" ? seen.expiresAt : ""; ++ if (!name && !lead && !people2 && !expiresAt) return null; ++ return { name, lead, people: people2, expiresAt, clockOffsetMs: Number(seen.clockOffsetMs) || 0 }; ++} ++function inviteCountdown(expiresAt, offsetMs = 0) { ++ const at = Date.parse(expiresAt) - (Number(offsetMs) || 0); ++ if (!Number.isFinite(at)) return "Open while the room is open"; ++ const left = Math.max(0, at - Date.now()); ++ if (left <= 0) return "This link has expired"; ++ const total = Math.max(1, Math.ceil(left / 1e3)); ++ return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")} left to join`; ++} ++function RoomInvitePreview({ h: h42, preview, kind = "face-link-preview", busy = false, joinLabel = "Join", clearLabel = "Clear", showClear = true, onJoin, onClear, now } = {}) { ++ if (!preview || typeof h42 !== "function") return null; ++ const at = `${kind}`; ++ const child = (name) => `${at}-${name}`; ++ const countdown = now === void 0 ? inviteCountdown(preview.expiresAt, preview.clockOffsetMs) : inviteCountdownAt(preview.expiresAt, preview.clockOffsetMs, now); ++ return h42( ++ "div", ++ { className: "fs-preview", "data-flybot": at, role: "status" }, ++ h42("div", { className: "fs-preview-name", "data-flybot": child("name") }, preview.name || "Face-to-face room"), ++ preview.lead ? h42("div", { className: "fs-preview-meta", "data-flybot": child("lead") }, `Lead: ${preview.lead}`) : null, ++ preview.people ? h42("div", { className: "fs-preview-meta", "data-flybot": child("members") }, preview.people) : null, ++ preview.expiresAt ? h42("div", { className: "fs-preview-meta", "data-flybot": child("expiry") }, countdown) : null, ++ onJoin || onClear ? h42( ++ "div", ++ { className: "fs-preview-foot", "data-flybot": child("foot") }, ++ showClear && onClear ? h42("button", { ++ type: "button", ++ className: "fs-btn fs-btn-sm", ++ "data-flybot": child("clear"), ++ disabled: !!busy, ++ onClick: onClear ++ }, clearLabel) : null, ++ onJoin ? h42("button", { ++ type: "button", ++ className: "fs-btn fs-btn-sm fs-btn-primary", ++ "data-flybot": child("join"), ++ disabled: !!busy, ++ onClick: onJoin ++ }, joinLabel) : null ++ ) : null ++ ); ++} ++function inviteCountdownAt(expiresAt, offsetMs, now) { ++ const at = Date.parse(expiresAt) - (Number(offsetMs) || 0); ++ if (!Number.isFinite(at)) return "Open while the room is open"; ++ const left = Math.max(0, at - Number(now || 0)); ++ if (left <= 0) return "This link has expired"; ++ const total = Math.max(1, Math.ceil(left / 1e3)); ++ return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")} left to join`; ++} ++ +// src/client/approval-claims.js +var claims = /* @__PURE__ */ new Map(); +var claimListeners = /* @__PURE__ */ new Set(); @@ -1487,6 +1568,9 @@ diff --git a/client.js b/client.js + globalThis.__flybot__.BotMark ??= BotMark; + globalThis.__flybot__.BotAvatar ??= BotAvatar; + globalThis.__flybot__.lookOf ??= lookOf; ++ globalThis.__flybot__.RoomInvitePreview ??= RoomInvitePreview; ++ globalThis.__flybot__.invitePreviewOf ??= invitePreviewOf; ++ globalThis.__flybot__.inviteCountdown ??= inviteCountdown; + globalThis.__flybot__.names ??= { friendBotTitle, friendBotOwner }; + return slots; +} @@ -1898,7 +1982,7 @@ diff --git a/client.js b/client.js } return ""; } -@@ -1624,47 +1624,226 @@ function usageOf(list, ids) { +@@ -1624,33 +1624,212 @@ 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 }); } @@ -1967,17 +2051,6 @@ diff --git a/client.js b/client.js -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) { -- const values = /* @__PURE__ */ new Map(); -- let frame = 0; -- let last = 0; -- const read = () => Object.fromEntries([...values].map(([key, value]) => [key, value.x])); -- const step = (now) => { -- const dt = Math.min(0.5, Math.max(1e-3, (now - last) / 1e3)); -- last = now; -- const steps = Math.ceil(dt * 240); -- const h34 = dt / steps; -- let moving = false; -- for (const value of values.values()) { +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) { @@ -2143,17 +2216,17 @@ diff --git a/client.js b/client.js +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) { -+ const values = /* @__PURE__ */ new Map(); -+ let frame = 0; -+ let last = 0; -+ const read = () => Object.fromEntries([...values].map(([key, value]) => [key, value.x])); -+ const step = (now) => { -+ const dt = Math.min(0.5, Math.max(1e-3, (now - last) / 1e3)); -+ last = now; -+ const steps = Math.ceil(dt * 240); + 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; + const steps = Math.ceil(dt * 240); +- const h34 = dt / steps; + const h42 = dt / steps; -+ let moving = false; -+ for (const value of values.values()) { + let moving = false; + for (const value of values.values()) { for (let index = 0; index < steps; index += 1) { - value.v += (-value.k * (value.x - value.to) - value.c * value.v) * h34; - value.x += value.v * h34; @@ -2593,10 +2666,6 @@ diff --git a/client.js b/client.js - onAnimationEnd: poked ? (event) => { - if (/^bt-poke/.test(event.animationName)) setPoked(false); - } : void 0 -- }; -- if (look.image) return (0, import_react2.createElement)("span", common, (0, import_react2.createElement)("img", { className: "bt-mark-img", src: look.image, alt: "", draggable: false })); -- const box = shapeOf(look.shape).size; -- return (0, import_react2.createElement)("span", common, (0, import_react2.createElement)("svg", { viewBox: `0 0 ${box} ${box}`, dangerouslySetInnerHTML: { __html: characterMarkup(look.shape, look.color).replaceAll(MASK_ID, maskId.current) } })); +function uniqueOwners(owners) { + const seen = /* @__PURE__ */ new Set(); + const out = []; @@ -2630,13 +2699,7 @@ diff --git a/client.js b/client.js + if (people2.length) bits.push(people2.join(", ")); + bits.push(join.length === 6 ? join : when3); + return bits.join(" · "); - } --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" }) -- ); ++} + +// src/client/brand.js +var FLYBOT_WING = "M150 238 C 96 200, 40 196, 22 214 C 8 230, 40 262, 150 300 Z"; @@ -2662,9 +2725,7 @@ diff --git a/client.js b/client.js + if (room2 && room2.origin === "face-to-face" && (room2.face?.status === "closed" || room2.face?.status === "left") && isAutoFaceTitle(room2.name)) return "Face-to-face · closed"; + if (room2) return room2.displayName || (room2.sharedFrom ? sharedRoomTitle(room2.sharedFrom.inviterName) : room2.name) || ""; + return bot?.name || ""; - } --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 installTabTitle(sessions, roster, doc = globalThis.document) { + if (!doc || !sessions || !roster) return () => { + }; @@ -2681,11 +2742,59 @@ diff --git a/client.js b/client.js + return () => { + offSessions?.(); + offRoster?.(); + }; +- if (look.image) return (0, import_react2.createElement)("span", common, (0, import_react2.createElement)("img", { className: "bt-mark-img", src: look.image, alt: "", draggable: false })); +- const box = shapeOf(look.shape).size; +- return (0, import_react2.createElement)("span", common, (0, import_react2.createElement)("svg", { viewBox: `0 0 ${box} ${box}`, dangerouslySetInnerHTML: { __html: characterMarkup(look.shape, look.color).replaceAll(MASK_ID, maskId.current) } })); + } +-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 () => { ++ }; ++ const href = `data:image/svg+xml,${encodeURIComponent(flybotMarkSvg())}`; ++ let link = document.getElementById("flybot-favicon"); ++ if (!link) { ++ link = document.createElement("link"); ++ link.id = "flybot-favicon"; ++ link.rel = "icon"; ++ link.type = "image/svg+xml"; ++ document.head.appendChild(link); ++ } ++ link.href = href; ++ const retitle = () => { ++ const next = brandTitle(document.title); ++ if (next !== document.title) document.title = next; ++ }; ++ retitle(); ++ const observer = typeof MutationObserver === "function" ? new MutationObserver(retitle) : null; ++ const node = document.querySelector("title") ?? document.head; ++ observer?.observe(node, { childList: true, characterData: true, subtree: true }); ++ return () => { ++ 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)( -- "svg", ++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", - { viewBox: "0 0 20 20", "aria-hidden": true }, - (0, import_react2.createElement)("path", { d: "M10 2.4l2.29 5.04 5.51.63-4.09 3.74 1.11 5.42L10 14.5l-4.82 2.73 1.11-5.42L2.2 8.07l5.51-.63z" }) - ); @@ -2763,52 +2872,24 @@ diff --git a/client.js b/client.js - return (0, import_react2.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); - }) - ); -+function installBrandChrome() { -+ if (typeof document === "undefined") return () => { -+ }; -+ const href = `data:image/svg+xml,${encodeURIComponent(flybotMarkSvg())}`; -+ let link = document.getElementById("flybot-favicon"); -+ if (!link) { -+ link = document.createElement("link"); -+ link.id = "flybot-favicon"; -+ link.rel = "icon"; -+ link.type = "image/svg+xml"; -+ document.head.appendChild(link); -+ } -+ link.href = href; -+ const retitle = () => { -+ const next = brandTitle(document.title); -+ if (next !== document.title) document.title = next; -+ }; -+ retitle(); -+ const observer = typeof MutationObserver === "function" ? new MutationObserver(retitle) : null; -+ const node = document.querySelector("title") ?? document.head; -+ observer?.observe(node, { childList: true, characterData: true, subtree: true }); -+ return () => { -+ observer?.disconnect(); -+ link.remove(); -+ }; - } - - // src/client/icons.js +-} +- +-// src/client/icons.js -var import_react3 = require("react"); -+var import_react4 = require("react"); - function PlusIcon() { +-function PlusIcon() { - return (0, import_react3.createElement)("svg", { width: 18, height: 18, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("path", { d: "M10 3.5v13M3.5 10h13", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); -+ 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 SearchIcon() { +-} +-function SearchIcon() { - return (0, import_react3.createElement)("svg", { width: 16, height: 16, viewBox: "0 0 20 20", "aria-hidden": true }, (0, import_react3.createElement)("circle", { cx: 8.5, cy: 8.5, r: 5.5, stroke: "currentColor", strokeWidth: 1.6, fill: "none" }), (0, import_react3.createElement)("path", { d: "M13 13l4 4", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" })); -+ 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() { +-} +-function PeopleIcon() { - return (0, import_react3.createElement)( -+ return (0, import_react4.createElement)( - "svg", - { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", "aria-hidden": true }, +- "svg", +- { width: 14, height: 14, viewBox: "0 0 20 20", fill: "none", stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", "aria-hidden": true }, - (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" }) @@ -3907,7 +3988,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,61 +3940,230 @@ function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, use +@@ -3940,79 +3940,248 @@ function AssistantCell({ node, groupPart, sessionId, openFile, fileMentions, use whole: true }); }), @@ -4102,8 +4183,14 @@ diff --git a/client.js b/client.js - (0, import_react6.createElement)(Bubbles, { texts: [text], roster, selfId: bot.id, actions, sessionId: room2?.id, msgKey, useUi, lead }) + (0, import_react8.createElement)(Bubbles, { texts: [stripTrace(text)], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead }), + files.length ? (0, import_react8.createElement)(RemoteFiles, { files, plugin: member?.plugin ?? "social", botId: roster.byId[sessionId]?.id ?? sessionId }) : null -+ ); -+} + ); + } +-function GroupEvent({ prefix, room: room2, name, roster, actions }) { +- return (0, import_react6.createElement)( +- "div", +- { 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; @@ -4116,8 +4203,17 @@ diff --git a/client.js b/client.js + { 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)(""); @@ -4147,25 +4243,24 @@ diff --git a/client.js b/client.js + `📎 ${file.name} · ${fileSize(file.size)}` + )), + error ? (0, import_react8.createElement)("span", { className: "bt-file-error", role: "alert" }, error) : null - ); - } - function GroupEvent({ prefix, room: room2, name, roster, actions }) { -- return (0, import_react6.createElement)( ++ ); ++} ++function GroupEvent({ prefix, room: room2, name, roster, actions }) { + return (0, import_react8.createElement)( - "div", - { 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 ++ "div", ++ { className: "bt-event" }, + (0, import_react8.createElement)("span", null, prefix), + room2 ? (0, import_react8.createElement)("button", { type: "button", onClick: () => actions.openSession(room2.id) }, (0, import_react8.createElement)(RoomAvatar, { room: room2, roster, size: 16 }), room2.name) : name ? (0, import_react8.createElement)("span", null, name) : null - ); - } - function MemoryEvent({ sessionId, scope, result, roster, actions, findBot }) { -@@ -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)( ++ ); ++} ++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_react8.createElement)( "div", { className: "bt-event" }, diff --git a/patches/ds-bot/files/src/client/flybot-slots.js b/patches/ds-bot/files/src/client/flybot-slots.js index 097a9903..44e1df97 100644 --- a/patches/ds-bot/files/src/client/flybot-slots.js +++ b/patches/ds-bot/files/src/client/flybot-slots.js @@ -34,6 +34,7 @@ import { call as callHost } from './api.js' import { friendBotOwner, friendBotTitle, isListedRemoteChat } from '../shared/remote-label.js' import { registry as baseRegistry } from './flybot-registry.js' import { BotAvatar, BotMark, lookOf } from './mark.js' +import { RoomInvitePreview, inviteCountdown, invitePreviewOf } from './room-invite-preview.js' import { t } from './i18n.js' import { chatOpened, claimApprovals, claimedIds, onClaims } from './approval-claims.js' import { isApprovalWaiting, WAITING_STATUSES } from '../shared/approval-waiting.js' @@ -54,6 +55,11 @@ export function registry() { globalThis.__flybot__.BotMark ??= BotMark globalThis.__flybot__.BotAvatar ??= BotAvatar globalThis.__flybot__.lookOf ??= lookOf + // One room-invite preview card for every surface that shows an invite (#876): the face-room paste + // flow and the member-invite dialog render this, so the markup is not written twice. + globalThis.__flybot__.RoomInvitePreview ??= RoomInvitePreview + globalThis.__flybot__.invitePreviewOf ??= invitePreviewOf + globalThis.__flybot__.inviteCountdown ??= inviteCountdown // The same for a friend's Bot's one name and its owner (#498), so the social plugin says it as the sidebar does. globalThis.__flybot__.names ??= { friendBotTitle, friendBotOwner } return slots diff --git a/patches/ds-bot/files/src/client/room-invite-preview.js b/patches/ds-bot/files/src/client/room-invite-preview.js new file mode 100644 index 00000000..ec9500a6 --- /dev/null +++ b/patches/ds-bot/files/src/client/room-invite-preview.js @@ -0,0 +1,111 @@ +// One room-invite preview card, drawn wherever an invite is shown (#876). The face-room paste flow +// and the member-invite dialog both render this component, so the markup exists once: a change here +// is visible in both, and neither surface keeps a copy. +// +// It is a plain function, not a hook component: the caller passes its own `createElement` as `h`, +// which is what lets the core bundle and a classic-script plugin (which gets `h` from `require`) +// draw the same card. Everything the card says comes from a server reply, so the strings are +// cleaned here, at the last place before a person reads them — the same rules the server applies to +// a room name (`cleanFaceName`): one line, no controls, brackets, markup or @, capped. +export const INVITE_PREVIEW_NAME_MAX = 60 + +// Untrusted fence markers are stripped to a fixed point: one pass can remove a match and assemble a +// new one, so a nested `` would otherwise leave a complete +// `` in the card a person reads (#822). +export function stripInviteMarkers(text) { + let out = String(text ?? '') + for (let previous; previous !== out;) { + previous = out + out = out.replace(/<\/?\s*untrusted-(?:file|message)/gi, '') + } + return out +} + +export function cleanInviteName(value, fallback = '') { + return stripInviteMarkers(value) + .replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ') + .replace(/[[\]"<>`@]/g, '') + .replace(/\s+/g, ' ') + .trim() + .slice(0, INVITE_PREVIEW_NAME_MAX) || fallback +} + +// A count is only what reads as one (#823): `Number('')` is 0, which would draw an unknown room as +// empty, so an empty or non-numeric string is "not sent", like null. +export function inviteMemberCount(value) { + if (typeof value === 'number') return Number.isFinite(value) ? value : NaN + if (typeof value !== 'string') return NaN + const text = value.trim() + if (!text) return NaN + const count = Number(text) + return Number.isFinite(count) ? count : NaN +} + +// What the card can honestly say, and nothing else: a reply with no field worth showing is no card +// at all, so the caller keeps its own path (a paste that previews nothing still joins as before). +export function invitePreviewOf(seen) { + if (!seen || typeof seen !== 'object' || seen.unsupported === true) return null + const name = cleanInviteName(seen.name) + const lead = cleanInviteName(seen.lead) + const members = inviteMemberCount(seen.members) + const maxMembers = inviteMemberCount(seen.maxMembers) + const people = Number.isFinite(members) && Number.isFinite(maxMembers) + ? `${members} / ${maxMembers} in the room` + : Number.isFinite(members) ? `${members} in the room` : '' + const expiresAt = typeof seen.expiresAt === 'string' ? seen.expiresAt : '' + if (!name && !lead && !people && !expiresAt) return null + return { name, lead, people, expiresAt, clockOffsetMs: Number(seen.clockOffsetMs) || 0 } +} + +// Every home counts down to the server's one deadline (#458): `offsetMs` is how far the server's +// clock runs ahead of this one (0 unless the skew is real). m:ss, so the line visibly ticks. +export function inviteCountdown(expiresAt, offsetMs = 0) { + const at = Date.parse(expiresAt) - (Number(offsetMs) || 0) + if (!Number.isFinite(at)) return 'Open while the room is open' + const left = Math.max(0, at - Date.now()) + if (left <= 0) return 'This link has expired' + const total = Math.max(1, Math.ceil(left / 1000)) + return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')} left to join` +} + +// `kind` prefixes every `data-flybot` name, so each surface keeps the hook its tests and its CSS +// already use while sharing this one tree of elements. +export function RoomInvitePreview({ h, preview, kind = 'face-link-preview', busy = false, joinLabel = 'Join', clearLabel = 'Clear', showClear = true, onJoin, onClear, now } = {}) { + if (!preview || typeof h !== 'function') return null + const at = `${kind}` + const child = (name) => `${at}-${name}` + const countdown = now === undefined + ? inviteCountdown(preview.expiresAt, preview.clockOffsetMs) + : inviteCountdownAt(preview.expiresAt, preview.clockOffsetMs, now) + return h('div', { className: 'fs-preview', 'data-flybot': at, role: 'status' }, + h('div', { className: 'fs-preview-name', 'data-flybot': child('name') }, preview.name || 'Face-to-face room'), + preview.lead ? h('div', { className: 'fs-preview-meta', 'data-flybot': child('lead') }, `Lead: ${preview.lead}`) : null, + preview.people ? h('div', { className: 'fs-preview-meta', 'data-flybot': child('members') }, preview.people) : null, + preview.expiresAt ? h('div', { className: 'fs-preview-meta', 'data-flybot': child('expiry') }, countdown) : null, + onJoin || onClear + ? h('div', { className: 'fs-preview-foot', 'data-flybot': child('foot') }, + showClear && onClear + ? h('button', { + type: 'button', className: 'fs-btn fs-btn-sm', 'data-flybot': child('clear'), + disabled: !!busy, onClick: onClear, + }, clearLabel) + : null, + onJoin + ? h('button', { + type: 'button', className: 'fs-btn fs-btn-sm fs-btn-primary', 'data-flybot': child('join'), + disabled: !!busy, onClick: onJoin, + }, joinLabel) + : null) + : null) +} + +// The same text as `inviteCountdown`, measured against a clock the caller already has — a render is +// then reproducible in a test without freezing the machine's clock. +export function inviteCountdownAt(expiresAt, offsetMs, now) { + const at = Date.parse(expiresAt) - (Number(offsetMs) || 0) + if (!Number.isFinite(at)) return 'Open while the room is open' + const left = Math.max(0, at - Number(now || 0)) + if (left <= 0) return 'This link has expired' + const total = Math.max(1, Math.ceil(left / 1000)) + return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')} left to join` +} diff --git a/patches/ds-bot/files/src/host/authz.js b/patches/ds-bot/files/src/host/authz.js index e75c860f..9ac1c622 100644 --- a/patches/ds-bot/files/src/host/authz.js +++ b/patches/ds-bot/files/src/host/authz.js @@ -47,7 +47,10 @@ const BOT_TARGETED = new Set([ ]) // Endpoints whose body names a room. -const ROOM_TARGETED = new Set(['update-room', 'delete-room', 'room-progress', 'create-room', 'room-history', 'room-post']) +const ROOM_TARGETED = new Set(['update-room', 'delete-room', 'room-progress', 'create-room', 'room-history', 'room-post', + // #876: the room's own link. Reading it is a member's read; minting and withdrawing hand out + // or kill a way in, so they follow the room's admin rule. + 'room-invite', 'room-invite-mint', 'room-invite-revoke']) // Endpoints whose body names a Session. const SESSION_TARGETED = new Set(['earlier', 'send', 'answer-question', 'dismiss-question', 'approval-answer', 'exchange']) @@ -334,6 +337,19 @@ export function install(rt) { const id = body?.id ?? body?.roomId return roomMemberOf(actor, id) || roomOwnedBy(actor, id) ? ALLOW : deny('bot/forbidden', 'You are not a member of that room') } + if (ep === 'room-invite') { + const id = body?.id ?? body?.roomId + const room = state.rooms?.[id] + const me = currentParticipants(room).find(p => p.kind === 'human' && p.id === actor.memberId) + return roomOwnedBy(actor, id) || roomMemberOf(actor, id) || me?.role === 'admin' ? ALLOW : deny('bot/forbidden', 'You are not a member of that room') + } + if (ep === 'room-invite-mint' || ep === 'room-invite-revoke') { + const id = body?.id ?? body?.roomId + if (roomOwnedBy(actor, id)) return ALLOW + const room = state.rooms?.[id] + const me = currentParticipants(room).find(p => p.kind === 'human' && p.id === actor.memberId) + return me?.role === 'admin' ? ALLOW : deny('bot/forbidden', 'You do not own that room') + } if (ep === 'delete-room') { const id = body?.id ?? body?.roomId if (roomOwnedBy(actor, id)) return ALLOW @@ -370,6 +386,9 @@ export function install(rt) { // Sharing a member Bot's brief is the Bot owner's decision, checked on the record // in room-membership (#53); the endpoint itself only needs a signed-in actor. if (ep === 'share-brief') return ALLOW + // #876: knocking at another FlyBot's room link is not authority over anything here. The knock + // carries a ticket that the inviter's record decides on, and the sender is the actor's own Bot. + if (ep === 'room-invite-ask') return ALLOW // Task transitions are requester-checked on the record in tasks.js (#54) — the // endpoint only needs a signed-in actor, like held-send decisions. Create is // grant-aware there (owner, matching grant, or the named room). Explicit set: diff --git a/patches/ds-bot/files/src/host/room-membership.js b/patches/ds-bot/files/src/host/room-membership.js index 8bfe44e7..471d122d 100644 --- a/patches/ds-bot/files/src/host/room-membership.js +++ b/patches/ds-bot/files/src/host/room-membership.js @@ -36,7 +36,7 @@ import { randomBytes } from 'node:crypto' import { cleanPeerName, disambiguatedRemoteTitle, isChiefRecord, remoteBotTitle, sharedRoomTitle } from '../shared/remote-label.js' import { formatFaceRoomTitle, isAutoFaceTitle, faceRoomCreatedAt } from '../shared/face-title.js' -import { encodeRoomShare, LOCAL_INVITE_TTL_MS, parseRoomShare, roomInviteUrl } from '../shared/room-share.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 { hasAnyGrant, mayShareRoom } from './reach.js' @@ -1012,6 +1012,40 @@ export function install(rt) { return { revoked: true } } + // #876: the invitee's side of a local link. The link names the Bot to knock on, and the knock + // carries the ticket and nothing else; the accepting click is the same knock with `accept: true`. + // Nothing here decides the seat: the inviter's room record answers, and its offer comes back as + // `invite` through applyInboundRoomShare (rt.onLocalInviteOffer). The knock goes out from a Bot of + // the actor's whose home has a Bot Social identity — the link cannot name the sender, so the + // transport does, exactly as on the answering side. + function localSpeaker(actor) { + const usable = Object.values(state.bots ?? {}).filter(bot => bot && bot.remote === undefined + && typeof bot?.fields?.social?.member?.handle === 'string' + && bot.fields.social.member.handle.startsWith('@')) + const mine = actor?.memberId ? usable.filter(bot => bot.ownerId === actor.memberId) : usable + const list = mine.length > 0 ? mine : usable + const chief = actor?.memberId ? state.members?.[actor.memberId]?.designatedChiefId : undefined + return (chief !== undefined ? list.find(bot => bot.id === chief) : undefined)?.id ?? list[0]?.id + } + + rt.askLocalInvite = async function askLocalInvite(link, opts = {}, actor) { + const parsed = parseRoomInviteLink(typeof link === 'string' ? link : '') + if (!parsed || parsed.kind !== 'local') throw invalid('That is not a room link on another FlyBot') + if (!rt.flybotSend) throw invalid('Bot Social is not connected on this machine yet') + const from = localSpeaker(actor) + if (from === undefined) throw invalid('This machine has no Bot Social identity yet') + const accept = opts?.accept === true + const sent = await rt.flybotSend({ + from, + to: parsed.handle, + text: encodeRoomShare({ op: 'ask', ticket: parsed.ticket, ...(accept ? { accept: true } : {}) }), + role: 'request', + via: 'room-share', + meta: { share: 'ask', ticket: parsed.ticket }, + }) + return { sent: sent?.action ?? 'sent', handle: parsed.handle, ticket: parsed.ticket, accept } + } + // #852: a knock at a local room's link. The invitee's host sends `ask` (the accepting knock carries // `accept: true`); this side answers from the room record only, and a knock never creates a room, // adds a participant, or writes a line. The asker is the transport's sender, never a field of the diff --git a/patches/ds-bot/files/test/authz.test.js b/patches/ds-bot/files/test/authz.test.js index f67b1130..db81a421 100644 --- a/patches/ds-bot/files/test/authz.test.js +++ b/patches/ds-bot/files/test/authz.test.js @@ -104,6 +104,17 @@ test('owner cannot touch another owner Bot or room', async (t) => { assert.equal(gate.code, 'bot/forbidden', ep) } + // #876: a room's link is a member's read, and minting or withdrawing it is the room admin's call. + for (const ep of ['room-invite', 'room-invite-mint', 'room-invite-revoke']) { + assert.equal(rt.authorize(actorA, ep, { id: 'ra' }).ok, true, ep) + const gate = rt.authorize(actorA, ep, { id: 'rb' }) + assert.equal(gate.ok, false, ep) + assert.equal(gate.code, 'bot/forbidden', ep) + } + // A knock at another FlyBot's link carries no authority here, so a signed-in actor may always ask. + assert.equal(rt.authorize(actorA, 'room-invite-ask', { link: 'https://social.botawaken.com/r/@chief.b.home/AbCdEfGhIjKlMnOpQrStUv' }).ok, true) + assert.equal(rt.authorize(null, 'room-invite-ask', {}).ok, false, 'a signed-in actor is still required') + const mixed = rt.authorize(actorA, 'create-room', { members: ['a1', 'b1'] }) assert.equal(mixed.ok, false) assert.equal(mixed.code, 'bot/forbidden') diff --git a/patches/ds-bot/files/test/remote-room.test.js b/patches/ds-bot/files/test/remote-room.test.js index 58525273..ea918898 100644 --- a/patches/ds-bot/files/test/remote-room.test.js +++ b/patches/ds-bot/files/test/remote-room.test.js @@ -1337,6 +1337,55 @@ test('#852: a local link is not a face-room link, and a shared or face room has room.face = { roomId: 'frm_1', members: [] } await assert.rejects(() => rt.mintRoomInvite('war'), /its own invitation/) }) +test('#876: the invitee knocks on the link, and Join is the same knock with accept', async (t) => { + const { rt, sent } = host(t, TEAM) + await rt.load() + const { bob } = await signAliceAndBob(rt) + const actorB = await rt.authenticate(bob.sessionToken) + rt.state.bots['chief-b'].fields = { social: { member: { home: '@bob.home', handle: '@chief.b.home' } } } + const link = 'https://social.botawaken.com/r/@chief.a.home/AbCdEfGhIjKlMnOpQrStUv' + + // The preview knock: the ticket and the Bot to knock on, and no room id — this home has no room. + const knock = await rt.askLocalInvite(link + '/', {}, actorB) + assert.deepEqual( + { handle: knock.handle, ticket: knock.ticket, accept: knock.accept, sent: knock.sent }, + { handle: '@chief.a.home', ticket: 'AbCdEfGhIjKlMnOpQrStUv', accept: false, sent: 'hold' }, + ) + const first = sent.at(-1) + assert.equal(first.to, '@chief.a.home', 'the link names who is knocked on') + assert.equal(first.from, 'chief-b', "the knock leaves from the actor's own Bot, never a body field") + assert.equal(first.via, 'room-share') + const asked = parseRoomShare(first.text) + assert.equal(asked.op, 'ask') + assert.equal(asked.ticket, 'AbCdEfGhIjKlMnOpQrStUv') + assert.equal(asked.accept, undefined, 'the preview asks without accepting') + assert.equal(asked.roomId, undefined, 'the invitee cannot name a room it has never seen') + + // Join is the same knock with the one flag the answering side reads. + const accepted = await rt.askLocalInvite(link, { accept: true }, actorB) + assert.equal(accepted.accept, true) + assert.equal(parseRoomShare(sent.at(-1).text).accept, true) + assert.equal(parseRoomShare(sent.at(-1).text).ticket, asked.ticket) + + // A face-room link and a bare ticket are not this knock: one sentence, and nothing is sent. + const before = sent.length + await assert.rejects(() => rt.askLocalInvite('https://social.botawaken.com/r/AbCdEfGhIjKlMnOpQrStUv', {}, actorB), /not a room link on another FlyBot/) + await assert.rejects(() => rt.askLocalInvite('AbCdEfGhIjKlMnOpQrStUv', {}, actorB), /not a room link on another FlyBot/) + await assert.rejects(() => rt.askLocalInvite('https://social.botawaken.com/r/@chief.a.home/short', {}, actorB), /not a room link on another FlyBot/) + assert.equal(sent.length, before, 'a refused link knocks on nothing') + + // No Bot Social identity on this side: the refusal names that, not the link. + const kept = rt.state.bots['chief-b'].fields + delete rt.state.bots['chief-b'].fields + await assert.rejects(() => rt.askLocalInvite(link, {}, actorB), /no Bot Social identity yet/) + rt.state.bots['chief-b'].fields = kept + // Without a transport installed there is nothing to knock with. + const send = rt.flybotSend + rt.flybotSend = undefined + await assert.rejects(() => rt.askLocalInvite(link, {}, actorB), /Bot Social is not connected/) + rt.flybotSend = send +}) + test('#852: the knock asks the room record, and only the accept admits one home', async (t) => { const { rt, sent } = host(t, TEAM) await rt.load() diff --git a/patches/ds-bot/files/test/room-invite-preview.test.js b/patches/ds-bot/files/test/room-invite-preview.test.js new file mode 100644 index 00000000..73e99e9f --- /dev/null +++ b/patches/ds-bot/files/test/room-invite-preview.test.js @@ -0,0 +1,79 @@ +// #876: the room-invite preview card is one component in the core client, drawn by both surfaces +// that show an invite. These tests pin what it says, what it refuses to say, and that the markup a +// surface gets depends only on `kind` — not on which surface asked. +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { + RoomInvitePreview, cleanInviteName, inviteCountdown, inviteCountdownAt, + inviteMemberCount, invitePreviewOf, +} from '../src/client/room-invite-preview.js' + +const h = (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat() }) +const walk = (node) => { + if (!node || typeof node !== 'object') return [] + if (typeof node.type === 'function') return walk(node.type(node.props)) + return [node, ...(node.children ?? []).flatMap(walk)] +} +const byKind = (node, kind) => walk(node).filter(n => n.props?.['data-flybot'] === kind) + +const preview = { name: 'Saturday room', lead: 'Chief', people: '3 / 8 in the room', expiresAt: '2099-01-01T00:00:00.000Z', clockOffsetMs: 0 } + +test('#876: the card names the room, its lead, its people and the deadline', () => { + const tree = RoomInvitePreview({ h, preview, onJoin() {}, onClear() {} }) + assert.equal(tree.props['data-flybot'], 'face-link-preview') + assert.equal(tree.children[0].children.join(''), 'Saturday room') + assert.equal(tree.children[1].children.join(''), 'Lead: Chief') + assert.equal(tree.children[2].children.join(''), '3 / 8 in the room') + // The deadline line is the one countdown text, not a per-surface copy of the rule. + assert.equal(tree.children[3].children.join(''), inviteCountdownAt(preview.expiresAt, 0, Date.now())) + const foot = tree.children[4] + assert.deepEqual(foot.children.map(button => button.children[0]), ['Clear', 'Join']) +}) + +test('#876: `kind` only renames the hooks, so both surfaces draw this one tree', () => { + const face = RoomInvitePreview({ h, preview, kind: 'face-link-preview', onJoin() {}, onClear() {} }) + const dialog = RoomInvitePreview({ h, preview, kind: 'room-invite-preview', onJoin() {}, onClear() {} }) + const shape = (node) => JSON.stringify(node, (key, value) => { + if (key === 'data-flybot' && typeof value === 'string') return value.replace(/^face-link-preview/, 'KIND').replace(/^room-invite-preview/, 'KIND') + return typeof value === 'function' ? 'fn' : value + }) + assert.equal(shape(face), shape(dialog), 'same elements, same order: nothing is written twice') + assert.equal(byKind(dialog, 'room-invite-preview').length, 1) + assert.equal(byKind(dialog, 'room-invite-preview-name').length, 1) + assert.equal(byKind(face, 'face-link-preview-name').length, 1) +}) + +test('#876: a name a stranger wrote reaches the card as plain text', () => { + const seen = { + name: 'Room [x] @chief\u0000 now', + lead: 'Chief @home', + members: 2, + } + const card = invitePreviewOf(seen) + assert.equal(card.name, 'Room x chief now', 'fence markers removed to a fixed point, then brackets and @') + assert.equal(card.lead, 'Chief ihome/i', 'markup characters are not kept') + assert.equal(card.people, '2 in the room') + const tree = RoomInvitePreview({ h, preview: card, onJoin() {} }) + assert.equal(walk(tree).every(n => !String(n.props?.children ?? '').includes('<')), true) +}) + +test('#876: nothing worth saying is no card, and a count is only what reads as one', () => { + assert.equal(invitePreviewOf(undefined), null) + assert.equal(invitePreviewOf({ unsupported: true }), null) + assert.equal(invitePreviewOf({}), null, 'an empty reply draws no card') + assert.equal(RoomInvitePreview({ h, preview: null }), null) + assert.equal(inviteMemberCount(''), Number.NaN, 'an empty string is not zero members (#823)') + assert.equal(inviteMemberCount(' 3 '), 3) + assert.equal(inviteMemberCount('three'), Number.NaN) + assert.equal(invitePreviewOf({ members: '', maxMembers: '' }), null, 'no count line at all, so no card') + assert.equal(invitePreviewOf({ members: 3 }).people, '3 in the room') + assert.equal(invitePreviewOf({ members: 3, maxMembers: '' }).people, '3 in the room') +}) + +test('#876: the deadline line is shared copy, and an expired one says so', () => { + const future = new Date(Date.now() + 61_000).toISOString() + assert.match(inviteCountdownAt(future, 0, Date.now()), /^1:0\d left to join$/) + assert.equal(inviteCountdownAt('2000-01-01T00:00:00.000Z', 0, Date.now()), 'This link has expired') + assert.equal(inviteCountdown('not a date'), 'Open while the room is open') + assert.equal(cleanInviteName(' ', 'a room'), 'a room') +}) diff --git a/patches/ds-bot/files/types/flybot.d.ts b/patches/ds-bot/files/types/flybot.d.ts index 11502295..f178923c 100644 --- a/patches/ds-bot/files/types/flybot.d.ts +++ b/patches/ds-bot/files/types/flybot.d.ts @@ -614,6 +614,9 @@ export interface FlyBotClientRegistry { export type FlyBotBotMark = (props: { bot?: any; look?: { shape?: string; color?: string; image?: string; emoji?: string }; size?: number; [key: string]: unknown }) => any export type FlyBotBotAvatar = (props: { bot?: any; size?: number; badge?: boolean; live?: boolean; [key: string]: unknown }) => any export type FlyBotLookOf = (bot?: any) => { shape: string; color: string; image?: string } +/** One room-invite preview card, for every surface that shows an invite (#876). */ +export type FlyBotRoomInvitePreview = (props: any) => any +export type FlyBotInvitePreview = (seen?: any) => { name: string; lead: string; people: string; expiresAt: string; clockOffsetMs: number } | null declare global { // eslint-disable-next-line no-var @@ -626,6 +629,10 @@ declare global { /** Sidebar avatar (mark plus optional badges). Prefer this at 24px with badge: false. */ BotAvatar?: FlyBotBotAvatar lookOf?: FlyBotLookOf + /** The invite card the face-room paste flow and the member-invite dialog both render (#876). */ + RoomInvitePreview?: FlyBotRoomInvitePreview + invitePreviewOf?: FlyBotInvitePreview + inviteCountdown?: (expiresAt: string, offsetMs?: number) => string /** A friend's Bot chat (a roster Bot with `remote`): its one name, "Chief (Bob)", and its owner, "Bob" (#498). */ names?: { friendBotTitle(bot?: any): string; friendBotOwner(bot?: any): string } } | undefined diff --git a/plugins/social/client.js b/plugins/social/client.js index 7b637c51..f6cc5198 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -908,31 +908,11 @@ window.__ModuleLoader__.load({ .replace(/\s+/g, ' ') .trim() .slice(0, ROOM_NAME_MAX) || fallback - // A count is only what reads as one (#823): `Number('')` is 0, which would draw an unknown room - // as empty, so an empty or non-numeric string is "not sent", like null. - const roomCount = (value) => { - if (typeof value === 'number') return Number.isFinite(value) ? value : NaN - if (typeof value !== 'string') return NaN - const text = value.trim() - if (!text) return NaN - const count = Number(text) - return Number.isFinite(count) ? count : NaN - } - // What the card can honestly say, and nothing else: a room with no field worth showing is no - // card at all, so the join below runs as before. - const faceInvitePreview = (seen) => { - if (!seen || typeof seen !== 'object' || seen.unsupported === true) return null - const name = cleanRoomName(seen.name) - const lead = cleanRoomName(seen.lead) - const members = roomCount(seen.members) - const maxMembers = roomCount(seen.maxMembers) - const people = Number.isFinite(members) && Number.isFinite(maxMembers) - ? `${members} / ${maxMembers} in the room` - : Number.isFinite(members) ? `${members} in the room` : '' - const expiresAt = typeof seen.expiresAt === 'string' ? seen.expiresAt : '' - if (!name && !lead && !people && !expiresAt) return null - return { name, lead, people, expiresAt, clockOffsetMs: Number(seen.clockOffsetMs) || 0 } - } + // The card is core's, not this plugin's (#876): `invitePreviewOf` and `RoomInvitePreview` are + // registered by ds-bot's client bundle, so the face-room paste flow and the member-invite dialog + // draw one tree of elements. This file keeps no copy of the markup. + const invitePreview = (seen) => globalThis.__flybot__?.invitePreviewOf?.(seen) ?? null + const InvitePreviewCard = (props) => h(globalThis.__flybot__?.RoomInvitePreview ?? (() => null), { h, ...props }) const qrPath = (modules) => { const parts = [] for (let y = 0; y < modules.length; y += 1) { @@ -1418,7 +1398,7 @@ window.__ModuleLoader__.load({ setInvite({ link: text, status: { tone: 'error', text: INVITE_UNSUPPORTED } }) return } - const card = faceInvitePreview(seen) + const card = invitePreview(seen) if (card) { setInvite({ link: text, status: null, preview: { ...card, ticket } }) return @@ -1540,20 +1520,11 @@ window.__ModuleLoader__.load({ invite.status ? h('div', { className: 'fs-hint fs-scan-hint', 'data-flybot': 'face-link-status', 'data-tone': invite.status.tone, role: 'status', }, invite.status.text) : null), - preview ? h('div', { className: 'fs-preview', 'data-flybot': 'face-link-preview', role: 'status' }, - h('div', { className: 'fs-preview-name', 'data-flybot': 'face-link-preview-name' }, preview.name || 'Face-to-face room'), - preview.lead ? h('div', { className: 'fs-preview-meta', 'data-flybot': 'face-link-preview-lead' }, `Lead: ${preview.lead}`) : null, - preview.people ? h('div', { className: 'fs-preview-meta', 'data-flybot': 'face-link-preview-members' }, preview.people) : null, - preview.expiresAt ? h('div', { className: 'fs-preview-meta', 'data-flybot': 'face-link-preview-expiry' }, countdown(preview.expiresAt, preview.clockOffsetMs)) : null, - h('div', { className: 'fs-preview-foot' }, - h('button', { - type: 'button', className: 'fs-btn fs-btn-sm', 'data-flybot': 'face-link-preview-clear', - disabled: !!busy, onClick: clearInvite, - }, 'Clear'), - h('button', { - type: 'button', className: 'fs-btn fs-btn-sm fs-btn-primary', 'data-flybot': 'face-link-preview-join', - disabled: !!busy, onClick: () => joinTicket(preview.link ?? invite.link, preview.ticket), - }, 'Join'))) : null, + preview ? h(InvitePreviewCard, { + preview, busy, kind: 'face-link-preview', + onClear: clearInvite, + onJoin: () => joinTicket(preview.link ?? invite.link, preview.ticket), + }) : null, h('div', { className: 'fs-scan-row' }, h('button', { type: 'button', className: 'fs-btn', 'data-flybot': 'face-scan', disabled: !!busy, diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index 4382098f..4d1e54ae 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -12,6 +12,7 @@ import { approvalDraftText, approvalView, createSocial, draftText, holdAllowsFac import { createMcpClient, createSocialApi, untrustedText, mapPool, socialHttpAgent, fetchOptions, grantPersonView, memberHandleOf, personHomeOf, normalizeGrant, isUnknownTool, isGrantNotFound, isMissingEndpoint } from '../src/mcp-client.js' import { lookFromServer, lookToServer, lookFingerprint, hexOfColor, shownLook, defaultShapeOf, defaultColorOf, DEFAULT_SHAPES as LOOK_DEFAULT_SHAPES, DEFAULT_COLORS as LOOK_DEFAULT_COLORS } from '../src/look.js' import { encodeRoomShare } from '../../../core/src/shared/room-share.js' +import { RoomInvitePreview, inviteCountdown, invitePreviewOf } from '../../../core/src/client/room-invite-preview.js' import { encodeFaceHuman } from '../src/face-rooms.js' import { hideRemoteInSettings, forgetHiddenIfGone, filterHiddenHomes, presentRemoteHandles, @@ -2962,6 +2963,10 @@ function loadSocialClient(callImpl) { }) sandbox.__flybot__.BotAvatar = stubMark('bot-avatar') sandbox.__flybot__.BotMark = stubMark('bot-mark') + // The invite card is core's (#876); the plugin only calls it, exactly as ds-bot's bundle registers it. + sandbox.__flybot__.RoomInvitePreview = RoomInvitePreview + sandbox.__flybot__.invitePreviewOf = invitePreviewOf + sandbox.__flybot__.inviteCountdown = inviteCountdown const walk = (node, all = []) => { if (!node || typeof node !== 'object') return all if (typeof node.type === 'function') return walk(node.type(node.props), all) @@ -9860,6 +9865,20 @@ test('#773: a pasted link joins through social/face-join-invite, and a non-link assert.equal(calls.length, before, 'the invalid link went nowhere') }) +test('#876: the invite preview markup lives in the core card, not a second copy here', async () => { + // The paste flow and the member-invite dialog draw one tree of elements (#876). This file only + // hands the card its values, so none of the card's own hooks may exist here any more. + const source = readFileSync(new URL('../client.js', import.meta.url), 'utf8') + for (const hook of ['face-link-preview-name', 'face-link-preview-lead', 'face-link-preview-members', 'face-link-preview-expiry', 'face-link-preview-join', 'face-link-preview-clear', 'face-link-preview-foot']) { + assert.equal(source.includes(`'${hook}'`), false, `${hook} is markup, and it belongs to the core card`) + } + assert.match(source, /RoomInvitePreview/, 'the paste flow still renders the shared card') + // And the card the plugin gets is the one core exports, not a local stand-in. + const { walk, pluginOf } = loadSocialClient(async () => ({})) + const tree = walk(pluginOf([]).FaceToFaceGroup ? [] : []) + 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' const calls = [] From c2746e1e39e4a844f7b6971ccc0e21e199799090 Mon Sep 17 00:00:00 2001 From: Birfy <15378933+Birfy@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:02:27 +0000 Subject: [PATCH 2/2] Keep main's generated patch headers for 0001 and 0003 --- patches/ds-bot/0001-core-fixes.patch | 14 ++++++-------- patches/ds-bot/0003-flybot-api-client.patch | 8 +++----- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 9b9dcb68..19303df1 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -1,5 +1,3 @@ -FlyBot patch 0001-core-fixes on Fei2411/DeepSeek-Bot@2b10c70 (ds-bot/). Generated by scripts/ds-bot-patches.mjs; edits to upstream files only (files FlyBot adds are in files/). - diff --git a/README.md b/README.md --- a/README.md +++ b/README.md @@ -1812,7 +1810,7 @@ diff --git a/src/client/text.js b/src/client/text.js // The prompts in a due scheduled task's message. DSH frames it for the model: one // task as `reminder_prompt_json: "…"`, several as `reminders_json: [{reminder_prompt}]`. export function reminderPrompts(text) { -@@ -62,6 +62,15 @@ export function reminderPrompts(text) { +@@ -62,6 +69,15 @@ export function reminderPrompts(text) { } } export const stripHeader = text => text.replace(HEADER, '') @@ -1828,7 +1826,7 @@ diff --git a/src/client/text.js b/src/client/text.js // Of the Bots in a conversation, the one whose memory changed last, by the host's // clock: changes reaching one window in the same poll share a `seen` time. -@@ -95,6 +95,13 @@ export function summaryBlocks(text) { +@@ -95,6 +102,13 @@ export function summaryBlocks(text) { return blocks } @@ -1842,7 +1840,7 @@ diff --git a/src/client/text.js b/src/client/text.js export function isHiddenTurn(roster, sessionId, turn) { if (sessionId === undefined || turn === undefined) return false return (roster.exchangeTurns[sessionId] ?? []).includes(turn) -@@ -102,6 +102,13 @@ export function isHiddenTurn(roster, sessionId, turn) { +@@ -102,6 +109,13 @@ export function isHiddenTurn(roster, sessionId, turn) { export const toolName = root => root.name || root.call?.name @@ -1856,7 +1854,7 @@ diff --git a/src/client/text.js b/src/client/text.js // While a Session's history loads, a card asked in it may yet be drawn at its call, // so the docks hold it back until then. export const historyLoading = snapshot => snapshot?.openState === 'cold' || snapshot?.openState === 'loading' -@@ -125,7 +125,7 @@ export function toolArgs(root) { +@@ -125,7 +132,7 @@ export function toolArgs(root) { } // A relay context is a Bot-to-Bot request, reply or stop notice injected into the @@ -1865,7 +1863,7 @@ diff --git a/src/client/text.js b/src/client/text.js // parts) are not events in the chat, nor is a request handed back after its line was // already shown: deferred, resumed after a restart, or retried after a tool call // written as text. -@@ -200,14 +200,26 @@ export function commRunOf(order, peers, turn, hiddenTurns = []) { +@@ -200,14 +207,26 @@ export function commRunOf(order, peers, turn, hiddenTurns = []) { } // Latest visible line for a sidebar row; Bot-to-Bot exchange turns stay out of it. @@ -1895,7 +1893,7 @@ diff --git a/src/client/text.js b/src/client/text.js } return '' } -@@ -230,3 +230,35 @@ export function usageOf(list, ids) { +@@ -230,3 +237,35 @@ export 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 }) } diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index 909a5532..174bad39 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -1,5 +1,3 @@ -FlyBot patch 0003-flybot-api-client on Fei2411/DeepSeek-Bot@2b10c70 (ds-bot/). Generated by scripts/ds-bot-patches.mjs; edits to upstream files only (files FlyBot adds are in files/). - diff --git a/src/client/activity-row.js b/src/client/activity-row.js --- a/src/client/activity-row.js +++ b/src/client/activity-row.js @@ -2312,7 +2310,7 @@ diff --git a/src/client/sidebar.js b/src/client/sidebar.js return () => clearTimeout(timer) }, [mainView, roster]) } -@@ -572,9 +572,9 @@ export function FooterAccount(props) { +@@ -572,9 +574,9 @@ export function FooterAccount(props) { export function BotReturn({ wide, useSurface, actions }) { const mode = useSurface(value => value.mode) if (mode !== 'agent') return null @@ -2325,7 +2323,7 @@ diff --git a/src/client/sidebar.js b/src/client/sidebar.js } // Agent mode has none of the shell-group styles, so the return button carries its own. -@@ -582,22 +582,22 @@ export const RETURN_CSS = ` +@@ -582,22 +584,22 @@ export const RETURN_CSS = ` .bt-return{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:36px;padding:0 14px;border:0;border-radius:999px;background:none;color:inherit;font:inherit;font-size:14px;line-height:20px;cursor:pointer;transition:background-color .12s ease} .bt-return:hover{background:rgba(127,127,127,.14)} .bt-return:active{transform:scale(.96)} @@ -2353,7 +2351,7 @@ diff --git a/src/client/sidebar.js b/src/client/sidebar.js // shell's footer classes are CSS-module names, so they match on their stems. export const SIDEBAR_CSS = ` .bt-side-top .bt-brand{cursor:default;padding-left:6px} -@@ -658,4 +658,14 @@ export const SIDEBAR_CSS = ` +@@ -658,4 +660,14 @@ export const SIDEBAR_CSS = ` .bt-agent-sub[data-open] .bt-agent-sub-row{animation:bt-agent-row-in .24s ease both;animation-delay:calc(var(--i, 0)*30ms)} @keyframes bt-agent-row-in{from{opacity:0;filter:blur(4px)}to{opacity:1;filter:blur(0)}} @media (prefers-reduced-motion:reduce){.bt-agent-sub{transition:none}.bt-agent-sub .bt-agent-sub-row{animation:none}}