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.
Files changed (77) hide show
  1. package/bin/git-agent-attribution-hook.sh +144 -0
  2. package/dist/agent-scheduler/index.js +55 -0
  3. package/dist/auth-broker/index.js +116 -6
  4. package/dist/cli/notion-write-pretool.mjs +55 -0
  5. package/dist/cli/switchroom.js +2055 -1193
  6. package/dist/host-control/main.js +117 -7
  7. package/dist/vault/approvals/kernel-server.js +114 -6
  8. package/dist/vault/broker/server.js +114 -6
  9. package/package.json +6 -2
  10. package/profiles/_base/cron-session.sh.hbs +8 -0
  11. package/profiles/_base/start.sh.hbs +105 -0
  12. package/telegram-plugin/card-layout.ts +328 -0
  13. package/telegram-plugin/dist/bridge/bridge.js +93 -1
  14. package/telegram-plugin/dist/gateway/gateway.js +2009 -1166
  15. package/telegram-plugin/dist/server.js +96 -1
  16. package/telegram-plugin/edit-flood-fuse.ts +637 -56
  17. package/telegram-plugin/flood-429-ledger.ts +526 -0
  18. package/telegram-plugin/flood-circuit-breaker.ts +18 -0
  19. package/telegram-plugin/gateway/flood-reply-queue.ts +168 -0
  20. package/telegram-plugin/gateway/gateway.ts +58 -68
  21. package/telegram-plugin/gateway/narrative-lane.ts +14 -0
  22. package/telegram-plugin/gateway/outbound-send-path.ts +36 -0
  23. package/telegram-plugin/gateway/outbox-sweep.ts +183 -6
  24. package/telegram-plugin/gateway/pinned-message-handler.ts +12 -16
  25. package/telegram-plugin/gateway/status-pin-retarget.ts +72 -36
  26. package/telegram-plugin/gateway/status-pin-store.ts +58 -9
  27. package/telegram-plugin/gateway/worker-pin-reaper.ts +56 -7
  28. package/telegram-plugin/llm-error-present.ts +61 -2
  29. package/telegram-plugin/model-unavailable.ts +8 -0
  30. package/telegram-plugin/operator-events.ts +72 -5
  31. package/telegram-plugin/outbound-class.ts +81 -0
  32. package/telegram-plugin/provider-credit.ts +237 -0
  33. package/telegram-plugin/scripts/bun-test-ci.sh +36 -6
  34. package/telegram-plugin/send-gate.ts +24 -2
  35. package/telegram-plugin/status-no-truncate.ts +10 -48
  36. package/telegram-plugin/status-pin-driver.ts +33 -45
  37. package/telegram-plugin/status-pin.ts +18 -1
  38. package/telegram-plugin/tests/card-golden.test.ts +69 -0
  39. package/telegram-plugin/tests/card-lifecycle-render.test.ts +362 -0
  40. package/telegram-plugin/tests/card-type-distinguishability.test.ts +187 -164
  41. package/telegram-plugin/tests/card-variants.golden.txt +211 -0
  42. package/telegram-plugin/tests/card-variants.ts +366 -0
  43. package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +316 -0
  44. package/telegram-plugin/tests/edit-flood-fuse-default-deny.test.ts +319 -0
  45. package/telegram-plugin/tests/edit-flood-fuse.test.ts +11 -2
  46. package/telegram-plugin/tests/feed-edit-rate-ceiling.test.ts +462 -0
  47. package/telegram-plugin/tests/fixtures/real-429-stream.ts +220 -0
  48. package/telegram-plugin/tests/flood-429-ledger.test.ts +278 -0
  49. package/telegram-plugin/tests/flood-429-recorder-wiring.test.ts +128 -0
  50. package/telegram-plugin/tests/flood-reply-queue.test.ts +418 -0
  51. package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +221 -0
  52. package/telegram-plugin/tests/pinned-card-collapse.test.ts +19 -24
  53. package/telegram-plugin/tests/pinned-message-handler.test.ts +15 -15
  54. package/telegram-plugin/tests/provider-credit-402.test.ts +243 -0
  55. package/telegram-plugin/tests/status-pin-api.test.ts +11 -11
  56. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +36 -37
  57. package/telegram-plugin/tests/status-pin-lifecycle.test.ts +602 -0
  58. package/telegram-plugin/tests/status-pin-retarget.test.ts +90 -62
  59. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +7 -3
  60. package/telegram-plugin/tests/status-pin-store.test.ts +109 -60
  61. package/telegram-plugin/tests/status-pin.test.ts +56 -5
  62. package/telegram-plugin/tests/test-runner-coverage.test.ts +133 -0
  63. package/telegram-plugin/tests/worker-activity-feed.test.ts +12 -10
  64. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +23 -29
  65. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +56 -59
  66. package/telegram-plugin/tests/worker-feed-terminal-edit-class.test.ts +335 -0
  67. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
  68. package/telegram-plugin/tool-activity-summary.ts +239 -365
  69. package/telegram-plugin/uat/assertions.ts +22 -11
  70. package/telegram-plugin/uat/feed-matcher.test.ts +24 -17
  71. package/telegram-plugin/worker-activity-feed.ts +105 -47
  72. package/vendor/hindsight-memory/CLAUDE.md +45 -0
  73. package/vendor/hindsight-memory/scripts/lib/config.py +33 -0
  74. package/vendor/hindsight-memory/scripts/recall.py +176 -7
  75. package/vendor/hindsight-memory/scripts/tests/test_config_recall_passthrough_env.py +170 -0
  76. package/vendor/hindsight-memory/scripts/tests/test_recall_min_score.py +464 -0
  77. 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 per-message ceiling here is 20/60s, i.e. BELOW all three. That is
