@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 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 under directory
236
- `~/.tg-sai/logs/`, using the next available filename such as
237
- `sai-find-00.jsonl` or `sai-find-01.jsonl`. Use flag `--out <path>` to also
238
- write a copy somewhere else.
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` always writes a JSONL run log under directory
247
- `~/.tg-sai/logs/`, using the next available filename such as
248
- `sai-bootstrap-00.jsonl` or `sai-bootstrap-01.jsonl`. The command waits one
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 objects are the source of truth for both test assertions and production
5
- * JSONL logs. Keep event payloads stable enough that a failed or interrupted
6
- * operator run can be inspected after the fact.
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
- /** Number of attempts required before success. */
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 with a non-retryable error or exhausted retries. */
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 `withFloodWaitRetry`.
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, error, and flood-wait events. */
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 with `FLOOD_WAIT_X` retry handling.
109
+ * Runs one Telegram operation once and reports start, success, or error.
157
110
  *
158
- * The operation is passed as a thunk so the executor can call the exact same
159
- * Telegram action after waiting. Non-flood errors and exhausted retry attempts
160
- * are logged as `action_error` and then rethrown for the batch runner to record
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 withFloodWaitRetry<T>(
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
- const maxAttempts = deps.maxRetries + 1
170
-
171
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
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: "action_start",
137
+ type: "action_error",
174
138
  chatId: context.chatId,
175
139
  action: context.action,
176
- attempt,
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
- const result = await operation()
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
- const waitSeconds = parseFloodWaitSeconds(error)
191
- const canRetry = waitSeconds !== null && attempt < maxAttempts
192
-
193
- if (!canRetry) {
194
- await deps.logger.event({
195
- type: "action_error",
196
- chatId: context.chatId,
197
- action: context.action,
198
- attempt,
199
- errorName: errorName(error),
200
- errorMessage: errorMessage(error),
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
  }