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.
- package/dist/auth-broker/index.js +54 -34
- package/dist/cli/switchroom.js +765 -367
- package/dist/host-control/main.js +8 -1
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +174 -18
- package/telegram-plugin/external-spend.ts +1 -1
- package/telegram-plugin/gateway/gateway.ts +8 -0
- package/telegram-plugin/gateway/system-message-observer.ts +189 -13
- package/telegram-plugin/shared/sent-text-capture.ts +191 -0
- package/telegram-plugin/tests/card-history-lane.test.ts +121 -1
- package/telegram-plugin/tests/sent-text-capture.test.ts +526 -0
- package/telegram-plugin/tests/system-message-observer.test.ts +11 -2
|
@@ -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
|
-
/**
|
|
31
|
-
*
|
|
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,
|