@uniquedivine/tg-sai 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/main.ts ADDED
@@ -0,0 +1,1290 @@
1
+ #!/usr/bin/env bun
2
+ import { appendFileSync } from "node:fs"
3
+ import {
4
+ mkdir,
5
+ mkdtemp,
6
+ readdir,
7
+ readFile,
8
+ rm,
9
+ writeFile,
10
+ } from "node:fs/promises"
11
+ import { tmpdir } from "node:os"
12
+ import { join } from "node:path"
13
+ import { Command } from "commander"
14
+
15
+ import type { BatchLogger } from "./actionExecutor"
16
+ import { withFloodWaitRetry } from "./actionExecutor"
17
+ import type {
18
+ ChatAuditInput,
19
+ ChatMemberData,
20
+ ProposedAction,
21
+ } from "./adminAudit"
22
+ import { planAdminBootstrap } from "./adminAudit"
23
+ import { saiBot, uniqueDivine } from "./cfg"
24
+ import { runHealthCheck } from "./health"
25
+ import {
26
+ addTargetToChat,
27
+ createMtcuteClient,
28
+ fetchChatAuditInput,
29
+ findSaiChats,
30
+ promoteTargetAdmin,
31
+ transferTargetOwnership,
32
+ } from "./mtcuteAdapter"
33
+ import { formatSaiChatJsonLine, parseChatInputFile } from "./ops"
34
+ import type { ChatInput } from "./ops"
35
+ import {
36
+ addProfile,
37
+ configDir,
38
+ discoveryReport,
39
+ getProfile,
40
+ profileInputFromUser,
41
+ readConfig,
42
+ redactedProfile,
43
+ removeProfile,
44
+ resolveTelegramCredentials,
45
+ setActiveProfile,
46
+ } from "./profiles"
47
+ import type {
48
+ DiscoveryReport,
49
+ ProfileAddInput,
50
+ TelegramCredentials,
51
+ TelegramCredentialValues,
52
+ } from "./profiles"
53
+
54
+ const DEFAULT_USER_TARGET_ID = uniqueDivine.id
55
+ const DEFAULT_BOT_TARGET_ID = saiBot.id
56
+ const DEFAULT_BOT_TARGET_PEER = saiBot.handle
57
+
58
+ interface BaseCliOptions {
59
+ chat: number | string
60
+ targetUser: number | string
61
+ profile?: string
62
+ }
63
+
64
+ interface AddBotOptions extends BaseCliOptions {
65
+ targetPeer: number | string
66
+ run?: boolean
67
+ }
68
+
69
+ interface AddOwnerOptions {
70
+ chat: number | string
71
+ targetUser: number | string
72
+ profile?: string
73
+ run?: boolean
74
+ }
75
+
76
+ /**
77
+ * Parsed options for command `tg-sai ops sai-find`.
78
+ */
79
+ interface SaiFindOptions {
80
+ /** Optional local Telegram credential profile name. */
81
+ profile?: string
82
+ /** Maximum number of dialogs to inspect before local filtering. */
83
+ limit: string
84
+ /** Optional extra output path for a copy of the JSONL chat records. */
85
+ out?: string
86
+ /** Archive scope for dialog discovery. */
87
+ archived?: "include" | "exclude" | "only"
88
+ }
89
+
90
+ /**
91
+ * Parsed options for command `tg-sai ops sai-bootstrap`.
92
+ */
93
+ interface SaiBootstrapOptions {
94
+ /** Optional local Telegram credential profile name. */
95
+ profile?: string
96
+ /** Path to a reviewed chat input file. */
97
+ file: string
98
+ /** Inter-chat delay in milliseconds. */
99
+ delayMs: string
100
+ /** Execute writes when true; default behavior is dry-run planning. */
101
+ run?: boolean
102
+ /** Attempt ownership transfer when the Telegram password is available. */
103
+ transferOwner?: boolean
104
+ }
105
+
106
+ interface TelegramEnv {
107
+ apiId: number
108
+ apiHash: string
109
+ session: string
110
+ password: string | null
111
+ }
112
+
113
+ interface ProfileAddOptions {
114
+ name: string
115
+ apiId?: string
116
+ apiHash?: string
117
+ sessionString?: string
118
+ password?: string
119
+ src?: string
120
+ handle?: string
121
+ userId?: string
122
+ displayName?: string
123
+ login?: boolean
124
+ qr?: boolean
125
+ phone?: boolean
126
+ }
127
+
128
+ const saiBotAdminRights = {
129
+ changeInfo: false,
130
+ postMessages: false,
131
+ editMessages: false,
132
+ deleteMessages: true,
133
+ banUsers: true,
134
+ inviteUsers: true,
135
+ pinMessages: true,
136
+ addAdmins: false,
137
+ anonymous: false,
138
+ manageCall: false,
139
+ other: false,
140
+ manageTopics: true,
141
+ postStories: false,
142
+ editStories: false,
143
+ deleteStories: false,
144
+ manageDirectMessages: false,
145
+ manageRanks: false,
146
+ }
147
+
148
+ const ownerTargetAdminRights = {
149
+ changeInfo: true,
150
+ postMessages: false,
151
+ editMessages: false,
152
+ deleteMessages: true,
153
+ banUsers: true,
154
+ inviteUsers: true,
155
+ pinMessages: true,
156
+ addAdmins: true,
157
+ anonymous: false,
158
+ manageCall: true,
159
+ other: true,
160
+ manageTopics: true,
161
+ postStories: false,
162
+ editStories: false,
163
+ deleteStories: false,
164
+ manageDirectMessages: false,
165
+ manageRanks: true,
166
+ }
167
+
168
+ const DEFAULT_INTER_CHAT_DELAY_MS = 1000
169
+ let activeJsonLogPath: string | null = null
170
+
171
+ /**
172
+ * Emits one structured JSON object to stdout and the active JSONL log, if set.
173
+ */
174
+ function emitJsonObject(fields: Record<string, unknown>): void {
175
+ const line = JSON.stringify({ ts: new Date().toISOString(), ...fields })
176
+ console.log(line)
177
+ if (activeJsonLogPath) {
178
+ appendFileSync(activeJsonLogPath, `${line}\n`, "utf8")
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Batch logger that emits each structured event as one JSON object on stdout.
184
+ *
185
+ * Operators can redirect stdout to a `.jsonl` file when they want a durable run
186
+ * log, while tests can use `MemoryBatchLogger` from file `actionExecutor.ts`.
187
+ */
188
+ const consoleBatchLogger: BatchLogger = {
189
+ event(event) {
190
+ emitJsonObject(event)
191
+ },
192
+ }
193
+
194
+ /**
195
+ * Promise-based sleep used by the Telegram action executor.
196
+ */
197
+ function sleep(ms: number): Promise<void> {
198
+ return new Promise((resolve) => setTimeout(resolve, ms))
199
+ }
200
+
201
+ /**
202
+ * Builds the default retry dependencies for live Telegram actions.
203
+ */
204
+ function actionExecutorDeps(logger: BatchLogger = consoleBatchLogger) {
205
+ return {
206
+ logger,
207
+ sleep,
208
+ now: () => Date.now(),
209
+ maxRetries: 3,
210
+ bufferMs: 2000,
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Emits one command-level JSON event to stdout.
216
+ */
217
+ function printJsonEvent(type: string, fields: Record<string, unknown>): void {
218
+ emitJsonObject({ type, ...fields })
219
+ }
220
+
221
+ /**
222
+ * Returns the directory for durable tg-sai run and discovery logs.
223
+ */
224
+ function logsDir(): string {
225
+ return join(configDir(), "logs")
226
+ }
227
+
228
+ /**
229
+ * Chooses the next numbered JSONL log path for a named command prefix.
230
+ *
231
+ * Example output paths are `sai-find-00.jsonl`, `sai-find-01.jsonl`, and so on.
232
+ */
233
+ async function nextJsonlLogPath(prefix: string): Promise<string> {
234
+ const dir = logsDir()
235
+ await mkdir(dir, { recursive: true, mode: 0o700 })
236
+
237
+ const files = await readdir(dir).catch((error) => {
238
+ if (
239
+ error instanceof Error &&
240
+ "code" in error &&
241
+ (error as NodeJS.ErrnoException).code === "ENOENT"
242
+ ) {
243
+ return []
244
+ }
245
+ throw error
246
+ })
247
+ const matcher = new RegExp(`^${prefix}-(\\d+)\\.jsonl$`)
248
+ const maxIndex = files.reduce((max, file) => {
249
+ const match = file.match(matcher)
250
+ if (!match) {
251
+ return max
252
+ }
253
+ return Math.max(max, Number(match[1]))
254
+ }, -1)
255
+ const nextIndex = maxIndex + 1
256
+ const suffix = String(nextIndex).padStart(2, "0")
257
+ return join(dir, `${prefix}-${suffix}.jsonl`)
258
+ }
259
+
260
+ async function getTelegramEnv(profileName?: string): Promise<TelegramEnv> {
261
+ const credentials = await resolveTelegramCredentials(profileName)
262
+ const config = await readConfig()
263
+ const profile = getProfile(config, profileName)
264
+ return {
265
+ apiId: credentials.apiId,
266
+ apiHash: credentials.apiHash,
267
+ session: credentials.sessionString,
268
+ password: process.env.TELEGRAM_PASSWORD ?? profile?.password ?? null,
269
+ }
270
+ }
271
+
272
+ function parsePeer(value: string): number | string {
273
+ const normalized = value.startsWith("@") ? value.slice(1) : value
274
+ const asNumber = Number(normalized)
275
+ return Number.isFinite(asNumber) ? asNumber : normalized
276
+ }
277
+
278
+ function showMember(label: string, member: ChatMemberData | null) {
279
+ if (!member) {
280
+ console.log(`${label}: not_present`)
281
+ return
282
+ }
283
+
284
+ console.log(`${label}:`)
285
+ console.log(` user_id: ${member.user.id}`)
286
+ console.log(` username: ${member.user.username ?? ""}`)
287
+ console.log(` status: ${member.status}`)
288
+ console.log(` is_admin: ${member.status === "admin"}`)
289
+ console.log(` is_creator: ${member.status === "creator"}`)
290
+ console.log(` has_admin_permissions: ${member.permissions !== null}`)
291
+ if (member.permissions) {
292
+ console.log(` admin_permissions: ${JSON.stringify(member.permissions)}`)
293
+ }
294
+ }
295
+
296
+ function printAudit(input: ChatAuditInput) {
297
+ const audit = planAdminBootstrap(input)
298
+
299
+ console.log("connection_ok")
300
+ console.log(`session_user: ${input.self.id} ${input.self.username ?? ""}`)
301
+ console.log(`chat_id: ${input.chatId}`)
302
+ console.log(`target_user_id: ${input.targetUserId}`)
303
+
304
+ showMember("actor", input.actor)
305
+ showMember("target", input.target)
306
+ console.log(`actor_can_apply: ${audit.actorCanApply}`)
307
+ console.log(
308
+ `actor_can_transfer_ownership: ${audit.actorCanTransferOwnership}`,
309
+ )
310
+ console.log(
311
+ `target_can_receive_ownership: ${audit.targetCanReceiveOwnership}`,
312
+ )
313
+ console.log(`proposed_actions: ${audit.proposedActions.join("; ")}`)
314
+ }
315
+
316
+ function shouldRunAction(
317
+ proposedActions: ProposedAction[],
318
+ action: ProposedAction,
319
+ ): boolean {
320
+ return proposedActions.includes(action)
321
+ }
322
+
323
+ async function withClient<T>(
324
+ profileName: string | undefined,
325
+ fn: (
326
+ client: Awaited<ReturnType<typeof createMtcuteClient>>,
327
+ env: TelegramEnv,
328
+ ) => Promise<T>,
329
+ ): Promise<T> {
330
+ const env = await getTelegramEnv(profileName)
331
+ const client = await createMtcuteClient({
332
+ apiId: env.apiId,
333
+ apiHash: env.apiHash,
334
+ session: env.session,
335
+ })
336
+
337
+ try {
338
+ return await fn(client, env)
339
+ } finally {
340
+ await client.disconnect()
341
+ }
342
+ }
343
+
344
+ async function runAudit(options: BaseCliOptions): Promise<void> {
345
+ await withClient(options.profile, async (client) => {
346
+ const auditInput = await fetchChatAuditInput(client, {
347
+ chatId: options.chat,
348
+ targetUserId: options.targetUser,
349
+ })
350
+ printAudit(auditInput)
351
+ })
352
+ }
353
+
354
+ async function runHealth(): Promise<void> {
355
+ const result = await runHealthCheck({
356
+ createClient: async (credentials) =>
357
+ createMtcuteClient({
358
+ apiId: credentials.apiId,
359
+ apiHash: credentials.apiHash,
360
+ session: credentials.sessionString,
361
+ }),
362
+ })
363
+ for (const line of result.lines) {
364
+ console.log(line)
365
+ }
366
+ process.exitCode = result.exitCode
367
+ }
368
+
369
+ async function runAddBot(options: AddBotOptions): Promise<void> {
370
+ await withClient(options.profile, async (client) => {
371
+ const beforeInput = await fetchChatAuditInput(client, {
372
+ chatId: options.chat,
373
+ targetUserId: options.targetUser,
374
+ })
375
+ const beforePlan = planAdminBootstrap(beforeInput)
376
+
377
+ console.log(`run: ${options.run === true}`)
378
+ console.log(`chat_id: ${beforePlan.chatId}`)
379
+ console.log(`target_user_id: ${beforePlan.targetUserId}`)
380
+ console.log(`target_peer: ${options.targetPeer}`)
381
+ console.log(`actor_role: ${beforePlan.actorRole}`)
382
+ console.log(`target_role_before: ${beforePlan.targetRole}`)
383
+ console.log(`proposed_actions: ${beforePlan.proposedActions.join("; ")}`)
384
+
385
+ if (options.run !== true) {
386
+ console.log("dry_run: pass --run to add/promote the bot")
387
+ return
388
+ }
389
+ if (!beforePlan.actorCanApply) {
390
+ throw new Error(
391
+ "Refusing to run: session user cannot apply admin changes",
392
+ )
393
+ }
394
+
395
+ if (shouldRunAction(beforePlan.proposedActions, "add target user")) {
396
+ console.log("action: add target user")
397
+ await addTargetToChat(client, {
398
+ chatId: options.chat,
399
+ targetPeer: options.targetPeer,
400
+ })
401
+
402
+ const afterAddInput = await fetchChatAuditInput(client, {
403
+ chatId: options.chat,
404
+ targetUserId: options.targetUser,
405
+ })
406
+ const afterAddPlan = planAdminBootstrap(afterAddInput)
407
+ console.log(`target_role_after_add: ${afterAddPlan.targetRole}`)
408
+ console.log(`target_is_admin_after_add: ${afterAddPlan.targetIsAdmin}`)
409
+ }
410
+
411
+ if (
412
+ shouldRunAction(beforePlan.proposedActions, "promote target user admin")
413
+ ) {
414
+ console.log("action: promote target user admin")
415
+ await promoteTargetAdmin(client, {
416
+ chatId: options.chat,
417
+ targetPeer: options.targetPeer,
418
+ rights: saiBotAdminRights,
419
+ })
420
+ }
421
+
422
+ const afterInput = await fetchChatAuditInput(client, {
423
+ chatId: options.chat,
424
+ targetUserId: options.targetUser,
425
+ })
426
+ const afterPlan = planAdminBootstrap(afterInput)
427
+
428
+ console.log(`target_role_after: ${afterPlan.targetRole}`)
429
+ console.log(`target_is_admin_after: ${afterPlan.targetIsAdmin}`)
430
+ console.log(`remaining_actions: ${afterPlan.proposedActions.join("; ")}`)
431
+ })
432
+ }
433
+
434
+ async function runAddOwner(options: AddOwnerOptions): Promise<void> {
435
+ await withClient(options.profile, async (client, env) => {
436
+ const beforeInput = await fetchChatAuditInput(client, {
437
+ chatId: options.chat,
438
+ targetUserId: options.targetUser,
439
+ })
440
+ const beforePlan = planAdminBootstrap(beforeInput)
441
+
442
+ console.log(`run: ${options.run === true}`)
443
+ console.log(`elevated: ${env.password !== null}`)
444
+ console.log(`chat_id: ${beforePlan.chatId}`)
445
+ console.log(`target_user: ${options.targetUser}`)
446
+ console.log(`actor_role: ${beforePlan.actorRole}`)
447
+ console.log(`target_role_before: ${beforePlan.targetRole}`)
448
+ console.log(
449
+ `target_can_receive_ownership_before: ${beforePlan.targetCanReceiveOwnership}`,
450
+ )
451
+ console.log(`proposed_actions: ${beforePlan.proposedActions.join("; ")}`)
452
+
453
+ if (options.run !== true) {
454
+ console.log("dry_run: pass --run to add/promote the target user")
455
+ return
456
+ }
457
+ if (!beforePlan.actorCanApply) {
458
+ throw new Error(
459
+ "Refusing to run: session user cannot apply admin changes",
460
+ )
461
+ }
462
+
463
+ if (shouldRunAction(beforePlan.proposedActions, "add target user")) {
464
+ console.log("action: add target user")
465
+ await addTargetToChat(client, {
466
+ chatId: options.chat,
467
+ targetPeer: options.targetUser,
468
+ })
469
+
470
+ const afterAddInput = await fetchChatAuditInput(client, {
471
+ chatId: options.chat,
472
+ targetUserId: options.targetUser,
473
+ })
474
+ const afterAddPlan = planAdminBootstrap(afterAddInput)
475
+ console.log(`target_role_after_add: ${afterAddPlan.targetRole}`)
476
+ console.log(`target_is_admin_after_add: ${afterAddPlan.targetIsAdmin}`)
477
+ }
478
+
479
+ if (
480
+ shouldRunAction(beforePlan.proposedActions, "promote target user admin")
481
+ ) {
482
+ console.log("action: promote target user admin")
483
+ await promoteTargetAdmin(client, {
484
+ chatId: options.chat,
485
+ targetPeer: options.targetUser,
486
+ rights: ownerTargetAdminRights,
487
+ })
488
+ }
489
+
490
+ const afterInput = await fetchChatAuditInput(client, {
491
+ chatId: options.chat,
492
+ targetUserId: options.targetUser,
493
+ })
494
+ const afterPlan = planAdminBootstrap(afterInput)
495
+
496
+ console.log(`target_role_after: ${afterPlan.targetRole}`)
497
+ console.log(`target_is_admin_after: ${afterPlan.targetIsAdmin}`)
498
+ console.log(
499
+ `target_can_receive_ownership_after: ${afterPlan.targetCanReceiveOwnership}`,
500
+ )
501
+ console.log(`remaining_actions: ${afterPlan.proposedActions.join("; ")}`)
502
+
503
+ if (!afterPlan.actorCanTransferOwnership) {
504
+ console.log("ownership_transfer: skipped_actor_not_owner")
505
+ return
506
+ }
507
+ if (!afterPlan.targetCanReceiveOwnership) {
508
+ console.log("ownership_transfer: skipped_target_not_eligible")
509
+ return
510
+ }
511
+ if (env.password === null) {
512
+ console.log("ownership_transfer: skipped_missing_telegram_password")
513
+ return
514
+ }
515
+
516
+ console.log("ownership_transfer: run")
517
+ await transferTargetOwnership(client, {
518
+ chatId: options.chat,
519
+ targetPeer: options.targetUser,
520
+ password: env.password,
521
+ })
522
+
523
+ const transferInput = await fetchChatAuditInput(client, {
524
+ chatId: options.chat,
525
+ targetUserId: options.targetUser,
526
+ })
527
+ const transferPlan = planAdminBootstrap(transferInput)
528
+ console.log(`target_role_after_transfer: ${transferPlan.targetRole}`)
529
+ console.log(`actor_role_after_transfer: ${transferPlan.actorRole}`)
530
+ })
531
+ }
532
+
533
+ async function runSaiFind(options: SaiFindOptions): Promise<void> {
534
+ await withClient(options.profile, async (client) => {
535
+ const records = await findSaiChats(client, {
536
+ limit: Number(options.limit),
537
+ archived: options.archived ?? "exclude",
538
+ })
539
+ const lines = records.map(formatSaiChatJsonLine)
540
+ const contents = `${lines.join("\n")}\n`
541
+ const logPath = await nextJsonlLogPath("sai-find")
542
+
543
+ await writeFile(logPath, contents, {
544
+ encoding: "utf8",
545
+ mode: 0o600,
546
+ })
547
+ console.log(`wrote_chat_file: ${logPath}`)
548
+ if (options.out) {
549
+ await writeFile(options.out, contents, {
550
+ encoding: "utf8",
551
+ mode: 0o600,
552
+ })
553
+ console.log(`wrote_out_copy: ${options.out}`)
554
+ }
555
+ console.log(`chat_count: ${records.length}`)
556
+ })
557
+ }
558
+
559
+ /**
560
+ * Plans and optionally applies add/promote actions for one target peer.
561
+ *
562
+ * The same helper handles the Sai bot and the owner-target user. It treats
563
+ * insufficient actor authority as a skip, not a fatal batch error.
564
+ */
565
+ async function applyTargetPlan(params: {
566
+ client: Awaited<ReturnType<typeof createMtcuteClient>>
567
+ chat: ChatInput
568
+ targetUserId: number | string
569
+ targetPeer: number | string
570
+ rights: Record<string, boolean>
571
+ actionPrefix: string
572
+ run: boolean
573
+ }): Promise<void> {
574
+ const beforeInput = await fetchChatAuditInput(params.client, {
575
+ chatId: params.chat.chatId,
576
+ targetUserId: params.targetUserId,
577
+ })
578
+ const beforePlan = planAdminBootstrap(beforeInput)
579
+
580
+ printJsonEvent("target_plan", {
581
+ chatId: params.chat.chatId,
582
+ title: params.chat.title,
583
+ targetUserId: params.targetUserId,
584
+ actionPrefix: params.actionPrefix,
585
+ run: params.run,
586
+ actorRole: beforePlan.actorRole,
587
+ targetRole: beforePlan.targetRole,
588
+ proposedActions: beforePlan.proposedActions,
589
+ })
590
+
591
+ if (!beforePlan.actorCanApply) {
592
+ printJsonEvent("target_skip", {
593
+ chatId: params.chat.chatId,
594
+ actionPrefix: params.actionPrefix,
595
+ reason: "actor_lacks_owner_or_add_admins_right",
596
+ })
597
+ return
598
+ }
599
+
600
+ if (!params.run) {
601
+ return
602
+ }
603
+
604
+ const deps = actionExecutorDeps()
605
+ if (shouldRunAction(beforePlan.proposedActions, "add target user")) {
606
+ await withFloodWaitRetry(
607
+ {
608
+ chatId: params.chat.chatId,
609
+ action: `${params.actionPrefix}_add`,
610
+ },
611
+ () =>
612
+ addTargetToChat(params.client, {
613
+ chatId: params.chat.chatId,
614
+ targetPeer: params.targetPeer,
615
+ }),
616
+ deps,
617
+ )
618
+ }
619
+
620
+ if (
621
+ shouldRunAction(beforePlan.proposedActions, "promote target user admin")
622
+ ) {
623
+ await withFloodWaitRetry(
624
+ {
625
+ chatId: params.chat.chatId,
626
+ action: `${params.actionPrefix}_promote_admin`,
627
+ },
628
+ () =>
629
+ promoteTargetAdmin(params.client, {
630
+ chatId: params.chat.chatId,
631
+ targetPeer: params.targetPeer,
632
+ rights: params.rights,
633
+ }),
634
+ deps,
635
+ )
636
+ }
637
+ }
638
+
639
+ /**
640
+ * Optionally transfers ownership after the target user is already admin.
641
+ *
642
+ * Ownership transfer only runs when the command flag is present, the actor is
643
+ * the current owner, the target user is eligible, and `TELEGRAM_PASSWORD` is
644
+ * present in the environment.
645
+ */
646
+ async function maybeTransferOwner(params: {
647
+ client: Awaited<ReturnType<typeof createMtcuteClient>>
648
+ env: TelegramEnv
649
+ chat: ChatInput
650
+ targetUser: number | string
651
+ enabled: boolean
652
+ run: boolean
653
+ }): Promise<void> {
654
+ if (!params.enabled) {
655
+ return
656
+ }
657
+
658
+ const input = await fetchChatAuditInput(params.client, {
659
+ chatId: params.chat.chatId,
660
+ targetUserId: params.targetUser,
661
+ })
662
+ const plan = planAdminBootstrap(input)
663
+
664
+ if (!plan.actorCanTransferOwnership) {
665
+ printJsonEvent("ownership_skip", {
666
+ chatId: params.chat.chatId,
667
+ reason: "actor_not_owner",
668
+ })
669
+ return
670
+ }
671
+ if (!plan.targetCanReceiveOwnership) {
672
+ printJsonEvent("ownership_skip", {
673
+ chatId: params.chat.chatId,
674
+ reason: "target_not_eligible",
675
+ })
676
+ return
677
+ }
678
+ if (params.env.password === null) {
679
+ printJsonEvent("ownership_skip", {
680
+ chatId: params.chat.chatId,
681
+ reason: "missing_telegram_password",
682
+ })
683
+ return
684
+ }
685
+ if (!params.run) {
686
+ printJsonEvent("ownership_plan", {
687
+ chatId: params.chat.chatId,
688
+ action: "transfer_owner",
689
+ })
690
+ return
691
+ }
692
+
693
+ await withFloodWaitRetry(
694
+ {
695
+ chatId: params.chat.chatId,
696
+ action: "transfer_owner",
697
+ },
698
+ () =>
699
+ transferTargetOwnership(params.client, {
700
+ chatId: params.chat.chatId,
701
+ targetPeer: params.targetUser,
702
+ password: params.env.password ?? "",
703
+ }),
704
+ actionExecutorDeps(),
705
+ )
706
+ }
707
+
708
+ /**
709
+ * Runs command `tg-sai ops sai-bootstrap` over a reviewed chat file.
710
+ *
711
+ * The command is dry-run by default. In execution mode, it applies the Sai bot
712
+ * update and owner-target update for each chat, logs per-chat failures, and
713
+ * continues to the next chat.
714
+ */
715
+ async function runSaiBootstrap(options: SaiBootstrapOptions): Promise<void> {
716
+ const contents = await readFile(options.file, "utf8")
717
+ const chats = parseChatInputFile(contents)
718
+ const delayMs = Number(options.delayMs)
719
+ if (!Number.isFinite(delayMs) || delayMs < 0) {
720
+ throw new Error("--delay-ms must be a non-negative number")
721
+ }
722
+ const logPath = await nextJsonlLogPath("sai-bootstrap")
723
+ await writeFile(logPath, "", {
724
+ encoding: "utf8",
725
+ mode: 0o600,
726
+ })
727
+
728
+ await withClient(options.profile, async (client, env) => {
729
+ activeJsonLogPath = logPath
730
+ const ownershipTransferEnabled =
731
+ options.transferOwner !== false && env.password !== null
732
+ console.log(`wrote_run_log: ${logPath}`)
733
+ printJsonEvent("run_start", {
734
+ file: options.file,
735
+ logPath,
736
+ chatCount: chats.length,
737
+ run: options.run === true,
738
+ transferOwner: ownershipTransferEnabled,
739
+ passwordAvailable: env.password !== null,
740
+ delayMs,
741
+ targetUser: uniqueDivine.id,
742
+ targetBot: saiBot.id,
743
+ })
744
+
745
+ try {
746
+ for (const [i, chat] of chats.entries()) {
747
+ printJsonEvent("chat_start", {
748
+ index: i + 1,
749
+ total: chats.length,
750
+ chatId: chat.chatId,
751
+ title: chat.title,
752
+ })
753
+
754
+ try {
755
+ await applyTargetPlan({
756
+ client,
757
+ chat,
758
+ targetUserId: saiBot.id,
759
+ targetPeer: saiBot.handle,
760
+ rights: saiBotAdminRights,
761
+ actionPrefix: "bot",
762
+ run: options.run === true,
763
+ })
764
+ await applyTargetPlan({
765
+ client,
766
+ chat,
767
+ targetUserId: uniqueDivine.id,
768
+ targetPeer: uniqueDivine.handle,
769
+ rights: ownerTargetAdminRights,
770
+ actionPrefix: "owner_target",
771
+ run: options.run === true,
772
+ })
773
+ await maybeTransferOwner({
774
+ client,
775
+ env,
776
+ chat,
777
+ targetUser: uniqueDivine.handle,
778
+ enabled: ownershipTransferEnabled,
779
+ run: options.run === true,
780
+ })
781
+ printJsonEvent("chat_complete", {
782
+ chatId: chat.chatId,
783
+ })
784
+ } catch (error) {
785
+ printJsonEvent("chat_error", {
786
+ chatId: chat.chatId,
787
+ errorName: error instanceof Error ? error.name : "UnknownError",
788
+ errorMessage:
789
+ error instanceof Error ? error.message : String(error),
790
+ })
791
+ }
792
+
793
+ if (delayMs > 0 && i < chats.length - 1) {
794
+ printJsonEvent("chat_delay", {
795
+ chatId: chat.chatId,
796
+ delayMs,
797
+ })
798
+ await sleep(delayMs)
799
+ }
800
+ }
801
+
802
+ printJsonEvent("run_complete", {
803
+ file: options.file,
804
+ logPath,
805
+ chatCount: chats.length,
806
+ })
807
+ } finally {
808
+ activeJsonLogPath = null
809
+ }
810
+ })
811
+ }
812
+
813
+ function getCredentialsFromProfileAddOptions(
814
+ options: ProfileAddOptions,
815
+ ): TelegramCredentials | null {
816
+ if (options.apiId && options.apiHash && options.sessionString) {
817
+ return {
818
+ apiId: Number(options.apiId),
819
+ apiHash: options.apiHash,
820
+ sessionString: options.sessionString,
821
+ }
822
+ }
823
+ return null
824
+ }
825
+
826
+ function credentialValuesFromProfileAddOptions(
827
+ options: ProfileAddOptions,
828
+ ): TelegramCredentialValues {
829
+ return {
830
+ TELEGRAM_API_ID: options.apiId,
831
+ TELEGRAM_API_HASH: options.apiHash,
832
+ TELEGRAM_SESSION_STRING: options.sessionString,
833
+ TELEGRAM_PASSWORD: options.password,
834
+ }
835
+ }
836
+
837
+ function profileFieldsFromCredentialValues(values: TelegramCredentialValues) {
838
+ return {
839
+ apiId: values.TELEGRAM_API_ID ? Number(values.TELEGRAM_API_ID) : null,
840
+ apiHash: values.TELEGRAM_API_HASH ?? "",
841
+ sessionString: values.TELEGRAM_SESSION_STRING ?? "",
842
+ password: values.TELEGRAM_PASSWORD ?? "",
843
+ }
844
+ }
845
+
846
+ function mergeCredentialValues(
847
+ ...valuesList: Array<TelegramCredentialValues | null>
848
+ ): TelegramCredentialValues {
849
+ const merged: TelegramCredentialValues = {}
850
+ for (const values of valuesList) {
851
+ if (!values) {
852
+ continue
853
+ }
854
+ for (const [key, value] of Object.entries(values)) {
855
+ if (value !== undefined && value !== "") {
856
+ merged[key as keyof TelegramCredentialValues] = value
857
+ }
858
+ }
859
+ }
860
+ return merged
861
+ }
862
+
863
+ function validateLoginOptions(options: ProfileAddOptions): void {
864
+ if (options.qr && options.phone) {
865
+ throw new Error("--qr and --phone are mutually exclusive")
866
+ }
867
+ }
868
+
869
+ async function generateSessionString(params: {
870
+ apiId: number
871
+ apiHash: string
872
+ mode: "qr" | "phone"
873
+ }): Promise<string> {
874
+ const dir = await mkdtemp(join(tmpdir(), "tg-sai-session-"))
875
+ const outputPath = join(dir, "session.txt")
876
+ try {
877
+ const result = Bun.spawnSync({
878
+ cmd: [
879
+ "uv",
880
+ "run",
881
+ "session_string_generator.py",
882
+ `--${params.mode}`,
883
+ "--session-output",
884
+ outputPath,
885
+ "--no-env-write",
886
+ ],
887
+ cwd: import.meta.dir,
888
+ env: {
889
+ ...process.env,
890
+ TELEGRAM_API_ID: String(params.apiId),
891
+ TELEGRAM_API_HASH: params.apiHash,
892
+ },
893
+ stdin: "inherit",
894
+ stdout: "inherit",
895
+ stderr: "inherit",
896
+ })
897
+ if (result.exitCode !== 0) {
898
+ throw new Error(
899
+ `session generator failed with exit code ${result.exitCode}`,
900
+ )
901
+ }
902
+ const session = (await readFile(outputPath, "utf8")).trim()
903
+ if (!session) {
904
+ throw new Error("session generator did not write a session string")
905
+ }
906
+ return session
907
+ } finally {
908
+ await rm(dir, { recursive: true, force: true })
909
+ }
910
+ }
911
+
912
+ async function getCredentialValuesFromSourceOption(
913
+ options: ProfileAddOptions,
914
+ ): Promise<TelegramCredentialValues | null> {
915
+ if (!options.src) {
916
+ return null
917
+ }
918
+ const index = Number(options.src)
919
+ if (!Number.isInteger(index) || index < 1) {
920
+ throw new Error("--src must be a 1-based source number")
921
+ }
922
+
923
+ const report = await discoveryReport()
924
+ const candidate = report.candidates[index - 1]
925
+ if (!candidate) {
926
+ throw new Error(`Unknown discovery source: ${options.src}`)
927
+ }
928
+ return candidate.values
929
+ }
930
+
931
+ async function inputFromProfileAddOptions(
932
+ options: ProfileAddOptions,
933
+ ): Promise<ProfileAddInput> {
934
+ validateLoginOptions(options)
935
+ const credentials = getCredentialsFromProfileAddOptions(options)
936
+ const sourceValues = credentials
937
+ ? null
938
+ : await getCredentialValuesFromSourceOption(options)
939
+ const optionValues = credentialValuesFromProfileAddOptions(options)
940
+ const profileFields = credentials
941
+ ? {
942
+ apiId: credentials.apiId,
943
+ apiHash: credentials.apiHash,
944
+ sessionString: credentials.sessionString,
945
+ password: optionValues.TELEGRAM_PASSWORD ?? "",
946
+ }
947
+ : profileFieldsFromCredentialValues(
948
+ mergeCredentialValues(sourceValues, optionValues),
949
+ )
950
+
951
+ if (options.login && !profileFields.sessionString) {
952
+ if (!profileFields.apiId || !profileFields.apiHash) {
953
+ throw new Error(
954
+ "--login requires apiId and apiHash from flags, --src, env, or .env discovery",
955
+ )
956
+ }
957
+ profileFields.sessionString = await generateSessionString({
958
+ apiId: profileFields.apiId,
959
+ apiHash: profileFields.apiHash,
960
+ mode: options.phone ? "phone" : "qr",
961
+ })
962
+ }
963
+
964
+ if (options.userId && options.displayName) {
965
+ return {
966
+ name: options.name,
967
+ ...profileFields,
968
+ handle: options.handle ?? null,
969
+ userId: Number(options.userId),
970
+ displayName: options.displayName,
971
+ }
972
+ }
973
+
974
+ if (
975
+ !profileFields.apiId ||
976
+ !profileFields.apiHash ||
977
+ !profileFields.sessionString
978
+ ) {
979
+ return {
980
+ name: options.name,
981
+ ...profileFields,
982
+ handle: options.handle ?? null,
983
+ userId: null,
984
+ displayName: "",
985
+ }
986
+ }
987
+
988
+ const completeProfileFields: TelegramCredentials = {
989
+ apiId: profileFields.apiId,
990
+ apiHash: profileFields.apiHash,
991
+ sessionString: profileFields.sessionString,
992
+ }
993
+ const client = await createMtcuteClient({
994
+ apiId: completeProfileFields.apiId,
995
+ apiHash: completeProfileFields.apiHash,
996
+ session: completeProfileFields.sessionString,
997
+ })
998
+ try {
999
+ const self = await client.getMe()
1000
+ return profileInputFromUser(
1001
+ options.name,
1002
+ completeProfileFields,
1003
+ {
1004
+ id: self.id,
1005
+ username: self.username ?? null,
1006
+ displayName: self.displayName,
1007
+ isBot: self.isBot,
1008
+ rawType: self.raw?._ ?? "unknown",
1009
+ },
1010
+ profileFields.password,
1011
+ )
1012
+ } finally {
1013
+ await client.disconnect()
1014
+ }
1015
+ }
1016
+
1017
+ async function runProfileList(): Promise<void> {
1018
+ const config = await readConfig()
1019
+ const profiles = Object.values(config.profiles)
1020
+ if (profiles.length === 0) {
1021
+ console.log("No profiles configured.")
1022
+ return
1023
+ }
1024
+ for (const profile of profiles) {
1025
+ const active = config.activeProfile === profile.name ? "*" : " "
1026
+ console.log(
1027
+ `${active} ${profile.name}\t${profile.handle ?? ""}\t${profile.userId}\t${profile.displayName}`,
1028
+ )
1029
+ }
1030
+ }
1031
+
1032
+ async function runProfileShow(name: string): Promise<void> {
1033
+ const config = await readConfig()
1034
+ const profile = getProfile(config, name)
1035
+ if (!profile) {
1036
+ throw new Error(`Unknown profile: ${name}`)
1037
+ }
1038
+ console.log(JSON.stringify(redactedProfile(profile), null, 2))
1039
+ }
1040
+
1041
+ async function runProfileAdd(options: ProfileAddOptions): Promise<void> {
1042
+ const input = await inputFromProfileAddOptions(options)
1043
+ const config = await addProfile(input)
1044
+ const active = config.activeProfile === input.name ? "active" : "saved"
1045
+ console.log(`profile_${active}: ${input.name}`)
1046
+ console.log(`user_id: ${input.userId}`)
1047
+ console.log(`handle: ${input.handle ?? ""}`)
1048
+ console.log(`telegram_password: ${input.password ? "stored" : "missing"}`)
1049
+ console.log(
1050
+ "ownership_transfer: profile password or TELEGRAM_PASSWORD enables transfer",
1051
+ )
1052
+ }
1053
+
1054
+ function printDiscoveryReport(report: DiscoveryReport): void {
1055
+ console.log(`platform: ${report.platform}`)
1056
+ console.log(`home: ${report.home}`)
1057
+ console.log("checked:")
1058
+ for (const path of report.checkedPaths) {
1059
+ const found = report.foundFiles.includes(path.expandedPath)
1060
+ ? "found"
1061
+ : "missing"
1062
+ console.log(` - ${path.name}: ${path.path} [${found}]`)
1063
+ }
1064
+
1065
+ console.log("candidates:")
1066
+ if (report.candidates.length === 0) {
1067
+ console.log(" - none")
1068
+ return
1069
+ }
1070
+
1071
+ for (const [index, candidate] of report.candidates.entries()) {
1072
+ const status =
1073
+ candidate.missingKeys.length === 0
1074
+ ? "complete"
1075
+ : `missing ${candidate.missingKeys.join(",")}`
1076
+ console.log(
1077
+ ` ${index + 1}. ${candidate.name}: ${candidate.path} ${candidate.objectPath} [${status}]`,
1078
+ )
1079
+ }
1080
+ console.log("next:")
1081
+ console.log(" tg-sai profile add --name <name> --src <number>")
1082
+ }
1083
+
1084
+ export function createProgram(): Command {
1085
+ const program = new Command()
1086
+
1087
+ program
1088
+ .name("tg-sai")
1089
+ .description("Audit and manage Sai Telegram chat admin state")
1090
+ .version("0.1.0")
1091
+ .helpOption("--help", "display help for command")
1092
+
1093
+ program.action(() => {
1094
+ program.help({ error: false })
1095
+ })
1096
+
1097
+ program
1098
+ .command("health")
1099
+ .description("Read-only preflight for the local tg-sai setup")
1100
+ .action(async () => {
1101
+ await runHealth()
1102
+ })
1103
+
1104
+ program
1105
+ .command("audit")
1106
+ .description("Read-only audit for one chat and target user")
1107
+ .option("--profile <name>", "Profile name to use for Telegram credentials")
1108
+ .requiredOption(
1109
+ "--chat <id>",
1110
+ "Telegram chat ID or username",
1111
+ parsePeer,
1112
+ )
1113
+ .option(
1114
+ "--target-user <id>",
1115
+ "Target user ID or username",
1116
+ parsePeer,
1117
+ DEFAULT_USER_TARGET_ID,
1118
+ )
1119
+ .addHelpText(
1120
+ "after",
1121
+ `
1122
+ Examples:
1123
+ tg-sai audit --chat -1001234567890
1124
+ tg-sai audit --chat @example_sai_chat --target-user @example_user
1125
+ `,
1126
+ )
1127
+ .action(async (options: BaseCliOptions) => {
1128
+ await runAudit(options)
1129
+ })
1130
+
1131
+ program
1132
+ .command("add-bot")
1133
+ .description("Add SaiTeam_bot and promote it to admin when possible")
1134
+ .option("--profile <name>", "Profile name to use for Telegram credentials")
1135
+ .requiredOption(
1136
+ "--chat <id>",
1137
+ "Telegram chat ID or username",
1138
+ parsePeer,
1139
+ )
1140
+ .option(
1141
+ "--target-user <id>",
1142
+ "Bot user ID used for membership audit",
1143
+ parsePeer,
1144
+ DEFAULT_BOT_TARGET_ID,
1145
+ )
1146
+ .option(
1147
+ "--target-peer <peer>",
1148
+ "Bot username or peer used for add/promote writes",
1149
+ parsePeer,
1150
+ DEFAULT_BOT_TARGET_PEER,
1151
+ )
1152
+ .option("--run", "Execute add/promote actions; default is dry-run")
1153
+ .action(async (options: AddBotOptions) => {
1154
+ await runAddBot(options)
1155
+ })
1156
+
1157
+ program
1158
+ .command("add-owner")
1159
+ .description("Add a target user as owner if possible, otherwise admin")
1160
+ .option("--profile <name>", "Profile name to use for Telegram credentials")
1161
+ .requiredOption(
1162
+ "--chat <id>",
1163
+ "Telegram chat ID or username",
1164
+ parsePeer,
1165
+ )
1166
+ .requiredOption(
1167
+ "--target-user <peer>",
1168
+ "Target user ID or username",
1169
+ parsePeer,
1170
+ )
1171
+ .option("--run", "Execute add/promote actions; default is dry-run")
1172
+ .action(async (options: AddOwnerOptions) => {
1173
+ await runAddOwner(options)
1174
+ })
1175
+
1176
+ const ops = program
1177
+ .command("ops")
1178
+ .description("Sai Telegram operations for chat discovery and updates")
1179
+
1180
+ ops
1181
+ .command("sai-find")
1182
+ .description(
1183
+ "Find Sai-related chats visible to the active Telegram session",
1184
+ )
1185
+ .option("--profile <name>", "Profile name to use for Telegram credentials")
1186
+ .option("--limit <count>", "Maximum dialogs to inspect", "500")
1187
+ .option(
1188
+ "--archived <mode>",
1189
+ "Archive mode: exclude, include, or only",
1190
+ "exclude",
1191
+ )
1192
+ .option("--out <path>", "Also write a copy of the JSONL chat records")
1193
+ .action(async (options: SaiFindOptions) => {
1194
+ await runSaiFind(options)
1195
+ })
1196
+
1197
+ ops
1198
+ .command("sai-bootstrap")
1199
+ .description("Apply Sai bot and owner-target updates from a chat file")
1200
+ .option("--profile <name>", "Profile name to use for Telegram credentials")
1201
+ .requiredOption("--file <path>", "Chat input file")
1202
+ .option(
1203
+ "--delay-ms <ms>",
1204
+ "Delay between chats in milliseconds",
1205
+ String(DEFAULT_INTER_CHAT_DELAY_MS),
1206
+ )
1207
+ .option("--run", "Execute actions; default is dry-run")
1208
+ .option(
1209
+ "--no-transfer-owner",
1210
+ "Skip ownership transfer even when TELEGRAM_PASSWORD is available",
1211
+ )
1212
+ .action(async (options: SaiBootstrapOptions) => {
1213
+ await runSaiBootstrap(options)
1214
+ })
1215
+
1216
+ const profile = program
1217
+ .command("profile")
1218
+ .description("Manage local Telegram session profiles")
1219
+
1220
+ profile
1221
+ .command("find")
1222
+ .description("Find Telegram credential sources without adding a profile")
1223
+ .action(async () => {
1224
+ printDiscoveryReport(await discoveryReport())
1225
+ })
1226
+
1227
+ profile
1228
+ .command("list")
1229
+ .description("List configured profiles")
1230
+ .action(async () => {
1231
+ await runProfileList()
1232
+ })
1233
+
1234
+ profile
1235
+ .command("add")
1236
+ .description("Add or update a Telegram session profile")
1237
+ .requiredOption("--name <name>", "Profile name")
1238
+ .option("--api-id <id>", "Telegram API ID")
1239
+ .option("--api-hash <hash>", "Telegram API hash")
1240
+ .option("--session-string <value>", "Telegram session string")
1241
+ .option(
1242
+ "--password <value>",
1243
+ "Telegram 2FA password for ownership transfer",
1244
+ )
1245
+ .option("--src <number>", "Use a numbered source from profile find")
1246
+ .option("--login", "Generate a session string when missing")
1247
+ .option("--qr", "Use QR login with --login")
1248
+ .option("--phone", "Use phone login with --login")
1249
+ .option("--handle <handle>", "Resolved Telegram handle")
1250
+ .option("--user-id <id>", "Resolved Telegram user ID")
1251
+ .option("--display-name <name>", "Resolved Telegram display name")
1252
+ .action(async (options: ProfileAddOptions) => {
1253
+ await runProfileAdd(options)
1254
+ })
1255
+
1256
+ profile
1257
+ .command("show")
1258
+ .description("Show one configured profile")
1259
+ .argument("<name>", "Profile name")
1260
+ .action(async (name: string) => {
1261
+ await runProfileShow(name)
1262
+ })
1263
+
1264
+ profile
1265
+ .command("use")
1266
+ .description("Set the active profile")
1267
+ .argument("<name>", "Profile name")
1268
+ .action(async (name: string) => {
1269
+ await setActiveProfile(name)
1270
+ console.log(`active_profile: ${name}`)
1271
+ })
1272
+
1273
+ profile
1274
+ .command("remove")
1275
+ .description("Remove one configured profile")
1276
+ .argument("<name>", "Profile name")
1277
+ .action(async (name: string) => {
1278
+ await removeProfile(name)
1279
+ console.log(`removed_profile: ${name}`)
1280
+ })
1281
+
1282
+ return program
1283
+ }
1284
+
1285
+ createProgram()
1286
+ .parseAsync()
1287
+ .catch((error) => {
1288
+ console.error(error)
1289
+ process.exit(1)
1290
+ })