@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/main.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  #!/usr/bin/env bun
2
- import { appendFileSync } from "node:fs"
3
2
  import {
4
3
  mkdir,
5
4
  mkdtemp,
@@ -17,7 +16,7 @@ import { Command } from "commander"
17
16
  import { addProvisionCommands } from "./provisioningCli"
18
17
  import { addCustodyWorkerCommands } from "./custodyWorkerCli"
19
18
  import type { BatchLogger } from "./actionExecutor"
20
- import { withFloodWaitRetry } from "./actionExecutor"
19
+ import { runChatBatch, runTelegramAction } from "./actionExecutor"
21
20
  import type {
22
21
  ChatAuditInput,
23
22
  ChatMemberData,
@@ -27,6 +26,17 @@ import { planAdminBootstrap } from "./adminAudit"
27
26
  import { saiBot, USERS } from "./cfg"
28
27
  import { runHealthCheck } from "./health"
29
28
  import { addRuntimeHealthCommands } from "./runtimeHealthCli"
29
+ import { HOLD_ACCOUNT_UNKNOWN, holdErrorSeconds } from "./state/guard"
30
+ import { stateDbPath } from "./state/db"
31
+ import { registerEventsCommand } from "./telemetry/cli"
32
+ import { registerHoldsCommand } from "./state/cli"
33
+ import type { TelemetrySubsystem } from "./telemetry/db"
34
+ import {
35
+ activeTelemetryRun,
36
+ BOOTSTRAP_FLOOD_MAX_WAIT_MS,
37
+ floodMaxWaitMs,
38
+ startTelemetryRun,
39
+ } from "./telemetry/run"
30
40
  import {
31
41
  addTargetToChat,
32
42
  createMtcuteClient,
@@ -53,7 +63,7 @@ import {
53
63
  readConfig,
54
64
  redactedProfile,
55
65
  removeProfile,
56
- resolveTelegramCredentials,
66
+ resolveTelegramCredentialSource,
57
67
  setActiveProfile,
58
68
  } from "./profiles"
59
69
  import type {
@@ -120,12 +130,15 @@ interface SaiBootstrapOptions {
120
130
  targetUser?: number | string
121
131
  }
122
132
 
123
-
124
133
  interface TelegramEnv {
125
134
  apiId: number
126
135
  apiHash: string
127
136
  session: string
128
137
  password: string | null
138
+ /** Saved account user ID, when the profile records one. */
139
+ userId: number | null
140
+ /** Selected profile label, for telemetry. */
141
+ profileName: string | null
129
142
  }
130
143
 
131
144
  interface ProfileAddOptions {
@@ -185,17 +198,14 @@ const ownerTargetAdminRights = {
185
198
  }
186
199
 
187
200
  const DEFAULT_INTER_CHAT_DELAY_MS = 1000
188
- let activeJsonLogPath: string | null = null
189
201
 
190
202
  /**
191
- * Emits one structured JSON object to stdout and the active JSONL log, if set.
203
+ * Emits one structured JSON object to stdout. Durable records live in the
204
+ * telemetry DB (`tg-sai events`); stdout is for the operator.
192
205
  */
193
206
  function emitJsonObject(fields: Record<string, unknown>): void {
194
- const line = JSON.stringify({ ts: new Date().toISOString(), ...fields })
195
- console.log(line)
196
- if (activeJsonLogPath) {
197
- appendFileSync(activeJsonLogPath, `${line}\n`, "utf8")
198
- }
207
+ const object = { ts: new Date().toISOString(), ...fields }
208
+ console.log(JSON.stringify(object))
199
209
  }
200
210
 
201
211
  /**
@@ -218,15 +228,13 @@ function sleep(ms: number): Promise<void> {
218
228
  }
219
229
 
220
230
  /**
221
- * Builds the default retry dependencies for live Telegram actions.
231
+ * Progress-logging dependencies for live Telegram actions. Flood waits are
232
+ * handled by mtcute's flood waiter, not here.
222
233
  */
223
234
  function actionExecutorDeps(logger: BatchLogger = consoleBatchLogger) {
224
235
  return {
225
236
  logger,
226
- sleep,
227
237
  now: () => Date.now(),
228
- maxRetries: 3,
229
- bufferMs: 2000,
230
238
  }
231
239
  }
232
240
 
@@ -249,19 +257,21 @@ function printJsonEvent(type: string, fields: Record<string, unknown>): void {
249
257
  }
250
258
 
251
259
  /**
252
- * Returns the directory for durable tg-sai run and discovery logs.
260
+ * Directory for `ops sai-find` chat files. These are command output (chat
261
+ * lists for `ops sai-bootstrap --file`), not logs; logs live in the
262
+ * telemetry DB.
253
263
  */
254
- function logsDir(): string {
264
+ function chatFilesDir(): string {
255
265
  return join(configDir(), "logs")
256
266
  }
257
267
 
258
268
  /**
259
- * Chooses the next numbered JSONL log path for a named command prefix.
269
+ * Chooses the next numbered chat file path for a named command prefix.
260
270
  *
261
271
  * Example output paths are `sai-find-00.jsonl`, `sai-find-01.jsonl`, and so on.
262
272
  */
263
- async function nextJsonlLogPath(prefix: string): Promise<string> {
264
- const dir = logsDir()
273
+ async function nextChatFilePath(prefix: string): Promise<string> {
274
+ const dir = chatFilesDir()
265
275
  await mkdir(dir, { recursive: true, mode: 0o700 })
266
276
 
267
277
  const files = await readdir(dir).catch((error) => {
@@ -288,7 +298,8 @@ async function nextJsonlLogPath(prefix: string): Promise<string> {
288
298
  }
289
299
 
290
300
  async function getTelegramEnv(profileName?: string): Promise<TelegramEnv> {
291
- const credentials = await resolveTelegramCredentials(profileName)
301
+ const { credentials, source } =
302
+ await resolveTelegramCredentialSource(profileName)
292
303
  const config = await readConfig()
293
304
  const profile = getProfile(config, profileName)
294
305
  return {
@@ -296,9 +307,20 @@ async function getTelegramEnv(profileName?: string): Promise<TelegramEnv> {
296
307
  apiHash: credentials.apiHash,
297
308
  session: credentials.sessionString,
298
309
  password: process.env.TELEGRAM_PASSWORD ?? profile?.password ?? null,
310
+ // TELEGRAM_* or credential-file sessions may act as another account.
311
+ userId: source === "profile" ? (profile?.userId ?? null) : null,
312
+ profileName: profile?.name || profileName || null,
299
313
  }
300
314
  }
301
315
 
316
+ /** Telegram telemetry for the current CLI run, if one was started. */
317
+ function cliTelemetry(identity: {
318
+ accountId?: number | null
319
+ profile?: string | null
320
+ } = {}) {
321
+ return activeTelemetryRun()?.client(identity)
322
+ }
323
+
302
324
  async function selectedProfileName(
303
325
  profileName?: string,
304
326
  ): Promise<string | null> {
@@ -373,11 +395,16 @@ async function withClient<T>(
373
395
  ) => Promise<T>,
374
396
  ): Promise<T> {
375
397
  const env = await getTelegramEnv(profileName)
376
- const client = await createMtcuteClient({
377
- apiId: env.apiId,
378
- apiHash: env.apiHash,
379
- session: env.session,
380
- })
398
+ // No `accountId`: the flood guard keys holds by the account this session's
399
+ // own getMe reports, never by profile metadata.
400
+ const client = await createMtcuteClient(
401
+ {
402
+ apiId: env.apiId,
403
+ apiHash: env.apiHash,
404
+ session: env.session,
405
+ },
406
+ cliTelemetry({ accountId: env.userId, profile: env.profileName }),
407
+ )
381
408
 
382
409
  try {
383
410
  return await fn(client, env)
@@ -397,13 +424,19 @@ async function runAudit(options: BaseCliOptions): Promise<void> {
397
424
  }
398
425
 
399
426
  async function runHealth(): Promise<void> {
427
+ // runHealthCheck connects as the active (first) profile. Its saved
428
+ // userId only labels telemetry; the flood guard keys by the session's getMe.
429
+ const accountId = (await readConfig()).profiles[0]?.userId ?? null
400
430
  const result = await runHealthCheck({
401
431
  createClient: async (credentials) =>
402
- createMtcuteClient({
403
- apiId: credentials.apiId,
404
- apiHash: credentials.apiHash,
405
- session: credentials.sessionString,
406
- }),
432
+ createMtcuteClient(
433
+ {
434
+ apiId: credentials.apiId,
435
+ apiHash: credentials.apiHash,
436
+ session: credentials.sessionString,
437
+ },
438
+ cliTelemetry({ accountId }),
439
+ ),
407
440
  })
408
441
  for (const line of result.lines) {
409
442
  console.log(line)
@@ -591,13 +624,13 @@ async function runSaiFind(options: SaiFindOptions): Promise<void> {
591
624
  })
592
625
  const lines = records.map(formatSaiChatJsonLine)
593
626
  const contents = `${lines.join("\n")}\n`
594
- const logPath = await nextJsonlLogPath("sai-find")
627
+ const chatFilePath = await nextChatFilePath("sai-find")
595
628
 
596
- await writeFile(logPath, contents, {
629
+ await writeFile(chatFilePath, contents, {
597
630
  encoding: "utf8",
598
631
  mode: 0o600,
599
632
  })
600
- console.log(`wrote_chat_file: ${logPath}`)
633
+ console.log(`wrote_chat_file: ${chatFilePath}`)
601
634
  if (options.out) {
602
635
  await writeFile(options.out, contents, {
603
636
  encoding: "utf8",
@@ -659,7 +692,7 @@ async function applyTargetPlan(params: {
659
692
 
660
693
  const deps = actionExecutorDeps(actorBatchLogger(params.actorUsername))
661
694
  if (shouldRunAction(beforePlan.proposedActions, "add target user")) {
662
- await withFloodWaitRetry(
695
+ await runTelegramAction(
663
696
  {
664
697
  chatId: params.chat.chatId,
665
698
  action: `${params.actionPrefix}_add`,
@@ -676,7 +709,7 @@ async function applyTargetPlan(params: {
676
709
  if (
677
710
  shouldRunAction(beforePlan.proposedActions, "promote target user admin")
678
711
  ) {
679
- await withFloodWaitRetry(
712
+ await runTelegramAction(
680
713
  {
681
714
  chatId: params.chat.chatId,
682
715
  action: `${params.actionPrefix}_promote_admin`,
@@ -751,7 +784,7 @@ async function maybeTransferOwner(params: {
751
784
  return
752
785
  }
753
786
 
754
- await withFloodWaitRetry(
787
+ await runTelegramAction(
755
788
  {
756
789
  chatId: params.chat.chatId,
757
790
  action: "transfer_owner",
@@ -771,7 +804,11 @@ async function maybeTransferOwner(params: {
771
804
  *
772
805
  * The command is dry-run by default. In execution mode, it applies the Sai bot
773
806
  * update and owner-target update for each chat, logs per-chat failures, and
774
- * continues to the next chat.
807
+ * continues to the next chat. A `PEER_FLOOD` or a flood wait longer than the
808
+ * run's threshold stops the batch with a `flood_stop` event and exit code 1.
809
+ * A refusal by the local flood guard (a saved hold, unreadable hold state,
810
+ * or an unverified session account) stops it the same way with exit 75.
811
+ * `SLOWMODE_WAIT_X` only fails that chat.
775
812
  */
776
813
  async function runSaiBootstrap(options: SaiBootstrapOptions): Promise<void> {
777
814
  const contents = await readFile(options.file, "utf8")
@@ -780,24 +817,20 @@ async function runSaiBootstrap(options: SaiBootstrapOptions): Promise<void> {
780
817
  if (!Number.isFinite(delayMs) || delayMs < 0) {
781
818
  throw new Error("--delay-ms must be a non-negative number")
782
819
  }
783
- const logPath = await nextJsonlLogPath("sai-bootstrap")
784
- await writeFile(logPath, "", {
785
- encoding: "utf8",
786
- mode: 0o600,
787
- })
820
+ // Telegram calls are in the telemetry DB under this run id.
821
+ const runId = activeTelemetryRun()?.context.runId ?? null
788
822
 
789
823
  await withClient(options.profile, async (client, env) => {
790
- activeJsonLogPath = logPath
791
824
  const actor = normalizeUser(await client.getMe())
792
825
  const actorUsername = actor.username ?? ""
793
826
  const profileName = await selectedProfileName(options.profile)
794
827
  const ownerTarget = resolveBootstrapTarget(options.targetUser)
795
828
  const ownershipTransferEnabled =
796
829
  options.transferOwner !== false && env.password !== null
797
- console.log(`wrote_run_log: ${logPath}`)
830
+ if (runId) console.log(`telemetry_run_id: ${runId}`)
798
831
  printJsonEvent("run_start", {
799
832
  file: options.file,
800
- logPath,
833
+ runId,
801
834
  profileName,
802
835
  actor,
803
836
  chatCount: chats.length,
@@ -809,8 +842,12 @@ async function runSaiBootstrap(options: SaiBootstrapOptions): Promise<void> {
809
842
  targetBot: saiBot.id,
810
843
  })
811
844
 
812
- try {
813
- for (const [i, chat] of chats.entries()) {
845
+ const maxWaitMs =
846
+ activeTelemetryRun()?.floodMaxWaitMs ??
847
+ floodMaxWaitMs("cli", process.env, BOOTSTRAP_FLOOD_MAX_WAIT_MS)
848
+ const { floodStop } = await runChatBatch(chats, {
849
+ maxWaitMs,
850
+ processItem: async (chat, i) => {
814
851
  printJsonEvent("chat_start", {
815
852
  index: i + 1,
816
853
  total: chats.length,
@@ -818,69 +855,84 @@ async function runSaiBootstrap(options: SaiBootstrapOptions): Promise<void> {
818
855
  title: chat.title,
819
856
  actorUsername,
820
857
  })
858
+ await applyTargetPlan({
859
+ client,
860
+ chat,
861
+ actorUsername,
862
+ targetUserId: saiBot.id,
863
+ targetPeer: saiBot.handle,
864
+ rights: saiBotAdminRights,
865
+ actionPrefix: "bot",
866
+ run: options.run === true,
867
+ })
868
+ await applyTargetPlan({
869
+ client,
870
+ chat,
871
+ actorUsername,
872
+ targetUserId: ownerTarget,
873
+ targetPeer: ownerTarget,
874
+ rights: ownerTargetAdminRights,
875
+ actionPrefix: "owner_target",
876
+ run: options.run === true,
877
+ })
878
+ await maybeTransferOwner({
879
+ client,
880
+ env,
881
+ chat,
882
+ actorUsername,
883
+ targetUser: ownerTarget,
884
+ enabled: ownershipTransferEnabled,
885
+ run: options.run === true,
886
+ })
887
+ printJsonEvent("chat_complete", {
888
+ chatId: chat.chatId,
889
+ actorUsername,
890
+ })
891
+ },
892
+ onItemError: (chat, error) => {
893
+ printJsonEvent("chat_error", {
894
+ chatId: chat.chatId,
895
+ actorUsername,
896
+ errorName: error instanceof Error ? error.name : "UnknownError",
897
+ errorMessage:
898
+ error instanceof Error ? error.message : String(error),
899
+ })
900
+ },
901
+ betweenItems: async (chat) => {
902
+ if (delayMs <= 0) return
903
+ printJsonEvent("chat_delay", {
904
+ chatId: chat.chatId,
905
+ actorUsername,
906
+ delayMs,
907
+ })
908
+ await sleep(delayMs)
909
+ },
910
+ })
821
911
 
822
- try {
823
- await applyTargetPlan({
824
- client,
825
- chat,
826
- actorUsername,
827
- targetUserId: saiBot.id,
828
- targetPeer: saiBot.handle,
829
- rights: saiBotAdminRights,
830
- actionPrefix: "bot",
831
- run: options.run === true,
832
- })
833
- await applyTargetPlan({
834
- client,
835
- chat,
836
- actorUsername,
837
- targetUserId: ownerTarget,
838
- targetPeer: ownerTarget,
839
- rights: ownerTargetAdminRights,
840
- actionPrefix: "owner_target",
841
- run: options.run === true,
842
- })
843
- await maybeTransferOwner({
844
- client,
845
- env,
846
- chat,
847
- actorUsername,
848
- targetUser: ownerTarget,
849
- enabled: ownershipTransferEnabled,
850
- run: options.run === true,
851
- })
852
- printJsonEvent("chat_complete", {
853
- chatId: chat.chatId,
854
- actorUsername,
855
- })
856
- } catch (error) {
857
- printJsonEvent("chat_error", {
858
- chatId: chat.chatId,
859
- actorUsername,
860
- errorName: error instanceof Error ? error.name : "UnknownError",
861
- errorMessage:
862
- error instanceof Error ? error.message : String(error),
863
- })
864
- }
865
-
866
- if (delayMs > 0 && i < chats.length - 1) {
867
- printJsonEvent("chat_delay", {
868
- chatId: chat.chatId,
869
- actorUsername,
870
- delayMs,
871
- })
872
- await sleep(delayMs)
873
- }
874
- }
875
-
876
- printJsonEvent("run_complete", {
877
- file: options.file,
878
- logPath,
879
- chatCount: chats.length,
912
+ if (floodStop) {
913
+ // The rest of the batch would hit the same account-wide limit.
914
+ printJsonEvent("flood_stop", {
915
+ chatId: floodStop.item.chatId,
916
+ actorUsername,
917
+ index: floodStop.index + 1,
918
+ total: chats.length,
919
+ remaining: floodStop.remaining,
920
+ code: floodStop.code,
921
+ waitS: floodStop.waitS,
922
+ resumeAt: floodStop.resumeAt,
923
+ hold: floodStop.hold,
880
924
  })
881
- } finally {
882
- activeJsonLogPath = null
925
+ // A saved hold is a retry-later stop (75); Telegram's own long wait
926
+ // or PEER_FLOOD keeps exit 1.
927
+ process.exitCode = floodStop.hold ? HOLD_EXIT_CODE : 1
928
+ return
883
929
  }
930
+
931
+ printJsonEvent("run_complete", {
932
+ file: options.file,
933
+ runId,
934
+ chatCount: chats.length,
935
+ })
884
936
  })
885
937
  }
886
938
 
@@ -1253,16 +1305,24 @@ async function inputFromProfileAddOptions(
1253
1305
  }
1254
1306
  }
1255
1307
 
1308
+ const knownUserId =
1309
+ options.userId && /^\d+$/.test(options.userId)
1310
+ ? Number(options.userId)
1311
+ : undefined
1256
1312
  const completeProfileFields: TelegramCredentials = {
1257
1313
  apiId: profileFields.apiId,
1258
1314
  apiHash: profileFields.apiHash,
1259
1315
  sessionString: profileFields.sessionString,
1260
1316
  }
1261
- const client = await createMtcuteClient({
1262
- apiId: completeProfileFields.apiId,
1263
- apiHash: completeProfileFields.apiHash,
1264
- session: completeProfileFields.sessionString,
1265
- })
1317
+ const client = await createMtcuteClient(
1318
+ {
1319
+ apiId: completeProfileFields.apiId,
1320
+ apiHash: completeProfileFields.apiHash,
1321
+ session: completeProfileFields.sessionString,
1322
+ // --user-id is unverified, so the guard keys by the session's getMe.
1323
+ },
1324
+ cliTelemetry({ accountId: knownUserId ?? null, profile: options.name ?? null }),
1325
+ )
1266
1326
  try {
1267
1327
  const self = await client.getMe()
1268
1328
  return profileInputFromUser(
@@ -1462,6 +1522,10 @@ export function createProgram(): Command {
1462
1522
  program.help({ error: false })
1463
1523
  })
1464
1524
 
1525
+ program.hook("preAction", (_root, actionCommand) => {
1526
+ startCliTelemetryRun(actionCommand)
1527
+ })
1528
+
1465
1529
  addRuntimeHealthCommands(program, runHealth)
1466
1530
 
1467
1531
  program
@@ -1677,12 +1741,252 @@ If apiId/apiHash are missing, create a Telegram API app at:
1677
1741
 
1678
1742
  addProvisionCommands(program)
1679
1743
  addCustodyWorkerCommands(program)
1744
+ registerEventsCommand(program)
1745
+ registerHoldsCommand(program)
1746
+ installBareHelp(program)
1680
1747
  return program
1681
1748
  }
1682
1749
 
1683
- createProgram()
1684
- .parseAsync()
1685
- .catch((error) => {
1686
- console.error(error)
1687
- process.exit(1)
1750
+ /** Space-joined subcommand path below the root, e.g. `ops sai-find`. */
1751
+ function commandPath(command: Command): string {
1752
+ const names: string[] = []
1753
+ for (let c: Command | null = command; c?.parent; c = c.parent) {
1754
+ names.unshift(c.name())
1755
+ }
1756
+ return names.join(" ")
1757
+ }
1758
+
1759
+ /** Telemetry subsystem for a subcommand path. */
1760
+ function subsystemFor(path: string): TelemetrySubsystem {
1761
+ if (path === "worker") return "worker"
1762
+ if (path === "health") return "health"
1763
+ return "cli"
1764
+ }
1765
+
1766
+ /** Commands that only read telemetry; they do not record runs of their own. */
1767
+ function skipsTelemetry(path: string): boolean {
1768
+ return path === "" || path === "events" || path.startsWith("events ")
1769
+ }
1770
+
1771
+ /** Exit code for a run stopped by SIGINT/SIGTERM (128 + SIGINT). */
1772
+ const SIGNAL_EXIT_CODE = 130
1773
+
1774
+ /**
1775
+ * Ends the run as aborted on the first SIGINT/SIGTERM, then exits. The
1776
+ * `worker` command installs its own handlers for a graceful stop.
1777
+ */
1778
+ function endRunOnSignal(path: string): void {
1779
+ if (path === "worker") return
1780
+ const onSignal = () => {
1781
+ process.removeListener("SIGINT", onSignal)
1782
+ process.removeListener("SIGTERM", onSignal)
1783
+ activeTelemetryRun()?.end({
1784
+ exitCode: SIGNAL_EXIT_CODE,
1785
+ outcome: "aborted",
1786
+ })
1787
+ process.exit(SIGNAL_EXIT_CODE)
1788
+ }
1789
+ process.once("SIGINT", onSignal)
1790
+ process.once("SIGTERM", onSignal)
1791
+ }
1792
+
1793
+ function startCliTelemetryRun(actionCommand: Command): void {
1794
+ const path = commandPath(actionCommand)
1795
+ if (helpOnlyCommands.has(actionCommand)) return
1796
+ if (skipsTelemetry(path) || activeTelemetryRun()) return
1797
+ startTelemetryRun({
1798
+ subsystem: subsystemFor(path),
1799
+ command: path,
1800
+ argv: process.argv.slice(2),
1801
+ version: packageJson.version,
1802
+ ...(path === "ops sai-bootstrap"
1803
+ ? { floodMaxWaitMs: BOOTSTRAP_FLOOD_MAX_WAIT_MS }
1804
+ : {}),
1805
+ })
1806
+ endRunOnSignal(path)
1807
+ }
1808
+
1809
+ // ---------------------------------------------------------------------------
1810
+ // Bare commands print help
1811
+
1812
+ /** Groups whose only action prints help; they record no telemetry run. */
1813
+ const helpOnlyCommands = new WeakSet<Command>()
1814
+
1815
+ /** Commander keeps the action handler private; read it without casting. */
1816
+ function hasAction(command: Command): boolean {
1817
+ return (command as unknown as { _actionHandler?: unknown })._actionHandler
1818
+ ? true
1819
+ : false
1820
+ }
1821
+
1822
+ /**
1823
+ * Makes every command group without an action print its help when called
1824
+ * bare, with exit code 0 (commander's default is help on stderr, exit 1).
1825
+ */
1826
+ export function installBareHelp(command: Command): void {
1827
+ for (const sub of command.commands) installBareHelp(sub)
1828
+ if (command.commands.length === 0 || hasAction(command)) return
1829
+ helpOnlyCommands.add(command)
1830
+ command.action(() => {
1831
+ command.help({ error: false })
1688
1832
  })
1833
+ }
1834
+
1835
+ /** True when the command has a required argument or mandatory option. */
1836
+ export function hasRequiredInput(command: Command): boolean {
1837
+ return (
1838
+ command.registeredArguments.some((arg) => arg.required) ||
1839
+ command.options.some((option) => option.mandatory)
1840
+ )
1841
+ }
1842
+
1843
+ /**
1844
+ * The leaf command `args` name when it has required input and `args` hold
1845
+ * nothing past its name, e.g. `tg-sai audit`. Such a call prints that
1846
+ * command's help instead of a missing-option error. Any extra token, even a
1847
+ * wrong one, returns null so commander reports the usual error.
1848
+ */
1849
+ export function bareHelpTarget(
1850
+ program: Command,
1851
+ args: readonly string[],
1852
+ ): Command | null {
1853
+ let command = program
1854
+ for (const arg of args) {
1855
+ const next = command.commands.find(
1856
+ (sub) => sub.name() === arg || sub.aliases().includes(arg),
1857
+ )
1858
+ if (!next) return null
1859
+ command = next
1860
+ }
1861
+ if (command === program || command.commands.length > 0) return null
1862
+ return hasRequiredInput(command) ? command : null
1863
+ }
1864
+
1865
+ // ---------------------------------------------------------------------------
1866
+ // Exit codes
1867
+
1868
+ /** `EX_TEMPFAIL`: retry later. Used while Telegram limits the account. */
1869
+ export const HOLD_EXIT_CODE = 75
1870
+ const HOLD_STATE_UNAVAILABLE = "TG_SAI_HOLD_STATE_UNAVAILABLE"
1871
+
1872
+ export interface HoldExit {
1873
+ exitCode: typeof HOLD_EXIT_CODE
1874
+ /** One line for stderr. */
1875
+ message: string
1876
+ }
1877
+
1878
+ interface HoldLike {
1879
+ code?: unknown
1880
+ text?: unknown
1881
+ message?: unknown
1882
+ untilMs?: unknown
1883
+ grp?: unknown
1884
+ cause?: unknown
1885
+ }
1886
+
1887
+ function holdText(error: HoldLike): string | null {
1888
+ if (typeof error.text === "string") return error.text
1889
+ if (typeof error.message === "string") return error.message
1890
+ return null
1891
+ }
1892
+
1893
+ function holdLike(value: unknown): HoldLike | null {
1894
+ return typeof value === "object" && value !== null
1895
+ ? (value as HoldLike)
1896
+ : null
1897
+ }
1898
+
1899
+ /** UTC timestamp without milliseconds, e.g. `2026-11-06T16:20:05Z`. */
1900
+ function utcSeconds(ms: number): string {
1901
+ return new Date(ms).toISOString().replace(/\.\d{3}Z$/, "Z")
1902
+ }
1903
+
1904
+ /** `4h 16m`, `12m 5s`, or `40s`. */
1905
+ export function shortDuration(totalS: number): string {
1906
+ const s = Math.max(0, Math.round(totalS))
1907
+ const h = Math.floor(s / 3600)
1908
+ const m = Math.floor((s % 3600) / 60)
1909
+ if (h > 0) return `${h}h ${m}m`
1910
+ if (m > 0) return `${m}m ${s % 60}s`
1911
+ return `${s}s`
1912
+ }
1913
+
1914
+ /**
1915
+ * Maps a saved flood hold (`HoldError`, `TG_SAI_HOLD_<s>`, code 420), an
1916
+ * unreadable `state.db` (`TG_SAI_HOLD_STATE_UNAVAILABLE`), or an unverified
1917
+ * session account (`TG_SAI_HOLD_ACCOUNT_UNKNOWN`) to exit 75 with a
1918
+ * one-line message. Looks through `error.cause` chains. Returns null for
1919
+ * every other error.
1920
+ */
1921
+ export function holdExitFor(
1922
+ error: unknown,
1923
+ nowMs: number = Date.now(),
1924
+ ): HoldExit | null {
1925
+ let current = holdLike(error)
1926
+ for (let depth = 0; current && depth < 8; depth += 1) {
1927
+ const text = holdText(current)
1928
+ if (text === HOLD_STATE_UNAVAILABLE) {
1929
+ return {
1930
+ exitCode: HOLD_EXIT_CODE,
1931
+ message:
1932
+ "tg-sai: cannot read saved Telegram limits in " +
1933
+ `${stateDbPath()}; invite/create calls stay blocked and nothing ` +
1934
+ "was sent. Fix the file, then retry.",
1935
+ }
1936
+ }
1937
+ if (text === HOLD_ACCOUNT_UNKNOWN) {
1938
+ return {
1939
+ exitCode: HOLD_EXIT_CODE,
1940
+ message:
1941
+ "tg-sai: could not confirm which Telegram account this session " +
1942
+ "acts as, so its saved limits cannot be checked; invite/create " +
1943
+ "calls stay blocked and nothing was sent. Retry once getMe works.",
1944
+ }
1945
+ }
1946
+ const seconds = holdErrorSeconds(text)
1947
+ const codeOk = current.code === undefined || current.code === 420
1948
+ if (seconds !== null && codeOk) {
1949
+ const untilMs =
1950
+ typeof current.untilMs === "number" && Number.isFinite(current.untilMs)
1951
+ ? current.untilMs
1952
+ : nowMs + seconds * 1000
1953
+ const grp = typeof current.grp === "string" ? current.grp : "this"
1954
+ const waitS = Math.max(0, Math.ceil((untilMs - nowMs) / 1000))
1955
+ return {
1956
+ exitCode: HOLD_EXIT_CODE,
1957
+ message:
1958
+ `tg-sai: Telegram is limiting ${grp} calls for this account; ` +
1959
+ `nothing was sent. Resume after ${utcSeconds(untilMs)} ` +
1960
+ `(in ${shortDuration(waitS)}).`,
1961
+ }
1962
+ }
1963
+ current = holdLike(current.cause)
1964
+ }
1965
+ return null
1966
+ }
1967
+
1968
+ // ---------------------------------------------------------------------------
1969
+ // Entry point
1970
+
1971
+ async function main(): Promise<void> {
1972
+ const program = createProgram()
1973
+ const args = process.argv.slice(2)
1974
+ const helpTarget = bareHelpTarget(program, args)
1975
+ if (helpTarget) {
1976
+ helpTarget.outputHelp()
1977
+ return
1978
+ }
1979
+ try {
1980
+ await program.parseAsync()
1981
+ } catch (error) {
1982
+ const hold = holdExitFor(error)
1983
+ const exitCode = hold?.exitCode ?? 1
1984
+ activeTelemetryRun()?.end({ exitCode, error })
1985
+ if (hold) console.error(hold.message)
1986
+ else console.error(error)
1987
+ process.exit(exitCode)
1988
+ }
1989
+ activeTelemetryRun()?.end({ exitCode: Number(process.exitCode ?? 0) })
1990
+ }
1991
+
1992
+ if (import.meta.main) await main()