switchroom 0.21.3 → 0.21.5

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.
@@ -5,8 +5,10 @@
5
5
  * standalone foreman bot before its retirement.
6
6
  *
7
7
  * What lives here:
8
- * - `installTgPostLogger` / `installRichMarkdownGuard` — the grammy API
9
- * transformers every outbound call transits.
8
+ * - `installTgPostLogger` / `installRichMarkdownGuard` /
9
+ * `installSystemMessageObserver` — the grammy API transformers every
10
+ * outbound call transits. This layer, not any caller-side wrapper, is the
11
+ * one seam no `ctx.*` helper or raw `bot.api.*` call can bypass.
10
12
  * - `makeSwitchroomExec` / `makeSwitchroomExecCombined` — factory fns for
11
13
  * the switchroom CLI exec helpers (callers pass their own CLI path / config
12
14
  * env so each process can be configured independently).
@@ -292,6 +294,121 @@ export function installRichMarkdownGuard(bot: Bot): void {
292
294
  })
293
295
  }
294
296
 
297
+ // ─── outbound send observation (#4571 / #4599) ────────────────────────────
298
+
299
+ /**
300
+ * The call-site metadata a send observer wants but the wire payload cannot
301
+ * supply. Structurally the same shape as `ObservedCallOpts` in
302
+ * `gateway/system-message-observer.ts`; declared here so this module keeps its
303
+ * no-imports-from-gateway rule.
304
+ */
305
+ export interface TgSendContext {
306
+ chat_id?: string
307
+ threadId?: number
308
+ /** The caller's own label for what this send IS (`activity-summary.send`). */
309
+ verb?: string
310
+ }
311
+
312
+ const sendContextStore = new AsyncLocalStorage<TgSendContext | undefined>()
313
+
314
+ /**
315
+ * Publish the enclosing call's `{chat_id, threadId, verb}` to the API
316
+ * transformer layer for the duration of `fn`'s async chain. `gateway.ts`'s
317
+ * `robustApiCall` wraps every call it issues; anything sent outside it (a
318
+ * `ctx.reply*` helper, a raw `bot.api.*`) simply observes with no verb.
319
+ */
320
+ export function withTgSendContext<T>(ctx: TgSendContext | undefined, fn: () => T): T {
321
+ return sendContextStore.run(ctx, fn)
322
+ }
323
+
324
+ /** Exposed for the transformer (and tests). Undefined outside a wrapped call. */
325
+ export function _getTgSendContext(): TgSendContext | undefined {
326
+ return sendContextStore.getStore()
327
+ }
328
+
329
+ /**
330
+ * Install the card-history send observer as a grammy API TRANSFORMER — the
331
+ * real universal outbound seam (#4599).
332
+ *
333
+ * #4571 hooked the observer onto `gateway.ts`'s `robustApiCall`, and
334
+ * `system-message-observer.ts` claimed that was "the ONE chokepoint every
335
+ * gateway outbound already goes through … enforced by the
336
+ * `check-bot-api-wrapping` lint guard". That claim was false on both halves.
337
+ * grammY's `ctx.*` sugar builds the payload and calls `bot.api.*` directly, so
338
+ * `switchroomReply` (`gateway.ts`, the helper every SLASH-COMMAND card answers
339
+ * through — `/usage`, `/model`, `/auth`, `/approvals`, `/start`, `/help`) sends
340
+ * via `ctx.replyWithRichMessage` and never transits `robustApiCall` at all; and
341
+ * the lint guard could not have caught it, because its verb pattern matched
342
+ * only `(bot|lockedBot|ctx)\.api\.<verb>` — it never mentioned `sendRichMessage`
343
+ * and structurally cannot match a `ctx.replyWith*` helper.
344
+ *
345
+ * Measured, not inferred: on a live agent's buffer the `/usage` card at id
346
+ * 20938 left NO row, while the `tg-post` transformer in this very file logged
347
+ * its `sendRichMessage` POST. The transformer layer SAW the send the recorder
348
+ * missed. That is the whole argument for moving here: grammy resolves this
349
+ * chain immediately around the HTTP POST, below every helper, every `ctx.*`
350
+ * shorthand, `lockedBot`, and `bot.api.raw` — there is no call shape that
351
+ * reaches Telegram without passing through it, so no future verb can silently
352
+ * opt out the way `switchroomReply` did.
353
+ *
354
+ * `observe` is injected rather than imported: this module must not depend on
355
+ * anything under `gateway/` (see the file header). The caller keeps ownership
356
+ * of the history writers, the `isGatewayMain && HISTORY_ENABLED` gate, and the
357
+ * empty-body alarm.
358
+ *
359
+ * Only a RESOLVED, `ok:true` response is observed. grammy hands this chain the
360
+ * raw `ApiResponse` and converts `{ok:false}` into a thrown `GrammyError` only
361
+ * after the chain returns (see `installTgPostLogger`'s docblock), so a
362
+ * rejection arrives here as a resolved body and must be skipped — recording a
363
+ * Telegram error object as a card body is exactly the empty-row failure #4576
364
+ * was. Non-message results (`getUpdates` arrays, `getMe`, `true` from
365
+ * `answerCallbackQuery`) are filtered by the observer's own shape test.
366
+ *
367
+ * Pure observation: the response is returned untouched and nothing here can
368
+ * throw into the send path.
369
+ */
370
+ export function installSystemMessageObserver(
371
+ bot: Bot,
372
+ observe: (result: unknown, opts?: TgSendContext) => void,
373
+ ): void {
374
+ bot.api.config.use(async (prev, method, payload, signal) => {
375
+ const res = await prev(method, payload, signal)
376
+ try {
377
+ const r = res as unknown as { ok?: boolean; result?: unknown }
378
+ if (r != null && typeof r === 'object' && r.ok === true) {
379
+ observe(r.result, resolveSendContext(payload))
380
+ }
381
+ } catch {
382
+ /* observing a send must never break the send */
383
+ }
384
+ return res
385
+ })
386
+ }
387
+
388
+ /**
389
+ * Merge the caller-published context with what the outbound PAYLOAD itself
390
+ * carries. The context wins where both exist (it is the caller's own intent);
391
+ * the payload is what keeps an unwrapped `ctx.reply*` send attributable to a
392
+ * chat and topic at all. The response's own `chat.id` still outranks both
393
+ * downstream — this is only the fallback tier.
394
+ */
395
+ function resolveSendContext(payload: unknown): TgSendContext | undefined {
396
+ const ctx = sendContextStore.getStore()
397
+ const p = (payload ?? {}) as Record<string, unknown>
398
+ const rawChat = p.chat_id
399
+ const chat_id =
400
+ ctx?.chat_id ??
401
+ (typeof rawChat === 'string' || typeof rawChat === 'number' ? String(rawChat) : undefined)
402
+ const rawThread = p.message_thread_id
403
+ const threadId = ctx?.threadId ?? (typeof rawThread === 'number' ? rawThread : undefined)
404
+ if (chat_id == null && threadId == null && ctx?.verb == null) return undefined
405
+ return {
406
+ ...(chat_id != null ? { chat_id } : {}),
407
+ ...(threadId != null ? { threadId } : {}),
408
+ ...(ctx?.verb != null ? { verb: ctx.verb } : {}),
409
+ }
410
+ }
411
+
295
412
  // ─── robustApiCall factory: REMOVED (#3863) ───────────────────────────────
