@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/README.md
CHANGED
|
@@ -29,6 +29,23 @@ target defaults to the Sai custody account,
|
|
|
29
29
|
[@SaiTeam_AI](https://t.me/SaiTeam_AI) (user 8274304175), and the bot is
|
|
30
30
|
[@SaiTeam_bot](https://t.me/SaiTeam_bot). `--target-user` overrides the owner.
|
|
31
31
|
|
|
32
|
+
## Logs
|
|
33
|
+
|
|
34
|
+
tg-sai writes no log files. Every run records Telegram calls and app log
|
|
35
|
+
rows in the SQLite file `~/.tg-sai/telemetry.db` (`$TG_SAI_DIR` overrides
|
|
36
|
+
`~/.tg-sai`). Read them with `tg-sai events`:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
tg-sai events logs --since 24h --level warn --subsystem worker
|
|
40
|
+
tg-sai events show <run_id|job_id|request_id>
|
|
41
|
+
tg-sai events runs
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Saved Telegram flood waits live in `~/.tg-sai/state.db`. While one is
|
|
45
|
+
active, a CLI command that would send the held call exits with code 75 and
|
|
46
|
+
prints when to retry. `TG_SAI_TELEMETRY=off` turns off `telemetry.db` only;
|
|
47
|
+
`state.db` and its holds still apply.
|
|
48
|
+
|
|
32
49
|
## Development
|
|
33
50
|
|
|
34
51
|
```bash
|
package/RUNBOOK.md
CHANGED
|
@@ -232,10 +232,11 @@ Find Sai-related chats visible to the active Telegram session:
|
|
|
232
232
|
tg-sai ops sai-find
|
|
233
233
|
```
|
|
234
234
|
|
|
235
|
-
Command `tg-sai ops sai-find` always writes a JSONL file
|
|
236
|
-
`~/.tg-sai/logs/`, using the next available filename such as
|
|
237
|
-
`sai-find-00.jsonl` or `sai-find-01.jsonl`.
|
|
238
|
-
|
|
235
|
+
Command `tg-sai ops sai-find` always writes its chat list as a JSONL file
|
|
236
|
+
under directory `~/.tg-sai/logs/`, using the next available filename such as
|
|
237
|
+
`sai-find-00.jsonl` or `sai-find-01.jsonl`. This file is command output for
|
|
238
|
+
`ops sai-bootstrap --file`, not a log. Use flag `--out <path>` to also write a
|
|
239
|
+
copy somewhere else.
|
|
239
240
|
|
|
240
241
|
Dry-run Sai bot and owner-target updates from a reviewed chat file:
|
|
241
242
|
|
|
@@ -243,13 +244,19 @@ Dry-run Sai bot and owner-target updates from a reviewed chat file:
|
|
|
243
244
|
tg-sai ops sai-bootstrap --file ~/.tg-sai/logs/sai-find-00.jsonl
|
|
244
245
|
```
|
|
245
246
|
|
|
246
|
-
Command `tg-sai ops sai-bootstrap`
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
second between chats by default. Use flag `--delay-ms <ms>` to change that
|
|
247
|
+
Command `tg-sai ops sai-bootstrap` prints one JSON event per line on stdout
|
|
248
|
+
and its `telemetry_run_id`; redirect stdout if you want a copy. Its Telegram
|
|
249
|
+
calls are in the telemetry database: `tg-sai events show <run_id>`. The
|
|
250
|
+
command waits one second between chats by default. Use flag `--delay-ms <ms>` to change that
|
|
250
251
|
delay. When environment variable `TELEGRAM_PASSWORD` is available, execution
|
|
251
252
|
mode attempts ownership transfer by default for eligible chats.
|
|
252
253
|
|
|
254
|
+
The command sleeps through flood waits up to five minutes (environment
|
|
255
|
+
variable `TG_SAI_FLOOD_MAX_WAIT_MS` overrides). A longer `FLOOD_WAIT_X` or any
|
|
256
|
+
`PEER_FLOOD` stops the remaining chats: stdout gets a `flood_stop` event with
|
|
257
|
+
the chat, the remaining count, the code, and `resumeAt`, and the command exits
|
|
258
|
+
with code 1. A `SLOWMODE_WAIT_X` fails only that chat.
|
|
259
|
+
|
|
253
260
|
Run a live read-only audit:
|
|
254
261
|
|
|
255
262
|
```bash
|
|
@@ -300,6 +307,58 @@ TELEGRAM_PASSWORD="<2fa password>" tg-sai add-owner \
|
|
|
300
307
|
--run
|
|
301
308
|
```
|
|
302
309
|
|
|
310
|
+
## Logs, Telemetry, and Flood Holds
|
|
311
|
+
|
|
312
|
+
tg-sai writes no JSONL run logs. Earlier versions wrote
|
|
313
|
+
`~/.tg-sai/logs/<command>-NN.jsonl` and `sai-bootstrap-NN.jsonl`; those files
|
|
314
|
+
stay readable but nothing new is added. Each process records into SQLite file
|
|
315
|
+
`~/.tg-sai/telemetry.db` (`$TG_SAI_DIR/telemetry.db`):
|
|
316
|
+
|
|
317
|
+
- `run.start` and `run.end` rows with the sanitized argv and exit code.
|
|
318
|
+
- `tg.rpc.call`, `tg.rpc.attempt`, and `tg.flood.wait` rows for Telegram
|
|
319
|
+
calls, with summarized request and response shapes.
|
|
320
|
+
- App log rows: a `level` (`debug`, `info`, `warn`, `error`), a short fixed
|
|
321
|
+
`msg`, and summarized `attrs`. Their `event` is `log.<subsystem>` or a
|
|
322
|
+
dotted name such as `worker.job.held`.
|
|
323
|
+
|
|
324
|
+
Read it with `tg-sai events`. Output is TOON; add `--json` for JSON.
|
|
325
|
+
|
|
326
|
+
```bash
|
|
327
|
+
tg-sai events logs --since 24h --level warn --subsystem worker
|
|
328
|
+
tg-sai events logs --run <run_id> --limit 200
|
|
329
|
+
tg-sai events show <run_id|job_id|request_id> # calls and log rows
|
|
330
|
+
tg-sai events runs
|
|
331
|
+
tg-sai events floods --since 7d
|
|
332
|
+
tg-sai events stats # rows, log rows by level
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
Saved flood waits live in a separate file, `~/.tg-sai/state.db`. Every tg-sai
|
|
336
|
+
process checks it before invite, create, resolve, and send calls. While a
|
|
337
|
+
hold is active, a CLI command exits with code 75 and one stderr line naming
|
|
338
|
+
the held call group and the UTC time to retry. If `state.db` cannot be read,
|
|
339
|
+
invite and create calls are blocked, and the command also exits 75. The
|
|
340
|
+
hold check keys by the account the session's own `getMe` reports, not by the
|
|
341
|
+
profile's saved user ID; until that is known, invite and create calls are
|
|
342
|
+
blocked the same way (`TG_SAI_HOLD_ACCOUNT_UNKNOWN`, exit 75).
|
|
343
|
+
|
|
344
|
+
`ops sai-bootstrap` stops the batch with a `flood_stop` event on any of
|
|
345
|
+
these refusals and exits 75. A `PEER_FLOOD` or a Telegram flood wait longer
|
|
346
|
+
than the run's threshold also stops it, with exit 1.
|
|
347
|
+
|
|
348
|
+
Inspect and override holds with `tg-sai holds`. `set` and `clear` are dry
|
|
349
|
+
runs without `--run`; `clear --run` on an active hold also needs `--force`.
|
|
350
|
+
Each write adds a `holds.set` or `holds.clear` row to `telemetry.db`. A
|
|
351
|
+
`--run` refuses a `state.db` owned by another user, so on the custody VM
|
|
352
|
+
run the worker's binary as the worker user:
|
|
353
|
+
|
|
354
|
+
```bash
|
|
355
|
+
sudo -u sai-custody-worker env TG_SAI_DIR=/var/lib/sai-custody-worker HOME=/var/lib/sai-custody-worker /opt/sai-custody-worker/current/tg-sai-worker holds list
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
`TG_SAI_TELEMETRY=off` disables `telemetry.db` writes only. Flood holds in
|
|
359
|
+
`state.db` still apply, and `tg-sai events` reads whatever was recorded
|
|
360
|
+
before.
|
|
361
|
+
|
|
303
362
|
## File roles
|
|
304
363
|
|
|
305
364
|
| File | Role |
|
package/actionExecutor.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import { HOLD_ACCOUNT_UNKNOWN, holdErrorSeconds } from "./state/guard"
|
|
2
|
+
import { parseWaitSeconds, rpcErrorText } from "./telemetry/middleware"
|
|
3
|
+
|
|
1
4
|
/**
|
|
2
|
-
* Structured event emitted by the batch action executor.
|
|
5
|
+
* Structured progress event emitted by the batch action executor.
|
|
3
6
|
*
|
|
4
|
-
* These
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
+
* These events are on-screen CLI progress (and copied to the run log). They do
|
|
8
|
+
* not retry: mtcute's flood waiter is the only retry layer, and every Telegram
|
|
9
|
+
* request, attempt, and flood wait is recorded by telemetry instead.
|
|
7
10
|
*/
|
|
8
11
|
export type BatchActionEvent =
|
|
9
12
|
| {
|
|
@@ -13,22 +16,6 @@ export type BatchActionEvent =
|
|
|
13
16
|
chatId: number | string
|
|
14
17
|
/** Stable action name, for example `bot_add`. */
|
|
15
18
|
action: string
|
|
16
|
-
/** One-based attempt number for this action. */
|
|
17
|
-
attempt: number
|
|
18
|
-
}
|
|
19
|
-
| {
|
|
20
|
-
/** Telegram returned `FLOOD_WAIT_X` and the executor will retry later. */
|
|
21
|
-
type: "flood_wait"
|
|
22
|
-
/** Telegram chat ID or peer for the rate-limited action. */
|
|
23
|
-
chatId: number | string
|
|
24
|
-
/** Stable action name, for example `bot_promote_admin`. */
|
|
25
|
-
action: string
|
|
26
|
-
/** One-based attempt number that received the flood-wait error. */
|
|
27
|
-
attempt: number
|
|
28
|
-
/** Seconds Telegram requested in `FLOOD_WAIT_X`. */
|
|
29
|
-
waitSeconds: number
|
|
30
|
-
/** Milliseconds the executor sleeps, including configured buffer. */
|
|
31
|
-
sleepMs: number
|
|
32
19
|
}
|
|
33
20
|
| {
|
|
34
21
|
/** Operation completed successfully. */
|
|
@@ -37,20 +24,16 @@ export type BatchActionEvent =
|
|
|
37
24
|
chatId: number | string
|
|
38
25
|
/** Stable action name, for example `owner_target_add`. */
|
|
39
26
|
action: string
|
|
40
|
-
/**
|
|
41
|
-
attempts: number
|
|
42
|
-
/** Elapsed milliseconds from first attempt to success. */
|
|
27
|
+
/** Elapsed milliseconds from start to success, flood sleeps included. */
|
|
43
28
|
elapsedMs: number
|
|
44
29
|
}
|
|
45
30
|
| {
|
|
46
|
-
/** Operation failed
|
|
31
|
+
/** Operation failed; mtcute already retried any short flood wait. */
|
|
47
32
|
type: "action_error"
|
|
48
33
|
/** Telegram chat ID or peer for the failed action. */
|
|
49
34
|
chatId: number | string
|
|
50
35
|
/** Stable action name, for example `transfer_owner`. */
|
|
51
36
|
action: string
|
|
52
|
-
/** One-based attempt number that produced the final error. */
|
|
53
|
-
attempt: number
|
|
54
37
|
/** Error name normalized for logs. */
|
|
55
38
|
errorName: string
|
|
56
39
|
/** Error message normalized for logs. */
|
|
@@ -78,22 +61,14 @@ export interface TelegramActionContext {
|
|
|
78
61
|
}
|
|
79
62
|
|
|
80
63
|
/**
|
|
81
|
-
* Runtime dependencies for `
|
|
82
|
-
*
|
|
83
|
-
* Passing these in keeps retry behavior deterministic in tests: sleep, time, and
|
|
84
|
-
* logging can all be faked without importing mtcute or touching Telegram.
|
|
64
|
+
* Runtime dependencies for `runTelegramAction`. Passing these in keeps event
|
|
65
|
+
* output deterministic in tests.
|
|
85
66
|
*/
|
|
86
67
|
export interface TelegramActionExecutorDeps {
|
|
87
|
-
/** Event sink for action start, success,
|
|
68
|
+
/** Event sink for action start, success, and error events. */
|
|
88
69
|
logger: BatchLogger
|
|
89
|
-
/** Sleep function used after Telegram returns `FLOOD_WAIT_X`. */
|
|
90
|
-
sleep: (ms: number) => Promise<void>
|
|
91
70
|
/** Clock function used to compute elapsed action time. */
|
|
92
71
|
now: () => number
|
|
93
|
-
/** Number of retries after the first failed attempt. */
|
|
94
|
-
maxRetries: number
|
|
95
|
-
/** Extra milliseconds added to Telegram's required flood-wait duration. */
|
|
96
|
-
bufferMs: number
|
|
97
72
|
}
|
|
98
73
|
|
|
99
74
|
/**
|
|
@@ -110,28 +85,6 @@ export class MemoryBatchLogger implements BatchLogger {
|
|
|
110
85
|
}
|
|
111
86
|
}
|
|
112
87
|
|
|
113
|
-
/**
|
|
114
|
-
* Extracts the wait duration from Telegram-style `FLOOD_WAIT_X` errors.
|
|
115
|
-
*
|
|
116
|
-
* mtcute and related libraries may expose the Telegram error string in the error
|
|
117
|
-
* name, message, or both. This helper checks both fields and returns `null` for
|
|
118
|
-
* non-flood errors so callers can distinguish retryable rate limits from normal
|
|
119
|
-
* failures.
|
|
120
|
-
*/
|
|
121
|
-
export function parseFloodWaitSeconds(error: unknown): number | null {
|
|
122
|
-
if (!(error instanceof Error)) {
|
|
123
|
-
return null
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const haystack = `${error.name} ${error.message}`
|
|
127
|
-
const match = haystack.match(/FLOOD_WAIT_(\d+)/)
|
|
128
|
-
if (!match) {
|
|
129
|
-
return null
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
return Number(match[1])
|
|
133
|
-
}
|
|
134
|
-
|
|
135
88
|
/**
|
|
136
89
|
* Returns a stable display name for unknown thrown values.
|
|
137
90
|
*/
|
|
@@ -153,67 +106,166 @@ export function errorMessage(error: unknown): string {
|
|
|
153
106
|
}
|
|
154
107
|
|
|
155
108
|
/**
|
|
156
|
-
* Runs one Telegram operation
|
|
109
|
+
* Runs one Telegram operation once and reports start, success, or error.
|
|
157
110
|
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
* at the chat level.
|
|
111
|
+
* There is no retry here: short `FLOOD_WAIT_X` waits are slept through by
|
|
112
|
+
* mtcute's flood waiter, and longer ones surface as errors. Errors are logged
|
|
113
|
+
* as `action_error` and rethrown for the batch runner to record per chat.
|
|
162
114
|
*/
|
|
163
|
-
export async function
|
|
115
|
+
export async function runTelegramAction<T>(
|
|
164
116
|
context: TelegramActionContext,
|
|
165
117
|
operation: () => Promise<T>,
|
|
166
118
|
deps: TelegramActionExecutorDeps,
|
|
167
119
|
): Promise<T> {
|
|
168
120
|
const startedAt = deps.now()
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
121
|
+
await deps.logger.event({
|
|
122
|
+
type: "action_start",
|
|
123
|
+
chatId: context.chatId,
|
|
124
|
+
action: context.action,
|
|
125
|
+
})
|
|
126
|
+
try {
|
|
127
|
+
const result = await operation()
|
|
128
|
+
await deps.logger.event({
|
|
129
|
+
type: "action_success",
|
|
130
|
+
chatId: context.chatId,
|
|
131
|
+
action: context.action,
|
|
132
|
+
elapsedMs: deps.now() - startedAt,
|
|
133
|
+
})
|
|
134
|
+
return result
|
|
135
|
+
} catch (error) {
|
|
172
136
|
await deps.logger.event({
|
|
173
|
-
type: "
|
|
137
|
+
type: "action_error",
|
|
174
138
|
chatId: context.chatId,
|
|
175
139
|
action: context.action,
|
|
176
|
-
|
|
140
|
+
errorName: errorName(error),
|
|
141
|
+
errorMessage: errorMessage(error),
|
|
177
142
|
})
|
|
143
|
+
throw error
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Text of `HoldStoreUnavailableError` (state/holds.ts). */
|
|
148
|
+
const HOLD_STATE_UNAVAILABLE = "TG_SAI_HOLD_STATE_UNAVAILABLE"
|
|
149
|
+
|
|
150
|
+
/** Why a batch stopped early on a Telegram rate limit. */
|
|
151
|
+
export interface FloodStop {
|
|
152
|
+
/**
|
|
153
|
+
* Telegram error code, e.g. `FLOOD_WAIT_15382` or `PEER_FLOOD`, or the
|
|
154
|
+
* local guard's `TG_SAI_HOLD_<s>`, `TG_SAI_HOLD_STATE_UNAVAILABLE`, or
|
|
155
|
+
* `TG_SAI_HOLD_ACCOUNT_UNKNOWN`.
|
|
156
|
+
*/
|
|
157
|
+
code: string
|
|
158
|
+
/** Wait Telegram asked for; `null` for `PEER_FLOOD` and unreadable holds. */
|
|
159
|
+
waitS: number | null
|
|
160
|
+
/** When the wait ends (ISO); `null` when no time is known. */
|
|
161
|
+
resumeAt: string | null
|
|
162
|
+
/**
|
|
163
|
+
* The local guard refused before sending (a saved hold, or holds that
|
|
164
|
+
* cannot be checked). Such stops exit 75; Telegram's own errors exit 1.
|
|
165
|
+
*/
|
|
166
|
+
hold: boolean
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function holdUntilMs(error: unknown): number | null {
|
|
170
|
+
const untilMs = (error as { untilMs?: unknown } | null)?.untilMs
|
|
171
|
+
return typeof untilMs === "number" && Number.isFinite(untilMs)
|
|
172
|
+
? untilMs
|
|
173
|
+
: null
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Returns a stop when `error` means the rest of a batch would only hit the
|
|
178
|
+
* same limit: any `PEER_FLOOD`, a `FLOOD_WAIT_X` (and premium/test-phone
|
|
179
|
+
* variants) longer than `maxWaitMs`, which mtcute did not sleep through, or
|
|
180
|
+
* a refusal by the local flood guard (`TG_SAI_HOLD_<s>`, unreadable hold
|
|
181
|
+
* state, unknown session account). `SLOWMODE_WAIT_X` is per chat, so it
|
|
182
|
+
* returns `null` and the batch goes on.
|
|
183
|
+
*/
|
|
184
|
+
export function floodStopFor(
|
|
185
|
+
error: unknown,
|
|
186
|
+
maxWaitMs: number,
|
|
187
|
+
nowMs: number,
|
|
188
|
+
): FloodStop | null {
|
|
189
|
+
const code = rpcErrorText(error)
|
|
190
|
+
if (code === null) return null
|
|
191
|
+
if (code === HOLD_STATE_UNAVAILABLE || code === HOLD_ACCOUNT_UNKNOWN) {
|
|
192
|
+
return { code, waitS: null, resumeAt: null, hold: true }
|
|
193
|
+
}
|
|
194
|
+
const holdS = holdErrorSeconds(code)
|
|
195
|
+
if (holdS !== null) {
|
|
196
|
+
const untilMs = holdUntilMs(error) ?? nowMs + holdS * 1000
|
|
197
|
+
return {
|
|
198
|
+
code,
|
|
199
|
+
waitS: holdS,
|
|
200
|
+
resumeAt: new Date(untilMs).toISOString(),
|
|
201
|
+
hold: true,
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (code === "PEER_FLOOD") {
|
|
205
|
+
return { code, waitS: null, resumeAt: null, hold: false }
|
|
206
|
+
}
|
|
207
|
+
if (code.startsWith("SLOWMODE_WAIT_")) return null
|
|
208
|
+
const waitS = parseWaitSeconds(code)
|
|
209
|
+
if (waitS === null || waitS * 1000 <= maxWaitMs) return null
|
|
210
|
+
return {
|
|
211
|
+
code,
|
|
212
|
+
waitS,
|
|
213
|
+
resumeAt: new Date(nowMs + waitS * 1000).toISOString(),
|
|
214
|
+
hold: false,
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** A flood stop plus where in the batch it happened. */
|
|
219
|
+
export interface BatchStop<T> extends FloodStop {
|
|
220
|
+
item: T
|
|
221
|
+
/** Zero-based index of the item that hit the limit. */
|
|
222
|
+
index: number
|
|
223
|
+
/** Items after it that were not started. */
|
|
224
|
+
remaining: number
|
|
225
|
+
}
|
|
178
226
|
|
|
227
|
+
export interface ChatBatchOptions<T> {
|
|
228
|
+
/** Runs one item; a throw is reported to `onItemError`. */
|
|
229
|
+
processItem: (item: T, index: number) => Promise<void>
|
|
230
|
+
/** Records a per-item failure; the batch continues unless it stops. */
|
|
231
|
+
onItemError: (item: T, error: unknown, index: number) => void | Promise<void>
|
|
232
|
+
/** Runs between items (e.g. the inter-chat delay), not after the last. */
|
|
233
|
+
betweenItems?: (item: T, index: number) => void | Promise<void>
|
|
234
|
+
/** Flood waits longer than this stop the batch. */
|
|
235
|
+
maxWaitMs: number
|
|
236
|
+
now?: () => number
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Processes `items` in order, isolating per-item errors, and stops at the
|
|
241
|
+
* first error `floodStopFor` treats as batch-wide.
|
|
242
|
+
*/
|
|
243
|
+
export async function runChatBatch<T>(
|
|
244
|
+
items: readonly T[],
|
|
245
|
+
options: ChatBatchOptions<T>,
|
|
246
|
+
): Promise<{ processed: number; floodStop: BatchStop<T> | null }> {
|
|
247
|
+
const now = options.now ?? Date.now
|
|
248
|
+
let processed = 0
|
|
249
|
+
for (const [index, item] of items.entries()) {
|
|
250
|
+
processed += 1
|
|
179
251
|
try {
|
|
180
|
-
|
|
181
|
-
await deps.logger.event({
|
|
182
|
-
type: "action_success",
|
|
183
|
-
chatId: context.chatId,
|
|
184
|
-
action: context.action,
|
|
185
|
-
attempts: attempt,
|
|
186
|
-
elapsedMs: deps.now() - startedAt,
|
|
187
|
-
})
|
|
188
|
-
return result
|
|
252
|
+
await options.processItem(item, index)
|
|
189
253
|
} catch (error) {
|
|
190
|
-
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
}
|
|
202
|
-
throw error
|
|
254
|
+
await options.onItemError(item, error, index)
|
|
255
|
+
const stop = floodStopFor(error, options.maxWaitMs, now())
|
|
256
|
+
if (stop) {
|
|
257
|
+
return {
|
|
258
|
+
processed,
|
|
259
|
+
floodStop: {
|
|
260
|
+
...stop,
|
|
261
|
+
item,
|
|
262
|
+
index,
|
|
263
|
+
remaining: items.length - index - 1,
|
|
264
|
+
},
|
|
265
|
+
}
|
|
203
266
|
}
|
|
204
|
-
|
|
205
|
-
const sleepMs = waitSeconds * 1000 + deps.bufferMs
|
|
206
|
-
await deps.logger.event({
|
|
207
|
-
type: "flood_wait",
|
|
208
|
-
chatId: context.chatId,
|
|
209
|
-
action: context.action,
|
|
210
|
-
attempt,
|
|
211
|
-
waitSeconds,
|
|
212
|
-
sleepMs,
|
|
213
|
-
})
|
|
214
|
-
await deps.sleep(sleepMs)
|
|
215
267
|
}
|
|
268
|
+
if (index < items.length - 1) await options.betweenItems?.(item, index)
|
|
216
269
|
}
|
|
217
|
-
|
|
218
|
-
throw new Error(`unreachable action executor state: ${context.action}`)
|
|
270
|
+
return { processed, floodStop: null }
|
|
219
271
|
}
|