@uniquedivine/tg-sai 0.0.4 → 0.0.5
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 +24 -0
- package/RUNBOOK.md +137 -16
- package/actionExecutor.ts +55 -55
- package/adminAudit.ts +60 -60
- package/captureProvisioningGolden.ts +98 -0
- package/cfg.ts +12 -1
- package/custodyWorker.ts +259 -0
- package/custodyWorkerCli.ts +52 -0
- package/health.ts +31 -50
- package/main.ts +442 -100
- package/mtcuteAdapter.ts +14 -6
- package/ops.ts +12 -0
- package/package.json +5 -4
- package/profiles.ts +134 -27
- package/provisioning.ts +674 -0
- package/provisioningCli.ts +140 -0
- package/provisioningCloud.ts +214 -0
- package/provisioningGoldens.ts +70 -0
- package/provisioningRuntime.ts +380 -0
- package/runtimeAssets.d.ts +4 -0
- package/runtimeHealth.ts +125 -0
- package/runtimeHealthCli.ts +46 -0
- package/session_string_generator.py +36 -24
- package/telegramRuntime.ts +25 -0
package/mtcuteAdapter.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { TelegramClient } from "@mtcute/bun"
|
|
2
|
+
import { createTelegramCryptoProvider } from "./telegramRuntime"
|
|
2
3
|
import {
|
|
3
4
|
addChatMembers,
|
|
4
5
|
editAdminRights,
|
|
@@ -7,7 +8,6 @@ import {
|
|
|
7
8
|
transferChatOwnership,
|
|
8
9
|
} from "@mtcute/bun/methods.js"
|
|
9
10
|
import { convertFromTelethonSession } from "@mtcute/convert"
|
|
10
|
-
|
|
11
11
|
import type { ChatAuditInput, ChatMemberData, UserData } from "./adminAudit"
|
|
12
12
|
import type { SaiChatRecord } from "./ops"
|
|
13
13
|
import { isSaiChatTitle } from "./ops"
|
|
@@ -90,17 +90,25 @@ function isPeerNotFound(error: unknown): boolean {
|
|
|
90
90
|
)
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/** Shared constructor used by offline readiness and authenticated operations. */
|
|
94
|
+
export function createMtcuteRuntimeClient(
|
|
95
|
+
config: Omit<MtcuteClientConfig, "session">,
|
|
96
|
+
): TelegramClient {
|
|
97
|
+
return new TelegramClient({
|
|
98
|
+
crypto: createTelegramCryptoProvider(),
|
|
99
|
+
apiId: config.apiId,
|
|
100
|
+
apiHash: config.apiHash,
|
|
101
|
+
storage: config.storage ?? "sai-telegram-admin-check.session",
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
93
105
|
/**
|
|
94
106
|
* Creates and authenticates a mtcute client from a Telethon session string.
|
|
95
107
|
*/
|
|
96
108
|
export async function createMtcuteClient(
|
|
97
109
|
config: MtcuteClientConfig,
|
|
98
110
|
): Promise<TelegramClient> {
|
|
99
|
-
const client =
|
|
100
|
-
apiId: config.apiId,
|
|
101
|
-
apiHash: config.apiHash,
|
|
102
|
-
storage: config.storage ?? "sai-telegram-admin-check.session",
|
|
103
|
-
})
|
|
111
|
+
const client = createMtcuteRuntimeClient(config)
|
|
104
112
|
|
|
105
113
|
await client.importSession(convertFromTelethonSession(config.session), true)
|
|
106
114
|
return client
|
package/ops.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { USERS } from "./cfg"
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* One chat entry consumed by command `tg-sai ops sai-bootstrap`.
|
|
3
5
|
*
|
|
@@ -103,3 +105,13 @@ export function parseChatInputFile(contents: string): ChatInput[] {
|
|
|
103
105
|
export function formatSaiChatJsonLine(record: SaiChatRecord): string {
|
|
104
106
|
return JSON.stringify(record)
|
|
105
107
|
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Resolves the owner target for `ops sai-bootstrap`. Defaults to the Sai
|
|
111
|
+
* custody account so a bare run never hands chats to a personal account.
|
|
112
|
+
*/
|
|
113
|
+
export function resolveBootstrapTarget(
|
|
114
|
+
targetUser: number | string | undefined,
|
|
115
|
+
): number | string {
|
|
116
|
+
return targetUser ?? USERS.saiCustody.id
|
|
117
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniquedivine/tg-sai",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"private": false,
|
|
6
6
|
"description": "Sai Telegram admin audit and bootstrap CLI",
|
|
@@ -8,12 +8,12 @@
|
|
|
8
8
|
"bun": ">=1.0.0"
|
|
9
9
|
},
|
|
10
10
|
"bin": {
|
|
11
|
-
"tg-sai": "
|
|
11
|
+
"tg-sai": "main.ts"
|
|
12
12
|
},
|
|
13
13
|
"scripts": {
|
|
14
14
|
"start": "bun main.ts",
|
|
15
15
|
"test": "bun test",
|
|
16
|
-
"typecheck": "
|
|
16
|
+
"typecheck": "bun check",
|
|
17
17
|
"py-test": "uv run pytest session_string_generator_test.py"
|
|
18
18
|
},
|
|
19
19
|
"files": [
|
|
@@ -30,12 +30,13 @@
|
|
|
30
30
|
],
|
|
31
31
|
"license": "UNLICENSED",
|
|
32
32
|
"devDependencies": {
|
|
33
|
-
"@types/bun": "^1.3
|
|
33
|
+
"@types/bun": "^1.4.3",
|
|
34
34
|
"typescript": "^6.0.3"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"@mtcute/bun": "^0.30.1",
|
|
38
38
|
"@mtcute/convert": "^0.30.1",
|
|
39
|
+
"@mtcute/wasm": "^0.29.0",
|
|
39
40
|
"commander": "^15.0.0"
|
|
40
41
|
}
|
|
41
42
|
}
|
package/profiles.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { mkdir, readFile, rm, writeFile } from "node:fs/promises"
|
|
2
2
|
import { homedir, platform as osPlatform } from "node:os"
|
|
3
3
|
import { dirname, join } from "node:path"
|
|
4
|
-
|
|
5
4
|
import type { UserData } from "./adminAudit"
|
|
6
5
|
|
|
7
6
|
const TG_SAI_DIR_ENV = "TG_SAI_DIR"
|
|
@@ -44,12 +43,16 @@ export interface TgSaiProfile {
|
|
|
44
43
|
}
|
|
45
44
|
|
|
46
45
|
export interface TgSaiConfig {
|
|
46
|
+
profiles: TgSaiProfile[]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface LegacyTgSaiConfig {
|
|
47
50
|
activeProfile: string | null
|
|
48
51
|
profiles: Record<string, TgSaiProfile>
|
|
49
52
|
}
|
|
50
53
|
|
|
51
54
|
export interface ProfileAddInput {
|
|
52
|
-
name
|
|
55
|
+
name?: string
|
|
53
56
|
apiId: number | null
|
|
54
57
|
apiHash: string
|
|
55
58
|
sessionString: string
|
|
@@ -139,8 +142,7 @@ export const WINDOWS_DISCOVERY_PATHS = [
|
|
|
139
142
|
] as const
|
|
140
143
|
|
|
141
144
|
export const emptyConfig = (): TgSaiConfig => ({
|
|
142
|
-
|
|
143
|
-
profiles: {},
|
|
145
|
+
profiles: [],
|
|
144
146
|
})
|
|
145
147
|
|
|
146
148
|
export function homeDir(env: ProfileStoreEnv = process.env): string {
|
|
@@ -236,11 +238,12 @@ export async function readConfig(
|
|
|
236
238
|
): Promise<TgSaiConfig> {
|
|
237
239
|
try {
|
|
238
240
|
const raw = await readFile(configPath(env), "utf8")
|
|
239
|
-
const parsed = JSON.parse(raw) as TgSaiConfig
|
|
240
|
-
|
|
241
|
+
const parsed = JSON.parse(raw) as TgSaiConfig | LegacyTgSaiConfig
|
|
242
|
+
const config = normalizeConfig(parsed)
|
|
243
|
+
for (const profile of config.profiles) {
|
|
241
244
|
profile.password = profile.password ?? ""
|
|
242
245
|
}
|
|
243
|
-
return
|
|
246
|
+
return config
|
|
244
247
|
} catch (error) {
|
|
245
248
|
if (
|
|
246
249
|
error instanceof Error &&
|
|
@@ -253,6 +256,44 @@ export async function readConfig(
|
|
|
253
256
|
}
|
|
254
257
|
}
|
|
255
258
|
|
|
259
|
+
export function normalizeConfig(
|
|
260
|
+
config: TgSaiConfig | LegacyTgSaiConfig,
|
|
261
|
+
): TgSaiConfig {
|
|
262
|
+
if (Array.isArray(config.profiles)) {
|
|
263
|
+
return {
|
|
264
|
+
profiles: config.profiles.map((profile, index) =>
|
|
265
|
+
normalizeProfile(profile, index),
|
|
266
|
+
),
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const legacyConfig = config as LegacyTgSaiConfig
|
|
271
|
+
const legacyProfiles = Object.entries(legacyConfig.profiles)
|
|
272
|
+
const orderedNames = [
|
|
273
|
+
...(legacyConfig.activeProfile &&
|
|
274
|
+
legacyConfig.profiles[legacyConfig.activeProfile]
|
|
275
|
+
? [legacyConfig.activeProfile]
|
|
276
|
+
: []),
|
|
277
|
+
...legacyProfiles
|
|
278
|
+
.map(([name]) => name)
|
|
279
|
+
.filter((name) => name !== legacyConfig.activeProfile),
|
|
280
|
+
]
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
profiles: orderedNames.map((name, index) =>
|
|
284
|
+
normalizeProfile({ ...legacyConfig.profiles[name]!, name }, index),
|
|
285
|
+
),
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function normalizeProfile(profile: TgSaiProfile, index: number): TgSaiProfile {
|
|
290
|
+
return {
|
|
291
|
+
...profile,
|
|
292
|
+
name: profile.name || profile.handle || `profile-${index}`,
|
|
293
|
+
password: profile.password ?? "",
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
256
297
|
export async function writeConfig(
|
|
257
298
|
config: TgSaiConfig,
|
|
258
299
|
env: ProfileStoreEnv = process.env,
|
|
@@ -273,25 +314,46 @@ export async function clearConfig(
|
|
|
273
314
|
export function profileFromInput(
|
|
274
315
|
input: ProfileAddInput,
|
|
275
316
|
now = new Date(),
|
|
317
|
+
index = 0,
|
|
276
318
|
): TgSaiProfile {
|
|
277
319
|
const timestamp = now.toISOString()
|
|
278
320
|
return {
|
|
279
321
|
...input,
|
|
322
|
+
name: profileNameFromInput(input, index),
|
|
280
323
|
createdAt: timestamp,
|
|
281
324
|
updatedAt: timestamp,
|
|
282
325
|
}
|
|
283
326
|
}
|
|
284
327
|
|
|
328
|
+
function profileNameFromInput(input: ProfileAddInput, index: number): string {
|
|
329
|
+
return (
|
|
330
|
+
input.name ??
|
|
331
|
+
input.handle ??
|
|
332
|
+
(input.userId !== null ? String(input.userId) : null) ??
|
|
333
|
+
`profile-${index}`
|
|
334
|
+
)
|
|
335
|
+
}
|
|
336
|
+
|
|
285
337
|
export async function addProfile(
|
|
286
338
|
input: ProfileAddInput,
|
|
287
339
|
env: ProfileStoreEnv = process.env,
|
|
288
340
|
now = new Date(),
|
|
289
341
|
): Promise<TgSaiConfig> {
|
|
290
342
|
const config = await readConfig(env)
|
|
291
|
-
const
|
|
343
|
+
const index =
|
|
344
|
+
input.name !== undefined
|
|
345
|
+
? findProfileIndex(config, input.name)
|
|
346
|
+
: config.profiles.length > 0
|
|
347
|
+
? 0
|
|
348
|
+
: -1
|
|
349
|
+
const targetIndex = index >= 0 ? index : config.profiles.length
|
|
350
|
+
const previous = index >= 0 ? config.profiles[index] : undefined
|
|
351
|
+
if (previous?.userId != null && input.userId != null && input.userId !== previous.userId) {
|
|
352
|
+
throw new Error("Cannot replace a saved profile's account identity. Use a new profile name or explicitly remove the old profile first.")
|
|
353
|
+
}
|
|
292
354
|
const timestamp = now.toISOString()
|
|
293
|
-
|
|
294
|
-
name: input
|
|
355
|
+
const nextProfile: TgSaiProfile = {
|
|
356
|
+
name: profileNameFromInput(input, targetIndex),
|
|
295
357
|
apiId: input.apiId ?? previous?.apiId ?? null,
|
|
296
358
|
apiHash: input.apiHash || previous?.apiHash || "",
|
|
297
359
|
sessionString: input.sessionString || previous?.sessionString || "",
|
|
@@ -303,9 +365,10 @@ export async function addProfile(
|
|
|
303
365
|
updatedAt: timestamp,
|
|
304
366
|
}
|
|
305
367
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
368
|
+
if (index >= 0) {
|
|
369
|
+
config.profiles[index] = nextProfile
|
|
370
|
+
} else {
|
|
371
|
+
config.profiles.push(nextProfile)
|
|
309
372
|
}
|
|
310
373
|
|
|
311
374
|
await writeConfig(config, env)
|
|
@@ -313,40 +376,69 @@ export async function addProfile(
|
|
|
313
376
|
}
|
|
314
377
|
|
|
315
378
|
export async function removeProfile(
|
|
316
|
-
|
|
379
|
+
selector: string,
|
|
317
380
|
env: ProfileStoreEnv = process.env,
|
|
318
381
|
): Promise<TgSaiConfig> {
|
|
319
382
|
const config = await readConfig(env)
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
const remaining = Object.keys(config.profiles)
|
|
324
|
-
config.activeProfile = remaining.length === 1 ? remaining[0]! : null
|
|
383
|
+
const index = findProfileIndex(config, selector)
|
|
384
|
+
if (index === -1) {
|
|
385
|
+
throw new Error(`Unknown profile: ${selector}`)
|
|
325
386
|
}
|
|
387
|
+
config.profiles.splice(index, 1)
|
|
326
388
|
|
|
327
389
|
await writeConfig(config, env)
|
|
328
390
|
return config
|
|
329
391
|
}
|
|
330
392
|
|
|
331
393
|
export async function setActiveProfile(
|
|
332
|
-
|
|
394
|
+
selector: string,
|
|
333
395
|
env: ProfileStoreEnv = process.env,
|
|
334
396
|
): Promise<TgSaiConfig> {
|
|
335
397
|
const config = await readConfig(env)
|
|
336
|
-
|
|
337
|
-
|
|
398
|
+
const index = findProfileIndex(config, selector)
|
|
399
|
+
if (index === -1) {
|
|
400
|
+
throw new Error(`Unknown profile: ${selector}`)
|
|
338
401
|
}
|
|
339
|
-
config.
|
|
402
|
+
const [profile] = config.profiles.splice(index, 1)
|
|
403
|
+
config.profiles.unshift(profile!)
|
|
340
404
|
await writeConfig(config, env)
|
|
341
405
|
return config
|
|
342
406
|
}
|
|
343
407
|
|
|
344
408
|
export function getProfile(
|
|
345
409
|
config: TgSaiConfig,
|
|
346
|
-
|
|
410
|
+
selector: string | undefined,
|
|
347
411
|
): TgSaiProfile | null {
|
|
348
|
-
|
|
349
|
-
|
|
412
|
+
if (selector === undefined) {
|
|
413
|
+
return config.profiles[0] ?? null
|
|
414
|
+
}
|
|
415
|
+
const index = findProfileIndex(config, selector)
|
|
416
|
+
return index === -1 ? null : config.profiles[index]!
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export function findProfileIndex(
|
|
420
|
+
config: TgSaiConfig,
|
|
421
|
+
selector: string,
|
|
422
|
+
): number {
|
|
423
|
+
const exactMatch = config.profiles.findIndex(
|
|
424
|
+
(profile) =>
|
|
425
|
+
profile.name === selector ||
|
|
426
|
+
profile.handle === selector ||
|
|
427
|
+
String(profile.userId) === selector,
|
|
428
|
+
)
|
|
429
|
+
if (exactMatch !== -1) {
|
|
430
|
+
return exactMatch
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const asIndex = Number(selector)
|
|
434
|
+
if (Number.isInteger(asIndex) && asIndex >= 0) {
|
|
435
|
+
return asIndex < config.profiles.length ? asIndex : -1
|
|
436
|
+
}
|
|
437
|
+
return -1
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export function profileLabel(profile: TgSaiProfile, index: number): string {
|
|
441
|
+
return profile.handle ?? profile.name ?? `profile-${index}`
|
|
350
442
|
}
|
|
351
443
|
|
|
352
444
|
export function redactedProfile(profile: TgSaiProfile) {
|
|
@@ -713,7 +805,7 @@ export async function resolveTelegramCredentials(
|
|
|
713
805
|
}
|
|
714
806
|
|
|
715
807
|
export function profileInputFromUser(
|
|
716
|
-
name: string,
|
|
808
|
+
name: string | undefined,
|
|
717
809
|
credentials: TelegramCredentials,
|
|
718
810
|
user: UserData,
|
|
719
811
|
password = "",
|
|
@@ -729,3 +821,18 @@ export function profileInputFromUser(
|
|
|
729
821
|
displayName: user.displayName,
|
|
730
822
|
}
|
|
731
823
|
}
|
|
824
|
+
|
|
825
|
+
/** Provisioning must never inherit another account from environment or discovery. */
|
|
826
|
+
export async function strictProfileCredentials(
|
|
827
|
+
selector: string,
|
|
828
|
+
expectedUserId: number,
|
|
829
|
+
env: ProfileStoreEnv = process.env,
|
|
830
|
+
): Promise<TelegramCredentials> {
|
|
831
|
+
if (!selector?.trim()) throw new Error("Explicit custody profile required")
|
|
832
|
+
const profile = getProfile(await readConfig(env), selector)
|
|
833
|
+
if (!profile) throw new Error("Custody profile is not configured")
|
|
834
|
+
if (profile.userId !== expectedUserId) throw new Error("Custody profile user ID mismatch")
|
|
835
|
+
const credentials = credentialsFromProfileRecord(profile)
|
|
836
|
+
if (!credentials) throw new Error("Custody profile is incomplete")
|
|
837
|
+
return credentials
|
|
838
|
+
}
|