diff --git a/cli.js b/cli.js index 904a9ad..d8b0b0a 100755 --- a/cli.js +++ b/cli.js @@ -936,6 +936,34 @@ function formatErrorMessage(error) { return String(error); } +function parseRequiredWaitSeconds(error) { + const text = formatErrorMessage(error); + const waitMatch = /wait of (\d+) seconds is required/i.exec(text); + if (waitMatch) { + return Number(waitMatch[1]); + } + const floodWaitMatch = /FLOOD_WAIT_(\d+)/i.exec(text); + if (floodWaitMatch) { + return Number(floodWaitMatch[1]); + } + return null; +} + +async function refreshDialogsWithRetry(messageSyncService, options = {}) { + const maxWaitSeconds = options.maxWaitSeconds ?? 30; + try { + return await messageSyncService.refreshChannelsFromDialogs(); + } catch (error) { + const waitSeconds = parseRequiredWaitSeconds(error); + if (!waitSeconds || waitSeconds > maxWaitSeconds) { + throw error; + } + console.log(`Rate limited while seeding dialogs. Waiting ${waitSeconds}s and retrying once...`); + await delay(waitSeconds * 1000); + return await messageSyncService.refreshChannelsFromDialogs(); + } +} + function readVersion() { try { const pkgPath = new URL('./package.json', import.meta.url); @@ -1257,7 +1285,7 @@ async function runAuthStatus(globalFlags) { } return; } - const { telegramClient } = createTelegramClient({ storeDir, config }); + const { telegramClient } = createTelegramClient({ storeDir, config, disableUpdates: true }); let messageSyncService = null; let search = { enabled: null }; let archiveError = null; @@ -1334,7 +1362,7 @@ async function runAuthLogout(globalFlags) { } const config = await ensureStoreConfig(storeDir); release = acquireStoreLock(storeDir); - ({ telegramClient } = createTelegramClient({ storeDir, config })); + ({ telegramClient } = createTelegramClient({ storeDir, config, disableUpdates: true })); try { const loginSuccess = await telegramClient.login(); if (!loginSuccess) { @@ -1383,6 +1411,7 @@ async function runAuthLogin(globalFlags, options = {}) { config, forceSms: options.forceSms, useQr: options.qr, + disableUpdates: !options.follow, })); try { const loginSuccess = await telegramClient.login(); @@ -1391,13 +1420,17 @@ async function runAuthLogin(globalFlags, options = {}) { } let dialogCount = null; let archiveError = null; - try { - ({ messageSyncService } = createMessageSyncService(telegramClient, { storeDir })); - dialogCount = await messageSyncService.refreshChannelsFromDialogs(); - } catch (error) { - archiveError = formatErrorMessage(error); - if (options.follow) { - throw new Error(`Authenticated, but archive sync could not start: ${archiveError}`); + if (timeoutMs && !options.follow) { + archiveError = 'Skipped dialog bootstrap because auth is running with a wall-clock timeout. Re-run without --timeout to seed dialogs.'; + } else { + try { + ({ messageSyncService } = createMessageSyncService(telegramClient, { storeDir })); + dialogCount = await refreshDialogsWithRetry(messageSyncService); + } catch (error) { + archiveError = formatErrorMessage(error); + if (options.follow) { + throw new Error(`Authenticated, but archive sync could not start: ${archiveError}`); + } } } if (options.follow) { diff --git a/core/services.js b/core/services.js index edc09a7..9fb1284 100644 --- a/core/services.js +++ b/core/services.js @@ -50,7 +50,11 @@ export function createTelegramClient(options = {}) { config.apiHash, config.phoneNumber, sessionPath, - { forceSms: options.forceSms ?? false, useQr: options.useQr ?? false }, + { + forceSms: options.forceSms ?? false, + useQr: options.useQr ?? false, + disableUpdates: options.disableUpdates ?? false, + }, ); return { diff --git a/telegram-client.js b/telegram-client.js index 7737cc5..8e270c0 100644 --- a/telegram-client.js +++ b/telegram-client.js @@ -431,13 +431,18 @@ class TelegramClient { } _createClient() { - return new MtCuteClient({ + const clientOptions = { apiId: this.apiId, apiHash: this.apiHash, storage: this.sessionPath, platform: createPlatform(), - updates: this.updatesConfig, - }); + }; + if (this.options.disableUpdates) { + clientOptions.disableUpdates = true; + } else { + clientOptions.updates = this.updatesConfig; + } + return new MtCuteClient(clientOptions); } _isAuthKeyUnregisteredError(error) { @@ -447,6 +452,23 @@ class TelegramClient { return code === 401 && message.includes('AUTH_KEY_UNREGISTERED'); } + _isSessionResetError(error) { + if (!error) return false; + const message = (error.errorMessage || error.text || error.message || '').toUpperCase(); + return message.includes('SESSION IS RESET'); + } + + async _recreateClient() { + try { + await this.client.destroy(); + } catch (error) { + console.warn('[warning] failed to destroy MTProto client during reset:', error?.message || error); + } + this.client = this._createClient(); + this.updatesRunning = false; + this.rawUpdateHandler = null; + } + async _resetSessionAndClient() { const sessionFiles = [this.sessionPath, `${this.sessionPath}-wal`, `${this.sessionPath}-shm`]; for (const filePath of sessionFiles) { @@ -458,14 +480,7 @@ class TelegramClient { } } } - try { - await this.client.destroy(); - } catch (error) { - console.warn('[warning] failed to destroy MTProto client during reset:', error?.message || error); - } - this.client = this._createClient(); - this.updatesRunning = false; - this.rawUpdateHandler = null; + await this._recreateClient(); } _isUnauthorizedError(error) { @@ -561,73 +576,84 @@ class TelegramClient { }); } - async login(retriedAfterReset = false) { - try { - if (await this._isAuthorized()) { - console.log('Existing session is valid.'); - return true; - } - - if (!this.options.useQr && !this.phoneNumber) { - throw new Error('TELEGRAM_PHONE_NUMBER is not configured.'); - } + _buildStartParams() { + const startParams = { + password: async () => { + const value = await this._askHiddenQuestion('Enter your 2FA password (leave empty if not enabled): '); + return value.length ? value : undefined; + }, + }; - const startParams = { - password: async () => { - const value = await this._askHiddenQuestion('Enter your 2FA password (leave empty if not enabled): '); - return value.length ? value : undefined; - }, + if (this.options.useQr) { + startParams.qrCodeHandler = (url, expiresAt) => { + const expiresLabel = expiresAt instanceof Date && !Number.isNaN(expiresAt.getTime()) + ? expiresAt.toISOString() + : 'unknown'; + console.log('\nScan this QR code in Telegram: Settings -> Devices -> Link Desktop Device'); + qrcode.generate(url, { small: true }, (rendered) => { + console.log(rendered); + }); + console.log(`QR login URL: ${url}`); + console.log(`QR expires at: ${expiresLabel}`); }; - - if (this.options.useQr) { - startParams.qrCodeHandler = (url, expiresAt) => { - const expiresLabel = expiresAt instanceof Date && !Number.isNaN(expiresAt.getTime()) - ? expiresAt.toISOString() - : 'unknown'; - console.log('\nScan this QR code in Telegram: Settings -> Devices -> Link Desktop Device'); - qrcode.generate(url, { small: true }, (rendered) => { - console.log(rendered); - }); - console.log(`QR login URL: ${url}`); - console.log(`QR expires at: ${expiresLabel}`); - }; - } else { - startParams.phone = this.phoneNumber; - startParams.code = async () => await this._askQuestion('Enter the code you received: '); - startParams.codeSentCallback = async (sentCode) => { - if (this.options.forceSms && (sentCode.type === 'app' || sentCode.type === 'email')) { - try { - await this.client.resendCode({ phone: this.phoneNumber, phoneCodeHash: sentCode.phoneCodeHash }); - console.log('Code re-sent via SMS.'); - } catch (e) { - const msg = (e.text || e.message || '').toUpperCase(); - if (msg.includes('SEND_CODE_UNAVAILABLE')) { - console.log('SMS unavailable for this number. Please use the code sent via app.'); - } else { - console.log(`Could not request SMS (${e.text || e.message}). Using code sent via ${sentCode.type}.`); - } + } else { + startParams.phone = this.phoneNumber; + startParams.code = async () => await this._askQuestion('Enter the code you received: '); + startParams.codeSentCallback = async (sentCode) => { + if (this.options.forceSms && (sentCode.type === 'app' || sentCode.type === 'email')) { + try { + await this.client.resendCode({ phone: this.phoneNumber, phoneCodeHash: sentCode.phoneCodeHash }); + console.log('Code re-sent via SMS.'); + } catch (e) { + const msg = (e.text || e.message || '').toUpperCase(); + if (msg.includes('SEND_CODE_UNAVAILABLE')) { + console.log('SMS unavailable for this number. Please use the code sent via app.'); + } else { + console.log(`Could not request SMS (${e.text || e.message}). Using code sent via ${sentCode.type}.`); } - } else { - console.log(`The confirmation code has been sent via ${sentCode.type}.`); } - }; + } else { + console.log(`The confirmation code has been sent via ${sentCode.type}.`); + } + }; + } + + return startParams; + } + + async login(retriedAfterReset = false, retriedAfterSessionReset = false) { + try { + const hasExistingSession = await this._isAuthorized(); + + if (!hasExistingSession && !this.options.useQr && !this.phoneNumber) { + throw new Error('TELEGRAM_PHONE_NUMBER is not configured.'); } - await this.client.start(startParams); + await this.client.start(this._buildStartParams()); - console.log('Logged in successfully!'); + console.log(hasExistingSession ? 'Existing session is valid.' : 'Logged in successfully!'); return true; } catch (error) { if (!retriedAfterReset && this._isAuthKeyUnregisteredError(error)) { console.log('Detected AUTH_KEY_UNREGISTERED. Resetting local session and retrying login once...'); try { await this._resetSessionAndClient(); - return await this.login(true); + return await this.login(true, retriedAfterSessionReset); } catch (resetError) { console.error('Failed to recover from AUTH_KEY_UNREGISTERED:', resetError); return false; } } + if (!retriedAfterSessionReset && this._isSessionResetError(error)) { + console.log('Detected session reset during login. Recreating MTProto client and retrying once...'); + try { + await this._recreateClient(); + return await this.login(retriedAfterReset, true); + } catch (resetError) { + console.error('Failed to recover from session reset:', resetError); + return false; + } + } console.error('Error during login:', error); return false; } @@ -651,36 +677,49 @@ class TelegramClient { return true; } - async listDialogs(limit = 50) { - await this.ensureLogin(); - const effectiveLimit = limit && limit > 0 ? limit : Infinity; - const results = []; + async listDialogs(limit = 50, retriedAfterSessionReset = false) { + try { + await this.ensureLogin(); + const effectiveLimit = limit && limit > 0 ? limit : Infinity; + const results = []; - for await (const dialog of this.client.iterDialogs({})) { - const peer = dialog.peer; - if (!peer) continue; + for await (const dialog of this.client.iterDialogs({})) { + const peer = dialog.peer; + if (!peer) continue; - const id = peer.id.toString(); - const username = 'username' in peer ? peer.username ?? null : null; - const chatType = typeof peer.chatType === 'string' ? peer.chatType : null; - const isForum = typeof peer.isForum === 'boolean' ? peer.isForum : null; - const isGroup = typeof peer.isGroup === 'boolean' ? peer.isGroup : null; - results.push({ - id, - type: normalizePeerType(peer), - title: peer.displayName || 'Unknown', - username, - chatType, - isForum, - isGroup, - }); + const id = peer.id.toString(); + const username = 'username' in peer ? peer.username ?? null : null; + const chatType = typeof peer.chatType === 'string' ? peer.chatType : null; + const isForum = typeof peer.isForum === 'boolean' ? peer.isForum : null; + const isGroup = typeof peer.isGroup === 'boolean' ? peer.isGroup : null; + results.push({ + id, + type: normalizePeerType(peer), + title: peer.displayName || 'Unknown', + username, + chatType, + isForum, + isGroup, + }); - if (results.length >= effectiveLimit) { - break; + if (results.length >= effectiveLimit) { + break; + } } - } - return results; + return results; + } catch (error) { + if (!retriedAfterSessionReset && this._isSessionResetError(error)) { + console.log('Detected session reset while listing dialogs. Recreating MTProto client and retrying once...'); + await this._recreateClient(); + const loginSuccess = await this.login(); + if (!loginSuccess) { + throw new Error('Failed to restore session after dialog fetch reset.'); + } + return this.listDialogs(limit, true); + } + throw error; + } } async searchPeers(query, limit = 50) { diff --git a/tests/auth-recovery.test.js b/tests/auth-recovery.test.js new file mode 100644 index 0000000..73eeb8e --- /dev/null +++ b/tests/auth-recovery.test.js @@ -0,0 +1,132 @@ +vi.mock('@mtcute/node', () => ({ + TelegramClient: vi.fn(), +})); +vi.mock('@mtcute/core', () => ({ + InputMedia: { + auto: vi.fn(), + }, +})); +vi.mock('@mtcute/markdown-parser', () => ({ + md: vi.fn((text) => text), +})); +vi.mock('@mtcute/html-parser', () => ({ + html: vi.fn((text) => text), +})); + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import TelegramClient from '../telegram-client.js'; + +function makeDialog(peer) { + return { peer }; +} + +describe('telegram auth recovery', () => { + let logSpy; + let errorSpy; + let warnSpy; + + beforeEach(() => { + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + logSpy.mockRestore(); + errorSpy.mockRestore(); + warnSpy.mockRestore(); + }); + + it('login routes existing sessions through client.start to complete mtcute login bootstrap', async () => { + const tc = Object.create(TelegramClient.prototype); + tc.options = { useQr: false, forceSms: false }; + tc.phoneNumber = ''; + tc.client = { + start: vi.fn().mockResolvedValue({}), + }; + tc._isAuthorized = vi.fn().mockResolvedValue(true); + tc._buildStartParams = vi.fn().mockReturnValue({ bootstrap: true }); + + const result = await tc.login(); + + expect(result).toBe(true); + expect(tc.client.start).toHaveBeenCalledWith({ bootstrap: true }); + expect(logSpy).toHaveBeenCalledWith('Existing session is valid.'); + }); + + it('login retries once after session reset by recreating the MTProto client', async () => { + const firstClient = { + start: vi.fn().mockRejectedValue(new Error('Session is reset')), + }; + const secondClient = { + start: vi.fn().mockResolvedValue({}), + }; + const tc = Object.create(TelegramClient.prototype); + tc.options = { useQr: false, forceSms: false }; + tc.phoneNumber = '+1234567890'; + tc.client = firstClient; + tc._isAuthorized = vi.fn() + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false); + tc._buildStartParams = vi.fn().mockReturnValue({ phone: '+1234567890' }); + tc._isAuthKeyUnregisteredError = TelegramClient.prototype._isAuthKeyUnregisteredError; + tc._isSessionResetError = TelegramClient.prototype._isSessionResetError; + tc._recreateClient = vi.fn(async () => { + tc.client = secondClient; + }); + + const result = await tc.login(); + + expect(result).toBe(true); + expect(tc._recreateClient).toHaveBeenCalledTimes(1); + expect(firstClient.start).toHaveBeenCalledTimes(1); + expect(secondClient.start).toHaveBeenCalledTimes(1); + }); + + it('listDialogs retries once after session reset and returns the recovered dialog list', async () => { + const firstClient = { + iterDialogs: async function* () { + throw new Error('Session is reset'); + }, + }; + const secondClient = { + iterDialogs: async function* () { + yield makeDialog({ + id: 42, + type: 'channel', + username: 'tgcli', + displayName: 'tgcli', + chatType: 'channel', + isForum: false, + isGroup: false, + }); + }, + }; + const tc = Object.create(TelegramClient.prototype); + tc.client = firstClient; + tc.ensureLogin = vi.fn().mockResolvedValue(undefined); + tc.login = vi.fn().mockResolvedValue(true); + tc._isSessionResetError = TelegramClient.prototype._isSessionResetError; + tc._recreateClient = vi.fn(async () => { + tc.client = secondClient; + }); + + const dialogs = await tc.listDialogs(10); + + expect(tc._recreateClient).toHaveBeenCalledTimes(1); + expect(tc.login).toHaveBeenCalledTimes(1); + expect(tc.ensureLogin).toHaveBeenCalledTimes(2); + expect(dialogs).toEqual([ + { + id: '42', + type: 'channel', + title: 'tgcli', + username: 'tgcli', + chatType: 'channel', + isForum: false, + isGroup: false, + }, + ]); + }); +}); diff --git a/tests/services.test.js b/tests/services.test.js index a10d3d7..6d4f745 100644 --- a/tests/services.test.js +++ b/tests/services.test.js @@ -49,6 +49,7 @@ describe('core services helpers', () => { storeDir, forceSms: true, useQr: true, + disableUpdates: true, }); expect(telegramClientCtor).toHaveBeenCalledWith( @@ -56,7 +57,7 @@ describe('core services helpers', () => { 'hash-value', '+1234567890', path.join(storeDir, 'session.json'), - { forceSms: true, useQr: true }, + { forceSms: true, useQr: true, disableUpdates: true }, ); expect(messageSyncServiceCtor).not.toHaveBeenCalled(); expect(result.sessionPath).toBe(path.join(storeDir, 'session.json')); diff --git a/tests/telegram-client-auth.test.js b/tests/telegram-client-auth.test.js new file mode 100644 index 0000000..e002a0c --- /dev/null +++ b/tests/telegram-client-auth.test.js @@ -0,0 +1,58 @@ +const mtcuteClientCtor = vi.hoisted(() => vi.fn(function () { + return { + destroy: vi.fn().mockResolvedValue(undefined), + stopUpdatesLoop: vi.fn().mockResolvedValue(undefined), + onRawUpdate: { remove: vi.fn() }, + }; +})); + +vi.mock('@mtcute/node', () => ({ + TelegramClient: mtcuteClientCtor, +})); + +vi.mock('@mtcute/core', () => ({ + InputMedia: {}, +})); + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import TelegramClient from '../telegram-client.js'; + +describe('telegram client auth bootstrap options', () => { + beforeEach(() => { + mtcuteClientCtor.mockReset(); + mtcuteClientCtor.mockImplementation(function () { + return { + destroy: vi.fn().mockResolvedValue(undefined), + stopUpdatesLoop: vi.fn().mockResolvedValue(undefined), + onRawUpdate: { remove: vi.fn() }, + }; + }); + }); + + it('disables mtcute updates when requested', () => { + new TelegramClient(12345, 'hash', '+1234567890', '/tmp/tgcli-auth-disable-updates.session', { + disableUpdates: true, + }); + + expect(mtcuteClientCtor).toHaveBeenCalledWith(expect.objectContaining({ + apiId: 12345, + apiHash: 'hash', + disableUpdates: true, + })); + expect(mtcuteClientCtor.mock.calls[0][0]).not.toHaveProperty('updates'); + }); + + it('keeps updates configuration enabled by default', () => { + new TelegramClient(12345, 'hash', '+1234567890', '/tmp/tgcli-auth-with-updates.session'); + + expect(mtcuteClientCtor).toHaveBeenCalledWith(expect.objectContaining({ + apiId: 12345, + apiHash: 'hash', + updates: expect.objectContaining({ + catchUp: true, + }), + })); + expect(mtcuteClientCtor.mock.calls[0][0]).not.toHaveProperty('disableUpdates'); + }); +});