@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/README.md +41 -0
- package/RUNBOOK.md +204 -24
- package/actionExecutor.ts +183 -131
- package/adminAudit.ts +60 -60
- package/captureProvisioningGolden.ts +98 -0
- package/cfg.ts +12 -1
- package/custodyWorker.ts +501 -0
- package/custodyWorkerCli.ts +58 -0
- package/health.ts +31 -50
- package/main.ts +860 -214
- package/mtcuteAdapter.ts +266 -10
- package/ops.ts +12 -0
- package/package.json +14 -5
- package/profiles.ts +157 -32
- package/provisioning.ts +805 -0
- package/provisioningCli.ts +147 -0
- package/provisioningCloud.ts +214 -0
- package/provisioningGoldens.ts +70 -0
- package/provisioningRuntime.ts +417 -0
- package/runtimeAssets.d.ts +4 -0
- package/runtimeHealth.ts +132 -0
- package/runtimeHealthCli.ts +48 -0
- package/session_string_generator.py +36 -24
- package/state/cli.ts +641 -0
- package/state/db.ts +72 -0
- package/state/guard.ts +270 -0
- package/state/holds.ts +381 -0
- package/state/migrations/0001-flood-hold.sql +16 -0
- package/telegramRuntime.ts +25 -0
- package/telemetry/cli.ts +698 -0
- package/telemetry/db.ts +471 -0
- package/telemetry/middleware.ts +475 -0
- package/telemetry/migrations/0001-event.sql +33 -0
- package/telemetry/migrations/0002-event-job-index.sql +2 -0
- package/telemetry/migrations/0003-event-log.sql +10 -0
- package/telemetry/queries.ts +475 -0
- package/telemetry/run.ts +491 -0
- package/telemetry/sqlAssets.d.ts +4 -0
- package/telemetry/summarize.ts +219 -0
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
181
|
-
console.log(
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
264
|
+
function chatFilesDir(): string {
|
|
241
265
|
return join(configDir(), "logs")
|
|
242
266
|
}
|
|
243
267
|
|
|
244
268
|
/**
|
|
245
|
-
* Chooses the next numbered
|
|
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
|
|
250
|
-
const dir =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
|
627
|
+
const chatFilePath = await nextChatFilePath("sai-find")
|
|
578
628
|
|
|
579
|
-
await writeFile(
|
|
629
|
+
await writeFile(chatFilePath, contents, {
|
|
580
630
|
encoding: "utf8",
|
|
581
631
|
mode: 0o600,
|
|
582
632
|
})
|
|
583
|
-
console.log(`wrote_chat_file: ${
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
767
|
-
|
|
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(`
|
|
830
|
+
if (runId) console.log(`telemetry_run_id: ${runId}`)
|
|
780
831
|
printJsonEvent("run_start", {
|
|
781
832
|
file: options.file,
|
|
782
|
-
|
|
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:
|
|
841
|
+
targetUser: ownerTarget,
|
|
791
842
|
targetBot: saiBot.id,
|
|
792
843
|
})
|
|
793
844
|
|
|
794
|
-
|
|
795
|
-
|
|
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
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
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
|
-
|
|
864
|
-
|
|
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
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
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
|
|
1142
|
+
async function discoveredValuesForProfileAdd(
|
|
969
1143
|
options: ProfileAddOptions,
|
|
970
|
-
): Promise<
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
if (
|
|
976
|
-
|
|
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
|
-
|
|
981
|
-
|
|
982
|
-
|
|
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
|
|
992
|
-
const
|
|
993
|
-
|
|
994
|
-
|
|
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
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
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 (
|
|
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
|
-
"
|
|
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
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
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
|
-
|
|
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 =
|
|
1351
|
+
for (const [index, profile] of config.profiles.entries()) {
|
|
1352
|
+
const active = index === 0 ? "*" : " "
|
|
1082
1353
|
console.log(
|
|
1083
|
-
`${active} ${
|
|
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(
|
|
1359
|
+
async function runProfileShow(selector = "0"): Promise<void> {
|
|
1089
1360
|
const config = await readConfig()
|
|
1090
|
-
const profile = getProfile(config,
|
|
1361
|
+
const profile = getProfile(config, selector)
|
|
1091
1362
|
if (!profile) {
|
|
1092
|
-
throw new Error(`Unknown profile: ${
|
|
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
|
|
1101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
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
|
-
.
|
|
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>", "
|
|
1302
|
-
.option(
|
|
1303
|
-
|
|
1304
|
-
|
|
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(
|
|
1316
|
-
|
|
1317
|
-
|
|
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("<
|
|
1324
|
-
.action(async (
|
|
1325
|
-
await setActiveProfile(
|
|
1326
|
-
|
|
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(
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
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
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
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()
|