switchroom 0.19.30 → 0.19.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/switchroom.js +1121 -584
- package/dist/host-control/main.js +151 -73
- package/package.json +1 -1
- package/profiles/_base/cron-session.sh.hbs +5 -1
- package/profiles/_base/start.sh.hbs +15 -1
- package/telegram-plugin/dist/gateway/gateway.js +1435 -551
- package/telegram-plugin/edit-flood-fuse.ts +70 -20
- package/telegram-plugin/gateway/boot-beacon.ts +364 -0
- package/telegram-plugin/gateway/boot-sweep-gate.ts +20 -15
- package/telegram-plugin/gateway/gateway.ts +87 -88
- package/telegram-plugin/gateway/inbound-spool.ts +39 -0
- package/telegram-plugin/gateway/narrative-lane.ts +12 -0
- package/telegram-plugin/gateway/obligation-store.ts +28 -0
- package/telegram-plugin/gateway/stale-pin-sweep-store.ts +221 -0
- package/telegram-plugin/gateway/stale-pin-sweep-wiring.ts +211 -0
- package/telegram-plugin/gateway/stale-pin-sweep.test.ts +804 -0
- package/telegram-plugin/gateway/stale-pin-sweep.ts +1146 -0
- package/telegram-plugin/gateway/status-pin-retarget.ts +15 -2
- package/telegram-plugin/gateway/status-pin-store.ts +33 -11
- package/telegram-plugin/registry/turns-schema.ts +21 -1
- package/telegram-plugin/retry-api-call.ts +46 -21
- package/telegram-plugin/shared/bot-runtime.ts +61 -17
- package/telegram-plugin/shared/gw-trace-gate.ts +18 -2
- package/telegram-plugin/tests/activity-card-wiring.test.ts +7 -7
- package/telegram-plugin/tests/activity-drain-fuse-drop-not-failure.test.ts +324 -0
- package/telegram-plugin/tests/agent-card-result-footer.test.ts +193 -0
- package/telegram-plugin/tests/boot-beacon.test.ts +462 -0
- package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +6 -6
- package/telegram-plugin/tests/boot-sweep-gate.test.ts +42 -31
- package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +2 -0
- package/telegram-plugin/tests/inbound-spool-progress.test.ts +2 -0
- package/telegram-plugin/tests/inbound-spool.test.ts +134 -5
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +28 -0
- package/telegram-plugin/tests/obligation-determinism.test.ts +2 -0
- package/telegram-plugin/tests/obligation-store.test.ts +67 -1
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
- package/telegram-plugin/tests/status-pin-store.test.ts +26 -5
- package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +161 -0
- package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +30 -0
- package/telegram-plugin/tool-activity-summary.ts +104 -38
- package/telegram-plugin/worker-activity-feed.ts +33 -16
- package/telegram-plugin/gateway/dm-pin-sweep.test.ts +0 -251
- package/telegram-plugin/gateway/dm-pin-sweep.ts +0 -178
|
@@ -0,0 +1,804 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stale-pin-sweep.test.ts — outcome tests for the stale-pin stack drain.
|
|
3
|
+
*
|
|
4
|
+
* The fake Telegram below models the ONE behaviour that made the previous
|
|
5
|
+
* cleanup a no-op for months: `unpinChatMessage` resolves `{ ok: true }`
|
|
6
|
+
* whether or not it popped anything. Every assertion here is about OBSERVED
|
|
7
|
+
* chat state (`stack`) or about calls that were / were not made — never about
|
|
8
|
+
* an internal code path — so a regression that goes back to trusting the API
|
|
9
|
+
* result fails these tests rather than passing them.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, expect, it } from 'vitest'
|
|
13
|
+
import {
|
|
14
|
+
CIRCUIT_BREAKER_RETRY_AFTER_SEC,
|
|
15
|
+
DM_SWEEP_GATE,
|
|
16
|
+
GROUP_SWEEP_GATE,
|
|
17
|
+
UNPIN_ALL_FORUM_TOPIC_ENABLED,
|
|
18
|
+
VERIFY_READ_GAP_MS,
|
|
19
|
+
classifyChatForSweep,
|
|
20
|
+
collectSweepTargets,
|
|
21
|
+
createPinOpBudget,
|
|
22
|
+
createStalePinSweeper,
|
|
23
|
+
isNothingToUnpinError,
|
|
24
|
+
isPeerFloodError,
|
|
25
|
+
isPinRightsError,
|
|
26
|
+
mayUnpinAllForumTopic,
|
|
27
|
+
retryAfterSeconds,
|
|
28
|
+
unexpiredStoreRepinIds,
|
|
29
|
+
type StalePinSweepDeps,
|
|
30
|
+
type SweepTarget,
|
|
31
|
+
} from './stale-pin-sweep.js'
|
|
32
|
+
import {
|
|
33
|
+
SWEEP_MAX_ATTEMPTS,
|
|
34
|
+
loadSweepCursors,
|
|
35
|
+
upsertSweepCursor,
|
|
36
|
+
type SweepCursor,
|
|
37
|
+
type SweepStoreFsSeam,
|
|
38
|
+
} from './stale-pin-sweep-store.js'
|
|
39
|
+
|
|
40
|
+
// Synthetic ids (check-no-pii-secrets): DMs positive, groups negative.
|
|
41
|
+
const DM = '900000001'
|
|
42
|
+
const GROUP = '-1009000000001'
|
|
43
|
+
|
|
44
|
+
// ─── in-memory durable ledger ────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
function memFs(): SweepStoreFsSeam & { files: Map<string, string> } {
|
|
47
|
+
const files = new Map<string, string>()
|
|
48
|
+
return {
|
|
49
|
+
files,
|
|
50
|
+
existsSync: (p) => files.has(p),
|
|
51
|
+
readFileSync: (p) => {
|
|
52
|
+
const v = files.get(p)
|
|
53
|
+
if (v == null) throw new Error(`ENOENT ${p}`)
|
|
54
|
+
return v
|
|
55
|
+
},
|
|
56
|
+
writeFileSync: (p, data) => {
|
|
57
|
+
files.set(p, data)
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ─── the fake chat ───────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
interface FakeOpts {
|
|
65
|
+
/** Initial pin stack; index 0 is the TOP (what `getChat` exposes). */
|
|
66
|
+
stack: number[]
|
|
67
|
+
/**
|
|
68
|
+
* THE BUG. When true, `unpinChatMessage` resolves `{ ok: true }` and pops
|
|
69
|
+
* NOTHING — exactly what Telegram does for a stale stack entry.
|
|
70
|
+
*/
|
|
71
|
+
unpinIsSilentNoop?: boolean
|
|
72
|
+
/** Errors to throw from `pinChatMessage`, consumed in order. */
|
|
73
|
+
pinErrors?: (unknown | null)[]
|
|
74
|
+
/** Errors to throw from `unpinAllForumTopicMessages`, consumed in order. */
|
|
75
|
+
unpinAllErrors?: (unknown | null)[]
|
|
76
|
+
canPin?: boolean
|
|
77
|
+
/** Throws from `getChat`, consumed in order (null = normal read). */
|
|
78
|
+
getChatErrors?: (unknown | null)[]
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function fakeChat(o: FakeOpts) {
|
|
82
|
+
const stack = [...o.stack]
|
|
83
|
+
const calls: string[] = []
|
|
84
|
+
const pinErrors = [...(o.pinErrors ?? [])]
|
|
85
|
+
const unpinAllErrors = [...(o.unpinAllErrors ?? [])]
|
|
86
|
+
const getChatErrors = [...(o.getChatErrors ?? [])]
|
|
87
|
+
return {
|
|
88
|
+
stack,
|
|
89
|
+
calls,
|
|
90
|
+
getTopPinnedMessageId: async () => {
|
|
91
|
+
calls.push('getChat')
|
|
92
|
+
const err = getChatErrors.shift()
|
|
93
|
+
if (err != null) throw err
|
|
94
|
+
return stack.length > 0 ? stack[0] : null
|
|
95
|
+
},
|
|
96
|
+
pinSilent: async (_chatId: string, messageId: number) => {
|
|
97
|
+
calls.push(`pin:${messageId}`)
|
|
98
|
+
const err = pinErrors.shift()
|
|
99
|
+
if (err != null) throw err
|
|
100
|
+
const at = stack.indexOf(messageId)
|
|
101
|
+
if (at >= 0) stack.splice(at, 1)
|
|
102
|
+
stack.unshift(messageId)
|
|
103
|
+
return { ok: true }
|
|
104
|
+
},
|
|
105
|
+
unpin: async (_chatId: string, messageId: number) => {
|
|
106
|
+
calls.push(`unpin:${messageId}`)
|
|
107
|
+
// ALWAYS resolves ok:true — the whole point.
|
|
108
|
+
if (o.unpinIsSilentNoop === true) return { ok: true }
|
|
109
|
+
const at = stack.indexOf(messageId)
|
|
110
|
+
if (at >= 0) stack.splice(at, 1)
|
|
111
|
+
return { ok: true }
|
|
112
|
+
},
|
|
113
|
+
unpinAllForumTopicMessages: async (_chatId: string, threadId: number) => {
|
|
114
|
+
calls.push(`unpinAllTopic:${threadId}`)
|
|
115
|
+
const err = unpinAllErrors.shift()
|
|
116
|
+
if (err != null) throw err
|
|
117
|
+
stack.length = 0
|
|
118
|
+
return { ok: true }
|
|
119
|
+
},
|
|
120
|
+
canPinInChat: async () => {
|
|
121
|
+
calls.push('getChatMember')
|
|
122
|
+
return o.canPin !== false
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
interface Harness {
|
|
128
|
+
deps: StalePinSweepDeps
|
|
129
|
+
fake: ReturnType<typeof fakeChat>
|
|
130
|
+
fs: ReturnType<typeof memFs>
|
|
131
|
+
sleeps: number[]
|
|
132
|
+
path: string
|
|
133
|
+
cursors: () => SweepCursor[]
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function harness(
|
|
137
|
+
o: FakeOpts & {
|
|
138
|
+
/** When false the fake clock does NOT advance on sleep (freezes the
|
|
139
|
+
* per-minute budget window open, so the budget gate is what binds). */
|
|
140
|
+
clockAdvances?: boolean
|
|
141
|
+
protectedMessageIds?: number[]
|
|
142
|
+
/** Wall-clock ms each fake API call consumes. Real latency is what lets a
|
|
143
|
+
* long sweep outrun the 60s per-minute window, so the per-CHAT pop cap —
|
|
144
|
+
* not the per-minute budget — becomes the binding gate. */
|
|
145
|
+
apiLatencyMs?: number
|
|
146
|
+
eligible?: boolean
|
|
147
|
+
allowUnpinAllForumTopic?: boolean
|
|
148
|
+
/** Ids the gateway is on record as having pinned (the group drain's list). */
|
|
149
|
+
recordedPinIds?: number[]
|
|
150
|
+
},
|
|
151
|
+
): Harness {
|
|
152
|
+
const fake = fakeChat(o)
|
|
153
|
+
const fs = memFs()
|
|
154
|
+
const path = '/state/stale-pin-sweep.json'
|
|
155
|
+
const sleeps: number[] = []
|
|
156
|
+
let clock = 1_000_000
|
|
157
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
158
|
+
const costed = <T extends (...a: any[]) => Promise<any>>(f: T): T =>
|
|
159
|
+
(async (...a: Parameters<T>) => {
|
|
160
|
+
clock += o.apiLatencyMs ?? 0
|
|
161
|
+
return f(...a)
|
|
162
|
+
}) as unknown as T
|
|
163
|
+
const deps: StalePinSweepDeps = {
|
|
164
|
+
getTopPinnedMessageId: costed(fake.getTopPinnedMessageId),
|
|
165
|
+
pinSilent: costed(fake.pinSilent),
|
|
166
|
+
unpin: costed(fake.unpin),
|
|
167
|
+
unpinAllForumTopicMessages: costed(fake.unpinAllForumTopicMessages),
|
|
168
|
+
canPinInChat: costed(fake.canPinInChat),
|
|
169
|
+
protectedMessageIds: () => o.protectedMessageIds ?? [],
|
|
170
|
+
recordedPinIds: () => o.recordedPinIds ?? [],
|
|
171
|
+
eligible: () => o.eligible !== false,
|
|
172
|
+
sleep: async (ms) => {
|
|
173
|
+
sleeps.push(ms)
|
|
174
|
+
if (o.clockAdvances !== false) clock += ms
|
|
175
|
+
},
|
|
176
|
+
now: () => clock,
|
|
177
|
+
store: { path, fs },
|
|
178
|
+
allowUnpinAllForumTopic: o.allowUnpinAllForumTopic,
|
|
179
|
+
log: () => {},
|
|
180
|
+
}
|
|
181
|
+
return { deps, fake, fs, sleeps, path, cursors: () => loadSweepCursors(path, fs) }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const flood = (retryAfter: number) => ({
|
|
185
|
+
error_code: 429,
|
|
186
|
+
description: `Too Many Requests: retry after ${retryAfter}`,
|
|
187
|
+
parameters: { retry_after: retryAfter },
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
// ─── headline: a stacked chat actually drains ───────────────────────────────
|
|
191
|
+
|
|
192
|
+
describe('stale-pin sweep — draining a stacked chat', () => {
|
|
193
|
+
it('drains a 5-deep DM pin stack to empty and discharges the obligation', async () => {
|
|
194
|
+
const h = harness({ stack: [11, 12, 13, 14, 15] })
|
|
195
|
+
const sweeper = createStalePinSweeper(h.deps)
|
|
196
|
+
|
|
197
|
+
const res = await sweeper.sweepTarget({ chatId: DM })
|
|
198
|
+
|
|
199
|
+
// The OUTCOME: the chat has no pins left.
|
|
200
|
+
expect(h.fake.stack).toEqual([])
|
|
201
|
+
expect(res.status).toBe('drained')
|
|
202
|
+
expect(res.popped).toBe(5)
|
|
203
|
+
// Each pop really was a re-pin followed by an unpin — a bare unpin does not
|
|
204
|
+
// pop, so a regression to unpin-only would leave the stack non-empty above.
|
|
205
|
+
expect(h.fake.calls.filter((c) => c.startsWith('pin:'))).toHaveLength(5)
|
|
206
|
+
expect(h.fake.calls.filter((c) => c.startsWith('unpin:'))).toHaveLength(5)
|
|
207
|
+
const cursor = h.cursors().find((c) => c.chatId === DM)
|
|
208
|
+
expect(cursor?.done).toBe(true)
|
|
209
|
+
expect(cursor?.popped).toBe(5)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('does NOT treat a resolved unpin as a pop — the ok:true silent no-op', async () => {
|
|
213
|
+
// Telegram resolves `{ok:true}` while popping nothing. Code that believes
|
|
214
|
+
// the API result reports `drained` here; code that reads back the chat
|
|
215
|
+
// cannot.
|
|
216
|
+
const h = harness({ stack: [21, 22, 23], unpinIsSilentNoop: true })
|
|
217
|
+
const sweeper = createStalePinSweeper(h.deps)
|
|
218
|
+
|
|
219
|
+
const res = await sweeper.sweepTarget({ chatId: DM })
|
|
220
|
+
|
|
221
|
+
expect(h.fake.stack).toEqual([21, 22, 23]) // nothing moved, as designed
|
|
222
|
+
expect(res.status).not.toBe('drained')
|
|
223
|
+
expect(res.status).toBe('incomplete')
|
|
224
|
+
expect(res.popped).toBe(0)
|
|
225
|
+
expect(res.detail).toMatch(/no progress/)
|
|
226
|
+
// The obligation stays OWED so the next boot retries.
|
|
227
|
+
const cursor = h.cursors().find((c) => c.chatId === DM)
|
|
228
|
+
expect(cursor?.done).toBe(false)
|
|
229
|
+
expect(cursor?.popped).toBe(0)
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('never concludes "empty" from a single read — a flapping chat is unknown', async () => {
|
|
233
|
+
// getChat never settles: 31 / null / 31 / null … No two consecutive reads
|
|
234
|
+
// agree, so the stack state is UNKNOWN and must not be reported drained.
|
|
235
|
+
const h = harness({ stack: [31] })
|
|
236
|
+
let n = 0
|
|
237
|
+
h.deps.getTopPinnedMessageId = async () => {
|
|
238
|
+
n++
|
|
239
|
+
return n % 2 === 1 ? 31 : null
|
|
240
|
+
}
|
|
241
|
+
const res = await createStalePinSweeper(h.deps).sweepTarget({ chatId: DM })
|
|
242
|
+
expect(res.status).toBe('incomplete')
|
|
243
|
+
expect(res.detail).toMatch(/unverifiable/)
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('does NOT terminate on the transient `pinned_message: None` lie', async () => {
|
|
247
|
+
// MEASURED (2026-07-29): right after an unpin, getChat briefly reports NO
|
|
248
|
+
// pinned message on a stack that is NOT empty — at ~0.15s it read None, at
|
|
249
|
+
// ~0.55s and after it read the next real pin. The transient value is the
|
|
250
|
+
// EMPTY one, so a loop that stops at the first None orphans the rest of the
|
|
251
|
+
// stack. Here read #2 lies; the drain must keep going and finish the stack.
|
|
252
|
+
const h = harness({ stack: [61, 62, 63] })
|
|
253
|
+
let n = 0
|
|
254
|
+
const honest = h.deps.getTopPinnedMessageId
|
|
255
|
+
h.deps.getTopPinnedMessageId = async (chatId) => {
|
|
256
|
+
n++
|
|
257
|
+
if (n === 2) return null // the lie, mid-verification
|
|
258
|
+
return honest(chatId)
|
|
259
|
+
}
|
|
260
|
+
const res = await createStalePinSweeper(h.deps).sweepTarget({ chatId: DM })
|
|
261
|
+
expect(res.status).toBe('drained')
|
|
262
|
+
expect(res.popped).toBe(3)
|
|
263
|
+
expect(h.fake.stack).toEqual([])
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it('treats a getChat throw as unknown, never as an empty stack', async () => {
|
|
267
|
+
const h = harness({ stack: [41], getChatErrors: [new Error('ETIMEDOUT')] })
|
|
268
|
+
const res = await createStalePinSweeper(h.deps).sweepTarget({ chatId: DM })
|
|
269
|
+
expect(res.status).toBe('incomplete')
|
|
270
|
+
expect(h.fake.stack).toEqual([41])
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it('accepts Telegram\'s honest empty-stack signal from unpin', async () => {
|
|
274
|
+
const h = harness({ stack: [51] })
|
|
275
|
+
h.deps.unpin = async () => {
|
|
276
|
+
throw { error_code: 400, description: 'Bad Request: message to unpin not found' }
|
|
277
|
+
}
|
|
278
|
+
const res = await createStalePinSweeper(h.deps).sweepTarget({ chatId: DM })
|
|
279
|
+
expect(res.status).toBe('drained')
|
|
280
|
+
})
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
// ─── rate gates ──────────────────────────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
describe('stale-pin sweep — rate gates', () => {
|
|
286
|
+
it('spaces DM writes by DM_SWEEP_GATE.minCallDelayMs', async () => {
|
|
287
|
+
const h = harness({ stack: [61, 62, 63] })
|
|
288
|
+
await createStalePinSweeper(h.deps).sweepTarget({ chatId: DM })
|
|
289
|
+
// Every gap the sweep inserted between two writes is the DM gate's, and it
|
|
290
|
+
// inserted at least one per pop.
|
|
291
|
+
const spacing = h.sleeps.filter((ms) => ms !== VERIFY_READ_GAP_MS)
|
|
292
|
+
expect(spacing.length).toBeGreaterThanOrEqual(3)
|
|
293
|
+
expect(new Set(spacing)).toEqual(new Set([DM_SWEEP_GATE.minCallDelayMs]))
|
|
294
|
+
expect(h.sleeps).not.toContain(GROUP_SWEEP_GATE.minCallDelayMs)
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
it('spaces supergroup writes by the slower GROUP gate', async () => {
|
|
298
|
+
const h = harness({ stack: [71, 72], canPin: true, recordedPinIds: [71, 72] })
|
|
299
|
+
await createStalePinSweeper(h.deps).sweepTarget({ chatId: GROUP })
|
|
300
|
+
expect(h.sleeps).toContain(GROUP_SWEEP_GATE.minCallDelayMs)
|
|
301
|
+
expect(h.sleeps).not.toContain(DM_SWEEP_GATE.minCallDelayMs)
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it('has NO per-chat pop ceiling in a group — the id list is the bound', async () => {
|
|
305
|
+
// The old 10-pop group ceiling existed to ration visible service messages.
|
|
306
|
+
// Measured: unpinning is silent, so there is nothing to ration and a deep
|
|
307
|
+
// group stack must drain in ONE sweep rather than dribbling over 3 boots.
|
|
308
|
+
expect(GROUP_SWEEP_GATE.maxPopsPerChatPerSweep).toBe(Number.POSITIVE_INFINITY)
|
|
309
|
+
const ids = Array.from({ length: 25 }, (_, i) => 700 + i)
|
|
310
|
+
const h = harness({ stack: ids, canPin: true, recordedPinIds: ids, apiLatencyMs: 6_000 })
|
|
311
|
+
const res = await createStalePinSweeper(h.deps).sweepTarget({ chatId: GROUP })
|
|
312
|
+
|
|
313
|
+
expect(res.status).toBe('drained')
|
|
314
|
+
expect(h.fake.stack).toEqual([])
|
|
315
|
+
expect(h.fake.calls.filter((c) => c.startsWith('unpin:'))).toHaveLength(25)
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
it('stops at the per-chat pop cap and yields the remainder to the next boot', async () => {
|
|
319
|
+
const deep = Array.from({ length: DM_SWEEP_GATE.maxPopsPerChatPerSweep + 6 }, (_, i) => 100 + i)
|
|
320
|
+
const h = harness({ stack: deep, apiLatencyMs: 6_000 })
|
|
321
|
+
const res = await createStalePinSweeper(h.deps).sweepTarget({ chatId: DM })
|
|
322
|
+
|
|
323
|
+
expect(res.status).toBe('deferred-budget')
|
|
324
|
+
expect(res.popped).toBe(DM_SWEEP_GATE.maxPopsPerChatPerSweep)
|
|
325
|
+
expect(h.fake.stack).toHaveLength(6) // exactly the overflow is left
|
|
326
|
+
const cursor = h.cursors().find((c) => c.chatId === DM)
|
|
327
|
+
expect(cursor?.done).toBe(false)
|
|
328
|
+
expect(cursor?.popped).toBe(DM_SWEEP_GATE.maxPopsPerChatPerSweep)
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
it('stops at the per-minute bot budget rather than sleeping out the minute', async () => {
|
|
332
|
+
// Clock frozen ⇒ nothing ages out of the 60s window, so the budget binds
|
|
333
|
+
// before the pop cap does.
|
|
334
|
+
const deep = Array.from({ length: 40 }, (_, i) => 200 + i)
|
|
335
|
+
const h = harness({ stack: deep, clockAdvances: false })
|
|
336
|
+
const res = await createStalePinSweeper(h.deps).sweepTarget({ chatId: DM })
|
|
337
|
+
|
|
338
|
+
expect(res.status).toBe('deferred-budget')
|
|
339
|
+
expect(res.detail).toMatch(/budget/)
|
|
340
|
+
const writes = h.fake.calls.filter((c) => c.startsWith('pin:') || c.startsWith('unpin:'))
|
|
341
|
+
expect(writes).toHaveLength(DM_SWEEP_GATE.maxPinOpsPerMinute)
|
|
342
|
+
// And it did NOT block for a whole minute waiting for the window.
|
|
343
|
+
expect(Math.max(...h.sleeps)).toBeLessThan(60_000)
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
it('per-minute budget is per bot across chats, and per class', () => {
|
|
347
|
+
let t = 0
|
|
348
|
+
const budget = createPinOpBudget(() => t)
|
|
349
|
+
for (let i = 0; i < GROUP_SWEEP_GATE.maxPinOpsPerMinute; i++) budget.record('group')
|
|
350
|
+
expect(budget.waitMs('group')).toBeGreaterThan(0)
|
|
351
|
+
expect(budget.waitMs('dm')).toBe(0) // separate class, separate window
|
|
352
|
+
t += 60_000
|
|
353
|
+
expect(budget.waitMs('group')).toBe(0) // window slid
|
|
354
|
+
})
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
// ─── flood / circuit breaker ─────────────────────────────────────────────────
|
|
358
|
+
|
|
359
|
+
describe('stale-pin sweep — flood waits and the circuit breaker', () => {
|
|
360
|
+
it('gives up after MAX_CONSECUTIVE_FLOOD_WAITS distinct 429s', async () => {
|
|
361
|
+
const h = harness({ stack: [81, 82], pinErrors: [flood(3), flood(5), flood(7)] })
|
|
362
|
+
const res = await createStalePinSweeper(h.deps).sweepTarget({ chatId: DM })
|
|
363
|
+
expect(res.status).toBe('deferred-flood')
|
|
364
|
+
expect(res.popped).toBe(0)
|
|
365
|
+
// It backed off, honouring retry_after then doubling.
|
|
366
|
+
expect(h.sleeps).toContain(3000)
|
|
367
|
+
expect(h.sleeps).toContain(10_000)
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
it('gives up on two IDENTICAL retry_after values with zero progress', async () => {
|
|
371
|
+
// The pegged-server signature: same wait handed back forever.
|
|
372
|
+
const h = harness({ stack: [91], pinErrors: [flood(3), flood(3)] })
|
|
373
|
+
const res = await createStalePinSweeper(h.deps).sweepTarget({ chatId: DM })
|
|
374
|
+
expect(res.status).toBe('deferred-flood')
|
|
375
|
+
expect(h.fake.calls.filter((c) => c.startsWith('pin:'))).toHaveLength(2)
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
it('trips the breaker on PEER_FLOOD and halts every later target', async () => {
|
|
379
|
+
const h = harness({
|
|
380
|
+
stack: [101],
|
|
381
|
+
pinErrors: [{ error_code: 400, description: 'Bad Request: PEER_FLOOD' }],
|
|
382
|
+
})
|
|
383
|
+
const sweeper = createStalePinSweeper(h.deps)
|
|
384
|
+
|
|
385
|
+
const first = await sweeper.sweepTarget({ chatId: DM })
|
|
386
|
+
expect(first.status).toBe('aborted-circuit-breaker')
|
|
387
|
+
expect(sweeper.isCircuitOpen()).toBe(true)
|
|
388
|
+
|
|
389
|
+
const before = h.fake.calls.length
|
|
390
|
+
const second = await sweeper.sweepTarget({ chatId: '900000002' })
|
|
391
|
+
expect(second.status).toBe('aborted-circuit-breaker')
|
|
392
|
+
expect(h.fake.calls.length).toBe(before) // zero further API traffic
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
it('trips the breaker on a retry_after above the ceiling', async () => {
|
|
396
|
+
const h = harness({
|
|
397
|
+
stack: [111],
|
|
398
|
+
pinErrors: [flood(CIRCUIT_BREAKER_RETRY_AFTER_SEC + 1)],
|
|
399
|
+
})
|
|
400
|
+
const sweeper = createStalePinSweeper(h.deps)
|
|
401
|
+
const res = await sweeper.sweepTarget({ chatId: DM })
|
|
402
|
+
expect(res.status).toBe('aborted-circuit-breaker')
|
|
403
|
+
expect(sweeper.isCircuitOpen()).toBe(true)
|
|
404
|
+
})
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
// ─── forum topics ────────────────────────────────────────────────────────────
|
|
408
|
+
|
|
409
|
+
describe('stale-pin sweep — forum topics', () => {
|
|
410
|
+
const topic: SweepTarget = { chatId: GROUP, threadId: 77, isForum: true }
|
|
411
|
+
|
|
412
|
+
it('does NOT use the wholesale topic drain by default — it would take other pins', async () => {
|
|
413
|
+
// MEASURED: the unpin-all family destroyed a pre-existing pin the gateway
|
|
414
|
+
// never placed. 122 here is a third party's. The routine sweep must unpin
|
|
415
|
+
// ONLY the recorded ids and leave 122 alone.
|
|
416
|
+
const h = harness({ stack: [121, 122, 123], recordedPinIds: [121, 123] })
|
|
417
|
+
const res = await createStalePinSweeper(h.deps).sweepTarget(topic)
|
|
418
|
+
|
|
419
|
+
expect(h.fake.calls.filter((c) => c.startsWith('unpinAllTopic'))).toHaveLength(0)
|
|
420
|
+
expect(h.fake.stack).toEqual([122]) // the stranger's pin survives
|
|
421
|
+
expect(res.status).toBe('drained')
|
|
422
|
+
expect(res.issued).toBe(2)
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
it('uses the wholesale topic drain ONLY when explicitly opted in', async () => {
|
|
426
|
+
const h = harness({ stack: [121, 122, 123], allowUnpinAllForumTopic: true })
|
|
427
|
+
const res = await createStalePinSweeper(h.deps).sweepTarget(topic)
|
|
428
|
+
|
|
429
|
+
expect(res.status).toBe('drained')
|
|
430
|
+
expect(h.fake.calls.filter((c) => c === 'unpinAllTopic:77')).toHaveLength(1)
|
|
431
|
+
// Still no repin: a pin is the one group op that emits a service message.
|
|
432
|
+
expect(h.fake.calls.filter((c) => c.startsWith('pin:'))).toHaveLength(0)
|
|
433
|
+
expect(h.fake.stack).toEqual([])
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
it('does NOT loop on a 429 from the unpin-all verb', async () => {
|
|
437
|
+
// TDLib re-issues the identical query while !is_final_, so a retry never
|
|
438
|
+
// terminates. One attempt, then defer.
|
|
439
|
+
const h = harness({
|
|
440
|
+
stack: [131, 132],
|
|
441
|
+
allowUnpinAllForumTopic: true,
|
|
442
|
+
unpinAllErrors: [flood(3), flood(3), flood(3)],
|
|
443
|
+
})
|
|
444
|
+
const res = await createStalePinSweeper(h.deps).sweepTarget(topic)
|
|
445
|
+
|
|
446
|
+
expect(res.status).toBe('deferred-flood')
|
|
447
|
+
expect(h.fake.calls.filter((c) => c === 'unpinAllTopic:77')).toHaveLength(1)
|
|
448
|
+
expect(h.sleeps).not.toContain(3000) // it did not even back off — it gave up
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
it('routes the wholesale-drain policy through ONE predicate', () => {
|
|
452
|
+
// Deliberately pinned: the destructive remedy is opt-in in exactly one
|
|
453
|
+
// place, and no chat class reaches it implicitly.
|
|
454
|
+
expect(mayUnpinAllForumTopic('dm')).toBe(false)
|
|
455
|
+
expect(mayUnpinAllForumTopic('supergroup')).toBe(false)
|
|
456
|
+
expect(mayUnpinAllForumTopic('forum-topic')).toBe(UNPIN_ALL_FORUM_TOPIC_ENABLED)
|
|
457
|
+
// An explicit per-deployment override wins; undefined means "take the
|
|
458
|
+
// standing policy", which is NOT the same as false.
|
|
459
|
+
expect(mayUnpinAllForumTopic('forum-topic', true)).toBe(true)
|
|
460
|
+
expect(mayUnpinAllForumTopic('forum-topic', false)).toBe(false)
|
|
461
|
+
expect(mayUnpinAllForumTopic('forum-topic', undefined)).toBe(UNPIN_ALL_FORUM_TOPIC_ENABLED)
|
|
462
|
+
// Even opted in, it is topic-scoped only — a chat-wide unpin-all is never
|
|
463
|
+
// reachable from any input.
|
|
464
|
+
expect(mayUnpinAllForumTopic('supergroup', true)).toBe(false)
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
it('sweeps a forum chat with no recorded thread as an ordinary group', async () => {
|
|
468
|
+
const h = harness({ stack: [141, 142], recordedPinIds: [141] })
|
|
469
|
+
const res = await createStalePinSweeper(h.deps).sweepTarget({ chatId: GROUP, isForum: true })
|
|
470
|
+
expect(res.status).toBe('drained')
|
|
471
|
+
expect(h.fake.calls.filter((c) => c.startsWith('unpinAllTopic'))).toHaveLength(0)
|
|
472
|
+
expect(h.fake.stack).toEqual([142])
|
|
473
|
+
})
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
// ─── group safety: rights + service-message spam ─────────────────────────────
|
|
477
|
+
|
|
478
|
+
describe('stale-pin sweep — group safety', () => {
|
|
479
|
+
it('skips a group without pin rights WITHOUT writing anything', async () => {
|
|
480
|
+
const h = harness({ stack: [151, 152], canPin: false })
|
|
481
|
+
const res = await createStalePinSweeper(h.deps).sweepTarget({
|
|
482
|
+
chatId: GROUP,
|
|
483
|
+
threadId: 5,
|
|
484
|
+
isForum: true,
|
|
485
|
+
})
|
|
486
|
+
|
|
487
|
+
expect(res.status).toBe('skipped-no-rights')
|
|
488
|
+
expect(h.fake.calls).toEqual(['getChatMember']) // the precheck and nothing else
|
|
489
|
+
expect(h.fake.stack).toEqual([151, 152])
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
it('treats a throwing rights check as "no rights"', async () => {
|
|
493
|
+
const h = harness({ stack: [161] })
|
|
494
|
+
h.deps.canPinInChat = async () => {
|
|
495
|
+
throw new Error('Bad Request: chat not found')
|
|
496
|
+
}
|
|
497
|
+
const res = await createStalePinSweeper(h.deps).sweepTarget({
|
|
498
|
+
chatId: GROUP,
|
|
499
|
+
threadId: 5,
|
|
500
|
+
isForum: true,
|
|
501
|
+
})
|
|
502
|
+
expect(res.status).toBe('skipped-no-rights')
|
|
503
|
+
expect(h.fake.calls).toEqual([])
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
it('NEVER pins in a group — a pin is the only op that emits a service message', async () => {
|
|
507
|
+
// MEASURED id-gap probe: control 1, pin 2 (with and without
|
|
508
|
+
// disable_notification), every unpin verb 1. So the group drain must be
|
|
509
|
+
// unpin-only, and the DM-style repin loop must not be reachable here.
|
|
510
|
+
const h = harness({ stack: [171, 172, 173], recordedPinIds: [171, 172, 173] })
|
|
511
|
+
const res = await createStalePinSweeper(h.deps).sweepTarget({ chatId: GROUP })
|
|
512
|
+
|
|
513
|
+
expect(res.status).toBe('drained')
|
|
514
|
+
expect(h.fake.stack).toEqual([])
|
|
515
|
+
expect(h.fake.calls.filter((c) => c.startsWith('pin:'))).toEqual([])
|
|
516
|
+
})
|
|
517
|
+
|
|
518
|
+
it('unpins a recorded id from the MIDDLE of the stack without a repin', async () => {
|
|
519
|
+
// MEASURED: targeted unpin works at ANY stack position in a group — an
|
|
520
|
+
// entry third from the top vanished from the middle.
|
|
521
|
+
const h = harness({ stack: [174, 175, 176], recordedPinIds: [176] })
|
|
522
|
+
const res = await createStalePinSweeper(h.deps).sweepTarget({ chatId: GROUP })
|
|
523
|
+
|
|
524
|
+
expect(h.fake.calls.filter((c) => c.startsWith('unpin:'))).toEqual(['unpin:176'])
|
|
525
|
+
expect(h.fake.stack).toEqual([174, 175])
|
|
526
|
+
expect(res.issued).toBe(1)
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
it('does nothing at all in a group with no recorded pin ids', async () => {
|
|
530
|
+
// The gateway only removes pins it placed. With no record there is no safe
|
|
531
|
+
// action — an unpin-all would take the strangers' pins with it.
|
|
532
|
+
const h = harness({ stack: [178, 179], recordedPinIds: [] })
|
|
533
|
+
const res = await createStalePinSweeper(h.deps).sweepTarget({ chatId: GROUP })
|
|
534
|
+
|
|
535
|
+
expect(res.status).toBe('skipped-nothing-recorded')
|
|
536
|
+
expect(h.fake.calls.filter((c) => c.startsWith('unpin'))).toEqual([])
|
|
537
|
+
expect(h.fake.stack).toEqual([178, 179])
|
|
538
|
+
})
|
|
539
|
+
|
|
540
|
+
it('does not report a group drained when a recorded pin survives on top', async () => {
|
|
541
|
+
// The ok:true silent no-op, observed in a supergroup too.
|
|
542
|
+
const h = harness({ stack: [180], recordedPinIds: [180], unpinIsSilentNoop: true })
|
|
543
|
+
const res = await createStalePinSweeper(h.deps).sweepTarget({ chatId: GROUP })
|
|
544
|
+
|
|
545
|
+
expect(res.status).toBe('incomplete')
|
|
546
|
+
expect(res.popped).toBe(0)
|
|
547
|
+
expect(h.cursors().find((c) => c.chatId === GROUP)?.done).toBe(false)
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
it('runs a DM sweep with NO rights precheck (getChatMember is meaningless there)', async () => {
|
|
551
|
+
const h = harness({ stack: [181] })
|
|
552
|
+
await createStalePinSweeper(h.deps).sweepTarget({ chatId: DM })
|
|
553
|
+
expect(h.fake.calls).not.toContain('getChatMember')
|
|
554
|
+
})
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
// ─── eligibility + durability ────────────────────────────────────────────────
|
|
558
|
+
|
|
559
|
+
describe('stale-pin sweep — eligibility and durable resume', () => {
|
|
560
|
+
it('writes nothing when this gateway did not win the startup mutex', async () => {
|
|
561
|
+
const h = harness({ stack: [191, 192], eligible: false })
|
|
562
|
+
const res = await createStalePinSweeper(h.deps).sweepTarget({ chatId: DM })
|
|
563
|
+
expect(res.status).toBe('skipped-not-eligible')
|
|
564
|
+
expect(h.fake.calls).toEqual([])
|
|
565
|
+
expect(h.fs.files.size).toBe(0)
|
|
566
|
+
})
|
|
567
|
+
|
|
568
|
+
it('RESUMES from a persisted cursor after a restart mid-sweep', async () => {
|
|
569
|
+
// Boot 1: budget-capped part way through a deep stack.
|
|
570
|
+
const deep = Array.from({ length: DM_SWEEP_GATE.maxPopsPerChatPerSweep + 3 }, (_, i) => 300 + i)
|
|
571
|
+
const h1 = harness({ stack: deep, apiLatencyMs: 6_000 })
|
|
572
|
+
const first = await createStalePinSweeper(h1.deps).sweepTarget({ chatId: DM })
|
|
573
|
+
expect(first.status).toBe('deferred-budget')
|
|
574
|
+
const carried = h1.cursors().find((c) => c.chatId === DM)
|
|
575
|
+
expect(carried?.popped).toBe(DM_SWEEP_GATE.maxPopsPerChatPerSweep)
|
|
576
|
+
expect(carried?.done).toBe(false)
|
|
577
|
+
|
|
578
|
+
// Boot 2: a NEW sweeper over the SAME ledger and the SAME (partly drained)
|
|
579
|
+
// chat. It must finish the remainder and count cumulatively.
|
|
580
|
+
const h2 = harness({ stack: h1.fake.stack, apiLatencyMs: 6_000 })
|
|
581
|
+
h2.fs.files.set(h2.path, h1.fs.files.get(h1.path)!)
|
|
582
|
+
const second = await createStalePinSweeper(h2.deps).sweepTarget({ chatId: DM })
|
|
583
|
+
|
|
584
|
+
expect(second.status).toBe('drained')
|
|
585
|
+
expect(h2.fake.stack).toEqual([])
|
|
586
|
+
const final = h2.cursors().find((c) => c.chatId === DM)
|
|
587
|
+
expect(final?.done).toBe(true)
|
|
588
|
+
expect(final?.popped).toBe(DM_SWEEP_GATE.maxPopsPerChatPerSweep + 3)
|
|
589
|
+
expect(final?.attempts).toBe(2)
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
it('RESUMES a group drain at the next unpinned id, never re-issuing one', async () => {
|
|
593
|
+
// Clock frozen ⇒ the 60s window never ages, so the per-minute budget binds
|
|
594
|
+
// part way through the id list and the boot must yield with a cursor that
|
|
595
|
+
// records exactly which ids are already done.
|
|
596
|
+
const ids = Array.from({ length: GROUP_SWEEP_GATE.maxPinOpsPerMinute + 4 }, (_, i) => 400 + i)
|
|
597
|
+
const h1 = harness({ stack: ids, recordedPinIds: ids, clockAdvances: false })
|
|
598
|
+
const first = await createStalePinSweeper(h1.deps).sweepTarget({ chatId: GROUP })
|
|
599
|
+
|
|
600
|
+
expect(first.status).toBe('deferred-budget')
|
|
601
|
+
expect(first.issued).toBe(GROUP_SWEEP_GATE.maxPinOpsPerMinute)
|
|
602
|
+
const carried = h1.cursors().find((c) => c.chatId === GROUP)
|
|
603
|
+
expect(carried?.done).toBe(false)
|
|
604
|
+
expect(carried?.doneIds).toEqual(ids.slice(0, GROUP_SWEEP_GATE.maxPinOpsPerMinute))
|
|
605
|
+
|
|
606
|
+
// Boot 2 over the SAME ledger and the SAME partly-drained chat.
|
|
607
|
+
const h2 = harness({ stack: h1.fake.stack, recordedPinIds: ids })
|
|
608
|
+
h2.fs.files.set(h2.path, h1.fs.files.get(h1.path)!)
|
|
609
|
+
const second = await createStalePinSweeper(h2.deps).sweepTarget({ chatId: GROUP })
|
|
610
|
+
|
|
611
|
+
expect(second.status).toBe('drained')
|
|
612
|
+
expect(second.issued).toBe(4) // ONLY the remainder — no id is unpinned twice
|
|
613
|
+
expect(h2.fake.calls.filter((c) => c.startsWith('unpin:'))).toHaveLength(4)
|
|
614
|
+
expect(h2.fake.stack).toEqual([])
|
|
615
|
+
expect(h2.cursors().find((c) => c.chatId === GROUP)?.done).toBe(true)
|
|
616
|
+
})
|
|
617
|
+
|
|
618
|
+
it('attempts a target AT MOST ONCE per process, however often it is called', async () => {
|
|
619
|
+
// The lazy first-inbound caller fires on EVERY inbound. A target whose
|
|
620
|
+
// drain did not finish must not be re-attempted per message: that burns the
|
|
621
|
+
// 8-boot attempt budget in 8 messages and hammers the flood ledger.
|
|
622
|
+
const h = harness({ stack: [261, 262, 263], unpinIsSilentNoop: true })
|
|
623
|
+
const sweeper = createStalePinSweeper(h.deps)
|
|
624
|
+
|
|
625
|
+
const first = await sweeper.sweepTarget({ chatId: DM })
|
|
626
|
+
expect(first.status).toBe('incomplete')
|
|
627
|
+
const callsAfterFirst = h.fake.calls.length
|
|
628
|
+
|
|
629
|
+
for (let i = 0; i < 12; i++) {
|
|
630
|
+
expect((await sweeper.sweepTarget({ chatId: DM })).status).toBe('already-attempted')
|
|
631
|
+
}
|
|
632
|
+
expect(h.fake.calls.length).toBe(callsAfterFirst) // zero further API traffic
|
|
633
|
+
expect(h.cursors().find((c) => c.chatId === DM)?.attempts).toBe(1)
|
|
634
|
+
})
|
|
635
|
+
|
|
636
|
+
it('coalesces concurrent callers for the same target onto one drain', async () => {
|
|
637
|
+
const h = harness({ stack: [271, 272] })
|
|
638
|
+
const sweeper = createStalePinSweeper(h.deps)
|
|
639
|
+
const [a, b] = await Promise.all([
|
|
640
|
+
sweeper.sweepTarget({ chatId: DM }),
|
|
641
|
+
sweeper.sweepTarget({ chatId: DM }),
|
|
642
|
+
])
|
|
643
|
+
expect(a).toBe(b) // literally the same promise result
|
|
644
|
+
expect(h.fake.calls.filter((c) => c.startsWith('pin:'))).toHaveLength(2)
|
|
645
|
+
})
|
|
646
|
+
|
|
647
|
+
it('an ineligible call does not consume the one allowed attempt', async () => {
|
|
648
|
+
// The lazy first-inbound path can fire before the startup mutex is won; the
|
|
649
|
+
// boot sweep must still get its turn afterwards.
|
|
650
|
+
let eligible = false
|
|
651
|
+
const h = harness({ stack: [281] })
|
|
652
|
+
h.deps.eligible = () => eligible
|
|
653
|
+
const sweeper = createStalePinSweeper(h.deps)
|
|
654
|
+
|
|
655
|
+
expect((await sweeper.sweepTarget({ chatId: DM })).status).toBe('skipped-not-eligible')
|
|
656
|
+
eligible = true
|
|
657
|
+
expect((await sweeper.sweepTarget({ chatId: DM })).status).toBe('drained')
|
|
658
|
+
})
|
|
659
|
+
|
|
660
|
+
it('does not re-drain a discharged obligation', async () => {
|
|
661
|
+
const h = harness({ stack: [201] })
|
|
662
|
+
upsertSweepCursor(h.path, h.fs, {
|
|
663
|
+
chatId: DM,
|
|
664
|
+
kind: 'dm',
|
|
665
|
+
popped: 9,
|
|
666
|
+
done: true,
|
|
667
|
+
attempts: 1,
|
|
668
|
+
updatedAt: 1,
|
|
669
|
+
})
|
|
670
|
+
const res = await createStalePinSweeper(h.deps).sweepTarget({ chatId: DM })
|
|
671
|
+
expect(res.status).toBe('already-drained')
|
|
672
|
+
expect(h.fake.calls).toEqual([])
|
|
673
|
+
})
|
|
674
|
+
|
|
675
|
+
it('forfeits an obligation that has burned its attempt budget', async () => {
|
|
676
|
+
const h = harness({ stack: [211] })
|
|
677
|
+
upsertSweepCursor(h.path, h.fs, {
|
|
678
|
+
chatId: DM,
|
|
679
|
+
kind: 'dm',
|
|
680
|
+
popped: 0,
|
|
681
|
+
done: false,
|
|
682
|
+
attempts: SWEEP_MAX_ATTEMPTS,
|
|
683
|
+
updatedAt: 1,
|
|
684
|
+
})
|
|
685
|
+
const res = await createStalePinSweeper(h.deps).sweepTarget({ chatId: DM })
|
|
686
|
+
expect(res.status).toBe('forfeited')
|
|
687
|
+
expect(h.fake.calls).toEqual([])
|
|
688
|
+
})
|
|
689
|
+
|
|
690
|
+
it('keys the ledger by (chat, thread), not by chat alone', async () => {
|
|
691
|
+
const h = harness({ stack: [] })
|
|
692
|
+
const sweeper = createStalePinSweeper(h.deps)
|
|
693
|
+
await sweeper.sweepTarget({ chatId: GROUP, threadId: 5, isForum: true })
|
|
694
|
+
await sweeper.sweepTarget({ chatId: GROUP, threadId: 9, isForum: true })
|
|
695
|
+
const rows = h.cursors()
|
|
696
|
+
expect(rows).toHaveLength(2)
|
|
697
|
+
expect(rows.map((r) => r.threadId).sort()).toEqual([5, 9])
|
|
698
|
+
})
|
|
699
|
+
|
|
700
|
+
it('survives a corrupt ledger by failing open rather than throwing', async () => {
|
|
701
|
+
const h = harness({ stack: [221] })
|
|
702
|
+
h.fs.files.set(h.path, '{ not json')
|
|
703
|
+
const res = await createStalePinSweeper(h.deps).sweepTarget({ chatId: DM })
|
|
704
|
+
expect(res.status).toBe('drained')
|
|
705
|
+
})
|
|
706
|
+
|
|
707
|
+
it('never lets a failing ledger write break the sweep', async () => {
|
|
708
|
+
const h = harness({ stack: [231, 232] })
|
|
709
|
+
h.fs.writeFileSync = () => {
|
|
710
|
+
throw new Error('EROFS')
|
|
711
|
+
}
|
|
712
|
+
const res = await createStalePinSweeper(h.deps).sweepTarget({ chatId: DM })
|
|
713
|
+
expect(res.status).toBe('drained')
|
|
714
|
+
expect(h.fake.stack).toEqual([])
|
|
715
|
+
})
|
|
716
|
+
})
|
|
717
|
+
|
|
718
|
+
// ─── protected pins ──────────────────────────────────────────────────────────
|
|
719
|
+
|
|
720
|
+
describe('stale-pin sweep — protected pins', () => {
|
|
721
|
+
it('restores deliberately-retained pins after the drain', async () => {
|
|
722
|
+
const h = harness({ stack: [241, 242], protectedMessageIds: [242, 242] })
|
|
723
|
+
await createStalePinSweeper(h.deps).sweepTarget({ chatId: DM })
|
|
724
|
+
// Drained, then the retained id re-pinned exactly once (deduped).
|
|
725
|
+
expect(h.fake.stack).toEqual([242])
|
|
726
|
+
const restorePins = h.fake.calls.slice(h.fake.calls.lastIndexOf('unpin:242') + 1)
|
|
727
|
+
expect(restorePins.filter((c) => c === 'pin:242')).toHaveLength(1)
|
|
728
|
+
})
|
|
729
|
+
|
|
730
|
+
it('does not re-pin anything when the sweep wrote nothing', async () => {
|
|
731
|
+
const h = harness({ stack: [251], protectedMessageIds: [251], eligible: false })
|
|
732
|
+
await createStalePinSweeper(h.deps).sweepTarget({ chatId: DM })
|
|
733
|
+
expect(h.fake.calls).toEqual([])
|
|
734
|
+
})
|
|
735
|
+
})
|
|
736
|
+
|
|
737
|
+
// ─── pure helpers ────────────────────────────────────────────────────────────
|
|
738
|
+
|
|
739
|
+
describe('stale-pin sweep — classification and seeding', () => {
|
|
740
|
+
it('classifies by chat id sign and forum/thread presence', () => {
|
|
741
|
+
expect(classifyChatForSweep({ chatId: DM })).toBe('dm')
|
|
742
|
+
expect(classifyChatForSweep({ chatId: GROUP })).toBe('supergroup')
|
|
743
|
+
expect(classifyChatForSweep({ chatId: GROUP, threadId: 4, isForum: true })).toBe('forum-topic')
|
|
744
|
+
// A thread id without a forum flag is not enough to aim the topic verb.
|
|
745
|
+
expect(classifyChatForSweep({ chatId: GROUP, threadId: 4 })).toBe('supergroup')
|
|
746
|
+
expect(classifyChatForSweep({ chatId: GROUP, isForum: true })).toBe('supergroup')
|
|
747
|
+
})
|
|
748
|
+
|
|
749
|
+
it('dedupes seed targets on the FULL (chat, thread) key and carries their ids', () => {
|
|
750
|
+
const targets = collectSweepTargets({
|
|
751
|
+
statusPins: [
|
|
752
|
+
{ chatId: GROUP, threadId: 5, messageId: 501 },
|
|
753
|
+
{ chatId: GROUP, threadId: 9, messageId: 901 },
|
|
754
|
+
{ chatId: GROUP, threadId: 5, messageId: 502 },
|
|
755
|
+
{ chatId: GROUP, threadId: 5, messageId: 501 },
|
|
756
|
+
],
|
|
757
|
+
activityCards: [
|
|
758
|
+
// Pinned ⇒ on the stack ⇒ its id joins the unpin list.
|
|
759
|
+
{ chatId: GROUP, threadId: null, activityMessageId: 601, pinned: true },
|
|
760
|
+
// NOT pinned ⇒ never on the stack ⇒ its id must NOT be unpinned.
|
|
761
|
+
{ chatId: DM, activityMessageId: 602 },
|
|
762
|
+
],
|
|
763
|
+
queuedCards: [{ chatId: '' }],
|
|
764
|
+
})
|
|
765
|
+
expect(targets).toEqual([
|
|
766
|
+
{ chatId: GROUP, threadId: 5, messageIds: [501, 502] },
|
|
767
|
+
{ chatId: GROUP, threadId: 9, messageIds: [901] },
|
|
768
|
+
{ chatId: GROUP, threadId: undefined, messageIds: [601] },
|
|
769
|
+
{ chatId: DM, threadId: undefined, messageIds: [] },
|
|
770
|
+
])
|
|
771
|
+
})
|
|
772
|
+
|
|
773
|
+
it('protects only unexpired time-scoped rows in the same chat', () => {
|
|
774
|
+
const now = 1_000
|
|
775
|
+
expect(
|
|
776
|
+
unexpiredStoreRepinIds(
|
|
777
|
+
[
|
|
778
|
+
{ chatId: DM, messageId: 1, expiresAt: now + 10 },
|
|
779
|
+
{ chatId: DM, messageId: 2, expiresAt: now - 10 },
|
|
780
|
+
{ chatId: DM, messageId: 3 },
|
|
781
|
+
{ chatId: GROUP, messageId: 4, expiresAt: now + 10 },
|
|
782
|
+
],
|
|
783
|
+
DM,
|
|
784
|
+
now,
|
|
785
|
+
),
|
|
786
|
+
).toEqual([1])
|
|
787
|
+
})
|
|
788
|
+
|
|
789
|
+
it('recognises the Telegram error shapes it branches on', () => {
|
|
790
|
+
expect(retryAfterSeconds(flood(7))).toBe(7)
|
|
791
|
+
expect(retryAfterSeconds({ error_code: 429, description: 'retry after 4' })).toBe(4)
|
|
792
|
+
expect(retryAfterSeconds(new Error('nope'))).toBeNull()
|
|
793
|
+
expect(isPeerFloodError({ description: 'Bad Request: PEER_FLOOD' })).toBe(true)
|
|
794
|
+
expect(
|
|
795
|
+
isPinRightsError({
|
|
796
|
+
description: 'Bad Request: not enough rights to manage pinned messages in the chat',
|
|
797
|
+
}),
|
|
798
|
+
).toBe(true)
|
|
799
|
+
expect(isNothingToUnpinError({ description: 'Bad Request: message to unpin not found' })).toBe(
|
|
800
|
+
true,
|
|
801
|
+
)
|
|
802
|
+
expect(isNothingToUnpinError(new Error('something else'))).toBe(false)
|
|
803
|
+
})
|
|
804
|
+
})
|