61
- * deliberate — Telegram's own per-group limit is ~20 messages/minute and
62
- * edits count against it, so the *legitimate* cadences are themselves above
63
- * what Telegram tolerates; that is precisely how `overlord` got banned while
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
- /** Hard ceiling on edits to ONE message id. Default 20 per 60s. */
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 to every ceiling after a 429. Default 0.5. */
339
+ /** Multiplicative decrease applied per observed 429. Default 0.5. */
130
340
  tightenFactor?: number
131
- /** How long a tightened ceiling stays in force. Default 10 minutes. */
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: { method: string; key: string; action: 'deferred' | 'dropped' | 'superseded' }) => void
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
- /** Live per-message ceiling (post-AIMD). */
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 = { deferred: 0, dropped: 0, superseded: 0, floodObserved: 0 }
200
- /** Timestamp until which the AIMD tightening is in force (0 = untightened). */
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 tightenedUntil > now
615
+ return levelAt(now) > 0
205
616
  }
206
617
 
207
618
  /**
208
- * AIMD multiplicative decrease. Applied to every ceiling while tightened;
209
- * floored at 1 so the fuse never deadlocks a surface completely.
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
- if (!isTightened(now)) return base
213
- return Math.max(1, Math.floor(base * tightenFactor))
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
- /** Record a 429 (whatever shape it arrives in) and tighten. */
270
- function noteFlood(now: number): void {
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
- tightenedUntil = now + tightenMs
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
- function looksLikeFlood(err: unknown): boolean {
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
- if (e.error_code === 429) return true
279
- if (e.parameters != null && typeof e.parameters.retry_after === 'number') return true
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
- return /too many requests/i.test(msg) || /retry[ _-]?after/i.test(msg)
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?: () => boolean,
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
- if (!isEdit && !isSend) return runObserved(next)
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
- if (isEdit) {
398
- if (msg == null) return runObserved(next)
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(msgKey, perMessageWindowMs, perMessageMax, method, 'supersede')
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, perChatEditMax, method, 'drop',
410
- // R1: only drop while something newer for THIS message is still in
411
- // flight to repaint it. `> 1` = this frame plus at least one newer.
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
- // Denied by the second tier — hand the first tier's slot back so a
416
- // dropped edit never consumes budget it did not use.
417
- unreserve(msgKey, msgSlot)
418
- return DROPPED_RESULT as unknown as R
419
- }
420
- return runObserved(next)
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
- const chatKey = `cs:${chat}`
427
- await awaitRoom(chatKey, perChatWindowMs, perChatSendMax, method, 'release')
428
- return runObserved(next)
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 { ok?: boolean; error_code?: number }
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
- if (looksLikeFlood(err)) noteFlood(clock.now())
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
- const fuse = createEditFloodFuse(config)
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