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/855-envelope-tail-escapes.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A held group line now reads as that line even when its text carries a `}` or a quote: the approval card's envelope scan knows JSON strings and escapes, so it no longer stops inside the text and falls back to the generic `Group update for … in a group chat` sentence. A broken face-room tail also keeps its `To the room:` prefix off the card ([#855](https://github.com/ThinkFlowLab/FlyBot/issues/855)).
21 changes: 16 additions & 5 deletions plugins/social/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -610,7 +610,7 @@
function qrRsGenerator(degree) {
let poly = [1]
for (let i = 0; i < degree; i += 1) {
const next = new Array(poly.length + 1).fill(0)

Check warning on line 613 in plugins/social/client.js

View workflow job for this annotation

GitHub Actions / plugins (social)

unicorn(no-new-array)

Do not use `new Array(singleArgument)`.
for (let j = 0; j < poly.length; j += 1) {
next[j] ^= poly[j]
next[j + 1] ^= qrMul(poly[j], QR_GF_EXP[i])
Expand All @@ -621,7 +621,7 @@
}
function qrRsRemainder(bytes, degree) {
const gen = qrRsGenerator(degree)
const res = new Array(bytes.length + degree).fill(0)

Check warning on line 624 in plugins/social/client.js

View workflow job for this annotation

GitHub Actions / plugins (social)

unicorn(no-new-array)

Do not use `new Array(singleArgument)`.
for (let i = 0; i < bytes.length; i += 1) res[i] = bytes[i]
for (let i = 0; i < bytes.length; i += 1) {
const factor = res[i]
Expand Down Expand Up @@ -695,7 +695,7 @@
}
function qrSkeleton(version) {
const size = version * 4 + 17
const modules = Array.from({ length: size }, () => new Array(size).fill(0))

Check warning on line 698 in plugins/social/client.js

View workflow job for this annotation

GitHub Actions / plugins (social)

unicorn(no-new-array)

Do not use `new Array(singleArgument)`.
const fixed = Array.from({ length: size }, () => new Array(size).fill(false))
const set = (x, y, dark) => { modules[y][x] = dark ? 1 : 0; fixed[y][x] = true }
const finder = (cx, cy) => {
Expand Down Expand Up @@ -1048,15 +1048,26 @@
h('div', { className: 'fs-wait-code-digits', 'data-flybot': 'face-wait-code-digits' }, digits),
h(FaceQr, { code: digits, at: qrAt, offsetMs: qrOffsetMs }))
}
// The JSON tail of a fenced envelope, by brace matching so a `}` inside a string does not end it
// early. An absent or unparsable tail answers undefined and each caller keeps its own fallback.
// The JSON tail of a fenced envelope, by brace matching that knows strings and escapes, so a `}`
// inside a string — even one right after a `\"` — does not end the object early (#855). An absent
// or unparsable tail answers undefined and each caller keeps its own fallback.
function envelopeTail(raw, at) {
const start = raw.indexOf('{', at)
if (start < 0) return undefined
let depth = 0
let inString = false
let escaped = false
for (let i = start; i < raw.length; i += 1) {
if (raw[i] === '{') depth += 1
else if (raw[i] === '}') {
const ch = raw[i]
if (inString) {
if (escaped) escaped = false
else if (ch === '\\') escaped = true
else if (ch === '"') inString = false
continue
}
if (ch === '"') inString = true
else if (ch === '{') depth += 1
else if (ch === '}') {
depth -= 1
if (depth > 0) continue
try { return JSON.parse(raw.slice(start, i + 1)) } catch { return undefined }
Expand Down Expand Up @@ -1088,7 +1099,7 @@
const at = faceAt
if (at < 0) return stripHoldPrefix(raw)
const parsed = envelopeTail(raw, at)
if (parsed === undefined) return raw.slice(0, at).trim()
if (parsed === undefined) return stripHoldPrefix(raw.slice(0, at).trim())
const text = typeof parsed?.text === 'string' ? parsed.text.trim() : ''
if (text) return stripHoldPrefix(text)
return stripHoldPrefix(raw.slice(0, at).trim())
Expand Down
3 changes: 2 additions & 1 deletion plugins/social/src/social.js
Original file line number Diff line number Diff line change
Expand Up @@ -1470,7 +1470,8 @@ export function createSocial(service, config = {}, { client: given, api: givenAp
// The owner already approved this very send on a FlyBot card (the host says so, never the
// text): Bot Social's own hold on it would ask the same question a second time, on a card
// nobody sees in the Bot's chat, and the reply would never leave (#258). Room lines are
// different (the friend's owner decides those), so they keep their hold.
// different (the friend's owner decides those), so this branch leaves them alone; a blanket
// hold on a room's own envelope is cleared below by autoSendRoomEnvelope (#847).
if (msg.approved === true && msg.via !== 'room' && msg.via !== 'room-share') await approveTypedLine(sent)
// Auto-send (#417) let this line out without a FlyBot card, so Bot Social's own hold would
// park it with nobody left to decide it: the owner's switch already answered the same
Expand Down
46 changes: 46 additions & 0 deletions plugins/social/test/social.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3610,6 +3610,52 @@ test('#847: a room-share hold card says what the envelope does, never the envelo
assert.equal(draft(card(packed)), 'the group says FLYBOT-ROOM-SHARE is a marker')
})

test('#855: a "}" inside a room-share body is text, not the end of the object', () => {
const { walk, pluginOf } = loadSocialClient(async () => ({}))
const plugin = pluginOf([[], [], ''])
const card = (text) => walk(plugin.ChatApproval({
item: { id: 'apr-room', kind: 'outbound_message', from: 'Chief', toName: 'Chief (SelfTest B2)', text, summary: text },
canDecide: true,
}))
const draft = (tree) => tree.find(n => n.props.className === 'fs-draft')?.children.join('')
const line = (text) => `FLYBOT-ROOM-SHARE\n${JSON.stringify({ v: 1, op: 'line', roomId: 'session-e2439f82', name: 'You, Chief, Chief', text, seq: 3 })}`

assert.equal(draft(card(line('a } b'))), 'Group message to Chief (SelfTest B2): a } b')

// An absent, truncated or unparsable tail still answers a plain sentence and never throws.
assert.equal(draft(card('FLYBOT-ROOM-SHARE')), 'Group update for Chief (SelfTest B2) in a group chat')
assert.equal(draft(card('FLYBOT-ROOM-SHARE\n{"v":1,"op":"line"')), 'Group update for Chief (SelfTest B2) in a group chat')
})

test('#855: an escaped quote in a room-share body does not swallow the closing brace', () => {
const { walk, pluginOf } = loadSocialClient(async () => ({}))
const plugin = pluginOf([[], [], ''])
const card = (text) => walk(plugin.ChatApproval({
item: { id: 'apr-room', kind: 'outbound_message', from: 'Chief', toName: 'Chief (SelfTest B2)', text, summary: text },
canDecide: true,
}))
const draft = (tree) => tree.find(n => n.props.className === 'fs-draft')?.children.join('')
// One quote in the text reaches the wire as `\"`: read as the end of the string, the scan would
// then treat the object's own `}` as text and answer nothing.
const line = `FLYBOT-ROOM-SHARE\n${JSON.stringify({ v: 1, op: 'line', roomId: 'session-e2439f82', name: 'You, Chief, Chief', text: 'say "hi', seq: 3 })}`
assert.equal(draft(card(line)), 'Group message to Chief (SelfTest B2): say "hi')
})

test('#855: a malformed face envelope still drops its "To the room:" prefix', () => {
const { walk, pluginOf } = loadSocialClient(async () => ({}))
const plugin = pluginOf([[], [], ''])
const card = (text) => walk(plugin.ChatApproval({
item: { id: 'apr-face', kind: 'face_room_message', from: 'You', toName: 'the room', text, summary: text },
canDecide: true,
}))
const draft = (tree) => tree.find(n => n.props.className === 'fs-draft')?.children.join('')

assert.equal(draft(card('To the room: hello FLYBOT-FACE-HUMAN\n{not json')), 'hello')
assert.equal(draft(card('To face room: hello FLYBOT-FACE-HUMAN')), 'hello')
// Same contract as above: a tail that does not parse is a sentence, not an exception.
assert.equal(draft(card('To the room: hello')), 'hello')
})

test('#159: a face-room hold never shows the FLYBOT-FACE-HUMAN envelope', () => {
const packed = encodeFaceHuman({ who: 'BoxA', text: 'hello from the owner' })
const { walk, pluginOf } = loadSocialClient(async () => ({}))
Expand Down
Loading