switchroom 0.19.26 → 0.19.27
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/git-agent-attribution-hook.sh +144 -0
- package/dist/agent-scheduler/index.js +55 -0
- package/dist/auth-broker/index.js +116 -6
- package/dist/cli/notion-write-pretool.mjs +55 -0
- package/dist/cli/switchroom.js +2055 -1193
- package/dist/host-control/main.js +117 -7
- package/dist/vault/approvals/kernel-server.js +114 -6
- package/dist/vault/broker/server.js +114 -6
- package/package.json +6 -2
- package/profiles/_base/cron-session.sh.hbs +8 -0
- package/profiles/_base/start.sh.hbs +105 -0
- package/telegram-plugin/card-layout.ts +328 -0
- package/telegram-plugin/dist/bridge/bridge.js +93 -1
- package/telegram-plugin/dist/gateway/gateway.js +2009 -1166
- package/telegram-plugin/dist/server.js +96 -1
- package/telegram-plugin/edit-flood-fuse.ts +637 -56
- package/telegram-plugin/flood-429-ledger.ts +526 -0
- package/telegram-plugin/flood-circuit-breaker.ts +18 -0
- package/telegram-plugin/gateway/flood-reply-queue.ts +168 -0
- package/telegram-plugin/gateway/gateway.ts +58 -68
- package/telegram-plugin/gateway/narrative-lane.ts +14 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +36 -0
- package/telegram-plugin/gateway/outbox-sweep.ts +183 -6
- package/telegram-plugin/gateway/pinned-message-handler.ts +12 -16
- package/telegram-plugin/gateway/status-pin-retarget.ts +72 -36
- package/telegram-plugin/gateway/status-pin-store.ts +58 -9
- package/telegram-plugin/gateway/worker-pin-reaper.ts +56 -7
- package/telegram-plugin/llm-error-present.ts +61 -2
- package/telegram-plugin/model-unavailable.ts +8 -0
- package/telegram-plugin/operator-events.ts +72 -5
- package/telegram-plugin/outbound-class.ts +81 -0
- package/telegram-plugin/provider-credit.ts +237 -0
- package/telegram-plugin/scripts/bun-test-ci.sh +36 -6
- package/telegram-plugin/send-gate.ts +24 -2
- package/telegram-plugin/status-no-truncate.ts +10 -48
- package/telegram-plugin/status-pin-driver.ts +33 -45
- package/telegram-plugin/status-pin.ts +18 -1
- package/telegram-plugin/tests/card-golden.test.ts +69 -0
- package/telegram-plugin/tests/card-lifecycle-render.test.ts +362 -0
- package/telegram-plugin/tests/card-type-distinguishability.test.ts +187 -164
- package/telegram-plugin/tests/card-variants.golden.txt +211 -0
- package/telegram-plugin/tests/card-variants.ts +366 -0
- package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +316 -0
- package/telegram-plugin/tests/edit-flood-fuse-default-deny.test.ts +319 -0
- package/telegram-plugin/tests/edit-flood-fuse.test.ts +11 -2
- package/telegram-plugin/tests/feed-edit-rate-ceiling.test.ts +462 -0
- package/telegram-plugin/tests/fixtures/real-429-stream.ts +220 -0
- package/telegram-plugin/tests/flood-429-ledger.test.ts +278 -0
- package/telegram-plugin/tests/flood-429-recorder-wiring.test.ts +128 -0
- package/telegram-plugin/tests/flood-reply-queue.test.ts +418 -0
- package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +221 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +19 -24
- package/telegram-plugin/tests/pinned-message-handler.test.ts +15 -15
- package/telegram-plugin/tests/provider-credit-402.test.ts +243 -0
- package/telegram-plugin/tests/status-pin-api.test.ts +11 -11
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +36 -37
- package/telegram-plugin/tests/status-pin-lifecycle.test.ts +602 -0
- package/telegram-plugin/tests/status-pin-retarget.test.ts +90 -62
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +7 -3
- package/telegram-plugin/tests/status-pin-store.test.ts +109 -60
- package/telegram-plugin/tests/status-pin.test.ts +56 -5
- package/telegram-plugin/tests/test-runner-coverage.test.ts +133 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +12 -10
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +23 -29
- package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +56 -59
- package/telegram-plugin/tests/worker-feed-terminal-edit-class.test.ts +335 -0
- package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
- package/telegram-plugin/tool-activity-summary.ts +239 -365
- package/telegram-plugin/uat/assertions.ts +22 -11
- package/telegram-plugin/uat/feed-matcher.test.ts +24 -17
- package/telegram-plugin/worker-activity-feed.ts +105 -47
- package/vendor/hindsight-memory/CLAUDE.md +45 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +33 -0
- package/vendor/hindsight-memory/scripts/recall.py +176 -7
- package/vendor/hindsight-memory/scripts/tests/test_config_recall_passthrough_env.py +170 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_min_score.py +464 -0
- package/vendor/hindsight-memory/settings.json +1 -1
|
@@ -56,11 +56,12 @@
|
|
|
56
56
|
* - send gate `editFloorMs` = 1500ms ⇒ up to 40 edits/min/message
|
|
57
57
|
* - send gate cosmetic budget 150 / 300s ⇒ 30 edits/min/message
|
|
58
58
|
* - worker feed's own floor 2500ms ⇒ 24 edits/min/message
|
|
59
|
+
* - gateway `FEED_HEARTBEAT_TICK_MS` = 6000ms ⇒ 10 repaints/min/turn
|
|
59
60
|
*
|
|
60
|
-
* The
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
61
|
+
* The fuse's cosmetic ceilings sit BELOW all of these. That is deliberate —
|
|
62
|
+
* Telegram's own per-group limit is ~20 messages/minute and edits count
|
|
63
|
+
* against it, so the *legitimate* cadences are themselves above what
|
|
64
|
+
* Telegram tolerates; that is precisely how `overlord` got banned while
|
|
64
65
|
* every in-repo pacer believed it was behaving. The fuse is therefore the
|
|
65
66
|
* BINDING constraint on a hot card, not a never-reached backstop.
|
|
66
67
|
*
|
|
@@ -74,12 +75,112 @@
|
|
|
74
75
|
* RELEASED late instead of dropped. Dropping it would freeze the card
|
|
75
76
|
* mid-run AND return `true` to the send gate, which would then record a
|
|
76
77
|
* never-painted payload as on-screen and no-op-skip every retry.
|
|
78
|
+
*
|
|
79
|
+
* ── 2026-07-27: the fuse existed and the ban happened anyway ──────────────
|
|
80
|
+
* The ceilings above were sized against the in-repo pacers, not against what
|
|
81
|
+
* Telegram actually tolerates. Agent `overlord` then sustained ~17
|
|
82
|
+
* `editMessageText`/min on ONE DM chat for hours (326 edits against 6 real
|
|
83
|
+
* replies in the final 20 minutes) and took a **15908-second** flood ban that
|
|
84
|
+
* severed every outbound reply. Both original ceilings — 20 edits/60s per
|
|
85
|
+
* message, 30 edits/60s per chat — sat ABOVE that observed rate, so the fuse
|
|
86
|
+
* never bound once. Three structural changes follow, and each is a separate,
|
|
87
|
+
* independently-sufficient reason the same incident cannot recur:
|
|
88
|
+
*
|
|
89
|
+
* 1. **Class awareness.** The fuse now reads the send gate's priority class
|
|
90
|
+
* off `outbound-class.ts` (AsyncLocalStorage). `cosmetic` traffic — every
|
|
91
|
+
* activity/worker/liveness repaint — is governed by its own, much tighter
|
|
92
|
+
* ceilings; `useful` / `critical` edits (approval cards, answers) are not
|
|
93
|
+
* throttled by them. Previously the fuse was class-blind and had to pick
|
|
94
|
+
* one number for both, which is why the number was too high.
|
|
95
|
+
* 2. **A hard cosmetic rate ceiling.** 4 edits/60s per message (one per 15s,
|
|
96
|
+
* matching the worker feed's own `elapsedRefreshMs`) and 6 edits/60s per
|
|
97
|
+
* chat across ALL cosmetic surfaces. 6/min is below the ~4-6/min band the
|
|
98
|
+
* live chat survived for hours and far below the 15-17/min that earned
|
|
99
|
+
* the ban. Coalescing (supersede) means the card still shows CURRENT
|
|
100
|
+
* state at every permitted edit — the operator loses refresh frequency,
|
|
101
|
+
* never accuracy.
|
|
102
|
+
* 3. **A shared per-chat budget with a reply reservation.** Telegram meters
|
|
103
|
+
* sends and edits against the SAME per-chat allowance, so separate
|
|
104
|
+
* edit/send windows could each be "in budget" while their sum was not.
|
|
105
|
+
* One `perChatTotalMaxPerWindow` window (default 20/60s) counts every
|
|
106
|
+
* chat-targeted call, and `perChatReplyReserve` (default 8) slots of it
|
|
107
|
+
* are unreachable by `cosmetic` traffic. A reply therefore cannot be
|
|
108
|
+
* starved by repaints even if a future call site is misclassified.
|
|
109
|
+
*
|
|
110
|
+
* ── 2026-07-28: the meter was reading ~58% of the traffic (#3855) ─────────
|
|
111
|
+
* Point 3 above USED to claim `perChatTotalMaxPerWindow` "counts every
|
|
112
|
+
* admitted call" and "matches Telegram's own per-chat metering". Both were
|
|
113
|
+
* false, and the gap is the reason a "20/60s" ceiling did not stop a ban.
|
|
114
|
+
* `apply` opened with `if (!isEdit && !isSend) return runObserved(next)` —
|
|
115
|
+
* an ALLOWLIST. Anything outside `EDIT_METHODS`/`SEND_METHODS` was entirely
|
|
116
|
+
* unmetered: it took no slot, and the ceiling therefore governed only the
|
|
117
|
+
* subset of traffic it happened to recognise.
|
|
118
|
+
*
|
|
119
|
+
* Measured on overlord's own gateway log (46791 `tg-post` lines), the
|
|
120
|
+
* unmetered share was 29% overall and 42% inside the 30 minutes before the
|
|
121
|
+
* 20:19:56 ban. By method:
|
|
122
|
+
*
|
|
123
|
+
* sendChatAction 10457 ← unmetered, and up to 18/min on ONE DM
|
|
124
|
+
* setMessageReaction 2098 ← unmetered
|
|
125
|
+
* deleteMessage 370 ← unmetered
|
|
126
|
+
* pinChatMessage 366 ← unmetered
|
|
127
|
+
* unpinChatMessage 365 ← unmetered
|
|
128
|
+
* answerCallbackQuery 31 ← unmetered (carries no chat_id)
|
|
129
|
+
*
|
|
130
|
+
* `sendChatAction` alone ran at Telegram's entire documented per-chat/minute
|
|
131
|
+
* allowance while the fuse counted none of it. So the fix is not a smaller
|
|
132
|
+
* number, it is an honest meter:
|
|
133
|
+
*
|
|
134
|
+
* 4. **DEFAULT-DENY metering.** Every method whose payload carries a
|
|
135
|
+
* `chat_id` is charged to `perChatTotalMaxPerWindow`, whether or not this
|
|
136
|
+
* file has heard of it. There is no per-chat exemption list to forget to
|
|
137
|
+
* update — a new Bot API method, or one this gateway starts calling
|
|
138
|
+
* tomorrow (`sendChecklist` is exactly that case: it carries `chat_id`,
|
|
139
|
+
* creates a message, and was in neither set), is metered on arrival.
|
|
140
|
+
* Methods that are neither edits nor sends are PACED and never dropped,
|
|
141
|
+
* except `CHAT_ACTION_METHODS` — a typing indicator expires by itself in
|
|
142
|
+
* ~5s and carries no content, so shedding one loses nothing.
|
|
143
|
+
* The ceiling NUMBER is unchanged at 20/60s; what changed is that 20
|
|
144
|
+
* admitted calls now means 20 on the wire rather than ~34.
|
|
145
|
+
* 5. **A per-BOT-TOKEN window.** Telegram's flood ban is scoped to the bot
|
|
146
|
+
* token, not the chat, and enforces a global rate across all chats. A
|
|
147
|
+
* second window keyed by the bot's PUBLIC numeric id (`perTokenWindowMs`
|
|
148
|
+
* / `perTokenMaxPerWindow`, default 25 per 1000ms) is now charged for
|
|
149
|
+
* EVERY outbound call — including the chat-less ones like
|
|
150
|
+
* `answerCallbackQuery`, which the per-chat tier structurally cannot key.
|
|
151
|
+
* A call is admitted only if BOTH windows have room.
|
|
152
|
+
* LIMITATION, stated plainly: this window is per-PROCESS. Two agents
|
|
153
|
+
* sharing one bot token run in separate containers with separate state
|
|
154
|
+
* dirs, so neither sees the other's window. It is a real improvement on
|
|
155
|
+
* the single process that actually floods and it is NOT full coverage
|
|
156
|
+
* under a shared token; a cross-process view would need a token-keyed
|
|
157
|
+
* store on a path both processes can write, which does not exist today.
|
|
158
|
+
* Only the public bot id (the prefix before the `:`) is ever used as a
|
|
159
|
+
* key, and it is never logged — see {@link deriveBotScopeKey}.
|
|
160
|
+
*
|
|
161
|
+
* Plus **escalating** backoff: a 429 used to apply ONE 0.5× tightening for a
|
|
162
|
+
* flat 10 minutes, so a stream that took repeated small 429s re-tightened to
|
|
163
|
+
* the same level forever and walked into the big ban. Tightening is now
|
|
164
|
+
* multiplicative PER 429 (`tightenFactor ^ level`, level capped by
|
|
165
|
+
* `maxTightenLevel`) and decays one level at a time, so sustained pressure
|
|
166
|
+
* ratchets the cosmetic rate down towards the floor of 1/window.
|
|
167
|
+
*
|
|
168
|
+
* Every ceiling is operator-overridable via env — see
|
|
169
|
+
* {@link editFloodFuseConfigFromEnv}. Previously none of them were.
|
|
77
170
|
*/
|
|
78
171
|
|
|
172
|
+
import { createHash } from 'node:crypto'
|
|
173
|
+
|
|
79
174
|
import type { Bot } from 'grammy'
|
|
80
175
|
|
|
81
176
|
import type { Clock } from './send-gate.js'
|
|
82
177
|
import { systemClock } from './send-gate.js'
|
|
178
|
+
import { floodStatePath, makeFloodWaitProbe } from './flood-circuit-breaker.js'
|
|
179
|
+
import {
|
|
180
|
+
currentOutboundClass,
|
|
181
|
+
defaultOutboundClass,
|
|
182
|
+
type OutboundClass,
|
|
183
|
+
} from './outbound-class.js'
|
|
83
184
|
|
|
84
185
|
/** Methods that mutate an EXISTING message — droppable, coalescible. */
|
|
85
186
|
const EDIT_METHODS: ReadonlySet<string> = new Set([
|
|
@@ -112,26 +213,160 @@ const SEND_METHODS: ReadonlySet<string> = new Set([
|
|
|
112
213
|
'sendRichMessage',
|
|
113
214
|
])
|
|
114
215
|
|
|
216
|
+
/**
|
|
217
|
+
* Chat-targeted methods whose effect EXPIRES on its own and carries no content,
|
|
218
|
+
* so shedding one under pressure loses nothing a user would notice.
|
|
219
|
+
*
|
|
220
|
+
* `sendChatAction` is the entire set and the reason this tier exists: it was
|
|
221
|
+
* the single largest unmetered method in the incident log (10457 calls, up to
|
|
222
|
+
* 18/min on ONE DM — Telegram's whole documented per-chat/minute allowance),
|
|
223
|
+
* and the typing status it sets clears itself after ~5 seconds regardless.
|
|
224
|
+
* Every OTHER non-edit, non-send method with a `chat_id` (`deleteMessage`,
|
|
225
|
+
* `pinChatMessage`, `setMessageReaction`, …) has a durable, user-visible
|
|
226
|
+
* effect and is paced but never dropped.
|
|
227
|
+
*/
|
|
228
|
+
const CHAT_ACTION_METHODS: ReadonlySet<string> = new Set(['sendChatAction'])
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The ONLY methods exempt from metering entirely. Deliberately one entry.
|
|
232
|
+
*
|
|
233
|
+
* `getUpdates` is the long-poll RECEIVE loop, not outbound traffic: it is how
|
|
234
|
+
* inbound messages arrive at all. Pacing it would stall message delivery
|
|
235
|
+
* rather than protect anything, and it targets no chat, so it consumes no
|
|
236
|
+
* per-chat budget by construction.
|
|
237
|
+
*
|
|
238
|
+
* Nothing else is listed. The one-off administrative calls (`setMyCommands`,
|
|
239
|
+
* `deleteWebhook`, `getFile`, `getChat` — 37 calls TOTAL across a 46791-call
|
|
240
|
+
* log) are metered like everything else: at 25/second the cost is nil, and
|
|
241
|
+
* exempting them would mean asserting something about Telegram's accounting
|
|
242
|
+
* that this repo cannot verify from first-party evidence. Default-deny means
|
|
243
|
+
* the burden of proof is on the exemption.
|
|
244
|
+
*/
|
|
245
|
+
const UNMETERED_METHODS: ReadonlySet<string> = new Set(['getUpdates'])
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Reduce a bot token to a key safe to hold in memory.
|
|
249
|
+
*
|
|
250
|
+
* A Telegram token is `<bot_id>:<secret>`. The bot id is PUBLIC (it is the
|
|
251
|
+
* bot's user id, visible on every message it sends); the secret half is not.
|
|
252
|
+
* This returns the bot id alone when the token has that shape, so nothing
|
|
253
|
+
* secret is ever used as a map key, logged, or persisted. A token that does
|
|
254
|
+
* not match the shape falls back to a truncated SHA-256, which is stable
|
|
255
|
+
* across processes and irreversible.
|
|
256
|
+
*/
|
|
257
|
+
export function deriveBotScopeKey(token: string | undefined): string {
|
|
258
|
+
if (token == null || token === '') return 'unknown'
|
|
259
|
+
const id = token.slice(0, token.indexOf(':'))
|
|
260
|
+
if (/^\d+$/.test(id)) return id
|
|
261
|
+
return `h${createHash('sha256').update(token).digest('hex').slice(0, 16)}`
|
|
262
|
+
}
|
|
263
|
+
|
|
115
264
|
export interface EditFloodFuseConfig {
|
|
116
265
|
/** Master switch. When false, `apply` is a pure passthrough. Default true. */
|
|
117
266
|
enabled?: boolean
|
|
118
267
|
clock?: Clock
|
|
119
|
-
/**
|
|
268
|
+
/**
|
|
269
|
+
* Hard ceiling on NON-cosmetic (`useful` / `critical`) edits to ONE message
|
|
270
|
+
* id. Default 20 per 60s. Approval cards and answer finalisations live here;
|
|
271
|
+
* they are low-cadence by nature, so this stays a backstop, not a pacer.
|
|
272
|
+
*/
|
|
120
273
|
perMessageMaxPerWindow?: number
|
|
121
274
|
perMessageWindowMs?: number
|
|
122
|
-
/** Hard ceiling on edits to ONE chat across all messages. Default 30 per 60s. */
|
|
275
|
+
/** Hard ceiling on ALL edits to ONE chat across all messages. Default 30 per 60s. */
|
|
123
276
|
perChatEditMaxPerWindow?: number
|
|
124
277
|
/** Pacing ceiling on non-edit sends to ONE chat. Default 25 per 60s. */
|
|
125
278
|
perChatSendMaxPerWindow?: number
|
|
279
|
+
/**
|
|
280
|
+
* ADDITIONAL ceiling applied to COSMETIC edits of ONE message id, on top of
|
|
281
|
+
* `perMessageMaxPerWindow` (the effective ceiling is the lower of the two).
|
|
282
|
+
* Default 4 per 60s — one per 15s, matching the worker feed's
|
|
283
|
+
* `elapsedRefreshMs`. This is the number that bounds a hot progress card.
|
|
284
|
+
*/
|
|
285
|
+
cosmeticPerMessageMaxPerWindow?: number
|
|
286
|
+
/**
|
|
287
|
+
* ADDITIONAL ceiling applied to COSMETIC edits in ONE chat, summed across
|
|
288
|
+
* every cosmetic surface in it (lower of this and `perChatEditMaxPerWindow`
|
|
289
|
+
* binds). Default 6 per 60s. Without it, N concurrent cards each inside their
|
|
290
|
+
* own per-message ceiling still add up to a flood.
|
|
291
|
+
*/
|
|
292
|
+
cosmeticPerChatMaxPerWindow?: number
|
|
293
|
+
/**
|
|
294
|
+
* Shared per-chat allowance counting EVERY admitted call — edits and sends,
|
|
295
|
+
* every class. Default 20 per 60s, matching Telegram's own per-chat/group
|
|
296
|
+
* metering (which does not separate the two). Sends and non-cosmetic edits
|
|
297
|
+
* are paced against it and never dropped.
|
|
298
|
+
*/
|
|
299
|
+
perChatTotalMaxPerWindow?: number
|
|
300
|
+
/**
|
|
301
|
+
* Slots of `perChatTotalMaxPerWindow` that `cosmetic` traffic may NEVER
|
|
302
|
+
* consume. Default 8. This is the reply-starvation guarantee: whatever the
|
|
303
|
+
* repaint surfaces do, at least this many calls per window remain available
|
|
304
|
+
* to an actual answer.
|
|
305
|
+
*/
|
|
306
|
+
perChatReplyReserve?: number
|
|
307
|
+
/**
|
|
308
|
+
* Ceiling on EVERY outbound call made with this bot token, across all chats
|
|
309
|
+
* and including the chat-less ones. Default 25 per `perTokenWindowMs`
|
|
310
|
+
* (1000ms), just under the ~30/second Telegram enforces at the token level.
|
|
311
|
+
* The flood ban is token-scoped, so this is the tier that matches the thing
|
|
312
|
+
* that actually gets banned. Per-PROCESS — see the docblock's point 5.
|
|
313
|
+
*/
|
|
314
|
+
perTokenMaxPerWindow?: number
|
|
315
|
+
perTokenWindowMs?: number
|
|
316
|
+
/**
|
|
317
|
+
* Scope key for the token window: the bot's PUBLIC numeric id. Never pass a
|
|
318
|
+
* raw token — {@link installEditFloodFuse} derives this via
|
|
319
|
+
* {@link deriveBotScopeKey}. Default `'unknown'` (a single shared window,
|
|
320
|
+
* which is still correct for the one-bot-per-process case).
|
|
321
|
+
*/
|
|
322
|
+
botScopeKey?: string
|
|
323
|
+
/**
|
|
324
|
+
* Longest a `CHAT_ACTION_METHODS` call may be held before it is shed.
|
|
325
|
+
* Default 3000ms — a typing indicator that arrives after the status it
|
|
326
|
+
* describes has already expired is worse than no typing indicator.
|
|
327
|
+
*/
|
|
328
|
+
chatActionMaxDeferMs?: number
|
|
329
|
+
/**
|
|
330
|
+
* Cap on COSMETIC edits that may be released late (over budget) because
|
|
331
|
+
* nothing newer will repaint them — the bounded form of the R1 rule. Default
|
|
332
|
+
* 2 per `perChatWindowMs`, so the worst-case sustained cosmetic rate is
|
|
333
|
+
* `cosmeticPerChatMaxPerWindow + lateReleaseMaxPerWindow`.
|
|
334
|
+
*/
|
|
335
|
+
lateReleaseMaxPerWindow?: number
|
|
126
336
|
perChatWindowMs?: number
|
|
127
337
|
/** Longest a call may be held before it is dropped (edit) / released (send). Default 30s. */
|
|
128
338
|
maxDeferMs?: number
|
|
129
|
-
/** Multiplicative decrease applied
|
|
339
|
+
/** Multiplicative decrease applied per observed 429. Default 0.5. */
|
|
130
340
|
tightenFactor?: number
|
|
131
|
-
/** How long
|
|
341
|
+
/** How long ONE level of tightening stays in force before decaying. Default 10 minutes. */
|
|
132
342
|
tightenMs?: number
|
|
343
|
+
/** Cap on compounding 429 tightening levels. Default 4 (⇒ 0.5^4 = 1/16). */
|
|
344
|
+
maxTightenLevel?: number
|
|
345
|
+
/**
|
|
346
|
+
* Remaining ms of a KNOWN-OPEN Telegram flood window, or 0 when none — the
|
|
347
|
+
* persisted breaker state (`flood-circuit-breaker.ts`) that every other
|
|
348
|
+
* outbound path already consults. Wired by {@link editFloodFuseConfigFromEnv}.
|
|
349
|
+
*
|
|
350
|
+
* The fuse's tightening lived only in process memory, so before #3856 a
|
|
351
|
+
* gateway restart during a ban came back FULLY UNTIGHTENED and resumed at
|
|
352
|
+
* the rate that earned the ban — and a restart is the single most likely
|
|
353
|
+
* thing to happen during a multi-hour outage. Consulting the persisted
|
|
354
|
+
* window makes the response durable: while it is open the fuse holds
|
|
355
|
+
* `maxTightenLevel` regardless of what this process remembers.
|
|
356
|
+
*
|
|
357
|
+
* FAILS OPEN (throwing or absent probe ⇒ untightened): a breaker that cannot
|
|
358
|
+
* read its own state must never gag the bot.
|
|
359
|
+
*/
|
|
360
|
+
floodWaitRemainingMs?: () => number
|
|
361
|
+
/** How often the persisted window may be re-read. Default 1000ms. */
|
|
362
|
+
floodProbeIntervalMs?: number
|
|
133
363
|
/** Observability hook; fired whenever the fuse binds. */
|
|
134
|
-
onTrip?: (info: {
|
|
364
|
+
onTrip?: (info: {
|
|
365
|
+
method: string
|
|
366
|
+
key: string
|
|
367
|
+
action: 'deferred' | 'dropped' | 'superseded'
|
|
368
|
+
cls: OutboundClass
|
|
369
|
+
}) => void
|
|
135
370
|
}
|
|
136
371
|
|
|
137
372
|
export interface EditFloodFuseStats {
|
|
@@ -146,8 +381,29 @@ export interface EditFloodFuseStats {
|
|
|
146
381
|
floodObserved: number
|
|
147
382
|
/** Whether a tightened ceiling is in force right now. */
|
|
148
383
|
tightened: boolean
|
|
149
|
-
/**
|
|
384
|
+
/** Compounding 429 tightening level currently in force (0 = untightened). */
|
|
385
|
+
tightenLevel: number
|
|
386
|
+
/** Live NON-cosmetic per-message ceiling (post-AIMD). */
|
|
150
387
|
perMessageCeiling: number
|
|
388
|
+
/** Live COSMETIC per-message ceiling (post-AIMD) — the incident-relevant one. */
|
|
389
|
+
cosmeticPerMessageCeiling: number
|
|
390
|
+
/** Live COSMETIC per-chat ceiling (post-AIMD). */
|
|
391
|
+
cosmeticPerChatCeiling: number
|
|
392
|
+
/**
|
|
393
|
+
* Chat-targeted calls charged by the DEFAULT-DENY rule — every one of these
|
|
394
|
+
* was completely unmetered before #3855. A non-zero value here is the direct
|
|
395
|
+
* measure of the hole this closed.
|
|
396
|
+
*/
|
|
397
|
+
meteredByDefault: number
|
|
398
|
+
/** Calls with no `chat_id`, charged to the token window only. */
|
|
399
|
+
chatless: number
|
|
400
|
+
/** Live per-bot-token ceiling (post-AIMD). */
|
|
401
|
+
perTokenCeiling: number
|
|
402
|
+
/**
|
|
403
|
+
* True when a PERSISTED flood window is open right now — i.e. the tightening
|
|
404
|
+
* in force is durable rather than remembered, and survives a restart (#3856).
|
|
405
|
+
*/
|
|
406
|
+
persistedFloodOpen: boolean
|
|
151
407
|
}
|
|
152
408
|
|
|
153
409
|
export const EDIT_FLOOD_FUSE_DEFAULTS = {
|
|
@@ -155,12 +411,87 @@ export const EDIT_FLOOD_FUSE_DEFAULTS = {
|
|
|
155
411
|
perMessageWindowMs: 60_000,
|
|
156
412
|
perChatEditMaxPerWindow: 30,
|
|
157
413
|
perChatSendMaxPerWindow: 25,
|
|
414
|
+
/**
|
|
415
|
+
* 4/60s. One cosmetic repaint per 15s per card. Chosen to equal the worker
|
|
416
|
+
* feed's `elapsedRefreshMs` (15000) — the slowest cadence at which the feed
|
|
417
|
+
* itself considers a repaint worth making — so the fuse binds the runaway
|
|
418
|
+
* case without ever throttling the feed's own intended pace.
|
|
419
|
+
*/
|
|
420
|
+
cosmeticPerMessageMaxPerWindow: 4,
|
|
421
|
+
/**
|
|
422
|
+
* 6/60s across every cosmetic surface in a chat. The incident's own timeline
|
|
423
|
+
* is the evidence: 10-minute buckets of 58/53/41 edits (≈4-6/min) ran for
|
|
424
|
+
* hours without a ban; 115/78/73 then 152/174 (≈8-17/min) earned one. 6/min
|
|
425
|
+
* sits at the top of the survived band and less than half the banned rate.
|
|
426
|
+
*/
|
|
427
|
+
cosmeticPerChatMaxPerWindow: 6,
|
|
428
|
+
/**
|
|
429
|
+
* 20/60s. Telegram's documented per-group ceiling, and it meters sends and
|
|
430
|
+
* edits together — so this is the only window that reflects the real budget.
|
|
431
|
+
*/
|
|
432
|
+
perChatTotalMaxPerWindow: 20,
|
|
433
|
+
/** 8 of those 20 slots/min are unreachable by cosmetic traffic. */
|
|
434
|
+
perChatReplyReserve: 8,
|
|
435
|
+
/**
|
|
436
|
+
* 25 per second across the whole bot token. Telegram enforces ~30/s at the
|
|
437
|
+
* token level and the ban it issues is token-scoped; 25 leaves headroom for
|
|
438
|
+
* traffic this process cannot see (a peer agent sharing the token, or a
|
|
439
|
+
* retry issued below the transformer stack).
|
|
440
|
+
*/
|
|
441
|
+
perTokenMaxPerWindow: 25,
|
|
442
|
+
perTokenWindowMs: 1_000,
|
|
443
|
+
/** A typing indicator held longer than this is not worth sending. */
|
|
444
|
+
chatActionMaxDeferMs: 3_000,
|
|
445
|
+
/** At most 2 cosmetic frames/min may exceed the ceiling as "lone" releases. */
|
|
446
|
+
lateReleaseMaxPerWindow: 2,
|
|
158
447
|
perChatWindowMs: 60_000,
|
|
159
448
|
maxDeferMs: 30_000,
|
|
160
449
|
tightenFactor: 0.5,
|
|
161
450
|
tightenMs: 600_000,
|
|
451
|
+
maxTightenLevel: 4,
|
|
452
|
+
/** The persisted flood window is re-read at most once a second (#3856). */
|
|
453
|
+
floodProbeIntervalMs: 1_000,
|
|
162
454
|
} as const
|
|
163
455
|
|
|
456
|
+
/** Parse a positive integer from env, or undefined when unset/invalid. */
|
|
457
|
+
function envInt(raw: string | undefined): number | undefined {
|
|
458
|
+
if (raw == null || raw.trim() === '') return undefined
|
|
459
|
+
const n = Number(raw)
|
|
460
|
+
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : undefined
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Operator config surface. Before the 2026-07-27 incident the fuse had exactly
|
|
465
|
+
* one knob — `SWITCHROOM_EDIT_FUSE=0`, which turns the whole failsafe OFF —
|
|
466
|
+
* so an operator whose chat was being flooded had no way to tighten it and no
|
|
467
|
+
* way to loosen it for a chat that could take more. Every ceiling is now
|
|
468
|
+
* overridable; unset values keep the defaults above.
|
|
469
|
+
*/
|
|
470
|
+
export function editFloodFuseConfigFromEnv(
|
|
471
|
+
env: Record<string, string | undefined>,
|
|
472
|
+
): EditFloodFuseConfig {
|
|
473
|
+
const cfg: EditFloodFuseConfig = { enabled: env.SWITCHROOM_EDIT_FUSE !== '0' }
|
|
474
|
+
const assign = <K extends keyof EditFloodFuseConfig>(k: K, v: number | undefined): void => {
|
|
475
|
+
if (v !== undefined) (cfg[k] as number) = v
|
|
476
|
+
}
|
|
477
|
+
assign('cosmeticPerMessageMaxPerWindow', envInt(env.SWITCHROOM_FEED_EDIT_MAX_PER_MSG_PER_MIN))
|
|
478
|
+
assign('cosmeticPerChatMaxPerWindow', envInt(env.SWITCHROOM_FEED_EDIT_MAX_PER_CHAT_PER_MIN))
|
|
479
|
+
assign('perChatTotalMaxPerWindow', envInt(env.SWITCHROOM_CHAT_TOTAL_MAX_PER_MIN))
|
|
480
|
+
assign('perChatReplyReserve', envInt(env.SWITCHROOM_CHAT_REPLY_RESERVE))
|
|
481
|
+
assign('perTokenMaxPerWindow', envInt(env.SWITCHROOM_TOKEN_MAX_PER_SEC))
|
|
482
|
+
assign('maxDeferMs', envInt(env.SWITCHROOM_EDIT_FUSE_MAX_DEFER_MS))
|
|
483
|
+
// #3856 — durable ban awareness. The fuse reads the SAME persisted marker
|
|
484
|
+
// (`flood-wait.json`) that `robustApiCall` and the outbox sweep consult, so a
|
|
485
|
+
// restart mid-ban comes back tightened instead of at full rate. Wired here
|
|
486
|
+
// rather than at the call site so no caller can forget it, and because
|
|
487
|
+
// `gateway.ts` is under a zero-slack line ratchet.
|
|
488
|
+
const stateDir = env.TELEGRAM_STATE_DIR
|
|
489
|
+
if (stateDir != null && stateDir !== '') {
|
|
490
|
+
cfg.floodWaitRemainingMs = makeFloodWaitProbe(floodStatePath(stateDir))
|
|
491
|
+
}
|
|
492
|
+
return cfg
|
|
493
|
+
}
|
|
494
|
+
|
|
164
495
|
/** grammY resolves an edit with `true` when there is nothing to return. */
|
|
165
496
|
const DROPPED_RESULT = true
|
|
166
497
|
|
|
@@ -189,28 +520,110 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
|
|
|
189
520
|
const perMessageWindowMs = config.perMessageWindowMs ?? D.perMessageWindowMs
|
|
190
521
|
const perChatEditMax = config.perChatEditMaxPerWindow ?? D.perChatEditMaxPerWindow
|
|
191
522
|
const perChatSendMax = config.perChatSendMaxPerWindow ?? D.perChatSendMaxPerWindow
|
|
523
|
+
// Cosmetic tiers are an ADDITIONAL constraint on the same window keys, so the
|
|
524
|
+
// effective ceiling is the lower of the two. Folding them in here (rather
|
|
525
|
+
// than as extra tiers) keeps the admission path at the same number of awaits.
|
|
526
|
+
const cosmeticPerMessageMax = Math.min(
|
|
527
|
+
perMessageMax, config.cosmeticPerMessageMaxPerWindow ?? D.cosmeticPerMessageMaxPerWindow)
|
|
528
|
+
const cosmeticPerChatMax = Math.min(
|
|
529
|
+
perChatEditMax, config.cosmeticPerChatMaxPerWindow ?? D.cosmeticPerChatMaxPerWindow)
|
|
530
|
+
const perChatTotalMax = config.perChatTotalMaxPerWindow ?? D.perChatTotalMaxPerWindow
|
|
192
531
|
const perChatWindowMs = config.perChatWindowMs ?? D.perChatWindowMs
|
|
532
|
+
// Clamped so a misconfigured reserve can never make the cosmetic allowance
|
|
533
|
+
// negative (deadlocking every card) or eat the whole budget.
|
|
534
|
+
const perChatReplyReserve = Math.min(
|
|
535
|
+
Math.max(0, config.perChatReplyReserve ?? D.perChatReplyReserve),
|
|
536
|
+
Math.max(0, perChatTotalMax - 1),
|
|
537
|
+
)
|
|
538
|
+
const perTokenMax = Math.max(1, config.perTokenMaxPerWindow ?? D.perTokenMaxPerWindow)
|
|
539
|
+
const perTokenWindowMs = Math.max(1, config.perTokenWindowMs ?? D.perTokenWindowMs)
|
|
540
|
+
// Only ever the PUBLIC bot id (or an irreversible hash) reaches this key.
|
|
541
|
+
const tokenKey = `g:${config.botScopeKey ?? 'unknown'}`
|
|
542
|
+
const chatActionMaxDeferMs = config.chatActionMaxDeferMs ?? D.chatActionMaxDeferMs
|
|
543
|
+
const lateReleaseMax = Math.max(0, config.lateReleaseMaxPerWindow ?? D.lateReleaseMaxPerWindow)
|
|
193
544
|
const maxDeferMs = config.maxDeferMs ?? D.maxDeferMs
|
|
194
545
|
const tightenFactor = config.tightenFactor ?? D.tightenFactor
|
|
195
546
|
const tightenMs = config.tightenMs ?? D.tightenMs
|
|
547
|
+
const maxTightenLevel = Math.max(0, config.maxTightenLevel ?? D.maxTightenLevel)
|
|
548
|
+
const floodProbe = config.floodWaitRemainingMs
|
|
549
|
+
const floodProbeIntervalMs = Math.max(0, config.floodProbeIntervalMs ?? D.floodProbeIntervalMs)
|
|
196
550
|
const onTrip = config.onTrip
|
|
197
551
|
|
|
198
552
|
const windows = new Map<string, Window>()
|
|
199
|
-
const counters = {
|
|
200
|
-
|
|
553
|
+
const counters = {
|
|
554
|
+
deferred: 0, dropped: 0, superseded: 0, floodObserved: 0,
|
|
555
|
+
/** Chat-targeted calls charged by the DEFAULT-DENY rule that the old
|
|
556
|
+
* allowlist would have let through unmetered (#3855 observability). */
|
|
557
|
+
meteredByDefault: 0,
|
|
558
|
+
/** Calls with no `chat_id`, charged to the token window only. */
|
|
559
|
+
chatless: 0,
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Compounding 429 backoff. `tightenLevel` is the number of multiplicative
|
|
563
|
+
* decreases currently in force; `tightenedUntil` is when the NEXT level
|
|
564
|
+
* decays. A single flat tightening (the pre-2026-07-27 behaviour) let a
|
|
565
|
+
* stream that kept taking small 429s sit at 0.5× indefinitely and walk into
|
|
566
|
+
* a long ban; escalating means repeated 429s ratchet the rate down.
|
|
567
|
+
*/
|
|
568
|
+
let tightenLevel = 0
|
|
201
569
|
let tightenedUntil = 0
|
|
202
570
|
|
|
571
|
+
/**
|
|
572
|
+
* Cached read of the PERSISTED flood window (#3856). Throttled to
|
|
573
|
+
* `floodProbeIntervalMs` because `levelAt` runs on every admission and the
|
|
574
|
+
* probe is a file read — an unthrottled read would make the fuse the latency
|
|
575
|
+
* problem it exists to prevent. Between reads the cached remaining time is
|
|
576
|
+
* decayed by elapsed wall time, so a window never appears to last longer
|
|
577
|
+
* than it does.
|
|
578
|
+
*
|
|
579
|
+
* FAILS OPEN: any throw is treated as "no window". `makeFloodWaitProbe`
|
|
580
|
+
* already fails open on an unreadable marker and warns loudly (#3106); this
|
|
581
|
+
* catch covers the rest.
|
|
582
|
+
*/
|
|
583
|
+
let probedAt = Number.NEGATIVE_INFINITY
|
|
584
|
+
let probedRemainingMs = 0
|
|
585
|
+
function persistedFloodRemainingMs(now: number): number {
|
|
586
|
+
if (floodProbe === undefined) return 0
|
|
587
|
+
const since = now - probedAt
|
|
588
|
+
if (since < floodProbeIntervalMs) return Math.max(0, probedRemainingMs - since)
|
|
589
|
+
probedAt = now
|
|
590
|
+
try {
|
|
591
|
+
const ms = floodProbe()
|
|
592
|
+
probedRemainingMs = Number.isFinite(ms) && ms > 0 ? ms : 0
|
|
593
|
+
} catch {
|
|
594
|
+
probedRemainingMs = 0
|
|
595
|
+
}
|
|
596
|
+
return probedRemainingMs
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** Decay one level per `tightenMs` of quiet, rather than a single cliff. */
|
|
600
|
+
function levelAt(now: number): number {
|
|
601
|
+
while (tightenLevel > 0 && tightenedUntil <= now) {
|
|
602
|
+
tightenLevel--
|
|
603
|
+
tightenedUntil = tightenLevel > 0 ? tightenedUntil + tightenMs : 0
|
|
604
|
+
}
|
|
605
|
+
// #3856 — a KNOWN-OPEN persisted window pins the fuse at its tightest
|
|
606
|
+
// regardless of in-memory state. This is what survives a restart: the
|
|
607
|
+
// process forgets, the marker on disk does not. It does not MUTATE
|
|
608
|
+
// `tightenLevel`, so when the window closes the fuse returns to whatever
|
|
609
|
+
// this process actually earned rather than staying pinned.
|
|
610
|
+
if (persistedFloodRemainingMs(now) > 0) return maxTightenLevel
|
|
611
|
+
return tightenLevel
|
|
612
|
+
}
|
|
613
|
+
|
|
203
614
|
function isTightened(now: number): boolean {
|
|
204
|
-
return
|
|
615
|
+
return levelAt(now) > 0
|
|
205
616
|
}
|
|
206
617
|
|
|
207
618
|
/**
|
|
208
|
-
* AIMD multiplicative decrease
|
|
209
|
-
* floored at 1 so the fuse never deadlocks a
|
|
619
|
+
* AIMD multiplicative decrease, compounding per observed 429. Applied to
|
|
620
|
+
* every ceiling while tightened; floored at 1 so the fuse never deadlocks a
|
|
621
|
+
* surface completely.
|
|
210
622
|
*/
|
|
211
623
|
function ceiling(base: number, now: number): number {
|
|
212
|
-
|
|
213
|
-
|
|
624
|
+
const level = levelAt(now)
|
|
625
|
+
if (level === 0) return base
|
|
626
|
+
return Math.max(1, Math.floor(base * Math.pow(tightenFactor, level)))
|
|
214
627
|
}
|
|
215
628
|
|
|
216
629
|
function win(key: string): Window {
|
|
@@ -266,23 +679,69 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
|
|
|
266
679
|
return { chat, msg }
|
|
267
680
|
}
|
|
268
681
|
|
|
269
|
-
/**
|
|
270
|
-
|
|
682
|
+
/**
|
|
683
|
+
* Record a 429 and tighten IN PROPORTION TO THE PENALTY (#3856).
|
|
684
|
+
*
|
|
685
|
+
* Before this, every 429 was worth exactly one level: a 3-second "slow down
|
|
686
|
+
* a touch" nudge and a **15908-second** ban produced the identical response.
|
|
687
|
+
* The whole point of AIMD is that the decrease matches the signal, and a
|
|
688
|
+
* four-hour ban is not one nudge's worth of signal. `retryAfterSec` is the
|
|
689
|
+
* severity Telegram itself states, so it is what the step is scaled by.
|
|
690
|
+
*
|
|
691
|
+
* The tightening is also held for at least the ban's own duration. A flat
|
|
692
|
+
* 10-minute `tightenMs` expired ~25× over during the 2026-07-27 ban, so the
|
|
693
|
+
* fuse would have been back at full rate long before the window closed.
|
|
694
|
+
*/
|
|
695
|
+
function noteFlood(now: number, retryAfterSec: number): void {
|
|
271
696
|
counters.floodObserved++
|
|
272
|
-
|
|
697
|
+
levelAt(now)
|
|
698
|
+
tightenLevel = Math.min(maxTightenLevel, tightenLevel + tightenStepFor(retryAfterSec))
|
|
699
|
+
// Every level currently in force is re-armed; the decay clock restarts
|
|
700
|
+
// from the newest 429, and never expires before the penalty itself does.
|
|
701
|
+
tightenedUntil = now + Math.max(tightenMs, retryAfterSec * 1000 + tightenMs)
|
|
273
702
|
}
|
|
274
703
|
|
|
275
|
-
|
|
704
|
+
/**
|
|
705
|
+
* Levels of multiplicative decrease one 429 is worth, by stated penalty:
|
|
706
|
+
*
|
|
707
|
+
* ≤ 5s 1 a routine burst nudge
|
|
708
|
+
* ≤ 60s 2 sustained overrate
|
|
709
|
+
* ≤ 600s 3 a real ban
|
|
710
|
+
* > 600s ALL the way to `maxTightenLevel` — at this magnitude the rate
|
|
711
|
+
* that produced it is categorically wrong, and stepping down
|
|
712
|
+
* one level at a time would spend the next window earning the
|
|
713
|
+
* next ban. The 2026-07-25 (3713s) and 2026-07-27 (15908s)
|
|
714
|
+
* bans are both in this band.
|
|
715
|
+
*/
|
|
716
|
+
function tightenStepFor(retryAfterSec: number): number {
|
|
717
|
+
if (!Number.isFinite(retryAfterSec) || retryAfterSec <= 5) return 1
|
|
718
|
+
if (retryAfterSec <= 60) return 2
|
|
719
|
+
if (retryAfterSec <= 600) return 3
|
|
720
|
+
return maxTightenLevel
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* The stated `retry_after` in seconds when `err` is a flood rejection, else
|
|
725
|
+
* null. Returns 0 for a flood whose magnitude is not stated (the text-match
|
|
726
|
+
* path), which `tightenStepFor` treats as the mildest case — an unquantified
|
|
727
|
+
* signal must not be inflated into a maximal response.
|
|
728
|
+
*/
|
|
729
|
+
function floodRetryAfterSec(err: unknown): number | null {
|
|
276
730
|
const e = err as { error_code?: number; parameters?: { retry_after?: number } } | null
|
|
277
731
|
if (e != null && typeof e === 'object') {
|
|
278
|
-
|
|
279
|
-
if (
|
|
732
|
+
const stated = e.parameters?.retry_after
|
|
733
|
+
if (typeof stated === 'number') return stated
|
|
734
|
+
if (e.error_code === 429) return 0
|
|
280
735
|
}
|
|
281
736
|
// Match on the SEMANTIC markers only. A bare "429" substring
|
|
282
737
|
// false-positives on ordinary server text (e.g. "message 429 not found"),
|
|
283
738
|
// and a false positive here halves every ceiling for ten minutes.
|
|
284
739
|
const msg = err instanceof Error ? err.message : String(err ?? '')
|
|
285
|
-
|
|
740
|
+
if (!/too many requests/i.test(msg) && !/retry[ _-]?after/i.test(msg)) return null
|
|
741
|
+
// grammY and the Bot API both surface the magnitude in the text; use it
|
|
742
|
+
// when it is there rather than throwing the severity away.
|
|
743
|
+
const m = /retry[ _-]?after[^0-9]{0,4}(\d+)/i.exec(msg)
|
|
744
|
+
return m != null ? Number(m[1]) : 0
|
|
286
745
|
}
|
|
287
746
|
|
|
288
747
|
/**
|
|
@@ -311,7 +770,7 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
|
|
|
311
770
|
* Returns the reserved timestamp, or null when the call must be dropped.
|
|
312
771
|
*/
|
|
313
772
|
async function awaitRoom(
|
|
314
|
-
key: string, windowMs: number, max: number, method: string, mode: WaitMode,
|
|
773
|
+
key: string, windowMs: number, max: number, method: string, mode: WaitMode, cls: OutboundClass,
|
|
315
774
|
/**
|
|
316
775
|
* Consulted ONLY at the defer deadline for `mode: 'drop'`. Returning false
|
|
317
776
|
* converts the drop into a late release: this call is the last thing that
|
|
@@ -319,10 +778,30 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
|
|
|
319
778
|
* frame", it is freezing a card. Absent ⇒ drop unconditionally (the
|
|
320
779
|
* pre-review behaviour).
|
|
321
780
|
*/
|
|
322
|
-
dropGuard
|
|
781
|
+
dropGuard: (() => boolean) | undefined,
|
|
782
|
+
/**
|
|
783
|
+
* Absolute deadline SHARED by every tier of one `apply` call. Each tier
|
|
784
|
+
* used to start its own `maxDeferMs`, so a call crossing three tiers could
|
|
785
|
+
* be held 3×`maxDeferMs` — an unbounded-in-practice hold that a caller's
|
|
786
|
+
* own timeout, not this fuse, would have to end. One deadline per call
|
|
787
|
+
* makes `maxDeferMs` mean what it says.
|
|
788
|
+
*/
|
|
789
|
+
deadline: number,
|
|
790
|
+
/**
|
|
791
|
+
* Bounded overshoot budget for the R1 late-release path (cosmetic edits
|
|
792
|
+
* only). R1 says an over-budget edit that nothing newer will repaint must
|
|
793
|
+
* be RELEASED late rather than dropped, so a card cannot freeze mid-run.
|
|
794
|
+
* Taken literally that rule has no ceiling: a stream of cosmetic edits to
|
|
795
|
+
* DISTINCT message ids is a stream of "lone" frames, every one of which
|
|
796
|
+
* releases over budget — which is how 6 concurrent worker cards can sail
|
|
797
|
+
* past a 6/min chat ceiling. Charging each late release to a small extra
|
|
798
|
+
* window keeps R1's guarantee for the rare genuinely-lone frame (a
|
|
799
|
+
* worker's terminal recap) while capping the leak at `lateReleaseMax` per
|
|
800
|
+
* window. Absent ⇒ unbounded late release (sends, non-cosmetic edits).
|
|
801
|
+
*/
|
|
802
|
+
lateReleaseKey?: string,
|
|
323
803
|
): Promise<number | null> {
|
|
324
804
|
const w = win(key)
|
|
325
|
-
const deadline = clock.now() + maxDeferMs
|
|
326
805
|
let counted = false
|
|
327
806
|
for (;;) {
|
|
328
807
|
const now = clock.now()
|
|
@@ -337,19 +816,32 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
|
|
|
337
816
|
// worse than a late one).
|
|
338
817
|
if (mode !== 'release' && (dropGuard === undefined || dropGuard())) {
|
|
339
818
|
counters.dropped++
|
|
340
|
-
onTrip?.({ method, key, action: 'dropped' })
|
|
819
|
+
onTrip?.({ method, key, action: 'dropped', cls })
|
|
341
820
|
return null
|
|
342
821
|
}
|
|
343
822
|
// A send — or an edit that nothing newer will repaint — is released
|
|
344
823
|
// rather than dropped, and still takes a slot so the window reflects
|
|
345
|
-
// what actually went out.
|
|
824
|
+
// what actually went out. Cosmetic late releases are additionally
|
|
825
|
+
// charged to the bounded overshoot budget; when that is exhausted the
|
|
826
|
+
// frame is dropped after all, so the ceiling cannot be walked past one
|
|
827
|
+
// "lone" frame at a time.
|
|
828
|
+
if (lateReleaseKey !== undefined) {
|
|
829
|
+
const lw = win(lateReleaseKey)
|
|
830
|
+
prune(lw, now, perChatWindowMs)
|
|
831
|
+
if (lw.ts.length >= ceiling(lateReleaseMax, now)) {
|
|
832
|
+
counters.dropped++
|
|
833
|
+
onTrip?.({ method, key, action: 'dropped', cls })
|
|
834
|
+
return null
|
|
835
|
+
}
|
|
836
|
+
lw.ts.push(now)
|
|
837
|
+
}
|
|
346
838
|
w.ts.push(now)
|
|
347
839
|
return now
|
|
348
840
|
}
|
|
349
841
|
if (!counted) {
|
|
350
842
|
counters.deferred++
|
|
351
843
|
counted = true
|
|
352
|
-
onTrip?.({ method, key, action: 'deferred' })
|
|
844
|
+
onTrip?.({ method, key, action: 'deferred', cls })
|
|
353
845
|
}
|
|
354
846
|
// Last-write-wins: a newer edit to the same message kills this one. Only
|
|
355
847
|
// valid on the per-MESSAGE tier — on a per-chat key the "newer" edit is
|
|
@@ -364,7 +856,7 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
|
|
|
364
856
|
await Promise.race([clock.sleep(Math.min(wait, deadline - now)), superseded])
|
|
365
857
|
if (killed) {
|
|
366
858
|
counters.superseded++
|
|
367
|
-
onTrip?.({ method, key, action: 'superseded' })
|
|
859
|
+
onTrip?.({ method, key, action: 'superseded', cls })
|
|
368
860
|
return null
|
|
369
861
|
}
|
|
370
862
|
w.waiter = null
|
|
@@ -381,51 +873,124 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
|
|
|
381
873
|
next: () => Promise<R>,
|
|
382
874
|
): Promise<R> {
|
|
383
875
|
if (!enabled) return next()
|
|
876
|
+
if (UNMETERED_METHODS.has(method)) return runObserved(next)
|
|
384
877
|
|
|
385
878
|
const isEdit = EDIT_METHODS.has(method)
|
|
386
879
|
const isSend = SEND_METHODS.has(method)
|
|
387
|
-
|
|
880
|
+
const isChatAction = CHAT_ACTION_METHODS.has(method)
|
|
388
881
|
|
|
389
882
|
const { chat, msg } = payloadKeys(payload)
|
|
390
|
-
// Inline-message edits carry no chat/message id — nothing to key on, and
|
|
391
|
-
// they are not part of any card loop. Pass through (still observed).
|
|
392
|
-
if (chat == null) return runObserved(next)
|
|
393
883
|
|
|
394
884
|
const now = clock.now()
|
|
395
885
|
evict(now)
|
|
886
|
+
// ONE deadline for the whole call, shared by every tier it crosses.
|
|
887
|
+
const deadline = now + maxDeferMs
|
|
888
|
+
|
|
889
|
+
// The send gate's priority class, propagated through AsyncLocalStorage.
|
|
890
|
+
// An untagged edit is COSMETIC by default — see `defaultOutboundClass`.
|
|
891
|
+
// A chat action is cosmetic too: it is a self-expiring status, not content.
|
|
892
|
+
const cls =
|
|
893
|
+
currentOutboundClass() ?? (isChatAction ? 'cosmetic' : defaultOutboundClass(isEdit))
|
|
396
894
|
|
|
397
|
-
|
|
398
|
-
|
|
895
|
+
// DEFAULT-DENY (#3855): a call the fuse cannot key to a chat — an inline
|
|
896
|
+
// edit, `answerCallbackQuery`, `getFile`, `setMyCommands` — is NOT free.
|
|
897
|
+
// It cannot take a per-chat slot (there is no chat to charge), but it does
|
|
898
|
+
// consume the bot token's global allowance, which is the thing Telegram
|
|
899
|
+
// actually bans. Charge it there and pass.
|
|
900
|
+
if (chat == null) {
|
|
901
|
+
counters.chatless++
|
|
902
|
+
await awaitRoom(tokenKey, perTokenWindowMs, perTokenMax, method, 'release', cls, undefined, deadline)
|
|
903
|
+
return runObserved(next)
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
const totalKey = `t:${chat}`
|
|
907
|
+
// Cosmetic traffic may only reach `perChatTotalMax - perChatReplyReserve`;
|
|
908
|
+
// the remaining slots stay available to a real reply no matter how hot the
|
|
909
|
+
// repaint surfaces are. This reservation is the reply-starvation guarantee.
|
|
910
|
+
const totalMaxFor = (c: OutboundClass): number =>
|
|
911
|
+
c === 'cosmetic' ? Math.max(1, perChatTotalMax - perChatReplyReserve) : perChatTotalMax
|
|
912
|
+
|
|
913
|
+
/** Final tier for every chat-targeted path: the token-scoped ceiling. */
|
|
914
|
+
const passToken = async (): Promise<R> => {
|
|
915
|
+
await awaitRoom(tokenKey, perTokenWindowMs, perTokenMax, method, 'release', cls, undefined, deadline)
|
|
916
|
+
return runObserved(next)
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
if (isEdit && msg != null) {
|
|
399
920
|
const msgKey = `m:${chat}:${msg}`
|
|
400
921
|
const mw = win(msgKey)
|
|
401
922
|
// Counted BEFORE the first await so a frame that arrives while an older
|
|
402
923
|
// one is waiting is visible to that older one's `dropGuard`.
|
|
403
924
|
mw.inflight++
|
|
404
925
|
try {
|
|
405
|
-
const msgSlot = await awaitRoom(
|
|
926
|
+
const msgSlot = await awaitRoom(
|
|
927
|
+
msgKey, perMessageWindowMs,
|
|
928
|
+
cls === 'cosmetic' ? cosmeticPerMessageMax : perMessageMax,
|
|
929
|
+
method, 'supersede', cls, undefined, deadline,
|
|
930
|
+
)
|
|
406
931
|
if (msgSlot === null) return DROPPED_RESULT as unknown as R
|
|
932
|
+
// R1: only drop while something newer for THIS message is still in
|
|
933
|
+
// flight to repaint it. `> 1` = this frame plus at least one newer.
|
|
934
|
+
const dropGuard = (): boolean => mw.inflight > 1
|
|
935
|
+
// Cosmetic frames share ONE overshoot budget per chat across both
|
|
936
|
+
// per-chat tiers, so a frame cannot late-release twice on its way out.
|
|
937
|
+
const lateKey = cls === 'cosmetic' ? `lr:${chat}` : undefined
|
|
938
|
+
const reserved: Array<[string, number]> = [[msgKey, msgSlot]]
|
|
939
|
+
const giveBack = (): void => { for (const [k, at] of reserved) unreserve(k, at) }
|
|
940
|
+
|
|
407
941
|
const chatKey = `ce:${chat}`
|
|
408
942
|
const chatSlot = await awaitRoom(
|
|
409
|
-
chatKey, perChatWindowMs,
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
() => mw.inflight > 1,
|
|
943
|
+
chatKey, perChatWindowMs,
|
|
944
|
+
cls === 'cosmetic' ? cosmeticPerChatMax : perChatEditMax,
|
|
945
|
+
method, 'drop', cls, dropGuard, deadline, lateKey,
|
|
413
946
|
)
|
|
414
|
-
if (chatSlot === null) {
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
947
|
+
if (chatSlot === null) { giveBack(); return DROPPED_RESULT as unknown as R }
|
|
948
|
+
reserved.push([chatKey, chatSlot])
|
|
949
|
+
|
|
950
|
+
// Shared per-chat budget — the one that mirrors Telegram's real
|
|
951
|
+
// metering. A non-cosmetic edit is RELEASED late rather than dropped:
|
|
952
|
+
// an approval card or answer finalisation has no newer frame coming.
|
|
953
|
+
const totalSlot = await awaitRoom(
|
|
954
|
+
totalKey, perChatWindowMs, totalMaxFor(cls), method,
|
|
955
|
+
cls === 'cosmetic' ? 'drop' : 'release', cls, dropGuard, deadline, lateKey,
|
|
956
|
+
)
|
|
957
|
+
if (totalSlot === null) { giveBack(); return DROPPED_RESULT as unknown as R }
|
|
958
|
+
return passToken()
|
|
421
959
|
} finally {
|
|
422
960
|
mw.inflight--
|
|
423
961
|
}
|
|
424
962
|
}
|
|
425
963
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
964
|
+
if (isSend) {
|
|
965
|
+
// Sends create user-visible output and are NEVER dropped — only paced.
|
|
966
|
+
// The shared budget's reserve is what guarantees they still have room
|
|
967
|
+
// when the repaint surfaces are saturated.
|
|
968
|
+
await awaitRoom(`cs:${chat}`, perChatWindowMs, perChatSendMax, method, 'release', cls, undefined, deadline)
|
|
969
|
+
await awaitRoom(totalKey, perChatWindowMs, totalMaxFor(cls), method, 'release', cls, undefined, deadline)
|
|
970
|
+
return passToken()
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// DEFAULT-DENY (#3855). Everything else with a `chat_id` — `sendChatAction`,
|
|
974
|
+
// `setMessageReaction`, `deleteMessage`, `pinChatMessage`, `sendChecklist`,
|
|
975
|
+
// an inline edit that carries a chat but no message id, and any Bot API
|
|
976
|
+
// method added after this file was written — is charged to the SAME shared
|
|
977
|
+
// per-chat window. Previously all of it was free, which is how a "20/60s"
|
|
978
|
+
// ceiling admitted ~34 calls/60s on the wire.
|
|
979
|
+
counters.meteredByDefault++
|
|
980
|
+
if (isChatAction) {
|
|
981
|
+
// Self-expiring status: shed it rather than deliver it late. Its own
|
|
982
|
+
// short deadline also keeps a typing ping from occupying a 30s hold.
|
|
983
|
+
const actionDeadline = Math.min(deadline, now + chatActionMaxDeferMs)
|
|
984
|
+
const slot = await awaitRoom(
|
|
985
|
+
totalKey, perChatWindowMs, totalMaxFor(cls), method, 'drop', cls, undefined, actionDeadline,
|
|
986
|
+
)
|
|
987
|
+
if (slot === null) return DROPPED_RESULT as unknown as R
|
|
988
|
+
return passToken()
|
|
989
|
+
}
|
|
990
|
+
// Durable, user-visible effect (a deletion, a pin, a reaction): paced,
|
|
991
|
+
// never dropped — the same contract sends get.
|
|
992
|
+
await awaitRoom(totalKey, perChatWindowMs, totalMaxFor(cls), method, 'release', cls, undefined, deadline)
|
|
993
|
+
return passToken()
|
|
429
994
|
}
|
|
430
995
|
|
|
431
996
|
/** Run the downstream call, tightening the ceilings if it floods. */
|
|
@@ -434,13 +999,16 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
|
|
|
434
999
|
const res = await next()
|
|
435
1000
|
// grammY throws on ok:false, but a transformer installed BELOW another
|
|
436
1001
|
// one can still observe a raw ApiResponse — handle both shapes.
|
|
437
|
-
const r = res as unknown as {
|
|
1002
|
+
const r = res as unknown as {
|
|
1003
|
+
ok?: boolean; error_code?: number; parameters?: { retry_after?: number }
|
|
1004
|
+
}
|
|
438
1005
|
if (r != null && typeof r === 'object' && r.ok === false && r.error_code === 429) {
|
|
439
|
-
noteFlood(clock.now())
|
|
1006
|
+
noteFlood(clock.now(), r.parameters?.retry_after ?? 0)
|
|
440
1007
|
}
|
|
441
1008
|
return res
|
|
442
1009
|
} catch (err) {
|
|
443
|
-
|
|
1010
|
+
const retryAfterSec = floodRetryAfterSec(err)
|
|
1011
|
+
if (retryAfterSec !== null) noteFlood(clock.now(), retryAfterSec)
|
|
444
1012
|
throw err
|
|
445
1013
|
}
|
|
446
1014
|
}
|
|
@@ -454,7 +1022,14 @@ export function createEditFloodFuse(config: EditFloodFuseConfig = {}) {
|
|
|
454
1022
|
superseded: counters.superseded,
|
|
455
1023
|
floodObserved: counters.floodObserved,
|
|
456
1024
|
tightened: isTightened(now),
|
|
1025
|
+
tightenLevel: levelAt(now),
|
|
457
1026
|
perMessageCeiling: ceiling(perMessageMax, now),
|
|
1027
|
+
cosmeticPerMessageCeiling: ceiling(cosmeticPerMessageMax, now),
|
|
1028
|
+
cosmeticPerChatCeiling: ceiling(cosmeticPerChatMax, now),
|
|
1029
|
+
meteredByDefault: counters.meteredByDefault,
|
|
1030
|
+
chatless: counters.chatless,
|
|
1031
|
+
perTokenCeiling: ceiling(perTokenMax, now),
|
|
1032
|
+
persistedFloodOpen: persistedFloodRemainingMs(now) > 0,
|
|
458
1033
|
}
|
|
459
1034
|
}
|
|
460
1035
|
|
|
@@ -470,7 +1045,13 @@ export type EditFloodFuse = ReturnType<typeof createEditFloodFuse>
|
|
|
470
1045
|
export type FuseInstallable = Bot
|
|
471
1046
|
|
|
472
1047
|
export function installEditFloodFuse(bot: FuseInstallable, config: EditFloodFuseConfig = {}): EditFloodFuse {
|
|
473
|
-
|
|
1048
|
+
// The token window is keyed by the bot's PUBLIC id only. `deriveBotScopeKey`
|
|
1049
|
+
// never returns any part of the secret half, and the key is used solely as an
|
|
1050
|
+
// in-memory Map key — never logged, never persisted.
|
|
1051
|
+
const fuse = createEditFloodFuse({
|
|
1052
|
+
botScopeKey: deriveBotScopeKey((bot as { token?: string }).token),
|
|
1053
|
+
...config,
|
|
1054
|
+
})
|
|
474
1055
|
bot.api.config.use(async (prev, method, payload, signal) =>
|
|
475
1056
|
fuse.apply(method, payload, () => prev(method, payload, signal)))
|
|
476
1057
|
return fuse
|