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,1056 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic outbound send gate for the Telegram Bot API (#3084, PR 1/3).
|
|
3
|
+
*
|
|
4
|
+
* WHY
|
|
5
|
+
* ---
|
|
6
|
+
* Each agent's gateway drives MANY outbound surfaces (reply chunks, answer /
|
|
7
|
+
* draft stream edits, typing, worker-feed edits, reactions, cards). Each has
|
|
8
|
+
* its own local throttle, but nothing composes them into a global or per-chat
|
|
9
|
+
* ceiling — so during a busy turn the per-surface throttles ADD UP with no cap
|
|
10
|
+
* and trip a per-bot-token flood ban (429 retry_after ~hours). See
|
|
11
|
+
* `part2-audit.md` §3 and issue #3084.
|
|
12
|
+
*
|
|
13
|
+
* This module is the core control from `part3-design.md` §1: ONE token-bucket
|
|
14
|
+
* scheduler that every Bot API call passes through (wired at the robustApiCall
|
|
15
|
+
* layer so no call site can bypass it). It enforces:
|
|
16
|
+
*
|
|
17
|
+
* - Global bucket: 25/sec sustained, small burst (headroom under ~30/s).
|
|
18
|
+
* - Per-chat bucket: 1/sec sustained, burst 3.
|
|
19
|
+
* - Per-group bucket: 18/min sustained, small burst (headroom under 20),
|
|
20
|
+
* keyed on chat type.
|
|
21
|
+
* - Per-message edit: >=1.5s between edits of the same message_id, with
|
|
22
|
+
* LAST-WRITE-WINS coalescing (an edit queued while one
|
|
23
|
+
* is pending REPLACES the pending payload; it never
|
|
24
|
+
* queues behind it — the next edit carries full state)
|
|
25
|
+
* AND in-flight serialization (only one send per
|
|
26
|
+
* message runs at a time; edits that arrive while a
|
|
27
|
+
* send is mid-flight — including one sleeping on a
|
|
28
|
+
* 429 retry_after — coalesce behind it rather than
|
|
29
|
+
* firing a second overlapping edit).
|
|
30
|
+
*
|
|
31
|
+
* Nested buckets like PTB's AIORateLimiter; grammY's Bottleneck numbers are the
|
|
32
|
+
* sanity reference. Sustained rate is the refill rate (= budget); the burst
|
|
33
|
+
* capacity is a small headroom under Telegram's hard window ceiling, NOT a full
|
|
34
|
+
* extra window's worth of budget (that would double the effective rate — see
|
|
35
|
+
* the M2 finding on #3092).
|
|
36
|
+
*
|
|
37
|
+
* It also skips no-op edits: identical rendered payload for the same
|
|
38
|
+
* message_id is dropped before hitting the API (Telegram 400s "message is not
|
|
39
|
+
* modified" today, which still costs flood budget).
|
|
40
|
+
*
|
|
41
|
+
* RESTART-PROOF FLOOD STATE (part3-design §7, PR 2 hook)
|
|
42
|
+
* -----------------------------------------------------
|
|
43
|
+
* `createSendGate` accepts `initialWindows` (flood windows loaded from
|
|
44
|
+
* `flood-wait.json` on boot) and `bootRamp` (start the global bucket at a
|
|
45
|
+
* fraction of capacity for the first N ms to absorb boot-card bursts).
|
|
46
|
+
* `openFloodWindow(scopeKey, untilTs)` lets PR 2 re-open a window at runtime
|
|
47
|
+
* on a 429. A bucket under an open window admits NOTHING until `untilTs`,
|
|
48
|
+
* regardless of token fill — a token bucket alone cannot express a
|
|
49
|
+
* "blocked until" suppression, so the buckets consult a per-scope window too.
|
|
50
|
+
*
|
|
51
|
+
* SAFETY / ROLLOUT
|
|
52
|
+
* ----------------
|
|
53
|
+
* ON BY DEFAULT in every install — an escape hatch, not an opt-in feature.
|
|
54
|
+
* `SWITCHROOM_TELEGRAM_SEND_GATE=0` (or `false`/`off`/`no`) is the safety valve
|
|
55
|
+
* that disables it without a rebuild, following the repo's default-on kill-
|
|
56
|
+
* switch convention (`midTurnFloorEnabled`, `SWITCHROOM_RATE_LIMIT_OVERAGE=0`).
|
|
57
|
+
* When disabled, `gate()` is a pure passthrough to the wrapped call — zero
|
|
58
|
+
* behaviour change. Priority-class shedding + degraded mode and observability +
|
|
59
|
+
* operator alert build on the counters exposed here.
|
|
60
|
+
*
|
|
61
|
+
* DETERMINISM / TESTABILITY
|
|
62
|
+
* -------------------------
|
|
63
|
+
* All time comes from an injectable `Clock` (`now()` + `sleep()`); there is no
|
|
64
|
+
* inline `Date.now()` / `setTimeout()` in the scheduling logic, so a fake clock
|
|
65
|
+
* makes the buckets fully deterministic under test. Token consumption happens
|
|
66
|
+
* in a synchronous critical section (no `await` between reading the clock and
|
|
67
|
+
* consuming), so concurrent admissions on a single-threaded runtime never
|
|
68
|
+
* double-spend a token.
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
import { createHash } from 'node:crypto'
|
|
72
|
+
import {
|
|
73
|
+
makeFloodWaitActiveError,
|
|
74
|
+
isFloodWaitActiveError,
|
|
75
|
+
} from './retry-api-call.js'
|
|
76
|
+
|
|
77
|
+
/** Injectable time source. Default binds to real wall clock + setTimeout. */
|
|
78
|
+
export interface Clock {
|
|
79
|
+
/** Milliseconds since epoch (monotonic-enough for bucket refill math). */
|
|
80
|
+
now(): number
|
|
81
|
+
/** Resolve after `ms` milliseconds. */
|
|
82
|
+
sleep(ms: number): Promise<void>
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const systemClock: Clock = {
|
|
86
|
+
now: () => Date.now(),
|
|
87
|
+
sleep: (ms: number) => new Promise<void>((r) => setTimeout(r, ms)),
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Chat type as reported by Telegram, used to key the per-group bucket. */
|
|
91
|
+
export type ChatType = 'private' | 'group' | 'supergroup' | 'channel'
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Priority class governing shedding + degraded mode (part3-design §2/§3):
|
|
95
|
+
* - `critical` — final reply chunks, approval / vault cards, error notices.
|
|
96
|
+
* Never shed; queued unbounded. Degraded mode: waits a short window,
|
|
97
|
+
* fails fast with a structured `FLOOD_WAIT_ACTIVE` for a long one.
|
|
98
|
+
* - `useful` — progress-card creation, worker handbacks, checklists,
|
|
99
|
+
* boot/config cards. Queued with a TTL; dropped (counted) when stale.
|
|
100
|
+
* - `cosmetic` — typing, reactions, all card EDITS, stream updates,
|
|
101
|
+
* heartbeats. Shed immediately (counted) when no token is free OR any
|
|
102
|
+
* flood window covering the scope is open.
|
|
103
|
+
*
|
|
104
|
+
* UNTAGGED default (L2, review PR #3106): an untagged NON-EDIT send defaults to
|
|
105
|
+
* `critical` — NON-droppable (queued unbounded, fail-fast only on a long
|
|
106
|
+
* window), NOT `useful`. Rationale: most call sites are untagged, and a `useful`
|
|
107
|
+
* default silently TTL-drops an untagged-but-important send under pressure
|
|
108
|
+
* (a resolved `undefined` a caller reads as "sent"). Conservative posture:
|
|
109
|
+
* nothing is droppable unless a call site OPTS IN by tagging `useful`/`cosmetic`.
|
|
110
|
+
* (Untagged EDITS keep coalescing — the latest payload always wins, never
|
|
111
|
+
* dropped — so their default is non-droppable already.)
|
|
112
|
+
*/
|
|
113
|
+
export type PriorityClass = 'critical' | 'useful' | 'cosmetic'
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Priority class an UNTAGGED non-edit send is admitted as. `critical` =
|
|
117
|
+
* non-droppable (L2). See the `PriorityClass` doc above.
|
|
118
|
+
*/
|
|
119
|
+
export const UNTAGGED_SEND_CLASS: PriorityClass = 'critical'
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Extra metadata a call site can attach so the gate can key the right buckets.
|
|
123
|
+
* All fields optional — a call with none still passes the global bucket. These
|
|
124
|
+
* mirror (a superset of) `RetryCallOpts` so the gate can wrap `robustApiCall`
|
|
125
|
+
* transparently.
|
|
126
|
+
*/
|
|
127
|
+
export interface SendGateOpts {
|
|
128
|
+
/** Destination chat id — keys the per-chat and (for groups) per-group bucket. */
|
|
129
|
+
chat_id?: string
|
|
130
|
+
/** Chat type — a group/supergroup additionally passes the per-group bucket. */
|
|
131
|
+
chatType?: ChatType
|
|
132
|
+
/** For edits: the target message id. Enables the edit floor + coalescing. */
|
|
133
|
+
messageId?: number
|
|
134
|
+
/**
|
|
135
|
+
* For edits: the rendered payload (any stable-stringifiable value). Used for
|
|
136
|
+
* the no-op skip (hash equal to last sent → dropped) and last-write-wins
|
|
137
|
+
* coalescing. Only meaningful together with `messageId`.
|
|
138
|
+
*/
|
|
139
|
+
editPayload?: unknown
|
|
140
|
+
/** Informational label (e.g. "editMessageText"); surfaced in stats/logs. */
|
|
141
|
+
verb?: string
|
|
142
|
+
/**
|
|
143
|
+
* Priority class for shedding + degraded mode (part3-design §2/§3). Untagged
|
|
144
|
+
* NON-EDIT calls default to `critical` (non-droppable — see
|
|
145
|
+
* `UNTAGGED_SEND_CLASS`); untagged edits coalesce (also non-droppable).
|
|
146
|
+
*/
|
|
147
|
+
priorityClass?: PriorityClass
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Per-bucket counters, snapshotted by `stats()`. */
|
|
151
|
+
export interface BucketCounters {
|
|
152
|
+
/** Calls that executed the wrapped fn. */
|
|
153
|
+
sent: number
|
|
154
|
+
/** Calls that had to wait on at least one bucket before executing. */
|
|
155
|
+
queued: number
|
|
156
|
+
/** Edits whose payload replaced a still-pending edit for the same message. */
|
|
157
|
+
coalesced: number
|
|
158
|
+
/** Calls dropped without hitting the API (no-op edit skip). */
|
|
159
|
+
dropped: number
|
|
160
|
+
/**
|
|
161
|
+
* Cosmetic calls shed under pressure — no token free OR a flood window open
|
|
162
|
+
* (part3-design §2). A shed resolves as `undefined`; the next send carries
|
|
163
|
+
* full state.
|
|
164
|
+
*/
|
|
165
|
+
shed: number
|
|
166
|
+
/** Useful calls dropped because they exceeded their queue TTL (part3-design §2). */
|
|
167
|
+
expired: number
|
|
168
|
+
/**
|
|
169
|
+
* Critical calls that failed fast with a structured `FLOOD_WAIT_ACTIVE`
|
|
170
|
+
* because the open window exceeded the fail-fast ceiling (part3-design §3).
|
|
171
|
+
*/
|
|
172
|
+
failedFast: number
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface SendGateStats {
|
|
176
|
+
enabled: boolean
|
|
177
|
+
global: BucketCounters
|
|
178
|
+
/** Number of live per-message edit states (watch for the H2 leak). */
|
|
179
|
+
messageStates: number
|
|
180
|
+
/** Current token fill of each live bucket (for observability, PR 3). */
|
|
181
|
+
fill: {
|
|
182
|
+
global: number
|
|
183
|
+
perChat: Record<string, number>
|
|
184
|
+
perGroup: Record<string, number>
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** A flood window: a scope suppressed until `untilTs` (part3-design §7). */
|
|
189
|
+
export interface FloodWindow {
|
|
190
|
+
/** `global` | `chat:<id>` | `group:<id>` | `msg-edit:<id>`. */
|
|
191
|
+
scopeKey: string
|
|
192
|
+
/** Epoch ms until which the scope admits nothing. */
|
|
193
|
+
untilTs: number
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Boot ramp for the global bucket (part3-design §7): start at a fraction of
|
|
198
|
+
* capacity for `durationMs` after boot so a burst of boot/config cards can't
|
|
199
|
+
* immediately saturate the freshly-full bucket.
|
|
200
|
+
*/
|
|
201
|
+
export interface BootRamp {
|
|
202
|
+
/** Fraction of the global burst capacity during the ramp (0..1). Default 0.5. */
|
|
203
|
+
fraction?: number
|
|
204
|
+
/** Ramp duration in ms from gate construction. Default 10_000. */
|
|
205
|
+
durationMs?: number
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export interface SendGateConfig {
|
|
209
|
+
/** Master switch. When false, `gate()` is a straight passthrough. */
|
|
210
|
+
enabled: boolean
|
|
211
|
+
/** Injected clock; defaults to the real system clock. */
|
|
212
|
+
clock?: Clock
|
|
213
|
+
/** Global bucket sustained rate (tokens/sec). Default 25. */
|
|
214
|
+
globalPerSec?: number
|
|
215
|
+
/**
|
|
216
|
+
* Global burst capacity (headroom under Telegram's ~30/s). Default 4.
|
|
217
|
+
* Worst-case sliding-window admissions = capacity + rate·T, so a full 1s
|
|
218
|
+
* window admits at most `globalBurst + globalPerSec` = 4 + 25 = 29 < 30 —
|
|
219
|
+
* a real margin under the ceiling (#3092 M2 residual: burst 5 hit exactly 30).
|
|
220
|
+
*/
|
|
221
|
+
globalBurst?: number
|
|
222
|
+
/** Per-chat sustained rate (tokens/sec). Default 1. */
|
|
223
|
+
perChatPerSec?: number
|
|
224
|
+
/** Per-chat burst capacity. Default 3. */
|
|
225
|
+
perChatBurst?: number
|
|
226
|
+
/** Per-group sustained rate (tokens/min). Default 18. */
|
|
227
|
+
perGroupPerMin?: number
|
|
228
|
+
/** Per-group burst capacity (headroom under 20/min). Default 2. */
|
|
229
|
+
perGroupBurst?: number
|
|
230
|
+
/** Minimum ms between edits of the same message_id. Default 1500. */
|
|
231
|
+
editFloorMs?: number
|
|
232
|
+
/**
|
|
233
|
+
* Flood windows to re-open at construction (part3-design §7). PR 2 loads
|
|
234
|
+
* these from `flood-wait.json` BEFORE the first outbound call so a restart
|
|
235
|
+
* during an active ban does not immediately resend into the flood.
|
|
236
|
+
*/
|
|
237
|
+
initialWindows?: FloodWindow[]
|
|
238
|
+
/** Global-bucket boot ramp (part3-design §7). Omit to start full. */
|
|
239
|
+
bootRamp?: BootRamp
|
|
240
|
+
/**
|
|
241
|
+
* TTL (ms) after which an idle per-message edit state is evicted. Default
|
|
242
|
+
* 60_000. Prevents unbounded growth of the perMessage map (#3092 H2).
|
|
243
|
+
*/
|
|
244
|
+
messageStateTtlMs?: number
|
|
245
|
+
/**
|
|
246
|
+
* Hard cap on live per-message edit states; oldest-idle are evicted (LRU)
|
|
247
|
+
* once exceeded. Default 5_000.
|
|
248
|
+
*/
|
|
249
|
+
maxMessageStates?: number
|
|
250
|
+
/**
|
|
251
|
+
* TTL (ms) a `useful` call may wait in the admission queue before it is
|
|
252
|
+
* dropped as stale (part3-design §2). Default 120_000 (~2 min).
|
|
253
|
+
*/
|
|
254
|
+
usefulTtlMs?: number
|
|
255
|
+
/**
|
|
256
|
+
* Ceiling (ms) on an open flood window under which a `critical` send WAITS
|
|
257
|
+
* (single in-flight, jitter); above it, the send fails fast with a structured
|
|
258
|
+
* `FLOOD_WAIT_ACTIVE` carrying `untilTs` so the MCP reply path surfaces a real
|
|
259
|
+
* error instead of a long opaque block (part3-design §3). Default 60_000.
|
|
260
|
+
*/
|
|
261
|
+
criticalFailFastMs?: number
|
|
262
|
+
/**
|
|
263
|
+
* Max jitter (ms) added before a `critical` send probes the API during an
|
|
264
|
+
* open (short) window, so serialized criticals don't thunder at the exact
|
|
265
|
+
* window edge. Default 250.
|
|
266
|
+
*/
|
|
267
|
+
criticalJitterMaxMs?: number
|
|
268
|
+
/**
|
|
269
|
+
* Injectable 0..1 source for the critical jitter (tests pass `() => 0` for
|
|
270
|
+
* determinism). Default `Math.random`.
|
|
271
|
+
*/
|
|
272
|
+
jitter?: () => number
|
|
273
|
+
/**
|
|
274
|
+
* Called whenever `openFloodWindow` opens / extends a window at RUNTIME (on a
|
|
275
|
+
* 429). Write-through persistence hook (part3-design §7) — the gateway wires
|
|
276
|
+
* this to `flood-windows.json` so a window survives a restart. NOT called for
|
|
277
|
+
* `initialWindows` applied at construction (those already came from disk).
|
|
278
|
+
*/
|
|
279
|
+
onWindowOpen?: (scopeKey: string, untilTs: number) => void
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Classic token bucket with an optional per-scope suppression window and an
|
|
284
|
+
* optional boot ramp. `tokens` refills continuously at `refillPerMs` up to
|
|
285
|
+
* `capacity` (or `rampCapacity` while inside the ramp). `consume` and
|
|
286
|
+
* `msUntilAvailable` both refill lazily against the supplied `now`, so there is
|
|
287
|
+
* no timer per bucket. A suppression window (`suppressUntil`) blocks all
|
|
288
|
+
* admission until its `untilTs`, regardless of token fill — a token bucket
|
|
289
|
+
* alone cannot represent "blocked until T" (part3-design §7).
|
|
290
|
+
*/
|
|
291
|
+
class TokenBucket {
|
|
292
|
+
private tokens: number
|
|
293
|
+
private lastRefillMs: number
|
|
294
|
+
private suppressedUntilMs = 0
|
|
295
|
+
private readonly rampCapacity: number
|
|
296
|
+
private readonly rampUntilMs: number
|
|
297
|
+
|
|
298
|
+
constructor(
|
|
299
|
+
readonly capacity: number,
|
|
300
|
+
private readonly refillPerMs: number,
|
|
301
|
+
now: number,
|
|
302
|
+
ramp?: { capacity: number; untilMs: number },
|
|
303
|
+
) {
|
|
304
|
+
this.rampCapacity = ramp?.capacity ?? capacity
|
|
305
|
+
this.rampUntilMs = ramp?.untilMs ?? 0
|
|
306
|
+
this.tokens = this.capAt(now)
|
|
307
|
+
this.lastRefillMs = now
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Effective capacity ceiling at `now` (lower during the boot ramp). */
|
|
311
|
+
private capAt(now: number): number {
|
|
312
|
+
return now < this.rampUntilMs ? this.rampCapacity : this.capacity
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private refill(now: number): void {
|
|
316
|
+
if (now <= this.lastRefillMs) return
|
|
317
|
+
const elapsed = now - this.lastRefillMs
|
|
318
|
+
this.tokens = Math.min(this.capAt(now), this.tokens + elapsed * this.refillPerMs)
|
|
319
|
+
this.lastRefillMs = now
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Open/extend a suppression window (part3-design §7). */
|
|
323
|
+
suppressUntil(untilTs: number): void {
|
|
324
|
+
if (untilTs > this.suppressedUntilMs) this.suppressedUntilMs = untilTs
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Remaining ms of an open suppression window (0 when none). */
|
|
328
|
+
windowRemainingMs(now: number): number {
|
|
329
|
+
return this.suppressedUntilMs > now ? this.suppressedUntilMs - now : 0
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Milliseconds until at least one token is available AND no suppression
|
|
334
|
+
* window is open (0 if admissible now).
|
|
335
|
+
*/
|
|
336
|
+
msUntilAvailable(now: number): number {
|
|
337
|
+
const windowWait = this.suppressedUntilMs > now ? this.suppressedUntilMs - now : 0
|
|
338
|
+
this.refill(now)
|
|
339
|
+
const tokenWait = this.tokens >= 1 ? 0 : Math.ceil((1 - this.tokens) / this.refillPerMs)
|
|
340
|
+
return Math.max(windowWait, tokenWait)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Consume one token. Caller must have checked availability in the SAME tick. */
|
|
344
|
+
consume(now: number): void {
|
|
345
|
+
this.refill(now)
|
|
346
|
+
this.tokens -= 1
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
fill(now: number): number {
|
|
350
|
+
this.refill(now)
|
|
351
|
+
return this.tokens
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
interface PendingEdit {
|
|
356
|
+
hash: string
|
|
357
|
+
fn: () => Promise<unknown>
|
|
358
|
+
promise: Promise<unknown>
|
|
359
|
+
resolve: (v: unknown) => void
|
|
360
|
+
reject: (e: unknown) => void
|
|
361
|
+
/**
|
|
362
|
+
* Effective priority of the CURRENTLY-queued edit for this message. Set when
|
|
363
|
+
* the pending edit is created and UPGRADED (never downgraded) on coalesce, so
|
|
364
|
+
* that a `critical` edit coalescing onto a non-critical driver still gets the
|
|
365
|
+
* critical fail-fast treatment. The driver reads THIS (not the driver-start
|
|
366
|
+
* opts) to decide fail-fast vs unbounded admit (F2, review 2026-07-12).
|
|
367
|
+
*/
|
|
368
|
+
priorityClass: PriorityClass
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Total order over priority classes: cosmetic < useful < critical. */
|
|
372
|
+
const PRIORITY_RANK: Record<PriorityClass, number> = {
|
|
373
|
+
cosmetic: 0,
|
|
374
|
+
useful: 1,
|
|
375
|
+
critical: 2,
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Return the HIGHER-priority of two classes (upgrade-only; never downgrades). */
|
|
379
|
+
function maxPriority(a: PriorityClass, b: PriorityClass): PriorityClass {
|
|
380
|
+
return PRIORITY_RANK[b] > PRIORITY_RANK[a] ? b : a
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
interface MessageEditState {
|
|
384
|
+
/** Wall time of the last edit SEND START for this message. */
|
|
385
|
+
lastSentMs: number
|
|
386
|
+
/** Hash of the last payload SUCCESSFULLY sent (for the no-op skip). */
|
|
387
|
+
lastHash: string | undefined
|
|
388
|
+
/** At most one queued coalesced edit per message (last-write-wins). */
|
|
389
|
+
pending: PendingEdit | null
|
|
390
|
+
/** True while a driver is actively sending / waiting for this message. */
|
|
391
|
+
running: boolean
|
|
392
|
+
/** Per-message flood suppression window (part3-design §7). */
|
|
393
|
+
suppressedUntilMs: number
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function hashPayload(payload: unknown): string {
|
|
397
|
+
let s: string
|
|
398
|
+
if (typeof payload === 'string') {
|
|
399
|
+
s = payload
|
|
400
|
+
} else {
|
|
401
|
+
const j = stableStringify(payload)
|
|
402
|
+
// `stableStringify(undefined)` (and any value that JSON.stringify drops)
|
|
403
|
+
// returns undefined; hash a fixed sentinel so createHash never throws
|
|
404
|
+
// (#3092 L2 — a caller may set editPayload: undefined alongside messageId).
|
|
405
|
+
s = j === undefined ? 'undefined' : j
|
|
406
|
+
}
|
|
407
|
+
return createHash('sha256').update(s).digest('hex')
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Deterministic JSON stringify (sorted keys) so equal payloads hash equal. */
|
|
411
|
+
function stableStringify(value: unknown): string | undefined {
|
|
412
|
+
return JSON.stringify(value, (_k, v) => {
|
|
413
|
+
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
|
414
|
+
return Object.keys(v as Record<string, unknown>)
|
|
415
|
+
.sort()
|
|
416
|
+
.reduce<Record<string, unknown>>((acc, k) => {
|
|
417
|
+
acc[k] = (v as Record<string, unknown>)[k]
|
|
418
|
+
return acc
|
|
419
|
+
}, {})
|
|
420
|
+
}
|
|
421
|
+
return v
|
|
422
|
+
})
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export interface SendGate {
|
|
426
|
+
/**
|
|
427
|
+
* Run `fn` (a single Bot API call) through the gate. Returns whatever `fn`
|
|
428
|
+
* resolves to. For a dropped no-op edit, resolves to `undefined` without
|
|
429
|
+
* calling `fn`. For a coalesced edit, resolves when the coalesced send
|
|
430
|
+
* completes (with that send's result).
|
|
431
|
+
*/
|
|
432
|
+
gate<T>(fn: () => Promise<T>, opts?: SendGateOpts): Promise<T>
|
|
433
|
+
/**
|
|
434
|
+
* Open/extend a flood-suppression window on a scope (part3-design §7). Used
|
|
435
|
+
* by PR 2 on a 429 and at boot from the persisted `flood-wait.json`.
|
|
436
|
+
*/
|
|
437
|
+
openFloodWindow(scopeKey: string, untilTs: number): void
|
|
438
|
+
/** Snapshot of counters + current bucket fill. */
|
|
439
|
+
stats(): SendGateStats
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const GROUP_TYPES = new Set<ChatType>(['group', 'supergroup'])
|
|
443
|
+
|
|
444
|
+
export function createSendGate(config: SendGateConfig): SendGate {
|
|
445
|
+
const enabled = config.enabled
|
|
446
|
+
const clock = config.clock ?? systemClock
|
|
447
|
+
const globalPerSec = config.globalPerSec ?? 25
|
|
448
|
+
const globalBurst = config.globalBurst ?? 4
|
|
449
|
+
const perChatPerSec = config.perChatPerSec ?? 1
|
|
450
|
+
const perChatBurst = config.perChatBurst ?? 3
|
|
451
|
+
const perGroupPerMin = config.perGroupPerMin ?? 18
|
|
452
|
+
const perGroupBurst = config.perGroupBurst ?? 2
|
|
453
|
+
const editFloorMs = config.editFloorMs ?? 1500
|
|
454
|
+
const messageStateTtlMs = config.messageStateTtlMs ?? 60_000
|
|
455
|
+
const maxMessageStates = config.maxMessageStates ?? 5_000
|
|
456
|
+
const usefulTtlMs = config.usefulTtlMs ?? 120_000
|
|
457
|
+
const criticalFailFastMs = config.criticalFailFastMs ?? 60_000
|
|
458
|
+
const criticalJitterMaxMs = config.criticalJitterMaxMs ?? 250
|
|
459
|
+
const jitter = config.jitter ?? Math.random
|
|
460
|
+
const onWindowOpen = config.onWindowOpen
|
|
461
|
+
|
|
462
|
+
const counters: BucketCounters = {
|
|
463
|
+
sent: 0,
|
|
464
|
+
queued: 0,
|
|
465
|
+
coalesced: 0,
|
|
466
|
+
dropped: 0,
|
|
467
|
+
shed: 0,
|
|
468
|
+
expired: 0,
|
|
469
|
+
failedFast: 0,
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const bootStart = clock.now()
|
|
473
|
+
const globalRamp = config.bootRamp
|
|
474
|
+
? {
|
|
475
|
+
capacity: Math.max(1, Math.floor(globalBurst * (config.bootRamp.fraction ?? 0.5))),
|
|
476
|
+
untilMs: bootStart + (config.bootRamp.durationMs ?? 10_000),
|
|
477
|
+
}
|
|
478
|
+
: undefined
|
|
479
|
+
|
|
480
|
+
const globalBucket = new TokenBucket(globalBurst, globalPerSec / 1000, bootStart, globalRamp)
|
|
481
|
+
const perChat = new Map<string, TokenBucket>()
|
|
482
|
+
const perGroup = new Map<string, TokenBucket>()
|
|
483
|
+
// H1 (review PR #3106): keyed by `${chat_id}:${messageId}`, NOT messageId
|
|
484
|
+
// alone. Telegram `message_id` is per-chat, not globally unique — two chats
|
|
485
|
+
// routinely both hold a low-numbered card (id 3, 100, …). Keying on the id
|
|
486
|
+
// alone collides cross-chat: chat B's edit overwrites chat A's pending edit
|
|
487
|
+
// (A's real update silently lost), and the edit floor / msg-edit suppression
|
|
488
|
+
// window of one chat sheds an unrelated card in another.
|
|
489
|
+
const perMessage = new Map<string, MessageEditState>()
|
|
490
|
+
let lastSweepMs = bootStart
|
|
491
|
+
|
|
492
|
+
function chatBucket(chatId: string): TokenBucket {
|
|
493
|
+
let b = perChat.get(chatId)
|
|
494
|
+
if (!b) {
|
|
495
|
+
b = new TokenBucket(perChatBurst, perChatPerSec / 1000, clock.now())
|
|
496
|
+
perChat.set(chatId, b)
|
|
497
|
+
}
|
|
498
|
+
return b
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function groupBucket(chatId: string): TokenBucket {
|
|
502
|
+
let b = perGroup.get(chatId)
|
|
503
|
+
if (!b) {
|
|
504
|
+
b = new TokenBucket(perGroupBurst, perGroupPerMin / 60000, clock.now())
|
|
505
|
+
perGroup.set(chatId, b)
|
|
506
|
+
}
|
|
507
|
+
return b
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Per-message state key: `${chat_id}:${messageId}` (H1). A missing chat_id
|
|
512
|
+
* degrades to `:${messageId}` — still unique per call site, and edits always
|
|
513
|
+
* carry a chat_id in production. This is ALSO the `msg-edit:` scope suffix, so
|
|
514
|
+
* the boot-reloaded scoped windows and the runtime edit floor agree.
|
|
515
|
+
*/
|
|
516
|
+
function messageKey(chatId: string | undefined, messageId: number): string {
|
|
517
|
+
return `${chatId ?? ''}:${messageId}`
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function messageState(key: string): MessageEditState {
|
|
521
|
+
let state = perMessage.get(key)
|
|
522
|
+
if (!state) {
|
|
523
|
+
state = {
|
|
524
|
+
lastSentMs: Number.NEGATIVE_INFINITY,
|
|
525
|
+
lastHash: undefined,
|
|
526
|
+
pending: null,
|
|
527
|
+
running: false,
|
|
528
|
+
suppressedUntilMs: 0,
|
|
529
|
+
}
|
|
530
|
+
perMessage.set(key, state)
|
|
531
|
+
}
|
|
532
|
+
return state
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Apply any flood windows injected at construction (§7 boot load) WITHOUT
|
|
537
|
+
* re-persisting them (they already came from disk). Applied before the first
|
|
538
|
+
* outbound call so a boot mid-ban never resends into an open window.
|
|
539
|
+
*/
|
|
540
|
+
for (const w of config.initialWindows ?? []) applyWindow(w.scopeKey, w.untilTs, false)
|
|
541
|
+
|
|
542
|
+
function bucketsFor(opts?: SendGateOpts): TokenBucket[] {
|
|
543
|
+
const buckets: TokenBucket[] = [globalBucket]
|
|
544
|
+
if (opts?.chat_id) {
|
|
545
|
+
buckets.push(chatBucket(opts.chat_id))
|
|
546
|
+
if (opts.chatType && GROUP_TYPES.has(opts.chatType)) {
|
|
547
|
+
buckets.push(groupBucket(opts.chat_id))
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
return buckets
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Open/extend a flood window on a scope. `global` suppresses the global
|
|
555
|
+
* bucket; `chat:<id>` / `group:<id>` the per-chat / per-group buckets;
|
|
556
|
+
* `msg-edit:<id>` the per-message edit floor. Idempotent + monotonic
|
|
557
|
+
* (only ever extends). Persists write-through via `onWindowOpen` (part3-design
|
|
558
|
+
* §7) so the window survives a restart.
|
|
559
|
+
*/
|
|
560
|
+
function openFloodWindow(scopeKey: string, untilTs: number): void {
|
|
561
|
+
// M3 (review PR #3106): flag-OFF is a PURE no-op. The gateway wires
|
|
562
|
+
// `onFloodWait` → `openFloodWindow('global', …)` unconditionally, so without
|
|
563
|
+
// this guard a 429 would mutate in-memory suppression AND write a new
|
|
564
|
+
// `flood-windows.json` even while the feature is disabled — a real fs side
|
|
565
|
+
// effect and behaviour change before the flag is ever enabled. When
|
|
566
|
+
// disabled the gate must open no window and write no file (the separate
|
|
567
|
+
// #2923 `flood-wait.json` recorder is unaffected — it is not wired here).
|
|
568
|
+
if (!enabled) return
|
|
569
|
+
applyWindow(scopeKey, untilTs, true)
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Open all scope windows implied by a call's `opts` at `untilTs` — global,
|
|
574
|
+
* plus per-chat / per-group / per-message where keyed. Called when a wrapped
|
|
575
|
+
* send surfaces a `FLOOD_WAIT_ACTIVE` (a real 429 or a pre-call
|
|
576
|
+
* short-circuit) so the ban is remembered at the finest scope we know
|
|
577
|
+
* (part3-design §7). Global is always opened as the conservative floor.
|
|
578
|
+
*/
|
|
579
|
+
function openScopedWindowsForOpts(opts: SendGateOpts | undefined, untilTs: number): void {
|
|
580
|
+
applyWindow('global', untilTs, true)
|
|
581
|
+
if (opts?.chat_id) {
|
|
582
|
+
applyWindow(`chat:${opts.chat_id}`, untilTs, true)
|
|
583
|
+
if (opts.chatType && GROUP_TYPES.has(opts.chatType)) {
|
|
584
|
+
applyWindow(`group:${opts.chat_id}`, untilTs, true)
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
if (opts?.messageId != null) {
|
|
588
|
+
// H1: msg-edit scope keyed by chat_id+messageId so a 429 on one chat's
|
|
589
|
+
// card never sheds another chat's same-id card.
|
|
590
|
+
applyWindow(`msg-edit:${messageKey(opts.chat_id, opts.messageId)}`, untilTs, true)
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** Core window application; `persist` gates the write-through hook. */
|
|
595
|
+
function applyWindow(scopeKey: string, untilTs: number, persist: boolean): void {
|
|
596
|
+
if (persist && onWindowOpen) {
|
|
597
|
+
try {
|
|
598
|
+
onWindowOpen(scopeKey, untilTs)
|
|
599
|
+
} catch {
|
|
600
|
+
/* best-effort — persistence must not break the send path */
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
if (scopeKey === 'global') {
|
|
604
|
+
globalBucket.suppressUntil(untilTs)
|
|
605
|
+
} else if (scopeKey.startsWith('chat:')) {
|
|
606
|
+
chatBucket(scopeKey.slice('chat:'.length)).suppressUntil(untilTs)
|
|
607
|
+
} else if (scopeKey.startsWith('group:')) {
|
|
608
|
+
groupBucket(scopeKey.slice('group:'.length)).suppressUntil(untilTs)
|
|
609
|
+
} else if (scopeKey.startsWith('msg-edit:')) {
|
|
610
|
+
// H1: the suffix is the composite `${chat_id}:${messageId}` state key.
|
|
611
|
+
const key = scopeKey.slice('msg-edit:'.length)
|
|
612
|
+
if (key) {
|
|
613
|
+
// N2: creating a per-message edit state here must go through the same
|
|
614
|
+
// eviction accounting (maybeEvict / LRU cap) as normal state creation,
|
|
615
|
+
// so opening many per-message flood windows can't grow perMessage past
|
|
616
|
+
// the cap.
|
|
617
|
+
const now = clock.now()
|
|
618
|
+
const existing = perMessage.get(key)
|
|
619
|
+
maybeEvict(now, existing === undefined)
|
|
620
|
+
const st = existing ?? messageState(key)
|
|
621
|
+
if (untilTs > st.suppressedUntilMs) st.suppressedUntilMs = untilTs
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function isEvictable(s: MessageEditState, now: number): boolean {
|
|
627
|
+
return !s.pending && !s.running && s.suppressedUntilMs <= now
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Bound the perMessage map (#3092 H2). Two mechanisms: a TTL sweep of idle
|
|
632
|
+
* states older than `messageStateTtlMs`, and a hard LRU cap. An entry with a
|
|
633
|
+
* pending/running send or an open suppression window is never evicted.
|
|
634
|
+
*/
|
|
635
|
+
function maybeEvict(now: number, reserving: boolean): void {
|
|
636
|
+
// Leave room for the entry about to be created so the map never settles
|
|
637
|
+
// ABOVE the cap (evicting AFTER the insert would perpetually sit at cap+1).
|
|
638
|
+
const target = Math.max(0, maxMessageStates - (reserving ? 1 : 0))
|
|
639
|
+
if (perMessage.size > target) {
|
|
640
|
+
const evictable = [...perMessage.entries()]
|
|
641
|
+
.filter(([, s]) => isEvictable(s, now))
|
|
642
|
+
.sort((a, b) => a[1].lastSentMs - b[1].lastSentMs)
|
|
643
|
+
let over = perMessage.size - target
|
|
644
|
+
for (const [k] of evictable) {
|
|
645
|
+
if (over <= 0) break
|
|
646
|
+
perMessage.delete(k)
|
|
647
|
+
over--
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
if (now - lastSweepMs >= messageStateTtlMs) {
|
|
651
|
+
lastSweepMs = now
|
|
652
|
+
for (const [k, s] of perMessage) {
|
|
653
|
+
if (isEvictable(s, now) && now - s.lastSentMs > messageStateTtlMs) {
|
|
654
|
+
perMessage.delete(k)
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Wait until every bucket has a token (and no scope window is open), then
|
|
662
|
+
* consume one from each. The check-and-consume block runs synchronously (no
|
|
663
|
+
* `await` after reading the clock), so concurrent admissions never
|
|
664
|
+
* double-spend. Loops because a competing admission may drain a bucket during
|
|
665
|
+
* our sleep. When `deadline` is set (a `useful` call's TTL), returns `false`
|
|
666
|
+
* without consuming if admission cannot happen before it — the caller drops
|
|
667
|
+
* the call as stale (part3-design §2).
|
|
668
|
+
*/
|
|
669
|
+
async function admitLoop(buckets: TokenBucket[], deadline?: number): Promise<boolean> {
|
|
670
|
+
let counted = false
|
|
671
|
+
for (;;) {
|
|
672
|
+
const now = clock.now()
|
|
673
|
+
let wait = 0
|
|
674
|
+
for (const b of buckets) wait = Math.max(wait, b.msUntilAvailable(now))
|
|
675
|
+
if (wait <= 0) {
|
|
676
|
+
for (const b of buckets) b.consume(now)
|
|
677
|
+
return true
|
|
678
|
+
}
|
|
679
|
+
if (deadline !== undefined && now + wait > deadline) return false
|
|
680
|
+
if (!counted) {
|
|
681
|
+
counters.queued++
|
|
682
|
+
counted = true
|
|
683
|
+
}
|
|
684
|
+
await clock.sleep(deadline !== undefined ? Math.min(wait, deadline - now) : wait)
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/** Back-compat unbounded admission (used by the per-message edit driver). */
|
|
689
|
+
function admit(buckets: TokenBucket[]): Promise<boolean> {
|
|
690
|
+
return admitLoop(buckets)
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// Serializes `critical` sends that must probe the API during an open (short)
|
|
694
|
+
// flood window: only ONE in-flight at a time so a burst of criticals doesn't
|
|
695
|
+
// thunder at the window edge (part3-design §3, "single in-flight, jitter").
|
|
696
|
+
let criticalTail: Promise<void> = Promise.resolve()
|
|
697
|
+
function criticalSerialize<R>(job: () => Promise<R>): Promise<R> {
|
|
698
|
+
const prev = criticalTail
|
|
699
|
+
let release!: () => void
|
|
700
|
+
criticalTail = new Promise<void>((r) => {
|
|
701
|
+
release = r
|
|
702
|
+
})
|
|
703
|
+
return (async () => {
|
|
704
|
+
await prev
|
|
705
|
+
try {
|
|
706
|
+
return await job()
|
|
707
|
+
} finally {
|
|
708
|
+
release()
|
|
709
|
+
}
|
|
710
|
+
})()
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
type AdmitOutcome =
|
|
714
|
+
| { result: 'ok' }
|
|
715
|
+
| { result: 'shed' }
|
|
716
|
+
| { result: 'expired' }
|
|
717
|
+
| { result: 'failfast'; untilTs: number }
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Priority-aware admission (part3-design §2/§3):
|
|
721
|
+
* - cosmetic — shed immediately when any bucket has a wait (no token OR a
|
|
722
|
+
* window open); otherwise consume and go.
|
|
723
|
+
* - useful — queue with a TTL; drop as stale past the deadline.
|
|
724
|
+
* - critical — never shed. If a window covering the scope exceeds the
|
|
725
|
+
* fail-fast ceiling, fail fast with `untilTs` (caller throws a structured
|
|
726
|
+
* FLOOD_WAIT_ACTIVE). A short window → wait single-in-flight with jitter.
|
|
727
|
+
* No window → queue unbounded.
|
|
728
|
+
*/
|
|
729
|
+
async function admitPriority(
|
|
730
|
+
buckets: TokenBucket[],
|
|
731
|
+
priority: PriorityClass,
|
|
732
|
+
): Promise<AdmitOutcome> {
|
|
733
|
+
const now = clock.now()
|
|
734
|
+
if (priority === 'cosmetic') {
|
|
735
|
+
let wait = 0
|
|
736
|
+
for (const b of buckets) wait = Math.max(wait, b.msUntilAvailable(now))
|
|
737
|
+
if (wait > 0) return { result: 'shed' }
|
|
738
|
+
for (const b of buckets) b.consume(now)
|
|
739
|
+
return { result: 'ok' }
|
|
740
|
+
}
|
|
741
|
+
if (priority === 'critical') {
|
|
742
|
+
let remaining = 0
|
|
743
|
+
for (const b of buckets) remaining = Math.max(remaining, b.windowRemainingMs(now))
|
|
744
|
+
if (remaining > criticalFailFastMs) {
|
|
745
|
+
return { result: 'failfast', untilTs: now + remaining }
|
|
746
|
+
}
|
|
747
|
+
if (remaining > 0) {
|
|
748
|
+
// Degraded but short window: serialize + jitter, then wait it out —
|
|
749
|
+
// re-checking the ceiling EACH loop (admitCriticalLoop) so a window
|
|
750
|
+
// extended past `criticalFailFastMs` while we wait converts to fail-fast
|
|
751
|
+
// instead of blocking the reply path unbounded (M2, review PR #3106).
|
|
752
|
+
return await criticalSerialize(async () => {
|
|
753
|
+
const j = Math.floor(jitter() * criticalJitterMaxMs)
|
|
754
|
+
if (j > 0) await clock.sleep(j)
|
|
755
|
+
return await admitCriticalLoop(buckets)
|
|
756
|
+
})
|
|
757
|
+
}
|
|
758
|
+
return await admitCriticalLoop(buckets)
|
|
759
|
+
}
|
|
760
|
+
// useful (default): TTL-bounded queue.
|
|
761
|
+
const admitted = await admitLoop(buckets, now + usefulTtlMs)
|
|
762
|
+
return admitted ? { result: 'ok' } : { result: 'expired' }
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* Unbounded critical admission that RE-EVALUATES the covering window on every
|
|
767
|
+
* iteration (M2, review PR #3106). If a competing 429 extends the window past
|
|
768
|
+
* the fail-fast ceiling while a critical waits out a short window, this stops
|
|
769
|
+
* blocking and returns `failfast` (the caller throws a structured
|
|
770
|
+
* `FLOOD_WAIT_ACTIVE`) rather than blocking the MCP reply path for the whole
|
|
771
|
+
* extended ban — exactly the "opaque long block" part3-design §3 forbids.
|
|
772
|
+
*/
|
|
773
|
+
async function admitCriticalLoop(buckets: TokenBucket[]): Promise<AdmitOutcome> {
|
|
774
|
+
let counted = false
|
|
775
|
+
for (;;) {
|
|
776
|
+
const now = clock.now()
|
|
777
|
+
let remaining = 0
|
|
778
|
+
let wait = 0
|
|
779
|
+
for (const b of buckets) {
|
|
780
|
+
remaining = Math.max(remaining, b.windowRemainingMs(now))
|
|
781
|
+
wait = Math.max(wait, b.msUntilAvailable(now))
|
|
782
|
+
}
|
|
783
|
+
if (remaining > criticalFailFastMs) return { result: 'failfast', untilTs: now + remaining }
|
|
784
|
+
if (wait <= 0) {
|
|
785
|
+
for (const b of buckets) b.consume(now)
|
|
786
|
+
return { result: 'ok' }
|
|
787
|
+
}
|
|
788
|
+
if (!counted) {
|
|
789
|
+
counters.queued++
|
|
790
|
+
counted = true
|
|
791
|
+
}
|
|
792
|
+
await clock.sleep(wait)
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* The single serialized driver for one message. Only ONE driver runs per
|
|
798
|
+
* message at a time (`state.running`); every edit that arrives while it runs
|
|
799
|
+
* — whether it is sleeping on the floor, waiting on a bucket, or mid-flight
|
|
800
|
+
* on the network (e.g. a 429 retry_after sleep INSIDE `fn`) — coalesces into
|
|
801
|
+
* `state.pending` rather than firing a second overlapping send. This closes
|
|
802
|
+
* the same-tick double-send (M3) and the in-flight parallelism (M4).
|
|
803
|
+
*/
|
|
804
|
+
async function drive(state: MessageEditState, opts: SendGateOpts): Promise<void> {
|
|
805
|
+
state.running = true
|
|
806
|
+
try {
|
|
807
|
+
while (state.pending) {
|
|
808
|
+
const now = clock.now()
|
|
809
|
+
const readyAt = Math.max(state.lastSentMs + editFloorMs, state.suppressedUntilMs)
|
|
810
|
+
const waitMs = readyAt - now
|
|
811
|
+
if (waitMs > 0) {
|
|
812
|
+
// Still inside the floor / an open window — sleep, then re-read
|
|
813
|
+
// state.pending (a newer edit may have replaced it in the meantime).
|
|
814
|
+
await clock.sleep(waitMs)
|
|
815
|
+
continue
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// Floor cleared: take the current pending edit. A newer edit arriving
|
|
819
|
+
// from here on lands in a FRESH state.pending and is handled next loop.
|
|
820
|
+
const p = state.pending
|
|
821
|
+
state.pending = null
|
|
822
|
+
|
|
823
|
+
// Re-check the no-op skip against the last SUCCESSFULLY-sent payload —
|
|
824
|
+
// the latest coalesced payload may have reverted to what's on screen.
|
|
825
|
+
if (p.hash === state.lastHash) {
|
|
826
|
+
counters.dropped++
|
|
827
|
+
p.resolve(undefined)
|
|
828
|
+
continue
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// A `critical` edit must NEVER block unbounded (part3-design §3). Mirror
|
|
832
|
+
// the non-edit critical path in `gate`: admit via the priority-aware loop
|
|
833
|
+
// so a flood-wait ban longer than the fail-fast ceiling rejects with a
|
|
834
|
+
// structured FLOOD_WAIT_ACTIVE — and a window that EXTENDS past the
|
|
835
|
+
// ceiling while we wait out a short window converts to fail-fast too (M2,
|
|
836
|
+
// review PR #3106) — instead of the unbounded `admit` below, which would
|
|
837
|
+
// re-introduce the multi-hour reply wedge the send-gate exists to
|
|
838
|
+
// eliminate (F4, review 2026-07-11). Non-critical edits keep PR 1's
|
|
839
|
+
// unbounded coalescing admit unchanged.
|
|
840
|
+
//
|
|
841
|
+
// Read the CURRENT pending edit's class (`p.priorityClass`), NOT the
|
|
842
|
+
// driver-start `opts`: a `critical` edit that coalesced onto a driver
|
|
843
|
+
// started by a non-critical edit upgraded `p.priorityClass`, and MUST
|
|
844
|
+
// fail-fast here rather than ride the non-critical unbounded admit for
|
|
845
|
+
// the whole ban (F2, review 2026-07-12).
|
|
846
|
+
if (p.priorityClass === 'critical') {
|
|
847
|
+
const outcome = await admitPriority(bucketsFor(opts), 'critical')
|
|
848
|
+
if (outcome.result === 'failfast') {
|
|
849
|
+
counters.failedFast++
|
|
850
|
+
const retryAfterSec = Math.ceil((outcome.untilTs - clock.now()) / 1000)
|
|
851
|
+
openScopedWindowsForOpts(opts, outcome.untilTs)
|
|
852
|
+
// Reject THIS edit's own promise (fail fast) and loop: a distinct
|
|
853
|
+
// newer edit that arrived during the wait owns a fresh state.pending
|
|
854
|
+
// and is handled on the next iteration.
|
|
855
|
+
p.reject(makeFloodWaitActiveError(retryAfterSec, outcome.untilTs, null))
|
|
856
|
+
continue
|
|
857
|
+
}
|
|
858
|
+
// outcome.result === 'ok' → admitPriority already consumed the buckets.
|
|
859
|
+
} else {
|
|
860
|
+
await admit(bucketsFor(opts))
|
|
861
|
+
}
|
|
862
|
+
// Reserve the send-start time BEFORE awaiting the network so the floor
|
|
863
|
+
// is measured from send start (matches the per-message serialization).
|
|
864
|
+
state.lastSentMs = clock.now()
|
|
865
|
+
try {
|
|
866
|
+
// N3 (liveness): this awaits `p.fn()` with no watchdog. A `fn` that
|
|
867
|
+
// NEVER settles would keep `state.running` true forever, making the
|
|
868
|
+
// state unevictable and hanging every coalesced caller. We rely on
|
|
869
|
+
// the fact that the only production caller is `robustApiCall`, whose
|
|
870
|
+
// own request/retry timeouts bound every send — so `p.fn()` is
|
|
871
|
+
// guaranteed to settle. No separate watchdog is needed here.
|
|
872
|
+
const res = await p.fn()
|
|
873
|
+
// M1: only record the payload as on-screen AFTER a successful send,
|
|
874
|
+
// so a FAILED edit can be retried with the same payload (not dropped
|
|
875
|
+
// as a phantom no-op).
|
|
876
|
+
state.lastHash = p.hash
|
|
877
|
+
counters.sent++
|
|
878
|
+
p.resolve(res)
|
|
879
|
+
} catch (err) {
|
|
880
|
+
// A 429 surfaced from the edit send opens the flood windows at this
|
|
881
|
+
// scope so later cosmetic edits shed and the window persists (§3/§7).
|
|
882
|
+
if (isFloodWaitActiveError(err)) {
|
|
883
|
+
openScopedWindowsForOpts(opts, err.untilTs)
|
|
884
|
+
}
|
|
885
|
+
// H1: reject THIS edit's own promise (the closure-local `p`), never
|
|
886
|
+
// state.pending — a distinct newer edit that arrived during the send
|
|
887
|
+
// owns state.pending and must survive to be sent on the next loop.
|
|
888
|
+
p.reject(err)
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
} finally {
|
|
892
|
+
state.running = false
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
function handleEdit<T>(fn: () => Promise<T>, opts: SendGateOpts): Promise<T> {
|
|
897
|
+
const messageId = opts.messageId as number
|
|
898
|
+
const key = messageKey(opts.chat_id, messageId)
|
|
899
|
+
const hash = hashPayload(opts.editPayload)
|
|
900
|
+
const now = clock.now()
|
|
901
|
+
const existing = perMessage.get(key)
|
|
902
|
+
maybeEvict(now, existing === undefined)
|
|
903
|
+
const state = existing ?? messageState(key)
|
|
904
|
+
|
|
905
|
+
// Cosmetic edits (part3-design §2) shed under pressure — a flood window
|
|
906
|
+
// covering the scope is open, or a bucket has no token free right now. A
|
|
907
|
+
// dropped edit costs nothing: the next edit carries the full state. Only an
|
|
908
|
+
// EXPLICITLY-cosmetic edit sheds; untagged edits keep PR 1's coalescing
|
|
909
|
+
// behaviour (default = useful), so this never changes an untagged caller.
|
|
910
|
+
if ((opts.priorityClass ?? 'useful') === 'cosmetic') {
|
|
911
|
+
let wait = 0
|
|
912
|
+
for (const b of bucketsFor(opts)) wait = Math.max(wait, b.msUntilAvailable(now))
|
|
913
|
+
const msgWait = state.suppressedUntilMs > now ? state.suppressedUntilMs - now : 0
|
|
914
|
+
if (wait > 0 || msgWait > 0) {
|
|
915
|
+
counters.shed++
|
|
916
|
+
return Promise.resolve(undefined as unknown as T)
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// N1: the coalesce (last-write-wins) check runs BEFORE the no-op skip. When
|
|
921
|
+
// a distinct edit is already queued, the newest payload always replaces it —
|
|
922
|
+
// even if that payload reverts to the on-screen one (hash === lastHash).
|
|
923
|
+
// Otherwise a revert-to-on-screen edit would be dropped as a no-op while the
|
|
924
|
+
// stale queued edit still rendered, violating last-write-wins. When the
|
|
925
|
+
// driver dequeues, its own no-op re-check (`p.hash === state.lastHash`) drops
|
|
926
|
+
// a coalesced revert so nothing needless hits the API. All callers share the
|
|
927
|
+
// single pending promise, which resolves with the coalesced send's result.
|
|
928
|
+
if (state.pending) {
|
|
929
|
+
// Upgrade (never downgrade) the queued edit's effective priority. A
|
|
930
|
+
// `critical` edit coalescing onto a `useful`/`cosmetic` pending edit must
|
|
931
|
+
// ride the driver's critical fail-fast path — otherwise the critical work
|
|
932
|
+
// rides the non-critical unbounded admit and blocks for a whole flood ban
|
|
933
|
+
// (F2, review 2026-07-12). We upgrade even when the hash is unchanged (a
|
|
934
|
+
// no-op payload from a critical caller still deserves fail-fast, not an
|
|
935
|
+
// unbounded block); a lower-priority coalesce leaves the class intact.
|
|
936
|
+
state.pending.priorityClass = maxPriority(
|
|
937
|
+
state.pending.priorityClass,
|
|
938
|
+
opts.priorityClass ?? 'useful',
|
|
939
|
+
)
|
|
940
|
+
if (state.pending.hash !== hash) {
|
|
941
|
+
counters.coalesced++
|
|
942
|
+
state.pending.hash = hash
|
|
943
|
+
state.pending.fn = fn as () => Promise<unknown>
|
|
944
|
+
}
|
|
945
|
+
return state.pending.promise as Promise<T>
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// No edit queued → a repeat of the last payload we actually sent is a plain
|
|
949
|
+
// no-op skip: drop it before the API.
|
|
950
|
+
if (hash === state.lastHash) {
|
|
951
|
+
counters.dropped++
|
|
952
|
+
return Promise.resolve(undefined as unknown as T)
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// Otherwise create a fresh pending edit. If no driver is currently running
|
|
956
|
+
// for this message, start one; if one IS running (mid-flight send, floor
|
|
957
|
+
// sleep, or bucket wait), it will pick this up on its next loop — this is
|
|
958
|
+
// what serializes sends per message (M3/M4).
|
|
959
|
+
let resolve!: (v: unknown) => void
|
|
960
|
+
let reject!: (e: unknown) => void
|
|
961
|
+
const promise = new Promise<unknown>((res, rej) => {
|
|
962
|
+
resolve = res
|
|
963
|
+
reject = rej
|
|
964
|
+
})
|
|
965
|
+
const pending: PendingEdit = {
|
|
966
|
+
hash,
|
|
967
|
+
fn: fn as () => Promise<unknown>,
|
|
968
|
+
promise,
|
|
969
|
+
resolve,
|
|
970
|
+
reject,
|
|
971
|
+
priorityClass: opts.priorityClass ?? 'useful',
|
|
972
|
+
}
|
|
973
|
+
state.pending = pending
|
|
974
|
+
if (!state.running) void drive(state, opts)
|
|
975
|
+
return promise as Promise<T>
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
async function gate<T>(fn: () => Promise<T>, opts?: SendGateOpts): Promise<T> {
|
|
979
|
+
// Flag OFF → pure passthrough, zero behaviour change.
|
|
980
|
+
if (!enabled) return fn()
|
|
981
|
+
|
|
982
|
+
// Edit path (floor + coalescing + no-op skip) only when we can key it.
|
|
983
|
+
if (opts && opts.messageId != null && 'editPayload' in opts) {
|
|
984
|
+
return handleEdit(fn, opts)
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// Priority-aware admission (part3-design §2/§3). Untagged → critical
|
|
988
|
+
// (non-droppable — L2, review PR #3106); tag `useful`/`cosmetic` to opt into
|
|
989
|
+
// shedding.
|
|
990
|
+
const priority = opts?.priorityClass ?? UNTAGGED_SEND_CLASS
|
|
991
|
+
const outcome = await admitPriority(bucketsFor(opts), priority)
|
|
992
|
+
if (outcome.result === 'shed') {
|
|
993
|
+
counters.shed++
|
|
994
|
+
return undefined as unknown as T
|
|
995
|
+
}
|
|
996
|
+
if (outcome.result === 'expired') {
|
|
997
|
+
counters.expired++
|
|
998
|
+
return undefined as unknown as T
|
|
999
|
+
}
|
|
1000
|
+
if (outcome.result === 'failfast') {
|
|
1001
|
+
counters.failedFast++
|
|
1002
|
+
// Compose #3094's structured error so the MCP reply path surfaces a real
|
|
1003
|
+
// flood_wait (untilTs / retry_after) instead of an opaque long block.
|
|
1004
|
+
const retryAfterSec = Math.ceil((outcome.untilTs - clock.now()) / 1000)
|
|
1005
|
+
openScopedWindowsForOpts(opts, outcome.untilTs)
|
|
1006
|
+
throw makeFloodWaitActiveError(retryAfterSec, outcome.untilTs, null)
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
try {
|
|
1010
|
+
// N4: count `sent` only AFTER a successful send, mirroring the edit path.
|
|
1011
|
+
const res = await fn()
|
|
1012
|
+
counters.sent++
|
|
1013
|
+
return res
|
|
1014
|
+
} catch (err) {
|
|
1015
|
+
// A 429 surfaced from a non-edit send opens the scope's flood windows so
|
|
1016
|
+
// subsequent cosmetic traffic sheds and the window persists (§3/§7).
|
|
1017
|
+
if (isFloodWaitActiveError(err)) openScopedWindowsForOpts(opts, err.untilTs)
|
|
1018
|
+
throw err
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function stats(): SendGateStats {
|
|
1023
|
+
const now = clock.now()
|
|
1024
|
+
const perChatFill: Record<string, number> = {}
|
|
1025
|
+
for (const [k, b] of perChat) perChatFill[k] = b.fill(now)
|
|
1026
|
+
const perGroupFill: Record<string, number> = {}
|
|
1027
|
+
for (const [k, b] of perGroup) perGroupFill[k] = b.fill(now)
|
|
1028
|
+
return {
|
|
1029
|
+
enabled,
|
|
1030
|
+
global: { ...counters },
|
|
1031
|
+
messageStates: perMessage.size,
|
|
1032
|
+
fill: {
|
|
1033
|
+
global: globalBucket.fill(now),
|
|
1034
|
+
perChat: perChatFill,
|
|
1035
|
+
perGroup: perGroupFill,
|
|
1036
|
+
},
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
return { gate, openFloodWindow, stats }
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/**
|
|
1044
|
+
* The send gate is an ESCAPE HATCH, not an opt-in feature: it is ON BY DEFAULT
|
|
1045
|
+
* in every install and can be disabled as a safety valve. Mirrors the repo's
|
|
1046
|
+
* default-on kill-switch convention (`midTurnFloorEnabled`, `PIN_STATUS_WHILE_
|
|
1047
|
+
* WORKING`, `SWITCHROOM_RATE_LIMIT_OVERAGE`): enabled unless
|
|
1048
|
+
* `SWITCHROOM_TELEGRAM_SEND_GATE` is explicitly set to a falsey/off value
|
|
1049
|
+
* (`0`/`false`/`off`/`no`, case-insensitive, trimmed). Unset → enabled.
|
|
1050
|
+
*/
|
|
1051
|
+
export function sendGateEnabledFromEnv(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
1052
|
+
const v = env.SWITCHROOM_TELEGRAM_SEND_GATE
|
|
1053
|
+
if (v == null) return true
|
|
1054
|
+
const t = v.trim().toLowerCase()
|
|
1055
|
+
return !(t === '0' || t === 'false' || t === 'off' || t === 'no')
|
|
1056
|
+
}
|