switchroom 0.19.26 → 0.19.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/git-agent-attribution-hook.sh +144 -0
- package/dist/agent-scheduler/index.js +55 -0
- package/dist/auth-broker/index.js +116 -6
- package/dist/cli/notion-write-pretool.mjs +55 -0
- package/dist/cli/switchroom.js +2055 -1193
- package/dist/host-control/main.js +117 -7
- package/dist/vault/approvals/kernel-server.js +114 -6
- package/dist/vault/broker/server.js +114 -6
- package/package.json +6 -2
- package/profiles/_base/cron-session.sh.hbs +8 -0
- package/profiles/_base/start.sh.hbs +105 -0
- package/telegram-plugin/card-layout.ts +328 -0
- package/telegram-plugin/dist/bridge/bridge.js +93 -1
- package/telegram-plugin/dist/gateway/gateway.js +2009 -1166
- package/telegram-plugin/dist/server.js +96 -1
- package/telegram-plugin/edit-flood-fuse.ts +637 -56
- package/telegram-plugin/flood-429-ledger.ts +526 -0
- package/telegram-plugin/flood-circuit-breaker.ts +18 -0
- package/telegram-plugin/gateway/flood-reply-queue.ts +168 -0
- package/telegram-plugin/gateway/gateway.ts +58 -68
- package/telegram-plugin/gateway/narrative-lane.ts +14 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +36 -0
- package/telegram-plugin/gateway/outbox-sweep.ts +183 -6
- package/telegram-plugin/gateway/pinned-message-handler.ts +12 -16
- package/telegram-plugin/gateway/status-pin-retarget.ts +72 -36
- package/telegram-plugin/gateway/status-pin-store.ts +58 -9
- package/telegram-plugin/gateway/worker-pin-reaper.ts +56 -7
- package/telegram-plugin/llm-error-present.ts +61 -2
- package/telegram-plugin/model-unavailable.ts +8 -0
- package/telegram-plugin/operator-events.ts +72 -5
- package/telegram-plugin/outbound-class.ts +81 -0
- package/telegram-plugin/provider-credit.ts +237 -0
- package/telegram-plugin/scripts/bun-test-ci.sh +36 -6
- package/telegram-plugin/send-gate.ts +24 -2
- package/telegram-plugin/status-no-truncate.ts +10 -48
- package/telegram-plugin/status-pin-driver.ts +33 -45
- package/telegram-plugin/status-pin.ts +18 -1
- package/telegram-plugin/tests/card-golden.test.ts +69 -0
- package/telegram-plugin/tests/card-lifecycle-render.test.ts +362 -0
- package/telegram-plugin/tests/card-type-distinguishability.test.ts +187 -164
- package/telegram-plugin/tests/card-variants.golden.txt +211 -0
- package/telegram-plugin/tests/card-variants.ts +366 -0
- package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +316 -0
- package/telegram-plugin/tests/edit-flood-fuse-default-deny.test.ts +319 -0
- package/telegram-plugin/tests/edit-flood-fuse.test.ts +11 -2
- package/telegram-plugin/tests/feed-edit-rate-ceiling.test.ts +462 -0
- package/telegram-plugin/tests/fixtures/real-429-stream.ts +220 -0
- package/telegram-plugin/tests/flood-429-ledger.test.ts +278 -0
- package/telegram-plugin/tests/flood-429-recorder-wiring.test.ts +128 -0
- package/telegram-plugin/tests/flood-reply-queue.test.ts +418 -0
- package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +221 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +19 -24
- package/telegram-plugin/tests/pinned-message-handler.test.ts +15 -15
- package/telegram-plugin/tests/provider-credit-402.test.ts +243 -0
- package/telegram-plugin/tests/status-pin-api.test.ts +11 -11
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +36 -37
- package/telegram-plugin/tests/status-pin-lifecycle.test.ts +602 -0
- package/telegram-plugin/tests/status-pin-retarget.test.ts +90 -62
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +7 -3
- package/telegram-plugin/tests/status-pin-store.test.ts +109 -60
- package/telegram-plugin/tests/status-pin.test.ts +56 -5
- package/telegram-plugin/tests/test-runner-coverage.test.ts +133 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +12 -10
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +23 -29
- package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +56 -59
- package/telegram-plugin/tests/worker-feed-terminal-edit-class.test.ts +335 -0
- package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
- package/telegram-plugin/tool-activity-summary.ts +239 -365
- package/telegram-plugin/uat/assertions.ts +22 -11
- package/telegram-plugin/uat/feed-matcher.test.ts +24 -17
- package/telegram-plugin/worker-activity-feed.ts +105 -47
- package/vendor/hindsight-memory/CLAUDE.md +45 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +33 -0
- package/vendor/hindsight-memory/scripts/recall.py +176 -7
- package/vendor/hindsight-memory/scripts/tests/test_config_recall_passthrough_env.py +170 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_min_score.py +464 -0
- package/vendor/hindsight-memory/settings.json +1 -1
|
@@ -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
|
+
})
|
|
@@ -297,9 +297,18 @@ describe('edit-flood fuse — structural: installed where nothing can bypass it'
|
|
|
297
297
|
expect(fuse.stats().dropped + fuse.stats().superseded).toBeGreaterThan(0)
|
|
298
298
|
})
|
|
299
299
|
|
|
300
|
-
|
|
300
|
+
// #3855: this test used to be titled "passes non-message methods straight
|
|
301
|
+
// through" and stood for the ALLOWLIST — everything outside EDIT/SEND was
|
|
302
|
+
// free, which left 29% of real traffic unmetered. The exemption is now a
|
|
303
|
+
// one-entry list and this test guards exactly that entry, not a category.
|
|
304
|
+
it('getUpdates — the long-poll RECEIVE loop — is the one unmetered method', async () => {
|
|
301
305
|
const clock = new FakeClock()
|
|
302
|
-
const fuse = createEditFloodFuse({
|
|
306
|
+
const fuse = createEditFloodFuse({
|
|
307
|
+
clock, perChatSendMaxPerWindow: 1, perChatWindowMs: 600_000,
|
|
308
|
+
// Deliberately brutal: if getUpdates were metered at all, the 2nd call
|
|
309
|
+
// would block forever on this clock.
|
|
310
|
+
perTokenMaxPerWindow: 1, perTokenWindowMs: 600_000,
|
|
311
|
+
})
|
|
303
312
|
let calls = 0
|
|
304
313
|
for (let i = 0; i < 100; i++) {
|
|
305
314
|
await fuse.apply('getUpdates', {}, async () => { calls++; return [] })
|