From da06e97586b15ad6e4ffa36e0a3c900254157049 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 09:51:15 +0000 Subject: [PATCH] Let co-members message a shared room without a friendship A group @ inserts the seated Bot's name. Every Bot still in a live ordinary group can reach that seat, and a send to the seat's home, without being the friendship via. A deleted friend home is dropped from the room and its chat is hidden. Fixes #1141 Co-authored-by: Danyang Chen --- changelog.d/1141.fixed.md | 1 + core/client.js | 21 +- core/src/client/at-menu.js | 10 +- core/src/host/reach.js | 29 +- core/src/host/room-membership.js | 3 +- core/src/shared/remote-label.js | 20 + core/test/at-menu.test.js | 32 +- core/test/reach-policy.test.js | 24 +- docs/subsystems/core-client.md | 1 + docs/subsystems/core-host.md | 2 + docs/subsystems/social.md | 1 + patches/ds-bot/0001-core-fixes.patch | 124 +- patches/ds-bot/0003-flybot-api-client.patch | 58 +- patches/ds-bot/0008-client-bundle.patch | 8544 ++++++++++++----- patches/ds-bot/0012-room-membership.patch | 34 +- patches/ds-bot/files/src/host/reach.js | 29 +- .../ds-bot/files/src/host/room-membership.js | 3 +- .../ds-bot/files/src/shared/remote-label.js | 20 + .../ds-bot/files/test/reach-policy.test.js | 24 +- plugins/social/src/social.js | 102 +- plugins/social/test/social.test.js | 90 + 21 files changed, 6483 insertions(+), 2689 deletions(-) create mode 100644 changelog.d/1141.fixed.md diff --git a/changelog.d/1141.fixed.md b/changelog.d/1141.fixed.md new file mode 100644 index 000000000..af65f229e --- /dev/null +++ b/changelog.d/1141.fixed.md @@ -0,0 +1 @@ +A group @ inserts the seated Bot's name, and Bots that share a live group can message each other without a separate friendship. A deleted friend home is dropped from the room and its chat is hidden ([#1141](https://github.com/ThinkFlowLab/FlyBot/issues/1141)). diff --git a/core/client.js b/core/client.js index 7a349ddd5..12bfefae5 100644 --- a/core/client.js +++ b/core/client.js @@ -1576,6 +1576,21 @@ function remoteBotTitle(record) { const name = remoteBotName(record, isChiefRecord(record) ? "Chief" : cleanPeerName(record?.handle, "Bot")); return `${name} (${owner})`; } +function remoteCallName(record) { + const handle = String(record?.handle ?? "").replace(/^@+/, "").trim(); + const bare2 = remoteBotName(record, "").replace(/^@+/, "").trim(); + const unusable = (value) => { + const text = String(value ?? "").replace(/^@+/, "").trim(); + if (!text || /^friend$/i.test(text)) return true; + if (text.includes("@") || /\.home\b/i.test(text)) return true; + if (handle && text.toLowerCase() === handle.toLowerCase()) return true; + return false; + }; + if (!unusable(bare2)) return bare2; + const slug = handle.split(".")[0]; + if (slug && !unusable(slug)) return slug; + return "Bot"; +} function remoteOwnerBadge(record) { return ownerNameOf(record) || cleanPeerName(record?.handle, "Friend"); } @@ -9759,11 +9774,13 @@ function mentionRows(roster, sessionId, query = "") { // A remote seat already listed under its handle (a duplicate participant record) is offered once. ...uniqueBy(live.filter((p) => p.remote), (p) => p.handle || p.id).map((p) => ({ id: p.handle || p.id, - name: remoteBotTitle(p), + // A face room inserts the disambiguated title. An ordinary group inserts the name the + // room routes on (#1141): never "@@handle.home (owner)" and never "(Friend)". + name: face ? remoteBotTitle(p) : remoteCallName(p), kind: "bot", remote: p, handle: p.handle, - ...face ? { label: faceLabel(ownerNameOf(p), cleanPeerName(p.displayName || p.name, "Bot")) } : {} + label: face ? faceLabel(ownerNameOf(p), cleanPeerName(p.displayName || p.name, "Bot")) : remoteBotTitle(p) })) ] : listedChatBots(roster).filter((bot) => bot.id !== here.self.id); const needle = query.trim().toLowerCase(); diff --git a/core/src/client/at-menu.js b/core/src/client/at-menu.js index 5f483535f..fb26d6e14 100644 --- a/core/src/client/at-menu.js +++ b/core/src/client/at-menu.js @@ -5,7 +5,7 @@ // rows stay beside them, because a source cannot be hidden for one Session (a face // room filters the session rows out of view: face-at-filter.js). import { mentionCandidates, remoteMentionRows } from './flybot-registry.js' -import { cleanPeerName, friendBotTitle, ownerNameOf, remoteBotTitle, listedChatBots, seatOwnerName } from '../shared/remote-label.js' +import { cleanPeerName, friendBotTitle, ownerNameOf, remoteBotTitle, remoteCallName, listedChatBots, seatOwnerName } from '../shared/remote-label.js' import { t } from './i18n.js' import { currentPart, relativeShort } from './text.js' @@ -39,8 +39,12 @@ export function mentionRows(roster, sessionId, query = '') { // A remote seat already listed under its handle (a duplicate participant record) is offered once. ...uniqueBy(live.filter(p => p.remote), p => p.handle || p.id) .map(p => ({ - id: p.handle || p.id, name: remoteBotTitle(p), kind: 'bot', remote: p, handle: p.handle, - ...(face ? { label: faceLabel(ownerNameOf(p), cleanPeerName(p.displayName || p.name, 'Bot')) } : {}), + id: p.handle || p.id, + // A face room inserts the disambiguated title. An ordinary group inserts the name the + // room routes on (#1141): never "@@handle.home (owner)" and never "(Friend)". + name: face ? remoteBotTitle(p) : remoteCallName(p), + kind: 'bot', remote: p, handle: p.handle, + label: face ? faceLabel(ownerNameOf(p), cleanPeerName(p.displayName || p.name, 'Bot')) : remoteBotTitle(p), })), ] : listedChatBots(roster).filter(bot => bot.id !== here.self.id) diff --git a/core/src/host/reach.js b/core/src/host/reach.js index 753f991ff..22bdfd36e 100644 --- a/core/src/host/reach.js +++ b/core/src/host/reach.js @@ -225,11 +225,21 @@ export function sharedRoom(state, botId, peer) { return room === undefined ? undefined : String(room.id) } -// #1126: a mention of a Bot that already sits in a live local or inbound group with the sender. -// The seat is the consent, so the mention does not also need a Sharing grant. A seat a group -// invite admitted (`admittedBy: 'ticket'`) counts while it is still in the room: that is how a -// local-group link seats the other home. Face-to-face stays on sharedRoom (it names a server -// room). A plain line and message_bot do not come here — the caller passes `via`. +// A live seat this sender still shares an ordinary group with (#1126, #1141). The exact seated +// Bot matches, and so does a send addressed to that seat's home (`@flybot….home`) — not some other +// Bot of the same home. The seat is the consent for every send, not only a user mention. +function seatReaches(seat, peer) { + const key = handleKey(peer) + if (!key) return false + if (handleKey(seat.handle || seat.id) === key) return true + const home = handleKey(seat.ownerHome || seat.home || homeOfHandle(seat.handle || seat.id)) + return home !== '' && home === key && homeOfHandle(key) === key +} + +// #1126, #1141: a Bot that already sits in a live local or inbound group with the sender. +// The seat is the consent, so the send does not also need a Sharing grant. A seat a group +// invite admitted (`admittedBy: 'ticket'`) counts while it is still in the room. Face-to-face +// stays on sharedRoom (it names a server room). export function mentionRoom(state, botId, peer) { if (typeof botId !== 'string' || state.bots?.[botId] === undefined) return undefined const key = handleKey(peer) @@ -238,7 +248,7 @@ export function mentionRoom(state, botId, peer) { && candidate.origin !== 'face-to-face' && !candidate.hidden && (candidate.members ?? []).includes(botId) - && remotesInRoom(candidate).some(seat => handleKey(seat.handle || seat.id) === key)) + && remotesInRoom(candidate).some(seat => seatReaches(seat, peer))) return room === undefined ? undefined : String(room.id) } @@ -365,12 +375,15 @@ export function install(rt) { const named = msg.meta?.roomId ?? msg.roomId ?? msg.fromRoomId ?? msg.deliverArgs?.roomId ?? msg.deliverArgs?.fromRoomId // #1126: a user mention of a live local-group seat is judged under that room even when the send // names none. Any other via keeps asking for a grant or an explicit roomId. - const mentioned = named === undefined && msg.via === 'user-mention' && isRemoteRef(to) && !isRemoteRef(from) + // #1126, #1141: a live ordinary-group seat is the consent for this send even when the caller + // names no room. message_bot stays a 1:1: it still needs a grant (#1126). A face-to-face + // room stays on sharedRoom. + const coRoom = named === undefined && msg.via !== 'message_bot' && isRemoteRef(to) && !isRemoteRef(from) ? mentionRoom(state, from, to) : undefined // #948: a local Bot's line to a friend's Bot is judged under the room the two of them are still in, // even when the send names no room: that room is the consent, and the transport names it outbound. - const roomId = named ?? mentioned ?? (isRemoteRef(to) && !isRemoteRef(from) ? sharedRoom(state, from, to) : undefined) + const roomId = named ?? coRoom ?? (isRemoteRef(to) && !isRemoteRef(from) ? sharedRoom(state, from, to) : undefined) return reach(state, { from, to, roomId, scope: msg.meta?.scope }) } rt.ticketPeers = () => ticketPeers(state) diff --git a/core/src/host/room-membership.js b/core/src/host/room-membership.js index 17564f1b2..8e19928c4 100644 --- a/core/src/host/room-membership.js +++ b/core/src/host/room-membership.js @@ -34,7 +34,7 @@ // of a friend's room that invited their granted Bot. import { randomBytes } from 'node:crypto' -import { cleanPeerName, disambiguatedRemoteTitle, faceAliases, isChiefRecord, remoteBotName, remoteBotTitle, sharedRoomTitle } from '../shared/remote-label.js' +import { cleanPeerName, disambiguatedRemoteTitle, faceAliases, isChiefRecord, remoteBotName, remoteBotTitle, remoteCallName, sharedRoomTitle } from '../shared/remote-label.js' import { formatFaceRoomTitle, isAutoFaceTitle, faceRoomCreatedAt } from '../shared/face-title.js' import { HISTORY_MAX, HISTORY_TEXT, encodeRoomShare, parseRoomInviteLink, parseRoomShare, roomInviteUrl } from '../shared/room-share.js' import { cleanFaceLead, cleanRoleLabel, faceLeadOf } from './face-lead.js' @@ -473,6 +473,7 @@ export function install(rt) { const aliases = [ ...faceAliases(peer, bare || rosterName), remoteBotTitle(peer), + remoteCallName(peer), rosterName, peer.label, social.replace(/^@+/, ''), diff --git a/core/src/shared/remote-label.js b/core/src/shared/remote-label.js index 2b2d5da67..dbc0c693f 100644 --- a/core/src/shared/remote-label.js +++ b/core/src/shared/remote-label.js @@ -138,6 +138,26 @@ export function remoteBotTitle(record) { return `${name} (${owner})` } +// What a group @ inserts and what the room routes on (#1141). The menu may still show +// remoteBotTitle ("Architect (flybot682b8a43)"); the token is the Bot's own name. A handle, +// a ".home" address, or the placeholder owner "Friend" is not a call token — a handle falls +// back to its first label (architect.flybot….home → architect). +export function remoteCallName(record) { + const handle = String(record?.handle ?? '').replace(/^@+/, '').trim() + const bare = remoteBotName(record, '').replace(/^@+/, '').trim() + const unusable = (value) => { + const text = String(value ?? '').replace(/^@+/, '').trim() + if (!text || /^friend$/i.test(text)) return true + if (text.includes('@') || /\.home\b/i.test(text)) return true + if (handle && text.toLowerCase() === handle.toLowerCase()) return true + return false + } + if (!unusable(bare)) return bare + const slug = handle.split('.')[0] + if (slug && !unusable(slug)) return slug + return 'Bot' +} + // The owner part of that name, for a badge next to the Bot's own name. export function remoteOwnerBadge(record) { return ownerNameOf(record) || cleanPeerName(record?.handle, 'Friend') diff --git a/core/test/at-menu.test.js b/core/test/at-menu.test.js index b07083b2a..4aaeb3a8b 100644 --- a/core/test/at-menu.test.js +++ b/core/test/at-menu.test.js @@ -142,7 +142,7 @@ test('#305: a face room offers each of its Bots once, named Bot (Person), and no // A friend's Bot already seated in the group is offered once, by its seat (#384). assert.deepEqual(mentionRows(group, 'face').filter(row => row.remote).map(row => row.remote.handle), ['@helper.other.home']) // That seat is still found by its handle, the second line of its row (#384). - assert.deepEqual(mentionRows(group, 'face', 'chief.selfa').map(row => row.text), ['@Chief (SelfA) ']) + assert.deepEqual(mentionRows(group, 'face', 'chief.selfa').map(row => row.text), ['@Chief ']) }) test('#384: a friend\'s Bot with its own chat is offered once in a Bot\'s chat, by its handle, under its one name (owner)', async () => { @@ -209,3 +209,33 @@ test('#305: "Everyone" in a group counts the Bots it reaches, seated friends\' B assert.equal(await everyone(room([{ kind: 'human', id: 'ann', displayName: 'Ann' }])), '1 member') assert.equal(await everyone(room([{ kind: 'bot', remote: true, id: '@coder.bob.home', handle: '@coder.bob.home', displayName: 'Coder', ownerName: 'Bob' }])), '2 members') }) + +test('#1141: a group @ inserts the seat name, never a .home handle or (Friend)', async () => { + const group = indexRoster({ + bots: [{ id: 'chief', name: 'Chief', sessionId: 'room' }], + rooms: [{ + id: 'room', name: '研发组', members: ['chief'], + participants: [ + { kind: 'bot', id: 'chief', displayName: 'Chief' }, + { kind: 'bot', remote: true, id: '@architect.flybot682b8a43.home', handle: '@architect.flybot682b8a43.home', displayName: '@architect.flybot682b8a43.home', ownerName: 'flybot682b8a43' }, + { kind: 'bot', remote: true, id: 'backend-seat', handle: ' ', displayName: 'Backend' }, + ], + }], + }) + const rows = mentionRows(group, 'room') + assert.deepEqual(rows.map(row => row.text), ['@Chief ', '@architect ', '@Backend ', '@Everyone ']) + assert.ok(rows.every(row => !row.text.includes('@@') && !/\.home/.test(row.text) && !/\(Friend\)/.test(row.text))) + const [bots] = atSources({ + roster: { getSnapshot: () => group }, + sessions: { list: { getSnapshot: () => ({ ids: [], byId: {} }) } }, + launched: () => false, + iconFor: () => 'bot', + }) + const listed = await bots.candidates({ sessionId: 'room' }, { query: '' }) + const architect = listed.find(row => row.description === '@architect.flybot682b8a43.home') + assert.equal(architect.value, '@architect ') + assert.match(architect.name, /architect/i) + const backend = listed.find(row => row.value === '@Backend ') + assert.ok(backend) + assert.equal(backend.name, 'Backend (Friend)') +}) diff --git a/core/test/reach-policy.test.js b/core/test/reach-policy.test.js index b94a8bd27..ec9fee7d6 100644 --- a/core/test/reach-policy.test.js +++ b/core/test/reach-policy.test.js @@ -267,22 +267,32 @@ test('#948: a room-only Bot may line the seat it shares a room with, and the roo assert.equal(left.action, 'deny', 'the seat it shares is the consent, and it is gone with the seat') assert.equal(left.reason, refused) assert.deepEqual(out, []) - // #948's scope is the face-to-face room, whose seats are admitted by a number or a link: a room of - // this home is opened and closed by its members, and that path keeps asking for a grant or an - // explicit roomId. Same pair, same handles, only the room's kind differs. + // #948's face-to-face room is the consent for a room-only identity. An ordinary group is too + // (#1141): the live seat is enough, and another Bot of that home who is not seated is not. live.rooms.g = { id: 'g', members: ['a2'], participants: [ { kind: 'human', id: 'alice', role: 'admin', joinedSeq: 0 }, { kind: 'bot', id: 'a2', role: 'member', joinedSeq: 0 }, - { kind: 'bot', remote: true, handle: '@coder.bob.home', joinedSeq: 0 }, + { kind: 'bot', remote: true, handle: '@coder.bob.home', ownerHome: '@bob.home', joinedSeq: 0 }, ], } const group = await fb.bot.send({ from: 'a2', to: seat, text: 'a room of this home' }) - assert.equal(group.action, 'deny', 'only a face-to-face room is read here') - assert.equal(group.reason, refused) - assert.deepEqual(out, []) + assert.equal(group.action, 'allow', 'a live ordinary group is enough, with no Sharing grant') + assert.deepEqual(out, ['@coder.bob.home']) + out.length = 0 + const sameHome = await fb.bot.send({ from: 'a2', to: { kind: 'remote', handle: '@writer.bob.home' }, text: 'same home, not seated' }) + assert.equal(sameHome.action, 'deny', 'another Bot of that home is not the seat') + assert.equal(sameHome.reason, refused) + const homeLine = await fb.bot.send({ from: 'a2', to: { kind: 'remote', handle: '@bob.home' }, text: 'the friend home' }) + assert.equal(homeLine.action, 'allow', 'a send to the seated home is that seat') + assert.deepEqual(out, ['@bob.home']) assert.equal(reach(live, { from: 'a2', to: seat, roomId: 'g' }).action, 'allow', 'an explicit roomId still names its room, face-to-face or not') + live.rooms.g.participants[2].leftSeq = 4 + out.length = 0 + const leftGroup = await fb.bot.send({ from: 'a2', to: seat, text: 'after the group seat left' }) + assert.equal(leftGroup.action, 'deny', 'leaving the group ends the consent') + assert.equal(leftGroup.reason, refused) }) test('a held message is judged again when it is approved: revoke while it waited means refused', async (t) => { diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 62b3418cc..8cc1b9823 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -189,6 +189,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second - **Group and face-room Bot labels (#526, owner decision 2026-10-20, option a).** The same person-first format: `Reviewer (flybot682b8a43)`, never Bot Social's member label plus the handle (`Reviewer · FlyBot 682b8a43 (flybot682b8a43)`). `remoteBotName` drops a trailing ` · ` from a Bot's name when it is that Bot's owner (the server's `label` reaching a name slot, e.g. a bubble header with no seat yet); `ownerNameOf` passes over an owner name made up from an install id (`isAutoOwnerName`: `FlyBot` + hex id) to the owner's username from the home handle, and keeps it only when no handle is known; `seatOwnerName` reads my own seat's owner the same way (`face-seats.js`, `at-menu.js`). The Bot names in `FacePersonRow` and the remote rows of the group Members lists (`group-settings.js`, `details-drawer.js`) carry the handle as a tooltip, as bubble headers already do. Covered by `core/test/group-bot-label.test.js`. - A face-to-face room's subtitle (`roomSubtitle` → `faceRoomCount` in `group-settings.js`) names what it counts, `1 person · 1 Bot` / `2 people · 4 Bots`: Bots are the live Bot seats, people the distinct owners among them (me once, a friend by owner name); human seats and Bots that left do not count (#306). Any other group's subtitle, command-palette row and Groups-list tag use `seatedCount`: humans, this home's Bots, and remote Bots still in the room — the same people the Members card lists — so a shared room does not read as only the Bots this home brought. A remote that left is not counted. The "Add a Bot first" note uses that same total. - The composer `@` in a face-to-face room (`mentionRows` / `atSources` in `at-menu.js`) offers the room's own Bots, each once (a repeated remote seat is folded by handle; Bots that left are out), labelled as the Members panel names them (`Chief (SelfA)`, `Chief (SelfB)`), plus **Everyone** with the room's Bot count (`2 Bots`, #457). Published friends' Bots (`remoteMentionRows`) and plugin rows (the social `Friends' Bots` section) are not offered there, since only the Bots in the room can be reached in it. Only the label changes: the inserted text is still the `@Name` the room routes on (`@SelfA's Chief`), never a handle (#305). +- An ordinary group's `@` still shows the seat as `remoteBotTitle` (`Backend (Friend)`, handle on the second line) but inserts `remoteCallName` (#1141): the Bot's own name (`@Backend`, `@architect`), which `callsIn` also aliases. It does not insert a second `@`, a `.home` handle, or the placeholder `(Friend)`. - A face room's `@` menu shows no DSH Sessions rows (#305, owner decision 2026-10-19: filter in FlyBot for now; #422 asks DSH for an API). DSH's built-in `reference` source cannot be dropped for one Session, so `installHeaderEffects` (`header-effects.js`) starts `actions.filterFaceAtMenu` for a face room, which watches that Session's trigger controller (`inputTriggers.sessionOf`, the same one the composer uses) with `watchFaceMenu` (`face-at-filter.js`): a `