@uniquedivine/tg-sai 0.0.5 → 0.1.0

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/state/guard.ts ADDED
@@ -0,0 +1,270 @@
1
+ import type { RpcCallMiddleware, RpcCallMiddlewareContext } from "@mtcute/bun"
2
+
3
+ import {
4
+ HoldStore,
5
+ HoldStoreUnavailableError,
6
+ isFailClosedGroup,
7
+ methodGroup,
8
+ parseHoldError,
9
+ remainingSeconds,
10
+ type FloodHold,
11
+ type HoldGroup,
12
+ type ParsedHold,
13
+ } from "./holds.ts"
14
+
15
+ export const HOLD_ERROR_CODE = 420
16
+ export const HOLD_ERROR_PATTERN = /^TG_SAI_HOLD_(\d+)$/
17
+
18
+ /** Seconds from a `TG_SAI_HOLD_<s>` text or message; `null` otherwise. */
19
+ export function holdErrorSeconds(
20
+ text: string | null | undefined,
21
+ ): number | null {
22
+ const match = typeof text === "string" ? HOLD_ERROR_PATTERN.exec(text) : null
23
+ return match ? Number(match[1]) : null
24
+ }
25
+
26
+ /**
27
+ * Thrown by the guard instead of sending a call that is under a flood hold.
28
+ * Shaped like an mtcute `RpcError` (`code`, `text`, `seconds`) so existing
29
+ * duck-typed handlers see a 420; `message` and `text` are both the stable
30
+ * `TG_SAI_HOLD_<s>`.
31
+ */
32
+ export class HoldError extends Error {
33
+ readonly code = HOLD_ERROR_CODE
34
+ readonly text: string
35
+ readonly seconds: number
36
+
37
+ constructor(
38
+ readonly untilMs: number,
39
+ readonly grp: HoldGroup,
40
+ nowMs: number = Date.now(),
41
+ readonly method: string | null = null,
42
+ ) {
43
+ const seconds = remainingSeconds(untilMs, nowMs)
44
+ super(`TG_SAI_HOLD_${seconds}`)
45
+ this.name = "HoldError"
46
+ this.seconds = seconds
47
+ this.text = this.message
48
+ }
49
+
50
+ static fromHold(hold: FloodHold, nowMs: number, method: string): HoldError {
51
+ return new HoldError(hold.untilMs, hold.grp, nowMs, method)
52
+ }
53
+ }
54
+
55
+ /** Text of `HoldAccountUnknownError`. */
56
+ export const HOLD_ACCOUNT_UNKNOWN = "TG_SAI_HOLD_ACCOUNT_UNKNOWN"
57
+
58
+ /**
59
+ * Thrown by the guard instead of sending an invite/create call when the
60
+ * account the session acts as is not known, so its saved holds cannot be
61
+ * checked. Shaped like `HoldStoreUnavailableError` (code 500).
62
+ */
63
+ export class HoldAccountUnknownError extends Error {
64
+ readonly code = 500
65
+ readonly text = HOLD_ACCOUNT_UNKNOWN
66
+
67
+ constructor(
68
+ readonly method: string,
69
+ readonly reason: string = "session account not verified",
70
+ ) {
71
+ super(HOLD_ACCOUNT_UNKNOWN)
72
+ this.name = "HoldAccountUnknownError"
73
+ }
74
+ }
75
+
76
+ /** Account the client acts as; `null`/`undefined` while unknown. */
77
+ export type HoldAccountProvider = () => string | number | null | undefined
78
+
79
+ export interface FloodGuardOptions {
80
+ now?: () => number
81
+ /**
82
+ * The process flood waiter's `maxWait`. A wait at or under it (or under
83
+ * the call's own `floodSleepThreshold`) is slept through and retried by
84
+ * mtcute, so it is not saved, except on invite/create and `PEER_FLOOD`.
85
+ * Unset: every flood wait is saved.
86
+ */
87
+ sleepThresholdMs?: number
88
+ /** Request ID to store with a recorded hold (e.g. telemetry's). */
89
+ requestIdOf?: (ctx: RpcCallMiddlewareContext) => string | null | undefined
90
+ /**
91
+ * Invite/create calls need a known account: while the provider returns
92
+ * none, `check` calls `resolveAccount` (if set) once, and refuses with
93
+ * `HoldAccountUnknownError` when the account is still unknown. Other
94
+ * calls pass unguarded until the account is known. Unset: a client with
95
+ * no account passes every call.
96
+ */
97
+ requireAccount?: boolean
98
+ /** Learns the account (e.g. `getMe`); the provider must then return it. */
99
+ resolveAccount?: () => Promise<unknown>
100
+ }
101
+
102
+ /** A store, or a provider called on each use (e.g. a lazy singleton). */
103
+ export type HoldStoreSource = HoldStore | (() => HoldStore)
104
+
105
+ export interface FloodGuard {
106
+ /** Place between telemetry's outer layer and `basic()`. */
107
+ check: RpcCallMiddleware
108
+ /** Place innermost, after `basic()`: sees only real wire results. */
109
+ record: RpcCallMiddleware
110
+ }
111
+
112
+ interface RpcErrorValue {
113
+ _: "mt_rpc_error"
114
+ errorCode: number
115
+ errorMessage: string
116
+ }
117
+
118
+ function isRpcErrorValue(value: unknown): value is RpcErrorValue {
119
+ return (
120
+ typeof value === "object" &&
121
+ value !== null &&
122
+ (value as { _?: unknown })._ === "mt_rpc_error"
123
+ )
124
+ }
125
+
126
+ /**
127
+ * Whether `record` saves a parsed hold. Invite/create and `PEER_FLOOD` are
128
+ * always saved; other waits only when mtcute will not sleep through them.
129
+ */
130
+ export function shouldSaveHold(
131
+ grp: HoldGroup,
132
+ parsed: ParsedHold,
133
+ thresholdMs: number | undefined,
134
+ ): boolean {
135
+ if (isFailClosedGroup(grp) || parsed.source === "peer_flood") return true
136
+ if (thresholdMs === undefined) return true
137
+ return parsed.waitS * 1000 > thresholdMs
138
+ }
139
+
140
+ function accountOf(provider: HoldAccountProvider): string | null {
141
+ try {
142
+ const id = provider()
143
+ if (id === null || id === undefined) return null
144
+ const text = String(id).trim()
145
+ return text === "" ? null : text
146
+ } catch {
147
+ return null
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Builds the flood-hold middlewares for one client.
153
+ *
154
+ * - `check` throws `HoldError` (code 420, `TG_SAI_HOLD_<s>`) without calling
155
+ * `next` while the call's group is held. With the store unavailable it
156
+ * throws `HoldStoreUnavailableError` for invite/create and passes the rest.
157
+ * With `requireAccount` and no known account it throws
158
+ * `HoldAccountUnknownError` for invite/create.
159
+ * - `record` saves `FLOOD_WAIT_N`, `FLOOD_PREMIUM_WAIT_N`, and `PEER_FLOOD`
160
+ * results that `shouldSaveHold` accepts, returns the result unchanged, and
161
+ * never throws from recording.
162
+ * Being innermost, it never sees mtcute's synthetic stored-wait results.
163
+ */
164
+ export function createFloodGuard(
165
+ source: HoldStoreSource,
166
+ accountProvider: HoldAccountProvider,
167
+ options: FloodGuardOptions = {},
168
+ ): FloodGuard {
169
+ const now = options.now ?? Date.now
170
+ const storeOf = (): HoldStore => {
171
+ if (typeof source !== "function") return source
172
+ try {
173
+ return source()
174
+ } catch (error) {
175
+ return HoldStore.unavailable(
176
+ error instanceof Error ? error.message : String(error),
177
+ )
178
+ }
179
+ }
180
+
181
+ let resolving: Promise<unknown> | null = null
182
+ const resolveAccount = async (method: string): Promise<string> => {
183
+ let reason: string | undefined
184
+ if (options.resolveAccount) {
185
+ resolving ??= options.resolveAccount().finally(() => {
186
+ resolving = null
187
+ })
188
+ try {
189
+ await resolving
190
+ } catch (error) {
191
+ reason = error instanceof Error ? error.message : String(error)
192
+ }
193
+ }
194
+ // The provider decides: a resolver may fail after the account is known.
195
+ const accountId = accountOf(accountProvider)
196
+ if (accountId === null) throw new HoldAccountUnknownError(method, reason)
197
+ return accountId
198
+ }
199
+
200
+ const check: RpcCallMiddleware = async (ctx, next) => {
201
+ const method = String(ctx.request._)
202
+ const grp = methodGroup(method)
203
+ let accountId = accountOf(accountProvider)
204
+ if (accountId === null) {
205
+ if (!options.requireAccount || !isFailClosedGroup(grp)) return next(ctx)
206
+ accountId = await resolveAccount(method)
207
+ }
208
+ const store = storeOf()
209
+ if (store.unavailable) {
210
+ if (isFailClosedGroup(grp)) {
211
+ throw new HoldStoreUnavailableError(
212
+ store.unavailableReason ?? "unavailable",
213
+ )
214
+ }
215
+ return next(ctx)
216
+ }
217
+ const nowMs = now()
218
+ let hold: FloodHold | null
219
+ try {
220
+ hold = store.activeHold(accountId, grp, nowMs)
221
+ } catch (error) {
222
+ if (!isFailClosedGroup(grp)) return next(ctx)
223
+ throw error instanceof HoldStoreUnavailableError
224
+ ? error
225
+ : new HoldStoreUnavailableError(
226
+ error instanceof Error ? error.message : String(error),
227
+ )
228
+ }
229
+ if (hold) throw HoldError.fromHold(hold, nowMs, method)
230
+ return next(ctx)
231
+ }
232
+
233
+ const record: RpcCallMiddleware = async (ctx, next) => {
234
+ const result = await next(ctx)
235
+ try {
236
+ if (!isRpcErrorValue(result)) return result
237
+ const parsed = parseHoldError(result.errorMessage)
238
+ if (!parsed) return result
239
+ const accountId = accountOf(accountProvider)
240
+ if (accountId === null) return result
241
+ const method = String(ctx.request._)
242
+ const grp = methodGroup(method)
243
+ const threshold =
244
+ ctx.params?.floodSleepThreshold ?? options.sleepThresholdMs
245
+ if (!shouldSaveHold(grp, parsed, threshold)) return result
246
+ const store = storeOf()
247
+ if (store.unavailable) return result
248
+ let requestId: string | null = null
249
+ try {
250
+ requestId = options.requestIdOf?.(ctx) ?? null
251
+ } catch {
252
+ requestId = null
253
+ }
254
+ store.recordHold({
255
+ accountId,
256
+ grp,
257
+ method,
258
+ source: parsed.source,
259
+ waitS: parsed.waitS,
260
+ requestId,
261
+ nowMs: now(),
262
+ })
263
+ } catch {
264
+ // Recording must never change the call's result.
265
+ }
266
+ return result
267
+ }
268
+
269
+ return { check, record }
270
+ }
package/state/holds.ts ADDED
@@ -0,0 +1,381 @@
1
+ import type { Database, Statement } from "bun:sqlite"
2
+
3
+ import { openStateDb } from "./db.ts"
4
+
5
+ /** Telegram limits one action family as a whole, so holds key by group. */
6
+ export type HoldGroup =
7
+ | "invite"
8
+ | "create"
9
+ | "resolve"
10
+ | "send"
11
+ | `method:${string}`
12
+
13
+ export type HoldSource =
14
+ | "flood_wait"
15
+ | "flood_premium_wait"
16
+ | "peer_flood"
17
+ | "manual"
18
+
19
+ export interface ParsedHold {
20
+ source: Exclude<HoldSource, "manual">
21
+ waitS: number
22
+ }
23
+
24
+ /** One row of `flood_hold`, in camelCase. */
25
+ export interface FloodHold {
26
+ accountId: string
27
+ grp: HoldGroup
28
+ untilMs: number
29
+ method: string
30
+ waitS: number | null
31
+ source: HoldSource
32
+ requestId: string | null
33
+ createdMs: number
34
+ updatedMs: number
35
+ }
36
+
37
+ export interface RecordHoldInput {
38
+ accountId: string | number
39
+ grp: HoldGroup
40
+ method: string
41
+ source: HoldSource
42
+ /** Telegram's wait; `untilMs` defaults to `holdUntil(nowMs, waitS)`. */
43
+ waitS?: number | null
44
+ /** Explicit end time (e.g. a manual hold). Wins over `waitS`. */
45
+ untilMs?: number
46
+ requestId?: string | null
47
+ nowMs?: number
48
+ }
49
+
50
+ export const PEER_FLOOD_WAIT_S = 24 * 60 * 60
51
+ export const MIN_HOLD_MARGIN_MS = 10_000
52
+ export const MAX_HOLD_MS = 365 * 24 * 60 * 60 * 1000
53
+
54
+ const GROUPS: Readonly<Record<string, HoldGroup>> = {
55
+ "channels.inviteToChannel": "invite",
56
+ "messages.addChatUser": "invite",
57
+ "channels.createChannel": "create",
58
+ "messages.createChat": "create",
59
+ "contacts.resolveUsername": "resolve",
60
+ "messages.sendMessage": "send",
61
+ "messages.sendMedia": "send",
62
+ "messages.sendMultiMedia": "send",
63
+ "messages.forwardMessages": "send",
64
+ }
65
+
66
+ /** Hold group of a TL method name; unknown methods key by their own name. */
67
+ export function methodGroup(method: string): HoldGroup {
68
+ return GROUPS[method] ?? `method:${method}`
69
+ }
70
+
71
+ /** Groups that must fail closed when hold state cannot be read. */
72
+ export function isFailClosedGroup(grp: HoldGroup): boolean {
73
+ return grp === "invite" || grp === "create"
74
+ }
75
+
76
+ const WAIT_PATTERN = /^(FLOOD_WAIT|FLOOD_PREMIUM_WAIT)_(\d{1,10})$/
77
+
78
+ /**
79
+ * Hold implied by a raw Telegram error message: `FLOOD_WAIT_N`,
80
+ * `FLOOD_PREMIUM_WAIT_N`, or `PEER_FLOOD` (24 h). `null` for anything else.
81
+ */
82
+ export function parseHoldError(
83
+ errorMessage: string | null | undefined,
84
+ ): ParsedHold | null {
85
+ if (typeof errorMessage !== "string") return null
86
+ if (errorMessage === "PEER_FLOOD") {
87
+ return { source: "peer_flood", waitS: PEER_FLOOD_WAIT_S }
88
+ }
89
+ const match = WAIT_PATTERN.exec(errorMessage)
90
+ if (!match) return null
91
+ const waitS = Number(match[2])
92
+ if (!Number.isSafeInteger(waitS)) return null
93
+ return {
94
+ source: match[1] === "FLOOD_WAIT" ? "flood_wait" : "flood_premium_wait",
95
+ waitS,
96
+ }
97
+ }
98
+
99
+ /** `nowMs + waitS` plus a margin of max(10 s, 1 % of the wait). */
100
+ export function holdUntil(nowMs: number, waitS: number): number {
101
+ const waitMs = Math.max(0, waitS) * 1000
102
+ return nowMs + waitMs + Math.max(MIN_HOLD_MARGIN_MS, Math.ceil(waitMs / 100))
103
+ }
104
+
105
+ /** Whole seconds left on a hold, at least 1 while it is active. */
106
+ export function remainingSeconds(untilMs: number, nowMs: number): number {
107
+ return Math.max(1, Math.ceil((untilMs - nowMs) / 1000))
108
+ }
109
+
110
+ /** Thrown by every `HoldStore` method when the store failed to open. */
111
+ export class HoldStoreUnavailableError extends Error {
112
+ readonly code = 500
113
+ readonly text = "TG_SAI_HOLD_STATE_UNAVAILABLE"
114
+ constructor(readonly reason: string) {
115
+ super("TG_SAI_HOLD_STATE_UNAVAILABLE")
116
+ this.name = "HoldStoreUnavailableError"
117
+ }
118
+ }
119
+
120
+ interface HoldRow {
121
+ account_id: string
122
+ grp: string
123
+ until_ms: number
124
+ method: string
125
+ wait_s: number | null
126
+ source: string
127
+ request_id: string | null
128
+ created_ms: number
129
+ updated_ms: number
130
+ }
131
+
132
+ function fromRow(row: HoldRow): FloodHold {
133
+ return {
134
+ accountId: row.account_id,
135
+ grp: row.grp as HoldGroup,
136
+ untilMs: row.until_ms,
137
+ method: row.method,
138
+ waitS: row.wait_s,
139
+ source: row.source as HoldSource,
140
+ requestId: row.request_id,
141
+ createdMs: row.created_ms,
142
+ updatedMs: row.updated_ms,
143
+ }
144
+ }
145
+
146
+ // SET expressions read the pre-update row, so each CASE compares against the
147
+ // stored hold. A longer hold replaces the details; an expired one restarts.
148
+ const UPSERT_SQL = `INSERT INTO flood_hold (
149
+ account_id, grp, until_ms, method, wait_s, source, request_id,
150
+ created_ms, updated_ms
151
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
152
+ ON CONFLICT (account_id, grp) DO UPDATE SET
153
+ until_ms = MAX(until_ms, excluded.until_ms),
154
+ method = CASE WHEN excluded.until_ms > until_ms
155
+ THEN excluded.method ELSE method END,
156
+ wait_s = CASE WHEN excluded.until_ms > until_ms
157
+ THEN excluded.wait_s ELSE wait_s END,
158
+ source = CASE WHEN excluded.until_ms > until_ms
159
+ THEN excluded.source ELSE source END,
160
+ request_id = CASE WHEN excluded.until_ms > until_ms
161
+ THEN excluded.request_id ELSE request_id END,
162
+ created_ms = CASE WHEN until_ms <= excluded.created_ms
163
+ THEN excluded.created_ms ELSE created_ms END,
164
+ updated_ms = excluded.updated_ms
165
+ RETURNING *`
166
+
167
+ const ACTIVE_SQL =
168
+ "SELECT * FROM flood_hold WHERE account_id = ? AND grp = ? AND until_ms > ?"
169
+
170
+ /**
171
+ * Durable flood holds in `state.db`. Construct with `HoldStore.open`; a
172
+ * store that failed to open has `unavailable` set and every method throws
173
+ * `HoldStoreUnavailableError`, so callers can fail closed.
174
+ */
175
+ export class HoldStore {
176
+ private upsert: Statement<HoldRow> | null = null
177
+ private active: Statement<HoldRow> | null = null
178
+
179
+ private constructor(
180
+ private db: Database | null,
181
+ private reason: string | null,
182
+ ) {}
183
+
184
+ /** Opens the store at `path`. Never throws. */
185
+ static open(path: string): HoldStore {
186
+ try {
187
+ return new HoldStore(openStateDb(path), null)
188
+ } catch (error) {
189
+ const reason = error instanceof Error ? error.message : String(error)
190
+ return new HoldStore(null, reason || "cannot open state database")
191
+ }
192
+ }
193
+
194
+ /** Wraps an already-migrated database (e.g. from `openStateDb`). */
195
+ static fromDb(db: Database): HoldStore {
196
+ return new HoldStore(db, null)
197
+ }
198
+
199
+ /** A store that reports `unavailable`, for callers and tests. */
200
+ static unavailable(reason: string): HoldStore {
201
+ return new HoldStore(null, reason)
202
+ }
203
+
204
+ get unavailable(): boolean {
205
+ return this.db === null
206
+ }
207
+
208
+ /** Why the store is unavailable; `null` while it is open. */
209
+ get unavailableReason(): string | null {
210
+ return this.db === null ? (this.reason ?? "closed") : null
211
+ }
212
+
213
+ private requireDb(): Database {
214
+ if (!this.db) {
215
+ throw new HoldStoreUnavailableError(this.reason ?? "closed")
216
+ }
217
+ return this.db
218
+ }
219
+
220
+ /**
221
+ * Upserts a hold, keeping the later `until_ms` of the stored and new
222
+ * holds. The end time is capped at one year from `nowMs`. Returns the
223
+ * stored row.
224
+ */
225
+ recordHold(input: RecordHoldInput): FloodHold {
226
+ const db = this.requireDb()
227
+ const nowMs = input.nowMs ?? Date.now()
228
+ const waitS = input.waitS ?? null
229
+ let untilMs =
230
+ input.untilMs ?? (waitS === null ? NaN : holdUntil(nowMs, waitS))
231
+ if (!Number.isFinite(untilMs)) {
232
+ throw new Error("recordHold needs waitS or untilMs")
233
+ }
234
+ untilMs = Math.min(Math.round(untilMs), nowMs + MAX_HOLD_MS)
235
+ this.upsert ??= db.prepare<HoldRow, (string | number | null)[]>(UPSERT_SQL)
236
+ const row = this.upsert.get(
237
+ String(input.accountId),
238
+ input.grp,
239
+ untilMs,
240
+ input.method,
241
+ waitS,
242
+ input.source,
243
+ input.requestId ?? null,
244
+ nowMs,
245
+ nowMs,
246
+ )
247
+ if (!row) throw new Error("flood_hold upsert returned no row")
248
+ return fromRow(row)
249
+ }
250
+
251
+ /** The hold on `grp` for `accountId` if it ends after `nowMs`. */
252
+ activeHold(
253
+ accountId: string | number,
254
+ grp: HoldGroup,
255
+ nowMs: number = Date.now(),
256
+ ): FloodHold | null {
257
+ const db = this.requireDb()
258
+ this.active ??= db.prepare<HoldRow, (string | number)[]>(ACTIVE_SQL)
259
+ const row = this.active.get(String(accountId), grp, nowMs)
260
+ return row ? fromRow(row) : null
261
+ }
262
+
263
+ /** Active holds, latest end first; all accounts when `accountId` is unset. */
264
+ activeHolds(
265
+ accountId?: string | number | null,
266
+ nowMs: number = Date.now(),
267
+ ): FloodHold[] {
268
+ const db = this.requireDb()
269
+ const rows =
270
+ accountId === undefined || accountId === null
271
+ ? db
272
+ .query<HoldRow, [number]>(
273
+ "SELECT * FROM flood_hold WHERE until_ms > ? " +
274
+ "ORDER BY until_ms DESC, account_id, grp",
275
+ )
276
+ .all(nowMs)
277
+ : db
278
+ .query<HoldRow, [string, number]>(
279
+ "SELECT * FROM flood_hold WHERE account_id = ? AND until_ms > ? " +
280
+ "ORDER BY until_ms DESC, grp",
281
+ )
282
+ .all(String(accountId), nowMs)
283
+ return rows.map(fromRow)
284
+ }
285
+
286
+ /** Operator override: deletes a hold. Returns whether one existed. */
287
+ clearHold(accountId: string | number, grp: HoldGroup): boolean {
288
+ const db = this.requireDb()
289
+ const result = db.run(
290
+ "DELETE FROM flood_hold WHERE account_id = ? AND grp = ?",
291
+ [String(accountId), grp],
292
+ )
293
+ return result.changes > 0
294
+ }
295
+
296
+ /** Whether `close` was called (as opposed to a failed open). */
297
+ get closed(): boolean {
298
+ return this.db === null && this.reason === "closed"
299
+ }
300
+
301
+ /** Closes the database; the store then reports `unavailable`. */
302
+ close(): void {
303
+ const db = this.db
304
+ this.db = null
305
+ this.reason ??= "closed"
306
+ try {
307
+ this.upsert?.finalize()
308
+ this.active?.finalize()
309
+ db?.close(true)
310
+ } catch {
311
+ // Already closed.
312
+ }
313
+ this.upsert = null
314
+ this.active = null
315
+ }
316
+ }
317
+
318
+ /** Least time between attempts to reopen a store that failed to open. */
319
+ export const HOLD_STORE_REOPEN_MS = 30_000
320
+
321
+ export interface ReopeningHoldStoreOptions {
322
+ /** Opens the store; default `HoldStore.open(path)`. */
323
+ open?: (path: string) => HoldStore
324
+ now?: () => number
325
+ /** Default `HOLD_STORE_REOPEN_MS`. */
326
+ reopenMs?: number
327
+ }
328
+
329
+ /**
330
+ * Hands out one open `HoldStore` for `path`. An open that fails is not
331
+ * kept: the unavailable store is returned (so callers fail closed) and the
332
+ * next call at least `reopenMs` later tries again. A store closed by its
333
+ * user is reopened on the next call.
334
+ */
335
+ export class ReopeningHoldStore {
336
+ private store: HoldStore | null = null
337
+ private attemptMs = -Infinity
338
+ private readonly openStore: (path: string) => HoldStore
339
+ private readonly now: () => number
340
+ private readonly reopenMs: number
341
+
342
+ constructor(
343
+ readonly path: string,
344
+ options: ReopeningHoldStoreOptions = {},
345
+ ) {
346
+ this.openStore = options.open ?? ((p) => HoldStore.open(p))
347
+ this.now = options.now ?? Date.now
348
+ this.reopenMs = options.reopenMs ?? HOLD_STORE_REOPEN_MS
349
+ }
350
+
351
+ /** The open store, or the last unavailable one inside the backoff. */
352
+ get(nowMs: number = this.now()): HoldStore {
353
+ const current = this.store
354
+ if (current && !current.unavailable) return current
355
+ if (
356
+ current &&
357
+ !current.closed &&
358
+ nowMs - this.attemptMs < this.reopenMs
359
+ ) {
360
+ return current
361
+ }
362
+ this.attemptMs = nowMs
363
+ let next: HoldStore
364
+ try {
365
+ next = this.openStore(this.path)
366
+ } catch (error) {
367
+ next = HoldStore.unavailable(
368
+ error instanceof Error ? error.message : String(error),
369
+ )
370
+ }
371
+ this.store = next
372
+ return next
373
+ }
374
+
375
+ /** Closes the store; the next `get` reopens it. */
376
+ close(): void {
377
+ this.store?.close()
378
+ this.store = null
379
+ this.attemptMs = -Infinity
380
+ }
381
+ }
@@ -0,0 +1,16 @@
1
+ -- One active Telegram flood hold per account and method group. Writers
2
+ -- upsert with MAX(until_ms), so a shorter wait never shortens a hold.
3
+ CREATE TABLE flood_hold (
4
+ account_id TEXT NOT NULL,
5
+ grp TEXT NOT NULL,
6
+ until_ms INTEGER NOT NULL,
7
+ method TEXT NOT NULL,
8
+ wait_s INTEGER,
9
+ source TEXT NOT NULL CHECK (
10
+ source IN ('flood_wait', 'flood_premium_wait', 'peer_flood', 'manual')
11
+ ),
12
+ request_id TEXT,
13
+ created_ms INTEGER NOT NULL,
14
+ updated_ms INTEGER NOT NULL,
15
+ PRIMARY KEY (account_id, grp)
16
+ ) STRICT;