diff --git a/changelog.d/2641.fixed.md b/changelog.d/2641.fixed.md new file mode 100644 index 000000000..244566c1a --- /dev/null +++ b/changelog.d/2641.fixed.md @@ -0,0 +1 @@ +A person's own line in a face-to-face room is now capped at 18 000 code points instead of UTF-16 units, so an emoji at the bound is kept whole rather than stored as half a surrogate pair that every later UTF-8 write shows as a replacement character ([#2641](https://github.com/ThinkFlowLab/FlyBot/issues/2641)). diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index dd91cc1d3..ccbca7b08 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -111,6 +111,7 @@ Creating a group opens its Session with zero turns, and the host draws no sessio ### 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. +- The encoded human line is bounded at 18 000 **code points**, not UTF-16 units (#2641): `encodeFaceHuman` cuts with the file's own `cutCodePoints`, the same 18 000-code-point budget `coSeatEnvelope` applies to an outgoing room-share line, so a person's line whose bound falls inside an emoji ends on a code-point boundary instead of storing a lone surrogate that the next UTF-8 write turns into `U+FFFD`. - **A call that cannot travel is said out loud (#997):** a person's `@` rides the attested line only. If the line has to go out as the legacy envelope and the server does stamp typed lines — the one combination where the other home reads that envelope as a Bot's line, so the `@` calls nobody — the room gets one quiet notice naming the handles (`No call went out for @x: this connector token may not attest typed lines, so the other home reads this line as a Bot's.`) right after the line is sent. The words are never withheld, a probe that did not answer is not treated as "no" and adds no notice (the next line asks again), and a server that cannot stamp keeps the old behaviour, where the envelope is the person's line and its labels still call the Bot. Only the room's **remote** seats count: a local Bot named after `@` is called by this room itself (core `openingSpeakers`), attested or as the envelope, so it is never reported as lost. `plugins/social/test/social.test.js` pins both directions, the local seat and the unanswered probe. - 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. diff --git a/plugins/social/src/face-rooms.js b/plugins/social/src/face-rooms.js index ca1d094ab..dda0829d2 100644 --- a/plugins/social/src/face-rooms.js +++ b/plugins/social/src/face-rooms.js @@ -325,7 +325,7 @@ export const FACE_HUMAN_MARK = 'FLYBOT-FACE-HUMAN' export function encodeFaceHuman(payload = {}) { const who = cleanFaceName(payload.who, 'Someone') - const text = String(payload.text ?? '').slice(0, 18_000) + const text = cutCodePoints(String(payload.text ?? ''), 18_000) return `${FACE_HUMAN_MARK}\n${JSON.stringify({ v: 1, who, text })}` } diff --git a/plugins/social/test/face-text-bounds.test.js b/plugins/social/test/face-text-bounds.test.js index c3c24efd6..eb4e25bb4 100644 --- a/plugins/social/test/face-text-bounds.test.js +++ b/plugins/social/test/face-text-bounds.test.js @@ -1,6 +1,14 @@ import assert from 'node:assert/strict' import { test } from 'node:test' -import { cleanFaceName, cleanFaceRole, mapFaceError, mapRegisterError, viewFaceEvent } from '../src/face-rooms.js' +import { + cleanFaceName, + cleanFaceRole, + encodeFaceHuman, + mapFaceError, + mapRegisterError, + parseFaceHuman, + viewFaceEvent, +} from '../src/face-rooms.js' // Peer-supplied face-room text is capped (#2438). A cap that lands between the halves of a surrogate // pair stores a lone high surrogate, which every encoder reads as U+FFFD, so each cut must land @@ -58,3 +66,31 @@ test('an unmapped join error message lands on a code point boundary, not inside const stored = shapes(error.message) assert.equal(stored, `${'a'.repeat(199)}${ROCKET}`) }) + +// The person's own face-room line is 18 000 code points, not 18 000 UTF-16 units (#2641): an +// emoji straddling that bound was kept as half a pair in the FLYBOT-FACE-HUMAN JSON, which still +// parses, and the next UTF-8 write of the parsed text became U+FFFD. +test('the face-room human line cut lands on a code point boundary, not inside an emoji', () => { + const text = `${'a'.repeat(17_999)}${ROCKET}` + const stored = shapes(parseFaceHuman(encodeFaceHuman({ who: 'Tester', text })).text) + assert.equal(stored, text) +}) + +test('the face-room human line cap counts code points, so whole emoji are never cut in half', () => { + const parsed = parseFaceHuman(encodeFaceHuman({ who: 'Tester', text: ROCKET.repeat(18_000) })) + const stored = shapes(parsed.text) + assert.equal(Array.from(stored).length, 18_000) + assert.equal(stored, ROCKET.repeat(18_000)) +}) + +test('a face-room human line under the cap keeps its emoji unchanged', () => { + const text = `hello ${ROCKET} there` + assert.equal(parseFaceHuman(encodeFaceHuman({ who: 'Tester', text })).text, text) +}) + +test('a cut face-room human line has no replacement character once its text is written as UTF-8', () => { + const text = `${'a'.repeat(17_999)}${ROCKET}` + const parsed = parseFaceHuman(encodeFaceHuman({ who: 'Tester', text })) + const bytes = Buffer.from(parsed.text, 'utf8') + assert.equal(bytes.includes(Buffer.from([0xef, 0xbf, 0xbd])), false) +})