import { existsSync } from 'node:fs' import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { getConfigDir } from '../../shared/utils/config-dir' import { createAccountId, type TelegramAccount, type TelegramAccountPaths, type TelegramConfig, TelegramError, type TelegramProvisioningState, } from './types' const PROVISIONING_STATE_TTL_MS = 10 * 60 * 1000 export class TelegramCredentialManager { private configDir: string private credentialsPath: string private provisioningStatePath: string private tdlibRootDir: string constructor(configDir?: string) { this.configDir = configDir ?? getConfigDir() this.credentialsPath = join(this.configDir, 'telegram-credentials.json') this.provisioningStatePath = join(this.configDir, 'telegram-provisioning-state.json') this.tdlibRootDir = join(this.configDir, 'telegram') } async loadConfig(): Promise { if (!existsSync(this.credentialsPath)) { return { current: null, accounts: {} } } try { const content = await readFile(this.credentialsPath, 'utf-8') return JSON.parse(content) as TelegramConfig } catch { return { current: null, accounts: {} } } } async saveConfig(config: TelegramConfig): Promise { await mkdir(this.configDir, { recursive: true }) // TODO: Windows does not honor mode 0o600 — consider platform-specific credential storage await writeFile(this.credentialsPath, JSON.stringify(config, null, 2), { mode: 0o600 }) } async getAccount(accountId?: string): Promise { const config = await this.loadConfig() if (!accountId) { return config.current ? (config.accounts[config.current] ?? null) : null } const direct = config.accounts[accountId] if (direct) { return direct } const normalized = createAccountId(accountId) return config.accounts[normalized] ?? null } async listAccounts(): Promise> { const config = await this.loadConfig() return Object.values(config.accounts).map((account) => ({ ...account, is_current: account.account_id === config.current, })) } async setAccount(account: TelegramAccount): Promise { const config = await this.loadConfig() config.accounts[account.account_id] = account if (!config.current) { config.current = account.account_id } await this.saveConfig(config) } async migrateAccount(oldAccountId: string, account: TelegramAccount): Promise { const config = await this.loadConfig() const normalizedOldAccountId = createAccountId(oldAccountId) const oldPaths = this.getAccountPaths(normalizedOldAccountId) const newPaths = this.getAccountPaths(account.account_id) if ( normalizedOldAccountId !== account.account_id && existsSync(oldPaths.account_dir) && existsSync(newPaths.account_dir) ) { throw new TelegramError( `Can't migrate Telegram account data from "${normalizedOldAccountId}" to "${account.account_id}" because the target TDLib directory already exists: ${newPaths.account_dir}`, 'account_migration_conflict', ) } if ( normalizedOldAccountId !== account.account_id && existsSync(oldPaths.account_dir) && !existsSync(newPaths.account_dir) ) { await mkdir(this.tdlibRootDir, { recursive: true }) await rename(oldPaths.account_dir, newPaths.account_dir) } delete config.accounts[normalizedOldAccountId] if (oldAccountId !== normalizedOldAccountId) { delete config.accounts[oldAccountId] } config.accounts[account.account_id] = account config.current = account.account_id await this.saveConfig(config) } async setCurrent(accountId: string): Promise { const config = await this.loadConfig() const account = config.accounts[accountId] ?? config.accounts[createAccountId(accountId)] if (!account) { return false } config.current = account.account_id await this.saveConfig(config) return true } async removeAccount(accountId: string): Promise { const config = await this.loadConfig() const account = config.accounts[accountId] ?? config.accounts[createAccountId(accountId)] if (!account) { return false } delete config.accounts[account.account_id] if (config.current === account.account_id) { config.current = Object.keys(config.accounts)[0] ?? null } await this.saveConfig(config) await rm(this.getAccountPaths(account.account_id).account_dir, { recursive: true, force: true }) return true } async clearCredentials(): Promise { if (existsSync(this.credentialsPath)) { await rm(this.credentialsPath, { force: true }) } if (existsSync(this.tdlibRootDir)) { await rm(this.tdlibRootDir, { recursive: true, force: true }) } await this.clearProvisioningState() } async saveProvisioningState(state: TelegramProvisioningState): Promise { await mkdir(this.configDir, { recursive: true }) await writeFile(this.provisioningStatePath, JSON.stringify(state, null, 2), { mode: 0o600 }) } async loadProvisioningState(): Promise { if (!existsSync(this.provisioningStatePath)) { return null } try { const content = await readFile(this.provisioningStatePath, 'utf-8') const state = JSON.parse(content) as TelegramProvisioningState const createdAtMs = new Date(state.created_at).getTime() const age = Date.now() - createdAtMs if (!Number.isFinite(createdAtMs) || age > PROVISIONING_STATE_TTL_MS) { await this.clearProvisioningState() return null } return state } catch { return null } } async clearProvisioningState(): Promise { if (existsSync(this.provisioningStatePath)) { await rm(this.provisioningStatePath, { force: true }) } } getAccountPaths(accountId: string): TelegramAccountPaths { const safeAccountId = createAccountId(accountId) const accountDir = join(this.tdlibRootDir, safeAccountId) return { account_dir: accountDir, database_dir: join(accountDir, 'db'), files_dir: join(accountDir, 'files'), } } async ensureAccountPaths(accountId: string): Promise { const paths = this.getAccountPaths(accountId) await mkdir(paths.database_dir, { recursive: true }) await mkdir(paths.files_dir, { recursive: true }) return paths } }