From 2ad178c1071c791ac66f40a7941ddca796d9b9b4 Mon Sep 17 00:00:00 2001 From: Birfy <15378933+Birfy@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:22:55 +0000 Subject: [PATCH 1/4] feat(social): a room link's shares reach exactly its seat, under that room (#883) --- changelog.d/883-stranger-room-share.added.md | 1 + docs/subsystems/social.md | 5 ++ plugins/social/src/face-rooms.js | 55 +++++++++++++++++ plugins/social/src/social.js | 62 +++++++++++++++++++- plugins/social/test/face-rooms.test.js | 40 +++++++++++++ plugins/social/test/social.test.js | 55 ++++++++++++++++- 6 files changed, 215 insertions(+), 3 deletions(-) create mode 100644 changelog.d/883-stranger-room-share.added.md diff --git a/changelog.d/883-stranger-room-share.added.md b/changelog.d/883-stranger-room-share.added.md new file mode 100644 index 000000000..c54f52d88 --- /dev/null +++ b/changelog.d/883-stranger-room-share.added.md @@ -0,0 +1 @@ +FlyBot now talks to Bot Social's record of a room link ([#883](https://github.com/ThinkFlowLab/FlyBot/issues/883)): making a link for a room that lives in your FlyBot registers it with Bot Social, a guest's Join claims it there, and the room's own messages travel between your Bot and the guest's Bots under that room only. Anything else to a Bot that is not your friend — a plain message, another room, another Bot — is still refused. diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index e710ca34a..e4daf2259 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -186,6 +186,11 @@ pins the list. The authority for the list is Bot Social's README and `docs/FACE_ - Bot Social has no FlyBot-group record (`list_conversations` is 1:1; inbox is `message.received` after a send). The plugin therefore carries a `FLYBOT-ROOM-SHARE` envelope on `send_as_member` text. `pollOnce` calls `flybot.rooms.applyInbound` before 1:1 chat delivery, so the Bot's owner sees the group as soon as they are added. `via: 'room'` / `via: 'room-share'` skip the social outbound hold (the add-to-group click is consent), and a send the host sees come back `pending_approval` clears Bot Social's own blanket hold by itself (`autoSendRoomEnvelope`, `holdIsBlanket()` on `hold_reason` of #865, the allow-list of #737): the invite and the room's lines leave with no card, while a hold with a finding of its own keeps its card. That card reads one sentence about the envelope (`Invite to the group ""`, `Group message to : `), never the JSON or a local `session-…` id (#847). The invited owner's Bot replies still go through `pending_approval`. A roster fetch that no longer lists a remote, or `social/revoke-grant`, leaves those rooms / hides inbound projections. +### A local room's link for a stranger (#883, #884) + +- Bot Social #60 keeps a local room's invite tickets (`/v1/local-room-invites`, feature `local_room_invites`). `face-rooms.js` `createFaceRoomApi().localInvites` has `mint` / `preview` (with this home's token the answer names the claimer to the minting or claiming owner only) / `claim` / `revoke`, on the same connector token as the face-room routes; a refusal keeps Bot Social's code (`expired`, `revoked`, `already_claimed`, `own_invite`, `invite_not_found`, …). `social.js` registers them as the host's seam (`flybot.rooms.serveInvites`); `verify` is the preview read, and `mint` answers `undefined` on a server without the feature, so the host keeps its friend-only link. +- The connector exception is one pair at a time. `ticketPair(msg)` passes a send only when it is a room share (`via` `room` / `room-share`, never a plain line, no files), from the exact local Bot, to the exact handle, under the room id in `meta.scope`, as `flybot.rooms.ticketPeers()` lists it (a seat here from the Bot its link named; on the guest side, the seated Bots to the room's inviter, and the claim's accepting knock). Such a send skips the roster and the hold (the link is the owner's consent, like adding a friend's Bot to a group) and goes out as `send_as_member` with `room_id` = that scope, which Bot Social's room reach carries and refuses once the link is withdrawn or given back. Every other send to someone off the roster keeps the deny sentence. Covered by `social.test.js` (#883) and `face-rooms.test.js` (#884). + ### Discarded promise `.catch` (#213) The plugin's discarded `.catch(() => {})` calls carry a one-line reason comment or `void`; `check-empty-catch` enforces it (see `docs/code-standards.md`). diff --git a/plugins/social/src/face-rooms.js b/plugins/social/src/face-rooms.js index c4d3aa635..1d4701e07 100644 --- a/plugins/social/src/face-rooms.js +++ b/plugins/social/src/face-rooms.js @@ -744,9 +744,64 @@ export function createFaceRoomApi({ server, token, fetch: fetchImpl = socialFetc return viewFaceRoom(body) } + // #884 / Bot Social #60: the ticket record of a room that lives in a FlyBot home. Same token as the + // face-room routes; refusals keep Bot Social's code on the error. + const localBase = `${origin}/v1/local-room-invites` + const localTicket = (value) => { + const clean = String(value ?? '').trim() + return /^[A-Za-z0-9_-]{16,64}$/.test(clean) ? clean : '' + } + const msOf = value => { + const at = typeof value === 'number' ? value : Date.parse(String(value ?? '')) + return Number.isFinite(at) ? at : undefined + } + const localView = body => ({ + roomId: String(body?.room_id ?? ''), + name: body?.name ? String(body.name) : '', + lead: body?.lead ? { handle: normHandle(body.lead.handle), name: String(body.lead.display_name ?? ''), ownerName: String(body.lead.owner_name ?? '') } : undefined, + inviterHandle: normHandle(body?.inviter?.handle), + inviterHome: normHandle(body?.inviter?.home) || undefined, + expiresAt: msOf(body?.expires_at), + claimed: body?.claimed === true, + ...(body?.claimer !== undefined ? { + claimer: body.claimer ? { + home: normHandle(body.claimer.home) || undefined, + bots: (Array.isArray(body.claimer.bots) ? body.claimer.bots : []).map(normHandle).filter(Boolean), + members: (Array.isArray(body.claimer.members) ? body.claimer.members : []).map(m => ({ handle: normHandle(m?.handle), display_name: String(m?.display_name ?? ''), owner_name: String(m?.owner_name ?? '') })), + } : null, + } : {}), + }) + const localInvites = { + async mint({ roomId, name, handle } = {}) { + const body = await callHttp('POST', localBase, { room_id: String(roomId ?? ''), ...(name ? { name: String(name) } : {}), ...(handle ? { bot_handle: normHandle(handle) } : {}) }) + const ticket = localTicket(body?.ticket) + if (!ticket) throw new FaceRoomError('Bot Social did not return an invite. Try again.', 'invite_failed') + return { ticket, url: String(body?.url ?? ''), expiresAt: msOf(body?.expires_at), handle: normHandle(body?.inviter?.handle) || undefined } + }, + // With this token the answer also names who claimed it, for the two owners the ticket binds. + async preview(ticket) { + const clean = localTicket(ticket) + if (!clean) throw new FaceRoomError('That is not an invite link.', 'invalid_invite') + return localView(await callHttp('GET', `${localBase}/${encodeURIComponent(clean)}`)) + }, + async claim(ticket, bots = []) { + const clean = localTicket(ticket) + if (!clean) throw new FaceRoomError('That is not an invite link.', 'invalid_invite') + const handles = (Array.isArray(bots) ? bots : [bots]).map(normHandle).filter(Boolean) + const body = await callHttp('POST', `${localBase}/${encodeURIComponent(clean)}/claim`, { bots: handles }) + return { ...localView(body), bots: (Array.isArray(body?.bots) ? body.bots : []).map(normHandle).filter(Boolean) } + }, + async revoke(ticket) { + const clean = localTicket(ticket) + if (!clean) return + await callHttp('DELETE', `${localBase}/${encodeURIComponent(clean)}`) + }, + } + return { // Bot Social's clock minus ours, as last seen (0 unless the skew is real). clockOffsetMs: () => clockOffsetMs, + localInvites, async join({ code, bots } = {}) { const digits = String(code ?? '').trim() if (!isJoinCode(digits)) throw mapFaceError({ error: { code: 'invalid_code' } }, 400) diff --git a/plugins/social/src/social.js b/plugins/social/src/social.js index ab0588454..5098621da 100644 --- a/plugins/social/src/social.js +++ b/plugins/social/src/social.js @@ -1432,6 +1432,10 @@ export function createSocial(service, config = {}, { client: given, api: givenAp priority: flybot.bot.send.priority?.APPROVAL ?? 900, async handle(msg) { if (msg.target?.remote !== true || (msg.to?.transport ?? 'bot-social') !== 'bot-social') return undefined + // #883: a room share to a seat a link opened (or, from a guest, to the room's inviter) is the + // room's own traffic: it passes, under that room's scope only. Anything else to someone off the + // roster — a plain line, another Bot, another room — keeps the refusal below. + if (ticketPair(msg)) return undefined const bot = await sendTarget(msg) if (bot === undefined) return { action: 'deny', reason: `${msg.target.handle} is not a Bot a friend shared with you, so your Bots cannot message it. Its owner can share it with you in that Bot's settings → Sharing.` } if (home && typeof msg.from === 'string' && !covered(await friendEntry(bot.home), msg.from)) { @@ -1535,12 +1539,38 @@ export function createSocial(service, config = {}, { client: given, api: givenAp } return new Error(`${short}. Try again.`) } + // #883: the one pair a room link opened for this send, if it is one: a room share (`via` room or + // room-share, never a plain line), from the exact local Bot, to the exact handle, under the room id + // the link names (`meta.scope`). The pairs are the host's (flybot.rooms.ticketPeers), not the text's. + function ticketPair(msg) { + if (msg.via !== 'room' && msg.via !== 'room-share') return undefined + const scope = typeof msg.meta?.scope === 'string' ? msg.meta.scope : '' + if (!scope || typeof msg.from !== 'string' || msg.attachments?.length) return undefined + const to = norm(msg.target?.handle) + return (flybot.rooms?.ticketPeers?.() ?? []).find(peer => peer.from === msg.from && peer.scope === scope && norm(peer.to) === to) + } + // Sent under the room's id: Bot Social's room reach (the claim's grant pair) carries it, and refuses + // it once the link is withdrawn or given back. + async function sendTicketShare(msg, pair) { + if (!home) throw new Error('Bot Social is not connected on this machine yet') + const member = activeMember(msg.from) + if (!member) throw new Error(`${flybot.bot.get(msg.from)?.name ?? 'This Bot'} has no Bot Social identity yet`) + const to = norm(msg.target.handle) + const sent = await client.callTool('send_as_member', { from_bot_id: member.bot_id, to, text: msg.text, room_id: pair.scope }) + .catch((error) => { if (isPaused(error)) notePaused(to, error); throw error }) + clearPaused(to) + if (sent?.message?.status === 'pending_approval') await autoSendRoomEnvelope(sent) + return { exchangeId: sent?.message?.message_id ?? msg.exchangeId } + } + disposers.push(flybot.transports.add('bot-social', { async send(msg) { try { return await sendOut(msg) } catch (error) { throw mapSendError(error, msg.target?.handle ?? msg.to?.handle) } finally { dropStaged(msg.attachments) } }, })) async function sendOut(msg) { + const pair = ticketPair(msg) + if (pair) return sendTicketShare(msg, pair) const bot = await sendTarget(msg) if (bot === undefined) throw new Error(`${msg.target.handle} is no longer reachable`) if (home) { @@ -1684,7 +1714,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp // The probe is shared: while one is in flight every caller awaits its result (#234) — an early // "not attesting" from a probe that has not finished would render forgeable envelopes as person // lines and mark the events seen, with no self-heal once the probe lands. - const typed = { supported: false, forbidden: false, mentions: false, presence: false, roles: false, invites: false, probedAt: 0, probeFailed: false, probing: undefined } + const typed = { supported: false, forbidden: false, mentions: false, presence: false, roles: false, invites: false, localInvites: false, probedAt: 0, probeFailed: false, probing: undefined } const TYPED_RECHECK_MS = 10 * 60_000 // A probe that got no answer (features() → undefined: an unreachable or pre-platform server) is // not a "no": it is retried soon, not held for the recheck window. @@ -1706,12 +1736,21 @@ export function createSocial(service, config = {}, { client: given, api: givenAp if (features?.includes('presence')) typed.presence = true if (features?.includes('member_roles')) typed.roles = true if (features?.includes(FACE_INVITE_FEATURE)) typed.invites = true + if (features?.includes('local_room_invites')) typed.localInvites = true return typed.supported } finally { typed.probing = undefined } } const canAttest = async () => !typed.forbidden && await serverAttests() + // Whether this server keeps a local room's invite tickets (Bot Social #60): the same probe. An older + // server, or one that has not answered, gets the friend-only link it had. + async function serverLocalInvites() { + if (typed.localInvites || !faceApi?.features) return typed.localInvites + if (typed.probing) await typed.probing + else if (!typed.probedAt || now() - typed.probedAt >= (typed.probeFailed ? TYPED_RETRY_MS : TYPED_RECHECK_MS)) await (typed.probing = probeTypedSupport()) + return typed.localInvites + } // Whether this server carries the explicit mention list (Bot Social `mentions`, #293): learned from the // same feature probe, shared with it. An older server, or one that has not answered, gets no field, // and the other home reads the @ labels from the text as before. @@ -3574,6 +3613,27 @@ export function createSocial(service, config = {}, { client: given, api: givenAp }) return viewHostedFace(room, { status: 'open' }) } + // #884: Bot Social's record of a local room's invite tickets, behind the host's link list and its + // seats. A server without `local_room_invites` mints nothing here, so the host keeps its friend-only + // link; verify is the same read with this home's token, which names the claimer to the minter only. + if (faceApi?.localInvites && typeof flybot.rooms?.serveInvites === 'function') { + disposers.push(flybot.rooms.serveInvites({ + // A link's own Bots get a full identity (polled, so the room's lines arrive), like a Bot invited + // to a friend: the owner's click on the link is that consent. A room-only one is promoted. + identify: async (ids) => { + for (const id of ids) { + const bot = flybot.bot.get(id) + if (bot) await register(bot, { forInvite: true }) + } + }, + mint: async spec => ((await serverLocalInvites()) ? faceApi.localInvites.mint(spec) : undefined), + preview: ticket => faceApi.localInvites.preview(ticket), + claim: (ticket, handles) => faceApi.localInvites.claim(ticket, handles), + verify: ticket => faceApi.localInvites.preview(ticket), + revoke: ticket => faceApi.localInvites.revoke(ticket), + })) + } + if (flybot.rooms?.onFaceLine) { disposers.push(flybot.rooms.onFaceLine(async (room, line, { mentions = [] } = {}) => { const serverId = room?.face?.roomId diff --git a/plugins/social/test/face-rooms.test.js b/plugins/social/test/face-rooms.test.js index c2b49b260..d2170d244 100644 --- a/plugins/social/test/face-rooms.test.js +++ b/plugins/social/test/face-rooms.test.js @@ -705,3 +705,43 @@ test('#773: joining with a ticket is join-invite, and each refusal keeps its own assert.equal(calls.length, before) }) + +// #884 / Bot Social #60: a local room's ticket record, on the same token as the face-room routes. +test('#884: the local room invite calls go to /v1/local-room-invites and keep Bot Social\'s refusal code', async () => { + const calls = [] + const answers = { + 'POST /v1/local-room-invites': [201, { kind: 'local_room', ticket: 'AbCdEfGhIjKlMnOpQrStUv', url: 'https://s/r/@chief.a.home/AbCdEfGhIjKlMnOpQrStUv', room_id: 'war', expires_at: '2026-10-23T16:00:00.000Z', inviter: { handle: '@chief.a.home', home: '@a.home' } }], + 'GET /v1/local-room-invites/AbCdEfGhIjKlMnOpQrStUv': [200, { kind: 'local_room', room_id: 'war', name: 'Launch', lead: { handle: '@chief.a.home', display_name: 'Chief', owner_name: 'Ann' }, inviter: { handle: '@chief.a.home', home: '@a.home' }, expires_at: '2026-10-23T16:00:00.000Z', claimed: true, claimer: { home: '@b.home', bots: ['@chief.b.home'], members: [{ handle: '@chief.b.home', display_name: 'Chief', owner_name: 'Ben' }] } }], + 'POST /v1/local-room-invites/AbCdEfGhIjKlMnOpQrStUv/claim': [200, { kind: 'local_room', room_id: 'war', inviter: { handle: '@chief.a.home', home: '@a.home' }, expires_at: '2026-10-23T16:00:00.000Z', claimed: true, bots: ['@chief.b.home'] }], + 'DELETE /v1/local-room-invites/AbCdEfGhIjKlMnOpQrStUv': [204, null], + 'POST /v1/local-room-invites/ZZZZZZZZZZZZZZZZZZZZZZ/claim': [409, { error: { code: 'already_claimed', message: 'this invite was already used by someone else' } }], + } + const api = createFaceRoomApi({ + server: 'https://s', + token: 'otk_test', + fetch: async (url, init) => { + const key = `${init.method} ${new URL(url).pathname}` + calls.push({ key, body: init.body ? JSON.parse(init.body) : undefined, auth: init.headers.authorization }) + const [status, body] = answers[key] ?? [404, { error: { code: 'not_found' } }] + return new Response(body === null ? null : JSON.stringify(body), { status }) + }, + }) + const minted = await api.localInvites.mint({ roomId: 'war', name: 'Launch', handle: 'chief.a.home' }) + assert.deepEqual(minted, { ticket: 'AbCdEfGhIjKlMnOpQrStUv', url: 'https://s/r/@chief.a.home/AbCdEfGhIjKlMnOpQrStUv', expiresAt: Date.parse('2026-10-23T16:00:00.000Z'), handle: '@chief.a.home' }) + assert.deepEqual(calls[0].body, { room_id: 'war', name: 'Launch', bot_handle: '@chief.a.home' }) + assert.equal(calls[0].auth, 'Bearer otk_test') + const seen = await api.localInvites.preview('AbCdEfGhIjKlMnOpQrStUv') + assert.equal(seen.roomId, 'war') + assert.deepEqual(seen.lead, { handle: '@chief.a.home', name: 'Chief', ownerName: 'Ann' }) + assert.deepEqual(seen.claimer.bots, ['@chief.b.home']) + assert.equal(seen.claimer.home, '@b.home') + const claimed = await api.localInvites.claim('AbCdEfGhIjKlMnOpQrStUv', ['chief.b.home']) + assert.deepEqual(calls.at(-1).body, { bots: ['@chief.b.home'] }) + assert.equal(claimed.roomId, 'war') + assert.equal(claimed.inviterHandle, '@chief.a.home') + assert.deepEqual(claimed.bots, ['@chief.b.home']) + await api.localInvites.revoke('AbCdEfGhIjKlMnOpQrStUv') + assert.equal(calls.at(-1).key, 'DELETE /v1/local-room-invites/AbCdEfGhIjKlMnOpQrStUv') + await assert.rejects(() => api.localInvites.claim('ZZZZZZZZZZZZZZZZZZZZZZ', ['@chief.b.home']), error => error.code === 'already_claimed') + await assert.rejects(() => api.localInvites.preview('short'), error => error.code === 'invalid_invite') +}) diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index 4d1e54aee..60a0f3fce 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -133,7 +133,7 @@ async function grantTeam(flybot, grantee = '@bob.home') { } } -async function team() { +async function team({ rooms = {} } = {}) { const home = mkdtempSync(join(tmpdir(), 'flybot-social-')) writeFileSync(join(home, 'state.json'), JSON.stringify({ version: 1, mainBotIds: ['a'], @@ -141,7 +141,7 @@ async function team() { a: { id: 'a', name: 'Chief', role: 'Boss', sessionId: 'a', createdAt: 1 }, b: { id: 'b', name: 'Writer', role: 'Write', sessionId: 'b', createdAt: 2 }, }, - rooms: {}, agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, revision: 0, + rooms, agents: {}, exchanges: [], exchangeTurns: {}, questions: {}, prefs: {}, revision: 0, })) const ctx = fakeContext() const received = new Map() @@ -10051,3 +10051,54 @@ test('#773: joining by ticket on a server without face_room_invites never reache assert.deepEqual(inviteCalls(server), []) assert.equal(server.calls.filter(c => /join-invite/.test(String(c.url))).length, 0) }) + +// #883: a room that lives here seated a stranger's Bot through a link (#884). Its room shares go to +// that Bot from the Bot the link named, under the room's id, and Bot Social's room reach carries them. +// Nothing else reaches a Bot that is not on the roster: not a plain line, not another room, not another +// of this home's Bots, not another Bot of the stranger's home. +const SEATED = { + war: { + id: 'war', name: 'Launch plan', members: ['a', 'b'], admin: 'a', named: true, log: [], seq: 0, seen: {}, + participants: [ + { kind: 'bot', id: 'a', role: 'admin', displayName: 'Chief', joinedSeq: 0, joinedAt: 1 }, + { kind: 'bot', id: 'b', role: 'member', displayName: 'Writer', joinedSeq: 0, joinedAt: 1 }, + { kind: 'bot', remote: true, id: '@guest.eve.home', handle: '@guest.eve.home', ownerHome: '@eve.home', ownerName: 'Eve', displayName: 'Guest', role: 'member', admittedBy: 'ticket', ticket: 'TktSeatAbCdEfGhIjKlMnO', hostBotId: 'a', joinedSeq: 1, joinedAt: 1 }, + ], + }, +} + +test('#883: a room share reaches a seat a link opened, under that room only, and nothing else gets through', async (t) => { + const { ctx, home, stop } = await team({ rooms: SEATED }) + t.after(stop) + const server = mockServer() + const social = createSocial(ctx.flybot, { autoAccept: false, inboxPollMs: 0, settingsPath: join(home, 'flybot-social-settings.json') }, { + client: createMcpClient({ server: 'https://s', token: 'owner-token', fetch: server.fetch }), + home: { id: 'h1', handle: '@danyang.home' }, + }) + t.after(() => social.dispose()) + await grantTeam(ctx.flybot) + await social.registerAll() + assert.deepEqual(ctx.flybot.rooms.ticketPeers(), [{ from: 'a', to: '@guest.eve.home', scope: 'war', ticket: 'TktSeatAbCdEfGhIjKlMnO' }]) + const share = 'FLYBOT-ROOM-SHARE\n{"v":1,"op":"line","roomId":"war","text":"hi"}' + const sends = () => server.calls.filter(c => c.name === 'send_as_member') + + const ok = await ctx.flybot.bot.send({ from: 'a', to: '@guest.eve.home', text: share, via: 'room', meta: { roomId: 'war', scope: 'war' } }) + assert.equal(ok.action, 'allow', JSON.stringify(ok)) + assert.equal(sends().length, 1) + assert.equal(sends()[0].args.to, '@guest.eve.home') + assert.equal(sends()[0].args.room_id, 'war', 'it goes under the room id, which is what Bot Social reaches by') + assert.equal(sends()[0].args.from_bot_id, ctx.flybot.scope('social').fields.get('a', 'member').bot_id) + + const refused = [ + ['a plain line', { from: 'a', to: '@guest.eve.home', text: 'hello there' }], + ['another room', { from: 'a', to: '@guest.eve.home', text: share, via: 'room', meta: { roomId: 'war', scope: 'other' } }], + ['no scope', { from: 'a', to: '@guest.eve.home', text: share, via: 'room', meta: { roomId: 'war' } }], + ['another Bot of mine', { from: 'b', to: '@guest.eve.home', text: share, via: 'room', meta: { roomId: 'war', scope: 'war' } }], + ['another Bot of theirs', { from: 'a', to: '@other.eve.home', text: share, via: 'room', meta: { roomId: 'war', scope: 'war' } }], + ] + for (const [why, msg] of refused) { + const out = await ctx.flybot.bot.send(msg) + assert.equal(out.action, 'deny', `${why}: ${JSON.stringify(out)}`) + } + assert.equal(sends().length, 1, 'none of those reached Bot Social') +}) From ca8f82268d4c6eb05216762fd604e23f40276b0e Mon Sep 17 00:00:00 2001 From: Birfy <15378933+Birfy@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:31:56 +0000 Subject: [PATCH 2/4] fix(social): a 204 from Bot Social is an answer, not a crash; a guest reads the owner, not "You" (#883) --- changelog.d/883-link-live-fixes.fixed.md | 1 + core/src/host/room-membership.js | 4 +++- core/test/room-ticket-seat.test.js | 4 +++- patches/ds-bot/files/src/host/room-membership.js | 4 +++- patches/ds-bot/files/test/room-ticket-seat.test.js | 4 +++- plugins/social/src/mcp-client.js | 11 ++++++++++- plugins/social/test/connector-scope.test.js | 14 ++++++++++++++ 7 files changed, 37 insertions(+), 5 deletions(-) create mode 100644 changelog.d/883-link-live-fixes.fixed.md diff --git a/changelog.d/883-link-live-fixes.fixed.md b/changelog.d/883-link-live-fixes.fixed.md new file mode 100644 index 000000000..0562cc680 --- /dev/null +++ b/changelog.d/883-link-live-fixes.fixed.md @@ -0,0 +1 @@ +A link withdrawn on Bot Social (a `204` answer) no longer ends the FlyBot process, and a guest reads the inviting owner’s name rather than the host’s own “You”. diff --git a/core/src/host/room-membership.js b/core/src/host/room-membership.js index 5f7348eed..f250315e1 100644 --- a/core/src/host/room-membership.js +++ b/core/src/host/room-membership.js @@ -903,8 +903,10 @@ export function install(rt) { const from = shareSpeaker(room) const member = from ? state.bots?.[from]?.fields?.social?.member : undefined const ownerId = from ? state.bots?.[from]?.ownerId : undefined + // "You" is this home's own label for its owner, so the far side would read itself in it. + const humanName = human?.displayName && human.displayName !== 'You' ? human.displayName : undefined return { - inviterName: human?.displayName || member?.ownerName || displayNameFor(state, ownerId, 'a friend'), + inviterName: humanName || member?.ownerName || displayNameFor(state, ownerId, 'a friend'), inviterHome: member?.home || human?.ownerHome, inviterHandle: member?.handle, } diff --git a/core/test/room-ticket-seat.test.js b/core/test/room-ticket-seat.test.js index 6660dbc78..2aa5d47c5 100644 --- a/core/test/room-ticket-seat.test.js +++ b/core/test/room-ticket-seat.test.js @@ -33,7 +33,8 @@ const ALICE = { seq: 0, seen: {}, participants: [ - { kind: 'human', id: 'owner-1', role: 'admin', displayName: 'Alice', joinedSeq: 0, joinedAt: 1 }, + // A group made on a home whose owner has no name yet: the human is the local "You" (live pre-check). + { kind: 'human', id: 'owner-1', role: 'admin', displayName: 'You', joinedSeq: 0, joinedAt: 1 }, { kind: 'bot', id: 'chief-a', role: 'admin', displayName: 'Chief', joinedSeq: 0, joinedAt: 1 }, { kind: 'bot', id: 'writer-a', role: 'member', displayName: 'Writer', joinedSeq: 0, joinedAt: 1 }, ], @@ -236,6 +237,7 @@ test('#884: a stranger claims a room link with two of its Bots, both are seated, assert.deepEqual(mirror.participants.filter(p => p.leftSeq === undefined).map(p => p.id).sort(), ['chief-b', 'helper-b']) assert.equal(mirror.sharedFrom.remoteRoomId, 'war') assert.equal(mirror.sharedFrom.ticket, invite.ticket) + assert.equal(mirror.sharedFrom.inviterName, 'Alice', 'the guest reads the owner, never the host’s own "You"') const joins = log.filter(m => parseRoomShare(m.text)?.op === 'join') assert.ok(joins.every(m => m.from === 'chief-a' && m.meta.scope === 'war' && m.delivered), 'joins go from the linked Bot, under the room scope') diff --git a/patches/ds-bot/files/src/host/room-membership.js b/patches/ds-bot/files/src/host/room-membership.js index 5f7348eed..f250315e1 100644 --- a/patches/ds-bot/files/src/host/room-membership.js +++ b/patches/ds-bot/files/src/host/room-membership.js @@ -903,8 +903,10 @@ export function install(rt) { const from = shareSpeaker(room) const member = from ? state.bots?.[from]?.fields?.social?.member : undefined const ownerId = from ? state.bots?.[from]?.ownerId : undefined + // "You" is this home's own label for its owner, so the far side would read itself in it. + const humanName = human?.displayName && human.displayName !== 'You' ? human.displayName : undefined return { - inviterName: human?.displayName || member?.ownerName || displayNameFor(state, ownerId, 'a friend'), + inviterName: humanName || member?.ownerName || displayNameFor(state, ownerId, 'a friend'), inviterHome: member?.home || human?.ownerHome, inviterHandle: member?.handle, } diff --git a/patches/ds-bot/files/test/room-ticket-seat.test.js b/patches/ds-bot/files/test/room-ticket-seat.test.js index 6660dbc78..2aa5d47c5 100644 --- a/patches/ds-bot/files/test/room-ticket-seat.test.js +++ b/patches/ds-bot/files/test/room-ticket-seat.test.js @@ -33,7 +33,8 @@ const ALICE = { seq: 0, seen: {}, participants: [ - { kind: 'human', id: 'owner-1', role: 'admin', displayName: 'Alice', joinedSeq: 0, joinedAt: 1 }, + // A group made on a home whose owner has no name yet: the human is the local "You" (live pre-check). + { kind: 'human', id: 'owner-1', role: 'admin', displayName: 'You', joinedSeq: 0, joinedAt: 1 }, { kind: 'bot', id: 'chief-a', role: 'admin', displayName: 'Chief', joinedSeq: 0, joinedAt: 1 }, { kind: 'bot', id: 'writer-a', role: 'member', displayName: 'Writer', joinedSeq: 0, joinedAt: 1 }, ], @@ -236,6 +237,7 @@ test('#884: a stranger claims a room link with two of its Bots, both are seated, assert.deepEqual(mirror.participants.filter(p => p.leftSeq === undefined).map(p => p.id).sort(), ['chief-b', 'helper-b']) assert.equal(mirror.sharedFrom.remoteRoomId, 'war') assert.equal(mirror.sharedFrom.ticket, invite.ticket) + assert.equal(mirror.sharedFrom.inviterName, 'Alice', 'the guest reads the owner, never the host’s own "You"') const joins = log.filter(m => parseRoomShare(m.text)?.op === 'join') assert.ok(joins.every(m => m.from === 'chief-a' && m.meta.scope === 'war' && m.delivered), 'joins go from the linked Bot, under the room scope') diff --git a/plugins/social/src/mcp-client.js b/plugins/social/src/mcp-client.js index 82bd85fc1..36021d0bd 100644 --- a/plugins/social/src/mcp-client.js +++ b/plugins/social/src/mcp-client.js @@ -36,6 +36,8 @@ export function fetchOptions(init = {}) { return { ...init, agent: init.agent ?? socialHttpAgent() } } +const NULL_BODY = new Set([101, 204, 205, 304]) + export function socialFetch(url, init = {}) { const parsed = new URL(String(url)) const lib = parsed.protocol === 'http:' ? http : https @@ -54,7 +56,14 @@ export function socialFetch(url, init = {}) { if (value === undefined) continue headerMap.set(key, Array.isArray(value) ? value.join(', ') : String(value)) } - resolve(new Response(buf, { status: res.statusCode ?? 0, statusText: res.statusMessage ?? '', headers: headerMap })) + // A 204/205/304 must have no body, and a Response that throws here would be uncaught and end the + // process (a link withdrawn with DELETE answers 204): no body for those, and a bad status rejects. + const status = res.statusCode ?? 0 + try { + resolve(new Response(NULL_BODY.has(status) ? null : buf, { status, statusText: res.statusMessage ?? '', headers: headerMap })) + } catch (error) { + reject(error) + } }) }) req.on('error', reject) diff --git a/plugins/social/test/connector-scope.test.js b/plugins/social/test/connector-scope.test.js index 2a88a1104..8ad548a07 100644 --- a/plugins/social/test/connector-scope.test.js +++ b/plugins/social/test/connector-scope.test.js @@ -72,3 +72,17 @@ test('face-room REST calls carry the connector token on the documented routes', assert.deepEqual(await api.list(), []) assert.deepEqual(calls, [{ url: 'https://social.test/v1/face-rooms', authorization: 'Bearer otk_test' }]) }) + +test('a 204 answer (a withdrawn link) is a Response, not a crash of the process', async (t) => { + const { createServer } = await import('node:http') + const { socialFetch } = await import('../src/mcp-client.js') + const server = createServer((req, res) => { res.writeHead(req.method === 'DELETE' ? 204 : 200, { 'content-type': 'application/json' }).end(req.method === 'DELETE' ? undefined : '{"ok":true}') }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + t.after(() => server.close()) + const base = `http://127.0.0.1:${server.address().port}` + const gone = await socialFetch(`${base}/v1/local-room-invites/tk`, { method: 'DELETE' }) + assert.equal(gone.status, 204) + assert.equal(await gone.text(), '') + const read = await socialFetch(`${base}/v1/local-room-invites/tk`) + assert.deepEqual(await read.json(), { ok: true }) +}) From 84231df4a22f1c8d7dae5b4938bf047646d42090 Mon Sep 17 00:00:00 2001 From: Birfy Date: Fri, 23 Oct 2026 23:48:04 +0800 Subject: [PATCH 3/4] docs(core-host): a guest reads the owner, not the host's own "You" (#883) The arch-docs gate failed the PR: room-membership.js changed without its subsystem page. --- docs/subsystems/core-host.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 27c779223..3fbac0620 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -145,7 +145,7 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi ### Inbound shared rooms -- Bot Social conversations are 1:1; it does not list a FlyBot group. When a room gains a granted remote Bot (create-room, addParticipants, or 新建群聊 picking a friend's chat), the host fans out a `FLYBOT-ROOM-SHARE` envelope (`src/shared/room-share.js`) through `flybotSend` (`via: 'room-share'` for join/leave, `via: 'room'` for lines, `meta.roomId` set) so the Bot's owner's FlyBot can project it immediately — including a room with no chat lines yet. Receive is `rt.applyInboundRoomShare` (`origin: 'inbound-share'`, `recordInboundSharedRoom`), labelled **Shared room from \** with the room name stored. The envelope names the person who invited the Bot (#848): a human participant in the room when there is one, else the owner-level `ownerName` / `home` the social plugin keeps on that Bot's member record (read from `whoami`), so a group made from the sidebar no longer says `a friend`; a room projected in from an inbound share forwards its own `sharedFrom` inviter instead of this home's owner, so re-sharing never renames the person who invited it. A leave envelope, hide, or grant revoke marks it left/hidden. Which identity a projection records is the envelope's, not the transport's (#873): `applyInboundRoomShare` prefers `data.inviterName` / `inviterHome` / `inviterHandle` and keeps `event.from*` only as the fallback, so a room carried here by a Chief's Bot is still named after the person who invited it. The lookup matches the values a room already stores as well, so a projection recorded under the transport's identity before this fix resolves to the same room instead of forking a second `inbound-share` room; a line keeps its own `who`, or the sender when it names nobody, never the inviter. `reach()` allows a local member to message a remote that already sits in that room when `roomId` is on the send. Still not team mode. +- Bot Social conversations are 1:1; it does not list a FlyBot group. When a room gains a granted remote Bot (create-room, addParticipants, or 新建群聊 picking a friend's chat), the host fans out a `FLYBOT-ROOM-SHARE` envelope (`src/shared/room-share.js`) through `flybotSend` (`via: 'room-share'` for join/leave, `via: 'room'` for lines, `meta.roomId` set) so the Bot's owner's FlyBot can project it immediately — including a room with no chat lines yet. Receive is `rt.applyInboundRoomShare` (`origin: 'inbound-share'`, `recordInboundSharedRoom`), labelled **Shared room from \** with the room name stored. The envelope names the person who invited the Bot (#848): a human participant in the room when there is one, else the owner-level `ownerName` / `home` the social plugin keeps on that Bot's member record (read from `whoami`), so a group made from the sidebar no longer says `a friend`; a human participant whose name is this home's own label `You` (an owner with no name yet) is skipped for that owner-level name, so the far side never reads itself as the inviter (#883); a room projected in from an inbound share forwards its own `sharedFrom` inviter instead of this home's owner, so re-sharing never renames the person who invited it. A leave envelope, hide, or grant revoke marks it left/hidden. Which identity a projection records is the envelope's, not the transport's (#873): `applyInboundRoomShare` prefers `data.inviterName` / `inviterHome` / `inviterHandle` and keeps `event.from*` only as the fallback, so a room carried here by a Chief's Bot is still named after the person who invited it. The lookup matches the values a room already stores as well, so a projection recorded under the transport's identity before this fix resolves to the same room instead of forking a second `inbound-share` room; a line keeps its own `who`, or the sender when it names nobody, never the inviter. `reach()` allows a local member to message a remote that already sits in that room when `roomId` is on the send. Still not team mode. - A Bot named in a shared room answers in that room (#825). `fanoutRoomLine` (`src/host/room-membership.js`) puts the handles a line calls in the envelope's `calls` (resolved where the room's mentions are resolved, so a fenced or quoted `@name` is literal text), plus its `hop`: a person's line is hop 0 and a Bot's answer is one hop on. The receiving `applyInboundRoomShare` keeps only the calls that name this room's own Bot (`localCalls`), opens **one** turn for each through `rt.startInboundTurn` (`groups.js`: `participant.open` + `participant.runTurn` + `appendRoom`), and skips it when the line's hop has reached the room's `maxHops` (kept on the room, `DEFAULT_MAX_HOPS` 3) or when the same remote line was already answered — the last 200 keys `remoteRoomId:seq:botId` live in `room.answered`. That turn's answer is an ordinary room line, so `fanoutRoomLine` sends it back to the inviter as an `op: 'line'` envelope under the *inviter's* `roomId`, one hop on; an answer its owner's approval holds or refuses still shows as one `notice` line in the room instead of silence. Remote text stays inside the `fencedUntrusted` fence. ### A local room's invite link (#852) From 7d21997304981f7b10a32622f693b5a0eca11ead Mon Sep 17 00:00:00 2001 From: Birfy Date: Fri, 23 Oct 2026 23:49:52 +0800 Subject: [PATCH 4/4] test(social): a plain line under the room's scope still cannot reach a seated stranger (#883) The via check in ticketPair had no test: removing it left the #883 test green. --- plugins/social/test/social.test.js | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index 60a0f3fce..4073eb297 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -10091,6 +10091,7 @@ test('#883: a room share reaches a seat a link opened, under that room only, and const refused = [ ['a plain line', { from: 'a', to: '@guest.eve.home', text: 'hello there' }], + ['a plain line under the room scope', { from: 'a', to: '@guest.eve.home', text: 'hello there', via: 'message_bot', meta: { roomId: 'war', scope: 'war' } }], ['another room', { from: 'a', to: '@guest.eve.home', text: share, via: 'room', meta: { roomId: 'war', scope: 'other' } }], ['no scope', { from: 'a', to: '@guest.eve.home', text: share, via: 'room', meta: { roomId: 'war' } }], ['another Bot of mine', { from: 'b', to: '@guest.eve.home', text: share, via: 'room', meta: { roomId: 'war', scope: 'war' } }],