From 4cf97488f51b1155ece5dd1bffe8ac604cb13f2e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 28 Jan 2026 05:59:09 +0530 Subject: [PATCH] Implement TelegramProvider for improved connection management and refactor TelegramConnectionModal - Introduced TelegramProvider to manage the Telegram MTProto connection, handling initialization and connection status updates. - Refactored TelegramConnectionModal to remove unused authStatus selector, streamlining the component. - Enhanced MTProtoService with FLOOD_WAIT handling for connection checks and message sending, improving reliability during Telegram interactions. - Updated telegramSlice to prevent concurrent authentication checks, optimizing performance during user authentication. These changes enhance the application's ability to manage Telegram connections effectively, providing a smoother user experience during authentication and interaction. --- src/components/TelegramConnectionModal.tsx | 3 +- src/providers/TelegramProvider.tsx | 156 +++++++++++++++++++++ src/services/mtprotoService.ts | 125 +++++++++++++++-- src/store/telegramSlice.ts | 61 ++++++-- 4 files changed, 319 insertions(+), 26 deletions(-) create mode 100644 src/providers/TelegramProvider.tsx diff --git a/src/components/TelegramConnectionModal.tsx b/src/components/TelegramConnectionModal.tsx index 3785cd1ba..881d5e428 100644 --- a/src/components/TelegramConnectionModal.tsx +++ b/src/components/TelegramConnectionModal.tsx @@ -10,7 +10,7 @@ import { setAuthError, setConnectionStatus, } from '../store/telegramSlice'; -import { selectIsInitialized, selectConnectionStatus, selectAuthStatus } from '../store/telegramSelectors'; +import { selectIsInitialized, selectConnectionStatus } from '../store/telegramSelectors'; import { mtprotoService } from '../services/mtprotoService'; interface TelegramConnectionModalProps { @@ -25,7 +25,6 @@ const TelegramConnectionModal = ({ isOpen, onClose, onComplete }: TelegramConnec const dispatch = useAppDispatch(); const isInitialized = useAppSelector(selectIsInitialized); const connectionStatus = useAppSelector(selectConnectionStatus); - const authStatus = useAppSelector(selectAuthStatus); const [currentStep, setCurrentStep] = useState('qr'); const [password, setPassword] = useState(''); diff --git a/src/providers/TelegramProvider.tsx b/src/providers/TelegramProvider.tsx new file mode 100644 index 000000000..bd5a35d72 --- /dev/null +++ b/src/providers/TelegramProvider.tsx @@ -0,0 +1,156 @@ +import { useEffect, useRef, useMemo, createContext, useContext, ReactNode } from 'react'; +import { useAppSelector, useAppDispatch } from '../store/hooks'; +import { selectIsAuthenticated, selectIsInitialized, selectConnectionStatus } from '../store/telegramSelectors'; +import { initializeTelegram, connectTelegram } from '../store/telegramSlice'; +import { mtprotoService } from '../services/mtprotoService'; + +// Helper to check if there's a saved session in localStorage +const hasSavedSession = (): boolean => { + try { + return !!localStorage.getItem('telegram_session'); + } catch { + return false; + } +}; + +interface TelegramContextType { + isAuthenticated: boolean; + isInitialized: boolean; + connectionStatus: 'disconnected' | 'connecting' | 'connected' | 'error'; + checkConnection: () => Promise; +} + +const TelegramContext = createContext(undefined); + +export const useTelegram = () => { + const context = useContext(TelegramContext); + if (!context) { + throw new Error('useTelegram must be used within TelegramProvider'); + } + return context; +}; + +interface TelegramProviderProps { + children: ReactNode; +} + +/** + * TelegramProvider manages the Telegram MTProto connection + * - Initializes when authenticated + * - Connects automatically + * - Provides Telegram context to children + */ +const TelegramProvider = ({ children }: TelegramProviderProps) => { + const dispatch = useAppDispatch(); + const isAuthenticated = useAppSelector(selectIsAuthenticated); + const isInitialized = useAppSelector(selectIsInitialized); + const connectionStatus = useAppSelector(selectConnectionStatus); + const sessionString = useAppSelector((state) => state.telegram.sessionString); + const initializedRef = useRef(false); + const connectedRef = useRef(false); + const setupInProgressRef = useRef(false); + const setupCompleteRef = useRef(false); + + // Memoize hasSession to prevent unnecessary recalculations + const hasSession = useMemo(() => { + return !!sessionString || hasSavedSession(); + }, [sessionString]); + + // Initialize Telegram when authenticated or when we have a saved session + useEffect(() => { + // Reset refs if we're not authenticated and have no session + if (!isAuthenticated && !hasSession) { + initializedRef.current = false; + connectedRef.current = false; + setupInProgressRef.current = false; + setupCompleteRef.current = false; + return; + } + + // If setup is already complete and everything is connected, don't run again + if (setupCompleteRef.current && isInitialized && connectionStatus === 'connected') { + return; + } + + // Prevent multiple simultaneous setup attempts + if (setupInProgressRef.current) { + return; + } + + const setupTelegram = async () => { + // Mark setup as in progress + setupInProgressRef.current = true; + + try { + // Initialize if not already initialized + if (!isInitialized && !initializedRef.current) { + initializedRef.current = true; + await dispatch(initializeTelegram()).unwrap(); + setupInProgressRef.current = false; + return; // Exit early, will re-run when isInitialized updates + } + + // Connect if not already connected + if (isInitialized && connectionStatus !== 'connected' && !connectedRef.current) { + connectedRef.current = true; + await dispatch(connectTelegram()).unwrap(); + setupInProgressRef.current = false; + return; // Exit early, will re-run when connectionStatus updates + } + + // Setup complete - mark as done + setupInProgressRef.current = false; + setupCompleteRef.current = true; + } catch (error) { + console.error('Failed to setup Telegram:', error); + initializedRef.current = false; + connectedRef.current = false; + setupInProgressRef.current = false; + setupCompleteRef.current = false; + } + }; + + setupTelegram(); + }, [isAuthenticated, hasSession, isInitialized, connectionStatus, dispatch]); + + // Check connection status periodically to keep user online + useEffect(() => { + if ((!isAuthenticated && !hasSession) || !isInitialized || connectionStatus !== 'connected') { + return; + } + + const checkConnection = async () => { + try { + await mtprotoService.checkConnection(); + } catch (error) { + console.warn('Telegram connection check failed:', error); + } + }; + + // Check immediately, then every 20 seconds + checkConnection(); + const interval = setInterval(checkConnection, 20000); + + return () => clearInterval(interval); + }, [isAuthenticated, hasSession, isInitialized, connectionStatus]); + + const checkConnection = async (): Promise => { + try { + return await mtprotoService.checkConnection(); + } catch (error) { + console.warn('Connection check failed:', error); + return false; + } + }; + + const value: TelegramContextType = { + isAuthenticated: isAuthenticated || hasSession, + isInitialized, + connectionStatus, + checkConnection, + }; + + return {children}; +}; + +export default TelegramProvider; diff --git a/src/services/mtprotoService.ts b/src/services/mtprotoService.ts index 11c143511..c8ad0594e 100644 --- a/src/services/mtprotoService.ts +++ b/src/services/mtprotoService.ts @@ -1,6 +1,7 @@ import { TelegramClient } from 'telegram'; import { StringSession } from 'telegram/sessions'; import type { UserAuthParams, BotAuthParams } from 'telegram/client/auth'; +import { FloodWaitError } from 'telegram/errors'; import { TELEGRAM_API_ID, TELEGRAM_API_HASH } from '../utils/config'; type LoginOptions = UserAuthParams | BotAuthParams; @@ -48,6 +49,8 @@ class MTProtoService { this.client = new TelegramClient(stringSession, this.apiId, this.apiHash, { connectionRetries: 5, + requestRetries: 5, + floodSleepThreshold: 60, // Auto-retry FLOOD_WAIT errors up to 60 seconds }); this.isInitialized = true; @@ -77,7 +80,7 @@ class MTProtoService { console.log('Connected to Telegram successfully'); // Save session string if it changed - const newSessionString = this.client.session.save(); + const newSessionString = this.client.session.save() as string | undefined; if (newSessionString && newSessionString !== this.sessionString) { this.sessionString = newSessionString; this.saveSession(newSessionString); @@ -101,7 +104,7 @@ class MTProtoService { await this.client.start(options); // Save session after successful login - const newSessionString = this.client.session.save(); + const newSessionString = this.client.session.save() as string | undefined; if (newSessionString && newSessionString !== this.sessionString) { this.sessionString = newSessionString; this.saveSession(newSessionString); @@ -136,7 +139,7 @@ class MTProtoService { qrCodeCallback(qrCode); }, password: passwordCallback, - onError: async (err: Error) => { + onError: async (err: Error): Promise => { // If password callback is provided and we get SESSION_PASSWORD_NEEDED, // the password callback should handle it, but if onError is called first, // we need to let it through @@ -145,14 +148,14 @@ class MTProtoService { // Don't stop - let the password callback handle it if (onError) { const result = await onError(err); - return result; + return result ?? false; } return false; } if (onError) { const result = await onError(err); - return result; + return result ?? false; } console.error('QR code auth error:', err); return false; @@ -161,7 +164,7 @@ class MTProtoService { ); // Save session after successful login - const newSessionString = this.client.session.save(); + const newSessionString = this.client.session.save() as string | undefined; if (newSessionString && newSessionString !== this.sessionString) { this.sessionString = newSessionString; this.saveSession(newSessionString); @@ -221,6 +224,45 @@ class MTProtoService { return this.sessionString; } + /** + * Check connection status and update user online status + * This calls getMe() which also updates the user's online status on Telegram + * Automatically initializes and connects if needed + */ + async checkConnection(): Promise { + try { + // Initialize if not already initialized + if (!this.isInitialized || !this.client) { + await this.initialize(); + } + + // Connect if not already connected + if (!this.isConnected) { + await this.connect(); + } + + // Check authorization + const isAuthorized = await this.client!.checkAuthorization(); + if (!isAuthorized) { + return false; + } + + // Call getMe() to check connection and update online status with FLOOD_WAIT handling + await this.handleFloodWait(async () => { + await this.client!.getMe(); + }); + return true; + } catch (error) { + // Don't log FLOOD_WAIT as a warning - it's expected behavior + if (error instanceof FloodWaitError) { + console.debug(`Telegram connection check: FLOOD_WAIT ${error.seconds}s`); + } else { + console.warn('Telegram connection check failed:', error); + } + return false; + } + } + /** * Disconnect from Telegram */ @@ -238,25 +280,88 @@ class MTProtoService { } /** - * Send a message using the client + * Send a message using the client with FLOOD_WAIT handling */ async sendMessage(entity: string, message: string): Promise { const client = this.getClient(); if (!this.isClientConnected()) { await this.connect(); } - await client.sendMessage(entity, { message }); + + return this.handleFloodWait(async () => { + await client.sendMessage(entity, { message }); + }); } /** - * Invoke a raw Telegram API method + * Handle FLOOD_WAIT errors by waiting and retrying + * @param operation The async operation to execute + * @param maxRetries Maximum number of retry attempts (default: 3) + * @param retryCount Current retry count (internal use) + * @returns The result of the operation + */ + private async handleFloodWait( + operation: () => Promise, + maxRetries = 3, + retryCount = 0 + ): Promise { + try { + return await operation(); + } catch (error) { + // Check if it's a FLOOD_WAIT error + if (error instanceof FloodWaitError) { + const waitSeconds = error.seconds; + + // If wait time is too long (more than 5 minutes), throw error + if (waitSeconds > 300) { + throw new Error(`FLOOD_WAIT: Too long wait time (${waitSeconds}s). Please try again later.`); + } + + // If we've exceeded max retries, throw error + if (retryCount >= maxRetries) { + throw new Error(`FLOOD_WAIT: Maximum retries exceeded. Wait ${waitSeconds}s before trying again.`); + } + + console.warn(`FLOOD_WAIT: Waiting ${waitSeconds} seconds before retry (attempt ${retryCount + 1}/${maxRetries})`); + + // Wait for the specified time (convert to milliseconds) + await new Promise((resolve) => setTimeout(resolve, waitSeconds * 1000)); + + // Retry the operation + return this.handleFloodWait(operation, maxRetries, retryCount + 1); + } + + // If it's not a FLOOD_WAIT error, rethrow it + throw error; + } + } + + /** + * Execute an operation with FLOOD_WAIT error handling + * This is a public utility method that can be used to wrap any Telegram API call + * @param operation The async operation to execute + * @param maxRetries Maximum number of retry attempts (default: 3) + * @returns The result of the operation + */ + async withFloodWaitHandling( + operation: () => Promise, + maxRetries = 3 + ): Promise { + return this.handleFloodWait(operation, maxRetries); + } + + /** + * Invoke a raw Telegram API method with FLOOD_WAIT handling */ async invoke(request: Parameters[0]): Promise { const client = this.getClient(); if (!this.isClientConnected()) { await this.connect(); } - return client.invoke(request) as Promise; + + return this.handleFloodWait(async () => { + return client.invoke(request) as Promise; + }); } /** diff --git a/src/store/telegramSlice.ts b/src/store/telegramSlice.ts index d7823b5a8..164b8e12c 100644 --- a/src/store/telegramSlice.ts +++ b/src/store/telegramSlice.ts @@ -186,9 +186,25 @@ export const connectTelegram = createAsyncThunk( } ); +// Global flag to prevent concurrent checkAuthStatus calls +let isCheckingAuth = false; +let lastCheckTime = 0; +const MIN_CHECK_INTERVAL = 5000; // 5 seconds minimum between checks + export const checkAuthStatus = createAsyncThunk( 'telegram/checkAuthStatus', - async (_, { rejectWithValue }) => { + async (_, { rejectWithValue, getState }) => { + // Prevent concurrent calls + const now = Date.now(); + if (isCheckingAuth && now - lastCheckTime < MIN_CHECK_INTERVAL) { + // Return current state instead of making another call + const state = getState() as { telegram: { authStatus: string; currentUser: TelegramUser | null } }; + return state.telegram.currentUser || null; + } + + isCheckingAuth = true; + lastCheckTime = now; + try { const client = mtprotoService.getClient(); @@ -196,20 +212,26 @@ export const checkAuthStatus = createAsyncThunk( const isAuthorized = await client.checkAuthorization(); if (!isAuthorized) { + isCheckingAuth = false; return null; } - // Only call getMe() if we're authorized + // Only call getMe() if we're authorized, with FLOOD_WAIT handling try { - const me = await client.getMe(); + const me = await mtprotoService.withFloodWaitHandling(async () => { + return client.getMe(); + }); + isCheckingAuth = false; return me; } catch (error) { // If getMe() fails, we're not actually authorized // This can happen if the session is invalid console.warn('getMe() failed, user not authenticated:', error); + isCheckingAuth = false; return null; } } catch (error) { + isCheckingAuth = false; // If checkAuthorization itself fails, we're definitely not authenticated // Don't treat this as an error, just return null if (error instanceof Error && error.message.includes('AUTH_KEY_UNREGISTERED')) { @@ -227,7 +249,9 @@ export const fetchChats = createAsyncThunk( async (_, { rejectWithValue }) => { try { const client = mtprotoService.getClient(); - const dialogs = await client.getDialogs({ limit: 100 }); + const dialogs = await mtprotoService.withFloodWaitHandling(async () => { + return client.getDialogs({ limit: 100 }); + }); return dialogs; } catch (error) { return rejectWithValue( @@ -247,7 +271,9 @@ export const fetchMessages = createAsyncThunk( const client = mtprotoService.getClient(); // Implementation depends on GramJS API // This is a placeholder - adjust based on actual API - const messages = await client.getMessages(chatId, { limit, offsetId }); + const messages = await mtprotoService.withFloodWaitHandling(async () => { + return client.getMessages(chatId, { limit, offsetId }); + }); return { chatId, messages }; } catch (error) { return rejectWithValue( @@ -485,15 +511,22 @@ const telegramSlice = createSlice({ if (action.payload) { state.authStatus = 'authenticated'; // Convert Api.User to TelegramUser - // This is a placeholder - adjust based on actual API response - state.currentUser = { - id: String(action.payload.id), - firstName: action.payload.firstName || '', - lastName: action.payload.lastName, - username: action.payload.username, - isBot: Boolean(action.payload.bot), - accessHash: action.payload.accessHash?.toString(), - }; + // Handle both TelegramUser (from cached state) and User (from API) + const payload = action.payload as TelegramUser | { id: number | string; firstName?: string; lastName?: string; username?: string; bot?: boolean; accessHash?: bigint | string }; + if ('isBot' in payload) { + // Already a TelegramUser + state.currentUser = payload; + } else { + // Convert from API User format + state.currentUser = { + id: String(payload.id), + firstName: payload.firstName || '', + lastName: payload.lastName, + username: payload.username, + isBot: Boolean(payload.bot), + accessHash: payload.accessHash?.toString(), + }; + } } else { state.authStatus = 'not_authenticated'; state.currentUser = null;