Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/2641.fixed.md
Original file line number Diff line number Diff line change
@@ -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)).
1 change: 1 addition & 0 deletions docs/subsystems/social.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion plugins/social/src/face-rooms.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@
// system copy that embeds those names.
export function cleanFaceName(value, fallback = 'a remote Bot') {
const text = cutCodePoints(stripFaceMarkers(String(value ?? ''))
.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ')

Check warning on line 87 in plugins/social/src/face-rooms.js

View workflow job for this annotation

GitHub Actions / plugins (social)

eslint(no-control-regex)

Unexpected control characters
.replace(/[[\]"<>`@]/g, '')
.replace(/\s+/g, ' ')
.trim(), NAME_MAX)
Expand Down Expand Up @@ -325,7 +325,7 @@

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 })}`
}

Expand Down Expand Up @@ -479,7 +479,7 @@
|| (typeof member.color === 'string' && !HEX_ONLY.test(member.color) ? member.color.trim().slice(0, 32) : '')
if (color && !/[\n<>]/.test(color)) out.color = color
const src = member.avatar && typeof member.avatar === 'object' ? member.avatar : {}
const avatar = { ...(fromServer.avatar ?? {}) }

Check warning on line 482 in plugins/social/src/face-rooms.js

View workflow job for this annotation

GitHub Actions / plugins (social)

unicorn(no-useless-fallback-in-spread)

Empty fallbacks in spreads are unnecessary
if (typeof src.shape === 'string' && /^[a-z][\w-]{0,31}$/.test(src.shape)) avatar.shape = src.shape
const image = typeof src.image === 'string' ? src.image.trim() : (typeof member.image === 'string' ? member.image.trim() : '')
if (image && image.length <= 65536 && /^(https?:\/\/|data:image\/)/i.test(image) && !/[\s<>]/.test(image)) avatar.image = image
Expand Down Expand Up @@ -509,7 +509,7 @@
export function cleanFaceRole(value) {
if (typeof value !== 'string') return ''
return cutCodePoints(stripFaceMarkers(value)
.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ')

Check warning on line 512 in plugins/social/src/face-rooms.js

View workflow job for this annotation

GitHub Actions / plugins (social)

eslint(no-control-regex)

Unexpected control characters
.replace(/[[\]"<>`@]/g, '')
.replace(/\s+/g, ' ')
.trim(), FACE_ROLE_MAX)
Expand Down Expand Up @@ -618,7 +618,7 @@
if (type === 'room.started') return platformStartedText(reason)
if (type === 'room.lead_changed') return platformLeadText(member, reason)
const cleaned = cutCodePoints(String(text ?? '')
.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ')

Check warning on line 621 in plugins/social/src/face-rooms.js

View workflow job for this annotation

GitHub Actions / plugins (social)

eslint(no-control-regex)

Unexpected control characters
.replace(/[[\]"<>`@]/g, '')
.replace(/\s+/g, ' ')
.trim(), 200)
Expand Down
38 changes: 37 additions & 1 deletion plugins/social/test/face-text-bounds.test.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
})
Loading