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.
This commit is contained in:
Steven Enamakel
2026-01-28 05:59:09 +05:30
parent 5d76837dff
commit 4cf97488f5
4 changed files with 319 additions and 26 deletions
+1 -2
View File
@@ -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<ConnectionStep>('qr');
const [password, setPassword] = useState('');
+156
View File
@@ -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<boolean>;
}
const TelegramContext = createContext<TelegramContextType | undefined>(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<boolean> => {
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 <TelegramContext.Provider value={value}>{children}</TelegramContext.Provider>;
};
export default TelegramProvider;
+115 -10
View File
@@ -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<boolean> => {
// 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<boolean> {
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<void> {
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<T>(
operation: () => Promise<T>,
maxRetries = 3,
retryCount = 0
): Promise<T> {
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<T>(
operation: () => Promise<T>,
maxRetries = 3
): Promise<T> {
return this.handleFloodWait(operation, maxRetries);
}
/**
* Invoke a raw Telegram API method with FLOOD_WAIT handling
*/
async invoke<T = unknown>(request: Parameters<TelegramClient['invoke']>[0]): Promise<T> {
const client = this.getClient();
if (!this.isClientConnected()) {
await this.connect();
}
return client.invoke(request) as Promise<T>;
return this.handleFloodWait(async () => {
return client.invoke(request) as Promise<T>;
});
}
/**
+47 -14
View File
@@ -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;