@uniquedivine/tg-sai 0.0.4 → 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/actionExecutor.ts CHANGED
@@ -1,61 +1,44 @@
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
  | {
10
13
  /** Operation attempt started. */
11
- type: "action_start";
14
+ type: "action_start"
12
15
  /** Telegram chat ID or peer for the attempted action. */
13
- chatId: number | string;
16
+ chatId: number | string
14
17
  /** Stable action name, for example `bot_add`. */
15
- 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;
18
+ action: string
32
19
  }
33
20
  | {
34
21
  /** Operation completed successfully. */
35
- type: "action_success";
22
+ type: "action_success"
36
23
  /** Telegram chat ID or peer for the successful action. */
37
- chatId: number | string;
24
+ chatId: number | string
38
25
  /** Stable action name, for example `owner_target_add`. */
39
- action: string;
40
- /** Number of attempts required before success. */
41
- attempts: number;
42
- /** Elapsed milliseconds from first attempt to success. */
43
- elapsedMs: number;
26
+ action: string
27
+ /** Elapsed milliseconds from start to success, flood sleeps included. */
28
+ elapsedMs: number
44
29
  }
45
30
  | {
46
- /** Operation failed with a non-retryable error or exhausted retries. */
47
- type: "action_error";
31
+ /** Operation failed; mtcute already retried any short flood wait. */
32
+ type: "action_error"
48
33
  /** Telegram chat ID or peer for the failed action. */
49
- chatId: number | string;
34
+ chatId: number | string
50
35
  /** Stable action name, for example `transfer_owner`. */
51
- action: string;
52
- /** One-based attempt number that produced the final error. */
53
- attempt: number;
36
+ action: string
54
37
  /** Error name normalized for logs. */
55
- errorName: string;
38
+ errorName: string
56
39
  /** Error message normalized for logs. */
57
- errorMessage: string;
58
- };
40
+ errorMessage: string
41
+ }
59
42
 
60
43
  /**
61
44
  * Receives structured batch action events.
@@ -64,7 +47,7 @@ export type BatchActionEvent =
64
47
  * Tests can use `MemoryBatchLogger` to inspect event order without parsing text.
65
48
  */
66
49
  export interface BatchLogger {
67
- event(event: BatchActionEvent): void | Promise<void>;
50
+ event(event: BatchActionEvent): void | Promise<void>
68
51
  }
69
52
 
70
53
  /**
@@ -72,74 +55,44 @@ export interface BatchLogger {
72
55
  */
73
56
  export interface TelegramActionContext {
74
57
  /** Telegram chat ID or resolvable chat peer for the action. */
75
- chatId: number | string;
58
+ chatId: number | string
76
59
  /** Stable action name such as `bot_add` or `owner_target_promote_admin`. */
77
- action: string;
60
+ action: string
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. */
88
- logger: BatchLogger;
89
- /** Sleep function used after Telegram returns `FLOOD_WAIT_X`. */
90
- sleep: (ms: number) => Promise<void>;
68
+ /** Event sink for action start, success, and error events. */
69
+ logger: BatchLogger
91
70
  /** Clock function used to compute elapsed action time. */
92
- 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;
71
+ now: () => number
97
72
  }
98
73
 
99
74
  /**
100
75
  * Test logger that stores every event in insertion order.
101
76
  */
102
77
  export class MemoryBatchLogger implements BatchLogger {
103
- readonly events: BatchActionEvent[] = [];
78
+ readonly events: BatchActionEvent[] = []
104
79
 
105
80
  /**
106
81
  * Appends an event to the in-memory event list.
107
82
  */
108
83
  event(event: BatchActionEvent): void {
109
- this.events.push(event);
84
+ this.events.push(event)
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
  */
138
91
  export function errorName(error: unknown): string {
139
92
  if (error instanceof Error && error.name) {
140
- return error.name;
93
+ return error.name
141
94
  }
142
- return "UnknownError";
95
+ return "UnknownError"
143
96
  }
144
97
 
145
98
  /**
@@ -147,73 +100,172 @@ export function errorName(error: unknown): string {
147
100
  */
148
101
  export function errorMessage(error: unknown): string {
149
102
  if (error instanceof Error) {
150
- return error.message;
103
+ return error.message
151
104
  }
152
- return String(error);
105
+ return String(error)
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
- const startedAt = deps.now();
169
- const maxAttempts = deps.maxRetries + 1;
170
-
171
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
120
+ const startedAt = deps.now()
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,
177
- });
140
+ errorName: errorName(error),
141
+ errorMessage: errorMessage(error),
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
+ }
178
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
+ }
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
  }
