diff --git a/changelog.d/1950-seat-line-text-wf.fixed.md b/changelog.d/1950-seat-line-text-wf.fixed.md
new file mode 100644
index 000000000..2f54d52d8
--- /dev/null
+++ b/changelog.d/1950-seat-line-text-wf.fixed.md
@@ -0,0 +1 @@
+A line that a remote seat writes into a room of this home is now made well formed before it is used: the stored room line, the line the pane draws and the text handed to the Bot the line calls all carry the replacement character instead of half of a surrogate pair that would not survive a UTF-8 round trip. The line is still accepted and still calls that Bot, and a body that is already well formed — a real emoji included — is passed through exactly as it arrived ([#1950](https://github.com/ThinkFlowLab/FlyBot/issues/1950)).
diff --git a/core/src/host/room-membership.js b/core/src/host/room-membership.js
index 77e2bc97e..29bbe0dda 100644
--- a/core/src/host/room-membership.js
+++ b/core/src/host/room-membership.js
@@ -1980,6 +1980,10 @@ export function install(rt) {
}
if (data.op !== 'line') return { handled: true, room }
const who = cleanPeerName(data.who, remoteBotTitle(seat))
+ // The seat path carries a peer's body exactly as the projected-mirror branch does (#1943):
+ // JSON.parse hands back an unpaired surrogate escape as half a character, and this body is
+ // stored, drawn and handed to a model. Repair it once, where the line is read.
+ const text = typeof data.text === 'string' ? data.text.toWellFormed() : (data.text || '')
const hop = Number.isFinite(Number(data.hop)) ? Math.max(0, Math.floor(Number(data.hop))) : 0
const human = data.authorKind !== 'bot'
// #904: this path never reads the envelope's `authorId`: `ownSeatOf` matched the seat by the
@@ -1987,7 +1991,7 @@ export function install(rt) {
// claim inside the envelope is only the guest's word. A guest cannot speak as another Bot here.
const posted = append(room, rt.authoredRoomLine(room, {
who,
- text: data.text || '',
+ text,
// A guest person's id is theirs, not this home's: it is kept apart from the members here.
authorId: human ? `${seat.handle}#${String(data.authorId || 'person').slice(0, 40)}` : seat.handle,
authorKind: human ? 'human' : 'bot',
@@ -2000,7 +2004,7 @@ export function install(rt) {
}))
await showShareLine(room, {
who,
- text: data.text || '',
+ text,
remoteSeq: data.seq,
via: seat.handle,
senderSessionId: seat.handle,
@@ -2019,7 +2023,7 @@ export function install(rt) {
if (room.answered.includes(key)) continue
room.answered = [...room.answered, key].slice(-ANSWERED_KEPT)
called.push(botId)
- void Promise.resolve(rt.startInboundTurn({ room, botId, seq: posted.seq, hop, who, text: data.text || '' }))
+ void Promise.resolve(rt.startInboundTurn({ room, botId, seq: posted.seq, hop, who, text }))
.catch(error => rt.warn?.('ds-bot: a guest\'s call in %s failed: %s', room.name, error))
}
}
diff --git a/core/test/remote-room.test.js b/core/test/remote-room.test.js
index 8ccc6a7f9..abb53720c 100644
--- a/core/test/remote-room.test.js
+++ b/core/test/remote-room.test.js
@@ -2929,3 +2929,54 @@ test('an inbound line body that arrives ill formed is made well formed where it
assert.equal(shown.at(-1).text, 'hi \u{1F600} there')
assert.equal(turns.at(-1).text, 'hi \u{1F600} there', 'a real pair is not replaced')
})
+
+// The other way a peer's text enters this home (#884): a remote seat writes a line into a room of
+// mine, and `ingestSeatShare` took `data.text` exactly as the envelope carried it — to the log, to the
+// pane and to the Bot the line calls. #1943 repaired the projected-mirror branch alone; this is the
+// sibling path, where the same string JSON accepts but does not make well formed arrives intact.
+test('a seat line body that arrives ill formed is made well formed where it is used', async (t) => {
+ const { rt } = host(t, TEAM)
+ await rt.load()
+ const { alice } = await signAliceAndBob(rt)
+ const actorA = await rt.authenticate(alice.sessionToken)
+ publish(rt)
+ await grantToFriend(rt, 'chief-a')
+ await grantToFriend(rt, 'chief-b')
+ await rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: '@coder.bob.home' }, actorA)
+ const shown = []
+ rt.showSharedLine = async (room, spec) => { shown.push({ roomId: room.id, ...spec }) }
+ const turns = []
+ rt.startInboundTurn = async args => { turns.push(args) }
+ const line = (seq, extra = {}) => 'FLYBOT-ROOM-SHARE\n' + JSON.stringify({
+ v: 1, op: 'line', roomId: 'war', seq, who: 'W\ud83dho', authorKind: 'human', authorId: 'alice',
+ text: 'seat \ud83d body', calls: ['chief-a'], ...extra,
+ })
+ const out = await rt.applyInboundRoomShare({ fromHandle: '@coder.bob.home', fromHome: '@bob.home', fromName: 'Coder', text: line(5) }, actorA)
+ assert.equal(out.handled, true, 'an ill-formed body is repaired, not refused')
+ assert.equal('refused' in out, false, 'the line is not refused')
+ assert.deepEqual(out.called, ['chief-a'], 'the line still calls the Bot it names')
+
+ const stored = rt.roomHistory('war', actorA).at(-1)
+ assert.ok(stored, 'the line is stored')
+ assert.equal(stored.text.isWellFormed(), true, `the stored row is not well formed: ${JSON.stringify(stored.text)}`)
+ assert.equal(stored.text.includes('seat \uFFFD body'), true, 'the stored body carries one U+FFFD for the lone half')
+ assert.equal(stored.who, 'W\uFFFDho', 'the author was already cleaned, in the same shape')
+
+ assert.equal(shown.length, 1, 'the pane is drawn once')
+ assert.equal(shown[0].text, 'seat \uFFFD body')
+ assert.equal(shown[0].text.isWellFormed(), true, `the pane is not well formed: ${JSON.stringify(shown[0].text)}`)
+
+ assert.equal(turns.length, 1, 'the named Bot is called')
+ assert.equal(turns[0].botId, 'chief-a')
+ assert.equal(turns[0].text, 'seat \uFFFD body')
+ assert.equal(turns[0].text.isWellFormed(), true, `the turn is not well formed: ${JSON.stringify(turns[0].text)}`)
+ assert.equal(turns[0].text.length, 'seat \ud83d body'.length, 'one unit in, one U+FFFD out: the length does not move')
+
+ // A body that is already well formed is passed through untouched, and a real pair keeps its code point.
+ const ok = await rt.applyInboundRoomShare({ fromHandle: '@coder.bob.home', fromHome: '@bob.home', fromName: 'Coder', text: line(6, { who: 'Ann', text: 'hi \u{1F600} there' }) }, actorA)
+ assert.equal(ok.handled, true)
+ const kept = rt.roomHistory('war', actorA).at(-1)
+ assert.equal(kept.text, '\nhi \u{1F600} there\n', 'a well-formed body is stored exactly as it arrived')
+ assert.equal(shown.at(-1).text, 'hi \u{1F600} there')
+ assert.equal(turns.at(-1).text, 'hi \u{1F600} there', 'a real pair is not replaced')
+})
diff --git a/patches/ds-bot/files/src/host/room-membership.js b/patches/ds-bot/files/src/host/room-membership.js
index 77e2bc97e..29bbe0dda 100644
--- a/patches/ds-bot/files/src/host/room-membership.js
+++ b/patches/ds-bot/files/src/host/room-membership.js
@@ -1980,6 +1980,10 @@ export function install(rt) {
}
if (data.op !== 'line') return { handled: true, room }
const who = cleanPeerName(data.who, remoteBotTitle(seat))
+ // The seat path carries a peer's body exactly as the projected-mirror branch does (#1943):
+ // JSON.parse hands back an unpaired surrogate escape as half a character, and this body is
+ // stored, drawn and handed to a model. Repair it once, where the line is read.
+ const text = typeof data.text === 'string' ? data.text.toWellFormed() : (data.text || '')
const hop = Number.isFinite(Number(data.hop)) ? Math.max(0, Math.floor(Number(data.hop))) : 0
const human = data.authorKind !== 'bot'
// #904: this path never reads the envelope's `authorId`: `ownSeatOf` matched the seat by the
@@ -1987,7 +1991,7 @@ export function install(rt) {
// claim inside the envelope is only the guest's word. A guest cannot speak as another Bot here.
const posted = append(room, rt.authoredRoomLine(room, {
who,
- text: data.text || '',
+ text,
// A guest person's id is theirs, not this home's: it is kept apart from the members here.
authorId: human ? `${seat.handle}#${String(data.authorId || 'person').slice(0, 40)}` : seat.handle,
authorKind: human ? 'human' : 'bot',
@@ -2000,7 +2004,7 @@ export function install(rt) {
}))
await showShareLine(room, {
who,
- text: data.text || '',
+ text,
remoteSeq: data.seq,
via: seat.handle,
senderSessionId: seat.handle,
@@ -2019,7 +2023,7 @@ export function install(rt) {
if (room.answered.includes(key)) continue
room.answered = [...room.answered, key].slice(-ANSWERED_KEPT)
called.push(botId)
- void Promise.resolve(rt.startInboundTurn({ room, botId, seq: posted.seq, hop, who, text: data.text || '' }))
+ void Promise.resolve(rt.startInboundTurn({ room, botId, seq: posted.seq, hop, who, text }))
.catch(error => rt.warn?.('ds-bot: a guest\'s call in %s failed: %s', room.name, error))
}
}
diff --git a/patches/ds-bot/files/test/remote-room.test.js b/patches/ds-bot/files/test/remote-room.test.js
index 8ccc6a7f9..abb53720c 100644
--- a/patches/ds-bot/files/test/remote-room.test.js
+++ b/patches/ds-bot/files/test/remote-room.test.js
@@ -2929,3 +2929,54 @@ test('an inbound line body that arrives ill formed is made well formed where it
assert.equal(shown.at(-1).text, 'hi \u{1F600} there')
assert.equal(turns.at(-1).text, 'hi \u{1F600} there', 'a real pair is not replaced')
})
+
+// The other way a peer's text enters this home (#884): a remote seat writes a line into a room of
+// mine, and `ingestSeatShare` took `data.text` exactly as the envelope carried it — to the log, to the
+// pane and to the Bot the line calls. #1943 repaired the projected-mirror branch alone; this is the
+// sibling path, where the same string JSON accepts but does not make well formed arrives intact.
+test('a seat line body that arrives ill formed is made well formed where it is used', async (t) => {
+ const { rt } = host(t, TEAM)
+ await rt.load()
+ const { alice } = await signAliceAndBob(rt)
+ const actorA = await rt.authenticate(alice.sessionToken)
+ publish(rt)
+ await grantToFriend(rt, 'chief-a')
+ await grantToFriend(rt, 'chief-b')
+ await rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: '@coder.bob.home' }, actorA)
+ const shown = []
+ rt.showSharedLine = async (room, spec) => { shown.push({ roomId: room.id, ...spec }) }
+ const turns = []
+ rt.startInboundTurn = async args => { turns.push(args) }
+ const line = (seq, extra = {}) => 'FLYBOT-ROOM-SHARE\n' + JSON.stringify({
+ v: 1, op: 'line', roomId: 'war', seq, who: 'W\ud83dho', authorKind: 'human', authorId: 'alice',
+ text: 'seat \ud83d body', calls: ['chief-a'], ...extra,
+ })
+ const out = await rt.applyInboundRoomShare({ fromHandle: '@coder.bob.home', fromHome: '@bob.home', fromName: 'Coder', text: line(5) }, actorA)
+ assert.equal(out.handled, true, 'an ill-formed body is repaired, not refused')
+ assert.equal('refused' in out, false, 'the line is not refused')
+ assert.deepEqual(out.called, ['chief-a'], 'the line still calls the Bot it names')
+
+ const stored = rt.roomHistory('war', actorA).at(-1)
+ assert.ok(stored, 'the line is stored')
+ assert.equal(stored.text.isWellFormed(), true, `the stored row is not well formed: ${JSON.stringify(stored.text)}`)
+ assert.equal(stored.text.includes('seat \uFFFD body'), true, 'the stored body carries one U+FFFD for the lone half')
+ assert.equal(stored.who, 'W\uFFFDho', 'the author was already cleaned, in the same shape')
+
+ assert.equal(shown.length, 1, 'the pane is drawn once')
+ assert.equal(shown[0].text, 'seat \uFFFD body')
+ assert.equal(shown[0].text.isWellFormed(), true, `the pane is not well formed: ${JSON.stringify(shown[0].text)}`)
+
+ assert.equal(turns.length, 1, 'the named Bot is called')
+ assert.equal(turns[0].botId, 'chief-a')
+ assert.equal(turns[0].text, 'seat \uFFFD body')
+ assert.equal(turns[0].text.isWellFormed(), true, `the turn is not well formed: ${JSON.stringify(turns[0].text)}`)
+ assert.equal(turns[0].text.length, 'seat \ud83d body'.length, 'one unit in, one U+FFFD out: the length does not move')
+
+ // A body that is already well formed is passed through untouched, and a real pair keeps its code point.
+ const ok = await rt.applyInboundRoomShare({ fromHandle: '@coder.bob.home', fromHome: '@bob.home', fromName: 'Coder', text: line(6, { who: 'Ann', text: 'hi \u{1F600} there' }) }, actorA)
+ assert.equal(ok.handled, true)
+ const kept = rt.roomHistory('war', actorA).at(-1)
+ assert.equal(kept.text, '\nhi \u{1F600} there\n', 'a well-formed body is stored exactly as it arrived')
+ assert.equal(shown.at(-1).text, 'hi \u{1F600} there')
+ assert.equal(turns.at(-1).text, 'hi \u{1F600} there', 'a real pair is not replaced')
+})