switchroom 0.21.0 → 0.21.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,526 @@
1
+ /**
2
+ * Card-body capture — wire-level outcome tests (#4576 follow-up).
3
+ *
4
+ * The defect being pinned: #4576 landed the card-history lane, and every
5
+ * `role='system'` row it wrote across the whole fleet had `length(text) = 0`.
6
+ * The lane's own tests passed because their fixtures hand-built a Telegram
7
+ * `Message` with a `text` field — a shape no card send has EVER produced. Every
8
+ * gateway card goes out via Bot API 10.1 `sendRichMessage`, whose response is a
9
+ * `Message.RichMessageMessage`: body under `rich_message.blocks`, no `text`, no
10
+ * `caption`. `extractSentMessage` read `msg.text ?? msg.caption ?? ''` and therefore
11
+ * resolved `''` 100% of the time, and a quote-reply to a card gave the agent the
12
+ * card's KIND but never its BODY.
13
+ *
14
+ * So these tests deliberately do NOT hand-build a response. They drive a REAL
15
+ * grammy `Bot` with the production transformer stack and a stubbed transport
16
+ * that answers with the REAL rich-message response shape, then assert the body
17
+ * survives to the history writer. A test that builds its own `{ text }` fixture
18
+ * cannot fail on this bug and is not a test of it.
19
+ *
20
+ * Equally: a test that hand-builds its own `{ markdown }` PAYLOAD cannot fail
21
+ * on the escaping bug. The production caller is `richMessage(body)`, which runs
22
+ * `guardAccidentalFormatting` in the CALLER — before any transformer can see it
23
+ * — so every send here goes THROUGH `richMessage()`, never around it.
24
+ */
25
+ import { describe, it, expect } from 'vitest'
26
+ import { readFileSync } from 'node:fs'
27
+ import { fileURLToPath } from 'node:url'
28
+ import { dirname, resolve } from 'node:path'
29
+ import ts from 'typescript'
30
+ import { Bot } from 'grammy'
31
+ import { installTgPostLogger, installRichMarkdownGuard } from '../shared/bot-runtime.js'
32
+ import {
33
+ installSentTextCapture,
34
+ outboundPayloadText,
35
+ readSentText,
36
+ attachSentText,
37
+ } from '../shared/sent-text-capture.js'
38
+ import { richMessage } from '../rich-send.js'
39
+ import { makeSystemMessageObserver, extractSentMessage } from '../gateway/system-message-observer.js'
40
+
41
+ const CHAT = 5550001
42
+
43
+ /**
44
+ * The response Telegram actually returns for `sendRichMessage` — the body lives
45
+ * in `rich_message.blocks`; `text` and `caption` are ABSENT. Verified against
46
+ * `@grammyjs/types` 3.28.0 (the version `grammy@^1.44` resolves)
47
+ * `message.d.ts:94` (`RichMessageMessage = CommonMessage &
48
+ * MsgWith<"rich_message">`) and `:180` (`rich_message?: RichMessage`), and
49
+ * against the Bot API reference: `sendRichMessage` "On success, the sent
50
+ * Message is returned", `Message.rich_message: RichMessage` "Optional. Message
51
+ * is a rich formatted message", `RichMessage.blocks` "Content of the message".
52
+ */
53
+ function richMessageResponse(messageId: number, rendered: string) {
54
+ return {
55
+ message_id: messageId,
56
+ date: 0,
57
+ chat: { id: CHAT, type: 'private' },
58
+ rich_message: { blocks: [{ type: 'paragraph', text: { text: rendered } }] },
59
+ }
60
+ }
61
+
62
+ /** A plain `sendMessage` response, for the verbs that don't go rich. */
63
+ function plainMessageResponse(messageId: number, text: string) {
64
+ return { message_id: messageId, date: 0, chat: { id: CHAT, type: 'private' }, text }
65
+ }
66
+
67
+ /**
68
+ * A real grammy Bot wired with the PRODUCTION transformer stack in the
69
+ * production order (logger → fmt guard → text capture), transport stubbed. The
70
+ * stub answers each method from `respond`, so a test can return the true
71
+ * rich-message shape rather than inventing one.
72
+ */
73
+ function makeBot(respond: (method: string, body: Record<string, unknown>) => unknown) {
74
+ const calls: { method: string; body: Record<string, unknown> }[] = []
75
+ const fakeFetch = (async (url: unknown, init?: { body?: unknown }) => {
76
+ const method = String(url).split('/').pop() ?? ''
77
+ const body =
78
+ typeof init?.body === 'string' ? (JSON.parse(init.body) as Record<string, unknown>) : {}
79
+ calls.push({ method, body })
80
+ const result = respond(method, body)
81
+ return {
82
+ ok: true,
83
+ status: 200,
84
+ json: async () => ({ ok: true, result }),
85
+ } as unknown as Response
86
+ }) as unknown as typeof fetch
87
+
88
+ const bot = new Bot('123456:TEST_TOKEN', {
89
+ botInfo: {
90
+ id: 123456,
91
+ is_bot: true,
92
+ first_name: 'Test',
93
+ username: 'test_bot',
94
+ can_join_groups: false,
95
+ can_read_all_group_messages: false,
96
+ supports_inline_queries: false,
97
+ can_connect_to_business: false,
98
+ has_main_web_app: false,
99
+ },
100
+ client: { fetch: fakeFetch },
101
+ })
102
+ installTgPostLogger(bot)
103
+ installRichMarkdownGuard(bot)
104
+ installSentTextCapture(bot)
105
+ return { bot, calls }
106
+ }
107
+
108
+ describe('outboundPayloadText — the body a request is about to POST', () => {
109
+ it('reads the rich-message markdown (the shape every card send uses)', () => {
110
+ expect(outboundPayloadText({ chat_id: 1, rich_message: { markdown: '**Working**' } })).toBe(
111
+ '**Working**',
112
+ )
113
+ })
114
+
115
+ it('reads html and flattens blocks for rich payloads that are not markdown', () => {
116
+ expect(outboundPayloadText({ rich_message: { html: '<b>hi</b>' } })).toBe('<b>hi</b>')
117
+ expect(
118
+ outboundPayloadText({
119
+ rich_message: { blocks: [{ text: 'top' }, { blocks: [{ text: 'nested' }] }] },
120
+ }),
121
+ ).toBe('top\nnested')
122
+ })
123
+
124
+ it('reads plain text and media captions', () => {
125
+ expect(outboundPayloadText({ text: 'restarting…' })).toBe('restarting…')
126
+ expect(outboundPayloadText({ caption: 'chart for July' })).toBe('chart for July')
127
+ })
128
+
129
+ it('returns null for the bodiless verbs (pin/delete/reaction/getUpdates)', () => {
130
+ expect(outboundPayloadText({ chat_id: 1, message_id: 2 })).toBeNull()
131
+ expect(outboundPayloadText(undefined)).toBeNull()
132
+ expect(outboundPayloadText('nonsense')).toBeNull()
133
+ })
134
+ })
135
+
136
+ describe('attachSentText / readSentText', () => {
137
+ it('stamps the Message inside an ok envelope, invisibly to every normal reader', () => {
138
+ const env = { ok: true, result: { message_id: 7, chat: { id: CHAT } } }
139
+ attachSentText(env, 'the card body')
140
+ expect(readSentText(env.result)).toBe('the card body')
141
+ // Invisible: no new enumerable key, JSON round-trip unchanged.
142
+ expect(Object.keys(env.result)).toEqual(['message_id', 'chat'])
143
+ expect(JSON.parse(JSON.stringify(env.result))).toEqual({ message_id: 7, chat: { id: CHAT } })
144
+ })
145
+
146
+ it('no-ops on the non-Message results a transformer also sees', () => {
147
+ expect(() => attachSentText({ ok: true, result: true }, 'x')).not.toThrow()
148
+ expect(() => attachSentText({ ok: false, error_code: 400 }, 'x')).not.toThrow()
149
+ expect(() => attachSentText(undefined, 'x')).not.toThrow()
150
+ expect(readSentText(true)).toBeNull()
151
+ expect(readSentText({ message_id: 1 })).toBeNull()
152
+ })
153
+ })
154
+
155
+ describe('the card body survives the real send path, for every verb a card uses', () => {
156
+ // Each row is a production card send verb reduced to the API call it makes.
157
+ // `expected` is the body the history row MUST end up carrying — Telegram's
158
+ // RENDERED form, which is what the response echoes and what the operator saw.
159
+ const cases: {
160
+ name: string
161
+ expected: string
162
+ send: (bot: Bot) => Promise<unknown>
163
+ respond: (method: string) => unknown
164
+ }[] = [
165
+ {
166
+ name: 'activity-summary.send / boot-card / issues-card / worker-feed (sendRichMessage)',
167
+ expected: '⚙️ Working — reading history.ts, 3 tools',
168
+ send: (bot) =>
169
+ bot.api.sendRichMessage(CHAT, richMessage('⚙️ Working — reading history.ts, 3 tools')),
170
+ respond: () => richMessageResponse(20395, '⚙️ Working — reading history.ts, 3 tools'),
171
+ },
172
+ {
173
+ name: 'activity-summary.edit / rollout-status-edit (rich editMessageText)',
174
+ expected: '⚙️ Working — 11 tools, editing gateway.ts',
175
+ send: (bot) =>
176
+ bot.api.editMessageText(CHAT, 20395, richMessage('⚙️ Working — 11 tools, editing gateway.ts')),
177
+ respond: () => richMessageResponse(20395, '⚙️ Working — 11 tools, editing gateway.ts'),
178
+ },
179
+ {
180
+ // The one row where the sent markdown and the rendered response DIVERGE.
181
+ // The stored antecedent is the rendered form: `**Approve**` reaches the
182
+ // operator's screen as bold "Approve", and that is what they quoted.
183
+ name: 'permission_request (sendRichMessage with an inline keyboard)',
184
+ expected: 'Approve docker restart switchroom-clerk?',
185
+ send: (bot) =>
186
+ bot.api.sendRichMessage(CHAT, richMessage('**Approve** `docker restart switchroom-clerk`?'), {
187
+ reply_markup: { inline_keyboard: [[{ text: 'Approve', callback_data: 'ok' }]] },
188
+ }),
189
+ respond: () => richMessageResponse(20406, 'Approve docker restart switchroom-clerk?'),
190
+ },
191
+ {
192
+ name: 'privacy-reset-alert / restart notices (plain sendMessage)',
193
+ expected: 'Restarting — back in a moment.',
194
+ send: (bot) => bot.api.sendMessage(CHAT, 'Restarting — back in a moment.'),
195
+ respond: () => plainMessageResponse(20410, 'Restarting — back in a moment.'),
196
+ },
197
+ {
198
+ name: 'a media card (sendPhoto caption)',
199
+ expected: 'fleet quota, last 24h',
200
+ send: (bot) => bot.api.sendPhoto(CHAT, 'file-id', { caption: 'fleet quota, last 24h' }),
201
+ respond: () => ({
202
+ message_id: 20411,
203
+ date: 0,
204
+ chat: { id: CHAT, type: 'private' },
205
+ photo: [{ file_id: 'file-id', file_unique_id: 'u', width: 1, height: 1 }],
206
+ caption: 'fleet quota, last 24h',
207
+ }),
208
+ },
209
+ ]
210
+
211
+ for (const c of cases) {
212
+ it(`${c.name} → the observer records a NON-EMPTY body`, async () => {
213
+ const { bot } = makeBot(c.respond)
214
+ const result = await c.send(bot)
215
+
216
+ // The outcome that #4576 got wrong: the extractor yields the real body.
217
+ const extracted = extractSentMessage(result, { chat_id: String(CHAT) })
218
+ expect(extracted).not.toBeNull()
219
+ expect(extracted!.text).toBe(c.expected)
220
+ expect(extracted!.text.length).toBeGreaterThan(0)
221
+
222
+ // …and it reaches the history writer that way.
223
+ const written: { text: string; kind: string | null }[] = []
224
+ const observe = makeSystemMessageObserver({
225
+ insert: (a) => {
226
+ written.push({ text: a.text, kind: a.kind })
227
+ return true
228
+ },
229
+ updateText: () => true,
230
+ })
231
+ observe(result, { chat_id: String(CHAT), verb: 'activity-summary.send' })
232
+ expect(written).toHaveLength(1)
233
+ expect(written[0].text).toBe(c.expected)
234
+ })
235
+ }
236
+
237
+ /**
238
+ * The fidelity property, asserted through the REAL caller.
239
+ *
240
+ * `richMessage()` runs `guardAccidentalFormatting` in the CALLER, upstream of
241
+ * every transformer, so the request-side stamp on this path is the ESCAPED
242
+ * wire form and there is no seam that could make it otherwise. A test that
243
+ * hand-builds `{ markdown: '…' }` bypasses that and passes even when the
244
+ * stored body is `sent\_text\_capture.ts`. These send through `richMessage()`
245
+ * and assert the STORED body is the clean one — which is only true because
246
+ * the observer prefers the response's rendered `rich_message`.
247
+ */
248
+ const escaping: { body: string; wire: string }[] = [
249
+ { body: '⚙️ Working — editing sent_text_capture.ts', wire: '⚙️ Working — editing sent\\_text\\_capture.ts' },
250
+ { body: '💸 Spend today: $12.40 (~$0.50/turn)', wire: '💸 Spend today: \\$12.40 (~\\$0.50/turn)' },
251
+ { body: '#4576 landed', wire: '\\#4576 landed' },
252
+ ]
253
+ for (const { body, wire } of escaping) {
254
+ it(`stores ${JSON.stringify(body)} unescaped, though the wire carries ${JSON.stringify(wire)}`, async () => {
255
+ const { bot, calls } = makeBot(() => richMessageResponse(4576, body))
256
+ // The production call, verbatim: richMessage() escapes, then we send.
257
+ const result = await bot.api.sendRichMessage(CHAT, richMessage(body))
258
+
259
+ // Pin the premise: the wire really is escaped, so the request-side stamp
260
+ // cannot be the source of a clean stored body.
261
+ expect((calls[calls.length - 1].body.rich_message as { markdown: string }).markdown).toBe(wire)
262
+ expect(readSentText(result)).toBe(wire)
263
+
264
+ // The outcome: what lands in history.db round-trips unescaped.
265
+ const written: string[] = []
266
+ const observe = makeSystemMessageObserver({
267
+ insert: (a) => {
268
+ written.push(a.text)
269
+ return true
270
+ },
271
+ updateText: () => true,
272
+ })
273
+ observe(result, { chat_id: String(CHAT), verb: 'activity-summary.send' })
274
+ expect(written).toEqual([body])
275
+ })
276
+ }
277
+
278
+ it('stamps nothing on the bodiless verbs, so the observer still ignores them', async () => {
279
+ const { bot } = makeBot(() => true)
280
+ const pinned = await bot.api.pinChatMessage(CHAT, 42)
281
+ expect(pinned).toBe(true)
282
+ expect(extractSentMessage(pinned, { chat_id: String(CHAT) })).toBeNull()
283
+ })
284
+ })
285
+
286
+ describe('the request-side stamp is the FALLBACK tier, never the preferred one', () => {
287
+ it('a response whose rich blocks render to nothing falls back to the stamped body', async () => {
288
+ // A media-only card: `rich_message` is present but flattens to '', and
289
+ // there is no `text` / `caption`. Without the stamp this row would be
290
+ // empty; with it, the escaped-but-readable request body is stored.
291
+ const { bot } = makeBot(() => ({
292
+ message_id: 20500,
293
+ date: 0,
294
+ chat: { id: CHAT, type: 'private' },
295
+ rich_message: { blocks: [{ type: 'anchor', name: 'top' }] },
296
+ }))
297
+ const result = await bot.api.sendRichMessage(CHAT, richMessage('fleet quota chart'))
298
+ expect(extractSentMessage(result, { chat_id: String(CHAT) })?.text).toBe('fleet quota chart')
299
+ })
300
+
301
+ it('the response wins whenever it has a body, even though a stamp is present', async () => {
302
+ const { bot } = makeBot(() => richMessageResponse(20501, 'rendered by Telegram'))
303
+ const result = await bot.api.sendRichMessage(CHAT, richMessage('written by the caller'))
304
+ expect(readSentText(result)).toBe('written by the caller')
305
+ expect(extractSentMessage(result, { chat_id: String(CHAT) })?.text).toBe('rendered by Telegram')
306
+ })
307
+ })
308
+
309
+ describe('regression: the pre-fix response shape', () => {
310
+ it('a rich-message response with NO capture stamp still yields a body, not ""', () => {
311
+ // Belt-and-braces layer 2: a Message that never transited a capture-installed
312
+ // bot (a second Bot instance, a replayed response) falls back to flattening
313
+ // the echoed `rich_message` rather than storing an empty row.
314
+ const raw = richMessageResponse(20395, '⚙️ Working — 3 tools')
315
+ expect(readSentText(raw)).toBeNull()
316
+ expect(extractSentMessage(raw, { chat_id: String(CHAT) })?.text).toBe('⚙️ Working — 3 tools')
317
+ })
318
+
319
+ it('an unrecognised bodiless shape fires the empty-text alarm exactly once per kind', () => {
320
+ const alarms: { kind: string | null }[] = []
321
+ const observe = makeSystemMessageObserver({
322
+ insert: () => true,
323
+ updateText: () => true,
324
+ onEmptyText: (i) => alarms.push({ kind: i.kind }),
325
+ })
326
+ for (const id of [1, 2, 3]) {
327
+ observe({ message_id: id, chat: { id: CHAT } }, { chat_id: String(CHAT), verb: 'mystery-card' })
328
+ }
329
+ observe({ message_id: 9, chat: { id: CHAT } }, { chat_id: String(CHAT), verb: 'other-card' })
330
+ expect(alarms).toEqual([{ kind: 'mystery-card' }, { kind: 'other-card' }])
331
+ })
332
+
333
+ it('an empty refresh never blanks a body that was already stored', () => {
334
+ const rows = new Map<number, string>()
335
+ let now = 1_000
336
+ const observe = makeSystemMessageObserver({
337
+ insert: (a) => {
338
+ rows.set(a.message_id, a.text)
339
+ return true
340
+ },
341
+ updateText: (a) => {
342
+ rows.set(a.message_id, a.text)
343
+ return true
344
+ },
345
+ now: () => now,
346
+ })
347
+ observe(
348
+ { message_id: 5, chat: { id: CHAT }, rich_message: { blocks: [{ text: { text: 'card v1' } }] } },
349
+ { chat_id: String(CHAT), verb: 'activity-summary.send' },
350
+ )
351
+ expect(rows.get(5)).toBe('card v1')
352
+
353
+ now += 10 * 60_000
354
+ // A later edit whose body did not reach us for any reason.
355
+ observe({ message_id: 5, chat: { id: CHAT } }, { chat_id: String(CHAT), verb: 'activity-summary.edit' })
356
+ expect(rows.get(5)).toBe('card v1')
357
+ })
358
+
359
+ it('the edit throttle NEVER pins an empty row empty — the first real body always lands', () => {
360
+ // The dual of the rule above, and the gap that kept the #4576 symptom alive
361
+ // on a row the once-per-kind alarm had already stopped reporting: a bodiless
362
+ // FIRST observation inserts '' and starts the 20s throttle clock, so the
363
+ // real body arriving 3s later was dropped and the row stayed unusable.
364
+ const rows = new Map<number, string>()
365
+ let now = 1_000
366
+ const observe = makeSystemMessageObserver({
367
+ insert: (a) => {
368
+ rows.set(a.message_id, a.text)
369
+ return true
370
+ },
371
+ updateText: (a) => {
372
+ rows.set(a.message_id, a.text)
373
+ return true
374
+ },
375
+ now: () => now,
376
+ onEmptyText: () => {},
377
+ })
378
+ // Open the card with a response we could not read a body out of.
379
+ observe({ message_id: 7, chat: { id: CHAT } }, { chat_id: String(CHAT), verb: 'activity-summary.send' })
380
+ expect(rows.get(7)).toBe('')
381
+
382
+ // 3s later — well inside DEFAULT_EDIT_REFRESH_MS — the real body arrives.
383
+ now += 3_000
384
+ observe(
385
+ { message_id: 7, chat: { id: CHAT }, rich_message: { blocks: [{ text: { text: '⚙️ Working — 3 tools' } }] } },
386
+ { chat_id: String(CHAT), verb: 'activity-summary.edit' },
387
+ )
388
+ expect(rows.get(7)).toBe('⚙️ Working — 3 tools')
389
+
390
+ // …and the throttle resumes normally once there IS something to protect.
391
+ now += 3_000
392
+ observe(
393
+ { message_id: 7, chat: { id: CHAT }, rich_message: { blocks: [{ text: { text: '⚙️ Working — 9 tools' } }] } },
394
+ { chat_id: String(CHAT), verb: 'activity-summary.edit' },
395
+ )
396
+ expect(rows.get(7)).toBe('⚙️ Working — 3 tools')
397
+ })
398
+
399
+ it('the alarm dedup key does not collapse every UNTAGGED verb into one bucket', () => {
400
+ // `kind ?? '<untagged>'` meant the first bodiless send with no `verb`
401
+ // permanently silenced the alarm for every other untagged verb in the
402
+ // process — including a real regression.
403
+ const alarms: { kind: string | null; id: number }[] = []
404
+ const observe = makeSystemMessageObserver({
405
+ insert: () => true,
406
+ updateText: () => true,
407
+ onEmptyText: (i) => alarms.push({ kind: i.kind, id: i.message_id }),
408
+ })
409
+ // Two DIFFERENT untagged-kind verbs: `normalizeSendVerb` returns null for a
410
+ // blank verb, so both land in the no-kind lane.
411
+ observe({ message_id: 31, chat: { id: CHAT } }, { chat_id: String(CHAT), verb: ' ' })
412
+ observe({ message_id: 32, chat: { id: CHAT } }, { chat_id: String(CHAT) })
413
+ expect(alarms).toEqual([
414
+ { kind: null, id: 31 },
415
+ { kind: null, id: 32 },
416
+ ])
417
+ })
418
+
419
+ it('does not alarm on the verbs that are BODILESS by construction', () => {
420
+ // sendSticker / sendAnimation / sendVoice / forwardMessage-of-media all
421
+ // resolve a Message with no text by design. An alarm there is identical in
422
+ // format to a real regression and unactionable, so it teaches readers to
423
+ // ignore the alarm.
424
+ const alarms: string[] = []
425
+ const observe = makeSystemMessageObserver({
426
+ insert: () => true,
427
+ updateText: () => true,
428
+ onEmptyText: (i) => alarms.push(i.kind ?? '<none>'),
429
+ })
430
+ const bodiless = [
431
+ { message_id: 41, chat: { id: CHAT }, sticker: { file_id: 's', file_unique_id: 'u' } },
432
+ { message_id: 42, chat: { id: CHAT }, animation: { file_id: 'a', file_unique_id: 'u' } },
433
+ { message_id: 43, chat: { id: CHAT }, voice: { file_id: 'v', file_unique_id: 'u', duration: 2 } },
434
+ { message_id: 44, chat: { id: CHAT }, photo: [{ file_id: 'p', file_unique_id: 'u' }] },
435
+ { message_id: 45, chat: { id: CHAT }, checklist: { title: 'rollout', tasks: [] } },
436
+ ]
437
+ for (const b of bodiless) observe(b, { chat_id: String(CHAT), verb: 'sendSticker' })
438
+ expect(alarms).toEqual([])
439
+
440
+ // A regressed CARD is a rich_message response that rendered to nothing —
441
+ // it carries none of those keys, so it still alarms.
442
+ observe(
443
+ { message_id: 46, chat: { id: CHAT }, rich_message: { blocks: [] } },
444
+ { chat_id: String(CHAT), verb: 'activity-summary.send' },
445
+ )
446
+ expect(alarms).toEqual(['activity-summary'])
447
+ })
448
+ })
449
+
450
+ /**
451
+ * The capture only works if it is actually installed. grammY's transformers are
452
+ * anonymous fns with nothing to grip at runtime, so the wiring is pinned at the
453
+ * source level — the same approach `format-guard-pins.test.ts` uses for the
454
+ * markdown guard. Without this, a refactor could silently drop the seam and
455
+ * every card row would go back to being empty with no test turning red.
456
+ */
457
+ describe('boot wiring: installSentTextCapture is on the production Bot', () => {
458
+ const gatewayPath = resolve(
459
+ dirname(fileURLToPath(import.meta.url)), '..', 'gateway', 'gateway.ts',
460
+ )
461
+ const src = readFileSync(gatewayPath, 'utf8')
462
+ const sourceFile = ts.createSourceFile(gatewayPath, src, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
463
+
464
+ it("imports installSentTextCapture from '../shared/sent-text-capture.js'", () => {
465
+ expect(src).toMatch(
466
+ /import\s*\{[^}]*\binstallSentTextCapture\b[^}]*\}\s*from\s*'\.\.\/shared\/sent-text-capture\.js'/,
467
+ )
468
+ })
469
+
470
+ it('calls installSentTextCapture(bot) exactly once inside initGatewayBot()', () => {
471
+ const fn = sourceFile.statements.find(
472
+ (s): s is ts.FunctionDeclaration =>
473
+ ts.isFunctionDeclaration(s) && s.name?.text === 'initGatewayBot',
474
+ )
475
+ expect(fn?.body).toBeDefined()
476
+ let count = 0
477
+ const visit = (node: ts.Node): void => {
478
+ if (
479
+ ts.isCallExpression(node) &&
480
+ ts.isIdentifier(node.expression) &&
481
+ node.expression.text === 'installSentTextCapture'
482
+ ) {
483
+ count++
484
+ expect(node.arguments[0]?.getText(sourceFile)).toBe('bot')
485
+ }
486
+ ts.forEachChild(node, visit)
487
+ }
488
+ visit(fn!.body!)
489
+ expect(count).toBe(1)
490
+ })
491
+
492
+ it('installs it AFTER the markdown guard, so it composes outside it', () => {
493
+ // Note what this does NOT claim: composing outside the guard TRANSFORMER
494
+ // does not yield a pre-escape body on the dominant card path, because
495
+ // `richMessage()` escapes in the caller (see the escaping cases above). It
496
+ // only matters for the call sites that build a raw `{ markdown }` and skip
497
+ // `richMessage()`. Kept as an ordering pin, not as a fidelity claim.
498
+ expect(src.indexOf('installRichMarkdownGuard(bot)')).toBeLessThan(
499
+ src.indexOf('installSentTextCapture(bot)'),
500
+ )
501
+ })
502
+
503
+ it('gets the empty-body alarm WITHOUT having to wire it — it is the default', () => {
504
+ // gateway.ts constructs the observer with insert/updateText only (it is
505
+ // under the anti-inflation line ratchet, switchroom#2996). So the alarm
506
+ // that makes a recurrence loud instead of silent-for-a-release, as #4576
507
+ // was, has to be the observer's DEFAULT — opting out must be the explicit
508
+ // act. Asserted as behaviour, not as a source pattern.
509
+ const written: string[] = []
510
+ const stderrWrite = process.stderr.write.bind(process.stderr)
511
+ ;(process.stderr as unknown as { write: unknown }).write = (chunk: unknown) => {
512
+ written.push(String(chunk))
513
+ return true
514
+ }
515
+ try {
516
+ const observe = makeSystemMessageObserver({
517
+ insert: () => true,
518
+ updateText: () => true,
519
+ })
520
+ observe({ message_id: 991, chat: { id: 5550001 } }, { chat_id: '5550001', verb: 'boot-card' })
521
+ } finally {
522
+ ;(process.stderr as unknown as { write: unknown }).write = stderrWrite
523
+ }
524
+ expect(written.join('')).toContain('card-history text capture MISSED kind=boot-card')
525
+ })
526
+ })
@@ -27,8 +27,17 @@ import {
27
27
 
28
28
  const CHAT = '5550001'
29
29
 
30
- /** A Telegram `Message` response as the Bot API returns it from sendMessage /
31
- * editMessageText. */
30
+ /**
31
+ * A Telegram `Message` response as the Bot API returns it from a PLAIN
32
+ * `sendMessage` / `editMessageText`.
33
+ *
34
+ * These cases exercise the observer's BOOKKEEPING (one row per card, edit
35
+ * throttling, foreign-lane demotion), for which the body is incidental. Do NOT
36
+ * assert card-body behaviour through this fixture: a card never goes out as a
37
+ * plain message, and hand-building a `text` field is exactly what let #4576
38
+ * ship an empty body on 100% of the fleet's card rows. Body coverage lives in
39
+ * `sent-text-capture.test.ts` (real grammy stack) and `card-history-lane.test.ts`.
40
+ */
32
41
  function sentMessage(messageId: number, text: string, threadId?: number) {
33
42
  return {
34
43
  message_id: messageId,