switchroom 0.19.26 → 0.19.28
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 +60 -2
- package/dist/auth-broker/index.js +244 -13
- package/dist/cli/autoaccept-poll.js +225 -17
- package/dist/cli/notion-write-pretool.mjs +60 -2
- package/dist/cli/switchroom.js +2843 -1220
- package/dist/host-control/main.js +245 -14
- package/dist/vault/approvals/kernel-server.js +242 -13
- package/dist/vault/broker/server.js +242 -13
- package/package.json +7 -2
- package/profiles/_base/cron-session.sh.hbs +8 -0
- package/profiles/_base/start.sh.hbs +175 -15
- package/telegram-plugin/card-layout.ts +328 -0
- package/telegram-plugin/dist/bridge/bridge.js +94 -1
- package/telegram-plugin/dist/gateway/gateway.js +2544 -1182
- package/telegram-plugin/dist/server.js +97 -1
- package/telegram-plugin/edit-flood-fuse.ts +841 -57
- package/telegram-plugin/flood-429-ledger.ts +526 -0
- package/telegram-plugin/flood-circuit-breaker.ts +18 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +6 -0
- package/telegram-plugin/gateway/flood-reply-queue.ts +168 -0
- package/telegram-plugin/gateway/gateway.ts +67 -70
- package/telegram-plugin/gateway/mcp-failure-hook.ts +74 -0
- 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/inline-keyboard-callbacks.ts +202 -21
- package/telegram-plugin/llm-error-present.ts +61 -2
- package/telegram-plugin/mcp-credential-failure.ts +459 -0
- package/telegram-plugin/model-unavailable.ts +8 -0
- package/telegram-plugin/operator-events.ts +110 -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 +373 -0
- package/telegram-plugin/tests/edit-flood-fuse-default-deny.test.ts +319 -0
- package/telegram-plugin/tests/edit-flood-fuse-reply-reserve.test.ts +340 -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/finalize-callback-flood-policy.test.ts +298 -0
- package/telegram-plugin/tests/finalize-callback.test.ts +41 -8
- 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/mcp-credential-failure.test.ts +310 -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/drain_pending.py +433 -11
- package/vendor/hindsight-memory/scripts/lib/config.py +33 -0
- package/vendor/hindsight-memory/scripts/lib/pending.py +193 -28
- 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_drain_circuit_breaker.py +401 -0
- package/vendor/hindsight-memory/scripts/tests/test_drain_serialisation.py +286 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +817 -8
- package/vendor/hindsight-memory/scripts/tests/test_recall_min_score.py +464 -0
- package/vendor/hindsight-memory/settings.json +1 -1
- package/vendor/hindsight-memory/tests/test_hooks.py +11 -2
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fuse must survive a restart and must react in PROPORTION to the ban
|
|
3
|
+
* (#3856).
|
|
4
|
+
*
|
|
5
|
+
* ── The two bugs these tests guard ───────────────────────────────────────
|
|
6
|
+
*
|
|
7
|
+
* 1. **All state was in process memory.** `tightenLevel` / `tightenedUntil`
|
|
8
|
+
* were module-local variables. The gateway restarting during a flood ban —
|
|
9
|
+
* the single most likely thing to happen during a multi-hour outage —
|
|
10
|
+
* brought the fuse back FULLY UNTIGHTENED, at exactly the rate that earned
|
|
11
|
+
* the ban, while the window was still open. Every other outbound path in
|
|
12
|
+
* the tree already consults `flood-wait.json`; the fuse did not.
|
|
13
|
+
*
|
|
14
|
+
* 2. **`noteFlood()` ignored the magnitude.** Every 429 was worth exactly one
|
|
15
|
+
* level of multiplicative decrease, so a 3-second burst nudge and the
|
|
16
|
+
* 15908-second (4.4h) ban of 2026-07-27 produced an identical response.
|
|
17
|
+
* The tightening also expired after a flat 10 minutes — ~25× shorter than
|
|
18
|
+
* that ban, so the fuse would have been back at full rate for hours while
|
|
19
|
+
* still banned.
|
|
20
|
+
*
|
|
21
|
+
* Every test asserts an OUTCOME (the live ceiling, or how many calls reached
|
|
22
|
+
* the wire), never that a branch ran.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { describe, it, expect } from 'vitest'
|
|
26
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
27
|
+
import { tmpdir } from 'node:os'
|
|
28
|
+
import { join } from 'node:path'
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
createEditFloodFuse,
|
|
32
|
+
editFloodFuseConfigFromEnv,
|
|
33
|
+
EDIT_FLOOD_FUSE_DEFAULTS,
|
|
34
|
+
} from '../edit-flood-fuse.js'
|
|
35
|
+
import { floodStatePath, makeFloodWaitProbe } from '../flood-circuit-breaker.js'
|
|
36
|
+
import type { Clock } from '../send-gate.js'
|
|
37
|
+
|
|
38
|
+
const CHAT = '1001'
|
|
39
|
+
|
|
40
|
+
class FakeClock implements Clock {
|
|
41
|
+
private cur = 0
|
|
42
|
+
private seq = 0
|
|
43
|
+
private timers: { at: number; id: number; resolve: () => void }[] = []
|
|
44
|
+
now(): number { return this.cur }
|
|
45
|
+
sleep(ms: number): Promise<void> {
|
|
46
|
+
return new Promise<void>((resolve) => {
|
|
47
|
+
this.timers.push({ at: this.cur + ms, id: this.seq++, resolve })
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
async advance(ms: number): Promise<void> {
|
|
51
|
+
const target = this.cur + ms
|
|
52
|
+
for (;;) {
|
|
53
|
+
await flush()
|
|
54
|
+
const due = this.timers.filter((t) => t.at <= target).sort((a, b) => a.at - b.at || a.id - b.id)
|
|
55
|
+
if (due.length === 0) break
|
|
56
|
+
const t = due[0]!
|
|
57
|
+
this.timers = this.timers.filter((x) => x !== t)
|
|
58
|
+
this.cur = t.at
|
|
59
|
+
t.resolve()
|
|
60
|
+
await flush()
|
|
61
|
+
}
|
|
62
|
+
this.cur = target
|
|
63
|
+
await flush()
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const flush = (): Promise<void> => new Promise((r) => setImmediate(r))
|
|
67
|
+
|
|
68
|
+
/** A 429 in the shape grammY throws it. */
|
|
69
|
+
function flood(retryAfterSec: number): Error & Record<string, unknown> {
|
|
70
|
+
const e = new Error('Too Many Requests: retry after ' + retryAfterSec) as Error &
|
|
71
|
+
Record<string, unknown>
|
|
72
|
+
e.error_code = 429
|
|
73
|
+
e.parameters = { retry_after: retryAfterSec }
|
|
74
|
+
return e
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe('restart durability — the persisted window outranks process memory', () => {
|
|
78
|
+
it('a FRESH fuse is already at its tightest when a ban is open on disk', async () => {
|
|
79
|
+
const clock = new FakeClock()
|
|
80
|
+
// Simulate the restart: brand-new fuse, zero in-memory history, but the
|
|
81
|
+
// marker on disk still says 12247s of ban to go.
|
|
82
|
+
const fuse = createEditFloodFuse({
|
|
83
|
+
clock, floodWaitRemainingMs: () => 12_247_000,
|
|
84
|
+
perMessageMaxPerWindow: 20, cosmeticPerMessageMaxPerWindow: 16,
|
|
85
|
+
maxTightenLevel: 4, tightenFactor: 0.5,
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const s = fuse.stats()
|
|
89
|
+
expect(s.persistedFloodOpen).toBe(true)
|
|
90
|
+
expect(s.tightenLevel).toBe(4)
|
|
91
|
+
// 0.5^4 = 1/16 of the base ceiling — the fuse did NOT come back at full rate.
|
|
92
|
+
expect(s.cosmeticPerMessageCeiling).toBe(1)
|
|
93
|
+
expect(s.perMessageCeiling).toBe(1)
|
|
94
|
+
// And it BINDS: 30 cosmetic edits offered, at most the tightened ceiling
|
|
95
|
+
// reaches the wire. Without the persisted read this is 16.
|
|
96
|
+
let landed = 0
|
|
97
|
+
const calls = Array.from({ length: 30 }, () =>
|
|
98
|
+
fuse.apply('editMessageText', { chat_id: CHAT, message_id: 7 }, async () => {
|
|
99
|
+
landed++
|
|
100
|
+
return true
|
|
101
|
+
}))
|
|
102
|
+
await clock.advance(31_000)
|
|
103
|
+
await Promise.all(calls)
|
|
104
|
+
expect(landed).toBeLessThanOrEqual(2)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('untightens once the window on disk closes — it is a floor, not a latch', async () => {
|
|
108
|
+
const clock = new FakeClock()
|
|
109
|
+
let remaining = 12_247_000
|
|
110
|
+
const fuse = createEditFloodFuse({
|
|
111
|
+
clock, floodWaitRemainingMs: () => remaining,
|
|
112
|
+
floodProbeIntervalMs: 0, // re-read every call, for determinism here
|
|
113
|
+
perMessageMaxPerWindow: 20, maxTightenLevel: 4, tightenFactor: 0.5,
|
|
114
|
+
})
|
|
115
|
+
expect(fuse.stats().tightenLevel).toBe(4)
|
|
116
|
+
remaining = 0
|
|
117
|
+
// The fuse earned nothing itself, so it returns all the way to base — the
|
|
118
|
+
// persisted window pins the level while open and never mutates it.
|
|
119
|
+
expect(fuse.stats().tightenLevel).toBe(0)
|
|
120
|
+
expect(fuse.stats().perMessageCeiling).toBe(20)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('grades the persisted window by what REMAINS, not straight to maximum (#3885)', () => {
|
|
124
|
+
// `makeFloodWaitRecorder` writes `flood-wait.json` for EVERY 429, including
|
|
125
|
+
// a routine 3-second nudge. Pinning at `maxTightenLevel` for any open
|
|
126
|
+
// window therefore reintroduced on the persisted path the exact defect
|
|
127
|
+
// #3856 fixed on the in-memory one: a nudge and a 4.4h ban produced the
|
|
128
|
+
// same maximal response, collapsing a whole chat to 1 call/min over a
|
|
129
|
+
// transient blip. The severity ladder is the same one `noteFlood` uses.
|
|
130
|
+
const cases: [number, number][] = [
|
|
131
|
+
[3_000, 1], // routine burst nudge
|
|
132
|
+
[45_000, 2], // sustained overrate
|
|
133
|
+
[300_000, 3], // a real ban
|
|
134
|
+
[12_247_000, 4], // the 2026-07-27 magnitude — still straight to maximum
|
|
135
|
+
]
|
|
136
|
+
for (const [remainingMs, expected] of cases) {
|
|
137
|
+
const fuse = createEditFloodFuse({
|
|
138
|
+
clock: new FakeClock(), floodWaitRemainingMs: () => remainingMs,
|
|
139
|
+
floodProbeIntervalMs: 0, maxTightenLevel: 4, tightenFactor: 0.5,
|
|
140
|
+
})
|
|
141
|
+
expect(fuse.stats().persistedFloodOpen).toBe(true)
|
|
142
|
+
expect(fuse.stats().tightenLevel).toBe(expected)
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('a single 3s 429 does not collapse the chat to 1 call/min (#3885)', async () => {
|
|
147
|
+
// The production regression, end to end: one transient 429 during a fleet
|
|
148
|
+
// restart both bumped the in-memory level AND wrote a 3-second marker that
|
|
149
|
+
// pinned the fuse at maximum, taking `perChatTotalMaxPerWindow: 20` down to
|
|
150
|
+
// an effective 1 for the whole chat.
|
|
151
|
+
const clock = new FakeClock()
|
|
152
|
+
const fuse = createEditFloodFuse({
|
|
153
|
+
clock, floodProbeIntervalMs: 0,
|
|
154
|
+
// The marker as the real recorder would write it for a 3s penalty.
|
|
155
|
+
floodWaitRemainingMs: () => Math.max(0, 3_000 - clock.now()),
|
|
156
|
+
perChatTotalMaxPerWindow: 20, perChatReplyReserve: 8, perChatSendMaxPerWindow: 25,
|
|
157
|
+
perTokenMaxPerWindow: 1_000, maxTightenLevel: 4, tightenFactor: 0.5,
|
|
158
|
+
})
|
|
159
|
+
await expect(
|
|
160
|
+
fuse.apply('sendMessage', { chat_id: CHAT, text: 'x' }, async () => { throw flood(3) }),
|
|
161
|
+
).rejects.toThrow()
|
|
162
|
+
|
|
163
|
+
// One nudge is worth one level, on both paths — so the chat keeps half its
|
|
164
|
+
// budget, not one-twentieth of it.
|
|
165
|
+
expect(fuse.stats().tightenLevel).toBe(1)
|
|
166
|
+
expect(fuse.stats().criticalPerChatTotalCeiling).toBe(10)
|
|
167
|
+
|
|
168
|
+
// And it BINDS at that rate: 20 replies offered inside one window, ~half
|
|
169
|
+
// land promptly rather than one. 9 rather than 10 because the 429'd call
|
|
170
|
+
// above took a slot of the same window before it failed.
|
|
171
|
+
let landed = 0
|
|
172
|
+
const calls = Array.from({ length: 20 }, () =>
|
|
173
|
+
fuse.apply('sendMessage', { chat_id: CHAT, text: 'answer' }, async () => { landed++; return true }))
|
|
174
|
+
await clock.advance(1_000)
|
|
175
|
+
expect(landed).toBe(9)
|
|
176
|
+
await clock.advance(60_000)
|
|
177
|
+
await Promise.all(calls)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('FAILS OPEN: an unreadable marker must never gag the bot', async () => {
|
|
181
|
+
const clock = new FakeClock()
|
|
182
|
+
const fuse = createEditFloodFuse({
|
|
183
|
+
clock,
|
|
184
|
+
floodWaitRemainingMs: () => {
|
|
185
|
+
throw new Error('EACCES: flood-wait.json owned by another uid')
|
|
186
|
+
},
|
|
187
|
+
perChatSendMaxPerWindow: 25,
|
|
188
|
+
})
|
|
189
|
+
expect(fuse.stats().persistedFloodOpen).toBe(false)
|
|
190
|
+
expect(fuse.stats().tightenLevel).toBe(0)
|
|
191
|
+
let landed = 0
|
|
192
|
+
await fuse.apply('sendMessage', { chat_id: CHAT, text: 'the answer' }, async () => {
|
|
193
|
+
landed++
|
|
194
|
+
return true
|
|
195
|
+
})
|
|
196
|
+
expect(landed).toBe(1)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('re-reads the marker at most once per floodProbeIntervalMs', async () => {
|
|
200
|
+
const clock = new FakeClock()
|
|
201
|
+
let reads = 0
|
|
202
|
+
const fuse = createEditFloodFuse({
|
|
203
|
+
clock, floodProbeIntervalMs: 1_000,
|
|
204
|
+
floodWaitRemainingMs: () => { reads++; return 0 },
|
|
205
|
+
perChatTotalMaxPerWindow: 1_000, perChatSendMaxPerWindow: 1_000,
|
|
206
|
+
perTokenMaxPerWindow: 1_000,
|
|
207
|
+
})
|
|
208
|
+
for (let i = 0; i < 50; i++) {
|
|
209
|
+
await fuse.apply('sendMessage', { chat_id: CHAT, text: 'x' }, async () => true)
|
|
210
|
+
}
|
|
211
|
+
// A file read on every admission would make the fuse the latency problem
|
|
212
|
+
// it exists to prevent.
|
|
213
|
+
expect(reads).toBeLessThanOrEqual(2)
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('reads the REAL breaker file that robustApiCall and the sweep use', async () => {
|
|
217
|
+
// Not a mock: write the actual on-disk marker and let the production probe
|
|
218
|
+
// read it, so the two halves cannot drift apart.
|
|
219
|
+
const dir = mkdtempSync(join(tmpdir(), 'fuse-flood-'))
|
|
220
|
+
try {
|
|
221
|
+
writeFileSync(
|
|
222
|
+
floodStatePath(dir),
|
|
223
|
+
JSON.stringify({ untilTs: Date.now() + 12_247_000, retryAfterSec: 12247, recordedTs: Date.now() }),
|
|
224
|
+
'utf-8',
|
|
225
|
+
)
|
|
226
|
+
const fuse = createEditFloodFuse({
|
|
227
|
+
floodWaitRemainingMs: makeFloodWaitProbe(floodStatePath(dir)),
|
|
228
|
+
maxTightenLevel: 4, tightenFactor: 0.5, perMessageMaxPerWindow: 20,
|
|
229
|
+
})
|
|
230
|
+
expect(fuse.stats().persistedFloodOpen).toBe(true)
|
|
231
|
+
expect(fuse.stats().perMessageCeiling).toBe(1)
|
|
232
|
+
} finally {
|
|
233
|
+
rmSync(dir, { recursive: true, force: true })
|
|
234
|
+
}
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('editFloodFuseConfigFromEnv wires the probe from TELEGRAM_STATE_DIR', () => {
|
|
238
|
+
const dir = mkdtempSync(join(tmpdir(), 'fuse-env-'))
|
|
239
|
+
try {
|
|
240
|
+
writeFileSync(
|
|
241
|
+
floodStatePath(dir),
|
|
242
|
+
JSON.stringify({ untilTs: Date.now() + 600_000, retryAfterSec: 600, recordedTs: Date.now() }),
|
|
243
|
+
'utf-8',
|
|
244
|
+
)
|
|
245
|
+
const cfg = editFloodFuseConfigFromEnv({ TELEGRAM_STATE_DIR: dir })
|
|
246
|
+
expect(cfg.floodWaitRemainingMs).toBeTypeOf('function')
|
|
247
|
+
expect(cfg.floodWaitRemainingMs!()).toBeGreaterThan(0)
|
|
248
|
+
// The wiring is what makes it durable in production — a fuse built from
|
|
249
|
+
// this config is tightened without anyone remembering to pass a probe.
|
|
250
|
+
expect(createEditFloodFuse(cfg).stats().persistedFloodOpen).toBe(true)
|
|
251
|
+
} finally {
|
|
252
|
+
rmSync(dir, { recursive: true, force: true })
|
|
253
|
+
}
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('is a no-op when TELEGRAM_STATE_DIR is unset (dev / one-shot contexts)', () => {
|
|
257
|
+
const cfg = editFloodFuseConfigFromEnv({})
|
|
258
|
+
expect(cfg.floodWaitRemainingMs).toBeUndefined()
|
|
259
|
+
expect(createEditFloodFuse(cfg).stats().persistedFloodOpen).toBe(false)
|
|
260
|
+
})
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
describe('severity scaling — a 4.4h ban is not one nudge\'s worth of signal', () => {
|
|
264
|
+
const mk = (clock: FakeClock) =>
|
|
265
|
+
createEditFloodFuse({
|
|
266
|
+
clock, maxTightenLevel: 4, tightenFactor: 0.5, tightenMs: 600_000,
|
|
267
|
+
perMessageMaxPerWindow: 32,
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
async function observe(fuse: ReturnType<typeof mk>, retryAfterSec: number): Promise<void> {
|
|
271
|
+
await expect(
|
|
272
|
+
fuse.apply('editMessageText', { chat_id: CHAT, message_id: 1 }, async () => {
|
|
273
|
+
throw flood(retryAfterSec)
|
|
274
|
+
}),
|
|
275
|
+
).rejects.toThrow()
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
it.each([
|
|
279
|
+
[3, 1], // routine burst nudge
|
|
280
|
+
[30, 2], // sustained overrate
|
|
281
|
+
[120, 3], // a real ban
|
|
282
|
+
[3713, 4], // the 2026-07-25 incident — straight to maximum
|
|
283
|
+
[15908, 4], // the 2026-07-27 incident
|
|
284
|
+
])('retry_after=%is tightens by %i level(s)', async (retryAfterSec, expected) => {
|
|
285
|
+
const clock = new FakeClock()
|
|
286
|
+
const fuse = mk(clock)
|
|
287
|
+
await observe(fuse, retryAfterSec)
|
|
288
|
+
expect(fuse.stats().tightenLevel).toBe(expected)
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
it('the ban-magnitude case reaches the floor in ONE 429, not four', async () => {
|
|
292
|
+
// This is the assertion that fails on the pre-fix code: there, a single
|
|
293
|
+
// 15908s ban moved the ceiling from 32 to 16 and the stream carried on at
|
|
294
|
+
// half rate into a four-hour outage.
|
|
295
|
+
const clock = new FakeClock()
|
|
296
|
+
const fuse = mk(clock)
|
|
297
|
+
expect(fuse.stats().perMessageCeiling).toBe(32)
|
|
298
|
+
await observe(fuse, 15_908)
|
|
299
|
+
expect(fuse.stats().perMessageCeiling).toBe(2) // 32 * 0.5^4
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it('holds the tightening for AT LEAST the ban\'s own duration', async () => {
|
|
303
|
+
// A flat 10-minute tightenMs expired ~25× over during the 4.4h ban.
|
|
304
|
+
const clock = new FakeClock()
|
|
305
|
+
const fuse = mk(clock)
|
|
306
|
+
await observe(fuse, 15_908)
|
|
307
|
+
await clock.advance(15_907_000) // one second before the ban lifts
|
|
308
|
+
expect(fuse.stats().tightenLevel).toBeGreaterThan(0)
|
|
309
|
+
expect(fuse.stats().perMessageCeiling).toBeLessThan(32)
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
it('a 3s nudge still decays on the normal 10-minute schedule', async () => {
|
|
313
|
+
const clock = new FakeClock()
|
|
314
|
+
const fuse = mk(clock)
|
|
315
|
+
await observe(fuse, 3)
|
|
316
|
+
expect(fuse.stats().tightenLevel).toBe(1)
|
|
317
|
+
// Held for tightenMs PLUS the stated penalty (3s) — still ~10 minutes.
|
|
318
|
+
await clock.advance(600_001)
|
|
319
|
+
expect(fuse.stats().tightenLevel).toBe(1)
|
|
320
|
+
await clock.advance(3_001)
|
|
321
|
+
expect(fuse.stats().tightenLevel).toBe(0)
|
|
322
|
+
expect(fuse.stats().perMessageCeiling).toBe(32)
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
it('recovers the magnitude from a 429 that arrives as a raw ApiResponse', async () => {
|
|
326
|
+
const clock = new FakeClock()
|
|
327
|
+
const fuse = mk(clock)
|
|
328
|
+
await fuse.apply('editMessageText', { chat_id: CHAT, message_id: 1 }, async () =>
|
|
329
|
+
({ ok: false, error_code: 429, parameters: { retry_after: 15_908 } }))
|
|
330
|
+
expect(fuse.stats().tightenLevel).toBe(4)
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
it('recovers the magnitude from the message text when there is no parameters block', async () => {
|
|
334
|
+
const clock = new FakeClock()
|
|
335
|
+
const fuse = mk(clock)
|
|
336
|
+
await expect(
|
|
337
|
+
fuse.apply('editMessageText', { chat_id: CHAT, message_id: 1 }, async () => {
|
|
338
|
+
throw new Error('Too Many Requests: retry after 15908')
|
|
339
|
+
}),
|
|
340
|
+
).rejects.toThrow()
|
|
341
|
+
expect(fuse.stats().tightenLevel).toBe(4)
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
it('an UNQUANTIFIED flood is treated as the mildest case, not the worst', async () => {
|
|
345
|
+
// Inflating an unmeasured signal into a maximal response would let one
|
|
346
|
+
// ambiguous error message throttle the bot to 1/window.
|
|
347
|
+
const clock = new FakeClock()
|
|
348
|
+
const fuse = mk(clock)
|
|
349
|
+
await expect(
|
|
350
|
+
fuse.apply('editMessageText', { chat_id: CHAT, message_id: 1 }, async () => {
|
|
351
|
+
throw new Error('Too Many Requests')
|
|
352
|
+
}),
|
|
353
|
+
).rejects.toThrow()
|
|
354
|
+
expect(fuse.stats().tightenLevel).toBe(1)
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
it('still does NOT tighten on an ordinary error that merely contains "429"', async () => {
|
|
358
|
+
const clock = new FakeClock()
|
|
359
|
+
const fuse = mk(clock)
|
|
360
|
+
await expect(
|
|
361
|
+
fuse.apply('editMessageText', { chat_id: CHAT, message_id: 1 }, async () => {
|
|
362
|
+
throw new Error('Bad Request: message 429 not found')
|
|
363
|
+
}),
|
|
364
|
+
).rejects.toThrow()
|
|
365
|
+
expect(fuse.stats().tightenLevel).toBe(0)
|
|
366
|
+
expect(fuse.stats().floodObserved).toBe(0)
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
it('the default probe interval is bounded', () => {
|
|
370
|
+
expect(EDIT_FLOOD_FUSE_DEFAULTS.floodProbeIntervalMs).toBeGreaterThan(0)
|
|
371
|
+
expect(EDIT_FLOOD_FUSE_DEFAULTS.floodProbeIntervalMs).toBeLessThanOrEqual(5_000)
|
|
372
|
+
})
|
|
373
|
+
})
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fuse's meter must count what Telegram counts (#3855).
|
|
3
|
+
*
|
|
4
|
+
* ── The bug these tests guard ────────────────────────────────────────────
|
|
5
|
+
* `apply` opened with an ALLOWLIST:
|
|
6
|
+
*
|
|
7
|
+
* const isEdit = EDIT_METHODS.has(method)
|
|
8
|
+
* const isSend = SEND_METHODS.has(method)
|
|
9
|
+
* if (!isEdit && !isSend) return runObserved(next) // ← everything
|
|
10
|
+
* // else was FREE
|
|
11
|
+
*
|
|
12
|
+
* So `perChatTotalMaxPerWindow` — documented as counting "every admitted call"
|
|
13
|
+
* and "matching Telegram's own per-chat metering" — counted neither. Measured
|
|
14
|
+
* on overlord's gateway log (46791 `tg-post` lines), the unmetered share was
|
|
15
|
+
* 29% overall and 42% in the 30 minutes before the 15908s ban:
|
|
16
|
+
*
|
|
17
|
+
* sendChatAction 10457 (up to 18/min on ONE DM — the whole documented
|
|
18
|
+
* per-chat/minute allowance, counted as zero)
|
|
19
|
+
* setMessageReaction 2098
|
|
20
|
+
* deleteMessage 370
|
|
21
|
+
* pinChatMessage 366
|
|
22
|
+
* unpinChatMessage 365
|
|
23
|
+
* answerCallbackQuery 31 (no chat_id — unkeyable per-chat)
|
|
24
|
+
*
|
|
25
|
+
* Plus: the ban is scoped to the BOT TOKEN, and the fuse had no token-scoped
|
|
26
|
+
* window at all, so nothing bounded the aggregate rate across chats.
|
|
27
|
+
*
|
|
28
|
+
* Every test below asserts an OUTCOME — how many calls reached `next` inside a
|
|
29
|
+
* window — not that a branch executed.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { describe, it, expect } from 'vitest'
|
|
33
|
+
import {
|
|
34
|
+
createEditFloodFuse,
|
|
35
|
+
deriveBotScopeKey,
|
|
36
|
+
EDIT_FLOOD_FUSE_DEFAULTS,
|
|
37
|
+
} from '../edit-flood-fuse.js'
|
|
38
|
+
import type { Clock } from '../send-gate.js'
|
|
39
|
+
|
|
40
|
+
const CHAT = '1001'
|
|
41
|
+
|
|
42
|
+
class FakeClock implements Clock {
|
|
43
|
+
private cur = 0
|
|
44
|
+
private seq = 0
|
|
45
|
+
private timers: { at: number; id: number; resolve: () => void }[] = []
|
|
46
|
+
now(): number { return this.cur }
|
|
47
|
+
sleep(ms: number): Promise<void> {
|
|
48
|
+
return new Promise<void>((resolve) => {
|
|
49
|
+
this.timers.push({ at: this.cur + ms, id: this.seq++, resolve })
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
async advance(ms: number): Promise<void> {
|
|
53
|
+
const target = this.cur + ms
|
|
54
|
+
for (;;) {
|
|
55
|
+
await flush()
|
|
56
|
+
const due = this.timers.filter((t) => t.at <= target).sort((a, b) => a.at - b.at || a.id - b.id)
|
|
57
|
+
if (due.length === 0) break
|
|
58
|
+
const t = due[0]!
|
|
59
|
+
this.timers = this.timers.filter((x) => x !== t)
|
|
60
|
+
this.cur = t.at
|
|
61
|
+
t.resolve()
|
|
62
|
+
await flush()
|
|
63
|
+
}
|
|
64
|
+
this.cur = target
|
|
65
|
+
await flush()
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const flush = (): Promise<void> => new Promise((r) => setImmediate(r))
|
|
69
|
+
|
|
70
|
+
describe('default-deny metering — the 29% the allowlist could not see', () => {
|
|
71
|
+
/**
|
|
72
|
+
* The headline outcome. `sendChatAction` was the single largest unmetered
|
|
73
|
+
* method in the incident. Before the fix this loop put 60 calls on the wire
|
|
74
|
+
* inside one 60s window while the fuse reported a 20/60s ceiling as intact.
|
|
75
|
+
*/
|
|
76
|
+
it('meters sendChatAction — 60 offered, at most the ceiling reaches the wire', async () => {
|
|
77
|
+
const clock = new FakeClock()
|
|
78
|
+
let landed = 0
|
|
79
|
+
const fuse = createEditFloodFuse({
|
|
80
|
+
clock, perChatTotalMaxPerWindow: 20, perChatReplyReserve: 8, perChatWindowMs: 60_000,
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const calls = Array.from({ length: 60 }, () =>
|
|
84
|
+
fuse.apply('sendChatAction', { chat_id: CHAT, action: 'typing' }, async () => {
|
|
85
|
+
landed++
|
|
86
|
+
return true
|
|
87
|
+
}))
|
|
88
|
+
await clock.advance(59_000)
|
|
89
|
+
await Promise.all(calls)
|
|
90
|
+
|
|
91
|
+
// 20 total minus the 8-slot reply reserve = 12 available to cosmetic
|
|
92
|
+
// traffic, which a typing indicator is.
|
|
93
|
+
expect(landed).toBeLessThanOrEqual(12)
|
|
94
|
+
expect(landed).toBeGreaterThan(0)
|
|
95
|
+
// And the meter SAW them — this counter is zero on the pre-fix code.
|
|
96
|
+
expect(fuse.stats().meteredByDefault).toBe(60)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it.each([
|
|
100
|
+
['setMessageReaction', { chat_id: CHAT, message_id: 5, reaction: [] }],
|
|
101
|
+
['deleteMessage', { chat_id: CHAT, message_id: 5 }],
|
|
102
|
+
['pinChatMessage', { chat_id: CHAT, message_id: 5 }],
|
|
103
|
+
['unpinChatMessage', { chat_id: CHAT, message_id: 5 }],
|
|
104
|
+
// Not in EDIT_METHODS or SEND_METHODS and never was — the exact class of
|
|
105
|
+
// method default-deny exists to catch. It carries chat_id and creates a
|
|
106
|
+
// message (gateway.ts `rawSendChecklist`), so it consumes chat budget.
|
|
107
|
+
['sendChecklist', { chat_id: CHAT, title: 't', tasks: [] }],
|
|
108
|
+
// A Bot API method this file has never heard of.
|
|
109
|
+
['sendSomethingInventedNextYear', { chat_id: CHAT }],
|
|
110
|
+
])('meters %s against the shared per-chat window', async (method, payload) => {
|
|
111
|
+
const clock = new FakeClock()
|
|
112
|
+
let landed = 0
|
|
113
|
+
const fuse = createEditFloodFuse({
|
|
114
|
+
clock, perChatTotalMaxPerWindow: 5, perChatReplyReserve: 0, perChatWindowMs: 60_000,
|
|
115
|
+
maxDeferMs: 1_000,
|
|
116
|
+
})
|
|
117
|
+
const calls = Array.from({ length: 30 }, () =>
|
|
118
|
+
fuse.apply(method, payload, async () => { landed++; return true }))
|
|
119
|
+
await clock.advance(2_000) // past the 1s defer deadline
|
|
120
|
+
await Promise.all(calls)
|
|
121
|
+
|
|
122
|
+
// These have durable, user-visible effects, so they are PACED not dropped —
|
|
123
|
+
// over-budget calls release at the defer deadline. The outcome that matters
|
|
124
|
+
// is that they took slots at all: the shared window is now full.
|
|
125
|
+
expect(fuse.stats().meteredByDefault).toBe(30)
|
|
126
|
+
expect(landed).toBe(30)
|
|
127
|
+
expect(fuse.stats().dropped).toBe(0)
|
|
128
|
+
|
|
129
|
+
// The proof the slots were really charged: a reply offered now finds the
|
|
130
|
+
// window saturated and is deferred rather than sailing straight through.
|
|
131
|
+
// On the pre-fix code none of the 30 took a slot, so this never deferred.
|
|
132
|
+
const before = fuse.stats().deferred
|
|
133
|
+
const reply = fuse.apply('sendMessage', { chat_id: CHAT, text: 'hi' }, async () => true)
|
|
134
|
+
await flush()
|
|
135
|
+
expect(fuse.stats().deferred).toBeGreaterThan(before)
|
|
136
|
+
await clock.advance(120_000)
|
|
137
|
+
await reply
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('never DROPS a durable-effect ancillary call — a deletion is paced, not lost', async () => {
|
|
141
|
+
const clock = new FakeClock()
|
|
142
|
+
let landed = 0
|
|
143
|
+
const fuse = createEditFloodFuse({
|
|
144
|
+
clock, perChatTotalMaxPerWindow: 2, perChatReplyReserve: 0, perChatWindowMs: 60_000,
|
|
145
|
+
maxDeferMs: 5_000,
|
|
146
|
+
})
|
|
147
|
+
const calls = Array.from({ length: 10 }, (_, i) =>
|
|
148
|
+
fuse.apply('deleteMessage', { chat_id: CHAT, message_id: i }, async () => {
|
|
149
|
+
landed++
|
|
150
|
+
return true
|
|
151
|
+
}))
|
|
152
|
+
await clock.advance(120_000)
|
|
153
|
+
await Promise.all(calls)
|
|
154
|
+
expect(landed).toBe(10)
|
|
155
|
+
expect(fuse.stats().dropped).toBe(0)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('DOES drop a typing indicator rather than deliver it stale', async () => {
|
|
159
|
+
const clock = new FakeClock()
|
|
160
|
+
let landed = 0
|
|
161
|
+
const fuse = createEditFloodFuse({
|
|
162
|
+
clock, perChatTotalMaxPerWindow: 2, perChatReplyReserve: 0, perChatWindowMs: 60_000,
|
|
163
|
+
maxDeferMs: 30_000, chatActionMaxDeferMs: 3_000,
|
|
164
|
+
})
|
|
165
|
+
const calls = Array.from({ length: 10 }, () =>
|
|
166
|
+
fuse.apply('sendChatAction', { chat_id: CHAT, action: 'typing' }, async () => {
|
|
167
|
+
landed++
|
|
168
|
+
return true
|
|
169
|
+
}))
|
|
170
|
+
await clock.advance(30_000)
|
|
171
|
+
await Promise.all(calls)
|
|
172
|
+
expect(landed).toBe(2)
|
|
173
|
+
expect(fuse.stats().dropped).toBe(8)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('the reply reserve survives a saturating ancillary stream', async () => {
|
|
177
|
+
// The starvation guarantee has to hold against the traffic that was
|
|
178
|
+
// previously invisible, or it is not a guarantee.
|
|
179
|
+
const clock = new FakeClock()
|
|
180
|
+
let repliesLanded = 0
|
|
181
|
+
const fuse = createEditFloodFuse({
|
|
182
|
+
clock, perChatTotalMaxPerWindow: 20, perChatReplyReserve: 8, perChatWindowMs: 60_000,
|
|
183
|
+
maxDeferMs: 30_000,
|
|
184
|
+
})
|
|
185
|
+
const noise = Array.from({ length: 200 }, () =>
|
|
186
|
+
fuse.apply('sendChatAction', { chat_id: CHAT, action: 'typing' }, async () => true))
|
|
187
|
+
await flush()
|
|
188
|
+
const replies = Array.from({ length: 8 }, (_, i) =>
|
|
189
|
+
fuse.apply('sendMessage', { chat_id: CHAT, text: `answer ${i}` }, async () => {
|
|
190
|
+
repliesLanded++
|
|
191
|
+
return true
|
|
192
|
+
}))
|
|
193
|
+
await clock.advance(120_000)
|
|
194
|
+
await Promise.all([...noise, ...replies])
|
|
195
|
+
expect(repliesLanded).toBe(8)
|
|
196
|
+
})
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
describe('the per-BOT-TOKEN window — the scope the ban actually uses', () => {
|
|
200
|
+
it('bounds the aggregate rate across MANY chats, which per-chat windows cannot', async () => {
|
|
201
|
+
// 40 distinct chats, one call each, all in the same instant. Every per-chat
|
|
202
|
+
// window is comfortably in budget; only a token-scoped window sees the sum.
|
|
203
|
+
const clock = new FakeClock()
|
|
204
|
+
let landed = 0
|
|
205
|
+
const fuse = createEditFloodFuse({
|
|
206
|
+
clock, botScopeKey: '123456789',
|
|
207
|
+
perTokenMaxPerWindow: 25, perTokenWindowMs: 1_000, maxDeferMs: 30_000,
|
|
208
|
+
})
|
|
209
|
+
const calls = Array.from({ length: 40 }, (_, i) =>
|
|
210
|
+
fuse.apply('sendMessage', { chat_id: `chat-${i}`, text: 'x' }, async () => {
|
|
211
|
+
landed++
|
|
212
|
+
return true
|
|
213
|
+
}))
|
|
214
|
+
await flush()
|
|
215
|
+
expect(landed).toBe(25) // the token ceiling, not 40
|
|
216
|
+
|
|
217
|
+
await clock.advance(2_000)
|
|
218
|
+
await Promise.all(calls)
|
|
219
|
+
// Nothing is LOST — the remaining 15 land once the window slides.
|
|
220
|
+
expect(landed).toBe(40)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('charges a CHAT-LESS call (answerCallbackQuery) to the token window', async () => {
|
|
224
|
+
// answerCallbackQuery carries `callback_query_id`, never `chat_id`, so the
|
|
225
|
+
// per-chat tier structurally cannot key it. Before #3855 that meant it was
|
|
226
|
+
// free everywhere. It is now charged where it genuinely counts.
|
|
227
|
+
const clock = new FakeClock()
|
|
228
|
+
let landed = 0
|
|
229
|
+
const fuse = createEditFloodFuse({
|
|
230
|
+
clock, perTokenMaxPerWindow: 3, perTokenWindowMs: 1_000, maxDeferMs: 30_000,
|
|
231
|
+
})
|
|
232
|
+
const calls = Array.from({ length: 10 }, (_, i) =>
|
|
233
|
+
fuse.apply('answerCallbackQuery', { callback_query_id: `q${i}` }, async () => {
|
|
234
|
+
landed++
|
|
235
|
+
return true
|
|
236
|
+
}))
|
|
237
|
+
await flush()
|
|
238
|
+
expect(landed).toBe(3)
|
|
239
|
+
expect(fuse.stats().chatless).toBe(10)
|
|
240
|
+
|
|
241
|
+
await clock.advance(5_000)
|
|
242
|
+
await Promise.all(calls)
|
|
243
|
+
expect(landed).toBe(10) // paced, never dropped
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('BOTH windows must pass — a single hot chat is still bounded per-chat', async () => {
|
|
247
|
+
const clock = new FakeClock()
|
|
248
|
+
let landed = 0
|
|
249
|
+
const fuse = createEditFloodFuse({
|
|
250
|
+
clock,
|
|
251
|
+
perChatTotalMaxPerWindow: 4, perChatReplyReserve: 0, perChatWindowMs: 60_000,
|
|
252
|
+
// Token window wide open, so only the per-chat tier can bind.
|
|
253
|
+
perTokenMaxPerWindow: 1_000, perTokenWindowMs: 1_000,
|
|
254
|
+
maxDeferMs: 500,
|
|
255
|
+
})
|
|
256
|
+
const calls = Array.from({ length: 20 }, () =>
|
|
257
|
+
fuse.apply('sendChatAction', { chat_id: CHAT, action: 'typing' }, async () => {
|
|
258
|
+
landed++
|
|
259
|
+
return true
|
|
260
|
+
}))
|
|
261
|
+
await clock.advance(2_000) // past the 500ms defer deadline
|
|
262
|
+
await Promise.all(calls)
|
|
263
|
+
expect(landed).toBe(4)
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it('separate bot ids get separate windows; the same id shares one', async () => {
|
|
267
|
+
const clock = new FakeClock()
|
|
268
|
+
const mk = (botScopeKey: string) =>
|
|
269
|
+
createEditFloodFuse({
|
|
270
|
+
clock, botScopeKey, perTokenMaxPerWindow: 2, perTokenWindowMs: 60_000,
|
|
271
|
+
perChatTotalMaxPerWindow: 1_000, maxDeferMs: 100,
|
|
272
|
+
})
|
|
273
|
+
// Two fuses with the SAME id are still two processes' worth of state — the
|
|
274
|
+
// documented limitation. What this asserts is the KEY derivation, which is
|
|
275
|
+
// what a future shared store would key on.
|
|
276
|
+
expect(deriveBotScopeKey('123456789:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw')).toBe('123456789')
|
|
277
|
+
expect(mk('123456789')).toBeDefined()
|
|
278
|
+
})
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
describe('deriveBotScopeKey — the token itself must never become a key', () => {
|
|
282
|
+
const REAL_SHAPE = '8123456789:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw'
|
|
283
|
+
|
|
284
|
+
it('returns ONLY the public bot id, never any part of the secret', () => {
|
|
285
|
+
const key = deriveBotScopeKey(REAL_SHAPE)
|
|
286
|
+
expect(key).toBe('8123456789')
|
|
287
|
+
const secret = REAL_SHAPE.slice(REAL_SHAPE.indexOf(':') + 1)
|
|
288
|
+
expect(key).not.toContain(secret)
|
|
289
|
+
// Not even a prefix of the secret leaks — check every prefix ≥ 4 chars.
|
|
290
|
+
for (let n = 4; n <= secret.length; n++) {
|
|
291
|
+
expect(key).not.toContain(secret.slice(0, n))
|
|
292
|
+
}
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it('falls back to an irreversible hash for a token of unexpected shape', () => {
|
|
296
|
+
const odd = 'not-a-normal-token'
|
|
297
|
+
const key = deriveBotScopeKey(odd)
|
|
298
|
+
expect(key).toMatch(/^h[0-9a-f]{16}$/)
|
|
299
|
+
expect(key).not.toContain(odd)
|
|
300
|
+
// Stable — the same token must key the same window across constructions.
|
|
301
|
+
expect(deriveBotScopeKey(odd)).toBe(key)
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it('handles an absent token without throwing', () => {
|
|
305
|
+
expect(deriveBotScopeKey(undefined)).toBe('unknown')
|
|
306
|
+
expect(deriveBotScopeKey('')).toBe('unknown')
|
|
307
|
+
})
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
describe('the defaults still describe the real budget', () => {
|
|
311
|
+
it('the token ceiling sits below Telegram\'s ~30/s token-level limit', () => {
|
|
312
|
+
expect(EDIT_FLOOD_FUSE_DEFAULTS.perTokenMaxPerWindow).toBeLessThan(30)
|
|
313
|
+
expect(EDIT_FLOOD_FUSE_DEFAULTS.perTokenWindowMs).toBe(1_000)
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
it('a chat action may not be held past its own ~5s lifetime', () => {
|
|
317
|
+
expect(EDIT_FLOOD_FUSE_DEFAULTS.chatActionMaxDeferMs).toBeLessThan(5_000)
|
|
318
|
+
})
|
|
319
|
+
})
|