@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/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,
@@ -11,10 +10,13 @@ import {
11
10
  import { createRequire } from "node:module"
12
11
  import { tmpdir } from "node:os"
13
12
  import { join } from "node:path"
13
+ import { stdin as input, stdout as output } from "node:process"
14
+ import { createInterface } from "node:readline/promises"
14
15
  import { Command } from "commander"
15
-
16
+ import { addProvisionCommands } from "./provisioningCli"
17
+ import { addCustodyWorkerCommands } from "./custodyWorkerCli"
16
18
  import type { BatchLogger } from "./actionExecutor"
17
- import { withFloodWaitRetry } from "./actionExecutor"
19
+ import { runChatBatch, runTelegramAction } from "./actionExecutor"
18
20
  import type {
19
21
  ChatAuditInput,
20
22
  ChatMemberData,
@@ -23,6 +25,18 @@ import type {
23
25
  import { planAdminBootstrap } from "./adminAudit"
24
26
  import { saiBot, USERS } from "./cfg"
25
27
  import { runHealthCheck } from "./health"
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"
26
40
  import {
27
41
  addTargetToChat,
28
42
  createMtcuteClient,
@@ -32,18 +46,24 @@ import {
32
46
  promoteTargetAdmin,
33
47
  transferTargetOwnership,
34
48
  } from "./mtcuteAdapter"
35
- import { formatSaiChatJsonLine, parseChatInputFile } from "./ops"
49
+ import {
50
+ formatSaiChatJsonLine,
51
+ parseChatInputFile,
52
+ resolveBootstrapTarget,
53
+ } from "./ops"
36
54
  import type { ChatInput } from "./ops"
37
55
  import {
38
56
  addProfile,
39
57
  configDir,
40
58
  discoveryReport,
59
+ findProfileIndex,
41
60
  getProfile,
42
61
  profileInputFromUser,
62
+ profileLabel,
43
63
  readConfig,
44
64
  redactedProfile,
45
65
  removeProfile,
46
- resolveTelegramCredentials,
66
+ resolveTelegramCredentialSource,
47
67
  setActiveProfile,
48
68
  } from "./profiles"
49
69
  import type {
@@ -106,6 +126,8 @@ interface SaiBootstrapOptions {
106
126
  run?: boolean
107
127
  /** Attempt ownership transfer when the Telegram password is available. */
108
128
  transferOwner?: boolean
129
+ /** Owner target: user ID or username. Defaults to the Sai custody account. */
130
+ targetUser?: number | string
109
131
  }
110
132
 
111
133
  interface TelegramEnv {
@@ -113,10 +135,14 @@ interface TelegramEnv {
113
135
  apiHash: string
114
136
  session: string
115
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
116
142
  }
117
143
 
118
144
  interface ProfileAddOptions {
119
- name: string
145
+ name?: string
120
146
  apiId?: string
121
147
  apiHash?: string
122
148
  sessionString?: string
@@ -128,6 +154,7 @@ interface ProfileAddOptions {
128
154
  login?: boolean
129
155
  qr?: boolean
130
156
  phone?: boolean
157
+ interactive?: boolean
131
158
  }
132
159
 
133
160
  const saiBotAdminRights = {
@@ -171,17 +198,14 @@ const ownerTargetAdminRights = {
171
198
  }
172
199
 
173
200
  const DEFAULT_INTER_CHAT_DELAY_MS = 1000
174
- let activeJsonLogPath: string | null = null
175
201
 
176
202
  /**
177
- * 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.
178
205
  */
179
206
  function emitJsonObject(fields: Record<string, unknown>): void {
180
- const line = JSON.stringify({ ts: new Date().toISOString(), ...fields })
181
- console.log(line)
182
- if (activeJsonLogPath) {
183
- appendFileSync(activeJsonLogPath, `${line}\n`, "utf8")
184
- }
207
+ const object = { ts: new Date().toISOString(), ...fields }
208
+ console.log(JSON.stringify(object))
185
209
  }
186
210
 
187
211
  /**
@@ -204,15 +228,13 @@ function sleep(ms: number): Promise<void> {
204
228
  }
205
229
 
206
230
  /**
207
- * 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.
208
233
  */
209
234
  function actionExecutorDeps(logger: BatchLogger = consoleBatchLogger) {
210
235
  return {
211
236
  logger,
212
- sleep,
213
237
  now: () => Date.now(),
214
- maxRetries: 3,
215
- bufferMs: 2000,
216
238
  }
217
239
  }
218
240
 
@@ -235,19 +257,21 @@ function printJsonEvent(type: string, fields: Record<string, unknown>): void {
235
257
  }
236
258
 
237
259
  /**
238
- * 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.
239
263
  */
240
- function logsDir(): string {
264
+ function chatFilesDir(): string {
241
265
  return join(configDir(), "logs")
242
266
  }
243
267
 
244
268
  /**
245
- * 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.
246
270
  *
247
271
  * Example output paths are `sai-find-00.jsonl`, `sai-find-01.jsonl`, and so on.
248
272
  */
249
- async function nextJsonlLogPath(prefix: string): Promise<string> {
250
- const dir = logsDir()
273
+ async function nextChatFilePath(prefix: string): Promise<string> {
274
+ const dir = chatFilesDir()
251
275
  await mkdir(dir, { recursive: true, mode: 0o700 })
252
276
 
253
277
  const files = await readdir(dir).catch((error) => {
@@ -274,7 +298,8 @@ async function nextJsonlLogPath(prefix: string): Promise<string> {
274
298
  }
275
299
 
276
300
  async function getTelegramEnv(profileName?: string): Promise<TelegramEnv> {
277
- const credentials = await resolveTelegramCredentials(profileName)
301
+ const { credentials, source } =
302
+ await resolveTelegramCredentialSource(profileName)
278
303
  const config = await readConfig()
279
304
  const profile = getProfile(config, profileName)
280
305
  return {
@@ -282,15 +307,29 @@ async function getTelegramEnv(profileName?: string): Promise<TelegramEnv> {
282
307
  apiHash: credentials.apiHash,
283
308
  session: credentials.sessionString,
284
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,
285
313
  }
286
314
  }
287
315
 
288
- async function selectedProfileName(profileName?: string): Promise<string | null> {
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
+
324
+ async function selectedProfileName(
325
+ profileName?: string,
326
+ ): Promise<string | null> {
289
327
  if (profileName) {
290
328
  return profileName
291
329
  }
292
330
  const config = await readConfig()
293
- return config.activeProfile
331
+ const profile = config.profiles[0]
332
+ return profile ? profileLabel(profile, 0) : null
294
333
  }
295
334
 
296
335
  function parsePeer(value: string): number | string {
@@ -356,11 +395,16 @@ async function withClient<T>(
356
395
  ) => Promise<T>,
357
396
  ): Promise<T> {
358
397
  const env = await getTelegramEnv(profileName)
359
- const client = await createMtcuteClient({
360
- apiId: env.apiId,
361
- apiHash: env.apiHash,
362
- session: env.session,
363
- })
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
+ )
364
408
 
365
409
  try {
366
410
  return await fn(client, env)
@@ -380,13 +424,19 @@ async function runAudit(options: BaseCliOptions): Promise<void> {
380
424
  }
381
425
 
382
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
383
430
  const result = await runHealthCheck({
384
431
  createClient: async (credentials) =>
385
- createMtcuteClient({
386
- apiId: credentials.apiId,
387
- apiHash: credentials.apiHash,
388
- session: credentials.sessionString,
389
- }),
432
+ createMtcuteClient(
433
+ {
434
+ apiId: credentials.apiId,
435
+ apiHash: credentials.apiHash,
436
+ session: credentials.sessionString,
437
+ },
438
+ cliTelemetry({ accountId }),
439
+ ),
390
440
  })
391
441
  for (const line of result.lines) {
392
442
  console.log(line)
@@ -574,13 +624,13 @@ async function runSaiFind(options: SaiFindOptions): Promise<void> {
574
624
  })
575
625
  const lines = records.map(formatSaiChatJsonLine)
576
626
  const contents = `${lines.join("\n")}\n`
577
- const logPath = await nextJsonlLogPath("sai-find")
627
+ const chatFilePath = await nextChatFilePath("sai-find")
578
628
 
579
- await writeFile(logPath, contents, {
629
+ await writeFile(chatFilePath, contents, {
580
630
  encoding: "utf8",
581
631
  mode: 0o600,
582
632
  })
583
- console.log(`wrote_chat_file: ${logPath}`)
633
+ console.log(`wrote_chat_file: ${chatFilePath}`)
584
634
  if (options.out) {
585
635
  await writeFile(options.out, contents, {
586
636
  encoding: "utf8",
@@ -642,7 +692,7 @@ async function applyTargetPlan(params: {
642
692
 
643
693
  const deps = actionExecutorDeps(actorBatchLogger(params.actorUsername))
644
694
  if (shouldRunAction(beforePlan.proposedActions, "add target user")) {
645
- await withFloodWaitRetry(
695
+ await runTelegramAction(
646
696
  {
647
697
  chatId: params.chat.chatId,
648
698
  action: `${params.actionPrefix}_add`,
@@ -659,7 +709,7 @@ async function applyTargetPlan(params: {
659
709
  if (
660
710
  shouldRunAction(beforePlan.proposedActions, "promote target user admin")
661
711
  ) {
662
- await withFloodWaitRetry(
712
+ await runTelegramAction(
663
713
  {
664
714
  chatId: params.chat.chatId,
665
715
  action: `${params.actionPrefix}_promote_admin`,
@@ -734,7 +784,7 @@ async function maybeTransferOwner(params: {
734
784
  return
735
785
  }
736
786
 
737
- await withFloodWaitRetry(
787
+ await runTelegramAction(
738
788
  {
739
789
  chatId: params.chat.chatId,
740
790
  action: "transfer_owner",
@@ -754,7 +804,11 @@ async function maybeTransferOwner(params: {
754
804
  *
755
805
  * The command is dry-run by default. In execution mode, it applies the Sai bot
756
806
  * update and owner-target update for each chat, logs per-chat failures, and
757
- * 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.
758
812
  */
759
813
  async function runSaiBootstrap(options: SaiBootstrapOptions): Promise<void> {
760
814
  const contents = await readFile(options.file, "utf8")
@@ -763,23 +817,20 @@ async function runSaiBootstrap(options: SaiBootstrapOptions): Promise<void> {
763
817
  if (!Number.isFinite(delayMs) || delayMs < 0) {
764
818
  throw new Error("--delay-ms must be a non-negative number")
765
819
  }
766
- const logPath = await nextJsonlLogPath("sai-bootstrap")
767
- await writeFile(logPath, "", {
768
- encoding: "utf8",
769
- mode: 0o600,
770
- })
820
+ // Telegram calls are in the telemetry DB under this run id.
821
+ const runId = activeTelemetryRun()?.context.runId ?? null
771
822
 
772
823
  await withClient(options.profile, async (client, env) => {
773
- activeJsonLogPath = logPath
774
824
  const actor = normalizeUser(await client.getMe())
775
825
  const actorUsername = actor.username ?? ""
776
826
  const profileName = await selectedProfileName(options.profile)
827
+ const ownerTarget = resolveBootstrapTarget(options.targetUser)
777
828
  const ownershipTransferEnabled =
778
829
  options.transferOwner !== false && env.password !== null
779
- console.log(`wrote_run_log: ${logPath}`)
830
+ if (runId) console.log(`telemetry_run_id: ${runId}`)
780
831
  printJsonEvent("run_start", {
781
832
  file: options.file,
782
- logPath,
833
+ runId,
783
834
  profileName,
784
835
  actor,
785
836
  chatCount: chats.length,
@@ -787,12 +838,16 @@ async function runSaiBootstrap(options: SaiBootstrapOptions): Promise<void> {
787
838
  transferOwner: ownershipTransferEnabled,
788
839
  passwordAvailable: env.password !== null,
789
840
  delayMs,
790
- targetUser: USERS.uniqueDivine.id,
841
+ targetUser: ownerTarget,
791
842
  targetBot: saiBot.id,
792
843
  })
793
844
 
794
- try {
795
- 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) => {
796
851
  printJsonEvent("chat_start", {
797
852
  index: i + 1,
798
853
  total: chats.length,
@@ -800,83 +855,85 @@ async function runSaiBootstrap(options: SaiBootstrapOptions): Promise<void> {
800
855
  title: chat.title,
801
856
  actorUsername,
802
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
+ })
803
911
 
804
- try {
805
- await applyTargetPlan({
806
- client,
807
- chat,
808
- actorUsername,
809
- targetUserId: saiBot.id,
810
- targetPeer: saiBot.handle,
811
- rights: saiBotAdminRights,
812
- actionPrefix: "bot",
813
- run: options.run === true,
814
- })
815
- await applyTargetPlan({
816
- client,
817
- chat,
818
- actorUsername,
819
- targetUserId: USERS.uniqueDivine.id,
820
- targetPeer: USERS.uniqueDivine.handle,
821
- rights: ownerTargetAdminRights,
822
- actionPrefix: "owner_target",
823
- run: options.run === true,
824
- })
825
- await maybeTransferOwner({
826
- client,
827
- env,
828
- chat,
829
- actorUsername,
830
- targetUser: USERS.uniqueDivine.handle,
831
- enabled: ownershipTransferEnabled,
832
- run: options.run === true,
833
- })
834
- printJsonEvent("chat_complete", {
835
- chatId: chat.chatId,
836
- actorUsername,
837
- })
838
- } catch (error) {
839
- printJsonEvent("chat_error", {
840
- chatId: chat.chatId,
841
- actorUsername,
842
- errorName: error instanceof Error ? error.name : "UnknownError",
843
- errorMessage:
844
- error instanceof Error ? error.message : String(error),
845
- })
846
- }
847
-
848
- if (delayMs > 0 && i < chats.length - 1) {
849
- printJsonEvent("chat_delay", {
850
- chatId: chat.chatId,
851
- actorUsername,
852
- delayMs,
853
- })
854
- await sleep(delayMs)
855
- }
856
- }
857
-
858
- printJsonEvent("run_complete", {
859
- file: options.file,
860
- logPath,
861
- 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,
862
924
  })
863
- } finally {
864
- 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
865
929
  }
866
- })
867
- }
868
930
 
869
- function getCredentialsFromProfileAddOptions(
870
- options: ProfileAddOptions,
871
- ): TelegramCredentials | null {
872
- if (options.apiId && options.apiHash && options.sessionString) {
873
- return {
874
- apiId: Number(options.apiId),
875
- apiHash: options.apiHash,
876
- sessionString: options.sessionString,
877
- }
878
- }
879
- return null
931
+ printJsonEvent("run_complete", {
932
+ file: options.file,
933
+ runId,
934
+ chatCount: chats.length,
935
+ })
936
+ })
880
937
  }
881
938
 
882
939
  function credentialValuesFromProfileAddOptions(
@@ -890,6 +947,17 @@ function credentialValuesFromProfileAddOptions(
890
947
  }
891
948
  }
892
949
 
950
+ function credentialValuesFromEnv(
951
+ env: NodeJS.ProcessEnv = process.env,
952
+ ): TelegramCredentialValues {
953
+ return {
954
+ TELEGRAM_API_ID: env.TELEGRAM_API_ID,
955
+ TELEGRAM_API_HASH: env.TELEGRAM_API_HASH,
956
+ TELEGRAM_SESSION_STRING: env.TELEGRAM_SESSION_STRING,
957
+ TELEGRAM_PASSWORD: env.TELEGRAM_PASSWORD,
958
+ }
959
+ }
960
+
893
961
  function profileFieldsFromCredentialValues(values: TelegramCredentialValues) {
894
962
  return {
895
963
  apiId: values.TELEGRAM_API_ID ? Number(values.TELEGRAM_API_ID) : null,
@@ -899,6 +967,10 @@ function profileFieldsFromCredentialValues(values: TelegramCredentialValues) {
899
967
  }
900
968
  }
901
969
 
970
+ type ProfileCredentialFields = ReturnType<
971
+ typeof profileFieldsFromCredentialValues
972
+ >
973
+
902
974
  function mergeCredentialValues(
903
975
  ...valuesList: Array<TelegramCredentialValues | null>
904
976
  ): TelegramCredentialValues {
@@ -916,10 +988,112 @@ function mergeCredentialValues(
916
988
  return merged
917
989
  }
918
990
 
991
+ function redactedCredentialValue(key: string, value: string): string {
992
+ if (
993
+ key === "TELEGRAM_API_HASH" ||
994
+ key === "TELEGRAM_SESSION_STRING" ||
995
+ key === "TELEGRAM_PASSWORD"
996
+ ) {
997
+ return value ? "[redacted]" : ""
998
+ }
999
+ return value
1000
+ }
1001
+
1002
+ function sourceLabel(candidate: DiscoveryReport["candidates"][number]): string {
1003
+ return `${candidate.name} ${candidate.path} ${candidate.objectPath}`
1004
+ }
1005
+
1006
+ function mergeDiscoveredValues(
1007
+ candidates: DiscoveryReport["candidates"],
1008
+ ): TelegramCredentialValues {
1009
+ const merged: TelegramCredentialValues = {}
1010
+ const origins: Partial<
1011
+ Record<
1012
+ keyof TelegramCredentialValues,
1013
+ DiscoveryReport["candidates"][number]
1014
+ >
1015
+ > = {}
1016
+
1017
+ for (const candidate of candidates) {
1018
+ for (const [key, value] of Object.entries(candidate.values)) {
1019
+ if (value === undefined || value === "") {
1020
+ continue
1021
+ }
1022
+ const typedKey = key as keyof TelegramCredentialValues
1023
+ const previous = merged[typedKey]
1024
+ if (previous !== undefined && previous !== value) {
1025
+ const previousOrigin = origins[typedKey]!
1026
+ throw new Error(
1027
+ [
1028
+ `Conflicting Telegram credential values for ${key}.`,
1029
+ `- ${sourceLabel(previousOrigin)}: ${redactedCredentialValue(
1030
+ key,
1031
+ previous,
1032
+ )}`,
1033
+ `- ${sourceLabel(candidate)}: ${redactedCredentialValue(
1034
+ key,
1035
+ value,
1036
+ )}`,
1037
+ "Run `tg-sai profile find` and choose one source with `--src <number>`.",
1038
+ ].join("\n"),
1039
+ )
1040
+ }
1041
+ merged[typedKey] = value
1042
+ origins[typedKey] = candidate
1043
+ }
1044
+ }
1045
+
1046
+ return merged
1047
+ }
1048
+
1049
+ function assertNoExplicitSourceConflicts(params: {
1050
+ sourceValues: TelegramCredentialValues
1051
+ higherPrecedenceValues: TelegramCredentialValues
1052
+ sourceDescription: string
1053
+ }): void {
1054
+ for (const [key, value] of Object.entries(params.sourceValues)) {
1055
+ if (value === undefined || value === "") {
1056
+ continue
1057
+ }
1058
+ const higherValue =
1059
+ params.higherPrecedenceValues[key as keyof TelegramCredentialValues]
1060
+ if (
1061
+ higherValue !== undefined &&
1062
+ higherValue !== "" &&
1063
+ higherValue !== value
1064
+ ) {
1065
+ throw new Error(
1066
+ [
1067
+ `Conflicting Telegram credential values for ${key}.`,
1068
+ `- ${params.sourceDescription}: ${redactedCredentialValue(
1069
+ key,
1070
+ value,
1071
+ )}`,
1072
+ `- CLI/env: ${redactedCredentialValue(key, higherValue)}`,
1073
+ "Remove the conflicting flag/env var or choose a different `--src`.",
1074
+ ].join("\n"),
1075
+ )
1076
+ }
1077
+ }
1078
+ }
1079
+
919
1080
  function validateLoginOptions(options: ProfileAddOptions): void {
920
1081
  if (options.qr && options.phone) {
921
1082
  throw new Error("--qr and --phone are mutually exclusive")
922
1083
  }
1084
+ if ((options.login || options.qr || options.phone) && options.sessionString) {
1085
+ throw new Error("Fresh login cannot be combined with --session-string")
1086
+ }
1087
+ }
1088
+
1089
+ /** A new login can reuse app credentials, never another account's session or 2FA. */
1090
+ function profileLoginValues(
1091
+ values: TelegramCredentialValues | null,
1092
+ options: ProfileAddOptions,
1093
+ ): TelegramCredentialValues {
1094
+ if (!(options.login || options.qr || options.phone)) return values ?? {}
1095
+ const { TELEGRAM_SESSION_STRING: _session, TELEGRAM_PASSWORD: _password, ...app } = values ?? {}
1096
+ return app
923
1097
  }
924
1098
 
925
1099
  async function generateSessionString(params: {
@@ -965,49 +1139,139 @@ async function generateSessionString(params: {
965
1139
  }
966
1140
  }
967
1141
 
968
- async function getCredentialValuesFromSourceOption(
1142
+ async function discoveredValuesForProfileAdd(
969
1143
  options: ProfileAddOptions,
970
- ): Promise<TelegramCredentialValues | null> {
971
- if (!options.src) {
972
- return null
973
- }
974
- const index = Number(options.src)
975
- if (!Number.isInteger(index) || index < 1) {
976
- throw new Error("--src must be a 1-based source number")
1144
+ ): Promise<{
1145
+ values: TelegramCredentialValues | null
1146
+ sourceDescription: string | null
1147
+ explicit: boolean
1148
+ }> {
1149
+ if (options.src) {
1150
+ const index = Number(options.src)
1151
+ if (!Number.isInteger(index) || index < 1) {
1152
+ throw new Error("--src must be a 1-based source number")
1153
+ }
1154
+
1155
+ const report = await discoveryReport()
1156
+ const candidate = report.candidates[index - 1]
1157
+ if (!candidate) {
1158
+ throw new Error(`Unknown discovery source: ${options.src}`)
1159
+ }
1160
+ return {
1161
+ values: profileLoginValues(candidate.values, options),
1162
+ sourceDescription: sourceLabel(candidate),
1163
+ explicit: true,
1164
+ }
977
1165
  }
978
1166
 
979
1167
  const report = await discoveryReport()
980
- const candidate = report.candidates[index - 1]
981
- if (!candidate) {
982
- throw new Error(`Unknown discovery source: ${options.src}`)
1168
+ return {
1169
+ values: mergeDiscoveredValues(report.candidates.map((candidate) => ({
1170
+ ...candidate, values: profileLoginValues(candidate.values, options),
1171
+ }))),
1172
+ sourceDescription: null,
1173
+ explicit: false,
1174
+ }
1175
+ }
1176
+
1177
+ async function promptForMissingProfileFields(
1178
+ profileFields: ProfileCredentialFields,
1179
+ options: ProfileAddOptions,
1180
+ ): Promise<void> {
1181
+ if (!options.interactive) {
1182
+ return
1183
+ }
1184
+
1185
+ const rl = createInterface({ input, output })
1186
+ try {
1187
+ if (!profileFields.apiId) {
1188
+ const value = (await rl.question("Telegram API ID: ")).trim()
1189
+ if (value) {
1190
+ profileFields.apiId = Number(value)
1191
+ }
1192
+ }
1193
+ if (!profileFields.apiHash) {
1194
+ const value = (await rl.question("Telegram API hash: ")).trim()
1195
+ if (value) {
1196
+ profileFields.apiHash = value
1197
+ }
1198
+ }
1199
+ if (
1200
+ !profileFields.sessionString &&
1201
+ !options.qr &&
1202
+ !options.phone &&
1203
+ !options.login
1204
+ ) {
1205
+ const value = (
1206
+ await rl.question("Generate a Telegram session now? [Y=QR/n/phone]: ")
1207
+ )
1208
+ .trim()
1209
+ .toLowerCase()
1210
+ if (value === "phone" || value === "p") {
1211
+ options.phone = true
1212
+ } else if (value === "" || value === "y" || value === "yes") {
1213
+ options.qr = true
1214
+ }
1215
+ }
1216
+ } finally {
1217
+ rl.close()
983
1218
  }
984
- return candidate.values
985
1219
  }
986
1220
 
987
1221
  async function inputFromProfileAddOptions(
988
1222
  options: ProfileAddOptions,
989
1223
  ): Promise<ProfileAddInput> {
990
1224
  validateLoginOptions(options)
991
- const credentials = getCredentialsFromProfileAddOptions(options)
992
- const sourceValues = credentials
993
- ? null
994
- : await getCredentialValuesFromSourceOption(options)
1225
+ const saved = getProfile(await readConfig(), options.name)
1226
+ const enrollsAccount = Boolean(
1227
+ options.login || options.qr || options.phone || options.src || options.sessionString,
1228
+ )
1229
+ if (saved && !enrollsAccount) {
1230
+ // Resolve the saved target before discovery. A password/app metadata edit
1231
+ // must never authenticate another discovered account and overwrite this one.
1232
+ if (
1233
+ (options.userId !== undefined && Number(options.userId) !== saved.userId) ||
1234
+ (options.handle !== undefined && options.handle !== saved.handle)
1235
+ ) {
1236
+ throw new Error("Changing a saved account identity requires an explicit fresh login or session import")
1237
+ }
1238
+ return {
1239
+ name: saved.name,
1240
+ apiId: options.apiId === undefined ? saved.apiId : Number(options.apiId),
1241
+ apiHash: options.apiHash ?? saved.apiHash,
1242
+ sessionString: saved.sessionString,
1243
+ password: options.password ?? saved.password,
1244
+ handle: saved.handle,
1245
+ userId: saved.userId,
1246
+ displayName: options.displayName ?? saved.displayName,
1247
+ }
1248
+ }
1249
+ if (!saved && options.password !== undefined && !enrollsAccount) {
1250
+ throw new Error(`Unknown configured profile: ${options.name ?? "active profile"}. Use tg-sai profile list and select it with --name; --src selects credential discovery sources.`)
1251
+ }
995
1252
  const optionValues = credentialValuesFromProfileAddOptions(options)
996
- const profileFields = credentials
997
- ? {
998
- apiId: credentials.apiId,
999
- apiHash: credentials.apiHash,
1000
- sessionString: credentials.sessionString,
1001
- password: optionValues.TELEGRAM_PASSWORD ?? "",
1002
- }
1003
- : profileFieldsFromCredentialValues(
1004
- mergeCredentialValues(sourceValues, optionValues),
1005
- )
1253
+ const envValues = profileLoginValues(credentialValuesFromEnv(), options)
1254
+ const discovery = await discoveredValuesForProfileAdd(options)
1255
+ const higherPrecedenceValues = mergeCredentialValues(envValues, optionValues)
1256
+ if (discovery.explicit && discovery.values && discovery.sourceDescription) {
1257
+ assertNoExplicitSourceConflicts({
1258
+ sourceValues: discovery.values,
1259
+ higherPrecedenceValues,
1260
+ sourceDescription: discovery.sourceDescription,
1261
+ })
1262
+ }
1263
+ const profileFields = profileFieldsFromCredentialValues(
1264
+ mergeCredentialValues(discovery.values, envValues, optionValues),
1265
+ )
1266
+ await promptForMissingProfileFields(profileFields, options)
1006
1267
 
1007
- if (options.login && !profileFields.sessionString) {
1268
+ if (
1269
+ (options.login || options.qr || options.phone) &&
1270
+ !profileFields.sessionString
1271
+ ) {
1008
1272
  if (!profileFields.apiId || !profileFields.apiHash) {
1009
1273
  throw new Error(
1010
- "--login requires apiId and apiHash from flags, --src, env, or .env discovery",
1274
+ "Session generation requires apiId and apiHash from flags, env, or discovery",
1011
1275
  )
1012
1276
  }
1013
1277
  profileFields.sessionString = await generateSessionString({
@@ -1041,16 +1305,24 @@ async function inputFromProfileAddOptions(
1041
1305
  }
1042
1306
  }
1043
1307
 
1308
+ const knownUserId =
1309
+ options.userId && /^\d+$/.test(options.userId)
1310
+ ? Number(options.userId)
1311
+ : undefined
1044
1312
  const completeProfileFields: TelegramCredentials = {
1045
1313
  apiId: profileFields.apiId,
1046
1314
  apiHash: profileFields.apiHash,
1047
1315
  sessionString: profileFields.sessionString,
1048
1316
  }
1049
- const client = await createMtcuteClient({
1050
- apiId: completeProfileFields.apiId,
1051
- apiHash: completeProfileFields.apiHash,
1052
- session: completeProfileFields.sessionString,
1053
- })
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
+ )
1054
1326
  try {
1055
1327
  const self = await client.getMe()
1056
1328
  return profileInputFromUser(
@@ -1072,33 +1344,117 @@ async function inputFromProfileAddOptions(
1072
1344
 
1073
1345
  async function runProfileList(): Promise<void> {
1074
1346
  const config = await readConfig()
1075
- const profiles = Object.values(config.profiles)
1076
- if (profiles.length === 0) {
1347
+ if (config.profiles.length === 0) {
1077
1348
  console.log("No profiles configured.")
1078
1349
  return
1079
1350
  }
1080
- for (const profile of profiles) {
1081
- const active = config.activeProfile === profile.name ? "*" : " "
1351
+ for (const [index, profile] of config.profiles.entries()) {
1352
+ const active = index === 0 ? "*" : " "
1082
1353
  console.log(
1083
- `${active} ${profile.name}\t${profile.handle ?? ""}\t${profile.userId}\t${profile.displayName}`,
1354
+ `${active} ${index}\t${profileLabel(profile, index)}\t${profile.userId}\t${profile.displayName}`,
1084
1355
  )
1085
1356
  }
1086
1357
  }
1087
1358
 
1088
- async function runProfileShow(name: string): Promise<void> {
1359
+ async function runProfileShow(selector = "0"): Promise<void> {
1089
1360
  const config = await readConfig()
1090
- const profile = getProfile(config, name)
1361
+ const profile = getProfile(config, selector)
1091
1362
  if (!profile) {
1092
- throw new Error(`Unknown profile: ${name}`)
1363
+ throw new Error(`Unknown profile: ${selector}`)
1093
1364
  }
1094
1365
  console.log(JSON.stringify(redactedProfile(profile), null, 2))
1095
1366
  }
1096
1367
 
1368
+ async function printConfiguredProfiles(): Promise<void> {
1369
+ console.log("\nConfigured local profiles (* marks the active/default profile):")
1370
+ await runProfileList()
1371
+ }
1372
+
1373
+ async function printProfileAddHelp(command: Command): Promise<void> {
1374
+ command.outputHelp()
1375
+ console.log(
1376
+ "\nNo profile is saved and no Telegram login starts without explicit options.",
1377
+ )
1378
+ console.log(
1379
+ "Use a new --name to add a separate account. Without --name, explicit options update the active profile.",
1380
+ )
1381
+ console.log("\nFresh QR login for a separate custody account:")
1382
+ console.log(
1383
+ " tg-sai profile add --name sai-custody --login --qr --interactive",
1384
+ )
1385
+ console.log(
1386
+ "Scan while signed into the intended Telegram account, then inspect it with:",
1387
+ )
1388
+ console.log(" tg-sai profile show sai-custody")
1389
+ await printConfiguredProfiles()
1390
+ console.log("\nInspect an existing profile with tg-sai profile show <selector>.")
1391
+ console.log("Update a saved password with --name <saved-name-or-handle-or-index> --password '<password>'.")
1392
+ console.log("--src selects a numbered credential source from profile find, not a saved profile index.")
1393
+ }
1394
+
1395
+ async function printProfileRemoveHelp(command: Command): Promise<void> {
1396
+ command.outputHelp()
1397
+ console.log(
1398
+ "\nRemoves a saved local profile, not the Telegram account or its chat memberships.",
1399
+ )
1400
+ console.log("Removing a profile does not revoke its Telegram sessions.")
1401
+ console.log("No profile is removed without an explicit selector.")
1402
+ const config = await readConfig()
1403
+ if (config.profiles.length === 0) {
1404
+ console.log("\nNo saved profiles are available to remove.")
1405
+ console.log(
1406
+ "Add an account with: tg-sai profile add --login --qr --interactive",
1407
+ )
1408
+ return
1409
+ }
1410
+
1411
+ console.log("\nRunnable examples for saved profiles:")
1412
+ let examples = 0
1413
+ for (const [index, saved] of config.profiles.entries()) {
1414
+ // Handles and names can overlap. Emit only selectors that resolve to this row.
1415
+ const selector = [
1416
+ saved.handle,
1417
+ saved.name,
1418
+ saved.userId?.toString(),
1419
+ String(index),
1420
+ ].find(
1421
+ (candidate) =>
1422
+ candidate != null && findProfileIndex(config, candidate) === index,
1423
+ )
1424
+ if (selector == null) continue
1425
+ const argument = /^[A-Za-z0-9_@./:-]+$/.test(selector)
1426
+ ? selector
1427
+ : `'${selector.replaceAll("'", "'\\''")}'`
1428
+ const label = JSON.stringify(profileLabel(saved, index))
1429
+ console.log(
1430
+ ` tg-sai profile remove -- ${argument} # ${label}${index === 0 ? " [active]" : ""}`,
1431
+ )
1432
+ examples++
1433
+ }
1434
+ if (examples < config.profiles.length) {
1435
+ console.log(
1436
+ "Some profiles have ambiguous selectors; inspect tg-sai profile list before removing them.",
1437
+ )
1438
+ }
1439
+ console.log(
1440
+ "\nExamples use the current profile configuration. Run tg-sai profile list to inspect it.",
1441
+ )
1442
+ }
1443
+
1097
1444
  async function runProfileAdd(options: ProfileAddOptions): Promise<void> {
1445
+ const previous = getProfile(await readConfig(), options.name)
1098
1446
  const input = await inputFromProfileAddOptions(options)
1447
+ if (previous?.userId != null && input.userId !== previous.userId) {
1448
+ throw new Error("Login or import does not match the saved account. Use a new --name for another account, or explicitly remove the old profile before replacing it.")
1449
+ }
1450
+ if (previous) input.name = previous.name
1099
1451
  const config = await addProfile(input)
1100
- const active = config.activeProfile === input.name ? "active" : "saved"
1101
- console.log(`profile_${active}: ${input.name}`)
1452
+ const profile = getProfile(config, input.name)
1453
+ const index = profile ? config.profiles.indexOf(profile) : 0
1454
+ const savedProfile = profile ?? config.profiles[0]!
1455
+ const active = index === 0 ? "default" : "saved"
1456
+ console.log(`profile_${active}: ${profileLabel(savedProfile, index)}`)
1457
+ console.log(`profile_index: ${index}`)
1102
1458
  console.log(`user_id: ${input.userId}`)
1103
1459
  console.log(`handle: ${input.handle ?? ""}`)
1104
1460
  console.log(`telegram_password: ${input.password ? "stored" : "missing"}`)
@@ -1134,7 +1490,23 @@ function printDiscoveryReport(report: DiscoveryReport): void {
1134
1490
  )
1135
1491
  }
1136
1492
  console.log("next:")
1137
- console.log(" tg-sai profile add --name <name> --src <number>")
1493
+ try {
1494
+ const values = mergeDiscoveredValues(report.candidates)
1495
+ if (Object.keys(values).length === 0) {
1496
+ console.log(" tg-sai profile add")
1497
+ return
1498
+ }
1499
+ console.log(" tg-sai profile add")
1500
+ if (!values.TELEGRAM_SESSION_STRING) {
1501
+ console.log(" tg-sai profile add --qr")
1502
+ console.log(" tg-sai profile add --phone")
1503
+ }
1504
+ } catch (error) {
1505
+ console.log(
1506
+ ` conflict: ${error instanceof Error ? error.message : String(error)}`,
1507
+ )
1508
+ console.log(" tg-sai profile add --src <number>")
1509
+ }
1138
1510
  }
1139
1511
 
1140
1512
  export function createProgram(): Command {
@@ -1150,22 +1522,17 @@ export function createProgram(): Command {
1150
1522
  program.help({ error: false })
1151
1523
  })
1152
1524
 
1153
- program
1154
- .command("health")
1155
- .description("Read-only preflight for the local tg-sai setup")
1156
- .action(async () => {
1157
- await runHealth()
1158
- })
1525
+ program.hook("preAction", (_root, actionCommand) => {
1526
+ startCliTelemetryRun(actionCommand)
1527
+ })
1528
+
1529
+ addRuntimeHealthCommands(program, runHealth)
1159
1530
 
1160
1531
  program
1161
1532
  .command("audit")
1162
1533
  .description("Read-only audit for one chat and target user")
1163
1534
  .option("--profile <name>", "Profile name to use for Telegram credentials")
1164
- .requiredOption(
1165
- "--chat <id>",
1166
- "Telegram chat ID or username",
1167
- parsePeer,
1168
- )
1535
+ .requiredOption("--chat <id>", "Telegram chat ID or username", parsePeer)
1169
1536
  .option(
1170
1537
  "--target-user <id>",
1171
1538
  "Target user ID or username",
@@ -1188,11 +1555,7 @@ Examples:
1188
1555
  .command("add-bot")
1189
1556
  .description("Add SaiTeam_bot and promote it to admin when possible")
1190
1557
  .option("--profile <name>", "Profile name to use for Telegram credentials")
1191
- .requiredOption(
1192
- "--chat <id>",
1193
- "Telegram chat ID or username",
1194
- parsePeer,
1195
- )
1558
+ .requiredOption("--chat <id>", "Telegram chat ID or username", parsePeer)
1196
1559
  .option(
1197
1560
  "--target-user <id>",
1198
1561
  "Bot user ID used for membership audit",
@@ -1214,11 +1577,7 @@ Examples:
1214
1577
  .command("add-owner")
1215
1578
  .description("Add a target user as owner if possible, otherwise admin")
1216
1579
  .option("--profile <name>", "Profile name to use for Telegram credentials")
1217
- .requiredOption(
1218
- "--chat <id>",
1219
- "Telegram chat ID or username",
1220
- parsePeer,
1221
- )
1580
+ .requiredOption("--chat <id>", "Telegram chat ID or username", parsePeer)
1222
1581
  .requiredOption(
1223
1582
  "--target-user <peer>",
1224
1583
  "Target user ID or username",
@@ -1265,6 +1624,11 @@ Examples:
1265
1624
  "--no-transfer-owner",
1266
1625
  "Skip ownership transfer even when TELEGRAM_PASSWORD is available",
1267
1626
  )
1627
+ .option(
1628
+ "--target-user <peer>",
1629
+ "Owner target user ID or username; default is the Sai custody account",
1630
+ parsePeer,
1631
+ )
1268
1632
  .action(async (options: SaiBootstrapOptions) => {
1269
1633
  await runSaiBootstrap(options)
1270
1634
  })
@@ -1272,6 +1636,10 @@ Examples:
1272
1636
  const profile = program
1273
1637
  .command("profile")
1274
1638
  .description("Manage local Telegram session profiles")
1639
+ .action(async () => {
1640
+ profile.outputHelp()
1641
+ await printConfiguredProfiles()
1642
+ })
1275
1643
 
1276
1644
  profile
1277
1645
  .command("find")
@@ -1287,10 +1655,13 @@ Examples:
1287
1655
  await runProfileList()
1288
1656
  })
1289
1657
 
1290
- profile
1658
+ const addProfileCommand = profile
1291
1659
  .command("add")
1292
1660
  .description("Add or update a Telegram session profile")
1293
- .requiredOption("--name <name>", "Profile name")
1661
+ .option(
1662
+ "--name <name>",
1663
+ "Local profile name; use a distinct name for another account",
1664
+ )
1294
1665
  .option("--api-id <id>", "Telegram API ID")
1295
1666
  .option("--api-hash <hash>", "Telegram API hash")
1296
1667
  .option("--session-string <value>", "Telegram session string")
@@ -1298,49 +1669,324 @@ Examples:
1298
1669
  "--password <value>",
1299
1670
  "Telegram 2FA password for ownership transfer",
1300
1671
  )
1301
- .option("--src <number>", "Use a numbered source from profile find")
1302
- .option("--login", "Generate a session string when missing")
1303
- .option("--qr", "Use QR login with --login")
1304
- .option("--phone", "Use phone login with --login")
1672
+ .option("--src <number>", "Import a numbered credential source from profile find, not a saved profile")
1673
+ .option(
1674
+ "--login",
1675
+ "Start a fresh account login, ignoring discovered sessions (defaults to QR)",
1676
+ )
1677
+ .option("--qr", "Generate a session string with QR login")
1678
+ .option("--phone", "Generate a session string with phone login")
1679
+ .option("-i, --interactive", "Prompt for missing profile values")
1305
1680
  .option("--handle <handle>", "Resolved Telegram handle")
1306
1681
  .option("--user-id <id>", "Resolved Telegram user ID")
1307
1682
  .option("--display-name <name>", "Resolved Telegram display name")
1683
+ .addHelpText(
1684
+ "after",
1685
+ `
1686
+ Examples:
1687
+ tg-sai profile add --name sai-custody --login --qr --interactive
1688
+ tg-sai profile add --name another-account --phone --interactive
1689
+ tg-sai profile add --name another-account --src 1
1690
+ tg-sai profile add --name existing-profile --password '<password>'
1691
+
1692
+ If apiId/apiHash are missing, create a Telegram API app at:
1693
+ https://my.telegram.org/apps
1694
+ `,
1695
+ )
1308
1696
  .action(async (options: ProfileAddOptions) => {
1697
+ if (Object.keys(options).length === 0) {
1698
+ await printProfileAddHelp(addProfileCommand)
1699
+ return
1700
+ }
1309
1701
  await runProfileAdd(options)
1310
1702
  })
1311
1703
 
1312
1704
  profile
1313
1705
  .command("show")
1314
1706
  .description("Show one configured profile")
1315
- .argument("<name>", "Profile name")
1316
- .action(async (name: string) => {
1317
- await runProfileShow(name)
1707
+ .argument(
1708
+ "[selector]",
1709
+ "Profile index, handle, user ID, or legacy name",
1710
+ "0",
1711
+ )
1712
+ .action(async (selector: string) => {
1713
+ await runProfileShow(selector)
1318
1714
  })
1319
1715
 
1320
1716
  profile
1321
1717
  .command("use")
1322
1718
  .description("Set the active profile")
1323
- .argument("<name>", "Profile name")
1324
- .action(async (name: string) => {
1325
- await setActiveProfile(name)
1326
- console.log(`active_profile: ${name}`)
1719
+ .argument("<selector>", "Profile index, handle, user ID, or legacy name")
1720
+ .action(async (selector: string) => {
1721
+ const config = await setActiveProfile(selector)
1722
+ const active = config.profiles[0]!
1723
+ console.log(`active_profile: ${profileLabel(active, 0)}`)
1327
1724
  })
1328
1725
 
1329
- profile
1726
+ const removeProfileCommand = profile
1330
1727
  .command("remove")
1331
1728
  .description("Remove one configured profile")
1332
- .argument("<name>", "Profile name")
1333
- .action(async (name: string) => {
1334
- await removeProfile(name)
1335
- console.log(`removed_profile: ${name}`)
1729
+ .argument(
1730
+ "[selector]",
1731
+ "Profile index, handle, user ID, or legacy name; omit for examples",
1732
+ )
1733
+ .action(async (selector: string | undefined) => {
1734
+ if (selector === undefined) {
1735
+ await printProfileRemoveHelp(removeProfileCommand)
1736
+ return
1737
+ }
1738
+ await removeProfile(selector)
1739
+ console.log(`removed_profile: ${selector}`)
1336
1740
  })
1337
1741
 
1742
+ addProvisionCommands(program)
1743
+ addCustodyWorkerCommands(program)
1744
+ registerEventsCommand(program)
1745
+ registerHoldsCommand(program)
1746
+ installBareHelp(program)
1338
1747
  return program
1339
1748
  }
1340
1749
 
1341
- createProgram()
1342
- .parseAsync()
1343
- .catch((error) => {
1344
- console.error(error)
1345
- 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 })
1346
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()