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,191 @@
1
+ /**
2
+ * sent-text-capture.ts — the card-body FALLBACK: stamp a send's REQUEST body
3
+ * onto the `Message` Telegram returned for it (#4571 / #4576 follow-up).
4
+ *
5
+ * Read this first: it is NOT the primary source of the stored card body.
6
+ * -----------------------------------------------------------------------
7
+ * `system-message-observer.ts` takes the body off the RESPONSE — `rich_message`
8
+ * (Telegram's own rendered block tree) first, then `text` / `caption`. That is
9
+ * the more faithful source, and it covers every send verb the gateway writes a
10
+ * history row for. This module supplies the LAST tier of that precedence
11
+ * chain, for responses that carry no renderable body at all: a rich send whose
12
+ * blocks flatten to nothing (a media-only card), or a future verb whose
13
+ * response omits the body.
14
+ *
15
+ * Deliberately last, because the body it captures is NOT the body as written.
16
+ * The dominant card path is `sendRichMessage(chat, richMessage(body))`, and
17
+ * `richMessage()` applies `guardAccidentalFormatting` in the CALLER
18
+ * (`rich-send.ts`), long before any transformer seam. So what this module sees
19
+ * on that path is already wire-escaped: `sent_text_capture.ts` arrives as
20
+ * `sent\_text\_capture.ts`, `$12.40` as `\$12.40`. Escaped-but-present beats
21
+ * empty, which is why the tier exists at all — but it must never win over a
22
+ * response that resolved those escapes.
23
+ *
24
+ * The bug this exists to backstop
25
+ * -------------------------------
26
+ * #4576 gave every gateway card a `role='system'` history row so a quote-reply
27
+ * to it resolves. The row carried the right `message_id`, chat, thread and
28
+ * `kind` — and an EMPTY `text`, on 100% of the rows, on every agent in the
29
+ * fleet. `resolveReplyToFromBuffer` only sets `reply_to_text` when the stored
30
+ * body is non-empty (`inbound-router.ts`), so the agent learned WHICH card was
31
+ * tapped and still could not see WHAT IT SAID. That was the entire point.
32
+ *
33
+ * Root cause: the observer read ONE body field off the response
34
+ * (`msg.text ?? msg.caption`), and every card goes out through Bot API 10.1
35
+ * `sendRichMessage`, whose response is a `Message.RichMessageMessage` — the
36
+ * body lives under `rich_message: { blocks }`, and `text` / `caption` are
37
+ * simply absent (`@grammyjs/types` `message.d.ts:94`, `:180`; Bot API
38
+ * `RichMessage.blocks` = "Content of the message"). So the extractor fell
39
+ * through to `''` every single time. Reading `rich_message` is the fix; this
40
+ * module is the belt to that pair of braces.
41
+ *
42
+ * The mechanism
43
+ * -------------
44
+ * Capture the body from the REQUEST at the one seam no outbound call can
45
+ * bypass: a grammY API transformer (`bot.api.config.use`) — the same seam
46
+ * `installRichMarkdownGuard` already uses, and the reason that guard is
47
+ * universal where `richMessage()` is not.
48
+ *
49
+ * The transformer sees the outbound payload AND the resolved response in one
50
+ * call, so it can pair them with zero bookkeeping: no id→text map, no eviction,
51
+ * no cross-call race. It stamps the body onto the returned `Message` under a
52
+ * non-enumerable, `Symbol.for`-keyed property, which:
53
+ * - survives grammY's `callApi` unwrapping — the transformer chain resolves
54
+ * with the raw `{ok, result}` envelope and `callApi` returns `data.result`
55
+ * BY REFERENCE (grammy 1.44.0 `out/core/client.js:95-99`), so the object
56
+ * the caller receives is the object we stamped;
57
+ * - is invisible to `JSON.stringify`, `Object.keys`, spreads and structural
58
+ * equality, so nothing that reads a `Message` today can observe it.
59
+ *
60
+ * Non-negotiable: this must never break the send it observes. Every step is
61
+ * defensive and the transformer's only unconditional act is `return prev(...)`.
62
+ */
63
+
64
+ import type { Bot } from 'grammy'
65
+
66
+ /**
67
+ * The stamp key. `Symbol.for` (not a module-local `Symbol()`) deliberately:
68
+ * the plugin is consumed both from source and from a bundle, and a duplicated
69
+ * module instance would otherwise mint a second, non-matching symbol and
70
+ * silently reopen the exact hole this file closes.
71
+ */
72
+ export const SENT_TEXT = Symbol.for('switchroom.telegram.sentText')
73
+
74
+ /** Depth cap for the rich-block walk — a hostile/odd payload cannot recurse us. */
75
+ const MAX_BLOCK_DEPTH = 8
76
+
77
+ /**
78
+ * Best-effort readable text out of an OUTBOUND `InputRichMessage.blocks` array.
79
+ *
80
+ * Nothing in this repo builds `{ blocks }` today (every rich send goes through
81
+ * `richMessage()` → `{ markdown }`), so this is purely the guard against a
82
+ * future adopter silently re-emptying the card lane. Bounded, allocation-shy,
83
+ * never throws.
84
+ */
85
+ function flattenInputRichBlocks(blocks: unknown, depth: number): string {
86
+ if (!Array.isArray(blocks) || depth > MAX_BLOCK_DEPTH) return ''
87
+ const parts: string[] = []
88
+ for (const block of blocks) {
89
+ if (block == null || typeof block !== 'object') continue
90
+ const b = block as Record<string, unknown>
91
+ for (const key of ['markdown', 'html', 'text', 'caption'] as const) {
92
+ const v = b[key]
93
+ if (typeof v === 'string' && v.length > 0) parts.push(v)
94
+ }
95
+ const nested = flattenInputRichBlocks(b.blocks, depth + 1)
96
+ if (nested.length > 0) parts.push(nested)
97
+ }
98
+ return parts.join('\n')
99
+ }
100
+
101
+ /**
102
+ * The body a Telegram API request is about to POST, or null when the payload
103
+ * carries no user-visible text (pins, deletes, reactions, `getUpdates`, …).
104
+ *
105
+ * Shape verified against grammy 1.44.0 `out/core/api.js` and the payload notes
106
+ * in `installRichMarkdownGuard`: `sendRichMessage` / rich `editMessageText`
107
+ * put the body at `payload.rich_message.markdown`, plain sends at
108
+ * `payload.text`, media sends at `payload.caption`. Pure.
109
+ */
110
+ export function outboundPayloadText(payload: unknown): string | null {
111
+ if (payload == null || typeof payload !== 'object') return null
112
+ const p = payload as Record<string, unknown>
113
+ const rich = p.rich_message
114
+ if (rich != null && typeof rich === 'object') {
115
+ const r = rich as Record<string, unknown>
116
+ if (typeof r.markdown === 'string') return r.markdown
117
+ if (typeof r.html === 'string') return r.html
118
+ const flat = flattenInputRichBlocks(r.blocks, 0)
119
+ if (flat.length > 0) return flat
120
+ }
121
+ if (typeof p.text === 'string') return p.text
122
+ if (typeof p.caption === 'string') return p.caption
123
+ return null
124
+ }
125
+
126
+ /**
127
+ * Stamp `text` onto a resolved API response envelope's `Message` result.
128
+ *
129
+ * Takes the raw `{ok, result}` envelope (what a transformer sees), not the
130
+ * unwrapped message, and no-ops on anything that isn't a single Message —
131
+ * `true` (pins / dropped edits / `editMessageText` on an inline message),
132
+ * arrays, `{ok:false}` rejections. Never throws.
133
+ */
134
+ export function attachSentText(envelope: unknown, text: string): void {
135
+ try {
136
+ if (envelope == null || typeof envelope !== 'object') return
137
+ const env = envelope as { ok?: unknown; result?: unknown }
138
+ if (env.ok !== true) return
139
+ const result = env.result
140
+ if (result == null || typeof result !== 'object' || Array.isArray(result)) return
141
+ Object.defineProperty(result, SENT_TEXT, {
142
+ value: text,
143
+ enumerable: false,
144
+ configurable: true,
145
+ writable: true,
146
+ })
147
+ } catch {
148
+ /* stamping must never break the send */
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Read the body stamped by {@link installSentTextCapture} off a `Message`, or
154
+ * null when the message did not transit a capture-installed bot (a test double,
155
+ * a second Bot instance, a hand-built fixture). Pure.
156
+ */
157
+ export function readSentText(message: unknown): string | null {
158
+ if (message == null || typeof message !== 'object') return null
159
+ const v = (message as Record<symbol, unknown>)[SENT_TEXT]
160
+ return typeof v === 'string' ? v : null
161
+ }
162
+
163
+ /**
164
+ * Install the capture transformer on the production Bot.
165
+ *
166
+ * Install it AFTER `installRichMarkdownGuard` so it composes OUTSIDE the guard
167
+ * (grammY's last-installed transformer runs first) and therefore captures the
168
+ * payload before the guard's backslash escapes are applied.
169
+ *
170
+ * Be precise about what that does and does not buy. It only avoids the
171
+ * TRANSFORMER's escaping pass, which matters for the call sites that build a
172
+ * raw `{ markdown }` and go straight to `sendRichMessage` / `editMessageText`
173
+ * (banners, approval and folder-picker edits — see `richMessage()`'s docblock
174
+ * for the list). It does NOT recover a pre-escape body for the dominant path,
175
+ * because `richMessage()` escapes in the CALLER, upstream of every transformer.
176
+ * There is no seam that can. That is precisely why this capture is the LAST
177
+ * tier of the observer's precedence chain rather than the first.
178
+ */
179
+ export function installSentTextCapture(bot: Bot): void {
180
+ bot.api.config.use(async (prev, method, payload, signal) => {
181
+ let text: string | null = null
182
+ try {
183
+ text = outboundPayloadText(payload)
184
+ } catch {
185
+ text = null
186
+ }
187
+ const res = await prev(method, payload, signal)
188
+ if (text != null) attachSentText(res, text)
189
+ return res
190
+ })
191
+ }
@@ -49,6 +49,10 @@ import {
49
49
  type EnvelopeBuildParams,
50
50
  } from '../gateway/inbound-router.js'
51
51
  import { makeSystemMessageObserver } from '../gateway/system-message-observer.js'
52
+ import { Bot } from 'grammy'
53
+ import { installRichMarkdownGuard } from '../shared/bot-runtime.js'
54
+ import { installSentTextCapture } from '../shared/sent-text-capture.js'
55
+ import { richMessage } from '../rich-send.js'
52
56
 
53
57
  const CHAT = '5550001'
54
58
  const REPLY_TO_TEXT_MAX = 200
@@ -62,11 +66,58 @@ function makeObserver(now?: () => number) {
62
66
  })
63
67
  }