package/adminAudit.ts CHANGED
@@ -1,54 +1,54 @@
1
1
  export interface TestData<T> {
2
- goldReq: string;
3
- notes?: string;
4
- data: T;
2
+ goldReq: string
3
+ notes?: string
4
+ data: T
5
5
  }
6
6
 
7
7
  export interface UserData {
8
- id: number;
9
- username: string | null;
10
- displayName: string;
11
- isBot: boolean;
12
- rawType: string;
8
+ id: number
9
+ username: string | null
10
+ displayName: string
11
+ isBot: boolean
12
+ rawType: string
13
13
  }
14
14
 
15
15
  export interface ChatMemberData {
16
- user: UserData;
17
- status: string;
18
- title: string | null;
19
- invitedBy: UserData | null;
20
- promotedBy: UserData | null;
21
- restrictedBy: UserData | null;
22
- restrictions: unknown | null;
23
- isMember: boolean;
24
- permissions: AdminPermissionsData | null;
25
- rawType: string;
16
+ user: UserData
17
+ status: string
18
+ title: string | null
19
+ invitedBy: UserData | null
20
+ promotedBy: UserData | null
21
+ restrictedBy: UserData | null
22
+ restrictions: unknown | null
23
+ isMember: boolean
24
+ permissions: AdminPermissionsData | null
25
+ rawType: string
26
26
  }
27
27
 
28
28
  export interface AdminPermissionsData {
29
- addAdmins?: boolean;
30
- add_admins?: boolean;
31
- [key: string]: unknown;
29
+ addAdmins?: boolean
30
+ add_admins?: boolean
31
+ [key: string]: unknown
32
32
  }
33
33
 
34
34
  export interface ChatAuditInput {
35
- chatId: number | string;
36
- targetUserId: number | string;
37
- self: UserData;
38
- actor: ChatMemberData | null;
39
- target: ChatMemberData | null;
35
+ chatId: number | string
36
+ targetUserId: number | string
37
+ self: UserData
38
+ actor: ChatMemberData | null
39
+ target: ChatMemberData | null
40
40
  }
41
41
 
42
42
  export interface ChatAuditResult {
43
- chatId: number | string;
44
- targetUserId: number | string;
45
- actorRole: MemberRole;
46
- targetRole: MemberRole;
47
- actorCanApply: boolean;
48
- actorCanTransferOwnership: boolean;
49
- targetCanReceiveOwnership: boolean;
50
- targetIsAdmin: boolean;
51
- proposedActions: ProposedAction[];
43
+ chatId: number | string
44
+ targetUserId: number | string
45
+ actorRole: MemberRole
46
+ targetRole: MemberRole
47
+ actorCanApply: boolean
48
+ actorCanTransferOwnership: boolean
49
+ targetCanReceiveOwnership: boolean
50
+ targetIsAdmin: boolean
51
+ proposedActions: ProposedAction[]
52
52
  }
53
53
 
54
54
  export type MemberRole =
@@ -59,17 +59,17 @@ export type MemberRole =
59
59
  | "banned"
60
60
  | "left"
61
61
  | "not_present"
62
- | "unknown";
62
+ | "unknown"
63
63
 
64
64
  export type ProposedAction =
65
65
  | "skip: actor lacks owner/admin rights"
66
66
  | "add target user"
67
67
  | "promote target user admin"
68
- | "noop: target user already admin";
68
+ | "noop: target user already admin"
69
69
 
