switchroom 0.18.29 → 0.18.31

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.
Files changed (40) hide show
  1. package/bin/handoff-briefing.sh +8 -2
  2. package/dist/agent-scheduler/index.js +111 -7
  3. package/dist/auth-broker/index.js +154 -16
  4. package/dist/cli/autoaccept-poll.js +8 -3
  5. package/dist/cli/drive-write-pretool.mjs +8 -3
  6. package/dist/cli/ms-365-write-pretool.mjs +158 -11
  7. package/dist/cli/notion-write-pretool.mjs +103 -4
  8. package/dist/cli/switchroom.js +2089 -1587
  9. package/dist/host-control/main.js +110 -13
  10. package/dist/vault/approvals/kernel-server.js +116 -13
  11. package/dist/vault/broker/server.js +314 -145
  12. package/package.json +3 -3
  13. package/profiles/_base/start.sh.hbs +172 -22
  14. package/telegram-plugin/dist/bridge/bridge.js +71 -47
  15. package/telegram-plugin/dist/gateway/gateway.js +601 -104
  16. package/telegram-plugin/dist/server.js +89 -64
  17. package/telegram-plugin/gateway/gateway.ts +280 -33
  18. package/telegram-plugin/gateway/model-command.ts +104 -0
  19. package/telegram-plugin/gateway/session-model-file.ts +40 -0
  20. package/telegram-plugin/gateway/turn-flush-suppression.ts +82 -0
  21. package/telegram-plugin/gateway/unhandled-message.ts +177 -0
  22. package/telegram-plugin/llm-error-present.ts +24 -0
  23. package/telegram-plugin/model-unavailable.ts +55 -0
  24. package/telegram-plugin/operator-events.ts +113 -0
  25. package/telegram-plugin/pending-user-notice.ts +88 -0
  26. package/telegram-plugin/shared/local-time.ts +43 -0
  27. package/telegram-plugin/tests/catch-all-forwarded-history.test.ts +103 -0
  28. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +264 -0
  29. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +26 -2
  30. package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +278 -0
  31. package/telegram-plugin/tests/local-time.test.ts +68 -1
  32. package/telegram-plugin/tests/model-command.test.ts +133 -0
  33. package/telegram-plugin/tests/session-model-file.test.ts +23 -0
  34. package/telegram-plugin/tests/turn-flush-suppression.test.ts +90 -0
  35. package/vendor/hindsight-memory/scripts/backfill_transcripts.py +399 -2
  36. package/vendor/hindsight-memory/scripts/lib/client.py +47 -0
  37. package/vendor/hindsight-memory/scripts/lib/content.py +53 -1
  38. package/vendor/hindsight-memory/scripts/lib/turnlog.py +450 -0
  39. package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +467 -0
  40. package/vendor/hindsight-memory/tests/test_content.py +35 -0
@@ -14,12 +14,14 @@
14
14
 
15
15
  import { escapeMarkdown } from './format.js'
16
16
  import { stripRawErrorBytes } from './raw-error-scrub.js'
17
+ import { isLitellmProxyAuthMisconfig } from './model-unavailable.js'
17
18
 
18
19
  // ─── Taxonomy ────────────────────────────────────────────────────────────────
19
20
 
20
21
  export type OperatorEventKind =
21
22
  | 'credentials-expired'
22
23
  | 'credentials-invalid'
24
+ | 'proxy-misconfig'
23
25
  | 'credit-exhausted'
24
26
  | 'quota-exhausted'
25
27
  | 'rate-limited'