64
68
 
65
- /** A Telegram Message response, as `robustApiCall` resolves with. */
69
+ /**
70
+ * A Telegram Message response, as `robustApiCall` resolves with.
71
+ *
72
+ * NOTE (#4576): this PLAIN shape is what `sendMessage` returns. No CARD send
73
+ * uses it — cards go out via `sendRichMessage`, whose response carries no
74
+ * `text` at all. Tests that assert the card BODY must use `sentRichCard()`
75
+ * below; a hand-built `{ text }` fixture cannot fail on the empty-body bug.
76
+ */
66
77
  function sentMessage(messageId: number, text: string) {
67
78
  return { message_id: messageId, chat: { id: Number(CHAT) }, text }
68
79
  }
69
80
 
81
+ /**
82
+ * The result of a REAL `sendRichMessage` through the production transformer
83
+ * stack (fmt guard + `installSentTextCapture`), transport stubbed to answer
84
+ * with the true `Message.RichMessageMessage` shape: `rich_message` blocks, and
85
+ * NO `text` / `caption`.
86
+ *
87
+ * This is the fixture the #4576 defect demanded: every `role='system'` row on
88
+ * every agent in the fleet had `length(text)=0`, because the lane's tests fed
89
+ * the observer a response shape production never produces.
90
+ */
91
+ async function sentRichCard(messageId: number, body: string): Promise<unknown> {
92
+ const fakeFetch = (async () =>
93
+ ({
94
+ ok: true,
95
+ status: 200,
96
+ json: async () => ({
97
+ ok: true,
98
+ result: {
99
+ message_id: messageId,
100
+ date: 0,
101
+ chat: { id: Number(CHAT), type: 'private' },
102
+ rich_message: { blocks: [{ type: 'paragraph', text: { text: body } }] },
103
+ },
104
+ }),
105
+ }) as unknown as Response) as unknown as typeof fetch
106
+
107
+ const bot = new Bot('123456:TEST_TOKEN', {
108
+ botInfo: {
109
+ id: 123456, is_bot: true, first_name: 'Test', username: 'test_bot',
110
+ can_join_groups: false, can_read_all_group_messages: false,
111
+ supports_inline_queries: false, can_connect_to_business: false,
112
+ has_main_web_app: false,
113
+ },
114
+ client: { fetch: fakeFetch },
115
+ })
116
+ installRichMarkdownGuard(bot)
117
+ installSentTextCapture(bot)
118
+ return bot.api.sendRichMessage(Number(CHAT), richMessage(body))
119
+ }
120
+
70
121
  /** The reply-antecedent lookup as gateway.ts binds it (#4571: includeSystem). */
