@uniquedivine/tg-sai 0.0.3 → 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/README.md +24 -0
- package/RUNBOOK.md +137 -16
- package/actionExecutor.ts +55 -55
- package/adminAudit.ts +60 -60
- package/captureProvisioningGolden.ts +98 -0
- package/cfg.ts +12 -1
- package/custodyWorker.ts +259 -0
- package/custodyWorkerCli.ts +52 -0
- package/health.ts +32 -51
- package/main.ts +460 -106
- package/mtcuteAdapter.ts +14 -6
- package/ops.ts +12 -0
- package/package.json +5 -4
- package/profiles.ts +134 -27
- package/provisioning.ts +674 -0
- package/provisioningCli.ts +140 -0
- package/provisioningCloud.ts +214 -0
- package/provisioningGoldens.ts +70 -0
- package/provisioningRuntime.ts +380 -0
- package/runtimeAssets.d.ts +4 -0
- package/runtimeHealth.ts +125 -0
- package/runtimeHealthCli.ts +46 -0
- package/session_string_generator.py +36 -24
- package/telegramRuntime.ts +25 -0
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 {
|
|
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
|
|
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(
|
|
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
|
-
|
|
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 {
|
|
@@ -334,7 +351,7 @@ function printAudit(input: ChatAuditInput) {
|
|
|
334
351
|
console.log(
|
|
335
352
|
`target_can_receive_ownership: ${audit.targetCanReceiveOwnership}`,
|
|
336
353
|
)
|
|
337
|
-
console.log(`proposed_actions: ${audit.proposedActions
|
|
354
|
+
console.log(`proposed_actions: ${formatActionList(audit.proposedActions)}`)
|
|
338
355
|
}
|
|
339
356
|
|
|
340
357
|
function shouldRunAction(
|
|
@@ -344,6 +361,10 @@ function shouldRunAction(
|
|
|
344
361
|
return proposedActions.includes(action)
|
|
345
362
|
}
|
|
346
363
|
|
|
364
|
+
function formatActionList(actions: ProposedAction[]): string {
|
|
365
|
+
return JSON.stringify(actions)
|
|
366
|
+
}
|
|
367
|
+
|
|
347
368
|
async function withClient<T>(
|
|
348
369
|
profileName: string | undefined,
|
|
349
370
|
fn: (
|
|
@@ -404,7 +425,9 @@ async function runAddBot(options: AddBotOptions): Promise<void> {
|
|
|
404
425
|
console.log(`target_peer: ${options.targetPeer}`)
|
|
405
426
|
console.log(`actor_role: ${beforePlan.actorRole}`)
|
|
406
427
|
console.log(`target_role_before: ${beforePlan.targetRole}`)
|
|
407
|
-
console.log(
|
|
428
|
+
console.log(
|
|
429
|
+
`proposed_actions: ${formatActionList(beforePlan.proposedActions)}`,
|
|
430
|
+
)
|
|
408
431
|
|
|
409
432
|
if (options.run !== true) {
|
|
410
433
|
console.log("dry_run: pass --run to add/promote the bot")
|
|
@@ -451,7 +474,9 @@ async function runAddBot(options: AddBotOptions): Promise<void> {
|
|
|
451
474
|
|
|
452
475
|
console.log(`target_role_after: ${afterPlan.targetRole}`)
|
|
453
476
|
console.log(`target_is_admin_after: ${afterPlan.targetIsAdmin}`)
|
|
454
|
-
console.log(
|
|
477
|
+
console.log(
|
|
478
|
+
`remaining_actions: ${formatActionList(afterPlan.proposedActions)}`,
|
|
479
|
+
)
|
|
455
480
|
})
|
|
456
481
|
}
|
|
457
482
|
|
|
@@ -472,7 +497,9 @@ async function runAddOwner(options: AddOwnerOptions): Promise<void> {
|
|
|
472
497
|
console.log(
|
|
473
498
|
`target_can_receive_ownership_before: ${beforePlan.targetCanReceiveOwnership}`,
|
|
474
499
|
)
|
|
475
|
-
console.log(
|
|
500
|
+
console.log(
|
|
501
|
+
`proposed_actions: ${formatActionList(beforePlan.proposedActions)}`,
|
|
502
|
+
)
|
|
476
503
|
|
|
477
504
|
if (options.run !== true) {
|
|
478
505
|
console.log("dry_run: pass --run to add/promote the target user")
|
|
@@ -522,7 +549,9 @@ async function runAddOwner(options: AddOwnerOptions): Promise<void> {
|
|
|
522
549
|
console.log(
|
|
523
550
|
`target_can_receive_ownership_after: ${afterPlan.targetCanReceiveOwnership}`,
|
|
524
551
|
)
|
|
525
|
-
console.log(
|
|
552
|
+
console.log(
|
|
553
|
+
`remaining_actions: ${formatActionList(afterPlan.proposedActions)}`,
|
|
554
|
+
)
|
|
526
555
|
|
|
527
556
|
if (!afterPlan.actorCanTransferOwnership) {
|
|
528
557
|
console.log("ownership_transfer: skipped_actor_not_owner")
|
|
@@ -762,6 +791,7 @@ async function runSaiBootstrap(options: SaiBootstrapOptions): Promise<void> {
|
|
|
762
791
|
const actor = normalizeUser(await client.getMe())
|
|
763
792
|
const actorUsername = actor.username ?? ""
|
|
764
793
|
const profileName = await selectedProfileName(options.profile)
|
|
794
|
+
const ownerTarget = resolveBootstrapTarget(options.targetUser)
|
|
765
795
|
const ownershipTransferEnabled =
|
|
766
796
|
options.transferOwner !== false && env.password !== null
|
|
767
797
|
console.log(`wrote_run_log: ${logPath}`)
|
|
@@ -775,7 +805,7 @@ async function runSaiBootstrap(options: SaiBootstrapOptions): Promise<void> {
|
|
|
775
805
|
transferOwner: ownershipTransferEnabled,
|
|
776
806
|
passwordAvailable: env.password !== null,
|
|
777
807
|
delayMs,
|
|
778
|
-
targetUser:
|
|
808
|
+
targetUser: ownerTarget,
|
|
779
809
|
targetBot: saiBot.id,
|
|
780
810
|
})
|
|
781
811
|
|
|
@@ -804,8 +834,8 @@ async function runSaiBootstrap(options: SaiBootstrapOptions): Promise<void> {
|
|
|
804
834
|
client,
|
|
805
835
|
chat,
|
|
806
836
|
actorUsername,
|
|
807
|
-
targetUserId:
|
|
808
|
-
targetPeer:
|
|
837
|
+
targetUserId: ownerTarget,
|
|
838
|
+
targetPeer: ownerTarget,
|
|
809
839
|
rights: ownerTargetAdminRights,
|
|
810
840
|
actionPrefix: "owner_target",
|
|
811
841
|
run: options.run === true,
|
|
@@ -815,7 +845,7 @@ async function runSaiBootstrap(options: SaiBootstrapOptions): Promise<void> {
|
|
|
815
845
|
env,
|
|
816
846
|
chat,
|
|
817
847
|
actorUsername,
|
|
818
|
-
targetUser:
|
|
848
|
+
targetUser: ownerTarget,
|
|
819
849
|
enabled: ownershipTransferEnabled,
|
|
820
850
|
run: options.run === true,
|
|
821
851
|
})
|
|
@@ -854,19 +884,6 @@ async function runSaiBootstrap(options: SaiBootstrapOptions): Promise<void> {
|
|
|
854
884
|
})
|
|
855
885
|
}
|
|
856
886
|
|
|
857
|
-
function getCredentialsFromProfileAddOptions(
|
|
858
|
-
options: ProfileAddOptions,
|
|
859
|
-
): TelegramCredentials | null {
|
|
860
|
-
if (options.apiId && options.apiHash && options.sessionString) {
|
|
861
|
-
return {
|
|
862
|
-
apiId: Number(options.apiId),
|
|
863
|
-
apiHash: options.apiHash,
|
|
864
|
-
sessionString: options.sessionString,
|
|
865
|
-
}
|
|
866
|
-
}
|
|
867
|
-
return null
|
|
868
|
-
}
|
|
869
|
-
|
|
870
887
|
function credentialValuesFromProfileAddOptions(
|
|
871
888
|
options: ProfileAddOptions,
|
|
872
889
|
): TelegramCredentialValues {
|
|
@@ -878,6 +895,17 @@ function credentialValuesFromProfileAddOptions(
|
|
|
878
895
|
}
|
|
879
896
|
}
|
|
880
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
|
+
|
|
881
909
|
function profileFieldsFromCredentialValues(values: TelegramCredentialValues) {
|
|
882
910
|
return {
|
|
883
911
|
apiId: values.TELEGRAM_API_ID ? Number(values.TELEGRAM_API_ID) : null,
|
|
@@ -887,6 +915,10 @@ function profileFieldsFromCredentialValues(values: TelegramCredentialValues) {
|
|
|
887
915
|
}
|
|
888
916
|
}
|
|
889
917
|
|
|
918
|
+
type ProfileCredentialFields = ReturnType<
|
|
919
|
+
typeof profileFieldsFromCredentialValues
|
|
920
|
+
>
|
|
921
|
+
|
|
890
922
|
function mergeCredentialValues(
|
|
891
923
|
...valuesList: Array<TelegramCredentialValues | null>
|
|
892
924
|
): TelegramCredentialValues {
|
|
@@ -904,10 +936,112 @@ function mergeCredentialValues(
|
|
|
904
936
|
return merged
|
|
905
937
|
}
|
|
906
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
|
+
|
|
907
1028
|
function validateLoginOptions(options: ProfileAddOptions): void {
|
|
908
1029
|
if (options.qr && options.phone) {
|
|
909
1030
|
throw new Error("--qr and --phone are mutually exclusive")
|
|
910
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
|
|
911
1045
|
}
|
|
912
1046
|
|
|
913
1047
|
async function generateSessionString(params: {
|
|
@@ -953,49 +1087,139 @@ async function generateSessionString(params: {
|
|
|
953
1087
|
}
|
|
954
1088
|
}
|
|
955
1089
|
|
|
956
|
-
async function
|
|
1090
|
+
async function discoveredValuesForProfileAdd(
|
|
957
1091
|
options: ProfileAddOptions,
|
|
958
|
-
): Promise<
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
if (
|
|
964
|
-
|
|
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
|
+
}
|
|
965
1113
|
}
|
|
966
1114
|
|
|
967
1115
|
const report = await discoveryReport()
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
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()
|
|
971
1166
|
}
|
|
972
|
-
return candidate.values
|
|
973
1167
|
}
|
|
974
1168
|
|
|
975
1169
|
async function inputFromProfileAddOptions(
|
|
976
1170
|
options: ProfileAddOptions,
|
|
977
1171
|
): Promise<ProfileAddInput> {
|
|
978
1172
|
validateLoginOptions(options)
|
|
979
|
-
const
|
|
980
|
-
const
|
|
981
|
-
|
|
982
|
-
|
|
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
|
+
}
|
|
983
1200
|
const optionValues = credentialValuesFromProfileAddOptions(options)
|
|
984
|
-
const
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
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)
|
|
994
1215
|
|
|
995
|
-
if (
|
|
1216
|
+
if (
|
|
1217
|
+
(options.login || options.qr || options.phone) &&
|
|
1218
|
+
!profileFields.sessionString
|
|
1219
|
+
) {
|
|
996
1220
|
if (!profileFields.apiId || !profileFields.apiHash) {
|
|
997
1221
|
throw new Error(
|
|
998
|
-
"
|
|
1222
|
+
"Session generation requires apiId and apiHash from flags, env, or discovery",
|
|
999
1223
|
)
|
|
1000
1224
|
}
|
|
1001
1225
|
profileFields.sessionString = await generateSessionString({
|
|
@@ -1060,33 +1284,117 @@ async function inputFromProfileAddOptions(
|
|
|
1060
1284
|
|
|
1061
1285
|
async function runProfileList(): Promise<void> {
|
|
1062
1286
|
const config = await readConfig()
|
|
1063
|
-
|
|
1064
|
-
if (profiles.length === 0) {
|
|
1287
|
+
if (config.profiles.length === 0) {
|
|
1065
1288
|
console.log("No profiles configured.")
|
|
1066
1289
|
return
|
|
1067
1290
|
}
|
|
1068
|
-
for (const profile of profiles) {
|
|
1069
|
-
const active =
|
|
1291
|
+
for (const [index, profile] of config.profiles.entries()) {
|
|
1292
|
+
const active = index === 0 ? "*" : " "
|
|
1070
1293
|
console.log(
|
|
1071
|
-
`${active} ${
|
|
1294
|
+
`${active} ${index}\t${profileLabel(profile, index)}\t${profile.userId}\t${profile.displayName}`,
|
|
1072
1295
|
)
|
|
1073
1296
|
}
|
|
1074
1297
|
}
|
|
1075
1298
|
|
|
1076
|
-
async function runProfileShow(
|
|
1299
|
+
async function runProfileShow(selector = "0"): Promise<void> {
|
|
1077
1300
|
const config = await readConfig()
|
|
1078
|
-
const profile = getProfile(config,
|
|
1301
|
+
const profile = getProfile(config, selector)
|
|
1079
1302
|
if (!profile) {
|
|
1080
|
-
throw new Error(`Unknown profile: ${
|
|
1303
|
+
throw new Error(`Unknown profile: ${selector}`)
|
|
1081
1304
|
}
|
|
1082
1305
|
console.log(JSON.stringify(redactedProfile(profile), null, 2))
|
|
1083
1306
|
}
|
|
1084
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
|
+
|
|
1085
1384
|
async function runProfileAdd(options: ProfileAddOptions): Promise<void> {
|
|
1385
|
+
const previous = getProfile(await readConfig(), options.name)
|
|
1086
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
|
|
1087
1391
|
const config = await addProfile(input)
|
|
1088
|
-
const
|
|
1089
|
-
|
|
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}`)
|
|
1090
1398
|
console.log(`user_id: ${input.userId}`)
|
|
1091
1399
|
console.log(`handle: ${input.handle ?? ""}`)
|
|
1092
1400
|
console.log(`telegram_password: ${input.password ? "stored" : "missing"}`)
|
|
@@ -1122,7 +1430,23 @@ function printDiscoveryReport(report: DiscoveryReport): void {
|
|
|
1122
1430
|
)
|
|
1123
1431
|
}
|
|
1124
1432
|
console.log("next:")
|
|
1125
|
-
|
|
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
|
+
}
|
|
1126
1450
|
}
|
|
1127
1451
|
|
|
1128
1452
|
export function createProgram(): Command {
|
|
@@ -1131,29 +1455,20 @@ export function createProgram(): Command {
|
|
|
1131
1455
|
program
|
|
1132
1456
|
.name("tg-sai")
|
|
1133
1457
|
.description("Audit and manage Sai Telegram chat admin state")
|
|
1134
|
-
.version(packageJson.version)
|
|
1458
|
+
.version(packageJson.version, "--version", "output the version number")
|
|
1135
1459
|
.helpOption("--help", "display help for command")
|
|
1136
1460
|
|
|
1137
1461
|
program.action(() => {
|
|
1138
1462
|
program.help({ error: false })
|
|
1139
1463
|
})
|
|
1140
1464
|
|
|
1141
|
-
program
|
|
1142
|
-
.command("health")
|
|
1143
|
-
.description("Read-only preflight for the local tg-sai setup")
|
|
1144
|
-
.action(async () => {
|
|
1145
|
-
await runHealth()
|
|
1146
|
-
})
|
|
1465
|
+
addRuntimeHealthCommands(program, runHealth)
|
|
1147
1466
|
|
|
1148
1467
|
program
|
|
1149
1468
|
.command("audit")
|
|
1150
1469
|
.description("Read-only audit for one chat and target user")
|
|
1151
1470
|
.option("--profile <name>", "Profile name to use for Telegram credentials")
|
|
1152
|
-
.requiredOption(
|
|
1153
|
-
"--chat <id>",
|
|
1154
|
-
"Telegram chat ID or username",
|
|
1155
|
-
parsePeer,
|
|
1156
|
-
)
|
|
1471
|
+
.requiredOption("--chat <id>", "Telegram chat ID or username", parsePeer)
|
|
1157
1472
|
.option(
|
|
1158
1473
|
"--target-user <id>",
|
|
1159
1474
|
"Target user ID or username",
|
|
@@ -1176,11 +1491,7 @@ Examples:
|
|
|
1176
1491
|
.command("add-bot")
|
|
1177
1492
|
.description("Add SaiTeam_bot and promote it to admin when possible")
|
|
1178
1493
|
.option("--profile <name>", "Profile name to use for Telegram credentials")
|
|
1179
|
-
.requiredOption(
|
|
1180
|
-
"--chat <id>",
|
|
1181
|
-
"Telegram chat ID or username",
|
|
1182
|
-
parsePeer,
|
|
1183
|
-
)
|
|
1494
|
+
.requiredOption("--chat <id>", "Telegram chat ID or username", parsePeer)
|
|
1184
1495
|
.option(
|
|
1185
1496
|
"--target-user <id>",
|
|
1186
1497
|
"Bot user ID used for membership audit",
|
|
@@ -1202,11 +1513,7 @@ Examples:
|
|
|
1202
1513
|
.command("add-owner")
|
|
1203
1514
|
.description("Add a target user as owner if possible, otherwise admin")
|
|
1204
1515
|
.option("--profile <name>", "Profile name to use for Telegram credentials")
|
|
1205
|
-
.requiredOption(
|
|
1206
|
-
"--chat <id>",
|
|
1207
|
-
"Telegram chat ID or username",
|
|
1208
|
-
parsePeer,
|
|
1209
|
-
)
|
|
1516
|
+
.requiredOption("--chat <id>", "Telegram chat ID or username", parsePeer)
|
|
1210
1517
|
.requiredOption(
|
|
1211
1518
|
"--target-user <peer>",
|
|
1212
1519
|
"Target user ID or username",
|
|
@@ -1253,6 +1560,11 @@ Examples:
|
|
|
1253
1560
|
"--no-transfer-owner",
|
|
1254
1561
|
"Skip ownership transfer even when TELEGRAM_PASSWORD is available",
|
|
1255
1562
|
)
|
|
1563
|
+
.option(
|
|
1564
|
+
"--target-user <peer>",
|
|
1565
|
+
"Owner target user ID or username; default is the Sai custody account",
|
|
1566
|
+
parsePeer,
|
|
1567
|
+
)
|
|
1256
1568
|
.action(async (options: SaiBootstrapOptions) => {
|
|
1257
1569
|
await runSaiBootstrap(options)
|
|
1258
1570
|
})
|
|
@@ -1260,6 +1572,10 @@ Examples:
|
|
|
1260
1572
|
const profile = program
|
|
1261
1573
|
.command("profile")
|
|
1262
1574
|
.description("Manage local Telegram session profiles")
|
|
1575
|
+
.action(async () => {
|
|
1576
|
+
profile.outputHelp()
|
|
1577
|
+
await printConfiguredProfiles()
|
|
1578
|
+
})
|
|
1263
1579
|
|
|
1264
1580
|
profile
|
|
1265
1581
|
.command("find")
|
|
@@ -1275,10 +1591,13 @@ Examples:
|
|
|
1275
1591
|
await runProfileList()
|
|
1276
1592
|
})
|
|
1277
1593
|
|
|
1278
|
-
profile
|
|
1594
|
+
const addProfileCommand = profile
|
|
1279
1595
|
.command("add")
|
|
1280
1596
|
.description("Add or update a Telegram session profile")
|
|
1281
|
-
.
|
|
1597
|
+
.option(
|
|
1598
|
+
"--name <name>",
|
|
1599
|
+
"Local profile name; use a distinct name for another account",
|
|
1600
|
+
)
|
|
1282
1601
|
.option("--api-id <id>", "Telegram API ID")
|
|
1283
1602
|
.option("--api-hash <hash>", "Telegram API hash")
|
|
1284
1603
|
.option("--session-string <value>", "Telegram session string")
|
|
@@ -1286,43 +1605,78 @@ Examples:
|
|
|
1286
1605
|
"--password <value>",
|
|
1287
1606
|
"Telegram 2FA password for ownership transfer",
|
|
1288
1607
|
)
|
|
1289
|
-
.option("--src <number>", "
|
|
1290
|
-
.option(
|
|
1291
|
-
|
|
1292
|
-
|
|
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")
|
|
1293
1616
|
.option("--handle <handle>", "Resolved Telegram handle")
|
|
1294
1617
|
.option("--user-id <id>", "Resolved Telegram user ID")
|
|
1295
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
|
+
)
|
|
1296
1632
|
.action(async (options: ProfileAddOptions) => {
|
|
1633
|
+
if (Object.keys(options).length === 0) {
|
|
1634
|
+
await printProfileAddHelp(addProfileCommand)
|
|
1635
|
+
return
|
|
1636
|
+
}
|
|
1297
1637
|
await runProfileAdd(options)
|
|
1298
1638
|
})
|
|
1299
1639
|
|
|
1300
1640
|
profile
|
|
1301
1641
|
.command("show")
|
|
1302
1642
|
.description("Show one configured profile")
|
|
1303
|
-
.argument(
|
|
1304
|
-
|
|
1305
|
-
|
|
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)
|
|
1306
1650
|
})
|
|
1307
1651
|
|
|
1308
1652
|
profile
|
|
1309
1653
|
.command("use")
|
|
1310
1654
|
.description("Set the active profile")
|
|
1311
|
-
.argument("<
|
|
1312
|
-
.action(async (
|
|
1313
|
-
await setActiveProfile(
|
|
1314
|
-
|
|
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)}`)
|
|
1315
1660
|
})
|
|
1316
1661
|
|
|
1317
|
-
profile
|
|
1662
|
+
const removeProfileCommand = profile
|
|
1318
1663
|
.command("remove")
|
|
1319
1664
|
.description("Remove one configured profile")
|
|
1320
|
-
.argument(
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
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}`)
|
|
1324
1676
|
})
|
|
1325
1677
|
|
|
1678
|
+
addProvisionCommands(program)
|
|
1679
|
+
addCustodyWorkerCommands(program)
|
|
1326
1680
|
return program
|
|
1327
1681
|
}
|
|
1328
1682
|
|