@@ -92,6 +94,20 @@ function classifyInner(raw: unknown): OperatorEventKind {
92
94
  // Anthropic SDK: error_code field (newer SDK shape)
93
95
  const sdkCode = extractString(obj, 'error_code') ?? ''
94
96
 
97
+ // LiteLLM-proxy AUTH misconfig — checked BEFORE the generic
98
+ // authentication_error branch. The proxy's internal fallback chain
99
+ // re-dispatches without the client's OAuth Authorization header onto a
100
+ // keyless deployment, so Anthropic returns a 401 authentication_error
101
+ // ("x-api-key header is required"). That is a HOST-side infra misconfig for
102
+ // the operator to fix — NOT an end-user login wall. Mapping it to
103
+ // credentials-invalid mis-fired a "🔑 re-authenticate" card at non-operator
104
+ // users who cannot re-auth anything (incident 2026-07-16). Route it to the
105
+ // operator-only infra kind instead. Scanned across type/code/message so the
106
+ // marker is caught wherever the proxy stamps it.
107
+ if (isLitellmProxyAuthMisconfig(`${errorType}\n${errorCode}\n${sdkCode}\n${message}`)) {
108
+ return 'proxy-misconfig'
109
+ }
110
+
95
111
  // Map known Anthropic error types/codes first.
96
112
  // Source: https://docs.anthropic.com/en/api/errors
97
113
  if (
@@ -263,6 +279,27 @@ export function renderOperatorEvent(ev: OperatorEvent): RenderResult {
263
279
  },
264
280
  }
265
281
 
282
+ // Operator-only infra fault. Deliberately NO "Reauth" button and NO
283
+ // login language: the OAuth credential is fine — the local LiteLLM proxy
284
+ // re-dispatched an internal fallback without forwarding the OAuth header
285
+ // onto a keyless deployment, so Anthropic 401'd it. The fix is in the
286
+ // proxy fallback config (host-side), not a re-auth. Dismiss-only.
287
+ case 'proxy-misconfig':
288
+ return {
289
+ text: [
290
+ `🛠️ **Model-gateway auth misconfig** for **${agent}**.`,
291
+ detail ? `_${detail}_` : '',
292
+ `The local LiteLLM proxy re-dispatched a fallback without forwarding the OAuth header (keyless deployment → Anthropic 401). Fix the proxy fallback config — this is NOT a login problem.`,
293
+ ]
294
+ .filter(Boolean)
295
+ .join('\n'),
296
+ keyboard: {
297
+ inline_keyboard: [
298
+ [{ text: '❌ Dismiss', callback_data: `op:dismiss:${encodeURIComponent(ev.agent)}` }],
299
+ ],
300
+ },
301
+ }
302
+
266
303
  case 'credit-exhausted':
267
304
  return {
268
305
  text: [
@@ -487,4 +524,80 @@ export function resetAllCooldowns(): void {
487
524
  cooldownMap.clear()
488
525
  }
489
526
 
527
+ // ─── Audience routing (Ken's deterministic error-surfacing policy) ────────────
528
+
529
+ /**
530
+ * Kinds that are OPERATOR-ACTIONABLE and NOT user-actionable: a credential /
531
+ * infra fault whose remedy (re-auth, switch account slot, fix the proxy
532
+ * fallback config) only the operator can perform. Per Ken's standing policy
533
+ * (2026-07): raw or misleading auth/infra errors must not reach non-operator
534
+ * end users — they cannot act on them and the diagnosis is often wrong for
535
+ * them (e.g. a proxy misconfig rendered as "your login expired"). These route
536
+ * to the operator surface ONLY; a non-operator user gets at most a brief
537
+ * plain-language "couldn't complete, it's on our side" notice.
538
+ *
539
+ * NOTE: `quota-exhausted` / `rate-limited` are deliberately EXCLUDED — they
540
+ * carry their own auto-fallback UX + card-collapse machinery and are handled
541
+ * upstream; this set is scoped to the credential/infra fault classes.
542
+ */
543
+ export const OPERATOR_ACTIONABLE_KINDS: ReadonlySet<OperatorEventKind> = new Set<OperatorEventKind>([
544
+ 'credentials-expired',
545
+ 'credentials-invalid',
546
+ 'credit-exhausted',
547
+ 'proxy-misconfig',
548
+ ])
549
+
550
+ export function isOperatorActionableKind(kind: OperatorEventKind): boolean {
551
+ return OPERATOR_ACTIONABLE_KINDS.has(kind)
552
+ }
553
+
554
+ export interface OperatorEventAudience {
555
+ /** Chats that receive the full operator card (with any action buttons). */
556
+ operatorChats: string[]
557
+ /** Non-operator chats that receive only the plain-language failure notice. */
558
+ userNoticeChats: string[]
559
+ }
560
+
561
+ /**
562
+ * Split an operator-event's allowlist audience per Ken's routing policy.
563
+ *
564
+ * - Non-operator-actionable kinds (transient rate-limit, 5xx, crash, config
565
+ * warning, …) keep their existing broadcast: every allowlist chat is an
566
+ * `operatorChat`. Behavior unchanged.
567
+ * - Operator-actionable kinds (see {@link OPERATOR_ACTIONABLE_KINDS}) go to the
568
+ * OPERATOR chat only. `operatorChatId` is the operator (in switchroom the
569
+ * allowlist HEAD — `allowFrom[0]`); it stays an `operatorChat` even in a DM
570
+ * agent where the operator is their own user (so the operator NEVER has an
571
+ * error hidden from their own DM). Every other allowlist chat becomes a
572
+ * `userNoticeChat`.
573
+ *
574
+ * Pure — no IPC, no bot. `allowFrom` order is preserved.
575
+ */
576
+ export function decideOperatorEventAudience(
577
+ kind: OperatorEventKind,
578
+ allowFrom: readonly string[],
579
+ operatorChatId: string | undefined,
580
+ ): OperatorEventAudience {
581
+ if (!isOperatorActionableKind(kind)) {
582
+ return { operatorChats: [...allowFrom], userNoticeChats: [] }
583
+ }
584
+ const operator =
585
+ operatorChatId != null && allowFrom.includes(operatorChatId)
586
+ ? operatorChatId
587
+ : allowFrom[0]
588
+ const operatorChats = operator != null ? [operator] : []
589
+ const userNoticeChats = allowFrom.filter((c) => c !== operator)
590
+ return { operatorChats, userNoticeChats }
591
+ }
592
+
593
+ /**
594
+ * The ONE brief, plain-language failure notice a non-operator user gets when a
595
+ * turn genuinely can't be served because of an operator-actionable fault.
596
+ * Deliberately carries NO diagnosis, NO agent internals, NO re-auth / config
597
+ * instructions, and NO raw error text — just an honest "it's on our side".
598
+ */
599
+ export function renderUserFacingFailureNotice(): string {
600
+ return "⚠️ Sorry — I couldn't complete that just now. It's a problem on our side, not anything you did. Please try again shortly."
601
+ }
602
+
490
603
  // ─── Markdown escape (#2669) ──────────────────────────────────────────────────
@@ -0,0 +1,88 @@
1
+ /**
2
+ * pending-user-notice.ts — deterministic turn-outcome gate for the
3
+ * non-operator user failure notice (#3293 review finding 1).
4
+ *
5
+ * THE PROBLEM this closes: `emitGatewayOperatorEvent` fires whenever
6
+ * session-tail sees an api_error line, with no knowledge of whether the turn
7
+ * ultimately RECOVERS (e.g. the LiteLLM fallback 401s but a retry / another
8
+ * deployment serves the turn). Sending the "couldn't complete that — it's on
9
+ * our side" notice at error time would tell users a turn failed that actually
10
+ * completed. The 5-min per-kind operator-event cooldown debounces retry SPAM
11
+ * but cannot know the turn's outcome.
12
+ *
13
+ * THE MECHANISM: the notice is never sent at error time. It is SCHEDULED here,
14
+ * and resolved at the gateway's single turn-end funnel (`endCurrentTurnAtomic`):
15
+ * - turn ended WITH a delivered reply → the turn recovered → DROP the notice
16
+ * - turn ended WITHOUT a delivered reply → the turn genuinely died → SEND it
17
+ * Operator cards are NOT gated — they stay immediate (the operator must see
18
+ * the infra fault even when the turn recovers).
19
+ *
20
+ * CONSERVATIVE TTL: if no turn end resolves a scheduled notice within
21
+ * {@link PENDING_USER_NOTICE_TTL_MS} (error arrived between turns, or the turn
22
+ * record was lost), the notice is silently discarded — a missed notice costs a
23
+ * user some confusion; a FALSE "couldn't complete" on a served turn costs
24
+ * trust. Bias to silence.
25
+ *
26
+ * Pure module: no IPC, no bot, no FS. Injectable `now` throughout.
27
+ */
28
+
29
+ export interface PendingUserNotice {
30
+ /** Non-operator allowlist chats that should receive the notice. */
31
+ chatIds: string[]
32
+ /** The plain-language notice text (renderUserFacingFailureNotice()). */
33
+ text: string
34
+ agent: string
35
+ /** The operator-event kind that produced it (log/debug context only). */
36
+ kind: string
37
+ /** When the notice was scheduled (ms epoch). */
38
+ atMs: number
39
+ }
40
+
41
+ /** How long a scheduled notice may wait for a resolving turn end. */
42
+ export const PENDING_USER_NOTICE_TTL_MS = 10 * 60_000
43
+
44
+ export class PendingUserNoticeGate {
45
+ private pending: PendingUserNotice[] = []
46
+
47
+ /**
48
+ * Schedule a notice for turn-end resolution. Collapses per agent — a burst
49
+ * of error lines within one turn holds ONE pending notice, not a stack.
50
+ */
51
+ schedule(notice: PendingUserNotice): void {
52
+ this.prune(notice.atMs)
53
+ this.pending = this.pending.filter((p) => p.agent !== notice.agent)
54
+ this.pending.push(notice)
55
+ }
56
+
57
+ /**
58
+ * Resolve at turn end. `turnDeliveredReply` is the turn's outcome signal
59
+ * (the gateway passes `finalAnswerDelivered || replyCalled`):
60
+ * - true → the turn recovered; every pending notice is dropped, [] returned.
61
+ * - false → the turn died without a reply; the un-expired pending notices
62
+ * are returned EXACTLY ONCE for the caller to send.
63
+ * Either way the ledger is cleared (a notice never survives its turn end).
64
+ */
65
+ resolveTurnEnd(turnDeliveredReply: boolean, now: number = Date.now()): PendingUserNotice[] {
66
+ this.prune(now)
67
+ const out = turnDeliveredReply ? [] : [...this.pending]
68
+ this.pending = []
69
+ return out
70
+ }
71
+
72
+ /** True when at least one un-expired notice is pending (does not mutate). */
73
+ hasPending(now: number = Date.now()): boolean {
74
+ return this.pending.some((p) => now - p.atMs < PENDING_USER_NOTICE_TTL_MS)
75
+ }
76
+
77
+ private prune(now: number): void {
78
+ this.pending = this.pending.filter((p) => now - p.atMs < PENDING_USER_NOTICE_TTL_MS)
79
+ }
80
+
81
+ /** Test-only: forget everything. */
82
+ reset(): void {
83
+ this.pending = []
84
+ }
85
+ }
86
+
87
+ /** The process-wide gate the gateway consults. */
88
+ export const pendingUserNoticeGate = new PendingUserNoticeGate()
@@ -123,3 +123,46 @@ export function fmtLocalStamp(ms: number, tz: string): string {
123
123
  return new Date(ms).toISOString()
124
124
  }
125
125
  }
126
+
127
+ /**
128
+ * Leading ISO-8601-Z timestamp at the start of a log line, e.g.
129
+ * `2026-07-16T04:09:00.123456789Z` or `2026-07-16T04:09:00Z`. Docker log
130
+ * lines (as surfaced by `switchroom agent logs`) carry one of these per line.
131
+ * Anchored at line start; the trailing capture is the rest of the line.
132
+ */
133
+ const LEADING_ISO_Z = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z)(\s|$)/
134
+
135
+ /**
136
+ * DISPLAY-ONLY: rewrite a leading UTC ISO-8601-Z timestamp on each line of
137
+ * `text` into the caller's LOCAL am/pm wall clock via {@link fmtLocalStamp},
138
+ * so `/logs` output reads `Thursday 2026-07-16 02:09 PM AEST …` instead of a
139
+ * raw `…T04:09:00Z`. Applied at send time; never mutates stored logs.
140
+ *
141
+ * The leading stamp comes from `docker logs --timestamps` (requested by the
142
+ * /logs path via `switchroom agent logs --timestamps`) — docker's stamp is
143
+ * ALWAYS UTC ISO-Z, which is what makes this conversion deterministic. App-
144
+ * emitted stamps inside the line body (e.g. Python's `%H:%M:%S,mmm`) are
145
+ * deliberately NOT converted: post-#3275 containers run with local TZ baked,
146
+ * so those are already local wall clock — re-shifting them as UTC would be
147
+ * wrong. The `tz` passed by /logs is the GATEWAY/operator zone
148
+ * (`resolveEnvTimezone` of the gateway process), not the target agent's
149
+ * zone — intended, since /logs is an operator-facing surface.
150
+ *
151
+ * Pure / total — matches ONLY a well-formed leading ISO-Z stamp and preserves
152
+ * every other line (and any line whose timestamp doesn't parse) verbatim, so a
153
+ * non-timestamped or partial log line is passed through untouched. `\r`
154
+ * line endings are preserved.
155
+ */
156
+ export function renderLogTimestampsLocal(text: string, tz: string): string {
157
+ if (!text) return text
158
+ return text
159
+ .split('\n')
160
+ .map((line) => {
161
+ const m = LEADING_ISO_Z.exec(line)
162
+ if (!m) return line
163
+ const ms = Date.parse(m[1])
164
+ if (Number.isNaN(ms)) return line
165
+ return `${fmtLocalStamp(ms, tz)}${m[2] === '' ? '' : ' '}${line.slice(m[0].length)}`
166
+ })
167
+ .join('\n')
168
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Outcome regression for the forwarded-message history row (#3300 / #3162).
3
+ *
4
+ * SCOPE (honest): this suite pins the PERSISTENCE leg of the end-to-end
5
+ * chain — `parseForwardOrigin` (#3162) feeding `recordInbound` against the
6
+ * real bun:sqlite history store. The ROUTING leg (an update reaching the
7
+ * pipeline at all — the layer where the 2026-07-16 silent drop happened) is
8
+ * pinned by `catch-all-unhandled-message.test.ts`, which drives the real
9
+ * production catch-all module on a real grammy composer. Together the two
10
+ * suites cover the chain; this one alone also passes on pre-#3300 code
11
+ * because #3162's persistence was always correct — it was simply never
12
+ * reached for the dropped message.
13
+ *
14
+ * Runs under bun (history.ts uses bun:sqlite; gateway.ts itself is a
15
+ * side-effecting module that cannot be imported into a test).
16
+ */
17
+
18
+ import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
19
+ import { mkdtempSync, rmSync } from 'fs'
20
+ import { tmpdir } from 'os'
21
+ import { join } from 'path'
22
+ import {
23
+ initHistory,
24
+ recordInbound,
25
+ query as queryHistory,
26
+ _resetForTests as resetHistory,
27
+ } from '../history.js'
28
+ import { parseForwardOrigin } from '../gateway/forward-origin.js'
29
+
30
+ let stateDir: string
31
+
32
+ beforeEach(() => {
33
+ resetHistory()
34
+ stateDir = mkdtempSync(join(tmpdir(), 'catch-all-forward-'))
35
+ initHistory(stateDir, 0) // 0 disables the init-time prune so we can seed cleanly
36
+ })
37
+
38
+ afterEach(() => {
39
+ resetHistory()
40
+ rmSync(stateDir, { recursive: true, force: true })
41
+ })
42
+
43
+ describe('(a) a forwarded plain-text message records a history row with forwarded_from', () => {
44
+ it('parses the server-stamped forward_origin and persists forwarded_from', () => {
45
+ const chat_id = '777'
46
+ const message_id = 8100
47
+ // Telegram Bot API 7.0 forward_origin, server-stamped (untrusted body,
48
+ // trusted attrs) — a plain-text message forwarded from a user.
49
+ const forwardOrigin = parseForwardOrigin({
50
+ type: 'user',
51
+ date: 1_700_000_000,
52
+ sender_user: { id: 424242, is_bot: false, first_name: 'Ken', last_name: 'Thompson' },
53
+ })
54
+ expect(forwardOrigin).toBeDefined()
55
+ expect(forwardOrigin!.name).toBe('Ken Thompson')
56
+
57
+ recordInbound({
58
+ chat_id,
59
+ thread_id: null,
60
+ message_id,
61
+ user: 'ken',
62
+ user_id: '777',
63
+ ts: 1_700_000_100,
64
+ text: 'here is the brief I forwarded',
65
+ forwarded_from: forwardOrigin!.name,
66
+ forwarded_from_type: forwardOrigin!.type,
67
+ forwarded_from_id: forwardOrigin!.id != null ? String(forwardOrigin!.id) : null,
68
+ })
69
+
70
+ const rows = queryHistory({ chat_id, limit: 10 })
71
+ expect(rows).toHaveLength(1)
72
+ const row = rows[0]
73
+ // A delivered turn is present (the message was NOT dropped) AND carries
74
+ // forwarded provenance.
75
+ expect(row.role).toBe('user')
76
+ expect(row.text).toBe('here is the brief I forwarded')
77
+ expect(row.forwarded_from).toBe('Ken Thompson')
78
+ expect(row.forwarded_from_type).toBe('user')
79
+ expect(row.forwarded_from_id).toBe('424242')
80
+ })
81
+
82
+ it('a non-forwarded message leaves forwarded_from NULL (no false provenance)', () => {
83
+ const chat_id = '778'
84
+ // undefined forward_origin → parseForwardOrigin returns undefined.
85
+ const forwardOrigin = parseForwardOrigin(undefined)
86
+ expect(forwardOrigin).toBeUndefined()
87
+
88
+ recordInbound({
89
+ chat_id,
90
+ thread_id: null,
91
+ message_id: 8200,
92
+ user: 'ken',
93
+ user_id: '778',
94
+ ts: 1_700_000_200,
95
+ text: 'a normal message',
96
+ forwarded_from: forwardOrigin ?? null,
97
+ })
98
+
99
+ const rows = queryHistory({ chat_id, limit: 10 })
100
+ expect(rows).toHaveLength(1)
101
+ expect(rows[0].forwarded_from).toBeNull()
102
+ })
103
+ })
@@ -0,0 +1,264 @@
1
+ /**
2
+ * Regression tests for the terminal catch-all message handler + diagnostic
3
+ * update tap (#3300).
4
+ *
5
+ * THE CLASS OF BUG: the gateway registered ONLY content-specific handlers —
6
+ * `bot.on('message:text')`, `:photo`, `:document`, … `:paid_media`. grammy
7
+ * ^1.44 routes `bot.on` as filtering middleware (`on → filter(pred, handler)
8
+ * → branch(pred, handler, pass)`, verified in grammy/out/composer.js); when
9
+ * NO registered filter matches an inbound `message`, grammy SILENTLY drops
10
+ * it — no log, no ack, no history row. Live signature (2026-07-16, klanker
11
+ * DM): message_id 19090 was allocated between an outbound reply and the next
12
+ * inbound with ZERO gateway trace while polling stayed healthy.
13
+ *
14
+ * THE FIX under test is the REAL production module
15
+ * (`gateway/unhandled-message.ts`) — the same `installUpdateTap` /
16
+ * `installUnhandledMessageCatchAll` gateway.ts wires — driven through a real
17
+ * grammy 1.44 Bot via `bot.handleUpdate`, with a real `bot.on('message:text')`
18
+ * registered BEFORE the catch-all exactly like the gateway's registration
19
+ * order. (gateway.ts itself is a side-effecting module that cannot be
20
+ * imported into a unit test — the extraction into unhandled-message.ts exists
21
+ * precisely so the registered composer path is testable; a structural suite
22
+ * below guards that gateway.ts actually wires it in that order.)
23
+ *
24
+ * Outcomes asserted:
25
+ * (b) an unregistered content type reaches the catch-all, is logged, and
26
+ * yields a turn with the placeholder text naming the content type;
27
+ * (c) a plain text message is consumed by the specific handler and does
28
+ * NOT double-handle in the catch-all;
29
+ * service-noise messages (forum topic lifecycle etc.) are logged but do
30
+ * NOT become turns;
31
+ * the tap observes every update and rate-limits with a suppression summary.
32
+ */
33
+
34
+ import { describe, it, expect, beforeEach } from 'vitest'
35
+ import { readFileSync } from 'node:fs'
36
+ import { Bot, type Context } from 'grammy'
37
+ import type { Update } from 'grammy/types'
38
+ import { makeMessageUpdate, resetUpdateCounters } from './update-factory.js'
39
+ import {
40
+ installUpdateTap,
41
+ installUnhandledMessageCatchAll,
42
+ planUnhandledMessage,
43
+ SERVICE_NOISE_KEYS,
44
+ TAP_MAX_LINES_PER_MINUTE,
45
+ } from '../gateway/unhandled-message.js'
46
+
47
+ interface DeliveredTurn {
48
+ via: 'text' | 'catch-all'
49
+ text: string
50
+ update_id: number
51
+ }
52
+
53
+ /**
54
+ * Wire a real grammy Bot in the gateway's registration order using the REAL
55
+ * production install functions: tap first, specific `message:text` handler,
56
+ * terminal catch-all LAST. `onInbound` records what would flow into
57
+ * handleInboundCoalesced.
58
+ */
59
+ function buildHarness() {
60
+ const delivered: DeliveredTurn[] = []
61
+ const logLines: string[] = []
62
+
63
+ const bot = new Bot('12345:TEST_TOKEN_NOT_REAL')
64
+ bot.botInfo = {
65
+ id: 999,
66
+ is_bot: true,
67
+ first_name: 'TestBot',
68
+ username: 'test_bot',
69
+ can_join_groups: true,
70
+ can_read_all_group_messages: false,
71
+ supports_inline_queries: false,
72
+ can_connect_to_business: false,
73
+ has_main_web_app: false,
74
+ }
75
+
76
+ // REAL production tap (same call gateway.ts makes).
77
+ installUpdateTap(bot, line => logLines.push(line))
78
+
79
+ // Specific handler — representative of the gateway's message:text
80
+ // registration, placed BEFORE the catch-all exactly as in gateway.ts.
81
+ bot.on('message:text', async ctx => {
82
+ delivered.push({ via: 'text', text: ctx.message.text, update_id: ctx.update.update_id })
83
+ })
84
+
85
+ // REAL production catch-all, registered LAST (same call gateway.ts makes).
86
+ installUnhandledMessageCatchAll(
87
+ bot,
88
+ async (ctx: Context, text: string) => {
89
+ delivered.push({ via: 'catch-all', text, update_id: ctx.update.update_id })
90
+ },
91
+ line => logLines.push(line),
92
+ )
93
+
94
+ return { bot, delivered, logLines }
95
+ }
96
+
97
+ /** A message update carrying content that no `message:*` filter covers. */
98
+ function makeContentUpdate(update_id: number, content: Record<string, unknown>): Update {
99
+ return {
100
+ update_id,
101
+ message: {
102
+ message_id: 5000 + update_id,
103
+ chat: { id: 777, type: 'private' },
104
+ from: { id: 777, is_bot: false, first_name: 'Test' },
105
+ date: Math.floor(Date.now() / 1000),
106
+ ...content,
107
+ },
108
+ } as unknown as Update
109
+ }
110
+
111
+ describe('terminal catch-all — real module on a real grammy 1.44 composer (#3300)', () => {
112
+ beforeEach(() => resetUpdateCounters())
113
+
114
+ it('(c) a plain-text message is consumed by message:text and does NOT reach the catch-all', async () => {
115
+ const { bot, delivered, logLines } = buildHarness()
116
+ await bot.handleUpdate(makeMessageUpdate({ text: 'hello brief', update_id: 42 }))
117
+
118
+ expect(delivered).toHaveLength(1)
119
+ expect(delivered[0]).toMatchObject({ via: 'text', text: 'hello brief' })
120
+ // No catch-all log line — specific handler won (no double-handling).
121
+ expect(logLines.filter(l => l.includes('catch-all inbound'))).toHaveLength(0)
122
+ })
123
+
124
+ it('(b) an unregistered content type reaches the catch-all, is logged, and yields a placeholder turn', async () => {
125
+ const { bot, delivered, logLines } = buildHarness()
126
+ await bot.handleUpdate(makeContentUpdate(99, { unknown_future_type: { some: 'payload' } }))
127
+
128
+ // Logged with content KEYS + ids only (never the payload body).
129
+ const catchAllLines = logLines.filter(l => l.includes('catch-all inbound'))
130
+ expect(catchAllLines).toHaveLength(1)
131
+ expect(catchAllLines[0]).toContain('update_id=99')
132
+ expect(catchAllLines[0]).toContain('content_keys=[unknown_future_type]')
133
+ expect(catchAllLines[0]).not.toContain('payload')
134
+
135
+ // Produced a delivered turn with placeholder text naming the content type.
136
+ expect(delivered).toHaveLength(1)
137
+ expect(delivered[0]).toMatchObject({
138
+ via: 'catch-all',
139
+ text: '(unhandled message content: unknown_future_type)',
140
+ })
141
+ })
142
+
143
+ it('a caption on an unhandled content type is used as the turn text (best-effort)', async () => {
144
+ const { bot, delivered } = buildHarness()
145
+ await bot.handleUpdate(
146
+ makeContentUpdate(120, { some_new_media: { id: 'x' }, caption: 'read this brief' }),
147
+ )
148
+ expect(delivered).toHaveLength(1)
149
+ expect(delivered[0]).toMatchObject({ via: 'catch-all', text: 'read this brief' })
150
+ })
151
+
152
+ it('known-noise service messages are LOGGED but produce NO agent turn', async () => {
153
+ const { bot, delivered, logLines } = buildHarness()
154
+ await bot.handleUpdate(
155
+ makeContentUpdate(130, { forum_topic_created: { name: 'spam topic', icon_color: 1 } }),
156
+ )
157
+ await bot.handleUpdate(
158
+ makeContentUpdate(131, { new_chat_members: [{ id: 5, is_bot: false, first_name: 'X' }] }),
159
+ )
160
+
161
+ // No turns delivered — service noise never reaches the agent.
162
+ expect(delivered).toHaveLength(0)
163
+ // But both were explicitly logged (never silently dropped).
164
+ const noiseLines = logLines.filter(l => l.includes('action=log-only'))
165
+ expect(noiseLines).toHaveLength(2)
166
+ expect(noiseLines[0]).toContain('content_keys=[forum_topic_created]')
167
+ expect(noiseLines[1]).toContain('content_keys=[new_chat_members]')
168
+ })
169
+
170
+ it('the diagnostic tap observes EVERY update (pass-through, both routed and caught)', async () => {
171
+ const { bot, logLines } = buildHarness()
172
+ await bot.handleUpdate(makeMessageUpdate({ text: 'routed', update_id: 1 }))
173
+ await bot.handleUpdate(makeContentUpdate(2, { unknown_future_type: {} }))
174
+
175
+ const rx = logLines.filter(l => l.includes('rx update_id='))
176
+ expect(rx).toHaveLength(2)
177
+ expect(rx[0]).toContain('rx update_id=1 type=message')
178
+ expect(rx[1]).toContain('rx update_id=2 type=message')
179
+ })
180
+ })
181
+
182
+ describe('planUnhandledMessage — service-noise classification', () => {
183
+ it('every SERVICE_NOISE_KEYS entry classifies log-only', () => {
184
+ for (const key of SERVICE_NOISE_KEYS) {
185
+ const plan = planUnhandledMessage({ message_id: 1, chat: {}, date: 0, [key]: {} })
186
+ expect(plan.action, `key ${key} should be log-only`).toBe('log-only')
187
+ }
188
+ })
189
+
190
+ it('an unknown content type classifies as a turn (fail-toward-delivery)', () => {
191
+ const plan = planUnhandledMessage({ message_id: 1, chat: {}, date: 0, mystery_type: {} })
192
+ expect(plan).toMatchObject({
193
+ action: 'turn',
194
+ text: '(unhandled message content: mystery_type)',
195
+ })
196
+ })
197
+
198
+ it('a message mixing noise with real content still becomes a turn', () => {
199
+ const plan = planUnhandledMessage({
200
+ message_id: 1, chat: {}, date: 0,
201
+ boost_added: {}, mystery_media: {}, caption: 'look',
202
+ })
203
+ expect(plan).toMatchObject({ action: 'turn', text: 'look' })
204
+ })
205
+ })
206
+
207
+ describe('installUpdateTap — rate limit', () => {
208
+ it('caps lines per minute and emits ONE suppression summary on window rollover', async () => {
209
+ const logLines: string[] = []
210
+ let now = 1_000_000
211
+ const mw: Array<(ctx: unknown, next: () => Promise<void>) => Promise<void>> = []
212
+ const fakeBot = { use: (fn: (ctx: unknown, next: () => Promise<void>) => Promise<void>) => { mw.push(fn) } }
213
+ installUpdateTap(fakeBot as never, l => logLines.push(l), () => now)
214
+
215
+ const fire = (update_id: number) =>
216
+ mw[0]({ update: { update_id }, message: undefined } as never, async () => {})
217
+
218
+ for (let i = 0; i < TAP_MAX_LINES_PER_MINUTE + 50; i++) await fire(i)
219
+ expect(logLines.filter(l => l.includes('rx update_id='))).toHaveLength(TAP_MAX_LINES_PER_MINUTE)
220
+
221
+ // Roll the window: the 50 suppressed lines surface as ONE summary.
222
+ now += 61_000
223
+ await fire(9999)
224
+ const summaries = logLines.filter(l => l.includes('suppressed 50 update lines'))
225
+ expect(summaries).toHaveLength(1)
226
+ // And logging resumes in the fresh window.
227
+ expect(logLines.filter(l => l.includes('rx update_id=9999'))).toHaveLength(1)
228
+ })
229
+ })
230
+
231
+ // ─── Structural guard on the real gateway.ts wiring ────────────────────────
232
+ // The functional harness above proves the composer contract using the REAL
233
+ // production install functions; this guards that gateway.ts wires those same
234
+ // functions in the required order. Hardened beyond a naive regex: it fails
235
+ // if the install call disappears (e.g. refactored away) OR if ANY
236
+ // `bot.on('message…')` registration appears after the catch-all install.
237
+ describe('gateway.ts catch-all registration invariant', () => {
238
+ const SRC = readFileSync(new URL('../gateway/gateway.ts', import.meta.url), 'utf8')
239
+
240
+ it('imports and installs the real catch-all + tap from unhandled-message.ts', () => {
241
+ expect(SRC).toContain("from './unhandled-message.js'")
242
+ expect(SRC).toContain('installUpdateTap(bot,')
243
+ expect(SRC).toContain('installUnhandledMessageCatchAll(')
244
+ })
245
+
246
+ it('the catch-all install is AFTER every message handler registration', () => {
247
+ const installIdx = SRC.indexOf('installUnhandledMessageCatchAll(')
248
+ expect(installIdx).toBeGreaterThan(0)
249
+ const after = SRC.slice(installIdx)
250
+ // No message-filter registration of any spelling may follow the terminal
251
+ // catch-all — grammy ordering is the no-double-handling guarantee.
252
+ // (`message_reaction` etc. are DIFFERENT update types, not `message`
253
+ // filters — the pattern requires `message` exactly or `message:<sub>`.)
254
+ expect(after).not.toMatch(/bot\.on\(\s*['"`]message(:|['"`])/)
255
+ // And there must be exactly one catch-all install (no duplicate turns).
256
+ expect(SRC.indexOf('installUnhandledMessageCatchAll(', installIdx + 1)).toBe(-1)
257
+ })
258
+
259
+ it('routes the catch-all through handleInboundCoalesced (same gating + forward-origin path)', () => {
260
+ const installIdx = SRC.indexOf('installUnhandledMessageCatchAll(')
261
+ const body = SRC.slice(installIdx, installIdx + 400)
262
+ expect(body).toMatch(/handleInboundCoalesced\(ctx, text, undefined\)/)
263
+ })
264
+ })