296
413
  //
297
414
  // `createRobustApiCall` used to live here as a second `createRetryApiCall`
@@ -49,8 +49,13 @@ 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'
52
+ import { Bot, Context } from 'grammy'
53
+ import {
54
+ installRichMarkdownGuard,
55
+ installSystemMessageObserver,
56
+ makeSwitchroomReply,
57
+ withTgSendContext,
58
+ } from '../shared/bot-runtime.js'
54
59
  import { installSentTextCapture } from '../shared/sent-text-capture.js'
55
60
  import { richMessage } from '../rich-send.js'
56
61
 
@@ -317,6 +322,170 @@ describe('#4576: the stored card body is NOT empty — real sendRichMessage resp
317
322
  })
318
323
  })
319
324
 
325
+ describe('#4599: the SLASH-COMMAND card path is recorded too', () => {
326
+ const BOT_INFO = {
327
+ id: 123456, is_bot: true as const, first_name: 'Test', username: 'test_bot',
328
+ can_join_groups: false, can_read_all_group_messages: false,
329
+ supports_inline_queries: false, can_connect_to_business: false,
330
+ has_main_web_app: false,
331
+ }
332
+
333
+ /**
334
+ * The gateway's REAL transformer stack with the observer installed exactly
335
+ * where gateway.ts installs it (after `installSentTextCapture`), transport
336
+ * stubbed to answer with the true `Message.RichMessageMessage` shape echoing
337
+ * the markdown that was actually POSTed.
338
+ *
339
+ * `editMessageText` re-uses the `message_id` in the payload, so an edit
340
+ * returns the id it edited — the shape the observer's send-vs-edit rule
341
+ * depends on.
342
+ */
343
+ function makeCardBot(observe: (result: unknown, opts?: { verb?: string }) => void) {
344
+ let nextId = 30_000
345
+ const posted: string[] = []
346
+ const fakeFetch = (async (_url: unknown, init: { body?: string } | undefined) => {
347
+ const payload = JSON.parse(String(init?.body ?? '{}')) as Record<string, unknown>
348
+ posted.push(String(payload.__method__ ?? ''))
349
+ const rich = payload.rich_message as { markdown?: unknown } | undefined
350
+ const id = typeof payload.message_id === 'number' ? payload.message_id : nextId++
351
+ return {
352
+ ok: true,
353
+ status: 200,
354
+ json: async () => ({
355
+ ok: true,
356
+ result: {
357
+ message_id: id,
358
+ date: 0,
359
+ chat: { id: Number(CHAT), type: 'private' },
360
+ ...(typeof rich?.markdown === 'string'
361
+ ? { rich_message: { blocks: [{ type: 'paragraph', text: { text: rich.markdown } }] } }
362
+ : { text: String(payload.text ?? '') }),
363
+ },
364
+ }),
365
+ } as unknown as Response
366
+ }) as unknown as typeof fetch
367
+
368
+ const bot = new Bot('123456:TEST_TOKEN', { botInfo: BOT_INFO, client: { fetch: fakeFetch } })
369
+ installRichMarkdownGuard(bot)
370
+ installSentTextCapture(bot)
371
+ installSystemMessageObserver(bot, observe)
372
+ return { bot, posted }
373
+ }
374
+
375
+ /** A grammy Context for an inbound `/usage`, as the command handler receives it. */
376
+ function makeCtx(bot: Bot): Context {
377
+ return new Context(
378
+ {
379
+ update_id: 1,
380
+ message: {
381
+ message_id: 20_930,
382
+ date: 0,
383
+ chat: { id: Number(CHAT), type: 'private' as const },
384
+ from: { id: 111, is_bot: false, first_name: 'Alice' },
385
+ text: '/usage',
386
+ },
387
+ } as never,
388
+ bot.api,
389
+ BOT_INFO,
390
+ )
391
+ }
392
+
393
+ it('a /usage card sent via switchroomReply leaves exactly one non-empty row', async () => {
394
+ // THE #4599 DEFECT, pinned. `switchroomReply` answers every slash command
395
+ // through `ctx.replyWithRichMessage`, which builds its own payload and calls
396
+ // `bot.api.*` directly — it never transits `robustApiCall`, where the #4571
397
+ // recorder was hooked. Measured on a live agent: the `/usage` card at id
398
+ // 20938 left no row, so a quote-reply to it resolved to nothing. A recorder
399
+ // that only sees `robustApiCall` cannot pass this test.
400
+ const observe = makeObserver()
401
+ const { bot } = makeCardBot(observe)
402
+ const switchroomReply = makeSwitchroomReply(() => undefined)
403
+ const BODY = 'Usage this week — Opus 41 percent, Sonnet 12 percent'
404
+
405
+ await switchroomReply(makeCtx(bot), BODY, { html: true })
406
+
407
+ const cards = query({ chat_id: CHAT, limit: 50, include_system: true }).filter(
408
+ (r) => r.role === 'system',
409
+ )
410
+ expect(cards).toHaveLength(1)
411
+ expect(cards[0]!.text).toBe(BODY)
412
+
413
+ // …and the quote-reply the operator actually makes now resolves.
414
+ const resolved = resolveReplyToFromBuffer({
415
+ replyToMessageId: cards[0]!.message_id,
416
+ replyToText: undefined,
417
+ replyToTextEscaped: undefined,
418
+ historyEnabled: true,
419
+ replyToTextMax: REPLY_TO_TEXT_MAX,
420
+ lookup: boundLookup,
421
+ })
422
+ expect(resolved.replyToRole).toBe('system')
423
+ expect(resolved.replyToText).toBe(BODY)
424
+ })
425
+
426
+ it('the PLAIN ctx.reply branch of switchroomReply is recorded as well', async () => {
427
+ const observe = makeObserver()
428
+ const { bot } = makeCardBot(observe)
429
+ const switchroomReply = makeSwitchroomReply(() => undefined)
430
+
431
+ await switchroomReply(makeCtx(bot), 'plain notice, no markdown', {})
432
+
433
+ const cards = query({ chat_id: CHAT, limit: 50, include_system: true }).filter(
434
+ (r) => r.role === 'system',
435
+ )
436
+ expect(cards).toHaveLength(1)
437
+ expect(cards[0]!.text).toBe('plain notice, no markdown')
438
+ })
439
+
440
+ it('a wrapped send keeps its verb → kind, and its edits do NOT add a second row', async () => {
441
+ // The behaviour the move must PRESERVE: `robustApiCall` publishes its verb
442
+ // through `withTgSendContext`, so the 117 live `role='system'` rows keep
443
+ // their `kind` (`activity-summary`, `worker-feed`, …) instead of degrading
444
+ // to null now that the recorder no longer sits on that wrapper.
445
+ let clock = 1_000
446
+ const observe = makeObserver(() => clock)
447
+ const { bot } = makeCardBot(observe)
448
+
449
+ const sent = await withTgSendContext({ chat_id: CHAT, verb: 'activity-summary.send' }, () =>
450
+ bot.api.sendRichMessage(Number(CHAT), richMessage('Working — 3 tools')),
451
+ )
452
+ const id = (sent as { message_id: number }).message_id
453
+
454
+ clock += 60_000
455
+ await withTgSendContext({ chat_id: CHAT, verb: 'activity-summary.edit' }, () =>
456
+ bot.api.editMessageText(Number(CHAT), id, richMessage('Working — 11 tools')),
457
+ )
458
+
459
+ const cards = query({ chat_id: CHAT, limit: 50, include_system: true }).filter(
460
+ (r) => r.role === 'system',
461
+ )
462
+ expect(cards).toHaveLength(1)
463
+ expect(cards[0]!.message_id).toBe(id)
464
+ expect(cards[0]!.kind).toBe('activity-summary')
465
+ expect(cards[0]!.text).toBe('Working — 11 tools')
466
+ })
467
+
468
+ it('a Telegram REJECTION is never recorded as a card', async () => {
469
+ // grammy resolves the transformer chain with the raw `{ok:false}` body and
470
+ // only throws afterwards, so a failed send reaches the observer as a
471
+ // RESOLVED response. Recording its error object would be #4576 all over.
472
+ const observe = makeObserver()
473
+ const failFetch = (async () =>
474
+ ({
475
+ ok: true,
476
+ status: 200,
477
+ json: async () => ({ ok: false, error_code: 400, description: 'chat not found' }),
478
+ }) as unknown as Response) as unknown as typeof fetch
479
+ const bot = new Bot('123456:TEST_TOKEN', { botInfo: BOT_INFO, client: { fetch: failFetch } })
480
+ installSystemMessageObserver(bot, observe)
481
+
482
+ await expect(
483
+ bot.api.sendRichMessage(Number(CHAT), richMessage('never lands')),
484
+ ).rejects.toThrow()
485
+ expect(query({ chat_id: CHAT, limit: 50, include_system: true })).toHaveLength(0)
486
+ })
487
+ })
488
+
320
489
  describe('the card lane does not pollute normal history reads', () => {
321
490
  it('query() (get_recent_messages) lists the conversation, never the cards', () => {
322
491
  const observe = makeObserver()