switchroom 0.18.11 → 0.18.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-scheduler/index.js +37 -5
- package/dist/auth-broker/index.js +116 -78
- package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
- package/dist/cli/ms-365-write-pretool.mjs +31 -8
- package/dist/cli/notion-write-pretool.mjs +38 -6
- package/dist/cli/skill-validate-pretool.mjs +144 -2847
- package/dist/cli/switchroom.js +3529 -4543
- package/dist/cli/ui/index.html +163 -17
- package/dist/host-control/main.js +605 -2847
- package/dist/vault/approvals/kernel-server.js +120 -13
- package/dist/vault/broker/server.js +259 -157
- package/package.json +3 -4
- package/profiles/_base/start.sh.hbs +65 -0
- package/profiles/_shared/vault-protocol.md.hbs +3 -1
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/bridge/bridge.ts +37 -0
- package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
- package/telegram-plugin/dist/bridge/bridge.js +122 -4
- package/telegram-plugin/dist/gateway/gateway.js +4213 -3288
- package/telegram-plugin/dist/server.js +139 -5
- package/telegram-plugin/flood-circuit-breaker.ts +493 -21
- package/telegram-plugin/format.ts +19 -0
- package/telegram-plugin/gateway/approval-hold.ts +602 -0
- package/telegram-plugin/gateway/auth-command.ts +92 -2
- package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
- package/telegram-plugin/gateway/boot-card.ts +12 -5
- package/telegram-plugin/gateway/callback-query-handlers.ts +88 -1
- package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
- package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
- package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
- package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
- package/telegram-plugin/gateway/gateway.ts +1695 -230
- package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
- package/telegram-plugin/gateway/idle-clear.ts +90 -6
- package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
- package/telegram-plugin/gateway/inject-handler.ts +8 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
- package/telegram-plugin/gateway/ipc-server.ts +43 -0
- package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
- package/telegram-plugin/gateway/model-command.ts +9 -3
- package/telegram-plugin/gateway/pending-session-command.ts +13 -1
- package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
- package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
- package/telegram-plugin/gateway/queued-card-store.ts +217 -0
- package/telegram-plugin/gateway/session-model-file.ts +26 -1
- package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
- package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
- package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
- package/telegram-plugin/history.ts +51 -0
- package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
- package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
- package/telegram-plugin/hooks/hooks.json +9 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +303 -2
- package/telegram-plugin/model-unavailable.ts +41 -11
- package/telegram-plugin/operator-events.ts +23 -0
- package/telegram-plugin/outbound-field-redact.ts +69 -0
- package/telegram-plugin/package.json +0 -1
- package/telegram-plugin/permission-rule.ts +1 -0
- package/telegram-plugin/permission-title.ts +1 -0
- package/telegram-plugin/render/render.ts +32 -14
- package/telegram-plugin/retry-api-call.ts +212 -2
- package/telegram-plugin/scoped-approval.ts +11 -2
- package/telegram-plugin/secret-detect/chunker.ts +18 -4
- package/telegram-plugin/secret-detect/index.ts +12 -56
- package/telegram-plugin/send-gate-degraded.test.ts +574 -0
- package/telegram-plugin/send-gate-observability.test.ts +470 -0
- package/telegram-plugin/send-gate-observability.ts +355 -0
- package/telegram-plugin/send-gate.test.ts +717 -0
- package/telegram-plugin/send-gate.ts +1056 -0
- package/telegram-plugin/session-tail.ts +82 -7
- package/telegram-plugin/shared/bot-runtime.ts +17 -5
- package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
- package/telegram-plugin/status-pin-driver.ts +52 -7
- package/telegram-plugin/status-pin.ts +81 -0
- package/telegram-plugin/subagent-watcher.ts +173 -18
- package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
- package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
- package/telegram-plugin/tests/approval-hold-outcome.test.ts +327 -0
- package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
- package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
- package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
- package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
- package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
- package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
- package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
- package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
- package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
- package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
- package/telegram-plugin/tests/history.test.ts +115 -0
- package/telegram-plugin/tests/idle-clear.test.ts +233 -3
- package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
- package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
- package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +448 -0
- package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
- package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
- package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
- package/telegram-plugin/tests/model-command.test.ts +14 -0
- package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
- package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
- package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
- package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
- package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
- package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
- package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
- package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
- package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
- package/telegram-plugin/tests/render/render.test.ts +88 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
- package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
- package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
- package/telegram-plugin/tests/session-model-file.test.ts +50 -0
- package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
- package/telegram-plugin/tests/status-pin.test.ts +275 -1
- package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
- package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
- package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
- package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
- package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
- package/telegram-plugin/typing-emitter.ts +224 -0
- package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
- package/telegram-plugin/welcome-text.ts +42 -0
- package/telegram-plugin/worktree-watch-cwds.ts +194 -5
- package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
- package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
- package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
- package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
- package/vendor/hindsight-memory/scripts/session_end.py +14 -3
- package/vendor/hindsight-memory/scripts/session_start.py +21 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
- package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
- package/vendor/hindsight-memory/tests/test_pending.py +44 -0
- package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
- package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
- package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
- package/telegram-plugin/channel-envelope-safety.ts +0 -56
- package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
- package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Observability + operator alerting for the Telegram send gate (#3084, PR 3/3).
|
|
3
|
+
*
|
|
4
|
+
* WHY
|
|
5
|
+
* ---
|
|
6
|
+
* PRs 1 and 2 built the gate (token buckets, priority shedding, degraded mode,
|
|
7
|
+
* restart-proof scoped flood windows) but a live ban is still SILENT: the
|
|
8
|
+
* counters sit in memory and a prolonged flood window is invisible until a user
|
|
9
|
+
* notices dead air. part3-design §6 closes that gap:
|
|
10
|
+
*
|
|
11
|
+
* 1. A low-frequency one-line stats summary to the gateway-supervisor stderr
|
|
12
|
+
* log (only when a counter changed — no log spam), plus a snapshot on every
|
|
13
|
+
* flood-window open / close.
|
|
14
|
+
* 2. An operator alert when a flood window has been open past a threshold
|
|
15
|
+
* (~60s), at-most-once per window.
|
|
16
|
+
*
|
|
17
|
+
* DELIVERABILITY OF THE ALERT (the tricky part)
|
|
18
|
+
* ---------------------------------------------
|
|
19
|
+
* The alert is a `critical` send, but it must NOT fight the very window it is
|
|
20
|
+
* reporting: sending a "you're flood-banned" card straight into an open GLOBAL
|
|
21
|
+
* ban just feeds the ban. So the observer distinguishes:
|
|
22
|
+
*
|
|
23
|
+
* - The operator chat is NOT covered by any open window (e.g. a `chat:<other>`
|
|
24
|
+
* ban while the operator's own chat is clear) → deliver IMMEDIATELY, and
|
|
25
|
+
* persist an `alertedAt` marker on the window record so a restart mid-window
|
|
26
|
+
* never re-alerts.
|
|
27
|
+
* - The operator chat IS covered (a `global` ban, or a ban on the operator's
|
|
28
|
+
* own chat) → do NOT send during the window. Remember it is owed and send a
|
|
29
|
+
* "was banned from X to Y" alert when the window CLOSES (or as soon as the
|
|
30
|
+
* operator chat becomes reachable again).
|
|
31
|
+
*
|
|
32
|
+
* STATUS OF THE IMMEDIATE PATH (today vs. after #3111)
|
|
33
|
+
* ----------------------------------------------------
|
|
34
|
+
* The immediate-delivery branch is present and unit-tested, but it does NOT
|
|
35
|
+
* fire in production yet: `openScopedWindowsForOpts` opens a coincident `global`
|
|
36
|
+
* window on every 429, so the operator chat is always "covered" while any ban is
|
|
37
|
+
* active and every alert defers to close. It becomes live once #3111 makes
|
|
38
|
+
* `onFloodWait` scope-precise. Until then the operator is alerted at window
|
|
39
|
+
* CLOSE — once per incident, all scopes coalesced into one card (M1, #3112).
|
|
40
|
+
*
|
|
41
|
+
* At-most-once is anchored by the persisted `alertedAt` (survives restart). The
|
|
42
|
+
* deferred close-alert is best-effort at-least-once: if the gateway is down for
|
|
43
|
+
* the entire tail of the window and the record is pruned before it is seen
|
|
44
|
+
* again, the close alert is dropped — acceptable, because the invariant that
|
|
45
|
+
* matters is "never RE-alert" (which `alertedAt` guarantees), not "always alert".
|
|
46
|
+
*
|
|
47
|
+
* DETERMINISM / TESTABILITY
|
|
48
|
+
* -------------------------
|
|
49
|
+
* Everything is driven by an injectable `Clock` and manual `tick()` calls; the
|
|
50
|
+
* gateway wires `tick()` to a periodic timer. Window IO and the alert send are
|
|
51
|
+
* injected, so the module unit-tests with no disk, timer, or bot.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
import type { Clock, SendGateStats } from './send-gate.js'
|
|
55
|
+
import type { FloodWindowRecord } from './flood-circuit-breaker.js'
|
|
56
|
+
|
|
57
|
+
/** One-line, human-scannable summary of the gate counters + global fill. */
|
|
58
|
+
export function formatStatsLine(stats: SendGateStats): string {
|
|
59
|
+
const c = stats.global
|
|
60
|
+
const globalFill = Math.round(stats.fill.global * 10) / 10
|
|
61
|
+
const perChat = Object.keys(stats.fill.perChat).length
|
|
62
|
+
return (
|
|
63
|
+
`telegram gateway: send-gate stats: ` +
|
|
64
|
+
`sent=${c.sent} queued=${c.queued} coalesced=${c.coalesced} dropped=${c.dropped} ` +
|
|
65
|
+
`shed=${c.shed} expired=${c.expired} failedFast=${c.failedFast} ` +
|
|
66
|
+
`msgStates=${stats.messageStates} chatBuckets=${perChat} globalFill=${globalFill}\n`
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** True when any of the seven gate counters differ between two snapshots. */
|
|
71
|
+
export function countersChanged(a: SendGateStats['global'], b: SendGateStats['global']): boolean {
|
|
72
|
+
return (
|
|
73
|
+
a.sent !== b.sent ||
|
|
74
|
+
a.queued !== b.queued ||
|
|
75
|
+
a.coalesced !== b.coalesced ||
|
|
76
|
+
a.dropped !== b.dropped ||
|
|
77
|
+
a.shed !== b.shed ||
|
|
78
|
+
a.expired !== b.expired ||
|
|
79
|
+
a.failedFast !== b.failedFast
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface StatsLoggerConfig {
|
|
84
|
+
/** Snapshot source (the gate's `stats()`). */
|
|
85
|
+
stats: () => SendGateStats
|
|
86
|
+
/** Where the one-line summary goes (gateway-supervisor stderr). */
|
|
87
|
+
log: (line: string) => void
|
|
88
|
+
/** Injectable clock. */
|
|
89
|
+
clock: Clock
|
|
90
|
+
/** Minimum ms between logged lines. Default 60_000. */
|
|
91
|
+
intervalMs?: number
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface StatsLogger {
|
|
95
|
+
/**
|
|
96
|
+
* Emit the summary IFF (a) the gate is enabled, (b) at least `intervalMs`
|
|
97
|
+
* has elapsed since the last logged line, and (c) a counter changed since
|
|
98
|
+
* the last logged line. All three must hold — so a quiet gate is silent and
|
|
99
|
+
* a busy gate logs at most once per interval.
|
|
100
|
+
*/
|
|
101
|
+
tick(): void
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Low-frequency, change-gated stats logger (part3-design §6, item 1). */
|
|
105
|
+
export function createStatsLogger(config: StatsLoggerConfig): StatsLogger {
|
|
106
|
+
const intervalMs = config.intervalMs ?? 60_000
|
|
107
|
+
let lastLoggedMs = Number.NEGATIVE_INFINITY
|
|
108
|
+
let lastCounters: SendGateStats['global'] | null = null
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
tick() {
|
|
112
|
+
const s = config.stats()
|
|
113
|
+
if (!s.enabled) return
|
|
114
|
+
const now = config.clock.now()
|
|
115
|
+
if (now - lastLoggedMs < intervalMs) return
|
|
116
|
+
// First-ever log always fires (no baseline); afterwards only on a change.
|
|
117
|
+
if (lastCounters && !countersChanged(lastCounters, s.global)) return
|
|
118
|
+
lastLoggedMs = now
|
|
119
|
+
lastCounters = { ...s.global }
|
|
120
|
+
config.log(formatStatsLine(s))
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ─── Flood-window observer: open/close snapshots + operator alerting ─────────
|
|
126
|
+
|
|
127
|
+
/** Alert-worthy scopes. `msg-edit:` windows are cosmetic and never alerted. */
|
|
128
|
+
function isAlertableScope(scopeKey: string): boolean {
|
|
129
|
+
return scopeKey === 'global' || scopeKey.startsWith('chat:') || scopeKey.startsWith('group:')
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface FloodWindowObserverConfig {
|
|
133
|
+
/** Injectable clock. */
|
|
134
|
+
clock: Clock
|
|
135
|
+
/** Where snapshot lines go. */
|
|
136
|
+
log: (line: string) => void
|
|
137
|
+
/** Gate stats source (for the open/close snapshot line). */
|
|
138
|
+
stats: () => SendGateStats
|
|
139
|
+
/** Read the persisted scoped windows (expired pruned), e.g. `readFloodWindows`. */
|
|
140
|
+
readWindows: (now: number) => FloodWindowRecord[]
|
|
141
|
+
/** Persist an `alertedAt` marker on a window record (at-most-once anchor). */
|
|
142
|
+
markAlerted: (scopeKey: string, alertedAt: number) => void
|
|
143
|
+
/**
|
|
144
|
+
* Send ONE operator alert (a `critical` send to the operator chat). Rejections
|
|
145
|
+
* are swallowed by the observer so a failed alert never breaks the tick loop.
|
|
146
|
+
*/
|
|
147
|
+
sendAlert: (text: string) => Promise<void>
|
|
148
|
+
/**
|
|
149
|
+
* Resolve the operator chat id (`allowFrom[0]`) fresh each tick — `allowFrom`
|
|
150
|
+
* can change at runtime. When it returns undefined, no chat is "covered", so
|
|
151
|
+
* every alertable window is treated as immediately deliverable to the operator.
|
|
152
|
+
*/
|
|
153
|
+
operatorChatId?: () => string | undefined
|
|
154
|
+
/** Ms a window must be open before it earns an alert. Default 60_000. */
|
|
155
|
+
alertThresholdMs?: number
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface FloodWindowObserver {
|
|
159
|
+
/** Poll persisted windows once: emit open/close snapshots + drive alerts. */
|
|
160
|
+
tick(): Promise<void>
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Human-readable ISO-ish timestamp (UTC, second precision) for alert wording. */
|
|
164
|
+
function fmtTs(ms: number): string {
|
|
165
|
+
// Strip the milliseconds (`.\d{3}`) for second precision — this also covers
|
|
166
|
+
// the `.000` case, so no separate replace is needed.
|
|
167
|
+
return new Date(ms).toISOString().replace(/\.\d{3}/, '')
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function fmtDur(ms: number): string {
|
|
171
|
+
const s = Math.round(ms / 1000)
|
|
172
|
+
if (s < 60) return `${s}s`
|
|
173
|
+
const m = Math.floor(s / 60)
|
|
174
|
+
const rem = s % 60
|
|
175
|
+
return rem ? `${m}m${rem}s` : `${m}m`
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Flood-window observer (part3-design §6, item 2 + snapshots). Reads the
|
|
180
|
+
* persisted scoped windows each tick, diffs against the previous tick to detect
|
|
181
|
+
* opens / closes (snapshot log), and decides — per the deliverability rules in
|
|
182
|
+
* the module header — whether to alert the operator now, defer to window close,
|
|
183
|
+
* or stay quiet.
|
|
184
|
+
*/
|
|
185
|
+
export function createFloodWindowObserver(
|
|
186
|
+
config: FloodWindowObserverConfig,
|
|
187
|
+
): FloodWindowObserver {
|
|
188
|
+
const alertThresholdMs = config.alertThresholdMs ?? 60_000
|
|
189
|
+
// Full records seen on the previous tick, keyed by scope (close detection).
|
|
190
|
+
let lastSeen = new Map<string, FloodWindowRecord>()
|
|
191
|
+
// First tick after boot: any window already present was loaded from disk
|
|
192
|
+
// (a ban that predates this process), NOT freshly opened — so we log a single
|
|
193
|
+
// "loaded N windows" line instead of a per-scope OPENED snapshot (L3, #3112).
|
|
194
|
+
let firstTick = true
|
|
195
|
+
// Scopes whose alert could not be delivered during the window → owed on close.
|
|
196
|
+
const owedOnClose = new Set<string>()
|
|
197
|
+
// Close alerts owed but not yet deliverable, grouped per INCIDENT (an approx
|
|
198
|
+
// untilTs bucket). One real 429 opens several scopes (global + chat:<id> +
|
|
199
|
+
// group:<id>, all sharing one untilTs — see #3111's global over-suppression),
|
|
200
|
+
// which all close in the same tick; grouping collapses them into ONE operator
|
|
201
|
+
// "flood ban cleared" card listing every scope, instead of 2–3 near-identical
|
|
202
|
+
// cards per incident (M1, #3112).
|
|
203
|
+
// NOTE (L2, #3112): owedOnClose / pendingCloseByIncident are IN-MEMORY only —
|
|
204
|
+
// a restart during an open window drops the "owed" state and the deferred
|
|
205
|
+
// close-alert may be lost. This is the accepted at-least-once tradeoff
|
|
206
|
+
// documented in the module header; the invariant that matters (never RE-alert)
|
|
207
|
+
// is anchored on the persisted `alertedAt`, not on this state.
|
|
208
|
+
const pendingCloseByIncident = new Map<number, FloodWindowRecord[]>()
|
|
209
|
+
|
|
210
|
+
/** Bucket windows of one incident together by ~1s-rounded expiry. */
|
|
211
|
+
function incidentBucket(untilTs: number): number {
|
|
212
|
+
return Math.round(untilTs / 1000)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function coversOperator(w: FloodWindowRecord, operatorChatId: string | undefined): boolean {
|
|
216
|
+
if (w.scopeKey === 'global') return true
|
|
217
|
+
if (operatorChatId == null) return false
|
|
218
|
+
return (
|
|
219
|
+
w.scopeKey === `chat:${operatorChatId}` || w.scopeKey === `group:${operatorChatId}`
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function snapshotSuffix(): string {
|
|
224
|
+
const c = config.stats().global
|
|
225
|
+
return (
|
|
226
|
+
`sent=${c.sent} shed=${c.shed} coalesced=${c.coalesced} ` +
|
|
227
|
+
`expired=${c.expired} failedFast=${c.failedFast}`
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function openAlertText(w: FloodWindowRecord, now: number): string {
|
|
232
|
+
const openFor = fmtDur(now - w.observedAt)
|
|
233
|
+
return (
|
|
234
|
+
`⚠️ Telegram flood ban active (scope \`${w.scopeKey}\`). ` +
|
|
235
|
+
`Open for ${openFor}, expected to clear at ${fmtTs(w.untilTs)} UTC. ` +
|
|
236
|
+
`Outbound to that scope is being suppressed.`
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* ONE close-alert card for a whole incident. `recs` are the scopes of a
|
|
242
|
+
* single 429 (they share an ~untilTs bucket); we list every scope and span
|
|
243
|
+
* the interval as [earliest observedAt, latest untilTs] so the operator gets
|
|
244
|
+
* one card per incident rather than one per scope (M1, #3112).
|
|
245
|
+
*/
|
|
246
|
+
function closeAlertText(recs: FloodWindowRecord[]): string {
|
|
247
|
+
const observedAt = Math.min(...recs.map((r) => r.observedAt))
|
|
248
|
+
const untilTs = Math.max(...recs.map((r) => r.untilTs))
|
|
249
|
+
const scopes = recs.map((r) => `\`${r.scopeKey}\``).join(', ')
|
|
250
|
+
const plural = recs.length > 1 ? 's' : ''
|
|
251
|
+
return (
|
|
252
|
+
`⚠️ Telegram flood ban cleared (scope${plural} ${scopes}). ` +
|
|
253
|
+
`The bot was banned from ${fmtTs(observedAt)} to ${fmtTs(untilTs)} UTC ` +
|
|
254
|
+
`(~${fmtDur(untilTs - observedAt)}). Some outbound messages during that ` +
|
|
255
|
+
`window were suppressed.`
|
|
256
|
+
)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function tick(): Promise<void> {
|
|
260
|
+
const s = config.stats()
|
|
261
|
+
if (!s.enabled) return
|
|
262
|
+
const now = config.clock.now()
|
|
263
|
+
const operatorChatId = config.operatorChatId?.()
|
|
264
|
+
const windows = config.readWindows(now)
|
|
265
|
+
const current = new Map<string, FloodWindowRecord>()
|
|
266
|
+
for (const w of windows) current.set(w.scopeKey, w)
|
|
267
|
+
const operatorReachable = !windows.some((w) => coversOperator(w, operatorChatId))
|
|
268
|
+
|
|
269
|
+
if (firstTick) {
|
|
270
|
+
// Windows present on the very first tick were loaded at boot, not opened
|
|
271
|
+
// now — log ONE "loaded" line rather than a misleading OPENED snapshot per
|
|
272
|
+
// window (L3, #3112). Alerting for these still runs below (alertedAt-gated).
|
|
273
|
+
if (windows.length > 0) {
|
|
274
|
+
config.log(
|
|
275
|
+
`telegram gateway: send-gate flood observer loaded ${windows.length} ` +
|
|
276
|
+
`open flood window(s) at boot: ${windows.map((w) => w.scopeKey).join(', ')} ` +
|
|
277
|
+
`— ${snapshotSuffix()}\n`,
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
} else {
|
|
281
|
+
// Newly-opened scopes → snapshot.
|
|
282
|
+
for (const w of windows) {
|
|
283
|
+
if (!lastSeen.has(w.scopeKey)) {
|
|
284
|
+
config.log(
|
|
285
|
+
`telegram gateway: send-gate flood window OPENED scope=${w.scopeKey} ` +
|
|
286
|
+
`untilTs=${w.untilTs} src=${w.retryAfterSrc} — ${snapshotSuffix()}\n`,
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Closed scopes → snapshot, and queue any owed close-alert (grouped per
|
|
293
|
+
// incident so the operator gets ONE card listing all scopes — M1, #3112).
|
|
294
|
+
for (const [scope, prev] of lastSeen) {
|
|
295
|
+
if (current.has(scope)) continue
|
|
296
|
+
config.log(
|
|
297
|
+
`telegram gateway: send-gate flood window CLOSED scope=${scope} ` +
|
|
298
|
+
`(was open ${fmtDur(now - prev.observedAt)}) — ${snapshotSuffix()}\n`,
|
|
299
|
+
)
|
|
300
|
+
if (owedOnClose.has(scope) && isAlertableScope(scope) && prev.alertedAt == null) {
|
|
301
|
+
const bucket = incidentBucket(prev.untilTs)
|
|
302
|
+
const list = pendingCloseByIncident.get(bucket) ?? []
|
|
303
|
+
list.push(prev)
|
|
304
|
+
pendingCloseByIncident.set(bucket, list)
|
|
305
|
+
}
|
|
306
|
+
owedOnClose.delete(scope)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Prolonged open windows → alert now (if deliverable) or defer to close.
|
|
310
|
+
for (const w of windows) {
|
|
311
|
+
if (!isAlertableScope(w.scopeKey)) continue
|
|
312
|
+
if (w.alertedAt != null) continue
|
|
313
|
+
if (now - w.observedAt < alertThresholdMs) continue
|
|
314
|
+
if (operatorReachable && !coversOperator(w, operatorChatId)) {
|
|
315
|
+
// NOTE (M1, #3112): this immediate-delivery branch is currently
|
|
316
|
+
// UNREACHABLE for recorder-produced state — `openScopedWindowsForOpts`
|
|
317
|
+
// opens a coincident `global` window on every 429, so `operatorReachable`
|
|
318
|
+
// is always false while any ban is active. It goes live once #3111 makes
|
|
319
|
+
// `onFloodWait` scope-precise. Kept + unit-tested as forward-looking.
|
|
320
|
+
// NOTE (L1, #3112): send-first, then persist `alertedAt`. A crash between
|
|
321
|
+
// the delivered card and the disk write re-alerts on restart — a benign
|
|
322
|
+
// duplicate. Deliberate: send-first guarantees an alert is never LOST,
|
|
323
|
+
// and never RE-alerting is the weaker of the two guarantees.
|
|
324
|
+
try {
|
|
325
|
+
await config.sendAlert(openAlertText(w, now))
|
|
326
|
+
config.markAlerted(w.scopeKey, now)
|
|
327
|
+
owedOnClose.delete(w.scopeKey)
|
|
328
|
+
} catch {
|
|
329
|
+
/* best-effort — a failed alert must not break the observer loop */
|
|
330
|
+
}
|
|
331
|
+
} else {
|
|
332
|
+
// Cannot deliver during the window → alert when it closes.
|
|
333
|
+
owedOnClose.add(w.scopeKey)
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Flush deferred close alerts once the operator chat is reachable again —
|
|
338
|
+
// ONE card per incident, listing every scope (M1, #3112).
|
|
339
|
+
if (operatorReachable && pendingCloseByIncident.size > 0) {
|
|
340
|
+
for (const [bucket, recs] of [...pendingCloseByIncident]) {
|
|
341
|
+
try {
|
|
342
|
+
await config.sendAlert(closeAlertText(recs))
|
|
343
|
+
pendingCloseByIncident.delete(bucket)
|
|
344
|
+
} catch {
|
|
345
|
+
/* keep it pending; retry next tick */
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
lastSeen = current
|
|
351
|
+
firstTick = false
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return { tick }
|
|
355
|
+
}
|