70
70
  export function deriveMemberRole(member: ChatMemberData | null): MemberRole {
71
71
  if (!member) {
72
- return "not_present";
72
+ return "not_present"
73
73
  }
74
74
  switch (member.status) {
75
75
  case "creator":
@@ -78,9 +78,9 @@ export function deriveMemberRole(member: ChatMemberData | null): MemberRole {
78
78
  case "restricted":
79
79
  case "banned":
80
80
  case "left":
81
- return member.status;
81
+ return member.status
82
82
  default:
83
- return "unknown";
83
+ return "unknown"
84
84
  }
85
85
  }
86
86
 
@@ -88,49 +88,49 @@ export function hasAddAdminsRight(
88
88
  permissions: AdminPermissionsData | null,
89
89
  ): boolean {
90
90
  if (!permissions) {
91
- return false;
91
+ return false
92
92
  }
93
- return Boolean(permissions.addAdmins || permissions.add_admins);
93
+ return Boolean(permissions.addAdmins || permissions.add_admins)
94
94
  }
95
95
 
96
96
  export function canActorPromoteAdmins(input: ChatAuditInput): boolean {
97
- const actorRole = deriveMemberRole(input.actor);
97
+ const actorRole = deriveMemberRole(input.actor)
98
98
  if (actorRole === "creator") {
99
- return true;
99
+ return true
100
100
  }
101
101
  if (actorRole !== "admin") {
102
- return false;
102
+ return false
103
103
  }
104
- return hasAddAdminsRight(input.actor?.permissions ?? null);
104
+ return hasAddAdminsRight(input.actor?.permissions ?? null)
105
105
  }
106
106
 
107
107
  export function canActorTransferOwnership(input: ChatAuditInput): boolean {
108
- return deriveMemberRole(input.actor) === "creator";
108
+ return deriveMemberRole(input.actor) === "creator"
109
109
  }
110
110
 
111
111
  export function isAdminRole(role: MemberRole): boolean {
112
- return role === "admin" || role === "creator";
112
+ return role === "admin" || role === "creator"
113
113
  }
114
114
 
115
115
  export function planAdminBootstrap(input: ChatAuditInput): ChatAuditResult {
116
- const actorRole = deriveMemberRole(input.actor);
117
- const targetRole = deriveMemberRole(input.target);
118
- const actorCanApply = canActorPromoteAdmins(input);
119
- const actorCanTransferOwnership = canActorTransferOwnership(input);
120
- const targetIsAdmin = isAdminRole(targetRole);
116
+ const actorRole = deriveMemberRole(input.actor)
117
+ const targetRole = deriveMemberRole(input.target)
118
+ const actorCanApply = canActorPromoteAdmins(input)
119
+ const actorCanTransferOwnership = canActorTransferOwnership(input)
120
+ const targetIsAdmin = isAdminRole(targetRole)
121
121
  const targetCanReceiveOwnership =
122
- targetRole === "admin" && input.target?.user.isBot !== true;
122
+ targetRole === "admin" && input.target?.user.isBot !== true
123
123
 
124
- const proposedActions: ProposedAction[] = [];
124
+ const proposedActions: ProposedAction[] = []
125
125
  if (!actorCanApply) {
126
- proposedActions.push("skip: actor lacks owner/admin rights");
126
+ proposedActions.push("skip: actor lacks owner/admin rights")
127
127
  } else if (targetIsAdmin) {
128
- proposedActions.push("noop: target user already admin");
128
+ proposedActions.push("noop: target user already admin")
129
129
  } else {
130
130
  if (targetRole === "not_present" || targetRole === "left") {
131
- proposedActions.push("add target user");
131
+ proposedActions.push("add target user")
132
132
  }
133
- proposedActions.push("promote target user admin");
133
+ proposedActions.push("promote target user admin")
134
134
  }
135
135
 
136
136
  return {
@@ -143,5 +143,5 @@ export function planAdminBootstrap(input: ChatAuditInput): ChatAuditResult {
143
143
  targetCanReceiveOwnership,
144
144
  targetIsAdmin,
145
145
  proposedActions,
146
- };
146
+ }
147
147
  }