From 76972df94463176f1a751bdb17a12a507f8dac3d Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Fri, 23 Oct 2026 19:11:32 +0800 Subject: [PATCH] fix(social): read an envelope body with "}" or an escaped quote The approval card found the JSON tail of a fenced envelope by counting braces, so a `}` inside the text ended the object early: a held FLYBOT-ROOM-SHARE line fell back to the generic sentence and lost the message. The scan now tracks strings and escapes, and a malformed face-room tail keeps its "To the room:" prefix off the card. The stale #258 comment about room lines keeping their hold now says what the branch does (#855). Docs-not-needed: the card's sentences and the public behaviour are unchanged; this reads envelope text correctly and fixes a stale comment. Fixes #855 --- .../855-envelope-tail-escapes.fixed.md | 1 + plugins/social/client.js | 21 +++++++-- plugins/social/src/social.js | 3 +- plugins/social/test/social.test.js | 46 +++++++++++++++++++ 4 files changed, 65 insertions(+), 6 deletions(-) create mode 100644 changelog.d/855-envelope-tail-escapes.fixed.md diff --git a/changelog.d/855-envelope-tail-escapes.fixed.md b/changelog.d/855-envelope-tail-escapes.fixed.md new file mode 100644 index 000000000..20d2f67b0 --- /dev/null +++ b/changelog.d/855-envelope-tail-escapes.fixed.md @@ -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)). diff --git a/plugins/social/client.js b/plugins/social/client.js index 424d12514..b3a9f2c4f 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -1048,15 +1048,26 @@ window.__ModuleLoader__.load({ 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 } @@ -1088,7 +1099,7 @@ window.__ModuleLoader__.load({ 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()) diff --git a/plugins/social/src/social.js b/plugins/social/src/social.js index 88698d6d7..29805777f 100644 --- a/plugins/social/src/social.js +++ b/plugins/social/src/social.js @@ -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 diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index 896c8f140..5795bff99 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -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 () => ({}))