@uniquedivine/tg-sai 0.0.4 → 0.1.0

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/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) {
@@ -679,13 +771,24 @@ export async function credentialsFromProfile(
679
771
  return credentialsFromProfileRecord(profile)
680
772
  }
681
773
 
682
- export async function resolveTelegramCredentials(
774
+ /** Where `resolveTelegramCredentialSource` found the credentials. */
775
+ export type TelegramCredentialSource = "env" | "profile" | "known_file"
776
+
777
+ /**
778
+ * Credentials for `profileName` and where they came from: `TELEGRAM_*`
779
+ * environment variables, the selected profile, or a known credential file.
780
+ * Only `profile` credentials match the profile's saved `userId`.
781
+ */
782
+ export async function resolveTelegramCredentialSource(
683
783
  profileName: string | undefined,
684
784
  env: ProfileStoreEnv = process.env,
685
- ): Promise<TelegramCredentials> {
785
+ ): Promise<{
786
+ credentials: TelegramCredentials
787
+ source: TelegramCredentialSource
788
+ }> {
686
789
  const envCredentials = credentialsFromEnv(env)
687
790
  if (envCredentials) {
688
- return envCredentials
791
+ return { credentials: envCredentials, source: "env" }
689
792
  }
690
793
 
691
794
  const config = await readConfig(env)
@@ -694,7 +797,7 @@ export async function resolveTelegramCredentials(
694
797
  ? credentialsFromProfileRecord(selectedProfile)
695
798
  : null
696
799
  if (profileCredentials) {
697
- return profileCredentials
800
+ return { credentials: profileCredentials, source: "profile" }
698
801
  }
699
802
  if (selectedProfile) {
700
803
  throw new Error(
@@ -704,7 +807,7 @@ export async function resolveTelegramCredentials(
704
807
 
705
808
  const knownFileCredentials = await credentialsFromKnownFiles(env)
706
809
  if (knownFileCredentials) {
707
- return knownFileCredentials
810
+ return { credentials: knownFileCredentials, source: "known_file" }
708
811
  }
709
812
 
710
813
  throw new Error(
@@ -712,8 +815,15 @@ export async function resolveTelegramCredentials(
712
815
  )
713
816
  }
714
817
 
818
+ export async function resolveTelegramCredentials(
819
+ profileName: string | undefined,
820
+ env: ProfileStoreEnv = process.env,
821
+ ): Promise<TelegramCredentials> {
822
+ return (await resolveTelegramCredentialSource(profileName, env)).credentials
823
+ }
824
+
715
825
  export function profileInputFromUser(
716
- name: string,
826
+ name: string | undefined,
717
827
  credentials: TelegramCredentials,
718
828
  user: UserData,
719
829
  password = "",
@@ -729,3 +839,18 @@ export function profileInputFromUser(
729
839
  displayName: user.displayName,
730
840
  }
731
841
  }
842
+
843
+ /** Provisioning must never inherit another account from environment or discovery. */
844
+ export async function strictProfileCredentials(
845
+ selector: string,
846
+ expectedUserId: number,
847
+ env: ProfileStoreEnv = process.env,
848
+ ): Promise<TelegramCredentials> {
849
+ if (!selector?.trim()) throw new Error("Explicit custody profile required")
850
+ const profile = getProfile(await readConfig(env), selector)
851
+ if (!profile) throw new Error("Custody profile is not configured")
852
+ if (profile.userId !== expectedUserId) throw new Error("Custody profile user ID mismatch")
853
+ const credentials = credentialsFromProfileRecord(profile)
854
+ if (!credentials) throw new Error("Custody profile is incomplete")
855
+ return credentials
856
+ }