@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/README.md +21 -0
- package/RUNBOOK.md +343 -0
- package/actionExecutor.ts +219 -0
- package/adminAudit.ts +147 -0
- package/cfg.ts +29 -0
- package/health.ts +479 -0
- package/main.ts +1290 -0
- package/mtcuteAdapter.ts +268 -0
- package/ops.ts +105 -0
- package/package.json +41 -0
- package/profiles.ts +726 -0
- package/pyproject.toml +20 -0
- package/session_string_generator.py +267 -0
- package/uv.lock +227 -0
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
|
+
})
|