71
122
  function boundLookup(messageId: number) {
72
123
  return lookupMessageRoleAndText(CHAT, messageId, { includeSystem: true })
@@ -197,6 +248,75 @@ describe('a quote-reply to the live activity card is UNDERSTOOD, end to end', ()
197
248
  })
198
249
  })
199
250
 
251
+ describe('#4576: the stored card body is NOT empty — real sendRichMessage response', () => {
252
+ it('a rich card write leaves a non-empty row, and the quote-reply carries the body', async () => {
253
+ const observe = makeObserver()
254
+ const CARD_ID = 20395
255
+ const BODY = '⚙️ Working — reading history.ts, 3 tools'
256
+
257
+ // The exact production path: sendRichMessage → transformer stack → observer.
258
+ // Its response has NO `text` field, which is what made every fleet row empty.
259
+ const result = await sentRichCard(CARD_ID, BODY)
260
+ expect((result as { text?: unknown }).text).toBeUndefined()
261
+ observe(result, { chat_id: CHAT, verb: 'activity-summary.send' })
262
+
263
+ const row = lookupMessageRoleAndText(CHAT, CARD_ID, { includeSystem: true })
264
+ expect(row?.role).toBe('system')
265
+ expect(row?.kind).toBe('activity-summary')
266
+ // The assertion the shipped bug fails: a stored body, not ''.
267
+ expect(row?.text.length).toBeGreaterThan(0)
268
+ expect(row?.text).toBe(BODY)
269
+
270
+ const resolved = resolveReplyToFromBuffer({
271
+ replyToMessageId: CARD_ID,
272
+ replyToText: undefined,
273
+ replyToTextEscaped: undefined,
274
+ historyEnabled: true,
275
+ replyToTextMax: REPLY_TO_TEXT_MAX,
276
+ lookup: boundLookup,
277
+ })
278
+ expect(resolved.replyToText).toBe(BODY)
279
+
280
+ const msg = buildInboundEnvelope(
281
+ makeEnvelopeParams({
282
+ replyToMessageId: CARD_ID,
283
+ replyToTextEscaped: resolved.replyToTextEscaped,
284
+ replyToRole: resolved.replyToRole,
285
+ replyToKind: resolved.replyToKind,
286
+ }),
287
+ )
288
+ expect(msg.meta?.reply_to_text).toBe(BODY)
289
+ })
290
+
291
+ it('holds for every card verb observed on the live fleet, not just the activity card', async () => {
292
+ const observe = makeObserver()
293
+ // The `kind` values measured on live agents' history.db after v0.21.0.
294
+ const verbs: [string, number, string][] = [
295
+ ['boot-card', 5082, '🟢 **overlord** is up — sonnet, 3 skills'],
296
+ ['worker-feed', 20404, '🛠 Worker — scoping the card-persistence defect'],
297
+ ['issues-card', 20405, '**3 open issues** — #4571, #4576, #4580'],
298
+ ['rollout-status-post', 20402, 'Rolling v0.21.0 — 4/12 agents done'],
299
+ ['quota-watch.fleet-roll', 20407, 'Quota 61% — resets 09:00'],
300
+ ['permission_request', 20406, '**Approve** `docker restart switchroom-clerk`?'],
301
+ ]
302
+ for (const [verb, id, body] of verbs) {
303
+ observe(await sentRichCard(id, body), { chat_id: CHAT, verb })
304
+ }
305
+ for (const [verb, id, body] of verbs) {
306
+ const row = lookupMessageRoleAndText(CHAT, id, { includeSystem: true })
307
+ expect(`${verb}:${row?.text.length ?? 0}`).not.toBe(`${verb}:0`)
308
+ expect(row?.text).toBe(body)
309
+ }
310
+ // No card row anywhere in the buffer is bodiless — the fleet-wide invariant
311
+ // the shipped release violated on 100% of its rows.
312
+ const cards = query({ chat_id: CHAT, limit: 100, include_system: true }).filter(
313
+ (r) => r.role === 'system',
314
+ )
315
+ expect(cards).toHaveLength(verbs.length)
316
+ expect(cards.filter((r) => r.text.length === 0)).toEqual([])
317
+ })
318
+ })
319
+
200
320
  describe('the card lane does not pollute normal history reads', () => {
201
321
  it('query() (get_recent_messages) lists the conversation, never the cards', () => {
202
322
  const observe = makeObserver()