@uniquedivine/tg-sai 0.0.4 → 0.0.5

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
@@ -11,8 +11,11 @@ import {
11
11
  import { createRequire } from "node:module"
12
12
  import { tmpdir } from "node:os"
13
13
  import { join } from "node:path"
14
+ import { stdin as input, stdout as output } from "node:process"
15
+ import { createInterface } from "node:readline/promises"
14
16
  import { Command } from "commander"
15
-
17
+ import { addProvisionCommands } from "./provisioningCli"
18
+ import { addCustodyWorkerCommands } from "./custodyWorkerCli"
16
19
  import type { BatchLogger } from "./actionExecutor"
17
20
  import { withFloodWaitRetry } from "./actionExecutor"
18
21
  import type {
@@ -23,6 +26,7 @@ import type {
23
26
  import { planAdminBootstrap } from "./adminAudit"
24
27
  import { saiBot, USERS } from "./cfg"
25
28
  import { runHealthCheck } from "./health"
29
+ import { addRuntimeHealthCommands } from "./runtimeHealthCli"
26
30
  import {
27
31
  addTargetToChat,
28
32
  createMtcuteClient,
@@ -32,14 +36,20 @@ import {
32
36
  promoteTargetAdmin,
33
37
  transferTargetOwnership,
34
38
  } from "./mtcuteAdapter"
35
- import { formatSaiChatJsonLine, parseChatInputFile } from "./ops"
39
+ import {
40
+ formatSaiChatJsonLine,
41
+ parseChatInputFile,
42
+ resolveBootstrapTarget,
43
+ } from "./ops"
36
44
  import type { ChatInput } from "./ops"
37
45
  import {
38
46
  addProfile,
39
47
  configDir,
40
48
  discoveryReport,
49
+ findProfileIndex,
41
50
  getProfile,
42
51
  profileInputFromUser,
52
+ profileLabel,
43
53
  readConfig,
44
54
  redactedProfile,
45
55
  removeProfile,
@@ -106,8 +116,11 @@ interface SaiBootstrapOptions {
106
116
  run?: boolean
107
117
  /** Attempt ownership transfer when the Telegram password is available. */
108
118
  transferOwner?: boolean
119
+ /** Owner target: user ID or username. Defaults to the Sai custody account. */
120
+ targetUser?: number | string
109
121
  }
110
122
 
123
+
111
124
  interface TelegramEnv {
112
125
  apiId: number
113
126
  apiHash: string
@@ -116,7 +129,7 @@ interface TelegramEnv {
116
129
  }
117
130
 
118
131
  interface ProfileAddOptions {
119
- name: string
132
+ name?: string
120
133
  apiId?: string
121
134
  apiHash?: string
122
135
  sessionString?: string
@@ -128,6 +141,7 @@ interface ProfileAddOptions {
128
141
  login?: boolean
129
142
  qr?: boolean
130
143
  phone?: boolean
144
+ interactive?: boolean
131
145
  }
132
146
 
133
147
  const saiBotAdminRights = {
@@ -285,12 +299,15 @@ async function getTelegramEnv(profileName?: string): Promise<TelegramEnv> {
285
299
  }
286
300
  }
287
301
 
288
- async function selectedProfileName(profileName?: string): Promise<string | null> {
302
+ async function selectedProfileName(
303
+ profileName?: string,
304
+ ): Promise<string | null> {
289
305
  if (profileName) {
290
306
  return profileName
291
307
  }
292
308
  const config = await readConfig()
293
- return config.activeProfile
309
+ const profile = config.profiles[0]
310
+ return profile ? profileLabel(profile, 0) : null
294
311
  }
295
312
 
296
313
  function parsePeer(value: string): number | string {
@@ -774,6 +791,7 @@ async function runSaiBootstrap(options: SaiBootstrapOptions): Promise<void> {
774
791
  const actor = normalizeUser(await client.getMe())
775
792
  const actorUsername = actor.username ?? ""
776
793
  const profileName = await selectedProfileName(options.profile)
794
+ const ownerTarget = resolveBootstrapTarget(options.targetUser)
777
795
  const ownershipTransferEnabled =
778
796
  options.transferOwner !== false && env.password !== null
779
797
  console.log(`wrote_run_log: ${logPath}`)
@@ -787,7 +805,7 @@ async function runSaiBootstrap(options: SaiBootstrapOptions): Promise<void> {
787
805
  transferOwner: ownershipTransferEnabled,
788
806
  passwordAvailable: env.password !== null,
789
807
  delayMs,
790
- targetUser: USERS.uniqueDivine.id,
808
+ targetUser: ownerTarget,
791
809
  targetBot: saiBot.id,
792
810
  })
793
811
 
@@ -816,8 +834,8 @@ async function runSaiBootstrap(options: SaiBootstrapOptions): Promise<void> {
816
834
  client,
817
835
  chat,
818
836
  actorUsername,
819
- targetUserId: USERS.uniqueDivine.id,
820
- targetPeer: USERS.uniqueDivine.handle,
837
+ targetUserId: ownerTarget,
838
+ targetPeer: ownerTarget,
821
839
  rights: ownerTargetAdminRights,
822
840
  actionPrefix: "owner_target",
823
841
  run: options.run === true,
@@ -827,7 +845,7 @@ async function runSaiBootstrap(options: SaiBootstrapOptions): Promise<void> {
827
845
  env,
828
846
  chat,
829
847
  actorUsername,
830
- targetUser: USERS.uniqueDivine.handle,
848
+ targetUser: ownerTarget,
831
849
  enabled: ownershipTransferEnabled,
832
850
  run: options.run === true,
833
851
  })
@@ -866,19 +884,6 @@ async function runSaiBootstrap(options: SaiBootstrapOptions): Promise<void> {
866
884
  })
867
885
  }
868
886
 
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
880
- }
881
-
882
887
  function credentialValuesFromProfileAddOptions(
883
888
  options: ProfileAddOptions,
884
889
  ): TelegramCredentialValues {
@@ -890,6 +895,17 @@ function credentialValuesFromProfileAddOptions(
890
895
  }
891
896
  }
892
897
 
898
+ function credentialValuesFromEnv(
899
+ env: NodeJS.ProcessEnv = process.env,
900
+ ): TelegramCredentialValues {
901
+ return {
902
+ TELEGRAM_API_ID: env.TELEGRAM_API_ID,
903
+ TELEGRAM_API_HASH: env.TELEGRAM_API_HASH,
904
+ TELEGRAM_SESSION_STRING: env.TELEGRAM_SESSION_STRING,
905
+ TELEGRAM_PASSWORD: env.TELEGRAM_PASSWORD,
906
+ }
907
+ }
908
+
893
909
  function profileFieldsFromCredentialValues(values: TelegramCredentialValues) {
894
910
  return {
895
911
  apiId: values.TELEGRAM_API_ID ? Number(values.TELEGRAM_API_ID) : null,
@@ -899,6 +915,10 @@ function profileFieldsFromCredentialValues(values: TelegramCredentialValues) {
899
915
  }
900
916
  }
901
917
 
918
+ type ProfileCredentialFields = ReturnType<
919
+ typeof profileFieldsFromCredentialValues
920
+ >
921
+
902
922
  function mergeCredentialValues(
903
923
  ...valuesList: Array<TelegramCredentialValues | null>
904
924
  ): TelegramCredentialValues {
@@ -916,10 +936,112 @@ function mergeCredentialValues(
916
936
  return merged
917
937
  }
918
938
 
939
+ function redactedCredentialValue(key: string, value: string): string {
940
+ if (
941
+ key === "TELEGRAM_API_HASH" ||
942
+ key === "TELEGRAM_SESSION_STRING" ||
943
+ key === "TELEGRAM_PASSWORD"
944
+ ) {
945
+ return value ? "[redacted]" : ""
946
+ }
947
+ return value
948
+ }
949
+
950
+ function sourceLabel(candidate: DiscoveryReport["candidates"][number]): string {
951
+ return `${candidate.name} ${candidate.path} ${candidate.objectPath}`
952
+ }
953
+
954
+ function mergeDiscoveredValues(
955
+ candidates: DiscoveryReport["candidates"],
956
+ ): TelegramCredentialValues {
957
+ const merged: TelegramCredentialValues = {}
958
+ const origins: Partial<
959
+ Record<
960
+ keyof TelegramCredentialValues,
961
+ DiscoveryReport["candidates"][number]
962
+ >
963
+ > = {}
964
+
965
+ for (const candidate of candidates) {
966
+ for (const [key, value] of Object.entries(candidate.values)) {
967
+ if (value === undefined || value === "") {
968
+ continue
969
+ }
970
+ const typedKey = key as keyof TelegramCredentialValues
971
+ const previous = merged[typedKey]
972
+ if (previous !== undefined && previous !== value) {
973
+ const previousOrigin = origins[typedKey]!
974
+ throw new Error(
975
+ [
976
+ `Conflicting Telegram credential values for ${key}.`,
977
+ `- ${sourceLabel(previousOrigin)}: ${redactedCredentialValue(
978
+ key,
979
+ previous,
980
+ )}`,
981
+ `- ${sourceLabel(candidate)}: ${redactedCredentialValue(
982
+ key,
983
+ value,
984
+ )}`,
985
+ "Run `tg-sai profile find` and choose one source with `--src <number>`.",
986
+ ].join("\n"),
987
+ )
988
+ }
989
+ merged[typedKey] = value
990
+ origins[typedKey] = candidate
991
+ }
992
+ }
993
+
994
+ return merged
995
+ }
996
+
997
+ function assertNoExplicitSourceConflicts(params: {
998
+ sourceValues: TelegramCredentialValues
999
+ higherPrecedenceValues: TelegramCredentialValues
1000
+ sourceDescription: string
1001
+ }): void {
1002
+ for (const [key, value] of Object.entries(params.sourceValues)) {
1003
+ if (value === undefined || value === "") {
1004
+ continue
1005
+ }
1006
+ const higherValue =
1007
+ params.higherPrecedenceValues[key as keyof TelegramCredentialValues]
1008
+ if (
1009
+ higherValue !== undefined &&
1010
+ higherValue !== "" &&
1011
+ higherValue !== value
1012
+ ) {
1013
+ throw new Error(
1014
+ [
1015
+ `Conflicting Telegram credential values for ${key}.`,
1016
+ `- ${params.sourceDescription}: ${redactedCredentialValue(
1017
+ key,
1018
+ value,
1019
+ )}`,
1020
+ `- CLI/env: ${redactedCredentialValue(key, higherValue)}`,
1021
+ "Remove the conflicting flag/env var or choose a different `--src`.",
1022
+ ].join("\n"),
1023
+ )
1024
+ }
1025
+ }
1026
+ }
1027
+
919
1028
  function validateLoginOptions(options: ProfileAddOptions): void {
920
1029
  if (options.qr && options.phone) {
921
1030
  throw new Error("--qr and --phone are mutually exclusive")
922
1031
  }
1032
+ if ((options.login || options.qr || options.phone) && options.sessionString) {
1033
+ throw new Error("Fresh login cannot be combined with --session-string")
1034
+ }
1035
+ }
1036
+
1037
+ /** A new login can reuse app credentials, never another account's session or 2FA. */
1038
+ function profileLoginValues(
1039
+ values: TelegramCredentialValues | null,
1040
+ options: ProfileAddOptions,
1041
+ ): TelegramCredentialValues {
1042
+ if (!(options.login || options.qr || options.phone)) return values ?? {}
1043
+ const { TELEGRAM_SESSION_STRING: _session, TELEGRAM_PASSWORD: _password, ...app } = values ?? {}
1044
+ return app
923
1045
  }
924
1046
 
925
1047
  async function generateSessionString(params: {
@@ -965,49 +1087,139 @@ async function generateSessionString(params: {
965
1087
  }
966
1088
  }
967
1089
 
968
- async function getCredentialValuesFromSourceOption(
1090
+ async function discoveredValuesForProfileAdd(
969
1091
  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")
1092
+ ): Promise<{
1093
+ values: TelegramCredentialValues | null
1094
+ sourceDescription: string | null
1095
+ explicit: boolean
1096
+ }> {
1097
+ if (options.src) {
1098
+ const index = Number(options.src)
1099
+ if (!Number.isInteger(index) || index < 1) {
1100
+ throw new Error("--src must be a 1-based source number")
1101
+ }
1102
+
1103
+ const report = await discoveryReport()
1104
+ const candidate = report.candidates[index - 1]
1105
+ if (!candidate) {
1106
+ throw new Error(`Unknown discovery source: ${options.src}`)
1107
+ }
1108
+ return {
1109
+ values: profileLoginValues(candidate.values, options),
1110
+ sourceDescription: sourceLabel(candidate),
1111
+ explicit: true,
1112
+ }
977
1113
  }
978
1114
 
979
1115
  const report = await discoveryReport()
980
- const candidate = report.candidates[index - 1]
981
- if (!candidate) {
982
- throw new Error(`Unknown discovery source: ${options.src}`)
1116
+ return {
1117
+ values: mergeDiscoveredValues(report.candidates.map((candidate) => ({
1118
+ ...candidate, values: profileLoginValues(candidate.values, options),
1119
+ }))),
1120
+ sourceDescription: null,
1121
+ explicit: false,
1122
+ }
1123
+ }
1124
+
1125
+ async function promptForMissingProfileFields(
1126
+ profileFields: ProfileCredentialFields,
1127
+ options: ProfileAddOptions,
1128
+ ): Promise<void> {
1129
+ if (!options.interactive) {
1130
+ return
1131
+ }
1132
+
1133
+ const rl = createInterface({ input, output })
1134
+ try {
1135
+ if (!profileFields.apiId) {
1136
+ const value = (await rl.question("Telegram API ID: ")).trim()
1137
+ if (value) {
1138
+ profileFields.apiId = Number(value)
1139
+ }
1140
+ }
1141
+ if (!profileFields.apiHash) {
1142
+ const value = (await rl.question("Telegram API hash: ")).trim()
1143
+ if (value) {
1144
+ profileFields.apiHash = value
1145
+ }
1146
+ }
1147
+ if (
1148
+ !profileFields.sessionString &&
1149
+ !options.qr &&
1150
+ !options.phone &&
1151
+ !options.login
1152
+ ) {
1153
+ const value = (
1154
+ await rl.question("Generate a Telegram session now? [Y=QR/n/phone]: ")
1155
+ )
1156
+ .trim()
1157
+ .toLowerCase()
1158
+ if (value === "phone" || value === "p") {
1159
+ options.phone = true
1160
+ } else if (value === "" || value === "y" || value === "yes") {
1161
+ options.qr = true
1162
+ }
1163
+ }
1164
+ } finally {
1165
+ rl.close()
983
1166
  }
984
- return candidate.values
985
1167
  }
986
1168
 
987
1169
  async function inputFromProfileAddOptions(
988
1170
  options: ProfileAddOptions,
989
1171
  ): Promise<ProfileAddInput> {
990
1172
  validateLoginOptions(options)
991
- const credentials = getCredentialsFromProfileAddOptions(options)
992
- const sourceValues = credentials
993
- ? null
994
- : await getCredentialValuesFromSourceOption(options)
1173
+ const saved = getProfile(await readConfig(), options.name)
1174
+ const enrollsAccount = Boolean(
1175
+ options.login || options.qr || options.phone || options.src || options.sessionString,
1176
+ )
1177
+ if (saved && !enrollsAccount) {
1178
+ // Resolve the saved target before discovery. A password/app metadata edit
1179
+ // must never authenticate another discovered account and overwrite this one.
1180
+ if (
1181
+ (options.userId !== undefined && Number(options.userId) !== saved.userId) ||
1182
+ (options.handle !== undefined && options.handle !== saved.handle)
1183
+ ) {
1184
+ throw new Error("Changing a saved account identity requires an explicit fresh login or session import")
1185
+ }
1186
+ return {
1187
+ name: saved.name,
1188
+ apiId: options.apiId === undefined ? saved.apiId : Number(options.apiId),
1189
+ apiHash: options.apiHash ?? saved.apiHash,
1190
+ sessionString: saved.sessionString,
1191
+ password: options.password ?? saved.password,
1192
+ handle: saved.handle,
1193
+ userId: saved.userId,
1194
+ displayName: options.displayName ?? saved.displayName,
1195
+ }
1196
+ }
1197
+ if (!saved && options.password !== undefined && !enrollsAccount) {
1198
+ 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.`)
1199
+ }
995
1200
  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
- )
1201
+ const envValues = profileLoginValues(credentialValuesFromEnv(), options)
1202
+ const discovery = await discoveredValuesForProfileAdd(options)
1203
+ const higherPrecedenceValues = mergeCredentialValues(envValues, optionValues)
1204
+ if (discovery.explicit && discovery.values && discovery.sourceDescription) {
1205
+ assertNoExplicitSourceConflicts({
1206
+ sourceValues: discovery.values,
1207
+ higherPrecedenceValues,
1208
+ sourceDescription: discovery.sourceDescription,
1209
+ })
1210
+ }
1211
+ const profileFields = profileFieldsFromCredentialValues(
1212
+ mergeCredentialValues(discovery.values, envValues, optionValues),
1213
+ )
1214
+ await promptForMissingProfileFields(profileFields, options)
1006
1215
 
1007
- if (options.login && !profileFields.sessionString) {
1216
+ if (
1217
+ (options.login || options.qr || options.phone) &&
1218
+ !profileFields.sessionString
1219
+ ) {
1008
1220
  if (!profileFields.apiId || !profileFields.apiHash) {
1009
1221
  throw new Error(
1010
- "--login requires apiId and apiHash from flags, --src, env, or .env discovery",
1222
+ "Session generation requires apiId and apiHash from flags, env, or discovery",
1011
1223
  )
1012
1224
  }
1013
1225
  profileFields.sessionString = await generateSessionString({
@@ -1072,33 +1284,117 @@ async function inputFromProfileAddOptions(
1072
1284
 
1073
1285
  async function runProfileList(): Promise<void> {
1074
1286
  const config = await readConfig()
1075
- const profiles = Object.values(config.profiles)
1076
- if (profiles.length === 0) {
1287
+ if (config.profiles.length === 0) {
1077
1288
  console.log("No profiles configured.")
1078
1289
  return
1079
1290
  }
1080
- for (const profile of profiles) {
1081
- const active = config.activeProfile === profile.name ? "*" : " "
1291
+ for (const [index, profile] of config.profiles.entries()) {
1292
+ const active = index === 0 ? "*" : " "
1082
1293
  console.log(
1083
- `${active} ${profile.name}\t${profile.handle ?? ""}\t${profile.userId}\t${profile.displayName}`,
1294
+ `${active} ${index}\t${profileLabel(profile, index)}\t${profile.userId}\t${profile.displayName}`,
1084
1295
  )
1085
1296
  }
1086
1297
  }
1087
1298
 
1088
- async function runProfileShow(name: string): Promise<void> {
1299
+ async function runProfileShow(selector = "0"): Promise<void> {
1089
1300
  const config = await readConfig()
1090
- const profile = getProfile(config, name)
1301
+ const profile = getProfile(config, selector)
1091
1302
  if (!profile) {
1092
- throw new Error(`Unknown profile: ${name}`)
1303
+ throw new Error(`Unknown profile: ${selector}`)
1093
1304
  }
1094
1305
  console.log(JSON.stringify(redactedProfile(profile), null, 2))
1095
1306
  }
1096
1307
 
1308
+ async function printConfiguredProfiles(): Promise<void> {
1309
+ console.log("\nConfigured local profiles (* marks the active/default profile):")
1310
+ await runProfileList()
1311
+ }
1312
+
1313
+ async function printProfileAddHelp(command: Command): Promise<void> {
1314
+ command.outputHelp()
1315
+ console.log(
1316
+ "\nNo profile is saved and no Telegram login starts without explicit options.",
1317
+ )
1318
+ console.log(
1319
+ "Use a new --name to add a separate account. Without --name, explicit options update the active profile.",
1320
+ )
1321
+ console.log("\nFresh QR login for a separate custody account:")
1322
+ console.log(
1323
+ " tg-sai profile add --name sai-custody --login --qr --interactive",
1324
+ )
1325
+ console.log(
1326
+ "Scan while signed into the intended Telegram account, then inspect it with:",
1327
+ )
1328
+ console.log(" tg-sai profile show sai-custody")
1329
+ await printConfiguredProfiles()
1330
+ console.log("\nInspect an existing profile with tg-sai profile show <selector>.")
1331
+ console.log("Update a saved password with --name <saved-name-or-handle-or-index> --password '<password>'.")
1332
+ console.log("--src selects a numbered credential source from profile find, not a saved profile index.")
1333
+ }
1334
+
1335
+ async function printProfileRemoveHelp(command: Command): Promise<void> {
1336
+ command.outputHelp()
1337
+ console.log(
1338
+ "\nRemoves a saved local profile, not the Telegram account or its chat memberships.",
1339
+ )
1340
+ console.log("Removing a profile does not revoke its Telegram sessions.")
1341
+ console.log("No profile is removed without an explicit selector.")
1342
+ const config = await readConfig()
1343
+ if (config.profiles.length === 0) {
1344
+ console.log("\nNo saved profiles are available to remove.")
1345
+ console.log(
1346
+ "Add an account with: tg-sai profile add --login --qr --interactive",
1347
+ )
1348
+ return
1349
+ }
1350
+
1351
+ console.log("\nRunnable examples for saved profiles:")
1352
+ let examples = 0
1353
+ for (const [index, saved] of config.profiles.entries()) {
1354
+ // Handles and names can overlap. Emit only selectors that resolve to this row.
1355
+ const selector = [
1356
+ saved.handle,
1357
+ saved.name,
1358
+ saved.userId?.toString(),
1359
+ String(index),
1360
+ ].find(
1361
+ (candidate) =>
1362
+ candidate != null && findProfileIndex(config, candidate) === index,
1363
+ )
1364
+ if (selector == null) continue
1365
+ const argument = /^[A-Za-z0-9_@./:-]+$/.test(selector)
1366
+ ? selector
1367
+ : `'${selector.replaceAll("'", "'\\''")}'`
1368
+ const label = JSON.stringify(profileLabel(saved, index))
1369
+ console.log(
1370
+ ` tg-sai profile remove -- ${argument} # ${label}${index === 0 ? " [active]" : ""}`,
1371
+ )
1372
+ examples++
1373
+ }
1374
+ if (examples < config.profiles.length) {
1375
+ console.log(
1376
+ "Some profiles have ambiguous selectors; inspect tg-sai profile list before removing them.",
1377
+ )
1378
+ }
1379
+ console.log(
1380
+ "\nExamples use the current profile configuration. Run tg-sai profile list to inspect it.",
1381
+ )
1382
+ }
1383
+
1097
1384
  async function runProfileAdd(options: ProfileAddOptions): Promise<void> {
1385
+ const previous = getProfile(await readConfig(), options.name)
1098
1386
  const input = await inputFromProfileAddOptions(options)
1387
+ if (previous?.userId != null && input.userId !== previous.userId) {
1388
+ 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.")
1389
+ }
1390
+ if (previous) input.name = previous.name
1099
1391
  const config = await addProfile(input)
1100
- const active = config.activeProfile === input.name ? "active" : "saved"
1101
- console.log(`profile_${active}: ${input.name}`)
1392
+ const profile = getProfile(config, input.name)
1393
+ const index = profile ? config.profiles.indexOf(profile) : 0
1394
+ const savedProfile = profile ?? config.profiles[0]!
1395
+ const active = index === 0 ? "default" : "saved"
1396
+ console.log(`profile_${active}: ${profileLabel(savedProfile, index)}`)
1397
+ console.log(`profile_index: ${index}`)
1102
1398
  console.log(`user_id: ${input.userId}`)
1103
1399
  console.log(`handle: ${input.handle ?? ""}`)
1104
1400
  console.log(`telegram_password: ${input.password ? "stored" : "missing"}`)
@@ -1134,7 +1430,23 @@ function printDiscoveryReport(report: DiscoveryReport): void {
1134
1430
  )
1135
1431
  }
1136
1432
  console.log("next:")
1137
- console.log(" tg-sai profile add --name <name> --src <number>")
1433
+ try {
1434
+ const values = mergeDiscoveredValues(report.candidates)
1435
+ if (Object.keys(values).length === 0) {
1436
+ console.log(" tg-sai profile add")
1437
+ return
1438
+ }
1439
+ console.log(" tg-sai profile add")
1440
+ if (!values.TELEGRAM_SESSION_STRING) {
1441
+ console.log(" tg-sai profile add --qr")
1442
+ console.log(" tg-sai profile add --phone")
1443
+ }
1444
+ } catch (error) {
1445
+ console.log(
1446
+ ` conflict: ${error instanceof Error ? error.message : String(error)}`,
1447
+ )
1448
+ console.log(" tg-sai profile add --src <number>")
1449
+ }
1138
1450
  }
1139
1451
 
1140
1452
  export function createProgram(): Command {
@@ -1150,22 +1462,13 @@ export function createProgram(): Command {
1150
1462
  program.help({ error: false })
1151
1463
  })
1152
1464
 
1153
- program
1154
- .command("health")
1155
- .description("Read-only preflight for the local tg-sai setup")
1156
- .action(async () => {
1157
- await runHealth()
1158
- })
1465
+ addRuntimeHealthCommands(program, runHealth)
1159
1466
 
1160
1467
  program
1161
1468
  .command("audit")
1162
1469
  .description("Read-only audit for one chat and target user")
1163
1470
  .option("--profile <name>", "Profile name to use for Telegram credentials")
1164
- .requiredOption(
1165
- "--chat <id>",
1166
- "Telegram chat ID or username",
1167
- parsePeer,
1168
- )
1471
+ .requiredOption("--chat <id>", "Telegram chat ID or username", parsePeer)
1169
1472
  .option(
1170
1473
  "--target-user <id>",
1171
1474
  "Target user ID or username",
@@ -1188,11 +1491,7 @@ Examples:
1188
1491
  .command("add-bot")
1189
1492
  .description("Add SaiTeam_bot and promote it to admin when possible")
1190
1493
  .option("--profile <name>", "Profile name to use for Telegram credentials")
1191
- .requiredOption(
1192
- "--chat <id>",
1193
- "Telegram chat ID or username",
1194
- parsePeer,
1195
- )
1494
+ .requiredOption("--chat <id>", "Telegram chat ID or username", parsePeer)
1196
1495
  .option(
1197
1496
  "--target-user <id>",
1198
1497
  "Bot user ID used for membership audit",
@@ -1214,11 +1513,7 @@ Examples:
1214
1513
  .command("add-owner")
1215
1514
  .description("Add a target user as owner if possible, otherwise admin")
1216
1515
  .option("--profile <name>", "Profile name to use for Telegram credentials")
1217
- .requiredOption(
1218
- "--chat <id>",
1219
- "Telegram chat ID or username",
1220
- parsePeer,
1221
- )
1516
+ .requiredOption("--chat <id>", "Telegram chat ID or username", parsePeer)
1222
1517
  .requiredOption(
1223
1518
  "--target-user <peer>",
1224
1519
  "Target user ID or username",
@@ -1265,6 +1560,11 @@ Examples:
1265
1560
  "--no-transfer-owner",
1266
1561
  "Skip ownership transfer even when TELEGRAM_PASSWORD is available",
1267
1562
  )
1563
+ .option(
1564
+ "--target-user <peer>",
1565
+ "Owner target user ID or username; default is the Sai custody account",
1566
+ parsePeer,
1567
+ )
1268
1568
  .action(async (options: SaiBootstrapOptions) => {
1269
1569
  await runSaiBootstrap(options)
1270
1570
  })
@@ -1272,6 +1572,10 @@ Examples:
1272
1572
  const profile = program
1273
1573
  .command("profile")
1274
1574
  .description("Manage local Telegram session profiles")
1575
+ .action(async () => {
1576
+ profile.outputHelp()
1577
+ await printConfiguredProfiles()
1578
+ })
1275
1579
 
1276
1580
  profile
1277
1581
  .command("find")
@@ -1287,10 +1591,13 @@ Examples:
1287
1591
  await runProfileList()
1288
1592
  })
1289
1593
 
1290
- profile
1594
+ const addProfileCommand = profile
1291
1595
  .command("add")
1292
1596
  .description("Add or update a Telegram session profile")
1293
- .requiredOption("--name <name>", "Profile name")
1597
+ .option(
1598
+ "--name <name>",
1599
+ "Local profile name; use a distinct name for another account",
1600
+ )
1294
1601
  .option("--api-id <id>", "Telegram API ID")
1295
1602
  .option("--api-hash <hash>", "Telegram API hash")
1296
1603
  .option("--session-string <value>", "Telegram session string")
@@ -1298,43 +1605,78 @@ Examples:
1298
1605
  "--password <value>",
1299
1606
  "Telegram 2FA password for ownership transfer",
1300
1607
  )
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")
1608
+ .option("--src <number>", "Import a numbered credential source from profile find, not a saved profile")
1609
+ .option(
1610
+ "--login",
1611
+ "Start a fresh account login, ignoring discovered sessions (defaults to QR)",
1612
+ )
1613
+ .option("--qr", "Generate a session string with QR login")
1614
+ .option("--phone", "Generate a session string with phone login")
1615
+ .option("-i, --interactive", "Prompt for missing profile values")
1305
1616
  .option("--handle <handle>", "Resolved Telegram handle")
1306
1617
  .option("--user-id <id>", "Resolved Telegram user ID")
1307
1618
  .option("--display-name <name>", "Resolved Telegram display name")
1619
+ .addHelpText(
1620
+ "after",
1621
+ `
1622
+ Examples:
1623
+ tg-sai profile add --name sai-custody --login --qr --interactive
1624
+ tg-sai profile add --name another-account --phone --interactive
1625
+ tg-sai profile add --name another-account --src 1
1626
+ tg-sai profile add --name existing-profile --password '<password>'
1627
+
1628
+ If apiId/apiHash are missing, create a Telegram API app at:
1629
+ https://my.telegram.org/apps
1630
+ `,
1631
+ )
1308
1632
  .action(async (options: ProfileAddOptions) => {
1633
+ if (Object.keys(options).length === 0) {
1634
+ await printProfileAddHelp(addProfileCommand)
1635
+ return
1636
+ }
1309
1637
  await runProfileAdd(options)
1310
1638
  })
1311
1639
 
1312
1640
  profile
1313
1641
  .command("show")
1314
1642
  .description("Show one configured profile")
1315
- .argument("<name>", "Profile name")
1316
- .action(async (name: string) => {
1317
- await runProfileShow(name)
1643
+ .argument(
1644
+ "[selector]",
1645
+ "Profile index, handle, user ID, or legacy name",
1646
+ "0",
1647
+ )
1648
+ .action(async (selector: string) => {
1649
+ await runProfileShow(selector)
1318
1650
  })
1319
1651
 
1320
1652
  profile
1321
1653
  .command("use")
1322
1654
  .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}`)
1655
+ .argument("<selector>", "Profile index, handle, user ID, or legacy name")
1656
+ .action(async (selector: string) => {
1657
+ const config = await setActiveProfile(selector)
1658
+ const active = config.profiles[0]!
1659
+ console.log(`active_profile: ${profileLabel(active, 0)}`)
1327
1660
  })
1328
1661
 
1329
- profile
1662
+ const removeProfileCommand = profile
1330
1663
  .command("remove")
1331
1664
  .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}`)
1665
+ .argument(
1666
+ "[selector]",
1667
+ "Profile index, handle, user ID, or legacy name; omit for examples",
1668
+ )
1669
+ .action(async (selector: string | undefined) => {
1670
+ if (selector === undefined) {
1671
+ await printProfileRemoveHelp(removeProfileCommand)
1672
+ return
1673
+ }
1674
+ await removeProfile(selector)
1675
+ console.log(`removed_profile: ${selector}`)
1336
1676
  })
1337
1677
 
1678
+ addProvisionCommands(program)
1679
+ addCustodyWorkerCommands(program)
1338
1680
  return program
1339
1681
  }
1340
1682