@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/README.md +17 -0
- package/RUNBOOK.md +67 -8
- package/actionExecutor.ts +157 -105
- package/custodyWorker.ts +274 -32
- package/custodyWorkerCli.ts +7 -1
- package/main.ts +422 -118
- package/mtcuteAdapter.ts +255 -7
- package/package.json +10 -2
- package/profiles.ts +23 -5
- package/provisioning.ts +175 -44
- package/provisioningCli.ts +8 -1
- package/provisioningRuntime.ts +42 -5
- package/runtimeHealth.ts +8 -1
- package/runtimeHealthCli.ts +3 -1
- package/state/cli.ts +641 -0
- package/state/db.ts +72 -0
- package/state/guard.ts +270 -0
- package/state/holds.ts +381 -0
- package/state/migrations/0001-flood-hold.sql +16 -0
- package/telemetry/cli.ts +698 -0
- package/telemetry/db.ts +471 -0
- package/telemetry/middleware.ts +475 -0
- package/telemetry/migrations/0001-event.sql +33 -0
- package/telemetry/migrations/0002-event-job-index.sql +2 -0
- package/telemetry/migrations/0003-event-log.sql +10 -0
- package/telemetry/queries.ts +475 -0
- package/telemetry/run.ts +491 -0
- package/telemetry/sqlAssets.d.ts +4 -0
- package/telemetry/summarize.ts +219 -0
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;
|