From a950548d82532396247e5f292d4df68af23b1e59 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sat, 24 Oct 2026 23:26:42 +0800 Subject: [PATCH] Read a room number only when it is exactly six digits, and keep two codeless rooms apart #1005: holdRoomName took the stored joinCode, stripped non-digits and cut it to six, so a stored '1234567' read as 'the room 123456' -- the number of a different room. The two client sites that decide what the owner reads (faceWaitCode for the waiting card, shownFaceTitle for the room row) did the same: slice first, then test the length, so a value that is not a number became one that looks like it. One rule now, on both sides: a code counts only when the stored value is exactly six digits, otherwise it is absent -- the title stands in on the card, the typed code on the waiting card, the creation time in the title. A value that is not six digits is never truncated. The other half is the label a codeless room gets. A room joined by link keeps no number, so two live rooms sharing a title both read 'the room ' and the card could not tell them apart. A title is now the whole label only while no other live, codeless room reads the same way; a collision adds the last four alphanumerics of the room id ('the room Loft * a3f1'), which is stable forever and is not something a person types. A single link-joined room still reads 'the room Loft' (#991), and a room with no number, no live room and no title still keeps approvalView's bare 'the room'. social.md says both rules where #991's sentence and the face-room title bullet are, and the fragment is owner-visible: the label on the approval card can change. --- .../1005-room-label-six-digits.fixed.md | 1 + docs/subsystems/social.md | 4 +- plugins/social/client.js | 17 ++-- plugins/social/src/social.js | 22 ++++- plugins/social/test/social.test.js | 91 +++++++++++++++++++ 5 files changed, 123 insertions(+), 12 deletions(-) create mode 100644 changelog.d/1005-room-label-six-digits.fixed.md diff --git a/changelog.d/1005-room-label-six-digits.fixed.md b/changelog.d/1005-room-label-six-digits.fixed.md new file mode 100644 index 000000000..3aee379a8 --- /dev/null +++ b/changelog.d/1005-room-label-six-digits.fixed.md @@ -0,0 +1 @@ +A room number is now exactly the six digits stored for a room: a stored value that is not one (`1234567`) is no number at all — cut to six it would name a different room — so the approval card, the waiting card and the room title fall back to the room's own name or to the code you typed. Two live rooms with no number and the same name no longer share one label: each card adds a short, fixed tag from the room id (`the room Loft · a3f1`), the same room keeping its tag on every render ([#1005](https://github.com/ThinkFlowLab/FlyBot/issues/1005)). diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 846d1738f..f533d6778 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -95,7 +95,7 @@ pins the list. The authority for the list is Bot Social's README and `docs/FACE_ ### Face-room human turns and UI polish (#166, #167) - A plain human line in an open face-to-face room is logged and fanned out once, and starts a single pass of every local Bot (admin first, capped at 6, no @ needed; #287, #295; core `openingSpeakers`, see [core-host](core-host.md)). Since #299 the Bots pick the responder from a plain line (they may `[PASS]`; the first Bot that answers ends the round unless the line is to everyone; the first Bot answers if all pass on the owner's own line), and a line to everyone or `@all` is unchanged. A friend's attested plain line (or `@all`) starts the same pass on this home, routed and single like the owner's own line (#462) (the plugin passes `attested: true` to `postHuman` only for a stamped line); an attested line that names Bots starts only the Bot(s) it names by the label the picker shows (`Friend's Chief`) or by handle; `plugins/social/test` has the two-home case. **Explicit mention list (#293):** the sender's host resolves the picker's labels against the room (`faceMentions().handles`; core `onFaceLine(fn)` passes `{ mentions }` for the owner's typed line) and `faceApi.send` puts the handles in `mentions` next to `typed_by_owner` only when `GET /v1/platform` `features` lists `mentions` (the one feature probe, `serverMentions()`); never on the legacy envelope, Bot lines or an older server. The receiver reads `mentions` (`cleanMentions`, at most 8 handles) only from a line the server attests `typed_by_owner` (`renderFaceEvent`), and `postHuman` resolves the handles against the room first (`mentionedByHandle`): only my own Bots in the list answer; a list naming nobody the room knows, or no list, falls back to the label/name reading above. The composer's `@` picker inserts the label text, so the handles are derived from it at send, not tracked in the composer. -- A held room line belongs to one room (#188): the send path records `settings.holdRooms[approvalId] = faceRoomId` when Bot Social's card does not name the room, and `pendingApprovals` drops holds with no known room (legacy) or whose room is left / closed / past here, so no other room shows the card and no waiting dot stays on an old row. The card names that room (#991): the number this home joined it by (`settings.faceRooms[roomId].joinCode`) when there is one, else the hosted room's own title, so two open rooms never both read `Chief → the room` — a hold whose room id names no number and no live room keeps the bare `the room`. The room scope in the client, the `roomApprovals` provider and `hidden-remotes` match the room id strictly. +- A held room line belongs to one room (#188): the send path records `settings.holdRooms[approvalId] = faceRoomId` when Bot Social's card does not name the room, and `pendingApprovals` drops holds with no known room (legacy) or whose room is left / closed / past here, so no other room shows the card and no waiting dot stays on an old row. The card names that room (#991): the number this home joined it by (`settings.faceRooms[roomId].joinCode`) when there is one, else the hosted room's own title, so two open rooms never both read `Chief → the room` — a hold whose room id names no number and no live room keeps the bare `the room`. A number is the six digits only (#1005): a stored `joinCode` that is not exactly six digits is no number at all — never cut to six, which would name a different room — so the card falls back to the title, and two live rooms with no number and the same title take a stable last-four tag from the room id (`the room Loft · a3f1`), each room keeping its own tag on every render. The room scope in the client, the `roomApprovals` provider and `hidden-remotes` match the room id strictly. - Face-room holds (`face_room_message` / `roomId`) attach `pendingCount` to that room only. They do not badge an unrelated friend row via `from`/`to` handle. The social strip no longer draws an **N to approve** tag under the last sidebar row; the gold wait dot on the owning RoomRow / 1:1 remains. - Approval preview strips `To face room:` / `To the room:` and wraps on word boundaries (`overflow-wrap:break-word; word-break:normal`), with **Show more** when long. - Join notices are remembered by handle and by exact text so a `member.joined` event plus the members loop does not post two identical joined lines. @@ -170,7 +170,7 @@ pins the list. The authority for the list is Bot Social's README and `docs/FACE_ ### Past rooms, unique titles, poll, and + (#129–#133) -- Expired matching (`candidate` whose `expiresAt` has passed) and a peer leaving the last remote seat move the hosted room to **Past rooms** on the next poll (`rememberPastFace`, status `closed` / `left`). The local room stays so **Open** still works; **Leave** is not shown on those cards. Face-room names are `Face-to-face · Alice, Bob · 482913` (people plus the 6-digit code, or `Oct 16 21:40` when there is no code) so two rooms with the same people stay distinct. The literal `Face-to-face room` is not a fallback. +- Expired matching (`candidate` whose `expiresAt` has passed) and a peer leaving the last remote seat move the hosted room to **Past rooms** on the next poll (`rememberPastFace`, status `closed` / `left`). The local room stays so **Open** still works; **Leave** is not shown on those cards. Face-room names are `Face-to-face · Alice, Bob · 482913` (people plus the 6-digit code, or `Oct 16 21:40` when there is no code) so two rooms with the same people stay distinct. The literal `Face-to-face room` is not a fallback. The number printed there and on the waiting card is the six digits only (#1005): `faceWaitCode` and `shownFaceTitle` refuse a stored value that is not exactly six digits (`1234567` never reads as `123456`) and fall back to the code the owner typed, or to the creation time. - Ended-room state and titles (#304): when every other home's Bots have left an open room, this home marks it **Closed** (it did not leave; `faceSnapshotPast` → `closed` with `peersGone`) and then leaves it on Bot Social itself (`leaveFaceRoom({ status: 'closed' })`), so it stops polling the room and the room-only identity is released (invariant 4). If that leave fails (timeout, 5xx), the next poll that still finds the room open on Bot Social tries it again. The owner who left keeps **Left**: `rememberPastFace` never turns a `left` row into `closed` when a later poll finds the room gone, and their room reads `You left this room.` while the others read `<Name> left the room. This room is closed.` The number a room was joined by goes with every `upsertFace` (`joinCode`) and core keeps it on `room.face.joinCode`, so the title stays `Face-to-face · Alice, Bob · 482913` through polls, Leave and the end; Past rooms show that same title (people rebuilt from the row's members when it has them, the number or stored time kept; with neither, the creation time, never the joining deadline). A left or closed room's details are read-only: the status chip only, no Auto-send and no Leave. Past rows list everyone who was in the room (#304): `faceRoomTitle` takes the snapshot's members plus every participant the hosted room ever had (like the core title), and `viewHostedFace` for a closed or left room lists all participants, marking who had left `left: true` (Open on a dropped room seats only the others again); a row stored before this is refreshed from its hosted room. Each row shows its time (`data-flybot=face-time`): `endedAt`, set once by `rememberPastFace`, else the start (or creation) time; today reads `14:05`, an earlier day `Oct 19`, and the hover text has the full `Ended Oct 19 14:05`. The fold remembers whether it is open across a reload (`localStorage` `flybot.social.pastRoomsOpen`). - The face-to-face join field for the room number starts empty with a light `6 digits` hint (normal letter spacing, 13px), not a `000000` that looks pre-filled (#173). - Round-3 polish (#173): **Accept invite** is no longer a separate **+** row; **Add friend** opens the single join panel for both a handle and an invite link or code. Escape closes the Add friend and Face-to-face panels, the `000000` code is a placeholder, Past rooms and friend rows use shorter copy, and Bot settings group invite and access under **Sharing** / **People with access**. diff --git a/plugins/social/client.js b/plugins/social/client.js index 545840cd5..3e28de417 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -632,11 +632,14 @@ window.__ModuleLoader__.load({ return (bots ?? []).some(bot => bot.handle === handle) } + // #1005: a room number is exactly six digits. A stored value that is not one is no number at + // all — cutting 1234567 down to 123456 would name a different room, not fall back. + function sixRoomDigits(value) { + const digits = String(value ?? '').replace(/\D/g, '') + return digits.length === 6 ? digits : '' + } function faceWaitCode(room, typed) { - const fromRoom = String(room?.joinCode || room?.code || '').replace(/\D/g, '').slice(0, 6) - if (fromRoom.length === 6) return fromRoom - const fromTyped = String(typed || '').replace(/\D/g, '').slice(0, 6) - return fromTyped.length === 6 ? fromTyped : '' + return sixRoomDigits(room?.joinCode || room?.code) || sixRoomDigits(typed) } // QR code in byte mode, error correction level M, versions 1-10 (#773): a room number is 13 // bytes and an invite link is 40-70, so the version is picked from the payload. Everything is @@ -1835,11 +1838,11 @@ window.__ModuleLoader__.load({ rows.sort((a, b) => a.sort.localeCompare(b.sort)) const owners = rows.slice(0, 3).map(row => row.owner) const stored = named.match(/ · (\d{6})$/)?.[1] - const code = String(room?.joinCode || room?.code || stored || '').replace(/\D/g, '').slice(0, 6) + const code = sixRoomDigits(room?.joinCode || room?.code || stored) const bits = ['Face-to-face'] if (owners.length) bits.push(owners.join(', ')) const storedWhen = named.match(/ · ([A-Z][a-z]{2} \d{1,2} \d{2}:\d{2})$/)?.[1] - if (code.length === 6) bits.push(code) + if (code) bits.push(code) else if (storedWhen) bits.push(storedWhen) else { // Created = the joining deadline minus the 10-minute window, never the deadline itself. @@ -3433,6 +3436,8 @@ window.__ModuleLoader__.load({ FaceRoomCard, FaceRooms, FaceAutoSend, + faceWaitCode, + shownFaceTitle, ConfirmLeave, PausedChat, autoSendToast, diff --git a/plugins/social/src/social.js b/plugins/social/src/social.js index 41af5b1a9..7f804089c 100644 --- a/plugins/social/src/social.js +++ b/plugins/social/src/social.js @@ -2950,12 +2950,26 @@ export function createSocial(service, config = {}, { client: given, api: givenAp // "the room" while the owner is deciding where the line lands. The number the owner typed is the // shortest thing they can match to the room on screen; a guest that joined by link has no code yet, // so the room's own title stands in. Undefined keeps `approvalView`'s "the room" for an older shape. + // A room number is exactly six digits (#1005): a stored value that is not one is no number at + // all, because cutting 1234567 down to 123456 would name a different room. + const sixDigits = (value) => { + const digits = String(value ?? '').replace(/\D/g, '') + return digits.length === 6 ? digits : '' + } + const roomCode = (roomId) => sixDigits(settings.faceRooms?.[roomId]?.joinCode) + const roomTitle = (roomId) => cleanText(flybot.rooms?.ofFace?.(roomId)?.name) + // The last four alphanumerics of the id: stable for one room, fixed forever, and nobody types it. + const roomTag = (roomId) => String(roomId ?? '').replace(/[^a-z0-9]/gi, '').slice(-4).toLowerCase() const holdRoomName = (roomId) => { if (!roomId) return undefined - const code = String(settings.faceRooms?.[roomId]?.joinCode ?? '').replace(/\D/g, '').slice(0, 6) - if (code.length === 6) return `the room ${code}` - const title = cleanText(flybot.rooms?.ofFace?.(roomId)?.name) - return title ? `the room ${title}` : undefined + const code = roomCode(roomId) + if (code) return `the room ${code}` + const title = roomTitle(roomId) + // Without a code the title is the whole label only while no other live room reads the same way: + // two rooms joined by link keep no number, so the tag has to tell them apart (#1005). + const clash = Object.keys(settings.faceRooms ?? {}).some(id => id !== roomId && !roomCode(id) && faceHoldLive(id) && roomTitle(id) === title) + if (!title) return undefined + return clash ? `the room ${title} · ${roomTag(roomId)}` : `the room ${title}` } const ownLineApproving = new Set() async function approveOwnLine(item) { diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index 11fd44c59..387000cc7 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -11297,3 +11297,94 @@ test('#994: the share receipt names the Bot the click granted and never the home assert.equal(bare.message, 'Shared Chief with your friend. They can decline; you can Revoke in People with access.') assert.doesNotMatch(bare.message, /selftestb2/i) }) + +// ---- #1005: a room number is exactly six digits, and one card names one room ---------------- + +// A stored value that is not six digits is not a room number: cutting '1234567' to '123456' would +// name a different room. The card falls back to the room's own title instead. +test('#1005: a stored code that is not six digits is not a room number; the hold card reads the title', async (t) => { + const { ctx, call, stop, home } = await team() + t.after(stop) + const server = withFaceRooms(mockServer()) + server.tools.list_approvals = () => ({ + approvals: [{ + approval_id: 'apr-face-7', kind: 'face_room_message', status: 'pending', bot_id: 'm1', + room_id: 'frm_1234ab', message: { payload: { text: 'hold me' } }, summary: 'To the room: hold me', + }], + }) + // The room is live on Bot Social too: the startup poll closes a stored room the server never heard of. + server.face.rooms.set('frm_1234ab', { room_id: 'frm_1234ab', status: 'candidate', expires_at: new Date(Date.now() + 10 * 60_000).toISOString(), members: [], events: [], event_cursor: 0, my_bots: ['@chief.danyang.home'] }) + const settingsPath = join(home, 'face-settings.json') + writeFileSync(settingsPath, JSON.stringify({ faceRooms: { frm_1234ab: { joinCode: '1234567', status: 'candidate' } } })) + const social = createSocial(ctx.flybot, { server: 'https://s', token: 'owner-token', fetch: server.fetch, inboxPollMs: 0, autoAccept: false, settingsPath }, { + 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() + // Let the one startup poll finish first: it is what would rewrite a room's title. + await settle() + await ctx.flybot.rooms.upsertFace({ roomId: 'frm_1234ab', status: 'candidate', name: 'Loft' }) + const approvals = (await call('flybot/social/approvals', {})).value.approvals + assert.equal(approvals.length, 1) + // The title alone: a single link-joined room still reads by its name (#991). + assert.equal(approvals[0].toName, 'the room Loft', 'the title, never 123456 cut out of 1234567') +}) + +// Two live rooms with no code share a title: the title can no longer be the whole label, so each +// gets a short, stable fragment of its own id. +test('#1005: two live rooms with no code and the same title read as two different labels, and keep them', async (t) => { + const { ctx, call, stop, home } = await team() + t.after(stop) + const server = withFaceRooms(mockServer()) + server.tools.list_approvals = () => ({ + approvals: [ + { approval_id: 'apr-a', kind: 'face_room_message', status: 'pending', bot_id: 'm1', room_id: 'frm_a3f1', message: { payload: { text: 'first' } }, summary: 'To the room: first' }, + { approval_id: 'apr-b', kind: 'face_room_message', status: 'pending', bot_id: 'm1', room_id: 'frm_b7c2', message: { payload: { text: 'second' } }, summary: 'To the room: second' }, + ], + }) + for (const id of ['frm_a3f1', 'frm_b7c2']) server.face.rooms.set(id, { room_id: id, status: 'candidate', expires_at: new Date(Date.now() + 10 * 60_000).toISOString(), members: [], events: [], event_cursor: 0, my_bots: ['@chief.danyang.home'] }) + const settingsPath = join(home, 'face-settings.json') + writeFileSync(settingsPath, JSON.stringify({ faceRooms: { frm_a3f1: { status: 'candidate' }, frm_b7c2: { status: 'candidate' } } })) + const social = createSocial(ctx.flybot, { server: 'https://s', token: 'owner-token', fetch: server.fetch, inboxPollMs: 0, autoAccept: false, settingsPath }, { + 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() + await settle() + // Two rooms, each with its own local id: the same title must not merge them (that is #1005's subject, not a session name clash). + await ctx.flybot.rooms.upsertFace({ roomId: 'frm_a3f1', localId: 'face-a3f1', status: 'candidate', name: 'Loft' }) + await ctx.flybot.rooms.upsertFace({ roomId: 'frm_b7c2', localId: 'face-b7c2', status: 'candidate', name: 'Loft' }) + const first = (await call('flybot/social/approvals', {})).value.approvals.map(item => item.toName) + assert.equal(first.length, 2) + assert.notEqual(first[0], first[1], 'two live rooms never share one label') + assert.ok(first.every(name => name.includes('Loft')), JSON.stringify(first)) + const second = (await call('flybot/social/approvals', {})).value.approvals.map(item => item.toName) + assert.deepEqual(second, first, 'a second render keeps each room on its own label') +}) + +// The client decides what the owner reads: the waiting card's number and the room row's title. +test('#1005: the client refuses a stored code that is not six digits, on the card and in the row title', () => { + const { walk, pluginOf } = loadSocialClient(async () => ({})) + const api = pluginOf([]) + assert.equal(api.faceWaitCode({ joinCode: '123456' }, ''), '123456') + assert.equal(api.faceWaitCode({ joinCode: '1234567' }, ''), '', '1234567 is not 123456') + assert.equal(api.faceWaitCode({ joinCode: '1234567' }, '482913'), '482913', 'falls back to the code that was typed') + assert.equal(api.faceWaitCode({ code: '1234567' }, ''), '', 'nor from `code`') + // A stored auto name is trusted as it stands; the builder is where a code gets appended, so the + // row is built from its members (Alice is in the room). + const alice = [{ handle: '@coder.bob.home', ownerName: 'Alice' }] + const titled = api.shownFaceTitle({ joinCode: '1234567', members: alice, createdAt: '2026-10-24T10:00:00.000Z' }) + assert.doesNotMatch(titled, /123456/, titled) + assert.match(titled, /^Face-to-face · Alice · /, titled) + assert.match(api.shownFaceTitle({ joinCode: '123456', members: alice }), /· 123456$/) + // The waiting dialog draws a room number only when the stored value really is one. + const draw = (room, code) => walk(pluginOf(faceWaiting({ room, code })).FaceToFaceGroup({ onClose() {} })) + const room = joinCode => ({ roomId: 'g1', faceRoomId: 'frm_1', status: 'candidate', joinCode, expiresAt: '2099-01-01T00:00:00.000Z', members: [] }) + assert.equal(draw(room('1234567'), '').some(n => n.props['data-flybot'] === 'face-wait-code-digits'), false, 'no truncated number') + const six = draw(room('123456'), '') + assert.ok(six.some(n => n.props['data-flybot'] === 'face-wait-code-digits' && n.children.includes('123456'))) +})