@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/adminAudit.ts ADDED
@@ -0,0 +1,147 @@
1
+ export interface TestData<T> {
2
+ goldReq: string;
3
+ notes?: string;
4
+ data: T;
5
+ }
6
+
7
+ export interface UserData {
8
+ id: number;
9
+ username: string | null;
10
+ displayName: string;
11
+ isBot: boolean;
12
+ rawType: string;
13
+ }
14
+
15
+ export interface ChatMemberData {
16
+ user: UserData;
17
+ status: string;
18
+ title: string | null;
19
+ invitedBy: UserData | null;
20
+ promotedBy: UserData | null;
21
+ restrictedBy: UserData | null;
22
+ restrictions: unknown | null;
23
+ isMember: boolean;
24
+ permissions: AdminPermissionsData | null;
25
+ rawType: string;
26
+ }
27
+
28
+ export interface AdminPermissionsData {
29
+ addAdmins?: boolean;
30
+ add_admins?: boolean;
31
+ [key: string]: unknown;
32
+ }
33
+
34
+ export interface ChatAuditInput {
35
+ chatId: number | string;
36
+ targetUserId: number | string;
37
+ self: UserData;
38
+ actor: ChatMemberData | null;
39
+ target: ChatMemberData | null;
40
+ }
41
+
42
+ export interface ChatAuditResult {
43
+ chatId: number | string;
44
+ targetUserId: number | string;
45
+ actorRole: MemberRole;
46
+ targetRole: MemberRole;
47
+ actorCanApply: boolean;
48
+ actorCanTransferOwnership: boolean;
49
+ targetCanReceiveOwnership: boolean;
50
+ targetIsAdmin: boolean;
51
+ proposedActions: ProposedAction[];
52
+ }
53
+
54
+ export type MemberRole =
55
+ | "creator"
56
+ | "admin"
57
+ | "member"
58
+ | "restricted"
59
+ | "banned"
60
+ | "left"
61
+ | "not_present"
62
+ | "unknown";
63
+
64
+ export type ProposedAction =
65
+ | "skip: actor lacks owner/admin rights"
66
+ | "add target user"
67
+ | "promote target user admin"
68
+ | "noop: target user already admin";
69
+
70
+ export function deriveMemberRole(member: ChatMemberData | null): MemberRole {
71
+ if (!member) {
72
+ return "not_present";
73
+ }
74
+ switch (member.status) {
75
+ case "creator":
76
+ case "admin":
77
+ case "member":
78
+ case "restricted":
79
+ case "banned":
80
+ case "left":
81
+ return member.status;
82
+ default:
83
+ return "unknown";
84
+ }
85
+ }
86
+
87
+ export function hasAddAdminsRight(
88
+ permissions: AdminPermissionsData | null,
89
+ ): boolean {
90
+ if (!permissions) {
91
+ return false;
92
+ }
93
+ return Boolean(permissions.addAdmins || permissions.add_admins);
94
+ }
95
+
96
+ export function canActorPromoteAdmins(input: ChatAuditInput): boolean {
97
+ const actorRole = deriveMemberRole(input.actor);
98
+ if (actorRole === "creator") {
99
+ return true;
100
+ }
101
+ if (actorRole !== "admin") {
102
+ return false;
103
+ }
104
+ return hasAddAdminsRight(input.actor?.permissions ?? null);
105
+ }
106
+
107
+ export function canActorTransferOwnership(input: ChatAuditInput): boolean {
108
+ return deriveMemberRole(input.actor) === "creator";
109
+ }
110
+
111
+ export function isAdminRole(role: MemberRole): boolean {
112
+ return role === "admin" || role === "creator";
113
+ }
114
+
115
+ export function planAdminBootstrap(input: ChatAuditInput): ChatAuditResult {
116
+ const actorRole = deriveMemberRole(input.actor);
117
+ const targetRole = deriveMemberRole(input.target);
118
+ const actorCanApply = canActorPromoteAdmins(input);
119
+ const actorCanTransferOwnership = canActorTransferOwnership(input);
120
+ const targetIsAdmin = isAdminRole(targetRole);
121
+ const targetCanReceiveOwnership =
122
+ targetRole === "admin" && input.target?.user.isBot !== true;
123
+
124
+ const proposedActions: ProposedAction[] = [];
125
+ if (!actorCanApply) {
126
+ proposedActions.push("skip: actor lacks owner/admin rights");
127
+ } else if (targetIsAdmin) {
128
+ proposedActions.push("noop: target user already admin");
129
+ } else {
130
+ if (targetRole === "not_present" || targetRole === "left") {
131
+ proposedActions.push("add target user");
132
+ }
133
+ proposedActions.push("promote target user admin");
134
+ }
135
+
136
+ return {
137
+ chatId: input.chatId,
138
+ targetUserId: input.targetUserId,
139
+ actorRole,
140
+ targetRole,
141
+ actorCanApply,
142
+ actorCanTransferOwnership,
143
+ targetCanReceiveOwnership,
144
+ targetIsAdmin,
145
+ proposedActions,
146
+ };
147
+ }
package/cfg.ts ADDED
@@ -0,0 +1,29 @@
1
+ export interface TelegramIdentity {
2
+ id: number
3
+ handle: string
4
+ displayName: string
5
+ kind: "user" | "bot"
6
+ }
7
+
8
+ /**
9
+ * Telegram bot identity for the Sai team bot. The bot is the automation target
10
+ * to add and promote in candidate chats.
11
+ */
12
+ export const saiBot = {
13
+ id: 8983431101,
14
+ handle: "SaiTeam_bot",
15
+ displayName: "Sai Team",
16
+ kind: "bot",
17
+ } satisfies TelegramIdentity
18
+
19
+ /**
20
+ * Telegram user identity for Unique (`uniquedivine`). In this workflow, Unique
21
+ * is the target user to promote to admin or prepare for ownership transfer.
22
+ */
23
+ export const uniqueDivine = {
24
+ id: 1912700300,
25
+ handle: "uniquedivine",
26
+ displayName: "Unique (NIBIRU)",
27
+ kind: "user",
28
+ } satisfies TelegramIdentity
29
+
package/health.ts ADDED
@@ -0,0 +1,479 @@
1
+ import { constants } from "node:fs"
2
+ import { access, mkdir, readdir, readFile, stat } from "node:fs/promises"
3
+ import { join } from "node:path"
4
+
5
+ import { configDir, configPath, readConfig } from "./profiles"
6
+ import type {
7
+ ProfileStoreEnv,
8
+ TelegramCredentials,
9
+ TgSaiProfile,
10
+ } from "./profiles"
11
+
12
+ const ERROR_PREFIX = "❌😭"
13
+ const WARNING_PREFIX = "⚠️"
14
+ const GOOD_PREFIX = "✅🔥"
15
+
16
+ export interface HealthUser {
17
+ id: number
18
+ username: string | null
19
+ displayName: string
20
+ isBot: boolean
21
+ }
22
+
23
+ export interface HealthClient {
24
+ getMe(): Promise<HealthUser>
25
+ disconnect(): Promise<void>
26
+ }
27
+
28
+ export type HealthClientFactory = (
29
+ credentials: TelegramCredentials,
30
+ ) => Promise<HealthClient>
31
+
32
+ export interface HealthResult {
33
+ exitCode: 0 | 1 | 2
34
+ lines: string[]
35
+ errorCount: number
36
+ warningCount: number
37
+ }
38
+
39
+ interface HealthCollector {
40
+ lines: string[]
41
+ problems: HealthProblem[]
42
+ errorCount: number
43
+ warningCount: number
44
+ }
45
+
46
+ interface HealthProblem {
47
+ key: string
48
+ severity: "error" | "warning"
49
+ fix: string[]
50
+ }
51
+
52
+ function createCollector(): HealthCollector {
53
+ return {
54
+ lines: [],
55
+ problems: [],
56
+ errorCount: 0,
57
+ warningCount: 0,
58
+ }
59
+ }
60
+
61
+ function info(collector: HealthCollector, line: string): void {
62
+ collector.lines.push(line)
63
+ }
64
+
65
+ function good(collector: HealthCollector, line: string): void {
66
+ collector.lines.push(`${GOOD_PREFIX} ${line}`)
67
+ }
68
+
69
+ function warning(
70
+ collector: HealthCollector,
71
+ key: string,
72
+ line: string,
73
+ fix: string[],
74
+ ): void {
75
+ collector.warningCount += 1
76
+ collector.lines.push(`${WARNING_PREFIX} ${line}`)
77
+ collector.problems.push({ key, severity: "warning", fix })
78
+ }
79
+
80
+ function error(
81
+ collector: HealthCollector,
82
+ key: string,
83
+ line: string,
84
+ fix: string[],
85
+ ): void {
86
+ collector.errorCount += 1
87
+ collector.lines.push(`${ERROR_PREFIX} ${line}`)
88
+ collector.problems.push({ key, severity: "error", fix })
89
+ }
90
+
91
+ function exitCode(collector: HealthCollector): 0 | 1 | 2 {
92
+ if (collector.errorCount > 0) {
93
+ return 1
94
+ }
95
+ if (collector.warningCount > 0) {
96
+ return 2
97
+ }
98
+ return 0
99
+ }
100
+
101
+ function errorMessage(value: unknown): string {
102
+ return value instanceof Error ? value.message : String(value)
103
+ }
104
+
105
+ function directoryFix(path: string): string[] {
106
+ return [
107
+ `Make sure the directory exists and is writable: ${path}`,
108
+ `mkdir -p ${path}`,
109
+ `chmod 700 ${path}`,
110
+ ]
111
+ }
112
+
113
+ function missingProfileFix(profileName?: string): string[] {
114
+ const addCommand = profileName
115
+ ? `tg-sai profile add --name ${profileName} --src <number>`
116
+ : "tg-sai profile add --name <name> --src <number>"
117
+ return [
118
+ "Find a Telegram credential source and save it as a tg-sai profile.",
119
+ "tg-sai profile find",
120
+ addCommand,
121
+ ]
122
+ }
123
+
124
+ function incompleteProfileFix(profileName: string, fields: string[]): string[] {
125
+ return [
126
+ `Fill missing profile field(s): ${fields.join(",")}`,
127
+ "Check the redacted active profile:",
128
+ `tg-sai profile show ${profileName}`,
129
+ "Then refresh it from a complete credential source:",
130
+ "tg-sai profile find",
131
+ `tg-sai profile add --name ${profileName} --src <number>`,
132
+ ]
133
+ }
134
+
135
+ function connectionFix(profileName: string): string[] {
136
+ return [
137
+ "Check the active profile API credentials and Telegram session string.",
138
+ `tg-sai profile show ${profileName}`,
139
+ "If apiId, apiHash, or sessionString are stale, refresh from a known-good source:",
140
+ "tg-sai profile find",
141
+ `tg-sai profile add --name ${profileName} --src <number>`,
142
+ ]
143
+ }
144
+
145
+ function passwordFix(profileName?: string): string[] {
146
+ const saveCommand = profileName
147
+ ? `tg-sai profile add --name ${profileName} --password '<password>'`
148
+ : "tg-sai profile add --name <name> --password '<password>'"
149
+ return [
150
+ "Save the Telegram 2FA password in the active tg-sai profile:",
151
+ saveCommand,
152
+ "You can also override it for one command with TELEGRAM_PASSWORD.",
153
+ ]
154
+ }
155
+
156
+ function renderSuggestedFixes(collector: HealthCollector): void {
157
+ if (collector.problems.length === 0) {
158
+ return
159
+ }
160
+
161
+ info(collector, "")
162
+ info(collector, "suggested_fixes:")
163
+ for (const problem of collector.problems) {
164
+ info(collector, `- ${problem.key} (${problem.severity}):`)
165
+ for (const line of problem.fix) {
166
+ info(collector, ` ${line}`)
167
+ }
168
+ }
169
+ }
170
+
171
+ async function ensureWritableDirectory(
172
+ collector: HealthCollector,
173
+ label: string,
174
+ path: string,
175
+ ): Promise<boolean> {
176
+ try {
177
+ await mkdir(path, { recursive: true, mode: 0o700 })
178
+ await access(path, constants.W_OK)
179
+ info(collector, `${label}: ${path}`)
180
+ return true
181
+ } catch (caught) {
182
+ error(
183
+ collector,
184
+ label,
185
+ `${label}: ${path} not_writable ${errorMessage(caught)}`,
186
+ directoryFix(path),
187
+ )
188
+ return false
189
+ }
190
+ }
191
+
192
+ async function profileFileExists(env: ProfileStoreEnv): Promise<boolean> {
193
+ try {
194
+ const fileStat = await stat(configPath(env))
195
+ return fileStat.isFile()
196
+ } catch (caught) {
197
+ if (
198
+ caught instanceof Error &&
199
+ "code" in caught &&
200
+ (caught as NodeJS.ErrnoException).code === "ENOENT"
201
+ ) {
202
+ return false
203
+ }
204
+ throw caught
205
+ }
206
+ }
207
+
208
+ export function missingProfileFields(profile: TgSaiProfile): string[] {
209
+ const missing: string[] = []
210
+ if (!profile.apiId) {
211
+ missing.push("apiId")
212
+ }
213
+ if (!profile.apiHash) {
214
+ missing.push("apiHash")
215
+ }
216
+ if (!profile.sessionString) {
217
+ missing.push("sessionString")
218
+ }
219
+ return missing
220
+ }
221
+
222
+ function credentialsFromCompleteProfile(
223
+ profile: TgSaiProfile,
224
+ ): TelegramCredentials {
225
+ return {
226
+ apiId: profile.apiId ?? 0,
227
+ apiHash: profile.apiHash,
228
+ sessionString: profile.sessionString,
229
+ }
230
+ }
231
+
232
+ async function checkTelegramConnection(params: {
233
+ collector: HealthCollector
234
+ profile: TgSaiProfile | null
235
+ missingFields: string[]
236
+ createClient: HealthClientFactory
237
+ }): Promise<void> {
238
+ const { collector, profile, missingFields, createClient } = params
239
+ if (!profile) {
240
+ error(
241
+ collector,
242
+ "telegram_connection",
243
+ "telegram_connection: skipped because no active profile is configured",
244
+ missingProfileFix(),
245
+ )
246
+ return
247
+ }
248
+ if (missingFields.length > 0) {
249
+ error(
250
+ collector,
251
+ "telegram_connection",
252
+ "telegram_connection: skipped because active profile is incomplete",
253
+ incompleteProfileFix(profile.name, missingFields),
254
+ )
255
+ return
256
+ }
257
+
258
+ let client: HealthClient | null = null
259
+ try {
260
+ client = await createClient(credentialsFromCompleteProfile(profile))
261
+ const user = await client.getMe()
262
+ good(collector, "telegram_connection: ok")
263
+ info(
264
+ collector,
265
+ `session_user: ${user.id} ${user.username ?? ""} "${user.displayName}"`,
266
+ )
267
+ if (user.isBot) {
268
+ warning(
269
+ collector,
270
+ "session_user_is_bot",
271
+ "session_user_is_bot: true; tg-sai is designed for user-account sessions",
272
+ [
273
+ "Use a Telegram user-account session for tg-sai admin operations.",
274
+ "Switch to a user profile:",
275
+ "tg-sai profile list",
276
+ "tg-sai profile use <user-profile-name>",
277
+ ],
278
+ )
279
+ }
280
+ } catch (caught) {
281
+ error(
282
+ collector,
283
+ "telegram_connection",
284
+ `telegram_connection: failed ${errorMessage(caught)}`,
285
+ connectionFix(profile.name),
286
+ )
287
+ } finally {
288
+ if (client) {
289
+ await client.disconnect()
290
+ }
291
+ }
292
+ }
293
+
294
+ function checkPassword(
295
+ collector: HealthCollector,
296
+ env: ProfileStoreEnv,
297
+ profile: TgSaiProfile | null,
298
+ ): void {
299
+ if (env.TELEGRAM_PASSWORD || profile?.password) {
300
+ info(collector, "password_status: present")
301
+ return
302
+ }
303
+ warning(
304
+ collector,
305
+ "password_status",
306
+ "password_status: missing",
307
+ passwordFix(profile?.name),
308
+ )
309
+ info(
310
+ collector,
311
+ "No profile password or TELEGRAM_PASSWORD is set; ownership transfer will be skipped",
312
+ )
313
+ }
314
+
315
+ function latestNumberedJsonl(files: string[], prefix: string): string | null {
316
+ const matcher = new RegExp(`^${prefix}-(\\d+)\\.jsonl$`)
317
+ let latest: { file: string; index: number } | null = null
318
+ for (const file of files) {
319
+ const match = file.match(matcher)
320
+ if (!match) {
321
+ continue
322
+ }
323
+ const index = Number(match[1])
324
+ if (!latest || index > latest.index) {
325
+ latest = { file, index }
326
+ }
327
+ }
328
+ return latest?.file ?? null
329
+ }
330
+
331
+ async function countJsonlRecords(path: string): Promise<number> {
332
+ const text = await readFile(path, "utf8")
333
+ return text.split(/\r?\n/).filter((line) => line.trim().length > 0).length
334
+ }
335
+
336
+ async function checkLogs(
337
+ collector: HealthCollector,
338
+ logsPath: string,
339
+ ): Promise<void> {
340
+ let files: string[] = []
341
+ try {
342
+ files = await readdir(logsPath)
343
+ } catch (caught) {
344
+ error(
345
+ collector,
346
+ "logs_scan",
347
+ `logs_scan: failed ${errorMessage(caught)}`,
348
+ directoryFix(logsPath),
349
+ )
350
+ return
351
+ }
352
+
353
+ const saiFind = latestNumberedJsonl(files, "sai-find")
354
+ if (saiFind) {
355
+ const path = join(logsPath, saiFind)
356
+ info(collector, `latest_sai_find: ${path}`)
357
+ info(
358
+ collector,
359
+ `latest_sai_find_chat_count: ${await countJsonlRecords(path)}`,
360
+ )
361
+ } else {
362
+ info(collector, "latest_sai_find: none")
363
+ info(collector, "next: tg-sai ops sai-find")
364
+ }
365
+
366
+ const saiBootstrap = latestNumberedJsonl(files, "sai-bootstrap")
367
+ if (saiBootstrap) {
368
+ info(collector, `latest_sai_bootstrap: ${join(logsPath, saiBootstrap)}`)
369
+ } else {
370
+ info(collector, "latest_sai_bootstrap: none")
371
+ }
372
+ }
373
+
374
+ export async function runHealthCheck(params: {
375
+ env?: ProfileStoreEnv
376
+ createClient: HealthClientFactory
377
+ }): Promise<HealthResult> {
378
+ const env = params.env ?? process.env
379
+ const collector = createCollector()
380
+ const toolPath = configDir(env)
381
+ const logsPath = join(toolPath, "logs")
382
+
383
+ await ensureWritableDirectory(collector, "tool_dir", toolPath)
384
+ const logsWritable = await ensureWritableDirectory(
385
+ collector,
386
+ "logs_dir",
387
+ logsPath,
388
+ )
389
+
390
+ if (await profileFileExists(env)) {
391
+ info(collector, `profile_file: ${configPath(env)}`)
392
+ } else {
393
+ info(collector, `profile_file: missing ${configPath(env)}`)
394
+ }
395
+
396
+ const config = await readConfig(env)
397
+ const activeProfileName = config.activeProfile
398
+ const activeProfile = activeProfileName
399
+ ? (config.profiles[activeProfileName] ?? null)
400
+ : null
401
+ const missingFields = activeProfile ? missingProfileFields(activeProfile) : []
402
+
403
+ if (!activeProfileName) {
404
+ error(
405
+ collector,
406
+ "active_profile",
407
+ "active_profile: missing",
408
+ missingProfileFix(),
409
+ )
410
+ error(
411
+ collector,
412
+ "profile_status",
413
+ "profile_status: missing",
414
+ missingProfileFix(),
415
+ )
416
+ } else if (!activeProfile) {
417
+ error(
418
+ collector,
419
+ "active_profile",
420
+ `active_profile: ${activeProfileName}`,
421
+ missingProfileFix(activeProfileName),
422
+ )
423
+ error(
424
+ collector,
425
+ "profile_status",
426
+ "profile_status: missing",
427
+ missingProfileFix(activeProfileName),
428
+ )
429
+ } else {
430
+ info(collector, `active_profile: ${activeProfile.name}`)
431
+ if (missingFields.length > 0) {
432
+ error(
433
+ collector,
434
+ "profile_status",
435
+ "profile_status: incomplete",
436
+ incompleteProfileFix(activeProfile.name, missingFields),
437
+ )
438
+ error(
439
+ collector,
440
+ "missing_fields",
441
+ `missing_fields: ${missingFields.join(",")}`,
442
+ incompleteProfileFix(activeProfile.name, missingFields),
443
+ )
444
+ } else {
445
+ good(collector, "profile_status: complete")
446
+ }
447
+ }
448
+
449
+ await checkTelegramConnection({
450
+ collector,
451
+ profile: activeProfile,
452
+ missingFields,
453
+ createClient: params.createClient,
454
+ })
455
+ checkPassword(collector, env, activeProfile)
456
+ if (logsWritable) {
457
+ await checkLogs(collector, logsPath)
458
+ }
459
+
460
+ if (!activeProfileName) {
461
+ info(collector, "next: tg-sai profile find")
462
+ info(collector, "next: tg-sai profile add --name <name> --src <number>")
463
+ } else if (!activeProfile || missingFields.length > 0) {
464
+ info(
465
+ collector,
466
+ `next: tg-sai profile add --name ${activeProfileName} --src <number>`,
467
+ )
468
+ }
469
+
470
+ renderSuggestedFixes(collector)
471
+ const code = exitCode(collector)
472
+ info(collector, `exit_code: ${code}`)
473
+ return {
474
+ exitCode: code,
475
+ lines: collector.lines,
476
+ errorCount: collector.errorCount,
477
+ warningCount: collector.warningCount,
478
+ }
479
+ }