Skip to content
Closed
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/2438-social-face-text-code-points.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A face-to-face room now caps the names, roles, platform lines and join errors it takes from the other home on code points, so a member whose display name ends just past the cap shows a whole character instead of `�`, and a 60- or 80-character label counts what a person counts ([#2438](https://github.com/ThinkFlowLab/FlyBot/issues/2438)).
23 changes: 11 additions & 12 deletions plugins/social/src/face-rooms.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ export const FACE_TTL_MS = 10 * 60 * 1000
const CALL_TIMEOUT_MS = 30_000
const NAME_MAX = 60
const SEEN_KEPT = 200
// A cap on peer-supplied text cuts on code points, never on UTF-16 units: a unit offset can split
// a surrogate pair and leave a lone surrogate, which renders as a replacement character (#2431).
const cut = (text, max) => (!Number.isFinite(max) || text.length <= max ? text : Array.from(text).slice(0, max).join(''))

export class FaceRoomError extends Error {
constructor(message, code, extra = {}) {
Expand Down Expand Up @@ -74,12 +77,11 @@ export const localInviteHandle = value => parseInviteLink(value)?.handle ?? ''
// brackets, markup or @, capped. Used for every peer-controlled name and for platform
// system copy that embeds those names.
export function cleanFaceName(value, fallback = 'a remote Bot') {
const text = stripFaceMarkers(String(value ?? ''))
const text = cut(stripFaceMarkers(String(value ?? ''))
.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ')
.replace(/[[\]"<>`@]/g, '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, NAME_MAX)
.trim(), NAME_MAX)
return text || fallback
}

Expand Down Expand Up @@ -196,7 +198,7 @@ export function mapRegisterError(error, bot) {
if (code === 'http_401' || code === 'http_403' || code === 'unauthorized' || /401|403|unauthori[sz]ed|token/i.test(code + msg)) {
return new FaceRoomError(FRIENDLY.unauthorized, 'unauthorized', { status: 401 })
}
const raw = msg.replace(/^Bot Social\s+\S+:\s*/, '').replace(/\s+/g, ' ').trim().slice(0, 200)
const raw = cut(msg.replace(/^Bot Social\s+\S+:\s*/, '').replace(/\s+/g, ' ').trim(), 200)
const detail = raw && !/^[a-z_]+$/.test(raw) ? `: ${raw}` : '. Try again.'
return new FaceRoomError(`Could not register ${name} on Bot Social${detail}`, code || 'register_failed')
}
Expand All @@ -221,7 +223,7 @@ export function mapFaceError(data, status) {
? `Too many failed attempts. Try again in ${formatRetryAfter(retryAfter)}.`
: 'Too many failed attempts. Try again later.'
} else if (!message) {
const raw = String(payload.message ?? '').replace(/\s+/g, ' ').trim().slice(0, 200)
const raw = cut(String(payload.message ?? '').replace(/\s+/g, ' ').trim(), 200)
message = raw && !/^[a-z_]+$/.test(raw) ? raw : 'Could not join that room. Try another number.'
if (status === 401) message = FRIENDLY.unauthorized
else if (status === 429) message = retryAfter != null
Expand Down Expand Up @@ -500,12 +502,11 @@ export function viewFaceMember(member) {
export const FACE_ROLE_MAX = 80
export function cleanFaceRole(value) {
if (typeof value !== 'string') return ''
return stripFaceMarkers(value)
return cut(stripFaceMarkers(value)
.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ')
.replace(/[[\]"<>`@]/g, '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, FACE_ROLE_MAX)
.trim(), FACE_ROLE_MAX)
}

// The room lead every home follows (#476). Bot Social sends the creator's pick, else its default
Expand Down Expand Up @@ -610,13 +611,11 @@ function sanitizePlatformText(text, type, member, reason) {
if (type === 'member.left' && member) return platformLeaveText(member)
if (type === 'room.started') return platformStartedText(reason)
if (type === 'room.lead_changed') return platformLeadText(member, reason)
const cleaned = String(text ?? '')
return cut(String(text ?? '')
.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ')
.replace(/[[\]"<>`@]/g, '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 200)
return cleaned
.trim(), 200)
}

// `attested`: the server stamps typed_by_owner (Bot Social features), so a line is a person's only when
Expand Down
27 changes: 27 additions & 0 deletions plugins/social/test/face-text-bounds.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// A peer-controlled face-room name and role are capped before they reach a room, a transcript or
// an `<untrusted-message>` fence, and the cap must land on a code point boundary: a cut between
// the halves of a surrogate pair stores a lone surrogate, which every encoder reads as U+FFFD and
// every reader draws as a replacement character (#2438).
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { cleanFaceName, cleanFaceRole } from '../src/face-rooms.js'

test('a face-room name bound lands on a code point boundary, not inside an emoji', () => {
const name = cleanFaceName(`${'x'.repeat(59)}🚀rest`)
assert.equal(name.isWellFormed(), true, 'the capped name keeps half a surrogate pair')
assert.deepEqual([...name].length, 60, 'the name keeps its 60-character cap')
assert.equal([...name].at(-1), '🚀', 'the last whole character is the emoji, not half of it')
})

test('the name cap counts code points, so whole emoji are never cut in half', () => {
const kept = cleanFaceName('🚀'.repeat(60))
assert.deepEqual([...kept].length, 60, 'all 60 code points survive the cap')
assert.equal(kept.length, 120, 'the cap is counted in code points, so no emoji is cut in half')
})

test('a face-room role bound lands on a code point boundary, not inside an emoji', () => {
const role = cleanFaceRole(`${'y'.repeat(79)}🚀rest`)
assert.equal(role.isWellFormed(), true, 'the capped role keeps half a surrogate pair')
assert.deepEqual([...role].length, 80, 'the role keeps its 80-character cap')
assert.equal([...role].at(-1), '🚀', 'the last whole character is the emoji, not half of it')
})
Loading