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