@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/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 = new TelegramClient({
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.4",
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": "./main.ts"
11
+ "tg-sai": "main.ts"
12
12
  },
13
13
  "scripts": {
14
14
  "start": "bun main.ts",
15
15
  "test": "bun test",
16
- "typecheck": "tsc --noEmit",
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.14",
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: string
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
- activeProfile: null,
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
- for (const profile of Object.values(parsed.profiles)) {
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 parsed
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 previous = config.profiles[input.name]
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
- config.profiles[input.name] = {
294
- name: input.name,
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
- const profileCount = Object.keys(config.profiles).length
307
- if (config.activeProfile === null || profileCount === 1) {
308
- config.activeProfile = input.name
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
- name: string,
379
+ selector: string,
317
380
  env: ProfileStoreEnv = process.env,
318
381
  ): Promise<TgSaiConfig> {
319
382
  const config = await readConfig(env)
320
- delete config.profiles[name]
321
-
322
- if (config.activeProfile === name) {
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
- name: string,
394
+ selector: string,
333
395
  env: ProfileStoreEnv = process.env,
334
396
  ): Promise<TgSaiConfig> {
335
397
  const config = await readConfig(env)
336
- if (!config.profiles[name]) {
337
- throw new Error(`Unknown profile: ${name}`)
398
+ const index = findProfileIndex(config, selector)
399
+ if (index === -1) {
400
+ throw new Error(`Unknown profile: ${selector}`)
338
401
  }
339
- config.activeProfile = name
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
- name: string | undefined,
410
+ selector: string | undefined,
347
411
  ): TgSaiProfile | null {
348
- const profileName = name ?? config.activeProfile
349
- return profileName ? (config.profiles[profileName] ?? null) : null
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
+ }