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.
- package/bin/handoff-briefing.sh +8 -2
- package/dist/agent-scheduler/index.js +111 -7
- package/dist/auth-broker/index.js +154 -16
- package/dist/cli/autoaccept-poll.js +8 -3
- package/dist/cli/drive-write-pretool.mjs +8 -3
- package/dist/cli/ms-365-write-pretool.mjs +158 -11
- package/dist/cli/notion-write-pretool.mjs +103 -4
- package/dist/cli/switchroom.js +2089 -1587
- package/dist/host-control/main.js +110 -13
- package/dist/vault/approvals/kernel-server.js +116 -13
- package/dist/vault/broker/server.js +314 -145
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +172 -22
- package/telegram-plugin/dist/bridge/bridge.js +71 -47
- package/telegram-plugin/dist/gateway/gateway.js +601 -104
- package/telegram-plugin/dist/server.js +89 -64
- package/telegram-plugin/gateway/gateway.ts +280 -33
- package/telegram-plugin/gateway/model-command.ts +104 -0
- package/telegram-plugin/gateway/session-model-file.ts +40 -0
- package/telegram-plugin/gateway/turn-flush-suppression.ts +82 -0
- package/telegram-plugin/gateway/unhandled-message.ts +177 -0
- package/telegram-plugin/llm-error-present.ts +24 -0
- package/telegram-plugin/model-unavailable.ts +55 -0
- package/telegram-plugin/operator-events.ts +113 -0
- package/telegram-plugin/pending-user-notice.ts +88 -0
- package/telegram-plugin/shared/local-time.ts +43 -0
- package/telegram-plugin/tests/catch-all-forwarded-history.test.ts +103 -0
- package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +264 -0
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +26 -2
- package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +278 -0
- package/telegram-plugin/tests/local-time.test.ts +68 -1
- package/telegram-plugin/tests/model-command.test.ts +133 -0
- package/telegram-plugin/tests/session-model-file.test.ts +23 -0
- package/telegram-plugin/tests/turn-flush-suppression.test.ts +90 -0
- package/vendor/hindsight-memory/scripts/backfill_transcripts.py +399 -2
- package/vendor/hindsight-memory/scripts/lib/client.py +47 -0
- package/vendor/hindsight-memory/scripts/lib/content.py +53 -1
- package/vendor/hindsight-memory/scripts/lib/turnlog.py +450 -0
- package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +467 -0
- 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
|
+
})
|