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
23 changes: 23 additions & 0 deletions plugins/social/src/face-rooms.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@
// system copy that embeds those names.
export function cleanFaceName(value, fallback = 'a remote Bot') {
const text = stripFaceMarkers(String(value ?? ''))
.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ')

Check warning on line 78 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()
Expand Down Expand Up @@ -137,9 +137,15 @@
}

const ALREADY_LEFT = new Set(['bot_not_found', 'not_a_member', 'room_closed', 'face_room_not_found', 'code_void'])
// An outage is not an ending: these codes and any 5xx mean the server could not answer, so the
// room and its grants must be kept (social.js:4382 otherwise moves it to Past rooms).
const NOT_LEFT = new Set(['server_error', 'unreachable'])
const serverDown = (code, status) => Number(status) >= 500 || NOT_LEFT.has(code) || code.startsWith('server_')
const FACE_NOT_ANSWERING = 'Bot Social is not answering right now. Try again in a moment.'
export function alreadyLeftFace(error) {
const code = String(error?.code ?? '')
if (ALREADY_LEFT.has(code)) return true
if (serverDown(code, error?.status)) return false
return /not one of yours on Bot Social|no longer in this room|has closed|not available|no longer valid/i.test(String(error?.message ?? error ?? ''))
}

Expand Down Expand Up @@ -221,6 +227,7 @@
else if (status === 429) message = retryAfter != null
? `Too many attempts. Try again in ${formatRetryAfter(retryAfter)}.`
: 'Too many attempts. Try again in a few minutes.'
else if (serverDown(code, status)) message = FACE_NOT_ANSWERING
}
return new FaceRoomError(message, code || 'face_room_error', { retryAfter, scope: payload.scope, status, confirmCode })
}
Expand Down Expand Up @@ -464,7 +471,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 474 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 @@ -494,7 +501,7 @@
export function cleanFaceRole(value) {
if (typeof value !== 'string') return ''
return stripFaceMarkers(value)
.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ')

Check warning on line 504 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()
Expand Down Expand Up @@ -720,6 +727,17 @@
return body ? `@${body}` : ''
}

// fetch reports a reset syscall on `error.code` or on `error.cause.code`; TLS failures carry no
// code at all. Either way the join never reached Bot Social, so it is retryable, not an ending.
const DROPPED_CODES = new Set(['ECONNRESET', 'ENOTFOUND', 'EAI_AGAIN'])
function droppedConnection(error) {
for (const candidate of [error, error?.cause, error?.cause?.cause]) {
if (candidate && DROPPED_CODES.has(String(candidate.code ?? ''))) return true
}
const text = [error?.message, error?.cause?.message].filter(Boolean).join(' ')
return /ECONNRESET|ENOTFOUND|EAI_AGAIN|socket hang up|certificate|CERT_|SSL routines|TLS handshake|self[- ]signed/i.test(text)
}

export function createFaceRoomApi({ server, token, fetch: fetchImpl = socialFetch, timeoutMs = CALL_TIMEOUT_MS } = {}) {
if (!server) throw new Error('flybot-social: server URL is required')
if (!token) throw new Error('flybot-social: owner connector token is required')
Expand Down Expand Up @@ -758,6 +776,11 @@
if (signal.aborted || error?.name === 'TimeoutError' || error?.name === 'AbortError') {
throw new FaceRoomError(`Bot Social did not answer in ${Math.round(timeoutMs / 1000)}s`, 'timeout')
}
if (droppedConnection(error)) {
const wrapped = new FaceRoomError(FACE_NOT_ANSWERING, 'unreachable')
wrapped.cause = error
throw wrapped
}
throw error
}
if (!response.ok) throw mapFaceError(data, response.status)
Expand Down
52 changes: 52 additions & 0 deletions plugins/social/test/face-rooms.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,58 @@ test('error mapping covers the FACE_ROOMS.md codes', () => {
assert.equal(formatRetryAfter(60), '1 minute')
})

test('a 5xx at join says Bot Social is not answering, not "try another number"', () => {
const copy = 'Bot Social is not answering right now. Try again in a moment.'
for (const status of [500, 502, 503, 530]) {
const error = mapFaceError({}, status)
assert.equal(error.message, copy, `status ${status}`)
assert.equal(error.code, 'face_room_error', `status ${status}`)
assert.equal(error.status, status)
}
const server = mapFaceError({ error: { code: 'server_error', message: 'internal' } }, 502)
assert.equal(server.message, copy)
assert.equal(server.code, 'server_error')
assert.equal(server.status, 502)
// A server outage must never read like a room that is gone: social.js:4382 treats
// "already left" as a reason to move the room to Past rooms and drop every grant.
assert.doesNotMatch(mapFaceError({}, 500).message, /not one of yours on Bot Social|no longer in this room|has closed|not available|no longer valid/i)
assert.equal(alreadyLeftFace(mapFaceError({}, 500)), false)
assert.equal(alreadyLeftFace(mapFaceError({ error: { code: 'server_error' } }, 502)), false)
// The guard must also beat the message regex: a proxy 5xx can carry a room-gone line.
assert.equal(alreadyLeftFace(new FaceRoomError('You are no longer in this room.', 'proxy_bad_gateway', { status: 502 })), false)
assert.equal(alreadyLeftFace({ code: 'server_error', message: 'This room has closed.' }), false)
assert.equal(alreadyLeftFace(mapFaceError({ error: { code: 'room_closed' } }, 409)), true)
// 401 and 429 keep their own sentence, byte for byte.
assert.equal(mapFaceError(null, 401).message, 'Bot Social signed you out. Reconnect in Friends\' Bots, or restart FlyBot.')
assert.equal(mapFaceError(null, 429).message, 'Too many attempts. Try again in a few minutes.')
})

test('a dropped connection at join is unreachable, not a raw ECONNRESET', async () => {
const copy = 'Bot Social is not answering right now. Try again in a moment.'
for (const code of ['ECONNRESET', 'ENOTFOUND', 'EAI_AGAIN']) {
const failure = Object.assign(new Error('fetch failed'), { code, cause: Object.assign(new Error('read failed'), { code }) })
const api = createFaceRoomApi({ server: 'https://s', token: 't', fetch: async () => { throw failure } })
await assert.rejects(() => api.list(), (error) => {
assert.equal(error.code, 'unreachable', code)
assert.equal(error.message, copy, code)
assert.equal(error.cause, failure, `${code} keeps the original error reachable`)
return true
})
}
// fetch's own wrapper hides the syscall code on `cause`.
const wrapped = createFaceRoomApi({
server: 'https://s', token: 't',
fetch: async () => { throw new TypeError('fetch failed', { cause: Object.assign(new Error('read ECONNRESET'), { code: 'ECONNRESET' }) }) },
})
await assert.rejects(() => wrapped.list(), error => error.code === 'unreachable' && error.message === copy)
// A TLS failure carries no syscall code at all.
for (const message of ['unable to verify the first certificate', 'certificate has expired', 'socket hang up']) {
const tls = createFaceRoomApi({ server: 'https://s', token: 't', fetch: async () => { throw new Error(message) } })
await assert.rejects(() => tls.list(), error => error.code === 'unreachable' && error.message === copy, message)
}
assert.equal(alreadyLeftFace(new FaceRoomError(copy, 'unreachable')), false)
})

test('countdown copy for a candidate room', () => {
const now = Date.parse('2026-10-15T10:00:00.000Z')
assert.equal(countdownLabel('2026-10-15T10:08:00.000Z', now), '8:00 left to join')
Expand Down
Loading