diff --git a/CLAUDE.md b/CLAUDE.md index b95fd4301..f53f2e2e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,11 +79,18 @@ State lives in `src/store/` using Redux Toolkit slices: - **socketSlice** — connection status, socket ID - **telegramSlice** — connection/auth status, chats, messages, threads (selectively persisted; loading/error states excluded) -Redux Persist stores auth and telegram state to localStorage. The telegram slice has a complex nested structure in `src/store/telegram/` with separate files for types, reducers, extraReducers, and thunks. +Redux Persist stores auth and telegram state (storage backend is configurable; default uses localStorage). The telegram slice has a complex nested structure in `src/store/telegram/` with separate files for types, reducers, extraReducers, and thunks. + +### LocalStorage + +- **Do not use `localStorage` (or `sessionStorage`) for app state or feature logic.** Use Redux (and Redux Persist where needed) instead. +- **Remove any existing `localStorage` usage** when touching related code. User-scoped data (auth, onboarding, Telegram session, socket state) lives in Redux, keyed by user id where applicable. Telegram session is in `telegram.byUser[userId].sessionString`, not localStorage. +- **Exceptions**: Redux-persist may use a localStorage-backed storage adapter by default; that is the persistence layer, not app logic. Any other remaining usage (e.g. deep-link `deepLinkHandled` flag) should be migrated to Redux or similar when that code is modified. +- **General rule**: Avoid adding new `localStorage` or `sessionStorage` usage; prefer Redux and remove existing usage when you work on affected areas. ### Service Layer (Singletons) -- **mtprotoService** (`src/services/mtprotoService.ts`) — Telegram MTProto client via `telegram` npm package. Session stored in localStorage as `telegram_session`. Auto-retries FLOOD_WAIT up to 60s. +- **mtprotoService** (`src/services/mtprotoService.ts`) — Telegram MTProto client via `telegram` npm package. Session stored in Redux (`telegram.byUser[userId].sessionString`), not localStorage. Auto-retries FLOOD_WAIT up to 60s. - **socketService** (`src/services/socketService.ts`) — Socket.io client. Auth token passed in socket `auth` object (not query string). Transports: polling first, then WebSocket. - **apiClient** (`src/services/apiClient.ts`) — HTTP client for REST backend. @@ -116,7 +123,7 @@ Web-to-desktop handoff using `outsourced://` URL scheme: 4. Backend returns `sessionToken` + user object 5. App stores session in Redux, navigates to onboarding/home -Key file: `src/utils/desktopDeepLinkListener.ts` (lazy-loaded in `main.tsx`). Uses `localStorage.deepLinkHandled` flag to prevent infinite reload loops. Deep links do NOT work in `tauri dev` on macOS — must use built `.app` bundle. +Key file: `src/utils/desktopDeepLinkListener.ts` (lazy-loaded in `main.tsx`). Uses a `deepLinkHandled` flag to prevent infinite reload loops. Deep links do NOT work in `tauri dev` on macOS — must use built `.app` bundle. ### Rust Backend (`src-tauri/src/lib.rs`) @@ -180,6 +187,7 @@ Key updates from recent commits: ## Key Patterns +- **No localStorage**: Avoid `localStorage` and `sessionStorage`; use Redux (and persist) for app state. Remove any direct usage when working on affected code. - **Modal System**: Settings modal uses `createPortal` pattern with URL-based routing. Clean white design (not glass morphism) for system settings. Navigate with `/settings` and `/settings/connections` paths. - **Component Reuse**: Connection management reuses `connectOptions` array and components from onboarding flow. Maintains consistent UX patterns across features. - **Redux Integration**: Settings modal integrates with existing slices - auth for logout, user for profile display, telegram for connection status. No new state management needed. diff --git a/src/App.tsx b/src/App.tsx index 25c82aaca..eb670b21d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,22 +1,25 @@ -import { HashRouter as Router } from 'react-router-dom'; -import { Provider } from 'react-redux'; -import { PersistGate } from 'redux-persist/integration/react'; -import { store, persistor } from './store'; -import SocketProvider from './providers/SocketProvider'; -import TelegramProvider from './providers/TelegramProvider'; -import AppRoutes from './AppRoutes'; +import { HashRouter as Router } from "react-router-dom"; +import { Provider } from "react-redux"; +import { PersistGate } from "redux-persist/integration/react"; +import { store, persistor } from "./store"; +import UserProvider from "./providers/UserProvider"; +import SocketProvider from "./providers/SocketProvider"; +import TelegramProvider from "./providers/TelegramProvider"; +import AppRoutes from "./AppRoutes"; function App() { return ( - - - - - - - + + + + + + + + + ); diff --git a/src/AppRoutes.tsx b/src/AppRoutes.tsx index 4176f1cc1..9beba0f2b 100644 --- a/src/AppRoutes.tsx +++ b/src/AppRoutes.tsx @@ -1,12 +1,12 @@ -import { Routes, Route } from 'react-router-dom'; -import Welcome from './pages/Welcome'; -import Login from './pages/Login'; -import Onboarding from './pages/onboarding/Onboarding'; -import Home from './pages/Home'; -import PublicRoute from './components/PublicRoute'; -import ProtectedRoute from './components/ProtectedRoute'; -import DefaultRedirect from './components/DefaultRedirect'; -import SettingsModal from './components/settings/SettingsModal'; +import { Routes, Route } from "react-router-dom"; +import Welcome from "./pages/Welcome"; +import Login from "./pages/Login"; +import Onboarding from "./pages/onboarding/Onboarding"; +import Home from "./pages/Home"; +import PublicRoute from "./components/PublicRoute"; +import ProtectedRoute from "./components/ProtectedRoute"; +import DefaultRedirect from "./components/DefaultRedirect"; +import SettingsModal from "./components/settings/SettingsModal"; const AppRoutes = () => { return ( @@ -42,7 +42,11 @@ const AppRoutes = () => { + } @@ -52,7 +56,11 @@ const AppRoutes = () => { + } diff --git a/src/assets/icons/GmailIcon.tsx b/src/assets/icons/GmailIcon.tsx index 2a3f644e5..535514f5e 100644 --- a/src/assets/icons/GmailIcon.tsx +++ b/src/assets/icons/GmailIcon.tsx @@ -1,8 +1,8 @@ const GmailIcon = ({ className = "w-4 h-4" }: { className?: string }) => { return ( - - + + ); }; diff --git a/src/assets/icons/GoogleIcon.tsx b/src/assets/icons/GoogleIcon.tsx index 26f621f40..3d649f048 100644 --- a/src/assets/icons/GoogleIcon.tsx +++ b/src/assets/icons/GoogleIcon.tsx @@ -1,10 +1,22 @@ const GoogleIcon = ({ className = "w-5 h-5" }: { className?: string }) => { return ( - - - - + + + + ); }; diff --git a/src/components/ConnectionIndicator.tsx b/src/components/ConnectionIndicator.tsx index 3dafb6071..be9ba35de 100644 --- a/src/components/ConnectionIndicator.tsx +++ b/src/components/ConnectionIndicator.tsx @@ -1,34 +1,35 @@ -import { useAppSelector } from '../store/hooks'; +import { useAppSelector } from "../store/hooks"; +import { selectSocketStatus } from "../store/socketSelectors"; interface ConnectionIndicatorProps { - status?: 'connected' | 'disconnected' | 'connecting'; + status?: "connected" | "disconnected" | "connecting"; description?: string; className?: string; } const ConnectionIndicator = ({ status: overrideStatus, - description = 'Your browser is now connected to the AlphaHuman AI. Keep this tab open to keep the connection alive. You can message your assistant with the button below.', - className = '', + description = "Your browser is now connected to the AlphaHuman AI. Keep this tab open to keep the connection alive. You can message your assistant with the button below.", + className = "", }: ConnectionIndicatorProps) => { // Use socket store status, but allow override via props - const storeStatus = useAppSelector((state) => state.socket.status); + const storeStatus = useAppSelector(selectSocketStatus); const status = overrideStatus || storeStatus; const statusConfig = { connected: { - color: 'bg-sage-500', - textColor: 'text-sage-500', - text: 'Connected to AlphaHuman AI 🚀', + color: "bg-sage-500", + textColor: "text-sage-500", + text: "Connected to AlphaHuman AI 🚀", }, disconnected: { - color: 'bg-coral-500', - textColor: 'text-coral-500', - text: 'Disconnected', + color: "bg-coral-500", + textColor: "text-coral-500", + text: "Disconnected", }, connecting: { - color: 'bg-amber-500', - textColor: 'text-amber-500', - text: 'Connecting', + color: "bg-amber-500", + textColor: "text-amber-500", + text: "Connecting", }, }; @@ -37,7 +38,9 @@ const ConnectionIndicator = ({ return (
-
+
{config.text}
{description && ( diff --git a/src/components/DefaultRedirect.tsx b/src/components/DefaultRedirect.tsx index cc6278260..f65fc2ebc 100644 --- a/src/components/DefaultRedirect.tsx +++ b/src/components/DefaultRedirect.tsx @@ -1,5 +1,6 @@ -import { Navigate } from 'react-router-dom'; -import { useAppSelector } from '../store/hooks'; +import { Navigate } from "react-router-dom"; +import { useAppSelector } from "../store/hooks"; +import { selectIsOnboarded } from "../store/authSelectors"; /** * Default redirect component that routes users based on their auth and onboarding status @@ -9,7 +10,7 @@ import { useAppSelector } from '../store/hooks'; */ const DefaultRedirect = () => { const token = useAppSelector((state) => state.auth.token); - const isOnboarded = useAppSelector((state) => state.auth.isOnboarded); + const isOnboarded = useAppSelector(selectIsOnboarded); if (token && isOnboarded) { return ; diff --git a/src/components/DesignSystemShowcase.tsx b/src/components/DesignSystemShowcase.tsx index e55c47ae3..cbdd9814f 100644 --- a/src/components/DesignSystemShowcase.tsx +++ b/src/components/DesignSystemShowcase.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React from "react"; /** * Design System Showcase Component @@ -46,17 +46,23 @@ const DesignSystemShowcase: React.FC = () => { {/* Quick Actions */}
- - + +
@@ -70,19 +76,25 @@ const DesignSystemShowcase: React.FC = () => {
{/* Primary Colors */}
-

Primary Ocean

+

+ Primary Ocean +

-

Primary 500

+

+ Primary 500 +

#5B9BF3

-

Primary 600

+

+ Primary 600 +

#4A83DD

@@ -91,7 +103,9 @@ const DesignSystemShowcase: React.FC = () => { {/* Success Colors */}
-

Sage Success

+

+ Sage Success +

@@ -112,7 +126,9 @@ const DesignSystemShowcase: React.FC = () => { {/* Accent Colors */}
-

Accent Palette

+

+ Accent Palette +

@@ -164,14 +180,17 @@ const DesignSystemShowcase: React.FC = () => {

Body · 1rem

- The typography system is designed to create clear visual hierarchy while maintaining - excellent readability. Each text size has been carefully calibrated with appropriate - line heights and letter spacing to ensure optimal legibility across all device sizes. + The typography system is designed to create clear visual hierarchy + while maintaining excellent readability. Each text size has been + carefully calibrated with appropriate line heights and letter + spacing to ensure optimal legibility across all device sizes.

-

Monospace · For prices and data

+

+ Monospace · For prices and data +

$48,392.50 +2.45%

@@ -205,7 +224,9 @@ const DesignSystemShowcase: React.FC = () => {

$48,392.50

- +$1,185.20 + + +$1,185.20 + (+2.45%)
@@ -222,8 +243,18 @@ const DesignSystemShowcase: React.FC = () => {

View Details - - + +
@@ -231,25 +262,43 @@ const DesignSystemShowcase: React.FC = () => { {/* Elevated Card */}
-

Quick Stats

+

+ Quick Stats +

- - + +
Total Value - $125,430 + + $125,430 +
24h Change - +3.2% + + +3.2% +
Holdings - 12 + + 12 +
@@ -278,32 +327,82 @@ const DesignSystemShowcase: React.FC = () => { {/* Navigation Items */}
- +
@@ -377,4 +474,4 @@ const DesignSystemShowcase: React.FC = () => { ); }; -export default DesignSystemShowcase; \ No newline at end of file +export default DesignSystemShowcase; diff --git a/src/components/GmailConnectionIndicator.tsx b/src/components/GmailConnectionIndicator.tsx index 76b10ea66..af9df4c94 100644 --- a/src/components/GmailConnectionIndicator.tsx +++ b/src/components/GmailConnectionIndicator.tsx @@ -1,4 +1,4 @@ -import GmailIcon from '../assets/icons/GmailIcon'; +import GmailIcon from "../assets/icons/GmailIcon"; interface GmailConnectionIndicatorProps { description?: string; @@ -7,7 +7,7 @@ interface GmailConnectionIndicatorProps { const GmailConnectionIndicator = ({ description, - className = '', + className = "", }: GmailConnectionIndicatorProps) => { // Gmail is always offline for now (placeholder) const gmailIsOnline = false; @@ -15,11 +15,17 @@ const GmailConnectionIndicator = ({ return (
-
+
- - - {gmailIsOnline ? 'Connected to Gmail' : 'Gmail is Offline'} + + + {gmailIsOnline ? "Connected to Gmail" : "Gmail is Offline"}
diff --git a/src/components/LottieAnimation.tsx b/src/components/LottieAnimation.tsx index df33c9f1e..e55defac0 100644 --- a/src/components/LottieAnimation.tsx +++ b/src/components/LottieAnimation.tsx @@ -1,5 +1,5 @@ -import { useEffect, useState } from 'react'; -import { useLottie } from 'lottie-react'; +import { useEffect, useState } from "react"; +import { useLottie } from "lottie-react"; interface LottieAnimationProps { src: string; @@ -8,14 +8,21 @@ interface LottieAnimationProps { width?: number; } -const LottieAnimation = ({ src, className = '', height = 200, width = 200 }: LottieAnimationProps) => { +const LottieAnimation = ({ + src, + className = "", + height = 200, + width = 200, +}: LottieAnimationProps) => { const [animationData, setAnimationData] = useState(null); useEffect(() => { fetch(src) .then((response) => response.json()) .then((data) => setAnimationData(data)) - .catch((error) => console.error('Failed to load Lottie animation:', error)); + .catch((error) => + console.error("Failed to load Lottie animation:", error), + ); }, [src]); const options = { diff --git a/src/components/PrivacyFeatureCard.tsx b/src/components/PrivacyFeatureCard.tsx index 02522d7c1..0cb5b126b 100644 --- a/src/components/PrivacyFeatureCard.tsx +++ b/src/components/PrivacyFeatureCard.tsx @@ -3,13 +3,18 @@ interface PrivacyFeatureCardProps { description: string; } -const PrivacyFeatureCard = ({ title, description }: PrivacyFeatureCardProps) => { +const PrivacyFeatureCard = ({ + title, + description, +}: PrivacyFeatureCardProps) => { return (

{title}

-

{description}

+

+ {description} +

diff --git a/src/components/ProgressIndicator.tsx b/src/components/ProgressIndicator.tsx index e8bf5e6f6..9f9bf668e 100644 --- a/src/components/ProgressIndicator.tsx +++ b/src/components/ProgressIndicator.tsx @@ -3,14 +3,17 @@ interface ProgressIndicatorProps { totalSteps: number; } -const ProgressIndicator = ({ currentStep, totalSteps }: ProgressIndicatorProps) => { +const ProgressIndicator = ({ + currentStep, + totalSteps, +}: ProgressIndicatorProps) => { return (
{Array.from({ length: totalSteps }).map((_, index) => (
))} diff --git a/src/components/ProtectedRoute.tsx b/src/components/ProtectedRoute.tsx index e65fb5735..af7e309fb 100644 --- a/src/components/ProtectedRoute.tsx +++ b/src/components/ProtectedRoute.tsx @@ -1,5 +1,6 @@ -import { Navigate } from 'react-router-dom'; -import { useAppSelector } from '../store/hooks'; +import { Navigate } from "react-router-dom"; +import { useAppSelector } from "../store/hooks"; +import { selectIsOnboarded } from "../store/authSelectors"; interface ProtectedRouteProps { children: React.ReactNode; @@ -18,16 +19,16 @@ const ProtectedRoute = ({ redirectTo, }: ProtectedRouteProps) => { const token = useAppSelector((state) => state.auth.token); - const isOnboarded = useAppSelector((state) => state.auth.isOnboarded); + const isOnboarded = useAppSelector(selectIsOnboarded); // If auth is required but user is not logged in if (requireAuth && !token) { - return ; + return ; } // If onboarding is required but user is not onboarded if (requireOnboarded && !isOnboarded) { - return ; + return ; } return <>{children}; diff --git a/src/components/PublicRoute.tsx b/src/components/PublicRoute.tsx index 48071e81c..39b805671 100644 --- a/src/components/PublicRoute.tsx +++ b/src/components/PublicRoute.tsx @@ -1,5 +1,6 @@ -import { Navigate } from 'react-router-dom'; -import { useAppSelector } from '../store/hooks'; +import { Navigate } from "react-router-dom"; +import { useAppSelector } from "../store/hooks"; +import { selectIsOnboarded } from "../store/authSelectors"; interface PublicRouteProps { children: React.ReactNode; @@ -13,11 +14,11 @@ interface PublicRouteProps { */ const PublicRoute = ({ children, redirectTo }: PublicRouteProps) => { const token = useAppSelector((state) => state.auth.token); - const isOnboarded = useAppSelector((state) => state.auth.isOnboarded); + const isOnboarded = useAppSelector(selectIsOnboarded); // If user is logged in and onboarded, redirect to home if (token && isOnboarded) { - return ; + return ; } // If user is logged in but not onboarded, redirect to onboarding diff --git a/src/components/TelegramConnectionIndicator.tsx b/src/components/TelegramConnectionIndicator.tsx index 32037e349..8b64c489c 100644 --- a/src/components/TelegramConnectionIndicator.tsx +++ b/src/components/TelegramConnectionIndicator.tsx @@ -1,6 +1,6 @@ -import { useEffect, useState } from 'react'; -import { useTelegram } from '../providers/TelegramProvider'; -import TelegramIcon from '../assets/icons/telegram.svg'; +import { useEffect, useState } from "react"; +import { useTelegram } from "../providers/TelegramProvider"; +import TelegramIcon from "../assets/icons/telegram.svg"; interface TelegramConnectionIndicatorProps { description?: string; @@ -9,7 +9,7 @@ interface TelegramConnectionIndicatorProps { const TelegramConnectionIndicator = ({ description, - className = '', + className = "", }: TelegramConnectionIndicatorProps) => { const { isAuthenticated, connectionStatus, checkConnection } = useTelegram(); const [telegramIsOnline, setTelegramIsOnline] = useState(false); @@ -26,13 +26,13 @@ const TelegramConnectionIndicator = ({ const isConnected = await checkConnection(); setTelegramIsOnline(isConnected); } catch (error) { - console.warn('Failed to check Telegram connection:', error); + console.warn("Failed to check Telegram connection:", error); setTelegramIsOnline(false); } }; // Check immediately if connected - if (connectionStatus === 'connected') { + if (connectionStatus === "connected") { checkTelegramConnection(); } else { setTelegramIsOnline(false); @@ -52,11 +52,19 @@ const TelegramConnectionIndicator = ({ return (
-
+
- Telegram - - {telegramIsOnline ? 'Connected to Telegram' : 'Telegram is Offline'} + Telegram + + {telegramIsOnline ? "Connected to Telegram" : "Telegram is Offline"}
diff --git a/src/components/TelegramConnectionModal.tsx b/src/components/TelegramConnectionModal.tsx index ee7c7b7a7..5d55c541c 100644 --- a/src/components/TelegramConnectionModal.tsx +++ b/src/components/TelegramConnectionModal.tsx @@ -1,7 +1,7 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; -import { createPortal } from 'react-dom'; -import { QRCodeSVG } from 'qrcode.react'; -import { useAppDispatch, useAppSelector } from '../store/hooks'; +import { useState, useEffect, useCallback, useRef } from "react"; +import { createPortal } from "react-dom"; +import { QRCodeSVG } from "qrcode.react"; +import { useAppDispatch, useAppSelector } from "../store/hooks"; import { initializeTelegram, connectTelegram, @@ -9,9 +9,17 @@ import { setAuthStatus, setAuthError, setConnectionStatus, -} from '../store/telegram'; -import { selectIsInitialized, selectConnectionStatus } from '../store/telegramSelectors'; -import { mtprotoService } from '../services/mtprotoService'; + resetTelegramForUser, +} from "../store/telegram"; +import { + selectIsInitialized, + selectConnectionStatus, + selectTelegramCurrentUserId, +} from "../store/telegramSelectors"; +import { selectSocketStatus } from "../store/socketSelectors"; +import { mtprotoService } from "../services/mtprotoService"; +import { socketService } from "../services/socketService"; +import type { User } from "../types/api"; interface TelegramConnectionModalProps { isOpen: boolean; @@ -19,15 +27,30 @@ interface TelegramConnectionModalProps { onComplete: () => void; } -type ConnectionStep = 'qr' | '2fa' | 'loading' | 'error'; +type ConnectionStep = "qr" | "2fa" | "loading" | "error"; -const TelegramConnectionModal = ({ isOpen, onClose, onComplete }: TelegramConnectionModalProps) => { +const TELEGRAM_ACCOUNT_MISMATCH_ERROR = + "This Telegram account doesn't match your logged-in account. Please use the Telegram account you signed up with."; + +function telegramMeIdMatchesUser(me: { id: unknown }, appUser: User): boolean { + return String(me.id) === String(appUser.telegramId); +} + +const TelegramConnectionModal = ({ + isOpen, + onClose, + onComplete, +}: TelegramConnectionModalProps) => { const dispatch = useAppDispatch(); + const user = useAppSelector((state) => state.user.user); + const userId = useAppSelector(selectTelegramCurrentUserId); const isInitialized = useAppSelector(selectIsInitialized); const connectionStatus = useAppSelector(selectConnectionStatus); + const socketStatus = useAppSelector(selectSocketStatus); + const token = useAppSelector((state) => state.auth.token); - const [currentStep, setCurrentStep] = useState('qr'); - const [password, setPassword] = useState(''); + const [currentStep, setCurrentStep] = useState("qr"); + const [password, setPassword] = useState(""); const [passwordHint, setPasswordHint] = useState(); const [qrCodeUrl, setQrCodeUrl] = useState(null); const [qrCodeExpires, setQrCodeExpires] = useState(null); @@ -35,53 +58,40 @@ const TelegramConnectionModal = ({ isOpen, onClose, onComplete }: TelegramConnec const [error, setError] = useState(null); const [isAuthenticating, setIsAuthenticating] = useState(false); - // Store password promise resolver const passwordResolverRef = useRef<((password: string) => void) | null>(null); + const qrFlowStartedRef = useRef(false); - // Initialize and connect when modal opens + // Ensure socket is connected when modal opens (e.g. for MCP / real-time features) useEffect(() => { - if (!isOpen) return; + if (!isOpen) { + qrFlowStartedRef.current = false; + return; + } + if (token && socketStatus !== "connected") { - const init = async () => { - try { - setCurrentStep('loading'); - if (!isInitialized) { - await dispatch(initializeTelegram()).unwrap(); - } - if (connectionStatus !== 'connected') { - await dispatch(connectTelegram()).unwrap(); - } - // Check if already authenticated (but don't fail if not authenticated) - try { - const authCheck = await dispatch(checkAuthStatus()).unwrap(); - if (authCheck) { - onComplete(); - onClose(); - return; - } - } catch (err) { - // If checkAuthStatus fails, we're not authenticated - continue with QR flow - console.log('Not authenticated, proceeding with QR code flow'); - } - // Start QR code flow - startQrCodeFlow(); - } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Failed to initialize Telegram'; - setError(errorMessage); - setCurrentStep('error'); - dispatch(setConnectionStatus('error')); - } - }; + socketService.connect(token); + } + }, [isOpen, token, socketStatus]); - init(); - }, [isOpen, isInitialized, connectionStatus, dispatch, onComplete, onClose]); + const handleTelegramAccountMismatch = useCallback(async () => { + if (!userId) return; + setError(TELEGRAM_ACCOUNT_MISMATCH_ERROR); + setCurrentStep("error"); + dispatch(setConnectionStatus({ userId, status: "error" })); + try { + await mtprotoService.clearSessionAndDisconnect(userId); + } catch (e) { + console.warn("clearSessionAndDisconnect failed:", e); + } + dispatch(resetTelegramForUser({ userId })); + }, [dispatch, userId]); const startQrCodeFlow = useCallback(async () => { try { setIsAuthenticating(true); setError(null); - setCurrentStep('qr'); - setPassword(''); + setCurrentStep("qr"); + setPassword(""); setPasswordHint(undefined); setQrCodeUrl(null); setQrCodeExpires(null); @@ -94,15 +104,17 @@ const TelegramConnectionModal = ({ isOpen, onClose, onComplete }: TelegramConnec // Convert Uint8Array to base64url const binary = Array.from(qrCode.token) .map((byte) => String.fromCharCode(byte)) - .join(''); + .join(""); tokenBase64 = btoa(binary) - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=/g, ''); + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, ""); } else { // If it's a Buffer, use toString - const buffer = qrCode.token as { toString: (encoding: string) => string }; - tokenBase64 = buffer.toString('base64url'); + const buffer = qrCode.token as { + toString: (encoding: string) => string; + }; + tokenBase64 = buffer.toString("base64url"); } const url = `tg://login?token=${tokenBase64}`; setQrCodeUrl(url); @@ -110,17 +122,20 @@ const TelegramConnectionModal = ({ isOpen, onClose, onComplete }: TelegramConnec }, async (hint) => { // 2FA password required - console.log('Password callback invoked with hint:', hint); + console.log("Password callback invoked with hint:", hint); setPasswordHint(hint); - setCurrentStep('2fa'); + setCurrentStep("2fa"); setIsAuthenticating(false); // Wait for user to enter password return new Promise((resolve, reject) => { passwordResolverRef.current = (password: string) => { - console.log('Password resolved in callback, sending to Telegram:', password ? '***' : 'empty'); + console.log( + "Password resolved in callback, sending to Telegram:", + password ? "***" : "empty", + ); if (!password || !password.trim()) { - reject(new Error('Password is required')); + reject(new Error("Password is required")); return; } resolve(password.trim()); @@ -129,8 +144,10 @@ const TelegramConnectionModal = ({ isOpen, onClose, onComplete }: TelegramConnec // Set a timeout to prevent hanging forever setTimeout(() => { if (passwordResolverRef.current) { - console.warn('Password callback timeout - no password provided'); - reject(new Error('Password input timeout')); + console.warn( + "Password callback timeout - no password provided", + ); + reject(new Error("Password input timeout")); passwordResolverRef.current = null; } }, 300000); // 5 minutes timeout @@ -138,64 +155,144 @@ const TelegramConnectionModal = ({ isOpen, onClose, onComplete }: TelegramConnec }, async (err) => { // Handle errors - const errorMessage = err.message || 'Authentication error'; + const errorMessage = err.message || "Authentication error"; // Check if it's a 2FA password needed error - if (errorMessage.includes('SESSION_PASSWORD_NEEDED') || errorMessage.includes('PASSWORD')) { + if ( + errorMessage.includes("SESSION_PASSWORD_NEEDED") || + errorMessage.includes("PASSWORD") + ) { // This should trigger the password callback, but if it doesn't, handle it here setPasswordHint(undefined); - setCurrentStep('2fa'); + setCurrentStep("2fa"); setIsAuthenticating(false); // Don't stop authentication - let the password callback handle it return false; } setError(errorMessage); - dispatch(setAuthError(errorMessage)); + dispatch(setAuthError({ userId, error: errorMessage })); // Check if it's a cancellation - if (errorMessage.includes('AUTH_USER_CANCEL') || errorMessage.includes('cancel')) { - setCurrentStep('qr'); + if ( + errorMessage.includes("AUTH_USER_CANCEL") || + errorMessage.includes("cancel") + ) { + setCurrentStep("qr"); setIsAuthenticating(false); return true; // Stop authentication } return false; // Continue - } + }, ); - // Authentication successful setIsAuthenticating(false); + let me: { id: unknown } | null = null; try { - await dispatch(checkAuthStatus()).unwrap(); + me = await dispatch(checkAuthStatus(userId)).unwrap(); } catch (err) { - // Even if checkAuthStatus fails, we know we just authenticated - console.warn('checkAuthStatus failed after authentication, but continuing:', err); + console.warn("checkAuthStatus failed after authentication:", err); } - dispatch(setAuthStatus('authenticated')); + if (!user) { + setError("Could not verify account. Please try again."); + setCurrentStep("error"); + dispatch(setConnectionStatus({ userId, status: "error" })); + return; + } + if (!me || !telegramMeIdMatchesUser(me, user)) { + await handleTelegramAccountMismatch(); + return; + } + dispatch(setAuthStatus({ userId, status: "authenticated" })); onComplete(); onClose(); } catch (err) { setIsAuthenticating(false); - const errorMessage = err instanceof Error ? err.message : 'Authentication failed'; + const errorMessage = + err instanceof Error ? err.message : "Authentication failed"; - // If it's a password needed error, we should already be on the 2FA step - // Don't show it as an error, just log it - if (errorMessage.includes('SESSION_PASSWORD_NEEDED')) { - console.log('Password required - user should be on 2FA step'); - // If we're not on 2FA step, switch to it - if (currentStep !== '2fa') { - setCurrentStep('2fa'); + if (errorMessage.includes("SESSION_PASSWORD_NEEDED")) { + console.log("Password required - user should be on 2FA step"); + if (currentStep !== "2fa") { + setCurrentStep("2fa"); setPasswordHint(undefined); } - return; // Don't show error, password callback should handle it + return; } setError(errorMessage); - setCurrentStep('error'); - dispatch(setAuthError(errorMessage)); + setCurrentStep("error"); + dispatch(setAuthError({ userId, error: errorMessage })); } - }, [dispatch, onComplete, onClose]); + }, [ + dispatch, + onComplete, + onClose, + user, + userId, + handleTelegramAccountMismatch, + ]); + + useEffect(() => { + if (!isOpen || !userId) return; + if (qrFlowStartedRef.current) return; + + const init = async () => { + try { + setCurrentStep("loading"); + if (!isInitialized) { + await dispatch(initializeTelegram(userId)).unwrap(); + } + if (connectionStatus !== "connected") { + await dispatch(connectTelegram(userId)).unwrap(); + } + try { + const authCheck = await dispatch(checkAuthStatus(userId)).unwrap(); + if (authCheck) { + if (!user) { + setError("Could not verify account. Please try again."); + setCurrentStep("error"); + dispatch(setConnectionStatus({ userId, status: "error" })); + return; + } + if (!telegramMeIdMatchesUser(authCheck, user)) { + await handleTelegramAccountMismatch(); + return; + } + onComplete(); + onClose(); + return; + } + } catch (err) { + console.log("Not authenticated, proceeding with QR code flow"); + } + if (qrFlowStartedRef.current) return; + qrFlowStartedRef.current = true; + await startQrCodeFlow(); + } catch (err) { + qrFlowStartedRef.current = false; + const errorMessage = + err instanceof Error ? err.message : "Failed to initialize Telegram"; + setError(errorMessage); + setCurrentStep("error"); + dispatch(setConnectionStatus({ userId, status: "error" })); + } + }; + + void init(); + }, [ + isOpen, + isInitialized, + connectionStatus, + dispatch, + onComplete, + onClose, + user, + userId, + handleTelegramAccountMismatch, + startQrCodeFlow, + ]); // Update countdown timer every second and reload QR code on timeout useEffect(() => { @@ -205,11 +302,14 @@ const TelegramConnectionModal = ({ isOpen, onClose, onComplete }: TelegramConnec } const updateTimer = () => { - const remaining = Math.max(0, Math.floor((qrCodeExpires * 1000 - Date.now()) / 1000)); + const remaining = Math.max( + 0, + Math.floor((qrCodeExpires * 1000 - Date.now()) / 1000), + ); setTimeRemaining(remaining); // If timer reaches 0 and we're on QR step, reload the QR code - if (remaining === 0 && currentStep === 'qr' && !isAuthenticating) { + if (remaining === 0 && currentStep === "qr" && !isAuthenticating) { startQrCodeFlow(); } }; @@ -225,68 +325,92 @@ const TelegramConnectionModal = ({ isOpen, onClose, onComplete }: TelegramConnec // Poll authentication status every 5 seconds when QR code is displayed useEffect(() => { - if (currentStep !== 'qr' || !qrCodeUrl || isAuthenticating) { + if (currentStep !== "qr" || !qrCodeUrl || isAuthenticating) { return; } - const checkAuthStatus = async () => { + const pollAuthStatus = async () => { try { + if (!mtprotoService.isReady()) return; const client = mtprotoService.getClient(); - if (!client) return; - const isAuthorized = await client.checkAuthorization(); if (isAuthorized) { - // User is authenticated - check if we need password try { - // Try to get user info - if this fails with SESSION_PASSWORD_NEEDED, we need password - await client.getMe(); - // If getMe succeeds, authentication is complete - console.log('QR code scanned and authenticated successfully'); - // The signInWithQrCode promise should resolve, but if it doesn't, trigger completion + const me = await client.getMe(); + if (!user) { + setError("Could not verify account. Please try again."); + setCurrentStep("error"); + dispatch(setConnectionStatus({ userId, status: "error" })); + setIsAuthenticating(false); + return; + } + if (!telegramMeIdMatchesUser(me, user)) { + setIsAuthenticating(false); + await handleTelegramAccountMismatch(); + return; + } + console.log("QR code scanned and authenticated successfully"); setIsAuthenticating(false); - dispatch(setAuthStatus('authenticated')); + dispatch(setAuthStatus({ userId, status: "authenticated" })); onComplete(); onClose(); } catch (err) { - const errorMessage = err instanceof Error ? err.message : String(err); - if (errorMessage.includes('SESSION_PASSWORD_NEEDED') || errorMessage.includes('PASSWORD')) { - // Need password - switch to 2FA step - console.log('Password required after QR scan - switching to 2FA step'); - if (currentStep === 'qr' && !passwordResolverRef.current) { - // Trigger password callback manually if it wasn't called + const errorMessage = + err instanceof Error ? err.message : String(err); + if ( + errorMessage.includes("SESSION_PASSWORD_NEEDED") || + errorMessage.includes("PASSWORD") + ) { + console.log( + "Password required after QR scan - switching to 2FA step", + ); + if (currentStep === "qr" && !passwordResolverRef.current) { setPasswordHint(undefined); - setCurrentStep('2fa'); + setCurrentStep("2fa"); setIsAuthenticating(false); } } } } } catch (err) { - // Ignore errors during polling - they're expected if not authenticated yet - console.debug('Auth status check:', err instanceof Error ? err.message : String(err)); + console.debug( + "Auth status check:", + err instanceof Error ? err.message : String(err), + ); } }; - // Check immediately, then every 5 seconds - checkAuthStatus(); - const interval = setInterval(checkAuthStatus, 5000); + pollAuthStatus(); + const interval = setInterval(pollAuthStatus, 5000); return () => clearInterval(interval); - }, [currentStep, qrCodeUrl, isAuthenticating, dispatch, onComplete, onClose]); + }, [ + currentStep, + qrCodeUrl, + isAuthenticating, + dispatch, + onComplete, + onClose, + user, + userId, + handleTelegramAccountMismatch, + ]); const handle2FASubmit = async () => { const passwordValue = password.trim(); if (!passwordValue) { - setError('Please enter your password'); + setError("Please enter your password"); return; } if (!passwordResolverRef.current) { - console.error('Password resolver is null - password callback may not be active'); - setError('Authentication session expired. Please try again.'); - setCurrentStep('qr'); + console.error( + "Password resolver is null - password callback may not be active", + ); + setError("Authentication session expired. Please try again."); + setCurrentStep("qr"); return; } @@ -299,7 +423,7 @@ const TelegramConnectionModal = ({ isOpen, onClose, onComplete }: TelegramConnec const resolver = passwordResolverRef.current; passwordResolverRef.current = null; // Clear before resolving to prevent double calls - console.log('Resolving password promise - sending password to Telegram'); + console.log("Resolving password promise - sending password to Telegram"); resolver(passwordValue); // The authentication will continue in the background via signInWithQrCode @@ -307,20 +431,21 @@ const TelegramConnectionModal = ({ isOpen, onClose, onComplete }: TelegramConnec // We keep isAuthenticating true until the promise resolves or rejects } catch (err) { setIsAuthenticating(false); - const errorMessage = err instanceof Error ? err.message : 'Password verification failed'; + const errorMessage = + err instanceof Error ? err.message : "Password verification failed"; setError(errorMessage); - dispatch(setAuthError(errorMessage)); - console.error('Error in handle2FASubmit:', err); + dispatch(setAuthError({ userId, error: errorMessage })); + console.error("Error in handle2FASubmit:", err); } }; const handleBack = () => { - if (currentStep === '2fa') { - setCurrentStep('qr'); - setPassword(''); + if (currentStep === "2fa") { + setCurrentStep("qr"); + setPassword(""); setPasswordHint(undefined); if (passwordResolverRef.current) { - passwordResolverRef.current(''); + passwordResolverRef.current(""); passwordResolverRef.current = null; } } else { @@ -328,12 +453,27 @@ const TelegramConnectionModal = ({ isOpen, onClose, onComplete }: TelegramConnec } }; - const handleRetry = () => { + const handleRetry = async () => { + if (!userId) return; setError(null); - setPassword(''); + setPassword(""); setPasswordHint(undefined); setQrCodeUrl(null); setQrCodeExpires(null); + setCurrentStep("loading"); + try { + if (!isInitialized) { + await dispatch(initializeTelegram(userId)).unwrap(); + } + if (connectionStatus !== "connected") { + await dispatch(connectTelegram(userId)).unwrap(); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to initialize"); + setCurrentStep("error"); + dispatch(setConnectionStatus({ userId, status: "error" })); + return; + } startQrCodeFlow(); }; @@ -343,22 +483,22 @@ const TelegramConnectionModal = ({ isOpen, onClose, onComplete }: TelegramConnec
{/* Close button */} @@ -366,27 +506,49 @@ const TelegramConnectionModal = ({ isOpen, onClose, onComplete }: TelegramConnec onClick={onClose} className="absolute top-6 right-6 z-10 w-8 h-8 flex items-center justify-center rounded-full hover:bg-stone-800/50 transition-colors" > - - + + - {currentStep === 'loading' ? ( + {currentStep === "loading" ? (

Initializing Telegram connection...

- ) : currentStep === 'error' ? ( + ) : currentStep === "error" ? ( <> {/* Error Screen */}
- - + +

Connection Error

-

{error || 'An error occurred'}

+

+ {error || "An error occurred"} +

- ) : currentStep === 'qr' ? ( + ) : currentStep === "qr" ? ( <> {/* QR Code Screen */}
@@ -428,10 +590,14 @@ const TelegramConnectionModal = ({ isOpen, onClose, onComplete }: TelegramConnec {isAuthenticating ? (
-

Generating QR code...

+

+ Generating QR code... +

) : ( -

Loading QR code...

+

+ Loading QR code... +

)}
)} @@ -456,21 +622,27 @@ const TelegramConnectionModal = ({ isOpen, onClose, onComplete }: TelegramConnec
1
-

Open Telegram on your phone

+

+ Open Telegram on your phone +

2
-

Go to Settings > Devices > Link Desktop Device

+

+ Go to Settings > Devices > Link Desktop Device +

3
-

Point your phone at this screen to confirm login

+

+ Point your phone at this screen to confirm login +

@@ -483,7 +655,7 @@ const TelegramConnectionModal = ({ isOpen, onClose, onComplete }: TelegramConnec

{passwordHint ? `Your account is protected with two-step verification. Hint: ${passwordHint}` - : 'Your account is protected with two-step verification. Please enter your password to continue.'} + : "Your account is protected with two-step verification. Please enter your password to continue."}

{error && ( @@ -499,7 +671,11 @@ const TelegramConnectionModal = ({ isOpen, onClose, onComplete }: TelegramConnec value={password} onChange={(e) => setPassword(e.target.value)} onKeyDown={(e) => { - if (e.key === 'Enter' && password.trim() && !isAuthenticating) { + if ( + e.key === "Enter" && + password.trim() && + !isAuthenticating + ) { handle2FASubmit(); } }} @@ -531,7 +707,7 @@ const TelegramConnectionModal = ({ isOpen, onClose, onComplete }: TelegramConnec disabled={!password.trim() || isAuthenticating} className="flex-1 py-2.5 px-4 bg-primary-500 hover:bg-primary-600 active:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-xl text-sm font-medium transition-all duration-200" > - {isAuthenticating ? 'Verifying...' : 'Continue'} + {isAuthenticating ? "Verifying..." : "Continue"}
diff --git a/src/components/TelegramLoginButton.tsx b/src/components/TelegramLoginButton.tsx index 7cdf913f8..81eac730d 100644 --- a/src/components/TelegramLoginButton.tsx +++ b/src/components/TelegramLoginButton.tsx @@ -1,300 +1,24 @@ -import { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { BACKEND_URL, TELEGRAM_BOT_ID } from '../utils/config'; -import { useAppDispatch } from '../store/hooks'; -import { setToken } from '../store/authSlice'; +import { TELEGRAM_BOT_USERNAME } from "../utils/config"; +import { openUrl } from "../utils/openUrl"; -interface TelegramAuthData { - id: number; - first_name?: string; - last_name?: string; - username?: string; - photo_url?: string; - auth_date: number; - hash: string; -} interface TelegramLoginButtonProps { - onSuccess?: () => void; className?: string; text?: string; disabled?: boolean; } const TelegramLoginButton = ({ - onSuccess, - className = '', - text = 'Yes, Login with Telegram', + className = "", + text = "Yes, Login with Telegram", disabled: externalDisabled = false, }: TelegramLoginButtonProps) => { - const navigate = useNavigate(); - const dispatch = useAppDispatch(); - const [isAuthenticating, setIsAuthenticating] = useState(false); - - const handleTelegramLogin = async () => { - if (isAuthenticating || externalDisabled) return; - - setIsAuthenticating(true); - - try { - const origin = window.location.origin; - const botId = TELEGRAM_BOT_ID; - // Pass origin as query param so backend can use it for postMessage - const callbackUrl = `${BACKEND_URL}/auth/telegram/callback?frontend_origin=${encodeURIComponent(origin)}`; - const oauthUrl = `https://oauth.telegram.org/auth?bot_id=${botId}&origin=${encodeURIComponent(origin)}&request_access=write&embed=0&return_to=${encodeURIComponent(callbackUrl)}`; - - // Open popup window - const width = 550; - const height = 470; - const availLeft = 'availLeft' in screen ? (screen as { availLeft: number }).availLeft : 0; - const availTop = 'availTop' in screen ? (screen as { availTop: number }).availTop : 0; - const left = Math.max(0, (screen.width - width) / 2) + availLeft; - const top = Math.max(0, (screen.height - height) / 2) + availTop; - - const popup = window.open( - oauthUrl, - 'telegram_oauth', - `width=${width},height=${height},left=${left},top=${top},status=0,location=0,menubar=0,toolbar=0` - ); - - if (!popup) { - throw new Error('Failed to open popup window. Please allow popups for this site.'); - } - - let authCompleted = false; - - // Listen for postMessage from Telegram OAuth - const messageHandler = async (event: MessageEvent) => { - // 1) Only accept messages from Telegram OAuth - if (event.origin !== 'https://oauth.telegram.org') { - return; - } - - // 2) Parse the payload (Telegram sends a JSON string) - let data: any; - try { - data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data; - } catch { - return; - } - - // 3) Handle successful auth_result from Telegram - if (data.event === 'auth_result' && data.result && !authCompleted) { - authCompleted = true; - window.removeEventListener('message', messageHandler); - popup.close(); - - const telegramData: TelegramAuthData = { - id: data.result.id, - first_name: data.result.first_name, - last_name: data.result.last_name, - username: data.result.username, - photo_url: data.result.photo_url, - auth_date: data.result.auth_date, - hash: data.result.hash, - }; - - try { - // Send Telegram auth data to backend to verify and exchange for JWT - const webCompleteResponse = await fetch(`${BACKEND_URL}/auth/web-complete`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - method: 'telegram', - telegramUser: telegramData, - }), - }); - - if (!webCompleteResponse.ok) { - const errorData = await webCompleteResponse.json().catch(() => ({})); - throw new Error(errorData.error || 'Failed to complete authentication'); - } - - const { data: completeData } = await webCompleteResponse.json(); - const { loginToken } = completeData || {}; - - if (!loginToken) { - throw new Error('No login token received from server'); - } - - const exchangeResponse = await fetch(`${BACKEND_URL}/auth/desktop-exchange`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - token: loginToken, - }), - }); - - if (!exchangeResponse.ok) { - const errorData = await exchangeResponse.json().catch(() => ({})); - throw new Error(errorData.error || 'Failed to exchange token'); - } - - const exchangeData = await exchangeResponse.json(); - const { sessionToken } = exchangeData.data || {}; - - if (!sessionToken) { - throw new Error('No JWT token received from server'); - } - - // Store JWT token in store (this is the JWT from the backend) - dispatch(setToken(sessionToken)); - - // Call onSuccess callback if provided, otherwise navigate to onboarding - if (onSuccess) { - onSuccess(); - } else { - navigate('/onboarding/step1'); - } - setIsAuthenticating(false); - } catch (error) { - setIsAuthenticating(false); - console.error('Failed to complete Telegram authentication:', error); - } - } - }; - - window.addEventListener('message', messageHandler); - - // Fallback: Try to extract auth data from popup URL if postMessage doesn't work - // This will only work if the popup redirects to our origin (unlikely but possible) - const checkPopupUrl = setInterval(async () => { - if (authCompleted || popup.closed) { - clearInterval(checkPopupUrl); - return; - } - - try { - // Try to read popup location (will throw if cross-origin) - const popupUrl = popup.location.href; - - // If popup is on our callback URL, extract data and get JWT from backend - if (popupUrl.startsWith(callbackUrl.split('?')[0])) { - const url = new URL(popupUrl); - const telegramData: TelegramAuthData = { - id: parseInt(url.searchParams.get('id') || '0', 10), - first_name: url.searchParams.get('first_name') || undefined, - last_name: url.searchParams.get('last_name') || undefined, - username: url.searchParams.get('username') || undefined, - photo_url: url.searchParams.get('photo_url') || undefined, - auth_date: parseInt(url.searchParams.get('auth_date') || '0', 10), - hash: url.searchParams.get('hash') || '', - }; - - // Validate required fields - if (telegramData.id && telegramData.auth_date && telegramData.hash) { - authCompleted = true; - clearInterval(checkPopupUrl); - popup.close(); - window.removeEventListener('message', messageHandler); - - try { - // Send Telegram auth data to backend to get JWT token - // Use web-complete to verify and then exchange for JWT - const webCompleteResponse = await fetch(`${BACKEND_URL}/auth/web-complete`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - method: 'telegram', - telegramUser: telegramData, - }), - }); - - if (!webCompleteResponse.ok) { - const errorData = await webCompleteResponse.json().catch(() => ({})); - throw new Error(errorData.error || 'Failed to complete authentication'); - } - - const { data } = await webCompleteResponse.json(); - const { loginToken } = data; - - if (!loginToken) { - throw new Error('No login token received from server'); - } - - // Exchange handoff token for JWT session token - const exchangeResponse = await fetch(`${BACKEND_URL}/auth/desktop-exchange`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - token: loginToken, - }), - }); - - if (!exchangeResponse.ok) { - const errorData = await exchangeResponse.json().catch(() => ({})); - throw new Error(errorData.error || 'Failed to exchange token'); - } - - const exchangeData = await exchangeResponse.json(); - const { sessionToken } = exchangeData.data; - - if (!sessionToken) { - throw new Error('No JWT token received from server'); - } - - // Store JWT token in store (this is the JWT from the backend) - dispatch(setToken(sessionToken)); - - // Call onSuccess callback if provided, otherwise navigate to onboarding - if (onSuccess) { - onSuccess(); - } else { - navigate('/onboarding/step1'); - } - setIsAuthenticating(false); - } catch (error) { - setIsAuthenticating(false); - console.error('Failed to complete authentication:', error); - // alert(error instanceof Error ? error.message : 'Authentication failed. Please try again.'); - } - } - } - } catch (e) { - // Cross-origin or other error - this is expected, continue polling - // The postMessage handler will catch the auth completion - } - }, 500); - - // Monitor popup closure - const checkClosed = setInterval(() => { - if (popup.closed && !authCompleted) { - clearInterval(checkClosed); - clearInterval(checkPopupUrl); - window.removeEventListener('message', messageHandler); - setIsAuthenticating(false); - } - }, 500); - - // Timeout after 5 minutes - setTimeout(() => { - if (!authCompleted) { - if (!popup.closed) { - popup.close(); - } - clearInterval(checkClosed); - clearInterval(checkPopupUrl); - window.removeEventListener('message', messageHandler); - setIsAuthenticating(false); - // alert('Authentication timed out. Please try again.'); - } - }, 5 * 60 * 1000); - } catch (error) { - setIsAuthenticating(false); - console.error('Failed to start Telegram authentication:', error); - // alert(error instanceof Error ? error.message : 'Failed to start authentication. Please try again.'); - } + const handleTelegramLogin = () => { + if (externalDisabled) return; + openUrl(`https://t.me/${TELEGRAM_BOT_USERNAME}?start=login`); }; - const isDisabled = isAuthenticating || externalDisabled; + const isDisabled = externalDisabled; return ( ); }; diff --git a/src/components/TypewriterGreeting.tsx b/src/components/TypewriterGreeting.tsx index 1a1ad5659..93663a885 100644 --- a/src/components/TypewriterGreeting.tsx +++ b/src/components/TypewriterGreeting.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect } from "react"; interface TypewriterGreetingProps { greetings: string[]; @@ -13,10 +13,10 @@ const TypewriterGreeting = ({ typingSpeed = 100, deletingSpeed = 50, pauseTime = 2000, - className = '', + className = "", }: TypewriterGreetingProps) => { const [currentGreetingIndex, setCurrentGreetingIndex] = useState(0); - const [displayedText, setDisplayedText] = useState(''); + const [displayedText, setDisplayedText] = useState(""); const [isDeleting, setIsDeleting] = useState(false); const [isPaused, setIsPaused] = useState(false); @@ -56,7 +56,16 @@ const TypewriterGreeting = ({ }, speed); return () => clearTimeout(timer); - }, [displayedText, isDeleting, isPaused, currentGreetingIndex, greetings, typingSpeed, deletingSpeed, pauseTime]); + }, [ + displayedText, + isDeleting, + isPaused, + currentGreetingIndex, + greetings, + typingSpeed, + deletingSpeed, + pauseTime, + ]); return (

diff --git a/src/components/settings/SettingsHome.tsx b/src/components/settings/SettingsHome.tsx index 74bcc767d..7509509d6 100644 --- a/src/components/settings/SettingsHome.tsx +++ b/src/components/settings/SettingsHome.tsx @@ -1,8 +1,8 @@ -import { useAppDispatch } from '../../store/hooks'; -import { clearToken } from '../../store/authSlice'; -import { useSettingsNavigation } from './hooks/useSettingsNavigation'; -import SettingsHeader from './components/SettingsHeader'; -import SettingsMenuItem from './components/SettingsMenuItem'; +import { useAppDispatch } from "../../store/hooks"; +import { clearToken } from "../../store/authSlice"; +import { useSettingsNavigation } from "./hooks/useSettingsNavigation"; +import SettingsHeader from "./components/SettingsHeader"; +import SettingsMenuItem from "./components/SettingsMenuItem"; const SettingsHome = () => { const dispatch = useAppDispatch(); @@ -15,129 +15,224 @@ const SettingsHome = () => { const handleViewEncryptionKey = () => { // TODO: Show encryption key in a secure modal - console.log('View encryption key'); + console.log("View encryption key"); }; const handleDeleteAllData = () => { // TODO: Show confirmation dialog and delete all data - console.log('Delete all data'); + console.log("Delete all data"); }; // Main settings menu items const mainMenuItems = [ { - id: 'connections', - title: 'Connections', - description: 'Manage your connected accounts and services', + id: "connections", + title: "Connections", + description: "Manage your connected accounts and services", icon: ( - - + + ), - onClick: () => navigateToSettings('connections'), - dangerous: false + onClick: () => navigateToSettings("connections"), + dangerous: false, }, { - id: 'messaging', - title: 'Messaging', - description: 'Configure messaging preferences and templates', + id: "messaging", + title: "Messaging", + description: "Configure messaging preferences and templates", icon: ( - - + + ), - onClick: () => navigateToSettings('messaging'), - dangerous: false + onClick: () => navigateToSettings("messaging"), + dangerous: false, }, { - id: 'privacy', - title: 'Privacy & Security', - description: 'Control your privacy and security settings', + id: "privacy", + title: "Privacy & Security", + description: "Control your privacy and security settings", icon: ( - - + + ), - onClick: () => navigateToSettings('privacy'), - dangerous: false + onClick: () => navigateToSettings("privacy"), + dangerous: false, }, { - id: 'profile', - title: 'Profile', - description: 'Update your profile information and preferences', + id: "profile", + title: "Profile", + description: "Update your profile information and preferences", icon: ( - - + + ), - onClick: () => navigateToSettings('profile'), - dangerous: false + onClick: () => navigateToSettings("profile"), + dangerous: false, }, { - id: 'advanced', - title: 'Advanced', - description: 'Advanced configuration and developer options', + id: "advanced", + title: "Advanced", + description: "Advanced configuration and developer options", icon: ( - - - + + + ), - onClick: () => navigateToSettings('advanced'), - dangerous: false + onClick: () => navigateToSettings("advanced"), + dangerous: false, }, { - id: 'encryption', - title: 'View Encryption Key', - description: 'Access your encryption key for backup purposes', + id: "encryption", + title: "View Encryption Key", + description: "Access your encryption key for backup purposes", icon: ( - - + + ), onClick: handleViewEncryptionKey, - dangerous: false + dangerous: false, }, { - id: 'billing', - title: 'Billing', - description: 'Manage your subscription and payment methods', + id: "billing", + title: "Billing", + description: "Manage your subscription and payment methods", icon: ( - - + + ), - onClick: () => navigateToSettings('billing'), - dangerous: false - } + onClick: () => navigateToSettings("billing"), + dangerous: false, + }, ]; // Destructive actions menu items const destructiveMenuItems = [ { - id: 'delete', - title: 'Delete All Data', - description: 'Permanently delete all your data and reset your account', + id: "delete", + title: "Delete All Data", + description: "Permanently delete all your data and reset your account", icon: ( - - + + ), onClick: handleDeleteAllData, - dangerous: true + dangerous: true, }, { - id: 'logout', - title: 'Log out', - description: 'Sign out of your account', + id: "logout", + title: "Log out", + description: "Sign out of your account", icon: ( - - + + ), onClick: handleLogout, - dangerous: true - } + dangerous: true, + }, ]; return ( diff --git a/src/components/settings/SettingsLayout.tsx b/src/components/settings/SettingsLayout.tsx index 9ef58017a..6b37cf608 100644 --- a/src/components/settings/SettingsLayout.tsx +++ b/src/components/settings/SettingsLayout.tsx @@ -1,5 +1,5 @@ -import { ReactNode, useEffect, useRef } from 'react'; -import { createPortal } from 'react-dom'; +import { ReactNode, useEffect, useRef } from "react"; +import { createPortal } from "react-dom"; interface SettingsLayoutProps { children: ReactNode; @@ -12,13 +12,13 @@ const SettingsLayout = ({ children, onClose }: SettingsLayoutProps) => { // Handle escape key useEffect(() => { const handleEscape = (e: KeyboardEvent) => { - if (e.key === 'Escape') { + if (e.key === "Escape") { onClose(); } }; - document.addEventListener('keydown', handleEscape); - return () => document.removeEventListener('keydown', handleEscape); + document.addEventListener("keydown", handleEscape); + return () => document.removeEventListener("keydown", handleEscape); }, [onClose]); // Handle backdrop click @@ -57,9 +57,9 @@ const SettingsLayout = ({ children, onClose }: SettingsLayoutProps) => { ref={modalRef} className="glass rounded-3xl shadow-large w-full max-w-[520px] h-[800px] overflow-hidden animate-slide-right focus:outline-none focus:ring-0" style={{ - animationDuration: '300ms', - animationTimingFunction: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)', - animationFillMode: 'both' + animationDuration: "300ms", + animationTimingFunction: "cubic-bezier(0.25, 0.46, 0.45, 0.94)", + animationFillMode: "both", }} tabIndex={-1} onClick={(e) => e.stopPropagation()} @@ -72,4 +72,4 @@ const SettingsLayout = ({ children, onClose }: SettingsLayoutProps) => { return createPortal(modalContent, document.body); }; -export default SettingsLayout; \ No newline at end of file +export default SettingsLayout; diff --git a/src/components/settings/SettingsModal.tsx b/src/components/settings/SettingsModal.tsx index 193f0e259..6b3bd5f8d 100644 --- a/src/components/settings/SettingsModal.tsx +++ b/src/components/settings/SettingsModal.tsx @@ -1,21 +1,21 @@ -import { Routes, Route } from 'react-router-dom'; -import { useLocation } from 'react-router-dom'; -import SettingsLayout from './SettingsLayout'; -import SettingsHome from './SettingsHome'; -import ConnectionsPanel from './panels/ConnectionsPanel'; -import MessagingPanel from './panels/MessagingPanel'; -import PrivacyPanel from './panels/PrivacyPanel'; -import ProfilePanel from './panels/ProfilePanel'; -import AdvancedPanel from './panels/AdvancedPanel'; -import BillingPanel from './panels/BillingPanel'; -import { useSettingsNavigation } from './hooks/useSettingsNavigation'; +import { Routes, Route } from "react-router-dom"; +import { useLocation } from "react-router-dom"; +import SettingsLayout from "./SettingsLayout"; +import SettingsHome from "./SettingsHome"; +import ConnectionsPanel from "./panels/ConnectionsPanel"; +import MessagingPanel from "./panels/MessagingPanel"; +import PrivacyPanel from "./panels/PrivacyPanel"; +import ProfilePanel from "./panels/ProfilePanel"; +import AdvancedPanel from "./panels/AdvancedPanel"; +import BillingPanel from "./panels/BillingPanel"; +import { useSettingsNavigation } from "./hooks/useSettingsNavigation"; const SettingsModal = () => { const location = useLocation(); const { closeSettings } = useSettingsNavigation(); // Only render modal when on settings routes - const isSettingsRoute = location.pathname.startsWith('/settings'); + const isSettingsRoute = location.pathname.startsWith("/settings"); if (!isSettingsRoute) { return null; @@ -36,4 +36,4 @@ const SettingsModal = () => { ); }; -export default SettingsModal; \ No newline at end of file +export default SettingsModal; diff --git a/src/components/settings/components/SettingsBackButton.tsx b/src/components/settings/components/SettingsBackButton.tsx index 90649c36e..13923d1b4 100644 --- a/src/components/settings/components/SettingsBackButton.tsx +++ b/src/components/settings/components/SettingsBackButton.tsx @@ -6,8 +6,8 @@ interface SettingsBackButtonProps { const SettingsBackButton = ({ onClick, - title = 'Settings', - className = '' + title = "Settings", + className = "", }: SettingsBackButtonProps) => { return (
@@ -35,4 +35,4 @@ const SettingsBackButton = ({ ); }; -export default SettingsBackButton; \ No newline at end of file +export default SettingsBackButton; diff --git a/src/components/settings/components/SettingsHeader.tsx b/src/components/settings/components/SettingsHeader.tsx index 3a280832f..3b535b639 100644 --- a/src/components/settings/components/SettingsHeader.tsx +++ b/src/components/settings/components/SettingsHeader.tsx @@ -1,4 +1,4 @@ -import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; +import { useSettingsNavigation } from "../hooks/useSettingsNavigation"; interface SettingsHeaderProps { className?: string; @@ -8,15 +8,17 @@ interface SettingsHeaderProps { } const SettingsHeader = ({ - className = '', - title = 'Settings', + className = "", + title = "Settings", showBackButton = false, - onBack + onBack, }: SettingsHeaderProps) => { const { closeSettings } = useSettingsNavigation(); return ( -
+
{/* Back button */} @@ -26,14 +28,27 @@ const SettingsHeader = ({ className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-stone-800/50 transition-colors mr-3" aria-label="Go back" > - - + + )} {/* Title */} -

+

{title}

@@ -44,8 +59,18 @@ const SettingsHeader = ({ className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-stone-800/50 transition-colors" aria-label="Close settings" > - - + +
@@ -53,4 +78,4 @@ const SettingsHeader = ({ ); }; -export default SettingsHeader; \ No newline at end of file +export default SettingsHeader; diff --git a/src/components/settings/components/SettingsMenuItem.tsx b/src/components/settings/components/SettingsMenuItem.tsx index 4fba5696d..16b408223 100644 --- a/src/components/settings/components/SettingsMenuItem.tsx +++ b/src/components/settings/components/SettingsMenuItem.tsx @@ -1,4 +1,4 @@ -import { ReactNode } from 'react'; +import { ReactNode } from "react"; interface SettingsMenuItemProps { icon: ReactNode; @@ -17,16 +17,20 @@ const SettingsMenuItem = ({ onClick, dangerous = false, isFirst = false, - isLast = false + isLast = false, }: SettingsMenuItemProps) => { // Color variations for dangerous items (like logout/delete) - const titleColor = dangerous ? 'text-amber-400' : 'text-white'; - const iconColor = dangerous ? 'text-amber-400' : 'text-white'; - const borderColor = 'border-stone-700'; // Use consistent border color for all items + const titleColor = dangerous ? "text-amber-400" : "text-white"; + const iconColor = dangerous ? "text-amber-400" : "text-white"; + const borderColor = "border-stone-700"; // Use consistent border color for all items // Border classes for first/last items - const borderClasses = isLast ? '' : `border-b ${borderColor}`; - const roundedClasses = isFirst ? 'first:rounded-t-3xl' : (isLast ? 'last:rounded-b-3xl' : ''); + const borderClasses = isLast ? "" : `border-b ${borderColor}`; + const roundedClasses = isFirst + ? "first:rounded-t-3xl" + : isLast + ? "last:rounded-b-3xl" + : ""; return ( ); }; diff --git a/src/components/settings/components/SettingsPanelLayout.tsx b/src/components/settings/components/SettingsPanelLayout.tsx index 7bd97ab18..0d8f3542f 100644 --- a/src/components/settings/components/SettingsPanelLayout.tsx +++ b/src/components/settings/components/SettingsPanelLayout.tsx @@ -1,5 +1,5 @@ -import { ReactNode } from 'react'; -import SettingsBackButton from './SettingsBackButton'; +import { ReactNode } from "react"; +import SettingsBackButton from "./SettingsBackButton"; interface SettingsPanelLayoutProps { title: string; @@ -12,16 +12,16 @@ const SettingsPanelLayout = ({ title, onBack, children, - className = '' + className = "", }: SettingsPanelLayoutProps) => { return ( -
+
-
- {children} -
+
{children}
); }; -export default SettingsPanelLayout; \ No newline at end of file +export default SettingsPanelLayout; diff --git a/src/components/settings/hooks/useSettingsAnimation.ts b/src/components/settings/hooks/useSettingsAnimation.ts index 7488c75ea..c1aa92578 100644 --- a/src/components/settings/hooks/useSettingsAnimation.ts +++ b/src/components/settings/hooks/useSettingsAnimation.ts @@ -1,6 +1,6 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect } from "react"; -export type AnimationState = 'entering' | 'entered' | 'exiting' | 'exited'; +export type AnimationState = "entering" | "entered" | "exiting" | "exited"; interface SettingsAnimationHook { isVisible: boolean; @@ -9,24 +9,24 @@ interface SettingsAnimationHook { startExit: () => void; } -export const useSettingsAnimation = ( - duration = 300 -): SettingsAnimationHook => { - const [animationState, setAnimationState] = useState('exited'); +export const useSettingsAnimation = (duration = 300): SettingsAnimationHook => { + const [animationState, setAnimationState] = + useState("exited"); - const isVisible = animationState === 'entering' || animationState === 'entered'; + const isVisible = + animationState === "entering" || animationState === "entered"; const startEntry = () => { - setAnimationState('entering'); + setAnimationState("entering"); setTimeout(() => { - setAnimationState('entered'); + setAnimationState("entered"); }, duration); }; const startExit = () => { - setAnimationState('exiting'); + setAnimationState("exiting"); setTimeout(() => { - setAnimationState('exited'); + setAnimationState("exited"); }, duration); }; @@ -34,7 +34,7 @@ export const useSettingsAnimation = ( isVisible, animationState, startEntry, - startExit + startExit, }; }; @@ -52,7 +52,8 @@ export const usePanelAnimation = (isActive: boolean, duration = 300) => { }, [isActive, duration]); const getPanelClasses = () => { - const baseClasses = 'transition-all duration-300 ease-[cubic-bezier(0.25,0.46,0.45,0.94)]'; + const baseClasses = + "transition-all duration-300 ease-[cubic-bezier(0.25,0.46,0.45,0.94)]"; if (!mounted) return `${baseClasses} opacity-0`; return isActive @@ -62,6 +63,6 @@ export const usePanelAnimation = (isActive: boolean, duration = 300) => { return { mounted, - panelClasses: getPanelClasses() + panelClasses: getPanelClasses(), }; -}; \ No newline at end of file +}; diff --git a/src/components/settings/hooks/useSettingsNavigation.ts b/src/components/settings/hooks/useSettingsNavigation.ts index 145ae9811..ba2ad53e1 100644 --- a/src/components/settings/hooks/useSettingsNavigation.ts +++ b/src/components/settings/hooks/useSettingsNavigation.ts @@ -1,7 +1,14 @@ -import { useNavigate, useLocation } from 'react-router-dom'; -import { useCallback } from 'react'; +import { useNavigate, useLocation } from "react-router-dom"; +import { useCallback } from "react"; -export type SettingsRoute = 'home' | 'connections' | 'messaging' | 'privacy' | 'profile' | 'advanced' | 'billing'; +export type SettingsRoute = + | "home" + | "connections" + | "messaging" + | "privacy" + | "profile" + | "advanced" + | "billing"; interface SettingsNavigationHook { currentRoute: SettingsRoute; @@ -17,41 +24,44 @@ export const useSettingsNavigation = (): SettingsNavigationHook => { // Determine current settings route from URL const getCurrentRoute = (): SettingsRoute => { const path = location.pathname; - if (path.includes('/settings/connections')) return 'connections'; - if (path.includes('/settings/messaging')) return 'messaging'; - if (path.includes('/settings/privacy')) return 'privacy'; - if (path.includes('/settings/profile')) return 'profile'; - if (path.includes('/settings/advanced')) return 'advanced'; - if (path.includes('/settings/billing')) return 'billing'; - return 'home'; + if (path.includes("/settings/connections")) return "connections"; + if (path.includes("/settings/messaging")) return "messaging"; + if (path.includes("/settings/privacy")) return "privacy"; + if (path.includes("/settings/profile")) return "profile"; + if (path.includes("/settings/advanced")) return "advanced"; + if (path.includes("/settings/billing")) return "billing"; + return "home"; }; const currentRoute = getCurrentRoute(); - const navigateToSettings = useCallback((route: SettingsRoute = 'home') => { - if (route === 'home') { - navigate('/settings'); - } else { - navigate(`/settings/${route}`); - } - }, [navigate]); + const navigateToSettings = useCallback( + (route: SettingsRoute = "home") => { + if (route === "home") { + navigate("/settings"); + } else { + navigate(`/settings/${route}`); + } + }, + [navigate], + ); const navigateBack = useCallback(() => { - if (currentRoute === 'home') { - navigate('/home'); + if (currentRoute === "home") { + navigate("/home"); } else { - navigate('/settings'); + navigate("/settings"); } }, [navigate, currentRoute]); const closeSettings = useCallback(() => { - navigate('/home'); + navigate("/home"); }, [navigate]); return { currentRoute, navigateToSettings, navigateBack, - closeSettings + closeSettings, }; -}; \ No newline at end of file +}; diff --git a/src/components/settings/panels/AdvancedPanel.tsx b/src/components/settings/panels/AdvancedPanel.tsx index de5c0b4d2..1677761b9 100644 --- a/src/components/settings/panels/AdvancedPanel.tsx +++ b/src/components/settings/panels/AdvancedPanel.tsx @@ -1,5 +1,5 @@ -import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; -import SettingsHeader from '../components/SettingsHeader'; +import { useSettingsNavigation } from "../hooks/useSettingsNavigation"; +import SettingsHeader from "../components/SettingsHeader"; const AdvancedPanel = () => { const { navigateBack } = useSettingsNavigation(); @@ -16,14 +16,32 @@ const AdvancedPanel = () => {
- - - + + +
-

Advanced Settings

+

+ Advanced Settings +

- Configure advanced features, developer options, and system-level settings. + Configure advanced features, developer options, and system-level + settings.

@@ -37,4 +55,4 @@ const AdvancedPanel = () => { ); }; -export default AdvancedPanel; \ No newline at end of file +export default AdvancedPanel; diff --git a/src/components/settings/panels/BillingPanel.tsx b/src/components/settings/panels/BillingPanel.tsx index bc23fc19a..bcd1a11b0 100644 --- a/src/components/settings/panels/BillingPanel.tsx +++ b/src/components/settings/panels/BillingPanel.tsx @@ -1,5 +1,5 @@ -import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; -import SettingsHeader from '../components/SettingsHeader'; +import { useSettingsNavigation } from "../hooks/useSettingsNavigation"; +import SettingsHeader from "../components/SettingsHeader"; const BillingPanel = () => { const { navigateBack } = useSettingsNavigation(); @@ -16,11 +16,23 @@ const BillingPanel = () => {
- - + +
-

Billing & Subscription

+

+ Billing & Subscription +

Manage your subscription, payment methods, and billing history.

@@ -36,4 +48,4 @@ const BillingPanel = () => { ); }; -export default BillingPanel; \ No newline at end of file +export default BillingPanel; diff --git a/src/components/settings/panels/ConnectionsPanel.tsx b/src/components/settings/panels/ConnectionsPanel.tsx index 769514ff1..37f26515f 100644 --- a/src/components/settings/panels/ConnectionsPanel.tsx +++ b/src/components/settings/panels/ConnectionsPanel.tsx @@ -1,15 +1,18 @@ -import { useState, useMemo } from 'react'; -import { useAppSelector } from '../../../store/hooks'; -import { selectIsAuthenticated } from '../../../store/telegramSelectors'; -import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; -import SettingsHeader from '../components/SettingsHeader'; -import TelegramConnectionModal from '../../TelegramConnectionModal'; +import { useState } from "react"; +import { useAppSelector } from "../../../store/hooks"; +import { + selectIsAuthenticated, + selectSessionString, +} from "../../../store/telegramSelectors"; +import { useSettingsNavigation } from "../hooks/useSettingsNavigation"; +import SettingsHeader from "../components/SettingsHeader"; +import TelegramConnectionModal from "../../TelegramConnectionModal"; -import BinanceIcon from '../../../assets/icons/binance.svg'; -import NotionIcon from '../../../assets/icons/notion.svg'; -import TelegramIcon from '../../../assets/icons/telegram.svg'; -import MetamaskIcon from '../../../assets/icons/metamask.svg'; -import GoogleIcon from '../../../assets/icons/GoogleIcon'; +import BinanceIcon from "../../../assets/icons/binance.svg"; +import NotionIcon from "../../../assets/icons/notion.svg"; +import TelegramIcon from "../../../assets/icons/telegram.svg"; +import MetamaskIcon from "../../../assets/icons/metamask.svg"; +import GoogleIcon from "../../../assets/icons/GoogleIcon"; interface ConnectOption { id: string; @@ -19,61 +22,48 @@ interface ConnectOption { comingSoon?: boolean; } -// Reused from ConnectStep.tsx - helper to check saved session -const hasSavedSession = (): boolean => { - try { - return !!localStorage.getItem('telegram_session'); - } catch { - return false; - } -}; - const ConnectionsPanel = () => { const { navigateBack } = useSettingsNavigation(); const [isTelegramModalOpen, setIsTelegramModalOpen] = useState(false); - // Redux state const isTelegramAuthenticated = useAppSelector(selectIsAuthenticated); - const sessionString = useAppSelector((state) => state.telegram.sessionString); + const sessionString = useAppSelector(selectSessionString); - // Check if Telegram account is connected (authenticated or has saved session) - const isTelegramConnected = useMemo(() => { - return isTelegramAuthenticated || !!sessionString || hasSavedSession(); - }, [isTelegramAuthenticated, sessionString]); + const isTelegramConnected = !!sessionString && isTelegramAuthenticated; // Connection options - reused from ConnectStep.tsx const connectOptions: ConnectOption[] = [ { - id: 'telegram', - name: 'Telegram', - description: 'Organize chats, automate messages and get insights.', + id: "telegram", + name: "Telegram", + description: "Organize chats, automate messages and get insights.", icon: Telegram, }, { - id: 'google', - name: 'Google', - description: 'Manage emails, contacts and calendar events', + id: "google", + name: "Google", + description: "Manage emails, contacts and calendar events", icon: , comingSoon: true, }, { - id: 'notion', - name: 'Notion', - description: 'Manage tasks, documents and everything else in your Notion', + id: "notion", + name: "Notion", + description: "Manage tasks, documents and everything else in your Notion", icon: Notion, comingSoon: true, }, { - id: 'wallet', - name: 'Web3 Wallet', - description: 'Trade the trenches in a safe and secure way.', + id: "wallet", + name: "Web3 Wallet", + description: "Trade the trenches in a safe and secure way.", icon: Metamask, comingSoon: true, }, { - id: 'exchange', - name: 'Crypto Trading Exchanges', - description: 'Connect and make trades with deep insights.', + id: "exchange", + name: "Crypto Trading Exchanges", + description: "Connect and make trades with deep insights.", icon: Binance, comingSoon: true, }, @@ -81,7 +71,7 @@ const ConnectionsPanel = () => { // Check if an account is connected const isAccountConnected = (accountId: string): boolean => { - if (accountId === 'telegram') { + if (accountId === "telegram") { return isTelegramConnected; } // Add other account checks here when implemented @@ -89,17 +79,17 @@ const ConnectionsPanel = () => { }; const handleConnect = (provider: string) => { - if (provider === 'telegram') { + if (provider === "telegram") { if (isTelegramConnected) { // TODO: Show disconnect confirmation - console.log('Disconnect Telegram'); + console.log("Disconnect Telegram"); } else { setIsTelegramModalOpen(true); } return; } - if (connectOptions.find(opt => opt.id === provider)?.comingSoon) { + if (connectOptions.find((opt) => opt.id === provider)?.comingSoon) { console.log(`${provider} coming soon`); return; } @@ -131,15 +121,15 @@ const ConnectionsPanel = () => { key={option.id} onClick={() => handleConnect(option.id)} disabled={option.comingSoon} - className={`w-full flex items-center justify-between p-3 bg-black/50 ${ - index === connectOptions.length - 1 ? '' : 'border-b border-stone-700' - } hover:bg-stone-800/30 transition-all duration-200 text-left ${ - index === 0 ? 'first:rounded-t-3xl' : '' - } ${ - index === connectOptions.length - 1 ? 'last:rounded-b-3xl' : '' - } focus:outline-none focus:ring-0 focus:border-inherit relative ${ - option.comingSoon ? 'opacity-60 cursor-not-allowed' : '' - }`} + className={`w-full flex items-center justify-between p-3 bg-black/50 ${index === connectOptions.length - 1 + ? "" + : "border-b border-stone-700" + } hover:bg-stone-800/30 transition-all duration-200 text-left ${index === 0 ? "first:rounded-t-3xl" : "" + } ${index === connectOptions.length - 1 + ? "last:rounded-b-3xl" + : "" + } focus:outline-none focus:ring-0 focus:border-inherit relative ${option.comingSoon ? "opacity-60 cursor-not-allowed" : "" + }`} > {/* Connection status dot - top right corner */} {isConnected && !option.comingSoon && ( @@ -153,9 +143,7 @@ const ConnectionsPanel = () => {
{option.name}
-

- {option.description} -

+

{option.description}

@@ -188,14 +176,27 @@ const ConnectionsPanel = () => { {/* Security notice */}
- - + +
-

🔒 Privacy & Security

+

+ 🔒 Privacy & Security +

- All data and credentials are stored locally with zero-data retention policy. - Your information is encrypted and never shared with third parties. + All data and credentials are stored locally with zero-data + retention policy. Your information is encrypted and never + shared with third parties.

diff --git a/src/components/settings/panels/MessagingPanel.tsx b/src/components/settings/panels/MessagingPanel.tsx index a0deefb4f..d976ef766 100644 --- a/src/components/settings/panels/MessagingPanel.tsx +++ b/src/components/settings/panels/MessagingPanel.tsx @@ -1,5 +1,5 @@ -import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; -import SettingsHeader from '../components/SettingsHeader'; +import { useSettingsNavigation } from "../hooks/useSettingsNavigation"; +import SettingsHeader from "../components/SettingsHeader"; const MessagingPanel = () => { const { navigateBack } = useSettingsNavigation(); @@ -16,13 +16,26 @@ const MessagingPanel = () => {
- - + +
-

Messaging Settings

+

+ Messaging Settings +

- Configure your messaging preferences, notifications, and communication settings. + Configure your messaging preferences, notifications, and + communication settings.

@@ -36,4 +49,4 @@ const MessagingPanel = () => { ); }; -export default MessagingPanel; \ No newline at end of file +export default MessagingPanel; diff --git a/src/components/settings/panels/PrivacyPanel.tsx b/src/components/settings/panels/PrivacyPanel.tsx index f3094608c..914eb9f0a 100644 --- a/src/components/settings/panels/PrivacyPanel.tsx +++ b/src/components/settings/panels/PrivacyPanel.tsx @@ -1,5 +1,5 @@ -import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; -import SettingsHeader from '../components/SettingsHeader'; +import { useSettingsNavigation } from "../hooks/useSettingsNavigation"; +import SettingsHeader from "../components/SettingsHeader"; const PrivacyPanel = () => { const { navigateBack } = useSettingsNavigation(); @@ -16,13 +16,26 @@ const PrivacyPanel = () => {
- - + +
-

Privacy & Security

+

+ Privacy & Security +

- Manage your privacy settings, data retention policies, and security preferences. + Manage your privacy settings, data retention policies, and + security preferences.

@@ -36,4 +49,4 @@ const PrivacyPanel = () => { ); }; -export default PrivacyPanel; \ No newline at end of file +export default PrivacyPanel; diff --git a/src/components/settings/panels/ProfilePanel.tsx b/src/components/settings/panels/ProfilePanel.tsx index 8c68c85f6..3334f5783 100644 --- a/src/components/settings/panels/ProfilePanel.tsx +++ b/src/components/settings/panels/ProfilePanel.tsx @@ -1,5 +1,5 @@ -import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; -import SettingsHeader from '../components/SettingsHeader'; +import { useSettingsNavigation } from "../hooks/useSettingsNavigation"; +import SettingsHeader from "../components/SettingsHeader"; const ProfilePanel = () => { const { navigateBack } = useSettingsNavigation(); @@ -16,11 +16,23 @@ const ProfilePanel = () => {
- - + +
-

Profile Settings

+

+ Profile Settings +

Update your profile information, avatar, and personal preferences.

@@ -36,4 +48,4 @@ const ProfilePanel = () => { ); }; -export default ProfilePanel; \ No newline at end of file +export default ProfilePanel; diff --git a/src/data/countries.ts b/src/data/countries.ts index 974d836a7..2bff46f8d 100644 --- a/src/data/countries.ts +++ b/src/data/countries.ts @@ -1,24 +1,24 @@ -import { Country } from '../types/onboarding'; +import { Country } from "../types/onboarding"; export const countries: Country[] = [ - { code: 'US', name: 'United States', flag: '🇺🇸', dialCode: '+1' }, - { code: 'CA', name: 'Canada', flag: '🇨🇦', dialCode: '+1' }, - { code: 'GB', name: 'United Kingdom', flag: '🇬🇧', dialCode: '+44' }, - { code: 'DE', name: 'Germany', flag: '🇩🇪', dialCode: '+49' }, - { code: 'FR', name: 'France', flag: '🇫🇷', dialCode: '+33' }, - { code: 'JP', name: 'Japan', flag: '🇯🇵', dialCode: '+81' }, - { code: 'KR', name: 'South Korea', flag: '🇰🇷', dialCode: '+82' }, - { code: 'CN', name: 'China', flag: '🇨🇳', dialCode: '+86' }, - { code: 'IN', name: 'India', flag: '🇮🇳', dialCode: '+91' }, - { code: 'AU', name: 'Australia', flag: '🇦🇺', dialCode: '+61' }, - { code: 'BR', name: 'Brazil', flag: '🇧🇷', dialCode: '+55' }, - { code: 'MX', name: 'Mexico', flag: '🇲🇽', dialCode: '+52' }, - { code: 'AR', name: 'Argentina', flag: '🇦🇷', dialCode: '+54' }, - { code: 'CL', name: 'Chile', flag: '🇨🇱', dialCode: '+56' }, - { code: 'SG', name: 'Singapore', flag: '🇸🇬', dialCode: '+65' }, - { code: 'HK', name: 'Hong Kong', flag: '🇭🇰', dialCode: '+852' }, - { code: 'CH', name: 'Switzerland', flag: '🇨🇭', dialCode: '+41' }, - { code: 'NL', name: 'Netherlands', flag: '🇳🇱', dialCode: '+31' }, - { code: 'SE', name: 'Sweden', flag: '🇸🇪', dialCode: '+46' }, - { code: 'NO', name: 'Norway', flag: '🇳🇴', dialCode: '+47' }, -]; \ No newline at end of file + { code: "US", name: "United States", flag: "🇺🇸", dialCode: "+1" }, + { code: "CA", name: "Canada", flag: "🇨🇦", dialCode: "+1" }, + { code: "GB", name: "United Kingdom", flag: "🇬🇧", dialCode: "+44" }, + { code: "DE", name: "Germany", flag: "🇩🇪", dialCode: "+49" }, + { code: "FR", name: "France", flag: "🇫🇷", dialCode: "+33" }, + { code: "JP", name: "Japan", flag: "🇯🇵", dialCode: "+81" }, + { code: "KR", name: "South Korea", flag: "🇰🇷", dialCode: "+82" }, + { code: "CN", name: "China", flag: "🇨🇳", dialCode: "+86" }, + { code: "IN", name: "India", flag: "🇮🇳", dialCode: "+91" }, + { code: "AU", name: "Australia", flag: "🇦🇺", dialCode: "+61" }, + { code: "BR", name: "Brazil", flag: "🇧🇷", dialCode: "+55" }, + { code: "MX", name: "Mexico", flag: "🇲🇽", dialCode: "+52" }, + { code: "AR", name: "Argentina", flag: "🇦🇷", dialCode: "+54" }, + { code: "CL", name: "Chile", flag: "🇨🇱", dialCode: "+56" }, + { code: "SG", name: "Singapore", flag: "🇸🇬", dialCode: "+65" }, + { code: "HK", name: "Hong Kong", flag: "🇭🇰", dialCode: "+852" }, + { code: "CH", name: "Switzerland", flag: "🇨🇭", dialCode: "+41" }, + { code: "NL", name: "Netherlands", flag: "🇳🇱", dialCode: "+31" }, + { code: "SE", name: "Sweden", flag: "🇸🇪", dialCode: "+46" }, + { code: "NO", name: "Norway", flag: "🇳🇴", dialCode: "+47" }, +]; diff --git a/src/hooks/useSocket.ts b/src/hooks/useSocket.ts index cafbc333f..8bec21f8d 100644 --- a/src/hooks/useSocket.ts +++ b/src/hooks/useSocket.ts @@ -1,22 +1,23 @@ -import { useEffect, useRef } from 'react'; -import { socketService } from '../services/socketService'; -import { useAppSelector } from '../store/hooks'; -import type { Socket } from 'socket.io-client'; +import { useEffect, useRef } from "react"; +import { socketService } from "../services/socketService"; +import { useAppSelector } from "../store/hooks"; +import { selectSocketStatus } from "../store/socketSelectors"; +import type { Socket } from "socket.io-client"; /** * React hook for using the Socket.IO connection * Note: The socket connection is managed by SocketProvider based on JWT token. * This hook provides access to the socket instance and methods. - * + * * @example * ```tsx * const { socket, isConnected, emit, on, off } = useSocket(); - * + * * useEffect(() => { * on('ready', () => { * console.log('Socket ready!'); * }); - * + * * return () => { * off('ready'); * }; @@ -24,8 +25,10 @@ import type { Socket } from 'socket.io-client'; * ``` */ export const useSocket = () => { - const listenersRef = useRef void }>>([]); - const socketStatus = useAppSelector((state) => state.socket.status); + const listenersRef = useRef< + Array<{ event: string; callback: (...args: unknown[]) => void }> + >([]); + const socketStatus = useAppSelector(selectSocketStatus); useEffect(() => { return () => { @@ -50,10 +53,13 @@ export const useSocket = () => { socketService.off(event, callback); if (callback) { listenersRef.current = listenersRef.current.filter( - (listener) => listener.event !== event || listener.callback !== callback + (listener) => + listener.event !== event || listener.callback !== callback, ); } else { - listenersRef.current = listenersRef.current.filter((listener) => listener.event !== event); + listenersRef.current = listenersRef.current.filter( + (listener) => listener.event !== event, + ); } }; @@ -63,7 +69,7 @@ export const useSocket = () => { return { socket: socketService.getSocket() as Socket | null, - isConnected: socketStatus === 'connected', + isConnected: socketStatus === "connected", status: socketStatus, emit, on, diff --git a/src/hooks/useUser.ts b/src/hooks/useUser.ts index 48bf54640..7c3ec7a46 100644 --- a/src/hooks/useUser.ts +++ b/src/hooks/useUser.ts @@ -1,6 +1,6 @@ -import { useEffect } from 'react'; -import { useAppSelector, useAppDispatch } from '../store/hooks'; -import { fetchCurrentUser } from '../store/userSlice'; +import { useEffect } from "react"; +import { useAppSelector, useAppDispatch } from "../store/hooks"; +import { fetchCurrentUser } from "../store/userSlice"; /** * Hook to access user data and automatically fetch it when token is available diff --git a/src/lib/mcp/errorHandler.ts b/src/lib/mcp/errorHandler.ts index 3212cb855..9f0a2c406 100644 --- a/src/lib/mcp/errorHandler.ts +++ b/src/lib/mcp/errorHandler.ts @@ -2,42 +2,43 @@ * Error handling utilities for MCP server */ -import type { MCPToolResult } from './types'; -import { ValidationError } from './validation'; +import type { MCPToolResult } from "./types"; +import { ValidationError } from "./validation"; export enum ErrorCategory { - CHAT = 'CHAT', - MSG = 'MSG', - CONTACT = 'CONTACT', - GROUP = 'GROUP', - MEDIA = 'MEDIA', - PROFILE = 'PROFILE', - AUTH = 'AUTH', - ADMIN = 'ADMIN', - VALIDATION = 'VALIDATION', - SEARCH = 'SEARCH', - DRAFT = 'DRAFT', + CHAT = "CHAT", + MSG = "MSG", + CONTACT = "CONTACT", + GROUP = "GROUP", + MEDIA = "MEDIA", + PROFILE = "PROFILE", + AUTH = "AUTH", + ADMIN = "ADMIN", + VALIDATION = "VALIDATION", + SEARCH = "SEARCH", + DRAFT = "DRAFT", } function generateErrorCode( functionName: string, category?: ErrorCategory | string, ): string { - if (category === 'VALIDATION-001' || category === ErrorCategory.VALIDATION) { - return 'VALIDATION-001'; + if (category === "VALIDATION-001" || category === ErrorCategory.VALIDATION) { + return "VALIDATION-001"; } const prefix = category - ? typeof category === 'string' && category.startsWith('VALIDATION') + ? typeof category === "string" && category.startsWith("VALIDATION") ? category : category - : 'GEN'; + : "GEN"; - const hash = Math.abs( - functionName.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0), - ) % 1000; + const hash = + Math.abs( + functionName.split("").reduce((acc, char) => acc + char.charCodeAt(0), 0), + ) % 1000; - return `${prefix}-ERR-${hash.toString().padStart(3, '0')}`; + return `${prefix}-ERR-${hash.toString().padStart(3, "0")}`; } export function logAndFormatError( @@ -50,8 +51,8 @@ export function logAndFormatError( const contextStr = context ? Object.entries(context) .map(([k, v]) => `${k}=${String(v)}`) - .join(', ') - : ''; + .join(", ") + : ""; console.error( `[MCP] Error in ${functionName} (${contextStr}) - Code: ${errorCode}`, @@ -64,20 +65,19 @@ export function logAndFormatError( : `An error occurred (code: ${errorCode}). Check logs for details.`; return { - content: [{ type: 'text', text: userMessage }], + content: [{ type: "text", text: userMessage }], isError: true, }; } -export function withErrorHandling Promise>( - fn: T, - category?: ErrorCategory, -): T { +export function withErrorHandling< + T extends (...args: unknown[]) => Promise, +>(fn: T, category?: ErrorCategory): T { return (async (...args: Parameters): Promise => { try { return await fn(...args); } catch (error) { - const functionName = fn.name || 'unknown'; + const functionName = fn.name || "unknown"; return logAndFormatError( functionName, error instanceof Error ? error : new Error(String(error)), diff --git a/src/lib/mcp/index.ts b/src/lib/mcp/index.ts index 74c97db59..750b7f66c 100644 --- a/src/lib/mcp/index.ts +++ b/src/lib/mcp/index.ts @@ -3,8 +3,8 @@ * Used by MCP servers (e.g. telegram, gmail, etc.) */ -export * from './types'; -export * from './validation'; -export * from './errorHandler'; -export * from './logger'; -export { SocketIOMCPTransportImpl } from './transport'; +export * from "./types"; +export * from "./validation"; +export * from "./errorHandler"; +export * from "./logger"; +export { SocketIOMCPTransportImpl } from "./transport"; diff --git a/src/lib/mcp/logger.ts b/src/lib/mcp/logger.ts index 41a95ca61..09492934a 100644 --- a/src/lib/mcp/logger.ts +++ b/src/lib/mcp/logger.ts @@ -2,23 +2,28 @@ * MCP logger - simple console logger with [MCP] prefix */ -type LogLevel = 'log' | 'warn' | 'error'; +type LogLevel = "log" | "warn" | "error"; -const PREFIX = '[MCP]'; +const PREFIX = "[MCP]"; function log(level: LogLevel, message: string, ...data: unknown[]): void { - const fn = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log; + const fn = + level === "error" + ? console.error + : level === "warn" + ? console.warn + : console.log; fn(PREFIX, message, ...data); } export function mcpLog(message: string, ...data: unknown[]): void { - log('log', message, ...data); + log("log", message, ...data); } export function mcpWarn(message: string, ...data: unknown[]): void { - log('warn', message, ...data); + log("warn", message, ...data); } export function mcpError(message: string, ...data: unknown[]): void { - log('error', message, ...data); + log("error", message, ...data); } diff --git a/src/lib/mcp/telegram/index.ts b/src/lib/mcp/telegram/index.ts index 0058f26be..1e3574ec8 100644 --- a/src/lib/mcp/telegram/index.ts +++ b/src/lib/mcp/telegram/index.ts @@ -3,14 +3,16 @@ * Main entry point for Telegram MCP integration */ -import type { Socket } from 'socket.io-client'; -import { TelegramMCPServer } from './server'; +import type { Socket } from "socket.io-client"; +import { TelegramMCPServer } from "./server"; let telegramMCPInstance: TelegramMCPServer | undefined; -export function initTelegramMCPServer(socket: Socket | null | undefined): TelegramMCPServer { +export function initTelegramMCPServer( + socket: Socket | null | undefined, +): TelegramMCPServer { telegramMCPInstance = new TelegramMCPServer(socket); - console.log('[MCP] Telegram MCP server initialized'); + console.log("[MCP] Telegram MCP server initialized"); return telegramMCPInstance; } @@ -18,19 +20,21 @@ export function getTelegramMCPServer(): TelegramMCPServer | undefined { return telegramMCPInstance; } -export function updateTelegramMCPServerSocket(socket: Socket | null | undefined): void { +export function updateTelegramMCPServerSocket( + socket: Socket | null | undefined, +): void { if (telegramMCPInstance) { telegramMCPInstance.updateSocket(socket); - console.log('[MCP] Telegram MCP server socket updated'); + console.log("[MCP] Telegram MCP server socket updated"); } } export function cleanupTelegramMCPServer(): void { if (telegramMCPInstance) { telegramMCPInstance = undefined; - console.log('[MCP] Telegram MCP server cleaned up'); + console.log("[MCP] Telegram MCP server cleaned up"); } } -export { toHumanReadableAction } from './toolActionParser'; -export type { TelegramMCPServer } from './server'; +export { toHumanReadableAction } from "./toolActionParser"; +export type { TelegramMCPServer } from "./server"; diff --git a/src/lib/mcp/telegram/server.ts b/src/lib/mcp/telegram/server.ts index 8b06af3e8..df14916f1 100644 --- a/src/lib/mcp/telegram/server.ts +++ b/src/lib/mcp/telegram/server.ts @@ -13,6 +13,7 @@ import type { } from "../types"; import { store } from "../../../store"; +import { selectTelegramUserState } from "../../../store/telegramSelectors"; import { ErrorCategory, logAndFormatError } from "../errorHandler"; import { ValidationError } from "../validation"; import { SocketIOMCPTransportImpl } from "../transport"; @@ -104,7 +105,8 @@ export class TelegramMCPServer { }; } - const telegramState: TelegramState = store.getState().telegram; + const telegramState: TelegramState = + selectTelegramUserState(store.getState()); try { return await toolHandler(args, { diff --git a/src/lib/mcp/telegram/telegramApi.ts b/src/lib/mcp/telegram/telegramApi.ts index 044859101..807457ed0 100644 --- a/src/lib/mcp/telegram/telegramApi.ts +++ b/src/lib/mcp/telegram/telegramApi.ts @@ -3,13 +3,22 @@ * Uses mtprotoService + Redux telegram state (alphahuman) */ -import { store } from '../../../store'; -import { mtprotoService } from '../../../services/mtprotoService'; +import { store } from "../../../store"; +import { mtprotoService } from "../../../services/mtprotoService"; import { selectOrderedChats, selectCurrentUser, -} from '../../../store/telegramSelectors'; -import type { TelegramChat, TelegramUser, TelegramMessage } from '../../../store/telegram/types'; + selectTelegramUserState, +} from "../../../store/telegramSelectors"; +import type { + TelegramChat, + TelegramUser, + TelegramMessage, +} from "../../../store/telegram/types"; + +function getTelegramState() { + return selectTelegramUserState(store.getState()); +} export interface FormattedEntity { id: string; @@ -28,10 +37,6 @@ export interface FormattedMessage { media_type?: string; } -function getTelegramState() { - return store.getState().telegram; -} - /** * Get chat by ID or username */ @@ -42,10 +47,15 @@ export function getChatById(chatId: string | number): TelegramChat | undefined { const chat = state.chats[idStr]; if (chat) return chat; - if (typeof chatId === 'string' && (chatId.startsWith('@') || /^[a-zA-Z0-9_]+$/.test(chatId))) { - const username = chatId.startsWith('@') ? chatId : `@${chatId}`; + if ( + typeof chatId === "string" && + (chatId.startsWith("@") || /^[a-zA-Z0-9_]+$/.test(chatId)) + ) { + const username = chatId.startsWith("@") ? chatId : `@${chatId}`; return Object.values(state.chats).find( - (c) => c.username && (c.username === username || c.username === username.slice(1)), + (c) => + c.username && + (c.username === username || c.username === username.slice(1)), ); } @@ -95,7 +105,7 @@ export async function sendMessage( const chat = getChatById(chatId); if (!chat) return undefined; - const entity = chat.username ? `@${chat.username.replace('@', '')}` : chat.id; + const entity = chat.username ? `@${chat.username.replace("@", "")}` : chat.id; if (replyToMessageId !== undefined) { const client = mtprotoService.getClient(); @@ -129,8 +139,8 @@ export async function searchChats(query: string): Promise { const ordered = selectOrderedChats(state); const q = query.toLowerCase(); return ordered.filter((c) => { - const title = (c.title ?? '').toLowerCase(); - const un = (c.username ?? '').toLowerCase(); + const title = (c.title ?? "").toLowerCase(); + const un = (c.username ?? "").toLowerCase(); return title.includes(q) || un.includes(q); }); } @@ -146,23 +156,31 @@ export function getCurrentUser(): TelegramUser | undefined { /** * Format entity (chat or user) for display */ -export function formatEntity(entity: TelegramChat | TelegramUser): FormattedEntity { - if ('title' in entity) { +export function formatEntity( + entity: TelegramChat | TelegramUser, +): FormattedEntity { + if ("title" in entity) { const chat = entity as TelegramChat; - const type = chat.type === 'channel' ? 'channel' : chat.type === 'supergroup' ? 'group' : chat.type; + const type = + chat.type === "channel" + ? "channel" + : chat.type === "supergroup" + ? "group" + : chat.type; return { id: chat.id, - name: chat.title ?? 'Unknown', + name: chat.title ?? "Unknown", type, username: chat.username, }; } const user = entity as TelegramUser; - const name = [user.firstName, user.lastName].filter(Boolean).join(' ') || 'Unknown'; + const name = + [user.firstName, user.lastName].filter(Boolean).join(" ") || "Unknown"; return { id: user.id, name, - type: 'user', + type: "user", username: user.username, phone: user.phoneNumber, }; @@ -175,7 +193,7 @@ export function formatMessage(message: TelegramMessage): FormattedMessage { const result: FormattedMessage = { id: message.id, date: new Date(message.date * 1000).toISOString(), - text: message.message ?? '', + text: message.message ?? "", }; if (message.fromId) result.from_id = message.fromId; if (message.media?.type) { diff --git a/src/lib/mcp/telegram/toolActionParser.ts b/src/lib/mcp/telegram/toolActionParser.ts index 5cf09c89a..e275d246e 100644 --- a/src/lib/mcp/telegram/toolActionParser.ts +++ b/src/lib/mcp/telegram/toolActionParser.ts @@ -4,8 +4,8 @@ type ToolArguments = Record; -function formatId(id: string | number | undefined, prefix = ''): string { - if (!id) return ''; +function formatId(id: string | number | undefined, prefix = ""): string { + if (!id) return ""; const str = String(id); return prefix ? `${prefix} ${str}` : str; } @@ -15,7 +15,10 @@ function truncateText(text: string, maxLength = 50): string { return `${text.substring(0, maxLength)}...`; } -export function toHumanReadableAction(toolName: string, args: ToolArguments): string { +export function toHumanReadableAction( + toolName: string, + args: ToolArguments, +): string { const parser = toolParsers[toolName]; if (parser) return parser(args); return `Executing ${toolName} with provided parameters`; @@ -23,165 +26,165 @@ export function toHumanReadableAction(toolName: string, args: ToolArguments): st const toolParsers: Record string> = { send_message: (args) => { - const chatId = formatId(args.chat_id as string | number, 'chat'); - const message = truncateText((args.message as string) || '', 100); + const chatId = formatId(args.chat_id as string | number, "chat"); + const message = truncateText((args.message as string) || "", 100); return `Send message to ${chatId}: "${message}"`; }, reply_to_message: (args) => { - const chatId = formatId(args.chat_id as string | number, 'chat'); + const chatId = formatId(args.chat_id as string | number, "chat"); const messageId = args.message_id; - const text = truncateText((args.text as string) || '', 100); + const text = truncateText((args.text as string) || "", 100); return `Reply to message ${messageId} in ${chatId}: "${text}"`; }, edit_message: (args) => { - const chatId = formatId(args.chat_id as string | number, 'chat'); + const chatId = formatId(args.chat_id as string | number, "chat"); const messageId = args.message_id; - const newText = truncateText((args.new_text as string) || '', 100); + const newText = truncateText((args.new_text as string) || "", 100); return `Edit message ${messageId} in ${chatId} to: "${newText}"`; }, delete_message: (args) => { - const chatId = formatId(args.chat_id as string | number, 'chat'); + const chatId = formatId(args.chat_id as string | number, "chat"); const messageId = args.message_id; return `Delete message ${messageId} from ${chatId}`; }, forward_message: (args) => { - const fromChat = formatId(args.from_chat_id as string | number, 'chat'); - const toChat = formatId(args.to_chat_id as string | number, 'chat'); + const fromChat = formatId(args.from_chat_id as string | number, "chat"); + const toChat = formatId(args.to_chat_id as string | number, "chat"); const messageId = args.message_id; return `Forward message ${messageId} from ${fromChat} to ${toChat}`; }, pin_message: (args) => { - const chatId = formatId(args.chat_id as string | number, 'chat'); + const chatId = formatId(args.chat_id as string | number, "chat"); const messageId = args.message_id; return `Pin message ${messageId} in ${chatId}`; }, unpin_message: (args) => { - const chatId = formatId(args.chat_id as string | number, 'chat'); + const chatId = formatId(args.chat_id as string | number, "chat"); const messageId = args.message_id; return `Unpin message ${messageId} in ${chatId}`; }, mark_as_read: (args) => { - const chatId = formatId(args.chat_id as string | number, 'chat'); + const chatId = formatId(args.chat_id as string | number, "chat"); return `Mark messages as read in ${chatId}`; }, send_reaction: (args) => { - const chatId = formatId(args.chat_id as string | number, 'chat'); + const chatId = formatId(args.chat_id as string | number, "chat"); const messageId = args.message_id; - const reaction = (args.reaction as string) || '👍'; + const reaction = (args.reaction as string) || "👍"; return `Add reaction ${reaction} to message ${messageId} in ${chatId}`; }, remove_reaction: (args) => { - const chatId = formatId(args.chat_id as string | number, 'chat'); + const chatId = formatId(args.chat_id as string | number, "chat"); const messageId = args.message_id; - const reaction = (args.reaction as string) || ''; + const reaction = (args.reaction as string) || ""; return `Remove reaction ${reaction} from message ${messageId} in ${chatId}`; }, save_draft: (args) => { - const chatId = formatId(args.chat_id as string | number, 'chat'); - const text = truncateText((args.text as string) || '', 50); + const chatId = formatId(args.chat_id as string | number, "chat"); + const text = truncateText((args.text as string) || "", 50); return `Save draft in ${chatId}: "${text}"`; }, clear_draft: (args) => { - const chatId = formatId(args.chat_id as string | number, 'chat'); + const chatId = formatId(args.chat_id as string | number, "chat"); return `Clear draft in ${chatId}`; }, list_chats: (args) => { - const chatType = (args.chat_type as string) || 'all'; + const chatType = (args.chat_type as string) || "all"; const limit = (args.limit as number) || 20; return `List ${limit} ${chatType} chats`; }, get_chat: (args) => { - const chatId = formatId(args.chat_id as string | number, 'chat'); + const chatId = formatId(args.chat_id as string | number, "chat"); return `Get information for ${chatId}`; }, create_group: (args) => { - const title = (args.title as string) || 'Untitled Group'; + const title = (args.title as string) || "Untitled Group"; const userCount = Array.isArray(args.user_ids) ? args.user_ids.length : 0; - return `Create group "${title}" with ${userCount} member${userCount !== 1 ? 's' : ''}`; + return `Create group "${title}" with ${userCount} member${userCount !== 1 ? "s" : ""}`; }, create_channel: (args) => { - const title = (args.title as string) || 'Untitled Channel'; + const title = (args.title as string) || "Untitled Channel"; const description = args.description - ? `: ${truncateText((args.description as string) || '', 50)}` - : ''; + ? `: ${truncateText((args.description as string) || "", 50)}` + : ""; return `Create channel "${title}"${description}`; }, edit_chat_title: (args) => { - const chatId = formatId(args.chat_id as string | number, 'chat'); - const newTitle = (args.new_title as string) || ''; + const chatId = formatId(args.chat_id as string | number, "chat"); + const newTitle = (args.new_title as string) || ""; return `Change title of ${chatId} to "${newTitle}"`; }, delete_chat_photo: (args) => { - const chatId = formatId(args.chat_id as string | number, 'chat'); + const chatId = formatId(args.chat_id as string | number, "chat"); return `Remove photo from ${chatId}`; }, edit_chat_photo: (args) => { - const chatId = formatId(args.chat_id as string | number, 'chat'); + const chatId = formatId(args.chat_id as string | number, "chat"); return `Update photo for ${chatId}`; }, leave_chat: (args) => { - const chatId = formatId(args.chat_id as string | number, 'chat'); + const chatId = formatId(args.chat_id as string | number, "chat"); return `Leave ${chatId}`; }, mute_chat: (args) => { - const chatId = formatId(args.chat_id as string | number, 'chat'); - const muteFor = args.mute_for ? ` for ${args.mute_for} seconds` : ''; + const chatId = formatId(args.chat_id as string | number, "chat"); + const muteFor = args.mute_for ? ` for ${args.mute_for} seconds` : ""; return `Mute ${chatId}${muteFor}`; }, unmute_chat: (args) => { - const chatId = formatId(args.chat_id as string | number, 'chat'); + const chatId = formatId(args.chat_id as string | number, "chat"); return `Unmute ${chatId}`; }, archive_chat: (args) => { - const chatId = formatId(args.chat_id as string | number, 'chat'); + const chatId = formatId(args.chat_id as string | number, "chat"); return `Archive ${chatId}`; }, unarchive_chat: (args) => { - const chatId = formatId(args.chat_id as string | number, 'chat'); + const chatId = formatId(args.chat_id as string | number, "chat"); return `Unarchive ${chatId}`; }, invite_to_group: (args) => { - const chatId = formatId(args.chat_id as string | number, 'chat'); + const chatId = formatId(args.chat_id as string | number, "chat"); const userCount = Array.isArray(args.user_ids) ? args.user_ids.length : 0; - return `Invite ${userCount} user${userCount !== 1 ? 's' : ''} to ${chatId}`; + return `Invite ${userCount} user${userCount !== 1 ? "s" : ""} to ${chatId}`; }, ban_user: (args) => { - const chatId = formatId(args.chat_id as string | number, 'chat'); - const userId = formatId(args.user_id as string | number, 'user'); + const chatId = formatId(args.chat_id as string | number, "chat"); + const userId = formatId(args.user_id as string | number, "user"); return `Ban ${userId} from ${chatId}`; }, unban_user: (args) => { - const chatId = formatId(args.chat_id as string | number, 'chat'); - const userId = formatId(args.user_id as string | number, 'user'); + const chatId = formatId(args.chat_id as string | number, "chat"); + const userId = formatId(args.user_id as string | number, "user"); return `Unban ${userId} from ${chatId}`; }, promote_admin: (args) => { - const chatId = formatId(args.chat_id as string | number, 'chat'); - const userId = formatId(args.user_id as string | number, 'user'); + const chatId = formatId(args.chat_id as string | number, "chat"); + const userId = formatId(args.user_id as string | number, "user"); return `Promote ${userId} to admin in ${chatId}`; }, demote_admin: (args) => { - const chatId = formatId(args.chat_id as string | number, 'chat'); - const userId = formatId(args.user_id as string | number, 'user'); + const chatId = formatId(args.chat_id as string | number, "chat"); + const userId = formatId(args.user_id as string | number, "user"); return `Demote ${userId} from admin in ${chatId}`; }, block_user: (args) => { - const userId = formatId(args.user_id as string | number, 'user'); + const userId = formatId(args.user_id as string | number, "user"); return `Block ${userId}`; }, unblock_user: (args) => { - const userId = formatId(args.user_id as string | number, 'user'); + const userId = formatId(args.user_id as string | number, "user"); return `Unblock ${userId}`; }, add_contact: (args) => { - const firstName = (args.first_name as string) || ''; - const lastName = (args.last_name as string) || ''; - const phone = (args.phone_number as string) || ''; - const name = [firstName, lastName].filter(Boolean).join(' ') || phone; + const firstName = (args.first_name as string) || ""; + const lastName = (args.last_name as string) || ""; + const phone = (args.phone_number as string) || ""; + const name = [firstName, lastName].filter(Boolean).join(" ") || phone; return `Add contact: ${name}`; }, delete_contact: (args) => { - const userId = formatId(args.user_id as string | number, 'user'); + const userId = formatId(args.user_id as string | number, "user"); return `Delete contact ${userId}`; }, list_contacts: (args) => { @@ -189,144 +192,159 @@ const toolParsers: Record string> = { return `List ${limit} contacts`; }, search_contacts: (args) => { - const query = truncateText((args.query as string) || '', 30); + const query = truncateText((args.query as string) || "", 30); return `Search contacts for "${query}"`; }, search_messages: (args) => { - const chatId = args.chat_id ? formatId(args.chat_id as string | number, 'chat') : 'all chats'; - const query = truncateText((args.query as string) || '', 30); + const chatId = args.chat_id + ? formatId(args.chat_id as string | number, "chat") + : "all chats"; + const query = truncateText((args.query as string) || "", 30); const limit = (args.limit as number) || 20; return `Search for "${query}" in ${chatId} (limit: ${limit})`; }, search_public_chats: (args) => { - const query = truncateText((args.query as string) || '', 30); + const query = truncateText((args.query as string) || "", 30); return `Search public chats for "${query}"`; }, resolve_username: (args) => { - const username = (args.username as string) || ''; - return `Resolve username @${username.replace('@', '')}`; + const username = (args.username as string) || ""; + return `Resolve username @${username.replace("@", "")}`; }, get_messages: (args) => { - const chatId = formatId(args.chat_id as string | number, 'chat'); + const chatId = formatId(args.chat_id as string | number, "chat"); const limit = (args.limit as number) || 20; return `Get ${limit} messages from ${chatId}`; }, list_messages: (args) => { - const chatId = formatId(args.chat_id as string | number, 'chat'); + const chatId = formatId(args.chat_id as string | number, "chat"); const limit = (args.limit as number) || 20; return `List ${limit} messages from ${chatId}`; }, get_history: (args) => { - const chatId = formatId(args.chat_id as string | number, 'chat'); + const chatId = formatId(args.chat_id as string | number, "chat"); const limit = (args.limit as number) || 20; return `Get message history from ${chatId} (${limit} messages)`; }, get_pinned_messages: (args) => { - const chatId = formatId(args.chat_id as string | number, 'chat'); + const chatId = formatId(args.chat_id as string | number, "chat"); return `Get pinned messages from ${chatId}`; }, get_message_context: (args) => { - const chatId = formatId(args.chat_id as string | number, 'chat'); + const chatId = formatId(args.chat_id as string | number, "chat"); const messageId = args.message_id; const limit = (args.limit as number) || 5; return `Get context around message ${messageId} in ${chatId} (±${limit} messages)`; }, list_topics: (args) => { - const chatId = formatId(args.chat_id as string | number, 'chat'); + const chatId = formatId(args.chat_id as string | number, "chat"); return `List topics in ${chatId}`; }, get_participants: (args) => { - const chatId = formatId(args.chat_id as string | number, 'chat'); + const chatId = formatId(args.chat_id as string | number, "chat"); const limit = (args.limit as number) || 100; return `Get ${limit} participants from ${chatId}`; }, get_admins: (args) => { - const chatId = formatId(args.chat_id as string | number, 'chat'); + const chatId = formatId(args.chat_id as string | number, "chat"); return `Get administrators of ${chatId}`; }, get_banned_users: (args) => { - const chatId = formatId(args.chat_id as string | number, 'chat'); + const chatId = formatId(args.chat_id as string | number, "chat"); return `Get banned users from ${chatId}`; }, - get_blocked_users: () => 'Get list of blocked users', + get_blocked_users: () => "Get list of blocked users", get_invite_link: (args) => { - const chatId = formatId(args.chat_id as string | number, 'chat'); + const chatId = formatId(args.chat_id as string | number, "chat"); return `Get invite link for ${chatId}`; }, export_chat_invite: (args) => { - const chatId = formatId(args.chat_id as string | number, 'chat'); + const chatId = formatId(args.chat_id as string | number, "chat"); return `Create invite link for ${chatId}`; }, import_chat_invite: (args) => { - const inviteHash = args.invite_hash ? truncateText((args.invite_hash as string) || '', 20) : ''; + const inviteHash = args.invite_hash + ? truncateText((args.invite_hash as string) || "", 20) + : ""; return `Join chat via invite link: ${inviteHash}`; }, join_chat_by_link: (args) => { - const inviteLink = args.invite_link ? truncateText((args.invite_link as string) || '', 30) : ''; + const inviteLink = args.invite_link + ? truncateText((args.invite_link as string) || "", 30) + : ""; return `Join chat via link: ${inviteLink}`; }, subscribe_public_channel: (args) => { - const username = (args.username as string) || ''; - return `Subscribe to public channel @${username.replace('@', '')}`; + const username = (args.username as string) || ""; + return `Subscribe to public channel @${username.replace("@", "")}`; }, update_profile: (args) => { const updates: string[] = []; - if (args.first_name) updates.push(`first name: "${args.first_name as string}"`); - if (args.last_name) updates.push(`last name: "${args.last_name as string}"`); - if (args.bio) updates.push(`bio: "${truncateText((args.bio as string) || '', 30)}"`); - return `Update profile (${updates.join(', ')})`; + if (args.first_name) + updates.push(`first name: "${args.first_name as string}"`); + if (args.last_name) + updates.push(`last name: "${args.last_name as string}"`); + if (args.bio) + updates.push(`bio: "${truncateText((args.bio as string) || "", 30)}"`); + return `Update profile (${updates.join(", ")})`; }, - set_profile_photo: () => 'Set profile photo', - delete_profile_photo: () => 'Delete profile photo', + set_profile_photo: () => "Set profile photo", + delete_profile_photo: () => "Delete profile photo", get_user_photos: (args) => { - const userId = formatId(args.user_id as string | number, 'user'); + const userId = formatId(args.user_id as string | number, "user"); const limit = (args.limit as number) || 20; return `Get ${limit} photos from ${userId}`; }, get_user_status: (args) => { - const userId = formatId(args.user_id as string | number, 'user'); + const userId = formatId(args.user_id as string | number, "user"); return `Get status of ${userId}`; }, - get_me: () => 'Get current user information', + get_me: () => "Get current user information", list_inline_buttons: (args) => { - const chatId = formatId(args.chat_id as string | number, 'chat'); + const chatId = formatId(args.chat_id as string | number, "chat"); const messageId = args.message_id; return `Get inline buttons from message ${messageId} in ${chatId}`; }, press_inline_button: (args) => { - const chatId = formatId(args.chat_id as string | number, 'chat'); + const chatId = formatId(args.chat_id as string | number, "chat"); const messageId = args.message_id; - const buttonText = args.button_text ? ` "${truncateText((args.button_text as string) || '', 30)}"` : ''; + const buttonText = args.button_text + ? ` "${truncateText((args.button_text as string) || "", 30)}"` + : ""; return `Press inline button${buttonText} on message ${messageId} in ${chatId}`; }, create_poll: (args) => { - const chatId = formatId(args.chat_id as string | number, 'chat'); - const question = truncateText((args.question as string) || '', 50); + const chatId = formatId(args.chat_id as string | number, "chat"); + const question = truncateText((args.question as string) || "", 50); const optionCount = Array.isArray(args.options) ? args.options.length : 0; return `Create poll in ${chatId}: "${question}" with ${optionCount} options`; }, get_bot_info: (args) => { - const chatId = formatId(args.chat_id as string | number, 'chat'); + const chatId = formatId(args.chat_id as string | number, "chat"); return `Get bot information from ${chatId}`; }, set_bot_commands: (args) => { - const chatId = args.chat_id ? formatId(args.chat_id as string | number, 'chat') : 'all chats'; - const commandCount = Array.isArray(args.commands) ? args.commands.length : 0; + const chatId = args.chat_id + ? formatId(args.chat_id as string | number, "chat") + : "all chats"; + const commandCount = Array.isArray(args.commands) + ? args.commands.length + : 0; return `Set ${commandCount} bot commands for ${chatId}`; }, - get_privacy_settings: () => 'Get privacy settings', + get_privacy_settings: () => "Get privacy settings", set_privacy_settings: (args) => { - const setting = (args.setting as string) || 'unknown'; - const value = (args.value as string) || ''; + const setting = (args.setting as string) || "unknown"; + const value = (args.value as string) || ""; return `Set privacy setting "${setting}" to ${value}`; }, get_media_info: (args) => { - const chatId = formatId(args.chat_id as string | number, 'chat'); + const chatId = formatId(args.chat_id as string | number, "chat"); const messageId = args.message_id; return `Get media information from message ${messageId} in ${chatId}`; }, get_recent_actions: (args) => { - const chatId = formatId(args.chat_id as string | number, 'chat'); + const chatId = formatId(args.chat_id as string | number, "chat"); const limit = (args.limit as number) || 20; return `Get ${limit} recent admin actions from ${chatId}`; }, diff --git a/src/lib/mcp/telegram/tools/addContact.ts b/src/lib/mcp/telegram/tools/addContact.ts index 529ac9b62..458defd36 100644 --- a/src/lib/mcp/telegram/tools/addContact.ts +++ b/src/lib/mcp/telegram/tools/addContact.ts @@ -1,22 +1,22 @@ -import type { MCPTool, MCPToolResult } from '../../types'; -import type { TelegramMCPContext } from '../types'; -import { ErrorCategory, logAndFormatError } from '../../errorHandler'; -import { mtprotoService } from '../../../../services/mtprotoService'; -import { Api } from 'telegram'; -import bigInt from 'big-integer'; -import { optString } from '../args'; +import type { MCPTool, MCPToolResult } from "../../types"; +import type { TelegramMCPContext } from "../types"; +import { ErrorCategory, logAndFormatError } from "../../errorHandler"; +import { mtprotoService } from "../../../../services/mtprotoService"; +import { Api } from "telegram"; +import bigInt from "big-integer"; +import { optString } from "../args"; export const tool: MCPTool = { - name: 'add_contact', - description: 'Add a contact to your Telegram account', + name: "add_contact", + description: "Add a contact to your Telegram account", inputSchema: { - type: 'object', + type: "object", properties: { - phone: { type: 'string', description: 'Phone number' }, - first_name: { type: 'string', description: 'First name' }, - last_name: { type: 'string', description: 'Last name' }, + phone: { type: "string", description: "Phone number" }, + first_name: { type: "string", description: "First name" }, + last_name: { type: "string", description: "Last name" }, }, - required: ['phone', 'first_name'], + required: ["phone", "first_name"], }, }; @@ -25,12 +25,21 @@ export async function addContact( _context: TelegramMCPContext, ): Promise { try { - const phone = typeof args.phone === 'string' ? args.phone : ''; - const firstName = typeof args.first_name === 'string' ? args.first_name : ''; - const lastName = optString(args, 'last_name') ?? ''; + const phone = typeof args.phone === "string" ? args.phone : ""; + const firstName = + typeof args.first_name === "string" ? args.first_name : ""; + const lastName = optString(args, "last_name") ?? ""; - if (!phone) return { content: [{ type: 'text', text: 'phone is required' }], isError: true }; - if (!firstName) return { content: [{ type: 'text', text: 'first_name is required' }], isError: true }; + if (!phone) + return { + content: [{ type: "text", text: "phone is required" }], + isError: true, + }; + if (!firstName) + return { + content: [{ type: "text", text: "first_name is required" }], + isError: true, + }; const client = mtprotoService.getClient(); @@ -49,10 +58,18 @@ export async function addContact( ); }); - return { content: [{ type: 'text', text: 'Contact ' + firstName + ' ' + lastName + ' (' + phone + ') added.' }] }; + return { + content: [ + { + type: "text", + text: + "Contact " + firstName + " " + lastName + " (" + phone + ") added.", + }, + ], + }; } catch (error) { return logAndFormatError( - 'add_contact', + "add_contact", error instanceof Error ? error : new Error(String(error)), ErrorCategory.CONTACT, ); diff --git a/src/lib/mcp/telegram/tools/blockUser.ts b/src/lib/mcp/telegram/tools/blockUser.ts index 74548ee02..b0870b3a9 100644 --- a/src/lib/mcp/telegram/tools/blockUser.ts +++ b/src/lib/mcp/telegram/tools/blockUser.ts @@ -1,20 +1,20 @@ -import type { MCPTool, MCPToolResult } from '../../types'; -import type { TelegramMCPContext } from '../types'; -import { ErrorCategory, logAndFormatError } from '../../errorHandler'; -import { validateId } from '../../validation'; -import { mtprotoService } from '../../../../services/mtprotoService'; -import { Api } from 'telegram'; -import { toInputPeer } from '../apiCastHelpers'; +import type { MCPTool, MCPToolResult } from "../../types"; +import type { TelegramMCPContext } from "../types"; +import { ErrorCategory, logAndFormatError } from "../../errorHandler"; +import { validateId } from "../../validation"; +import { mtprotoService } from "../../../../services/mtprotoService"; +import { Api } from "telegram"; +import { toInputPeer } from "../apiCastHelpers"; export const tool: MCPTool = { - name: 'block_user', - description: 'Block a user', + name: "block_user", + description: "Block a user", inputSchema: { - type: 'object', + type: "object", properties: { - user_id: { type: 'string', description: 'User ID to block' }, + user_id: { type: "string", description: "User ID to block" }, }, - required: ['user_id'], + required: ["user_id"], }, }; @@ -23,7 +23,7 @@ export async function blockUser( _context: TelegramMCPContext, ): Promise { try { - const userId = validateId(args.user_id, 'user_id'); + const userId = validateId(args.user_id, "user_id"); const client = mtprotoService.getClient(); await mtprotoService.withFloodWaitHandling(async () => { @@ -33,10 +33,12 @@ export async function blockUser( ); }); - return { content: [{ type: 'text', text: 'User ' + userId + ' blocked.' }] }; + return { + content: [{ type: "text", text: "User " + userId + " blocked." }], + }; } catch (error) { return logAndFormatError( - 'block_user', + "block_user", error instanceof Error ? error : new Error(String(error)), ErrorCategory.CONTACT, ); diff --git a/src/lib/mcp/telegram/tools/clearDraft.ts b/src/lib/mcp/telegram/tools/clearDraft.ts index 3ec573ba1..f973f49dd 100644 --- a/src/lib/mcp/telegram/tools/clearDraft.ts +++ b/src/lib/mcp/telegram/tools/clearDraft.ts @@ -1,10 +1,10 @@ import type { MCPTool, MCPToolResult } from "../../types"; import type { TelegramMCPContext } from "../types"; -import { ErrorCategory, logAndFormatError } from '../../errorHandler'; -import { validateId } from '../../validation'; -import { getChatById } from '../telegramApi'; -import { mtprotoService } from '../../../../services/mtprotoService'; -import { Api } from 'telegram'; +import { ErrorCategory, logAndFormatError } from "../../errorHandler"; +import { validateId } from "../../validation"; +import { getChatById } from "../telegramApi"; +import { mtprotoService } from "../../../../services/mtprotoService"; +import { Api } from "telegram"; export const tool: MCPTool = { name: "clear_draft", @@ -23,10 +23,14 @@ export async function clearDraft( _context: TelegramMCPContext, ): Promise { try { - const chatId = validateId(args.chat_id, 'chat_id'); + const chatId = validateId(args.chat_id, "chat_id"); const chat = getChatById(chatId); - if (!chat) return { content: [{ type: 'text', text: 'Chat not found: ' + chatId }], isError: true }; + if (!chat) + return { + content: [{ type: "text", text: "Chat not found: " + chatId }], + isError: true, + }; const client = mtprotoService.getClient(); const entity = chat.username ? chat.username : chat.id; @@ -36,15 +40,19 @@ export async function clearDraft( await client.invoke( new Api.messages.SaveDraft({ peer: inputPeer, - message: '', + message: "", }), ); }); - return { content: [{ type: 'text', text: 'Draft cleared in chat ' + chatId + '.' }] }; + return { + content: [ + { type: "text", text: "Draft cleared in chat " + chatId + "." }, + ], + }; } catch (error) { return logAndFormatError( - 'clear_draft', + "clear_draft", error instanceof Error ? error : new Error(String(error)), ErrorCategory.DRAFT, ); diff --git a/src/lib/mcp/telegram/tools/createChannel.ts b/src/lib/mcp/telegram/tools/createChannel.ts index 4b3a42f07..35382892b 100644 --- a/src/lib/mcp/telegram/tools/createChannel.ts +++ b/src/lib/mcp/telegram/tools/createChannel.ts @@ -52,7 +52,8 @@ export async function createChannel( ); }); - const channelId = narrow(result)?.chats?.[0]?.id ?? "unknown"; + const channelId = + narrow(result)?.chats?.[0]?.id ?? "unknown"; const type = megagroup ? "Supergroup" : "Channel"; return { content: [ diff --git a/src/lib/mcp/telegram/tools/createPoll.ts b/src/lib/mcp/telegram/tools/createPoll.ts index 0077922f5..9a95596cc 100644 --- a/src/lib/mcp/telegram/tools/createPoll.ts +++ b/src/lib/mcp/telegram/tools/createPoll.ts @@ -1,11 +1,11 @@ import type { MCPTool, MCPToolResult } from "../../types"; import type { TelegramMCPContext } from "../types"; -import { ErrorCategory, logAndFormatError } from '../../errorHandler'; -import { validateId } from '../../validation'; -import { getChatById } from '../telegramApi'; -import { mtprotoService } from '../../../../services/mtprotoService'; -import { Api } from 'telegram'; -import bigInt from 'big-integer'; +import { ErrorCategory, logAndFormatError } from "../../errorHandler"; +import { validateId } from "../../validation"; +import { getChatById } from "../telegramApi"; +import { mtprotoService } from "../../../../services/mtprotoService"; +import { Api } from "telegram"; +import bigInt from "big-integer"; export const tool: MCPTool = { name: "create_poll", @@ -26,15 +26,27 @@ export async function createPoll( _context: TelegramMCPContext, ): Promise { try { - const chatId = validateId(args.chat_id, 'chat_id'); - const question = typeof args.question === 'string' ? args.question : ''; + const chatId = validateId(args.chat_id, "chat_id"); + const question = typeof args.question === "string" ? args.question : ""; const options = Array.isArray(args.options) ? args.options.map(String) : []; - if (!question) return { content: [{ type: 'text', text: 'question is required' }], isError: true }; - if (options.length < 2) return { content: [{ type: 'text', text: 'At least 2 options are required' }], isError: true }; + if (!question) + return { + content: [{ type: "text", text: "question is required" }], + isError: true, + }; + if (options.length < 2) + return { + content: [{ type: "text", text: "At least 2 options are required" }], + isError: true, + }; const chat = getChatById(chatId); - if (!chat) return { content: [{ type: 'text', text: 'Chat not found: ' + chatId }], isError: true }; + if (!chat) + return { + content: [{ type: "text", text: "Chat not found: " + chatId }], + isError: true, + }; const client = mtprotoService.getClient(); const entity = chat.username ? chat.username : chat.id; @@ -47,25 +59,29 @@ export async function createPoll( media: new Api.InputMediaPoll({ poll: new Api.Poll({ id: bigInt(0), - question: new Api.TextWithEntities({ text: question, entities: [] }), - answers: options.map((opt, i) => - new Api.PollAnswer({ - text: new Api.TextWithEntities({ text: opt, entities: [] }), - option: Buffer.from([i]), - }), + question: new Api.TextWithEntities({ + text: question, + entities: [], + }), + answers: options.map( + (opt, i) => + new Api.PollAnswer({ + text: new Api.TextWithEntities({ text: opt, entities: [] }), + option: Buffer.from([i]), + }), ), }), }), - message: '', + message: "", randomId: bigInt(Math.floor(Math.random() * Number.MAX_SAFE_INTEGER)), }), ); }); - return { content: [{ type: 'text', text: 'Poll created: ' + question }] }; + return { content: [{ type: "text", text: "Poll created: " + question }] }; } catch (error) { return logAndFormatError( - 'create_poll', + "create_poll", error instanceof Error ? error : new Error(String(error)), ErrorCategory.MSG, ); diff --git a/src/lib/mcp/telegram/tools/deleteContact.ts b/src/lib/mcp/telegram/tools/deleteContact.ts index 3f1bcfa54..48eb92bba 100644 --- a/src/lib/mcp/telegram/tools/deleteContact.ts +++ b/src/lib/mcp/telegram/tools/deleteContact.ts @@ -1,20 +1,23 @@ -import type { MCPTool, MCPToolResult } from '../../types'; -import type { TelegramMCPContext } from '../types'; -import { ErrorCategory, logAndFormatError } from '../../errorHandler'; -import { validateId } from '../../validation'; -import { mtprotoService } from '../../../../services/mtprotoService'; -import { Api } from 'telegram'; -import { toInputUser } from '../apiCastHelpers'; +import type { MCPTool, MCPToolResult } from "../../types"; +import type { TelegramMCPContext } from "../types"; +import { ErrorCategory, logAndFormatError } from "../../errorHandler"; +import { validateId } from "../../validation"; +import { mtprotoService } from "../../../../services/mtprotoService"; +import { Api } from "telegram"; +import { toInputUser } from "../apiCastHelpers"; export const tool: MCPTool = { - name: 'delete_contact', - description: 'Delete a contact from your Telegram account', + name: "delete_contact", + description: "Delete a contact from your Telegram account", inputSchema: { - type: 'object', + type: "object", properties: { - user_id: { type: 'string', description: 'User ID to remove from contacts' }, + user_id: { + type: "string", + description: "User ID to remove from contacts", + }, }, - required: ['user_id'], + required: ["user_id"], }, }; @@ -23,7 +26,7 @@ export async function deleteContact( _context: TelegramMCPContext, ): Promise { try { - const userId = validateId(args.user_id, 'user_id'); + const userId = validateId(args.user_id, "user_id"); const client = mtprotoService.getClient(); await mtprotoService.withFloodWaitHandling(async () => { @@ -35,10 +38,12 @@ export async function deleteContact( ); }); - return { content: [{ type: 'text', text: 'Contact ' + userId + ' deleted.' }] }; + return { + content: [{ type: "text", text: "Contact " + userId + " deleted." }], + }; } catch (error) { return logAndFormatError( - 'delete_contact', + "delete_contact", error instanceof Error ? error : new Error(String(error)), ErrorCategory.CONTACT, ); diff --git a/src/lib/mcp/telegram/tools/deleteMessage.ts b/src/lib/mcp/telegram/tools/deleteMessage.ts index 743388586..073fc9695 100644 --- a/src/lib/mcp/telegram/tools/deleteMessage.ts +++ b/src/lib/mcp/telegram/tools/deleteMessage.ts @@ -1,20 +1,20 @@ -import type { MCPTool, MCPToolResult } from '../../types'; -import type { TelegramMCPContext } from '../types'; -import { ErrorCategory, logAndFormatError } from '../../errorHandler'; -import { getChatById } from '../telegramApi'; -import { validateId } from '../../validation'; -import { mtprotoService } from '../../../../services/mtprotoService'; +import type { MCPTool, MCPToolResult } from "../../types"; +import type { TelegramMCPContext } from "../types"; +import { ErrorCategory, logAndFormatError } from "../../errorHandler"; +import { getChatById } from "../telegramApi"; +import { validateId } from "../../validation"; +import { mtprotoService } from "../../../../services/mtprotoService"; export const tool: MCPTool = { - name: 'delete_message', - description: 'Delete a message', + name: "delete_message", + description: "Delete a message", inputSchema: { - type: 'object', + type: "object", properties: { - chat_id: { type: 'string', description: 'Chat ID or username' }, - message_id: { type: 'number', description: 'Message ID' }, + chat_id: { type: "string", description: "Chat ID or username" }, + message_id: { type: "number", description: "Message ID" }, }, - required: ['chat_id', 'message_id'], + required: ["chat_id", "message_id"], }, }; @@ -23,21 +23,27 @@ export async function deleteMessage( _context: TelegramMCPContext, ): Promise { try { - const chatId = validateId(args.chat_id, 'chat_id'); - const messageId = typeof args.message_id === 'number' && Number.isInteger(args.message_id) - ? args.message_id - : undefined; + const chatId = validateId(args.chat_id, "chat_id"); + const messageId = + typeof args.message_id === "number" && Number.isInteger(args.message_id) + ? args.message_id + : undefined; if (messageId === undefined) { return { - content: [{ type: 'text', text: 'message_id must be a positive integer' }], + content: [ + { type: "text", text: "message_id must be a positive integer" }, + ], isError: true, }; } const chat = getChatById(chatId); if (!chat) { - return { content: [{ type: 'text', text: `Chat not found: ${chatId}` }], isError: true }; + return { + content: [{ type: "text", text: `Chat not found: ${chatId}` }], + isError: true, + }; } const entity = chat.username ? chat.username : chat.id; @@ -48,11 +54,13 @@ export async function deleteMessage( }); return { - content: [{ type: 'text', text: `Message ${messageId} deleted successfully.` }], + content: [ + { type: "text", text: `Message ${messageId} deleted successfully.` }, + ], }; } catch (error) { return logAndFormatError( - 'delete_message', + "delete_message", error instanceof Error ? error : new Error(String(error)), ErrorCategory.MSG, ); diff --git a/src/lib/mcp/telegram/tools/deleteProfilePhoto.ts b/src/lib/mcp/telegram/tools/deleteProfilePhoto.ts index f2793bee4..1f9f5e684 100644 --- a/src/lib/mcp/telegram/tools/deleteProfilePhoto.ts +++ b/src/lib/mcp/telegram/tools/deleteProfilePhoto.ts @@ -1,11 +1,11 @@ import type { MCPTool, MCPToolResult } from "../../types"; import type { TelegramMCPContext } from "../types"; -import { ErrorCategory, logAndFormatError } from '../../errorHandler'; -import { mtprotoService } from '../../../../services/mtprotoService'; -import { Api } from 'telegram'; -import bigInt from 'big-integer'; -import type { ApiPhoto } from '../apiResultTypes'; -import { narrow } from '../apiCastHelpers'; +import { ErrorCategory, logAndFormatError } from "../../errorHandler"; +import { mtprotoService } from "../../../../services/mtprotoService"; +import { Api } from "telegram"; +import bigInt from "big-integer"; +import type { ApiPhoto } from "../apiResultTypes"; +import { narrow } from "../apiCastHelpers"; export const tool: MCPTool = { name: "delete_profile_photo", @@ -31,8 +31,15 @@ export async function deleteProfilePhoto( ); }); - if (!photos || !('photos' in photos) || !Array.isArray(photos.photos) || photos.photos.length === 0) { - return { content: [{ type: 'text', text: 'No profile photo to delete.' }] }; + if ( + !photos || + !("photos" in photos) || + !Array.isArray(photos.photos) || + photos.photos.length === 0 + ) { + return { + content: [{ type: "text", text: "No profile photo to delete." }], + }; } const photo = narrow(photos.photos[0]); @@ -40,15 +47,21 @@ export async function deleteProfilePhoto( await mtprotoService.withFloodWaitHandling(async () => { await client.invoke( new Api.photos.DeletePhotos({ - id: [new Api.InputPhoto({ id: photo.id, accessHash: photo.accessHash, fileReference: photo.fileReference })], + id: [ + new Api.InputPhoto({ + id: photo.id, + accessHash: photo.accessHash, + fileReference: photo.fileReference, + }), + ], }), ); }); - return { content: [{ type: 'text', text: 'Profile photo deleted.' }] }; + return { content: [{ type: "text", text: "Profile photo deleted." }] }; } catch (error) { return logAndFormatError( - 'delete_profile_photo', + "delete_profile_photo", error instanceof Error ? error : new Error(String(error)), ErrorCategory.PROFILE, ); diff --git a/src/lib/mcp/telegram/tools/editChatPhoto.ts b/src/lib/mcp/telegram/tools/editChatPhoto.ts index acce6aaa5..241f26082 100644 --- a/src/lib/mcp/telegram/tools/editChatPhoto.ts +++ b/src/lib/mcp/telegram/tools/editChatPhoto.ts @@ -8,9 +8,9 @@ export const tool: MCPTool = { type: "object", properties: { chat_id: { type: "string", description: "Chat ID or username" }, - file_path: { type: 'string', description: 'Path to the photo file' }, + file_path: { type: "string", description: "Path to the photo file" }, }, - required: ["chat_id", 'file_path'], + required: ["chat_id", "file_path"], }, }; @@ -19,7 +19,12 @@ export async function editChatPhoto( _context: TelegramMCPContext, ): Promise { return { - content: [{ type: 'text', text: 'edit_chat_photo requires file upload which is not supported via MCP text interface. Use the Telegram client directly.' }], + content: [ + { + type: "text", + text: "edit_chat_photo requires file upload which is not supported via MCP text interface. Use the Telegram client directly.", + }, + ], isError: true, }; } diff --git a/src/lib/mcp/telegram/tools/editMessage.ts b/src/lib/mcp/telegram/tools/editMessage.ts index 9fde09b8a..d37096566 100644 --- a/src/lib/mcp/telegram/tools/editMessage.ts +++ b/src/lib/mcp/telegram/tools/editMessage.ts @@ -1,21 +1,21 @@ -import type { MCPTool, MCPToolResult } from '../../types'; -import type { TelegramMCPContext } from '../types'; -import { ErrorCategory, logAndFormatError } from '../../errorHandler'; -import { getChatById } from '../telegramApi'; -import { validateId } from '../../validation'; -import { mtprotoService } from '../../../../services/mtprotoService'; +import type { MCPTool, MCPToolResult } from "../../types"; +import type { TelegramMCPContext } from "../types"; +import { ErrorCategory, logAndFormatError } from "../../errorHandler"; +import { getChatById } from "../telegramApi"; +import { validateId } from "../../validation"; +import { mtprotoService } from "../../../../services/mtprotoService"; export const tool: MCPTool = { - name: 'edit_message', - description: 'Edit an existing message', + name: "edit_message", + description: "Edit an existing message", inputSchema: { - type: 'object', + type: "object", properties: { - chat_id: { type: 'string', description: 'Chat ID or username' }, - message_id: { type: 'number', description: 'Message ID' }, - new_text: { type: 'string', description: 'New message text' }, + chat_id: { type: "string", description: "Chat ID or username" }, + message_id: { type: "number", description: "Message ID" }, + new_text: { type: "string", description: "New message text" }, }, - required: ['chat_id', 'message_id', 'new_text'], + required: ["chat_id", "message_id", "new_text"], }, }; @@ -24,28 +24,34 @@ export async function editMessage( _context: TelegramMCPContext, ): Promise { try { - const chatId = validateId(args.chat_id, 'chat_id'); - const messageId = typeof args.message_id === 'number' && Number.isInteger(args.message_id) - ? args.message_id - : undefined; - const newText = typeof args.new_text === 'string' ? args.new_text : ''; + const chatId = validateId(args.chat_id, "chat_id"); + const messageId = + typeof args.message_id === "number" && Number.isInteger(args.message_id) + ? args.message_id + : undefined; + const newText = typeof args.new_text === "string" ? args.new_text : ""; if (messageId === undefined) { return { - content: [{ type: 'text', text: 'message_id must be a positive integer' }], + content: [ + { type: "text", text: "message_id must be a positive integer" }, + ], isError: true, }; } if (!newText) { return { - content: [{ type: 'text', text: 'new_text is required' }], + content: [{ type: "text", text: "new_text is required" }], isError: true, }; } const chat = getChatById(chatId); if (!chat) { - return { content: [{ type: 'text', text: `Chat not found: ${chatId}` }], isError: true }; + return { + content: [{ type: "text", text: `Chat not found: ${chatId}` }], + isError: true, + }; } const entity = chat.username ? chat.username : chat.id; @@ -56,11 +62,13 @@ export async function editMessage( }); return { - content: [{ type: 'text', text: `Message ${messageId} edited successfully.` }], + content: [ + { type: "text", text: `Message ${messageId} edited successfully.` }, + ], }; } catch (error) { return logAndFormatError( - 'edit_message', + "edit_message", error instanceof Error ? error : new Error(String(error)), ErrorCategory.MSG, ); diff --git a/src/lib/mcp/telegram/tools/exportChatInvite.ts b/src/lib/mcp/telegram/tools/exportChatInvite.ts index 6366fb4a5..6164f862c 100644 --- a/src/lib/mcp/telegram/tools/exportChatInvite.ts +++ b/src/lib/mcp/telegram/tools/exportChatInvite.ts @@ -1,26 +1,26 @@ -import type { MCPTool, MCPToolResult } from '../../types'; -import type { TelegramMCPContext } from '../types'; -import { ErrorCategory, logAndFormatError } from '../../errorHandler'; -import { validateId } from '../../validation'; -import { getChatById } from '../telegramApi'; -import { mtprotoService } from '../../../../services/mtprotoService'; -import { Api } from 'telegram'; -import { optString } from '../args'; -import type { ChatInviteResult } from '../apiResultTypes'; -import { narrow } from '../apiCastHelpers'; +import type { MCPTool, MCPToolResult } from "../../types"; +import type { TelegramMCPContext } from "../types"; +import { ErrorCategory, logAndFormatError } from "../../errorHandler"; +import { validateId } from "../../validation"; +import { getChatById } from "../telegramApi"; +import { mtprotoService } from "../../../../services/mtprotoService"; +import { Api } from "telegram"; +import { optString } from "../args"; +import type { ChatInviteResult } from "../apiResultTypes"; +import { narrow } from "../apiCastHelpers"; export const tool: MCPTool = { - name: 'export_chat_invite', - description: 'Export a new chat invite link', + name: "export_chat_invite", + description: "Export a new chat invite link", inputSchema: { - type: 'object', + type: "object", properties: { - chat_id: { type: 'string', description: 'Chat ID or username' }, - title: { type: 'string', description: 'Link title' }, - expire_date: { type: 'number', description: 'Expiration timestamp' }, - usage_limit: { type: 'number', description: 'Max number of uses' }, + chat_id: { type: "string", description: "Chat ID or username" }, + title: { type: "string", description: "Link title" }, + expire_date: { type: "number", description: "Expiration timestamp" }, + usage_limit: { type: "number", description: "Max number of uses" }, }, - required: ['chat_id'], + required: ["chat_id"], }, }; @@ -29,13 +29,19 @@ export async function exportChatInvite( _context: TelegramMCPContext, ): Promise { try { - const chatId = validateId(args.chat_id, 'chat_id'); - const title = optString(args, 'title'); - const expireDate = typeof args.expire_date === 'number' ? args.expire_date : undefined; - const usageLimit = typeof args.usage_limit === 'number' ? args.usage_limit : undefined; + const chatId = validateId(args.chat_id, "chat_id"); + const title = optString(args, "title"); + const expireDate = + typeof args.expire_date === "number" ? args.expire_date : undefined; + const usageLimit = + typeof args.usage_limit === "number" ? args.usage_limit : undefined; const chat = getChatById(chatId); - if (!chat) return { content: [{ type: 'text', text: `Chat not found: ${chatId}` }], isError: true }; + if (!chat) + return { + content: [{ type: "text", text: `Chat not found: ${chatId}` }], + isError: true, + }; const client = mtprotoService.getClient(); const entity = chat.username ? chat.username : chat.id; @@ -54,13 +60,18 @@ export async function exportChatInvite( const link = narrow(result)?.link; if (!link) { - return { content: [{ type: 'text', text: 'Could not create invite link.' }], isError: true }; + return { + content: [{ type: "text", text: "Could not create invite link." }], + isError: true, + }; } - return { content: [{ type: 'text', text: `Invite link created: ${link}` }] }; + return { + content: [{ type: "text", text: `Invite link created: ${link}` }], + }; } catch (error) { return logAndFormatError( - 'export_chat_invite', + "export_chat_invite", error instanceof Error ? error : new Error(String(error)), ErrorCategory.GROUP, ); diff --git a/src/lib/mcp/telegram/tools/forwardMessage.ts b/src/lib/mcp/telegram/tools/forwardMessage.ts index af8149e2f..7c8e43f88 100644 --- a/src/lib/mcp/telegram/tools/forwardMessage.ts +++ b/src/lib/mcp/telegram/tools/forwardMessage.ts @@ -1,21 +1,21 @@ -import type { MCPTool, MCPToolResult } from '../../types'; -import type { TelegramMCPContext } from '../types'; -import { ErrorCategory, logAndFormatError } from '../../errorHandler'; -import { getChatById } from '../telegramApi'; -import { validateId } from '../../validation'; -import { mtprotoService } from '../../../../services/mtprotoService'; +import type { MCPTool, MCPToolResult } from "../../types"; +import type { TelegramMCPContext } from "../types"; +import { ErrorCategory, logAndFormatError } from "../../errorHandler"; +import { getChatById } from "../telegramApi"; +import { validateId } from "../../validation"; +import { mtprotoService } from "../../../../services/mtprotoService"; export const tool: MCPTool = { - name: 'forward_message', - description: 'Forward a message to another chat', + name: "forward_message", + description: "Forward a message to another chat", inputSchema: { - type: 'object', + type: "object", properties: { - from_chat_id: { type: 'string', description: 'Source chat ID' }, - to_chat_id: { type: 'string', description: 'Target chat ID' }, - message_id: { type: 'number', description: 'Message ID' }, + from_chat_id: { type: "string", description: "Source chat ID" }, + to_chat_id: { type: "string", description: "Target chat ID" }, + message_id: { type: "number", description: "Message ID" }, }, - required: ['from_chat_id', 'to_chat_id', 'message_id'], + required: ["from_chat_id", "to_chat_id", "message_id"], }, }; @@ -24,27 +24,38 @@ export async function forwardMessage( _context: TelegramMCPContext, ): Promise { try { - const fromChatId = validateId(args.from_chat_id, 'from_chat_id'); - const toChatId = validateId(args.to_chat_id, 'to_chat_id'); - const messageId = typeof args.message_id === 'number' && Number.isInteger(args.message_id) - ? args.message_id - : undefined; + const fromChatId = validateId(args.from_chat_id, "from_chat_id"); + const toChatId = validateId(args.to_chat_id, "to_chat_id"); + const messageId = + typeof args.message_id === "number" && Number.isInteger(args.message_id) + ? args.message_id + : undefined; if (messageId === undefined) { return { - content: [{ type: 'text', text: 'message_id must be a positive integer' }], + content: [ + { type: "text", text: "message_id must be a positive integer" }, + ], isError: true, }; } const fromChat = getChatById(fromChatId); if (!fromChat) { - return { content: [{ type: 'text', text: `Source chat not found: ${fromChatId}` }], isError: true }; + return { + content: [ + { type: "text", text: `Source chat not found: ${fromChatId}` }, + ], + isError: true, + }; } const toChat = getChatById(toChatId); if (!toChat) { - return { content: [{ type: 'text', text: `Target chat not found: ${toChatId}` }], isError: true }; + return { + content: [{ type: "text", text: `Target chat not found: ${toChatId}` }], + isError: true, + }; } const fromEntity = fromChat.username ? fromChat.username : fromChat.id; @@ -59,11 +70,16 @@ export async function forwardMessage( }); return { - content: [{ type: 'text', text: `Message ${messageId} forwarded from ${fromChatId} to ${toChatId}.` }], + content: [ + { + type: "text", + text: `Message ${messageId} forwarded from ${fromChatId} to ${toChatId}.`, + }, + ], }; } catch (error) { return logAndFormatError( - 'forward_message', + "forward_message", error instanceof Error ? error : new Error(String(error)), ErrorCategory.MSG, ); diff --git a/src/lib/mcp/telegram/tools/getBlockedUsers.ts b/src/lib/mcp/telegram/tools/getBlockedUsers.ts index bdb09121a..16ba3f9a4 100644 --- a/src/lib/mcp/telegram/tools/getBlockedUsers.ts +++ b/src/lib/mcp/telegram/tools/getBlockedUsers.ts @@ -1,18 +1,18 @@ -import type { MCPTool, MCPToolResult } from '../../types'; -import type { TelegramMCPContext } from '../types'; -import { ErrorCategory, logAndFormatError } from '../../errorHandler'; -import { mtprotoService } from '../../../../services/mtprotoService'; -import { Api } from 'telegram'; -import { optNumber } from '../args'; -import type { ApiUser } from '../apiResultTypes'; +import type { MCPTool, MCPToolResult } from "../../types"; +import type { TelegramMCPContext } from "../types"; +import { ErrorCategory, logAndFormatError } from "../../errorHandler"; +import { mtprotoService } from "../../../../services/mtprotoService"; +import { Api } from "telegram"; +import { optNumber } from "../args"; +import type { ApiUser } from "../apiResultTypes"; export const tool: MCPTool = { - name: 'get_blocked_users', - description: 'Get list of blocked users', + name: "get_blocked_users", + description: "Get list of blocked users", inputSchema: { - type: 'object', + type: "object", properties: { - limit: { type: 'number', description: 'Max results', default: 50 }, + limit: { type: "number", description: "Max results", default: 50 }, }, }, }; @@ -22,29 +22,33 @@ export async function getBlockedUsers( _context: TelegramMCPContext, ): Promise { try { - const limit = optNumber(args, 'limit', 50); + const limit = optNumber(args, "limit", 50); const client = mtprotoService.getClient(); const result = await mtprotoService.withFloodWaitHandling(async () => { - return client.invoke( - new Api.contacts.GetBlocked({ offset: 0, limit }), - ); + return client.invoke(new Api.contacts.GetBlocked({ offset: 0, limit })); }); - if (!result || !('users' in result) || !Array.isArray(result.users) || result.users.length === 0) { - return { content: [{ type: 'text', text: 'No blocked users.' }] }; + if ( + !result || + !("users" in result) || + !Array.isArray(result.users) || + result.users.length === 0 + ) { + return { content: [{ type: "text", text: "No blocked users." }] }; } const lines = result.users.map((u: ApiUser) => { - const name = [u.firstName, u.lastName].filter(Boolean).join(' ') || 'Unknown'; - const username = u.username ? `@${u.username}` : ''; + const name = + [u.firstName, u.lastName].filter(Boolean).join(" ") || "Unknown"; + const username = u.username ? `@${u.username}` : ""; return `ID: ${u.id} | ${name} ${username}`.trim(); }); - return { content: [{ type: 'text', text: lines.join('\n') }] }; + return { content: [{ type: "text", text: lines.join("\n") }] }; } catch (error) { return logAndFormatError( - 'get_blocked_users', + "get_blocked_users", error instanceof Error ? error : new Error(String(error)), ErrorCategory.CONTACT, ); diff --git a/src/lib/mcp/telegram/tools/getBotInfo.ts b/src/lib/mcp/telegram/tools/getBotInfo.ts index d0e41183d..56f96b8e5 100644 --- a/src/lib/mcp/telegram/tools/getBotInfo.ts +++ b/src/lib/mcp/telegram/tools/getBotInfo.ts @@ -1,10 +1,10 @@ import type { MCPTool, MCPToolResult } from "../../types"; import type { TelegramMCPContext } from "../types"; -import { ErrorCategory, logAndFormatError } from '../../errorHandler'; -import { validateId } from '../../validation'; -import { mtprotoService } from '../../../../services/mtprotoService'; -import { Api } from 'telegram'; -import type { FullUserResult } from '../apiResultTypes'; +import { ErrorCategory, logAndFormatError } from "../../errorHandler"; +import { validateId } from "../../validation"; +import { mtprotoService } from "../../../../services/mtprotoService"; +import { Api } from "telegram"; +import type { FullUserResult } from "../apiResultTypes"; import { toInputUser, narrow } from "../apiCastHelpers"; export const tool: MCPTool = { @@ -24,7 +24,7 @@ export async function getBotInfo( _context: TelegramMCPContext, ): Promise { try { - const botId = validateId(args.chat_id, 'chat_id'); + const botId = validateId(args.chat_id, "chat_id"); const client = mtprotoService.getClient(); const result = await mtprotoService.withFloodWaitHandling(async () => { @@ -38,30 +38,34 @@ export async function getBotInfo( const user = narrow(result)?.users?.[0]; if (!user) { - return { content: [{ type: 'text', text: 'Bot not found: ' + botId }], isError: true }; + return { + content: [{ type: "text", text: "Bot not found: " + botId }], + isError: true, + }; } - const name = [user.firstName, user.lastName].filter(Boolean).join(' ') || 'Unknown'; + const name = + [user.firstName, user.lastName].filter(Boolean).join(" ") || "Unknown"; const lines = [ - 'Name: ' + name, - 'Username: @' + (user.username ?? 'N/A'), - 'ID: ' + user.id, - 'Bot: ' + (user.bot ? 'Yes' : 'No'), - 'About: ' + (fullUser?.about ?? 'N/A'), - 'Bot Info Description: ' + (fullUser?.botInfo?.description ?? 'N/A'), + "Name: " + name, + "Username: @" + (user.username ?? "N/A"), + "ID: " + user.id, + "Bot: " + (user.bot ? "Yes" : "No"), + "About: " + (fullUser?.about ?? "N/A"), + "Bot Info Description: " + (fullUser?.botInfo?.description ?? "N/A"), ]; if (fullUser?.botInfo?.commands) { - lines.push('Commands:'); + lines.push("Commands:"); for (const cmd of fullUser.botInfo.commands) { - lines.push(' /' + cmd.command + ' - ' + cmd.description); + lines.push(" /" + cmd.command + " - " + cmd.description); } } - return { content: [{ type: 'text', text: lines.join('\n') }] }; + return { content: [{ type: "text", text: lines.join("\n") }] }; } catch (error) { return logAndFormatError( - 'get_bot_info', + "get_bot_info", error instanceof Error ? error : new Error(String(error)), ErrorCategory.CONTACT, ); diff --git a/src/lib/mcp/telegram/tools/getChat.ts b/src/lib/mcp/telegram/tools/getChat.ts index 02f217e00..f48d63aa6 100644 --- a/src/lib/mcp/telegram/tools/getChat.ts +++ b/src/lib/mcp/telegram/tools/getChat.ts @@ -2,25 +2,25 @@ * Get Chat tool - Get detailed information about a specific chat */ -import type { MCPTool, MCPToolResult } from '../../types'; -import type { TelegramMCPContext } from '../types'; +import type { MCPTool, MCPToolResult } from "../../types"; +import type { TelegramMCPContext } from "../types"; -import { ErrorCategory, logAndFormatError } from '../../errorHandler'; -import { formatEntity, getChatById } from '../telegramApi'; -import { validateId } from '../../validation'; +import { ErrorCategory, logAndFormatError } from "../../errorHandler"; +import { formatEntity, getChatById } from "../telegramApi"; +import { validateId } from "../../validation"; export const tool: MCPTool = { - name: 'get_chat', - description: 'Get detailed information about a specific chat', + name: "get_chat", + description: "Get detailed information about a specific chat", inputSchema: { - type: 'object', + type: "object", properties: { chat_id: { - type: 'string', - description: 'The ID or username of the chat', + type: "string", + description: "The ID or username of the chat", }, }, - required: ['chat_id'], + required: ["chat_id"], }, }; @@ -29,12 +29,12 @@ export async function getChat( _context: TelegramMCPContext, ): Promise { try { - const chatId = validateId(args.chat_id, 'chat_id'); + const chatId = validateId(args.chat_id, "chat_id"); const chat = getChatById(chatId); if (!chat) { return { - content: [{ type: 'text', text: `Chat not found: ${chatId}` }], + content: [{ type: "text", text: `Chat not found: ${chatId}` }], isError: true, }; } @@ -46,27 +46,27 @@ export async function getChat( result.push(`Title: ${entity.name}`); result.push(`Type: ${entity.type}`); if (entity.username) result.push(`Username: @${entity.username}`); - if ('participantsCount' in chat && chat.participantsCount) { + if ("participantsCount" in chat && chat.participantsCount) { result.push(`Participants: ${chat.participantsCount}`); } - if ('unreadCount' in chat) { + if ("unreadCount" in chat) { result.push(`Unread Messages: ${chat.unreadCount ?? 0}`); } const lastMsg = chat.lastMessage; if (lastMsg) { - const from = lastMsg.fromName ?? lastMsg.fromId ?? 'Unknown'; + const from = lastMsg.fromName ?? lastMsg.fromId ?? "Unknown"; const date = new Date(lastMsg.date * 1000).toISOString(); result.push(`Last Message: From ${from} at ${date}`); - result.push(`Message: ${lastMsg.message || '[Media/No text]'}`); + result.push(`Message: ${lastMsg.message || "[Media/No text]"}`); } return { - content: [{ type: 'text', text: result.join('\n') }], + content: [{ type: "text", text: result.join("\n") }], }; } catch (error) { return logAndFormatError( - 'get_chat', + "get_chat", error instanceof Error ? error : new Error(String(error)), ErrorCategory.CHAT, ); diff --git a/src/lib/mcp/telegram/tools/getChats.ts b/src/lib/mcp/telegram/tools/getChats.ts index 450998353..c0059c372 100644 --- a/src/lib/mcp/telegram/tools/getChats.ts +++ b/src/lib/mcp/telegram/tools/getChats.ts @@ -2,21 +2,29 @@ * Get Chats tool - Get a paginated list of chats */ -import type { MCPTool, MCPToolResult } from '../../types'; -import type { TelegramMCPContext } from '../types'; +import type { MCPTool, MCPToolResult } from "../../types"; +import type { TelegramMCPContext } from "../types"; -import { ErrorCategory, logAndFormatError } from '../../errorHandler'; -import { optNumber } from '../args'; -import { formatEntity, getChats as getChatsApi } from '../telegramApi'; +import { ErrorCategory, logAndFormatError } from "../../errorHandler"; +import { optNumber } from "../args"; +import { formatEntity, getChats as getChatsApi } from "../telegramApi"; export const tool: MCPTool = { - name: 'get_chats', - description: 'Get a paginated list of chats', + name: "get_chats", + description: "Get a paginated list of chats", inputSchema: { - type: 'object', + type: "object", properties: { - page: { type: 'number', description: 'Page number (1-indexed)', default: 1 }, - page_size: { type: 'number', description: 'Number of chats per page', default: 20 }, + page: { + type: "number", + description: "Page number (1-indexed)", + default: 1, + }, + page_size: { + type: "number", + description: "Number of chats per page", + default: 20, + }, }, }, }; @@ -26,15 +34,15 @@ export async function getChats( _context: TelegramMCPContext, ): Promise { try { - const page = optNumber(args, 'page', 1); - const pageSize = optNumber(args, 'page_size', 20); + const page = optNumber(args, "page", 1); + const pageSize = optNumber(args, "page_size", 20); const start = (page - 1) * pageSize; const chats = await getChatsApi(pageSize + start); const paginatedChats = chats.slice(start, start + pageSize); if (paginatedChats.length === 0) { - return { content: [{ type: 'text', text: 'Page out of range.' }] }; + return { content: [{ type: "text", text: "Page out of range." }] }; } const lines = paginatedChats.map((chat) => { @@ -42,10 +50,10 @@ export async function getChats( return `Chat ID: ${entity.id}, Title: ${entity.name}`; }); - return { content: [{ type: 'text', text: lines.join('\n') }] }; + return { content: [{ type: "text", text: lines.join("\n") }] }; } catch (error) { return logAndFormatError( - 'get_chats', + "get_chats", error instanceof Error ? error : new Error(String(error)), ErrorCategory.CHAT, ); diff --git a/src/lib/mcp/telegram/tools/getContactChats.ts b/src/lib/mcp/telegram/tools/getContactChats.ts index 13cbc8238..82794c848 100644 --- a/src/lib/mcp/telegram/tools/getContactChats.ts +++ b/src/lib/mcp/telegram/tools/getContactChats.ts @@ -1,13 +1,13 @@ -import type { MCPTool, MCPToolResult } from '../../types'; -import type { TelegramMCPContext } from '../types'; -import { ErrorCategory, logAndFormatError } from '../../errorHandler'; -import { store } from '../../../../store'; -import { selectOrderedChats } from '../../../../store/telegramSelectors'; +import type { MCPTool, MCPToolResult } from "../../types"; +import type { TelegramMCPContext } from "../types"; +import { ErrorCategory, logAndFormatError } from "../../errorHandler"; +import { store } from "../../../../store"; +import { selectOrderedChats } from "../../../../store/telegramSelectors"; export const tool: MCPTool = { - name: 'get_contact_chats', - description: 'Get all chats that are direct messages with contacts', - inputSchema: { type: 'object', properties: {} }, + name: "get_contact_chats", + description: "Get all chats that are direct messages with contacts", + inputSchema: { type: "object", properties: {} }, }; export async function getContactChats( @@ -17,21 +17,28 @@ export async function getContactChats( try { const state = store.getState(); const chats = selectOrderedChats(state); - const dmChats = chats.filter((c) => c.type === 'private'); + const dmChats = chats.filter((c) => c.type === "private"); if (dmChats.length === 0) { - return { content: [{ type: 'text', text: 'No contact chats found.' }] }; + return { content: [{ type: "text", text: "No contact chats found." }] }; } const lines = dmChats.map((c) => { - const username = c.username ? '@' + c.username : ''; - return ('ID: ' + c.id + ' | ' + (c.title ?? 'DM') + ' ' + username).trim(); + const username = c.username ? "@" + c.username : ""; + return ( + "ID: " + + c.id + + " | " + + (c.title ?? "DM") + + " " + + username + ).trim(); }); - return { content: [{ type: 'text', text: lines.join('\n') }] }; + return { content: [{ type: "text", text: lines.join("\n") }] }; } catch (error) { return logAndFormatError( - 'get_contact_chats', + "get_contact_chats", error instanceof Error ? error : new Error(String(error)), ErrorCategory.CONTACT, ); diff --git a/src/lib/mcp/telegram/tools/getContactIds.ts b/src/lib/mcp/telegram/tools/getContactIds.ts index 8226fbc93..8fcb7159d 100644 --- a/src/lib/mcp/telegram/tools/getContactIds.ts +++ b/src/lib/mcp/telegram/tools/getContactIds.ts @@ -1,18 +1,18 @@ -import type { MCPTool, MCPToolResult } from '../../types'; -import type { TelegramMCPContext } from '../types'; -import { ErrorCategory, logAndFormatError } from '../../errorHandler'; -import { mtprotoService } from '../../../../services/mtprotoService'; -import { Api } from 'telegram'; -import bigInt from 'big-integer'; -import type { ContactIdEntry } from '../apiResultTypes'; -import { narrow } from '../apiCastHelpers'; +import type { MCPTool, MCPToolResult } from "../../types"; +import type { TelegramMCPContext } from "../types"; +import { ErrorCategory, logAndFormatError } from "../../errorHandler"; +import { mtprotoService } from "../../../../services/mtprotoService"; +import { Api } from "telegram"; +import bigInt from "big-integer"; +import type { ContactIdEntry } from "../apiResultTypes"; +import { narrow } from "../apiCastHelpers"; type ContactIdResult = number | ContactIdEntry; export const tool: MCPTool = { - name: 'get_contact_ids', - description: 'Get IDs of all contacts', - inputSchema: { type: 'object', properties: {} }, + name: "get_contact_ids", + description: "Get IDs of all contacts", + inputSchema: { type: "object", properties: {} }, }; export async function getContactIds( @@ -27,16 +27,20 @@ export async function getContactIds( }); if (!result || !Array.isArray(result) || result.length === 0) { - return { content: [{ type: 'text', text: 'No contact IDs found.' }] }; + return { content: [{ type: "text", text: "No contact IDs found." }] }; } const ids = narrow(result).map((c) => - String(typeof c === 'number' ? c : c.userId ?? c), + String(typeof c === "number" ? c : (c.userId ?? c)), ); - return { content: [{ type: 'text', text: ids.length + ' contacts:\n' + ids.join('\n') }] }; + return { + content: [ + { type: "text", text: ids.length + " contacts:\n" + ids.join("\n") }, + ], + }; } catch (error) { return logAndFormatError( - 'get_contact_ids', + "get_contact_ids", error instanceof Error ? error : new Error(String(error)), ErrorCategory.CONTACT, ); diff --git a/src/lib/mcp/telegram/tools/getDirectChatByContact.ts b/src/lib/mcp/telegram/tools/getDirectChatByContact.ts index ee2a24852..391ee34c3 100644 --- a/src/lib/mcp/telegram/tools/getDirectChatByContact.ts +++ b/src/lib/mcp/telegram/tools/getDirectChatByContact.ts @@ -1,19 +1,19 @@ -import type { MCPTool, MCPToolResult } from '../../types'; -import type { TelegramMCPContext } from '../types'; -import { ErrorCategory, logAndFormatError } from '../../errorHandler'; -import { validateId } from '../../validation'; -import { store } from '../../../../store'; -import { selectOrderedChats } from '../../../../store/telegramSelectors'; +import type { MCPTool, MCPToolResult } from "../../types"; +import type { TelegramMCPContext } from "../types"; +import { ErrorCategory, logAndFormatError } from "../../errorHandler"; +import { validateId } from "../../validation"; +import { store } from "../../../../store"; +import { selectOrderedChats } from "../../../../store/telegramSelectors"; export const tool: MCPTool = { - name: 'get_direct_chat_by_contact', - description: 'Get direct message chat with a contact', + name: "get_direct_chat_by_contact", + description: "Get direct message chat with a contact", inputSchema: { - type: 'object', + type: "object", properties: { - user_id: { type: 'string', description: 'User ID' }, + user_id: { type: "string", description: "User ID" }, }, - required: ['user_id'], + required: ["user_id"], }, }; @@ -22,27 +22,42 @@ export async function getDirectChatByContact( _context: TelegramMCPContext, ): Promise { try { - const userId = validateId(args.user_id, 'user_id'); + const userId = validateId(args.user_id, "user_id"); const state = store.getState(); const chats = selectOrderedChats(state); const dmChat = chats.find( - (c) => c.type === 'private' && String(c.id) === String(userId), + (c) => c.type === "private" && String(c.id) === String(userId), ); if (!dmChat) { - return { content: [{ type: 'text', text: 'No direct chat found with user ' + userId + '.' }] }; + return { + content: [ + { + type: "text", + text: "No direct chat found with user " + userId + ".", + }, + ], + }; } return { - content: [{ - type: 'text', - text: 'Chat ID: ' + dmChat.id + ' | Title: ' + (dmChat.title ?? 'DM') + ' | Username: ' + (dmChat.username ?? 'N/A'), - }], + content: [ + { + type: "text", + text: + "Chat ID: " + + dmChat.id + + " | Title: " + + (dmChat.title ?? "DM") + + " | Username: " + + (dmChat.username ?? "N/A"), + }, + ], }; } catch (error) { return logAndFormatError( - 'get_direct_chat_by_contact', + "get_direct_chat_by_contact", error instanceof Error ? error : new Error(String(error)), ErrorCategory.CONTACT, ); diff --git a/src/lib/mcp/telegram/tools/getDrafts.ts b/src/lib/mcp/telegram/tools/getDrafts.ts index 26f092d74..0dd351ec4 100644 --- a/src/lib/mcp/telegram/tools/getDrafts.ts +++ b/src/lib/mcp/telegram/tools/getDrafts.ts @@ -1,9 +1,9 @@ import type { MCPTool, MCPToolResult } from "../../types"; import type { TelegramMCPContext } from "../types"; -import { ErrorCategory, logAndFormatError } from '../../errorHandler'; -import { mtprotoService } from '../../../../services/mtprotoService'; -import { Api } from 'telegram'; -import type { UpdatesResult } from '../apiResultTypes'; +import { ErrorCategory, logAndFormatError } from "../../errorHandler"; +import { mtprotoService } from "../../../../services/mtprotoService"; +import { Api } from "telegram"; +import type { UpdatesResult } from "../apiResultTypes"; import { narrow } from "../apiCastHelpers"; export const tool: MCPTool = { @@ -25,25 +25,29 @@ export async function getDrafts( const updates = narrow(result); if (!updates || !updates.updates || updates.updates.length === 0) { - return { content: [{ type: 'text', text: 'No drafts found.' }] }; + return { content: [{ type: "text", text: "No drafts found." }] }; } const lines: string[] = []; for (const update of updates.updates) { if (update.draft && update.draft.message) { - const peerId = update.peer?.userId ?? update.peer?.chatId ?? update.peer?.channelId ?? '?'; - lines.push('Peer ' + peerId + ': ' + update.draft.message); + const peerId = + update.peer?.userId ?? + update.peer?.chatId ?? + update.peer?.channelId ?? + "?"; + lines.push("Peer " + peerId + ": " + update.draft.message); } } if (lines.length === 0) { - return { content: [{ type: 'text', text: 'No drafts found.' }] }; + return { content: [{ type: "text", text: "No drafts found." }] }; } - return { content: [{ type: 'text', text: lines.join('\n') }] }; + return { content: [{ type: "text", text: lines.join("\n") }] }; } catch (error) { return logAndFormatError( - 'get_drafts', + "get_drafts", error instanceof Error ? error : new Error(String(error)), ErrorCategory.DRAFT, ); diff --git a/src/lib/mcp/telegram/tools/getGifSearch.ts b/src/lib/mcp/telegram/tools/getGifSearch.ts index ccd28509a..e5025b0ce 100644 --- a/src/lib/mcp/telegram/tools/getGifSearch.ts +++ b/src/lib/mcp/telegram/tools/getGifSearch.ts @@ -1,10 +1,10 @@ import type { MCPTool, MCPToolResult } from "../../types"; import type { TelegramMCPContext } from "../types"; -import { ErrorCategory, logAndFormatError } from '../../errorHandler'; -import { mtprotoService } from '../../../../services/mtprotoService'; -import { Api } from 'telegram'; -import { optNumber } from '../args'; -import type { InlineBotResults } from '../apiResultTypes'; +import { ErrorCategory, logAndFormatError } from "../../errorHandler"; +import { mtprotoService } from "../../../../services/mtprotoService"; +import { Api } from "telegram"; +import { optNumber } from "../args"; +import type { InlineBotResults } from "../apiResultTypes"; import { toInputUser, narrow } from "../apiCastHelpers"; export const tool: MCPTool = { @@ -14,7 +14,7 @@ export const tool: MCPTool = { type: "object", properties: { query: { type: "string", description: "Search query" }, - limit: { type: 'number', description: 'Max results', default: 10 }, + limit: { type: "number", description: "Max results", default: 10 }, }, required: ["query"], }, @@ -25,38 +25,51 @@ export async function getGifSearch( _context: TelegramMCPContext, ): Promise { try { - const query = typeof args.query === 'string' ? args.query : ''; - if (!query) return { content: [{ type: 'text', text: 'query is required' }], isError: true }; - const limit = optNumber(args, 'limit', 10); + const query = typeof args.query === "string" ? args.query : ""; + if (!query) + return { + content: [{ type: "text", text: "query is required" }], + isError: true, + }; + const limit = optNumber(args, "limit", 10); const client = mtprotoService.getClient(); const result = await mtprotoService.withFloodWaitHandling(async () => { - const bot = await client.getInputEntity('gif'); + const bot = await client.getInputEntity("gif"); return client.invoke( new Api.messages.GetInlineBotResults({ bot: toInputUser(bot), peer: new Api.InputPeerSelf(), query, - offset: '', + offset: "", }), ); }); const results = narrow(result)?.results; if (!results || !Array.isArray(results) || results.length === 0) { - return { content: [{ type: 'text', text: 'No GIFs found for: ' + query }] }; + return { + content: [{ type: "text", text: "No GIFs found for: " + query }], + }; } const lines = results.slice(0, limit).map((r, i: number) => { - const title = r.title ?? r.description ?? 'GIF ' + (i + 1); - return (i + 1) + '. ' + title; + const title = r.title ?? r.description ?? "GIF " + (i + 1); + return i + 1 + ". " + title; }); - return { content: [{ type: 'text', text: lines.length + ' GIFs found:\n' + lines.join('\n') }] }; + return { + content: [ + { + type: "text", + text: lines.length + " GIFs found:\n" + lines.join("\n"), + }, + ], + }; } catch (error) { return logAndFormatError( - 'get_gif_search', + "get_gif_search", error instanceof Error ? error : new Error(String(error)), ErrorCategory.MEDIA, ); diff --git a/src/lib/mcp/telegram/tools/getHistory.ts b/src/lib/mcp/telegram/tools/getHistory.ts index c8fab3a8f..335441e0f 100644 --- a/src/lib/mcp/telegram/tools/getHistory.ts +++ b/src/lib/mcp/telegram/tools/getHistory.ts @@ -1,20 +1,20 @@ -import type { MCPTool, MCPToolResult } from '../../types'; -import type { TelegramMCPContext } from '../types'; -import { ErrorCategory, logAndFormatError } from '../../errorHandler'; -import { getChatById, getMessages, formatMessage } from '../telegramApi'; -import { validateId } from '../../validation'; -import { optNumber } from '../args'; +import type { MCPTool, MCPToolResult } from "../../types"; +import type { TelegramMCPContext } from "../types"; +import { ErrorCategory, logAndFormatError } from "../../errorHandler"; +import { getChatById, getMessages, formatMessage } from "../telegramApi"; +import { validateId } from "../../validation"; +import { optNumber } from "../args"; export const tool: MCPTool = { - name: 'get_history', - description: 'Get message history from a chat', + name: "get_history", + description: "Get message history from a chat", inputSchema: { - type: 'object', + type: "object", properties: { - chat_id: { type: 'string', description: 'Chat ID or username' }, - limit: { type: 'number', description: 'Number of messages', default: 20 }, + chat_id: { type: "string", description: "Chat ID or username" }, + limit: { type: "number", description: "Number of messages", default: 20 }, }, - required: ['chat_id'], + required: ["chat_id"], }, }; @@ -23,29 +23,34 @@ export async function getHistory( _context: TelegramMCPContext, ): Promise { try { - const chatId = validateId(args.chat_id, 'chat_id'); - const limit = optNumber(args, 'limit', 20); + const chatId = validateId(args.chat_id, "chat_id"); + const limit = optNumber(args, "limit", 20); const chat = getChatById(chatId); if (!chat) { - return { content: [{ type: 'text', text: `Chat not found: ${chatId}` }], isError: true }; + return { + content: [{ type: "text", text: `Chat not found: ${chatId}` }], + isError: true, + }; } const messages = await getMessages(chatId, limit, 0); if (!messages || messages.length === 0) { - return { content: [{ type: 'text', text: 'No messages found in this chat.' }] }; + return { + content: [{ type: "text", text: "No messages found in this chat." }], + }; } const lines = messages.map((msg) => { const f = formatMessage(msg); - const from = msg.fromName ?? msg.fromId ?? 'Unknown'; - return `ID: ${f.id} | ${from} | ${f.date} | ${f.text || '[Media/No text]'}`; + const from = msg.fromName ?? msg.fromId ?? "Unknown"; + return `ID: ${f.id} | ${from} | ${f.date} | ${f.text || "[Media/No text]"}`; }); - return { content: [{ type: 'text', text: lines.join('\n') }] }; + return { content: [{ type: "text", text: lines.join("\n") }] }; } catch (error) { return logAndFormatError( - 'get_history', + "get_history", error instanceof Error ? error : new Error(String(error)), ErrorCategory.MSG, ); diff --git a/src/lib/mcp/telegram/tools/getLastInteraction.ts b/src/lib/mcp/telegram/tools/getLastInteraction.ts index 5aacedcbd..674ae3b1a 100644 --- a/src/lib/mcp/telegram/tools/getLastInteraction.ts +++ b/src/lib/mcp/telegram/tools/getLastInteraction.ts @@ -1,18 +1,18 @@ -import type { MCPTool, MCPToolResult } from '../../types'; -import type { TelegramMCPContext } from '../types'; -import { ErrorCategory, logAndFormatError } from '../../errorHandler'; -import { validateId } from '../../validation'; -import { getChatById, getMessages, formatMessage } from '../telegramApi'; +import type { MCPTool, MCPToolResult } from "../../types"; +import type { TelegramMCPContext } from "../types"; +import { ErrorCategory, logAndFormatError } from "../../errorHandler"; +import { validateId } from "../../validation"; +import { getChatById, getMessages, formatMessage } from "../telegramApi"; export const tool: MCPTool = { - name: 'get_last_interaction', - description: 'Get the last message exchanged with a user or chat', + name: "get_last_interaction", + description: "Get the last message exchanged with a user or chat", inputSchema: { - type: 'object', + type: "object", properties: { - chat_id: { type: 'string', description: 'Chat ID or username' }, + chat_id: { type: "string", description: "Chat ID or username" }, }, - required: ['chat_id'], + required: ["chat_id"], }, }; @@ -21,31 +21,46 @@ export async function getLastInteraction( _context: TelegramMCPContext, ): Promise { try { - const chatId = validateId(args.chat_id, 'chat_id'); + const chatId = validateId(args.chat_id, "chat_id"); const chat = getChatById(chatId); if (!chat) { - return { content: [{ type: 'text', text: 'Chat not found: ' + chatId }], isError: true }; + return { + content: [{ type: "text", text: "Chat not found: " + chatId }], + isError: true, + }; } const messages = await getMessages(chatId, 1, 0); if (!messages || messages.length === 0) { - return { content: [{ type: 'text', text: 'No messages found in this chat.' }] }; + return { + content: [{ type: "text", text: "No messages found in this chat." }], + }; } const msg = messages[0]; const f = formatMessage(msg); - const from = msg.fromName ?? msg.fromId ?? 'Unknown'; + const from = msg.fromName ?? msg.fromId ?? "Unknown"; return { - content: [{ - type: 'text', - text: 'Last message in ' + (chat.title ?? chatId) + ':\nFrom: ' + from + ' | Date: ' + f.date + ' | ' + (f.text || '[Media/No text]'), - }], + content: [ + { + type: "text", + text: + "Last message in " + + (chat.title ?? chatId) + + ":\nFrom: " + + from + + " | Date: " + + f.date + + " | " + + (f.text || "[Media/No text]"), + }, + ], }; } catch (error) { return logAndFormatError( - 'get_last_interaction', + "get_last_interaction", error instanceof Error ? error : new Error(String(error)), ErrorCategory.CONTACT, ); diff --git a/src/lib/mcp/telegram/tools/getMediaInfo.ts b/src/lib/mcp/telegram/tools/getMediaInfo.ts index b14f54bbb..f9687298b 100644 --- a/src/lib/mcp/telegram/tools/getMediaInfo.ts +++ b/src/lib/mcp/telegram/tools/getMediaInfo.ts @@ -1,8 +1,8 @@ import type { MCPTool, MCPToolResult } from "../../types"; import type { TelegramMCPContext } from "../types"; -import { ErrorCategory, logAndFormatError } from '../../errorHandler'; -import { validateId } from '../../validation'; -import { getChatById, getMessages } from '../telegramApi'; +import { ErrorCategory, logAndFormatError } from "../../errorHandler"; +import { validateId } from "../../validation"; +import { getChatById, getMessages } from "../telegramApi"; export const tool: MCPTool = { name: "get_media_info", @@ -22,35 +22,61 @@ export async function getMediaInfo( _context: TelegramMCPContext, ): Promise { try { - const chatId = validateId(args.chat_id, 'chat_id'); - const messageId = typeof args.message_id === 'number' && Number.isInteger(args.message_id) ? args.message_id : undefined; + const chatId = validateId(args.chat_id, "chat_id"); + const messageId = + typeof args.message_id === "number" && Number.isInteger(args.message_id) + ? args.message_id + : undefined; if (messageId === undefined) { - return { content: [{ type: 'text', text: 'message_id must be a positive integer' }], isError: true }; + return { + content: [ + { type: "text", text: "message_id must be a positive integer" }, + ], + isError: true, + }; } const chat = getChatById(chatId); - if (!chat) return { content: [{ type: 'text', text: 'Chat not found: ' + chatId }], isError: true }; + if (!chat) + return { + content: [{ type: "text", text: "Chat not found: " + chatId }], + isError: true, + }; const messages = await getMessages(chatId, 200, 0); - if (!messages) return { content: [{ type: 'text', text: 'No messages found.' }] }; + if (!messages) + return { content: [{ type: "text", text: "No messages found." }] }; const msg = messages.find((m) => String(m.id) === String(messageId)); - if (!msg) return { content: [{ type: 'text', text: 'Message ' + messageId + ' not found in cache.' }], isError: true }; + if (!msg) + return { + content: [ + { + type: "text", + text: "Message " + messageId + " not found in cache.", + }, + ], + isError: true, + }; if (!msg.media) { - return { content: [{ type: 'text', text: 'No media in message ' + messageId + '.' }] }; + return { + content: [ + { type: "text", text: "No media in message " + messageId + "." }, + ], + }; } const info = { ...msg.media, - type: msg.media.type ?? 'unknown', + type: msg.media.type ?? "unknown", }; - return { content: [{ type: 'text', text: JSON.stringify(info, null, 2) }] }; + return { content: [{ type: "text", text: JSON.stringify(info, null, 2) }] }; } catch (error) { return logAndFormatError( - 'get_media_info', + "get_media_info", error instanceof Error ? error : new Error(String(error)), ErrorCategory.MEDIA, ); diff --git a/src/lib/mcp/telegram/tools/getMessageContext.ts b/src/lib/mcp/telegram/tools/getMessageContext.ts index 3a1667fdc..111db3fcb 100644 --- a/src/lib/mcp/telegram/tools/getMessageContext.ts +++ b/src/lib/mcp/telegram/tools/getMessageContext.ts @@ -1,21 +1,25 @@ -import type { MCPTool, MCPToolResult } from '../../types'; -import type { TelegramMCPContext } from '../types'; -import { ErrorCategory, logAndFormatError } from '../../errorHandler'; -import { getChatById, getMessages, formatMessage } from '../telegramApi'; -import { validateId } from '../../validation'; -import { optNumber } from '../args'; +import type { MCPTool, MCPToolResult } from "../../types"; +import type { TelegramMCPContext } from "../types"; +import { ErrorCategory, logAndFormatError } from "../../errorHandler"; +import { getChatById, getMessages, formatMessage } from "../telegramApi"; +import { validateId } from "../../validation"; +import { optNumber } from "../args"; export const tool: MCPTool = { - name: 'get_message_context', - description: 'Get context around a specific message', + name: "get_message_context", + description: "Get context around a specific message", inputSchema: { - type: 'object', + type: "object", properties: { - chat_id: { type: 'string', description: 'Chat ID or username' }, - message_id: { type: 'number', description: 'Message ID' }, - limit: { type: 'number', description: 'Number of messages before/after', default: 5 }, + chat_id: { type: "string", description: "Chat ID or username" }, + message_id: { type: "number", description: "Message ID" }, + limit: { + type: "number", + description: "Number of messages before/after", + default: 5, + }, }, - required: ['chat_id', 'message_id'], + required: ["chat_id", "message_id"], }, }; @@ -24,33 +28,48 @@ export async function getMessageContext( _context: TelegramMCPContext, ): Promise { try { - const chatId = validateId(args.chat_id, 'chat_id'); - const messageId = typeof args.message_id === 'number' && Number.isInteger(args.message_id) - ? args.message_id - : undefined; - const contextSize = optNumber(args, 'limit', 5); + const chatId = validateId(args.chat_id, "chat_id"); + const messageId = + typeof args.message_id === "number" && Number.isInteger(args.message_id) + ? args.message_id + : undefined; + const contextSize = optNumber(args, "limit", 5); if (messageId === undefined) { return { - content: [{ type: 'text', text: 'message_id must be a positive integer' }], + content: [ + { type: "text", text: "message_id must be a positive integer" }, + ], isError: true, }; } const chat = getChatById(chatId); if (!chat) { - return { content: [{ type: 'text', text: `Chat not found: ${chatId}` }], isError: true }; + return { + content: [{ type: "text", text: `Chat not found: ${chatId}` }], + isError: true, + }; } const allMessages = await getMessages(chatId, 200, 0); if (!allMessages || allMessages.length === 0) { - return { content: [{ type: 'text', text: 'No messages found in this chat.' }] }; + return { + content: [{ type: "text", text: "No messages found in this chat." }], + }; } - const targetIndex = allMessages.findIndex((m) => String(m.id) === String(messageId)); + const targetIndex = allMessages.findIndex( + (m) => String(m.id) === String(messageId), + ); if (targetIndex === -1) { return { - content: [{ type: 'text', text: `Message ${messageId} not found in cached messages.` }], + content: [ + { + type: "text", + text: `Message ${messageId} not found in cached messages.`, + }, + ], isError: true, }; } @@ -61,15 +80,15 @@ export async function getMessageContext( const lines = contextMessages.map((msg) => { const f = formatMessage(msg); - const from = msg.fromName ?? msg.fromId ?? 'Unknown'; - const marker = String(msg.id) === String(messageId) ? ' >>> ' : ' '; - return `${marker}ID: ${f.id} | ${from} | ${f.date} | ${f.text || '[Media/No text]'}`; + const from = msg.fromName ?? msg.fromId ?? "Unknown"; + const marker = String(msg.id) === String(messageId) ? " >>> " : " "; + return `${marker}ID: ${f.id} | ${from} | ${f.date} | ${f.text || "[Media/No text]"}`; }); - return { content: [{ type: 'text', text: lines.join('\n') }] }; + return { content: [{ type: "text", text: lines.join("\n") }] }; } catch (error) { return logAndFormatError( - 'get_message_context', + "get_message_context", error instanceof Error ? error : new Error(String(error)), ErrorCategory.MSG, ); diff --git a/src/lib/mcp/telegram/tools/getMessageReactions.ts b/src/lib/mcp/telegram/tools/getMessageReactions.ts index 7d3e66fa4..69efde804 100644 --- a/src/lib/mcp/telegram/tools/getMessageReactions.ts +++ b/src/lib/mcp/telegram/tools/getMessageReactions.ts @@ -1,23 +1,23 @@ -import type { MCPTool, MCPToolResult } from '../../types'; -import type { TelegramMCPContext } from '../types'; -import { ErrorCategory, logAndFormatError } from '../../errorHandler'; -import { validateId } from '../../validation'; -import { getChatById } from '../telegramApi'; -import { mtprotoService } from '../../../../services/mtprotoService'; -import { Api } from 'telegram'; -import type { UpdatesResult } from '../apiResultTypes'; +import type { MCPTool, MCPToolResult } from "../../types"; +import type { TelegramMCPContext } from "../types"; +import { ErrorCategory, logAndFormatError } from "../../errorHandler"; +import { validateId } from "../../validation"; +import { getChatById } from "../telegramApi"; +import { mtprotoService } from "../../../../services/mtprotoService"; +import { Api } from "telegram"; +import type { UpdatesResult } from "../apiResultTypes"; import { narrow } from "../apiCastHelpers"; export const tool: MCPTool = { - name: 'get_message_reactions', - description: 'Get reactions on a message', + name: "get_message_reactions", + description: "Get reactions on a message", inputSchema: { - type: 'object', + type: "object", properties: { - chat_id: { type: 'string', description: 'Chat ID or username' }, - message_id: { type: 'number', description: 'Message ID' }, + chat_id: { type: "string", description: "Chat ID or username" }, + message_id: { type: "number", description: "Message ID" }, }, - required: ['chat_id', 'message_id'], + required: ["chat_id", "message_id"], }, }; @@ -26,15 +26,27 @@ export async function getMessageReactions( _context: TelegramMCPContext, ): Promise { try { - const chatId = validateId(args.chat_id, 'chat_id'); - const messageId = typeof args.message_id === 'number' && Number.isInteger(args.message_id) ? args.message_id : undefined; + const chatId = validateId(args.chat_id, "chat_id"); + const messageId = + typeof args.message_id === "number" && Number.isInteger(args.message_id) + ? args.message_id + : undefined; if (messageId === undefined) { - return { content: [{ type: 'text', text: 'message_id must be a positive integer' }], isError: true }; + return { + content: [ + { type: "text", text: "message_id must be a positive integer" }, + ], + isError: true, + }; } const chat = getChatById(chatId); - if (!chat) return { content: [{ type: 'text', text: 'Chat not found: ' + chatId }], isError: true }; + if (!chat) + return { + content: [{ type: "text", text: "Chat not found: " + chatId }], + isError: true, + }; const client = mtprotoService.getClient(); const entity = chat.username ? chat.username : chat.id; @@ -51,28 +63,49 @@ export async function getMessageReactions( const updates = narrow(result); if (!updates || !updates.updates || updates.updates.length === 0) { - return { content: [{ type: 'text', text: 'No reactions found on message ' + messageId + '.' }] }; + return { + content: [ + { + type: "text", + text: "No reactions found on message " + messageId + ".", + }, + ], + }; } const lines: string[] = []; for (const update of updates.updates) { if (update.reactions && update.reactions.results) { for (const r of update.reactions.results) { - const emoji = r.reaction?.emoticon ?? r.reaction?.className ?? '?'; + const emoji = r.reaction?.emoticon ?? r.reaction?.className ?? "?"; const count = r.count ?? 0; - lines.push(emoji + ': ' + count); + lines.push(emoji + ": " + count); } } } if (lines.length === 0) { - return { content: [{ type: 'text', text: 'No reactions found on message ' + messageId + '.' }] }; + return { + content: [ + { + type: "text", + text: "No reactions found on message " + messageId + ".", + }, + ], + }; } - return { content: [{ type: 'text', text: 'Reactions on message ' + messageId + ':\n' + lines.join('\n') }] }; + return { + content: [ + { + type: "text", + text: "Reactions on message " + messageId + ":\n" + lines.join("\n"), + }, + ], + }; } catch (error) { return logAndFormatError( - 'get_message_reactions', + "get_message_reactions", error instanceof Error ? error : new Error(String(error)), ErrorCategory.MSG, ); diff --git a/src/lib/mcp/telegram/tools/getPinnedMessages.ts b/src/lib/mcp/telegram/tools/getPinnedMessages.ts index 49455280b..c79b97bda 100644 --- a/src/lib/mcp/telegram/tools/getPinnedMessages.ts +++ b/src/lib/mcp/telegram/tools/getPinnedMessages.ts @@ -1,21 +1,23 @@ -import type { MCPTool, MCPToolResult } from '../../types'; -import type { TelegramMCPContext } from '../types'; -import { ErrorCategory, logAndFormatError } from '../../errorHandler'; -import { getChatById, getMessages, formatMessage } from '../telegramApi'; -import { validateId } from '../../validation'; -import { mtprotoService } from '../../../../services/mtprotoService'; -import { Api } from 'telegram'; -import bigInt from 'big-integer'; -import type { ApiMessage } from '../apiResultTypes'; -import { narrow } from '../apiCastHelpers'; +import type { MCPTool, MCPToolResult } from "../../types"; +import type { TelegramMCPContext } from "../types"; +import { ErrorCategory, logAndFormatError } from "../../errorHandler"; +import { getChatById, getMessages, formatMessage } from "../telegramApi"; +import { validateId } from "../../validation"; +import { mtprotoService } from "../../../../services/mtprotoService"; +import { Api } from "telegram"; +import bigInt from "big-integer"; +import type { ApiMessage } from "../apiResultTypes"; +import { narrow } from "../apiCastHelpers"; export const tool: MCPTool = { - name: 'get_pinned_messages', - description: 'Get pinned messages from a chat', + name: "get_pinned_messages", + description: "Get pinned messages from a chat", inputSchema: { - type: 'object', - properties: { chat_id: { type: 'string', description: 'Chat ID or username' } }, - required: ['chat_id'], + type: "object", + properties: { + chat_id: { type: "string", description: "Chat ID or username" }, + }, + required: ["chat_id"], }, }; @@ -24,11 +26,14 @@ export async function getPinnedMessages( _context: TelegramMCPContext, ): Promise { try { - const chatId = validateId(args.chat_id, 'chat_id'); + const chatId = validateId(args.chat_id, "chat_id"); const chat = getChatById(chatId); if (!chat) { - return { content: [{ type: 'text', text: `Chat not found: ${chatId}` }], isError: true }; + return { + content: [{ type: "text", text: `Chat not found: ${chatId}` }], + isError: true, + }; } const entity = chat.username ? chat.username : chat.id; @@ -41,7 +46,7 @@ export async function getPinnedMessages( const result = await client.invoke( new Api.messages.Search({ peer: inputPeer, - q: '', + q: "", filter: new Api.InputMessagesFilterPinned(), minDate: 0, maxDate: 0, @@ -54,11 +59,13 @@ export async function getPinnedMessages( }), ); - if ('messages' in result && Array.isArray(result.messages)) { + if ("messages" in result && Array.isArray(result.messages)) { pinnedLines = narrow(result.messages).map((msg) => { - const id = msg.id ?? '?'; - const text = msg.message ?? '[Media/No text]'; - const date = msg.date ? new Date(msg.date * 1000).toISOString() : 'unknown'; + const id = msg.id ?? "?"; + const text = msg.message ?? "[Media/No text]"; + const date = msg.date + ? new Date(msg.date * 1000).toISOString() + : "unknown"; return `ID: ${id} | Date: ${date} | ${text}`; }); } @@ -69,19 +76,19 @@ export async function getPinnedMessages( const pinned = allMessages.filter((m) => narrow(m).pinned); pinnedLines = pinned.map((msg) => { const f = formatMessage(msg); - return `ID: ${f.id} | Date: ${f.date} | ${f.text || '[Media/No text]'}`; + return `ID: ${f.id} | Date: ${f.date} | ${f.text || "[Media/No text]"}`; }); } } if (pinnedLines.length === 0) { - return { content: [{ type: 'text', text: 'No pinned messages found.' }] }; + return { content: [{ type: "text", text: "No pinned messages found." }] }; } - return { content: [{ type: 'text', text: pinnedLines.join('\n') }] }; + return { content: [{ type: "text", text: pinnedLines.join("\n") }] }; } catch (error) { return logAndFormatError( - 'get_pinned_messages', + "get_pinned_messages", error instanceof Error ? error : new Error(String(error)), ErrorCategory.MSG, ); diff --git a/src/lib/mcp/telegram/tools/getPrivacySettings.ts b/src/lib/mcp/telegram/tools/getPrivacySettings.ts index 997d7c110..7fc954c0d 100644 --- a/src/lib/mcp/telegram/tools/getPrivacySettings.ts +++ b/src/lib/mcp/telegram/tools/getPrivacySettings.ts @@ -1,9 +1,9 @@ import type { MCPTool, MCPToolResult } from "../../types"; import type { TelegramMCPContext } from "../types"; -import { ErrorCategory, logAndFormatError } from '../../errorHandler'; -import { mtprotoService } from '../../../../services/mtprotoService'; -import { Api } from 'telegram'; -import type { PrivacyResult } from '../apiResultTypes'; +import { ErrorCategory, logAndFormatError } from "../../errorHandler"; +import { mtprotoService } from "../../../../services/mtprotoService"; +import { Api } from "telegram"; +import type { PrivacyResult } from "../apiResultTypes"; import { narrow } from "../apiCastHelpers"; export const tool: MCPTool = { @@ -12,7 +12,12 @@ export const tool: MCPTool = { inputSchema: { type: "object", properties: { - key: { type: 'string', description: 'Privacy key: phone_number, last_seen, profile_photo, forwards, phone_call, chat_invite', default: 'last_seen' }, + key: { + type: "string", + description: + "Privacy key: phone_number, last_seen, profile_photo, forwards, phone_call, chat_invite", + default: "last_seen", + }, }, }, }; @@ -22,7 +27,7 @@ export async function getPrivacySettings( _context: TelegramMCPContext, ): Promise { try { - const keyStr = typeof args.key === 'string' ? args.key : 'last_seen'; + const keyStr = typeof args.key === "string" ? args.key : "last_seen"; const client = mtprotoService.getClient(); const keyMap: Record = { @@ -36,7 +41,19 @@ export async function getPrivacySettings( const key = keyMap[keyStr]; if (!key) { - return { content: [{ type: 'text', text: 'Unknown privacy key: ' + keyStr + '. Valid keys: ' + Object.keys(keyMap).join(', ') }], isError: true }; + return { + content: [ + { + type: "text", + text: + "Unknown privacy key: " + + keyStr + + ". Valid keys: " + + Object.keys(keyMap).join(", "), + }, + ], + isError: true, + }; } const result = await mtprotoService.withFloodWaitHandling(async () => { @@ -45,14 +62,25 @@ export async function getPrivacySettings( const rules = narrow(result)?.rules; if (!rules || !Array.isArray(rules)) { - return { content: [{ type: 'text', text: 'No privacy rules found for ' + keyStr + '.' }] }; + return { + content: [ + { type: "text", text: "No privacy rules found for " + keyStr + "." }, + ], + }; } - const lines = rules.map((r) => r.className ?? 'Unknown rule'); - return { content: [{ type: 'text', text: 'Privacy settings for ' + keyStr + ':\n' + lines.join('\n') }] }; + const lines = rules.map((r) => r.className ?? "Unknown rule"); + return { + content: [ + { + type: "text", + text: "Privacy settings for " + keyStr + ":\n" + lines.join("\n"), + }, + ], + }; } catch (error) { return logAndFormatError( - 'get_privacy_settings', + "get_privacy_settings", error instanceof Error ? error : new Error(String(error)), ErrorCategory.PROFILE, ); diff --git a/src/lib/mcp/telegram/tools/getRecentActions.ts b/src/lib/mcp/telegram/tools/getRecentActions.ts index 9026f5c89..2bc7e77ce 100644 --- a/src/lib/mcp/telegram/tools/getRecentActions.ts +++ b/src/lib/mcp/telegram/tools/getRecentActions.ts @@ -1,13 +1,13 @@ import type { MCPTool, MCPToolResult } from "../../types"; import type { TelegramMCPContext } from "../types"; -import { ErrorCategory, logAndFormatError } from '../../errorHandler'; -import { validateId } from '../../validation'; -import { getChatById } from '../telegramApi'; -import { mtprotoService } from '../../../../services/mtprotoService'; -import { Api } from 'telegram'; -import bigInt from 'big-integer'; -import { optNumber } from '../args'; -import type { AdminLogResult } from '../apiResultTypes'; +import { ErrorCategory, logAndFormatError } from "../../errorHandler"; +import { validateId } from "../../validation"; +import { getChatById } from "../telegramApi"; +import { mtprotoService } from "../../../../services/mtprotoService"; +import { Api } from "telegram"; +import bigInt from "big-integer"; +import { optNumber } from "../args"; +import type { AdminLogResult } from "../apiResultTypes"; import { toInputChannel, narrow } from "../apiCastHelpers"; export const tool: MCPTool = { @@ -28,14 +28,26 @@ export async function getRecentActions( _context: TelegramMCPContext, ): Promise { try { - const chatId = validateId(args.chat_id, 'chat_id'); - const limit = optNumber(args, 'limit', 20); + const chatId = validateId(args.chat_id, "chat_id"); + const limit = optNumber(args, "limit", 20); const chat = getChatById(chatId); - if (!chat) return { content: [{ type: 'text', text: 'Chat not found: ' + chatId }], isError: true }; + if (!chat) + return { + content: [{ type: "text", text: "Chat not found: " + chatId }], + isError: true, + }; - if (chat.type !== 'channel' && chat.type !== 'supergroup') { - return { content: [{ type: 'text', text: 'Recent actions are only available for channels/supergroups.' }], isError: true }; + if (chat.type !== "channel" && chat.type !== "supergroup") { + return { + content: [ + { + type: "text", + text: "Recent actions are only available for channels/supergroups.", + }, + ], + isError: true, + }; } const client = mtprotoService.getClient(); @@ -46,7 +58,7 @@ export async function getRecentActions( return client.invoke( new Api.channels.GetAdminLog({ channel: toInputChannel(inputChannel), - q: '', + q: "", maxId: bigInt(0), minId: bigInt(0), limit, @@ -56,19 +68,19 @@ export async function getRecentActions( const events = narrow(result)?.events; if (!events || !Array.isArray(events) || events.length === 0) { - return { content: [{ type: 'text', text: 'No recent actions found.' }] }; + return { content: [{ type: "text", text: "No recent actions found." }] }; } const lines = events.map((e) => { - const date = e.date ? new Date(e.date * 1000).toISOString() : 'unknown'; - const action = e.action?.className ?? 'unknown'; - return date + ' | ' + action; + const date = e.date ? new Date(e.date * 1000).toISOString() : "unknown"; + const action = e.action?.className ?? "unknown"; + return date + " | " + action; }); - return { content: [{ type: 'text', text: lines.join('\n') }] }; + return { content: [{ type: "text", text: lines.join("\n") }] }; } catch (error) { return logAndFormatError( - 'get_recent_actions', + "get_recent_actions", error instanceof Error ? error : new Error(String(error)), ErrorCategory.ADMIN, ); diff --git a/src/lib/mcp/telegram/tools/getStickerSets.ts b/src/lib/mcp/telegram/tools/getStickerSets.ts index 4df931048..eda5236a8 100644 --- a/src/lib/mcp/telegram/tools/getStickerSets.ts +++ b/src/lib/mcp/telegram/tools/getStickerSets.ts @@ -1,10 +1,10 @@ import type { MCPTool, MCPToolResult } from "../../types"; import type { TelegramMCPContext } from "../types"; -import { ErrorCategory, logAndFormatError } from '../../errorHandler'; -import { mtprotoService } from '../../../../services/mtprotoService'; -import { Api } from 'telegram'; -import bigInt from 'big-integer'; -import type { StickerSetsResult } from '../apiResultTypes'; +import { ErrorCategory, logAndFormatError } from "../../errorHandler"; +import { mtprotoService } from "../../../../services/mtprotoService"; +import { Api } from "telegram"; +import bigInt from "big-integer"; +import type { StickerSetsResult } from "../apiResultTypes"; import { narrow } from "../apiCastHelpers"; export const tool: MCPTool = { @@ -21,22 +21,39 @@ export async function getStickerSets( const client = mtprotoService.getClient(); const result = await mtprotoService.withFloodWaitHandling(async () => { - return client.invoke(new Api.messages.GetAllStickers({ hash: bigInt(0) })); + return client.invoke( + new Api.messages.GetAllStickers({ hash: bigInt(0) }), + ); }); const sets = narrow(result)?.sets; if (!sets || !Array.isArray(sets) || sets.length === 0) { - return { content: [{ type: 'text', text: 'No sticker sets found.' }] }; + return { content: [{ type: "text", text: "No sticker sets found." }] }; } const lines = sets.map((s) => { - return 'ID: ' + s.id + ' | ' + (s.title ?? 'Untitled') + ' (' + (s.count ?? 0) + ' stickers)'; + return ( + "ID: " + + s.id + + " | " + + (s.title ?? "Untitled") + + " (" + + (s.count ?? 0) + + " stickers)" + ); }); - return { content: [{ type: 'text', text: lines.length + ' sticker sets:\n' + lines.join('\n') }] }; + return { + content: [ + { + type: "text", + text: lines.length + " sticker sets:\n" + lines.join("\n"), + }, + ], + }; } catch (error) { return logAndFormatError( - 'get_sticker_sets', + "get_sticker_sets", error instanceof Error ? error : new Error(String(error)), ErrorCategory.MEDIA, ); diff --git a/src/lib/mcp/telegram/tools/getUserPhotos.ts b/src/lib/mcp/telegram/tools/getUserPhotos.ts index 37a051f60..0dcd39d67 100644 --- a/src/lib/mcp/telegram/tools/getUserPhotos.ts +++ b/src/lib/mcp/telegram/tools/getUserPhotos.ts @@ -1,24 +1,24 @@ -import type { MCPTool, MCPToolResult } from '../../types'; -import type { TelegramMCPContext } from '../types'; -import { ErrorCategory, logAndFormatError } from '../../errorHandler'; -import { validateId } from '../../validation'; -import { mtprotoService } from '../../../../services/mtprotoService'; -import { Api } from 'telegram'; -import bigInt from 'big-integer'; -import { optNumber } from '../args'; -import type { ApiPhoto } from '../apiResultTypes'; +import type { MCPTool, MCPToolResult } from "../../types"; +import type { TelegramMCPContext } from "../types"; +import { ErrorCategory, logAndFormatError } from "../../errorHandler"; +import { validateId } from "../../validation"; +import { mtprotoService } from "../../../../services/mtprotoService"; +import { Api } from "telegram"; +import bigInt from "big-integer"; +import { optNumber } from "../args"; +import type { ApiPhoto } from "../apiResultTypes"; import { toInputUser, narrow } from "../apiCastHelpers"; export const tool: MCPTool = { - name: 'get_user_photos', - description: 'Get profile photos of a user', + name: "get_user_photos", + description: "Get profile photos of a user", inputSchema: { - type: 'object', + type: "object", properties: { - user_id: { type: 'string', description: 'User ID' }, - limit: { type: 'number', description: 'Max photos', default: 10 }, + user_id: { type: "string", description: "User ID" }, + limit: { type: "number", description: "Max photos", default: 10 }, }, - required: ['user_id'], + required: ["user_id"], }, }; @@ -27,8 +27,8 @@ export async function getUserPhotos( _context: TelegramMCPContext, ): Promise { try { - const userId = validateId(args.user_id, 'user_id'); - const limit = optNumber(args, 'limit', 10); + const userId = validateId(args.user_id, "user_id"); + const limit = optNumber(args, "limit", 10); const client = mtprotoService.getClient(); const result = await mtprotoService.withFloodWaitHandling(async () => { @@ -43,19 +43,33 @@ export async function getUserPhotos( ); }); - if (!result || !('photos' in result) || !Array.isArray(result.photos) || result.photos.length === 0) { - return { content: [{ type: 'text', text: 'No photos found.' }] }; + if ( + !result || + !("photos" in result) || + !Array.isArray(result.photos) || + result.photos.length === 0 + ) { + return { content: [{ type: "text", text: "No photos found." }] }; } const lines = narrow(result.photos).map((photo, i: number) => { - const date = photo.date ? new Date(photo.date * 1000).toISOString() : 'unknown'; - return 'Photo ' + (i + 1) + ': ID ' + photo.id + ' | Date: ' + date; + const date = photo.date + ? new Date(photo.date * 1000).toISOString() + : "unknown"; + return "Photo " + (i + 1) + ": ID " + photo.id + " | Date: " + date; }); - return { content: [{ type: 'text', text: lines.length + ' photos found:\n' + lines.join('\n') }] }; + return { + content: [ + { + type: "text", + text: lines.length + " photos found:\n" + lines.join("\n"), + }, + ], + }; } catch (error) { return logAndFormatError( - 'get_user_photos', + "get_user_photos", error instanceof Error ? error : new Error(String(error)), ErrorCategory.PROFILE, ); diff --git a/src/lib/mcp/telegram/tools/getUserStatus.ts b/src/lib/mcp/telegram/tools/getUserStatus.ts index 4f52795e0..77fd8e71b 100644 --- a/src/lib/mcp/telegram/tools/getUserStatus.ts +++ b/src/lib/mcp/telegram/tools/getUserStatus.ts @@ -1,21 +1,21 @@ -import type { MCPTool, MCPToolResult } from '../../types'; -import type { TelegramMCPContext } from '../types'; -import { ErrorCategory, logAndFormatError } from '../../errorHandler'; -import { validateId } from '../../validation'; -import { mtprotoService } from '../../../../services/mtprotoService'; -import { Api } from 'telegram'; -import type { ApiUser } from '../apiResultTypes'; +import type { MCPTool, MCPToolResult } from "../../types"; +import type { TelegramMCPContext } from "../types"; +import { ErrorCategory, logAndFormatError } from "../../errorHandler"; +import { validateId } from "../../validation"; +import { mtprotoService } from "../../../../services/mtprotoService"; +import { Api } from "telegram"; +import type { ApiUser } from "../apiResultTypes"; import { toInputUser, narrow } from "../apiCastHelpers"; export const tool: MCPTool = { - name: 'get_user_status', - description: 'Get online status of a user', + name: "get_user_status", + description: "Get online status of a user", inputSchema: { - type: 'object', + type: "object", properties: { - user_id: { type: 'string', description: 'User ID' }, + user_id: { type: "string", description: "User ID" }, }, - required: ['user_id'], + required: ["user_id"], }, }; @@ -24,7 +24,7 @@ export async function getUserStatus( _context: TelegramMCPContext, ): Promise { try { - const userId = validateId(args.user_id, 'user_id'); + const userId = validateId(args.user_id, "user_id"); const client = mtprotoService.getClient(); const result = await mtprotoService.withFloodWaitHandling(async () => { @@ -35,28 +35,41 @@ export async function getUserStatus( }); if (!result || !Array.isArray(result) || result.length === 0) { - return { content: [{ type: 'text', text: 'User ' + userId + ' not found.' }], isError: true }; + return { + content: [{ type: "text", text: "User " + userId + " not found." }], + isError: true, + }; } const user = narrow(result[0]); - const name = [user.firstName, user.lastName].filter(Boolean).join(' ') || 'Unknown'; - let statusText = 'unknown'; + const name = + [user.firstName, user.lastName].filter(Boolean).join(" ") || "Unknown"; + let statusText = "unknown"; if (user.status) { const s = user.status; - if (s.className === 'UserStatusOnline') statusText = 'Online'; - else if (s.className === 'UserStatusOffline') - statusText = 'Offline (last seen: ' + (s.wasOnline ? new Date(s.wasOnline * 1000).toISOString() : 'unknown') + ')'; - else if (s.className === 'UserStatusRecently') statusText = 'Recently'; - else if (s.className === 'UserStatusLastWeek') statusText = 'Last week'; - else if (s.className === 'UserStatusLastMonth') statusText = 'Last month'; - else statusText = s.className ?? 'unknown'; + if (s.className === "UserStatusOnline") statusText = "Online"; + else if (s.className === "UserStatusOffline") + statusText = + "Offline (last seen: " + + (s.wasOnline + ? new Date(s.wasOnline * 1000).toISOString() + : "unknown") + + ")"; + else if (s.className === "UserStatusRecently") statusText = "Recently"; + else if (s.className === "UserStatusLastWeek") statusText = "Last week"; + else if (s.className === "UserStatusLastMonth") statusText = "Last month"; + else statusText = s.className ?? "unknown"; } - return { content: [{ type: 'text', text: name + ' (ID: ' + user.id + '): ' + statusText }] }; + return { + content: [ + { type: "text", text: name + " (ID: " + user.id + "): " + statusText }, + ], + }; } catch (error) { return logAndFormatError( - 'get_user_status', + "get_user_status", error instanceof Error ? error : new Error(String(error)), ErrorCategory.CONTACT, ); diff --git a/src/lib/mcp/telegram/tools/importChatInvite.ts b/src/lib/mcp/telegram/tools/importChatInvite.ts index c1f66fca2..44fa8c357 100644 --- a/src/lib/mcp/telegram/tools/importChatInvite.ts +++ b/src/lib/mcp/telegram/tools/importChatInvite.ts @@ -39,7 +39,8 @@ export async function importChatInvite( return client.invoke(new Api.messages.ImportChatInvite({ hash })); }); - const chatTitle = narrow(result)?.chats?.[0]?.title ?? "unknown"; + const chatTitle = + narrow(result)?.chats?.[0]?.title ?? "unknown"; return { content: [{ type: "text", text: `Joined chat: ${chatTitle}` }] }; } catch (error) { return logAndFormatError( diff --git a/src/lib/mcp/telegram/tools/importContacts.ts b/src/lib/mcp/telegram/tools/importContacts.ts index c0a1605a6..978230ab5 100644 --- a/src/lib/mcp/telegram/tools/importContacts.ts +++ b/src/lib/mcp/telegram/tools/importContacts.ts @@ -67,7 +67,8 @@ export async function importContacts( ); }); - const imported = narrow(result)?.imported?.length ?? 0; + const imported = + narrow(result)?.imported?.length ?? 0; return { content: [ { diff --git a/src/lib/mcp/telegram/tools/index.ts b/src/lib/mcp/telegram/tools/index.ts index ad2b338a4..0be429aad 100644 --- a/src/lib/mcp/telegram/tools/index.ts +++ b/src/lib/mcp/telegram/tools/index.ts @@ -3,91 +3,154 @@ * Each tool exports a 'tool' definition and a handler function */ -export { getChats, tool as getChatsTool } from './getChats'; -export { listChats, tool as listChatsTool } from './listChats'; -export { getChat, tool as getChatTool } from './getChat'; -export { createGroup, tool as createGroupTool } from './createGroup'; -export { inviteToGroup, tool as inviteToGroupTool } from './inviteToGroup'; -export { createChannel, tool as createChannelTool } from './createChannel'; -export { editChatTitle, tool as editChatTitleTool } from './editChatTitle'; -export { deleteChatPhoto, tool as deleteChatPhotoTool } from './deleteChatPhoto'; -export { leaveChat, tool as leaveChatTool } from './leaveChat'; -export { getParticipants, tool as getParticipantsTool } from './getParticipants'; -export { getAdmins, tool as getAdminsTool } from './getAdmins'; -export { getBannedUsers, tool as getBannedUsersTool } from './getBannedUsers'; -export { promoteAdmin, tool as promoteAdminTool } from './promoteAdmin'; -export { demoteAdmin, tool as demoteAdminTool } from './demoteAdmin'; -export { banUser, tool as banUserTool } from './banUser'; -export { unbanUser, tool as unbanUserTool } from './unbanUser'; -export { getInviteLink, tool as getInviteLinkTool } from './getInviteLink'; -export { exportChatInvite, tool as exportChatInviteTool } from './exportChatInvite'; -export { importChatInvite, tool as importChatInviteTool } from './importChatInvite'; -export { joinChatByLink, tool as joinChatByLinkTool } from './joinChatByLink'; -export { subscribePublicChannel, tool as subscribePublicChannelTool } from './subscribePublicChannel'; -export { muteChat, tool as muteChatTool } from './muteChat'; -export { unmuteChat, tool as unmuteChatTool } from './unmuteChat'; -export { archiveChat, tool as archiveChatTool } from './archiveChat'; -export { unarchiveChat, tool as unarchiveChatTool } from './unarchiveChat'; +export { getChats, tool as getChatsTool } from "./getChats"; +export { listChats, tool as listChatsTool } from "./listChats"; +export { getChat, tool as getChatTool } from "./getChat"; +export { createGroup, tool as createGroupTool } from "./createGroup"; +export { inviteToGroup, tool as inviteToGroupTool } from "./inviteToGroup"; +export { createChannel, tool as createChannelTool } from "./createChannel"; +export { editChatTitle, tool as editChatTitleTool } from "./editChatTitle"; +export { + deleteChatPhoto, + tool as deleteChatPhotoTool, +} from "./deleteChatPhoto"; +export { leaveChat, tool as leaveChatTool } from "./leaveChat"; +export { + getParticipants, + tool as getParticipantsTool, +} from "./getParticipants"; +export { getAdmins, tool as getAdminsTool } from "./getAdmins"; +export { getBannedUsers, tool as getBannedUsersTool } from "./getBannedUsers"; +export { promoteAdmin, tool as promoteAdminTool } from "./promoteAdmin"; +export { demoteAdmin, tool as demoteAdminTool } from "./demoteAdmin"; +export { banUser, tool as banUserTool } from "./banUser"; +export { unbanUser, tool as unbanUserTool } from "./unbanUser"; +export { getInviteLink, tool as getInviteLinkTool } from "./getInviteLink"; +export { + exportChatInvite, + tool as exportChatInviteTool, +} from "./exportChatInvite"; +export { + importChatInvite, + tool as importChatInviteTool, +} from "./importChatInvite"; +export { joinChatByLink, tool as joinChatByLinkTool } from "./joinChatByLink"; +export { + subscribePublicChannel, + tool as subscribePublicChannelTool, +} from "./subscribePublicChannel"; +export { muteChat, tool as muteChatTool } from "./muteChat"; +export { unmuteChat, tool as unmuteChatTool } from "./unmuteChat"; +export { archiveChat, tool as archiveChatTool } from "./archiveChat"; +export { unarchiveChat, tool as unarchiveChatTool } from "./unarchiveChat"; -export { getMessages, tool as getMessagesTool } from './getMessages'; -export { listMessages, tool as listMessagesTool } from './listMessages'; -export { listTopics, tool as listTopicsTool } from './listTopics'; -export { sendMessage, tool as sendMessageTool } from './sendMessage'; -export { replyToMessage, tool as replyToMessageTool } from './replyToMessage'; -export { editMessage, tool as editMessageTool } from './editMessage'; -export { deleteMessage, tool as deleteMessageTool } from './deleteMessage'; -export { forwardMessage, tool as forwardMessageTool } from './forwardMessage'; -export { pinMessage, tool as pinMessageTool } from './pinMessage'; -export { unpinMessage, tool as unpinMessageTool } from './unpinMessage'; -export { markAsRead, tool as markAsReadTool } from './markAsRead'; -export { getMessageContext, tool as getMessageContextTool } from './getMessageContext'; -export { getHistory, tool as getHistoryTool } from './getHistory'; -export { getPinnedMessages, tool as getPinnedMessagesTool } from './getPinnedMessages'; -export { sendReaction, tool as sendReactionTool } from './sendReaction'; -export { removeReaction, tool as removeReactionTool } from './removeReaction'; -export { getMessageReactions, tool as getMessageReactionsTool } from './getMessageReactions'; -export { listInlineButtons, tool as listInlineButtonsTool } from './listInlineButtons'; -export { pressInlineButton, tool as pressInlineButtonTool } from './pressInlineButton'; -export { saveDraft, tool as saveDraftTool } from './saveDraft'; -export { getDrafts, tool as getDraftsTool } from './getDrafts'; -export { clearDraft, tool as clearDraftTool } from './clearDraft'; +export { getMessages, tool as getMessagesTool } from "./getMessages"; +export { listMessages, tool as listMessagesTool } from "./listMessages"; +export { listTopics, tool as listTopicsTool } from "./listTopics"; +export { sendMessage, tool as sendMessageTool } from "./sendMessage"; +export { replyToMessage, tool as replyToMessageTool } from "./replyToMessage"; +export { editMessage, tool as editMessageTool } from "./editMessage"; +export { deleteMessage, tool as deleteMessageTool } from "./deleteMessage"; +export { forwardMessage, tool as forwardMessageTool } from "./forwardMessage"; +export { pinMessage, tool as pinMessageTool } from "./pinMessage"; +export { unpinMessage, tool as unpinMessageTool } from "./unpinMessage"; +export { markAsRead, tool as markAsReadTool } from "./markAsRead"; +export { + getMessageContext, + tool as getMessageContextTool, +} from "./getMessageContext"; +export { getHistory, tool as getHistoryTool } from "./getHistory"; +export { + getPinnedMessages, + tool as getPinnedMessagesTool, +} from "./getPinnedMessages"; +export { sendReaction, tool as sendReactionTool } from "./sendReaction"; +export { removeReaction, tool as removeReactionTool } from "./removeReaction"; +export { + getMessageReactions, + tool as getMessageReactionsTool, +} from "./getMessageReactions"; +export { + listInlineButtons, + tool as listInlineButtonsTool, +} from "./listInlineButtons"; +export { + pressInlineButton, + tool as pressInlineButtonTool, +} from "./pressInlineButton"; +export { saveDraft, tool as saveDraftTool } from "./saveDraft"; +export { getDrafts, tool as getDraftsTool } from "./getDrafts"; +export { clearDraft, tool as clearDraftTool } from "./clearDraft"; -export { listContacts, tool as listContactsTool } from './listContacts'; -export { searchContacts, tool as searchContactsTool } from './searchContacts'; -export { addContact, tool as addContactTool } from './addContact'; -export { deleteContact, tool as deleteContactTool } from './deleteContact'; -export { blockUser, tool as blockUserTool } from './blockUser'; -export { unblockUser, tool as unblockUserTool } from './unblockUser'; -export { getBlockedUsers, tool as getBlockedUsersTool } from './getBlockedUsers'; +export { listContacts, tool as listContactsTool } from "./listContacts"; +export { searchContacts, tool as searchContactsTool } from "./searchContacts"; +export { addContact, tool as addContactTool } from "./addContact"; +export { deleteContact, tool as deleteContactTool } from "./deleteContact"; +export { blockUser, tool as blockUserTool } from "./blockUser"; +export { unblockUser, tool as unblockUserTool } from "./unblockUser"; +export { + getBlockedUsers, + tool as getBlockedUsersTool, +} from "./getBlockedUsers"; -export { getMe, tool as getMeTool } from './getMe'; -export { updateProfile, tool as updateProfileTool } from './updateProfile'; -export { getUserPhotos, tool as getUserPhotosTool } from './getUserPhotos'; -export { getUserStatus, tool as getUserStatusTool } from './getUserStatus'; +export { getMe, tool as getMeTool } from "./getMe"; +export { updateProfile, tool as updateProfileTool } from "./updateProfile"; +export { getUserPhotos, tool as getUserPhotosTool } from "./getUserPhotos"; +export { getUserStatus, tool as getUserStatusTool } from "./getUserStatus"; -export { searchPublicChats, tool as searchPublicChatsTool } from './searchPublicChats'; -export { searchMessages, tool as searchMessagesTool } from './searchMessages'; -export { resolveUsername, tool as resolveUsernameTool } from './resolveUsername'; +export { + searchPublicChats, + tool as searchPublicChatsTool, +} from "./searchPublicChats"; +export { searchMessages, tool as searchMessagesTool } from "./searchMessages"; +export { + resolveUsername, + tool as resolveUsernameTool, +} from "./resolveUsername"; -export { getMediaInfo, tool as getMediaInfoTool } from './getMediaInfo'; -export { getRecentActions, tool as getRecentActionsTool } from './getRecentActions'; -export { createPoll, tool as createPollTool } from './createPoll'; -export { getBotInfo, tool as getBotInfoTool } from './getBotInfo'; -export { setBotCommands, tool as setBotCommandsTool } from './setBotCommands'; +export { getMediaInfo, tool as getMediaInfoTool } from "./getMediaInfo"; +export { + getRecentActions, + tool as getRecentActionsTool, +} from "./getRecentActions"; +export { createPoll, tool as createPollTool } from "./createPoll"; +export { getBotInfo, tool as getBotInfoTool } from "./getBotInfo"; +export { setBotCommands, tool as setBotCommandsTool } from "./setBotCommands"; -export { getPrivacySettings, tool as getPrivacySettingsTool } from './getPrivacySettings'; -export { setPrivacySettings, tool as setPrivacySettingsTool } from './setPrivacySettings'; +export { + getPrivacySettings, + tool as getPrivacySettingsTool, +} from "./getPrivacySettings"; +export { + setPrivacySettings, + tool as setPrivacySettingsTool, +} from "./setPrivacySettings"; -export { setProfilePhoto, tool as setProfilePhotoTool } from './setProfilePhoto'; -export { deleteProfilePhoto, tool as deleteProfilePhotoTool } from './deleteProfilePhoto'; -export { editChatPhoto, tool as editChatPhotoTool } from './editChatPhoto'; +export { + setProfilePhoto, + tool as setProfilePhotoTool, +} from "./setProfilePhoto"; +export { + deleteProfilePhoto, + tool as deleteProfilePhotoTool, +} from "./deleteProfilePhoto"; +export { editChatPhoto, tool as editChatPhotoTool } from "./editChatPhoto"; -export { getStickerSets, tool as getStickerSetsTool } from './getStickerSets'; -export { getGifSearch, tool as getGifSearchTool } from './getGifSearch'; +export { getStickerSets, tool as getStickerSetsTool } from "./getStickerSets"; +export { getGifSearch, tool as getGifSearchTool } from "./getGifSearch"; -export { getContactIds, tool as getContactIdsTool } from './getContactIds'; -export { importContacts, tool as importContactsTool } from './importContacts'; -export { exportContacts, tool as exportContactsTool } from './exportContacts'; -export { getDirectChatByContact, tool as getDirectChatByContactTool } from './getDirectChatByContact'; -export { getContactChats, tool as getContactChatsTool } from './getContactChats'; -export { getLastInteraction, tool as getLastInteractionTool } from './getLastInteraction'; +export { getContactIds, tool as getContactIdsTool } from "./getContactIds"; +export { importContacts, tool as importContactsTool } from "./importContacts"; +export { exportContacts, tool as exportContactsTool } from "./exportContacts"; +export { + getDirectChatByContact, + tool as getDirectChatByContactTool, +} from "./getDirectChatByContact"; +export { + getContactChats, + tool as getContactChatsTool, +} from "./getContactChats"; +export { + getLastInteraction, + tool as getLastInteractionTool, +} from "./getLastInteraction"; diff --git a/src/lib/mcp/telegram/tools/joinChatByLink.ts b/src/lib/mcp/telegram/tools/joinChatByLink.ts index 00162d63e..cb7cd04de 100644 --- a/src/lib/mcp/telegram/tools/joinChatByLink.ts +++ b/src/lib/mcp/telegram/tools/joinChatByLink.ts @@ -1,20 +1,24 @@ -import type { MCPTool, MCPToolResult } from '../../types'; -import type { TelegramMCPContext } from '../types'; -import { ErrorCategory, logAndFormatError } from '../../errorHandler'; -import { mtprotoService } from '../../../../services/mtprotoService'; -import { Api } from 'telegram'; -import type { ResultWithChats } from '../apiResultTypes'; -import { narrow } from '../apiCastHelpers'; +import type { MCPTool, MCPToolResult } from "../../types"; +import type { TelegramMCPContext } from "../types"; +import { ErrorCategory, logAndFormatError } from "../../errorHandler"; +import { mtprotoService } from "../../../../services/mtprotoService"; +import { Api } from "telegram"; +import type { ResultWithChats } from "../apiResultTypes"; +import { narrow } from "../apiCastHelpers"; export const tool: MCPTool = { - name: 'join_chat_by_link', - description: 'Join a chat using an invite link', + name: "join_chat_by_link", + description: "Join a chat using an invite link", inputSchema: { - type: 'object', + type: "object", properties: { - link: { type: 'string', description: 'Invite link (e.g. https://t.me/+HASH or https://t.me/joinchat/HASH)' }, + link: { + type: "string", + description: + "Invite link (e.g. https://t.me/+HASH or https://t.me/joinchat/HASH)", + }, }, - required: ['link'], + required: ["link"], }, }; @@ -23,8 +27,12 @@ export async function joinChatByLink( _context: TelegramMCPContext, ): Promise { try { - const link = typeof args.link === 'string' ? args.link : ''; - if (!link) return { content: [{ type: 'text', text: 'link is required' }], isError: true }; + const link = typeof args.link === "string" ? args.link : ""; + if (!link) + return { + content: [{ type: "text", text: "link is required" }], + isError: true, + }; // Extract hash from link let hash = link; @@ -39,11 +47,12 @@ export async function joinChatByLink( return client.invoke(new Api.messages.ImportChatInvite({ hash })); }); - const chatTitle = narrow(result)?.chats?.[0]?.title ?? 'unknown'; - return { content: [{ type: 'text', text: `Joined chat: ${chatTitle}` }] }; + const chatTitle = + narrow(result)?.chats?.[0]?.title ?? "unknown"; + return { content: [{ type: "text", text: `Joined chat: ${chatTitle}` }] }; } catch (error) { return logAndFormatError( - 'join_chat_by_link', + "join_chat_by_link", error instanceof Error ? error : new Error(String(error)), ErrorCategory.GROUP, ); diff --git a/src/lib/mcp/telegram/tools/listContacts.ts b/src/lib/mcp/telegram/tools/listContacts.ts index 27fb8c36d..1feb6ab6f 100644 --- a/src/lib/mcp/telegram/tools/listContacts.ts +++ b/src/lib/mcp/telegram/tools/listContacts.ts @@ -1,15 +1,15 @@ -import type { MCPTool, MCPToolResult } from '../../types'; -import type { TelegramMCPContext } from '../types'; -import { ErrorCategory, logAndFormatError } from '../../errorHandler'; -import { mtprotoService } from '../../../../services/mtprotoService'; -import { Api } from 'telegram'; -import bigInt from 'big-integer'; -import type { ApiUser } from '../apiResultTypes'; +import type { MCPTool, MCPToolResult } from "../../types"; +import type { TelegramMCPContext } from "../types"; +import { ErrorCategory, logAndFormatError } from "../../errorHandler"; +import { mtprotoService } from "../../../../services/mtprotoService"; +import { Api } from "telegram"; +import bigInt from "big-integer"; +import type { ApiUser } from "../apiResultTypes"; export const tool: MCPTool = { - name: 'list_contacts', - description: 'List all contacts in your Telegram account', - inputSchema: { type: 'object', properties: {} }, + name: "list_contacts", + description: "List all contacts in your Telegram account", + inputSchema: { type: "object", properties: {} }, }; export async function listContacts( @@ -23,25 +23,26 @@ export async function listContacts( return client.invoke(new Api.contacts.GetContacts({ hash: bigInt(0) })); }); - if (!result || !('users' in result) || !Array.isArray(result.users)) { - return { content: [{ type: 'text', text: 'No contacts found.' }] }; + if (!result || !("users" in result) || !Array.isArray(result.users)) { + return { content: [{ type: "text", text: "No contacts found." }] }; } const lines = result.users.map((u: ApiUser) => { - const name = [u.firstName, u.lastName].filter(Boolean).join(' ') || 'Unknown'; - const username = u.username ? `@${u.username}` : ''; - const phone = u.phone ? `+${u.phone}` : ''; + const name = + [u.firstName, u.lastName].filter(Boolean).join(" ") || "Unknown"; + const username = u.username ? `@${u.username}` : ""; + const phone = u.phone ? `+${u.phone}` : ""; return `ID: ${u.id} | ${name} ${username} ${phone}`.trim(); }); if (lines.length === 0) { - return { content: [{ type: 'text', text: 'No contacts found.' }] }; + return { content: [{ type: "text", text: "No contacts found." }] }; } - return { content: [{ type: 'text', text: lines.join('\n') }] }; + return { content: [{ type: "text", text: lines.join("\n") }] }; } catch (error) { return logAndFormatError( - 'list_contacts', + "list_contacts", error instanceof Error ? error : new Error(String(error)), ErrorCategory.CONTACT, ); diff --git a/src/lib/mcp/telegram/tools/listInlineButtons.ts b/src/lib/mcp/telegram/tools/listInlineButtons.ts index 65633f1db..7852fc7f6 100644 --- a/src/lib/mcp/telegram/tools/listInlineButtons.ts +++ b/src/lib/mcp/telegram/tools/listInlineButtons.ts @@ -1,10 +1,10 @@ import type { MCPTool, MCPToolResult } from "../../types"; import type { TelegramMCPContext } from "../types"; -import { ErrorCategory, logAndFormatError } from '../../errorHandler'; -import { validateId } from '../../validation'; -import { getChatById, getMessages } from '../telegramApi'; -import type { MessageWithReplyMarkup, ReplyMarkupRow } from '../apiResultTypes'; -import { narrow } from '../apiCastHelpers'; +import { ErrorCategory, logAndFormatError } from "../../errorHandler"; +import { validateId } from "../../validation"; +import { getChatById, getMessages } from "../telegramApi"; +import type { MessageWithReplyMarkup, ReplyMarkupRow } from "../apiResultTypes"; +import { narrow } from "../apiCastHelpers"; export const tool: MCPTool = { name: "list_inline_buttons", @@ -24,44 +24,82 @@ export async function listInlineButtons( _context: TelegramMCPContext, ): Promise { try { - const chatId = validateId(args.chat_id, 'chat_id'); - const messageId = typeof args.message_id === 'number' && Number.isInteger(args.message_id) ? args.message_id : undefined; + const chatId = validateId(args.chat_id, "chat_id"); + const messageId = + typeof args.message_id === "number" && Number.isInteger(args.message_id) + ? args.message_id + : undefined; if (messageId === undefined) { - return { content: [{ type: 'text', text: 'message_id must be a positive integer' }], isError: true }; + return { + content: [ + { type: "text", text: "message_id must be a positive integer" }, + ], + isError: true, + }; } const chat = getChatById(chatId); - if (!chat) return { content: [{ type: 'text', text: 'Chat not found: ' + chatId }], isError: true }; + if (!chat) + return { + content: [{ type: "text", text: "Chat not found: " + chatId }], + isError: true, + }; const messages = await getMessages(chatId, 200, 0); - if (!messages) return { content: [{ type: 'text', text: 'No messages found.' }] }; + if (!messages) + return { content: [{ type: "text", text: "No messages found." }] }; const found = messages.find((m) => String(m.id) === String(messageId)); const msg = found ? narrow(found) : undefined; - if (!msg) return { content: [{ type: 'text', text: 'Message ' + messageId + ' not found in cache.' }], isError: true }; + if (!msg) + return { + content: [ + { + type: "text", + text: "Message " + messageId + " not found in cache.", + }, + ], + isError: true, + }; if (!msg.replyMarkup || !msg.replyMarkup.rows) { - return { content: [{ type: 'text', text: 'No inline buttons on message ' + messageId + '.' }] }; + return { + content: [ + { + type: "text", + text: "No inline buttons on message " + messageId + ".", + }, + ], + }; } const lines: string[] = []; msg.replyMarkup.rows.forEach((row: ReplyMarkupRow, ri: number) => { if (row.buttons) { row.buttons.forEach((btn, bi: number) => { - lines.push('Row ' + ri + ', Button ' + bi + ': "' + (btn.text ?? '?') + '"'); + lines.push( + "Row " + ri + ", Button " + bi + ': "' + (btn.text ?? "?") + '"', + ); }); } }); if (lines.length === 0) { - return { content: [{ type: 'text', text: 'No inline buttons on message ' + messageId + '.' }] }; + return { + content: [ + { + type: "text", + text: "No inline buttons on message " + messageId + ".", + }, + ], + }; } - return { content: [{ type: 'text', text: lines.join('\n') }] }; + return { content: [{ type: "text", text: lines.join("\n") }] }; } catch (error) { return logAndFormatError( - 'list_inline_buttons', + "list_inline_buttons", error instanceof Error ? error : new Error(String(error)), ErrorCategory.MSG, ); diff --git a/src/lib/mcp/telegram/tools/listTopics.ts b/src/lib/mcp/telegram/tools/listTopics.ts index f9d596cfc..f64b565a9 100644 --- a/src/lib/mcp/telegram/tools/listTopics.ts +++ b/src/lib/mcp/telegram/tools/listTopics.ts @@ -1,17 +1,23 @@ -import type { MCPTool, MCPToolResult } from '../../types'; -import type { TelegramMCPContext } from '../types'; -import { ErrorCategory, logAndFormatError } from '../../errorHandler'; -import { validateId } from '../../validation'; -import { getChatById } from '../telegramApi'; -import { mtprotoService } from '../../../../services/mtprotoService'; -import { Api } from 'telegram'; -import type { ForumTopicsResult } from '../apiResultTypes'; +import type { MCPTool, MCPToolResult } from "../../types"; +import type { TelegramMCPContext } from "../types"; +import { ErrorCategory, logAndFormatError } from "../../errorHandler"; +import { validateId } from "../../validation"; +import { getChatById } from "../telegramApi"; +import { mtprotoService } from "../../../../services/mtprotoService"; +import { Api } from "telegram"; +import type { ForumTopicsResult } from "../apiResultTypes"; import { toInputChannel, narrow } from "../apiCastHelpers"; export const tool: MCPTool = { - name: 'list_topics', - description: 'List topics in a forum chat', - inputSchema: { type: 'object', properties: { chat_id: { type: 'string', description: 'Chat ID or username' } }, required: ['chat_id'] }, + name: "list_topics", + description: "List topics in a forum chat", + inputSchema: { + type: "object", + properties: { + chat_id: { type: "string", description: "Chat ID or username" }, + }, + required: ["chat_id"], + }, }; export async function listTopics( @@ -19,13 +25,25 @@ export async function listTopics( _context: TelegramMCPContext, ): Promise { try { - const chatId = validateId(args.chat_id, 'chat_id'); + const chatId = validateId(args.chat_id, "chat_id"); const chat = getChatById(chatId); - if (!chat) return { content: [{ type: 'text', text: 'Chat not found: ' + chatId }], isError: true }; + if (!chat) + return { + content: [{ type: "text", text: "Chat not found: " + chatId }], + isError: true, + }; - if (chat.type !== 'channel' && chat.type !== 'supergroup') { - return { content: [{ type: 'text', text: 'Forum topics are only available for channels/supergroups.' }], isError: true }; + if (chat.type !== "channel" && chat.type !== "supergroup") { + return { + content: [ + { + type: "text", + text: "Forum topics are only available for channels/supergroups.", + }, + ], + isError: true, + }; } const client = mtprotoService.getClient(); @@ -46,17 +64,17 @@ export async function listTopics( const topics = narrow(result)?.topics; if (!topics || !Array.isArray(topics) || topics.length === 0) { - return { content: [{ type: 'text', text: 'No forum topics found.' }] }; + return { content: [{ type: "text", text: "No forum topics found." }] }; } const lines = topics.map((t) => { - return 'ID: ' + t.id + ' | ' + (t.title ?? 'Untitled'); + return "ID: " + t.id + " | " + (t.title ?? "Untitled"); }); - return { content: [{ type: 'text', text: lines.join('\n') }] }; + return { content: [{ type: "text", text: lines.join("\n") }] }; } catch (error) { return logAndFormatError( - 'list_topics', + "list_topics", error instanceof Error ? error : new Error(String(error)), ErrorCategory.GROUP, ); diff --git a/src/lib/mcp/telegram/tools/markAsRead.ts b/src/lib/mcp/telegram/tools/markAsRead.ts index ac0e43388..c0de621a0 100644 --- a/src/lib/mcp/telegram/tools/markAsRead.ts +++ b/src/lib/mcp/telegram/tools/markAsRead.ts @@ -1,17 +1,19 @@ -import type { MCPTool, MCPToolResult } from '../../types'; -import type { TelegramMCPContext } from '../types'; -import { ErrorCategory, logAndFormatError } from '../../errorHandler'; -import { getChatById } from '../telegramApi'; -import { validateId } from '../../validation'; -import { mtprotoService } from '../../../../services/mtprotoService'; +import type { MCPTool, MCPToolResult } from "../../types"; +import type { TelegramMCPContext } from "../types"; +import { ErrorCategory, logAndFormatError } from "../../errorHandler"; +import { getChatById } from "../telegramApi"; +import { validateId } from "../../validation"; +import { mtprotoService } from "../../../../services/mtprotoService"; export const tool: MCPTool = { - name: 'mark_as_read', - description: 'Mark messages as read in a chat', + name: "mark_as_read", + description: "Mark messages as read in a chat", inputSchema: { - type: 'object', - properties: { chat_id: { type: 'string', description: 'Chat ID or username' } }, - required: ['chat_id'], + type: "object", + properties: { + chat_id: { type: "string", description: "Chat ID or username" }, + }, + required: ["chat_id"], }, }; @@ -20,11 +22,14 @@ export async function markAsRead( _context: TelegramMCPContext, ): Promise { try { - const chatId = validateId(args.chat_id, 'chat_id'); + const chatId = validateId(args.chat_id, "chat_id"); const chat = getChatById(chatId); if (!chat) { - return { content: [{ type: 'text', text: `Chat not found: ${chatId}` }], isError: true }; + return { + content: [{ type: "text", text: `Chat not found: ${chatId}` }], + isError: true, + }; } const entity = chat.username ? chat.username : chat.id; @@ -35,11 +40,13 @@ export async function markAsRead( }); return { - content: [{ type: 'text', text: `Messages in chat ${chatId} marked as read.` }], + content: [ + { type: "text", text: `Messages in chat ${chatId} marked as read.` }, + ], }; } catch (error) { return logAndFormatError( - 'mark_as_read', + "mark_as_read", error instanceof Error ? error : new Error(String(error)), ErrorCategory.MSG, ); diff --git a/src/lib/mcp/telegram/tools/muteChat.ts b/src/lib/mcp/telegram/tools/muteChat.ts index ab7ee06f3..275e1109a 100644 --- a/src/lib/mcp/telegram/tools/muteChat.ts +++ b/src/lib/mcp/telegram/tools/muteChat.ts @@ -1,21 +1,25 @@ -import type { MCPTool, MCPToolResult } from '../../types'; -import type { TelegramMCPContext } from '../types'; -import { ErrorCategory, logAndFormatError } from '../../errorHandler'; -import { validateId } from '../../validation'; -import { getChatById } from '../telegramApi'; -import { mtprotoService } from '../../../../services/mtprotoService'; -import { Api } from 'telegram'; +import type { MCPTool, MCPToolResult } from "../../types"; +import type { TelegramMCPContext } from "../types"; +import { ErrorCategory, logAndFormatError } from "../../errorHandler"; +import { validateId } from "../../validation"; +import { getChatById } from "../telegramApi"; +import { mtprotoService } from "../../../../services/mtprotoService"; +import { Api } from "telegram"; export const tool: MCPTool = { - name: 'mute_chat', - description: 'Mute notifications for a chat', + name: "mute_chat", + description: "Mute notifications for a chat", inputSchema: { - type: 'object', + type: "object", properties: { - chat_id: { type: 'string', description: 'Chat ID or username' }, - duration: { type: 'number', description: 'Mute duration in seconds (0 = forever)', default: 0 }, + chat_id: { type: "string", description: "Chat ID or username" }, + duration: { + type: "number", + description: "Mute duration in seconds (0 = forever)", + default: 0, + }, }, - required: ['chat_id'], + required: ["chat_id"], }, }; @@ -24,16 +28,21 @@ export async function muteChat( _context: TelegramMCPContext, ): Promise { try { - const chatId = validateId(args.chat_id, 'chat_id'); - const duration = typeof args.duration === 'number' ? args.duration : 0; + const chatId = validateId(args.chat_id, "chat_id"); + const duration = typeof args.duration === "number" ? args.duration : 0; const chat = getChatById(chatId); - if (!chat) return { content: [{ type: 'text', text: `Chat not found: ${chatId}` }], isError: true }; + if (!chat) + return { + content: [{ type: "text", text: `Chat not found: ${chatId}` }], + isError: true, + }; const client = mtprotoService.getClient(); const entity = chat.username ? chat.username : chat.id; - const muteUntil = duration === 0 ? 2147483647 : Math.floor(Date.now() / 1000) + duration; + const muteUntil = + duration === 0 ? 2147483647 : Math.floor(Date.now() / 1000) + duration; await mtprotoService.withFloodWaitHandling(async () => { const inputPeer = await client.getInputEntity(entity); @@ -47,10 +56,10 @@ export async function muteChat( ); }); - return { content: [{ type: 'text', text: `Chat ${chatId} muted.` }] }; + return { content: [{ type: "text", text: `Chat ${chatId} muted.` }] }; } catch (error) { return logAndFormatError( - 'mute_chat', + "mute_chat", error instanceof Error ? error : new Error(String(error)), ErrorCategory.CHAT, ); diff --git a/src/lib/mcp/telegram/tools/notImplemented.ts b/src/lib/mcp/telegram/tools/notImplemented.ts index 92532a8c4..7d87c2676 100644 --- a/src/lib/mcp/telegram/tools/notImplemented.ts +++ b/src/lib/mcp/telegram/tools/notImplemented.ts @@ -1,8 +1,8 @@ -import type { MCPToolResult } from '../../types'; +import type { MCPToolResult } from "../../types"; export function notImplemented(name: string): MCPToolResult { return { - content: [{ type: 'text', text: `${name} is not implemented yet.` }], + content: [{ type: "text", text: `${name} is not implemented yet.` }], isError: true, }; } diff --git a/src/lib/mcp/telegram/tools/pinMessage.ts b/src/lib/mcp/telegram/tools/pinMessage.ts index 383184f0b..7e0c67597 100644 --- a/src/lib/mcp/telegram/tools/pinMessage.ts +++ b/src/lib/mcp/telegram/tools/pinMessage.ts @@ -1,20 +1,20 @@ -import type { MCPTool, MCPToolResult } from '../../types'; -import type { TelegramMCPContext } from '../types'; -import { ErrorCategory, logAndFormatError } from '../../errorHandler'; -import { getChatById } from '../telegramApi'; -import { validateId } from '../../validation'; -import { mtprotoService } from '../../../../services/mtprotoService'; +import type { MCPTool, MCPToolResult } from "../../types"; +import type { TelegramMCPContext } from "../types"; +import { ErrorCategory, logAndFormatError } from "../../errorHandler"; +import { getChatById } from "../telegramApi"; +import { validateId } from "../../validation"; +import { mtprotoService } from "../../../../services/mtprotoService"; export const tool: MCPTool = { - name: 'pin_message', - description: 'Pin a message in a chat', + name: "pin_message", + description: "Pin a message in a chat", inputSchema: { - type: 'object', + type: "object", properties: { - chat_id: { type: 'string', description: 'Chat ID or username' }, - message_id: { type: 'number', description: 'Message ID' }, + chat_id: { type: "string", description: "Chat ID or username" }, + message_id: { type: "number", description: "Message ID" }, }, - required: ['chat_id', 'message_id'], + required: ["chat_id", "message_id"], }, }; @@ -23,21 +23,27 @@ export async function pinMessage( _context: TelegramMCPContext, ): Promise { try { - const chatId = validateId(args.chat_id, 'chat_id'); - const messageId = typeof args.message_id === 'number' && Number.isInteger(args.message_id) - ? args.message_id - : undefined; + const chatId = validateId(args.chat_id, "chat_id"); + const messageId = + typeof args.message_id === "number" && Number.isInteger(args.message_id) + ? args.message_id + : undefined; if (messageId === undefined) { return { - content: [{ type: 'text', text: 'message_id must be a positive integer' }], + content: [ + { type: "text", text: "message_id must be a positive integer" }, + ], isError: true, }; } const chat = getChatById(chatId); if (!chat) { - return { content: [{ type: 'text', text: `Chat not found: ${chatId}` }], isError: true }; + return { + content: [{ type: "text", text: `Chat not found: ${chatId}` }], + isError: true, + }; } const entity = chat.username ? chat.username : chat.id; @@ -48,11 +54,16 @@ export async function pinMessage( }); return { - content: [{ type: 'text', text: `Message ${messageId} pinned in chat ${chatId}.` }], + content: [ + { + type: "text", + text: `Message ${messageId} pinned in chat ${chatId}.`, + }, + ], }; } catch (error) { return logAndFormatError( - 'pin_message', + "pin_message", error instanceof Error ? error : new Error(String(error)), ErrorCategory.MSG, ); diff --git a/src/lib/mcp/telegram/tools/pressInlineButton.ts b/src/lib/mcp/telegram/tools/pressInlineButton.ts index e22b6c9c4..fb8759483 100644 --- a/src/lib/mcp/telegram/tools/pressInlineButton.ts +++ b/src/lib/mcp/telegram/tools/pressInlineButton.ts @@ -1,11 +1,11 @@ import type { MCPTool, MCPToolResult } from "../../types"; import type { TelegramMCPContext } from "../types"; -import { ErrorCategory, logAndFormatError } from '../../errorHandler'; -import { validateId } from '../../validation'; -import { getChatById } from '../telegramApi'; -import { mtprotoService } from '../../../../services/mtprotoService'; -import { Api } from 'telegram'; -import type { BotCallbackAnswer } from '../apiResultTypes'; +import { ErrorCategory, logAndFormatError } from "../../errorHandler"; +import { validateId } from "../../validation"; +import { getChatById } from "../telegramApi"; +import { mtprotoService } from "../../../../services/mtprotoService"; +import { Api } from "telegram"; +import type { BotCallbackAnswer } from "../apiResultTypes"; import { narrow } from "../apiCastHelpers"; export const tool: MCPTool = { @@ -27,17 +27,33 @@ export async function pressInlineButton( _context: TelegramMCPContext, ): Promise { try { - const chatId = validateId(args.chat_id, 'chat_id'); - const messageId = typeof args.message_id === 'number' && Number.isInteger(args.message_id) ? args.message_id : undefined; - const data = typeof args.button_text === 'string' ? args.button_text : ''; + const chatId = validateId(args.chat_id, "chat_id"); + const messageId = + typeof args.message_id === "number" && Number.isInteger(args.message_id) + ? args.message_id + : undefined; + const data = typeof args.button_text === "string" ? args.button_text : ""; if (messageId === undefined) { - return { content: [{ type: 'text', text: 'message_id must be a positive integer' }], isError: true }; + return { + content: [ + { type: "text", text: "message_id must be a positive integer" }, + ], + isError: true, + }; } - if (!data) return { content: [{ type: 'text', text: 'button_text is required' }], isError: true }; + if (!data) + return { + content: [{ type: "text", text: "button_text is required" }], + isError: true, + }; const chat = getChatById(chatId); - if (!chat) return { content: [{ type: 'text', text: 'Chat not found: ' + chatId }], isError: true }; + if (!chat) + return { + content: [{ type: "text", text: "Chat not found: " + chatId }], + isError: true, + }; const client = mtprotoService.getClient(); const entity = chat.username ? chat.username : chat.id; @@ -48,16 +64,18 @@ export async function pressInlineButton( new Api.messages.GetBotCallbackAnswer({ peer: inputPeer, msgId: messageId, - data: Buffer.from(data, 'base64'), + data: Buffer.from(data, "base64"), }), ); }); - const answer = narrow(result)?.message ?? 'Button pressed (no response message).'; - return { content: [{ type: 'text', text: answer }] }; + const answer = + narrow(result)?.message ?? + "Button pressed (no response message)."; + return { content: [{ type: "text", text: answer }] }; } catch (error) { return logAndFormatError( - 'press_inline_button', + "press_inline_button", error instanceof Error ? error : new Error(String(error)), ErrorCategory.MSG, ); diff --git a/src/lib/mcp/telegram/tools/removeReaction.ts b/src/lib/mcp/telegram/tools/removeReaction.ts index b920855b0..68b09305e 100644 --- a/src/lib/mcp/telegram/tools/removeReaction.ts +++ b/src/lib/mcp/telegram/tools/removeReaction.ts @@ -1,22 +1,22 @@ -import type { MCPTool, MCPToolResult } from '../../types'; -import type { TelegramMCPContext } from '../types'; -import { ErrorCategory, logAndFormatError } from '../../errorHandler'; -import { validateId } from '../../validation'; -import { getChatById } from '../telegramApi'; -import { mtprotoService } from '../../../../services/mtprotoService'; -import { Api } from 'telegram'; +import type { MCPTool, MCPToolResult } from "../../types"; +import type { TelegramMCPContext } from "../types"; +import { ErrorCategory, logAndFormatError } from "../../errorHandler"; +import { validateId } from "../../validation"; +import { getChatById } from "../telegramApi"; +import { mtprotoService } from "../../../../services/mtprotoService"; +import { Api } from "telegram"; export const tool: MCPTool = { - name: 'remove_reaction', - description: 'Remove a reaction from a message', + name: "remove_reaction", + description: "Remove a reaction from a message", inputSchema: { - type: 'object', + type: "object", properties: { - chat_id: { type: 'string', description: 'Chat ID or username' }, - message_id: { type: 'number', description: 'Message ID' }, - reaction: { type: 'string', description: 'Reaction to remove' }, + chat_id: { type: "string", description: "Chat ID or username" }, + message_id: { type: "number", description: "Message ID" }, + reaction: { type: "string", description: "Reaction to remove" }, }, - required: ['chat_id', 'message_id'], + required: ["chat_id", "message_id"], }, }; @@ -25,15 +25,27 @@ export async function removeReaction( _context: TelegramMCPContext, ): Promise { try { - const chatId = validateId(args.chat_id, 'chat_id'); - const messageId = typeof args.message_id === 'number' && Number.isInteger(args.message_id) ? args.message_id : undefined; + const chatId = validateId(args.chat_id, "chat_id"); + const messageId = + typeof args.message_id === "number" && Number.isInteger(args.message_id) + ? args.message_id + : undefined; if (messageId === undefined) { - return { content: [{ type: 'text', text: 'message_id must be a positive integer' }], isError: true }; + return { + content: [ + { type: "text", text: "message_id must be a positive integer" }, + ], + isError: true, + }; } const chat = getChatById(chatId); - if (!chat) return { content: [{ type: 'text', text: 'Chat not found: ' + chatId }], isError: true }; + if (!chat) + return { + content: [{ type: "text", text: "Chat not found: " + chatId }], + isError: true, + }; const client = mtprotoService.getClient(); const entity = chat.username ? chat.username : chat.id; @@ -49,10 +61,17 @@ export async function removeReaction( ); }); - return { content: [{ type: 'text', text: 'Reaction removed from message ' + messageId + '.' }] }; + return { + content: [ + { + type: "text", + text: "Reaction removed from message " + messageId + ".", + }, + ], + }; } catch (error) { return logAndFormatError( - 'remove_reaction', + "remove_reaction", error instanceof Error ? error : new Error(String(error)), ErrorCategory.MSG, ); diff --git a/src/lib/mcp/telegram/tools/replyToMessage.ts b/src/lib/mcp/telegram/tools/replyToMessage.ts index 6dea35620..00507afea 100644 --- a/src/lib/mcp/telegram/tools/replyToMessage.ts +++ b/src/lib/mcp/telegram/tools/replyToMessage.ts @@ -34,21 +34,22 @@ export async function replyToMessage( _context: TelegramMCPContext, ): Promise { try { - const chatId = validateId(args.chat_id, 'chat_id'); - const messageId = typeof args.message_id === 'number' && Number.isInteger(args.message_id) - ? args.message_id - : undefined; - const text = typeof args.text === 'string' ? args.text : ''; + const chatId = validateId(args.chat_id, "chat_id"); + const messageId = + typeof args.message_id === "number" && Number.isInteger(args.message_id) + ? args.message_id + : undefined; + const text = typeof args.text === "string" ? args.text : ""; if (messageId === undefined) { return { - content: [{ type: 'text', text: 'message_id must be an integer' }], + content: [{ type: "text", text: "message_id must be an integer" }], isError: true, }; } if (!text) { return { - content: [{ type: 'text', text: 'text is required' }], + content: [{ type: "text", text: "text is required" }], isError: true, }; } @@ -66,7 +67,7 @@ export async function replyToMessage( return { content: [ { - type: 'text', + type: "text", text: `Failed to reply to message ${messageId} in chat ${chatId}.`, }, ], @@ -77,7 +78,7 @@ export async function replyToMessage( return { content: [ { - type: 'text', + type: "text", text: `Replied to message ${messageId} in chat ${chatId}.`, }, ], diff --git a/src/lib/mcp/telegram/tools/saveDraft.ts b/src/lib/mcp/telegram/tools/saveDraft.ts index 8c5af6b84..7246f13b0 100644 --- a/src/lib/mcp/telegram/tools/saveDraft.ts +++ b/src/lib/mcp/telegram/tools/saveDraft.ts @@ -1,10 +1,10 @@ import type { MCPTool, MCPToolResult } from "../../types"; import type { TelegramMCPContext } from "../types"; -import { ErrorCategory, logAndFormatError } from '../../errorHandler'; -import { validateId } from '../../validation'; -import { getChatById } from '../telegramApi'; -import { mtprotoService } from '../../../../services/mtprotoService'; -import { Api } from 'telegram'; +import { ErrorCategory, logAndFormatError } from "../../errorHandler"; +import { validateId } from "../../validation"; +import { getChatById } from "../telegramApi"; +import { mtprotoService } from "../../../../services/mtprotoService"; +import { Api } from "telegram"; export const tool: MCPTool = { name: "save_draft", @@ -24,12 +24,20 @@ export async function saveDraft( _context: TelegramMCPContext, ): Promise { try { - const chatId = validateId(args.chat_id, 'chat_id'); - const text = typeof args.text === 'string' ? args.text : ''; - if (!text) return { content: [{ type: 'text', text: 'text is required' }], isError: true }; + const chatId = validateId(args.chat_id, "chat_id"); + const text = typeof args.text === "string" ? args.text : ""; + if (!text) + return { + content: [{ type: "text", text: "text is required" }], + isError: true, + }; const chat = getChatById(chatId); - if (!chat) return { content: [{ type: 'text', text: 'Chat not found: ' + chatId }], isError: true }; + if (!chat) + return { + content: [{ type: "text", text: "Chat not found: " + chatId }], + isError: true, + }; const client = mtprotoService.getClient(); const entity = chat.username ? chat.username : chat.id; @@ -44,10 +52,12 @@ export async function saveDraft( ); }); - return { content: [{ type: 'text', text: 'Draft saved in chat ' + chatId + '.' }] }; + return { + content: [{ type: "text", text: "Draft saved in chat " + chatId + "." }], + }; } catch (error) { return logAndFormatError( - 'save_draft', + "save_draft", error instanceof Error ? error : new Error(String(error)), ErrorCategory.DRAFT, ); diff --git a/src/lib/mcp/telegram/tools/searchContacts.ts b/src/lib/mcp/telegram/tools/searchContacts.ts index 595314200..e62871126 100644 --- a/src/lib/mcp/telegram/tools/searchContacts.ts +++ b/src/lib/mcp/telegram/tools/searchContacts.ts @@ -1,21 +1,21 @@ -import type { MCPTool, MCPToolResult } from '../../types'; -import type { TelegramMCPContext } from '../types'; -import { ErrorCategory, logAndFormatError } from '../../errorHandler'; -import { mtprotoService } from '../../../../services/mtprotoService'; -import { Api } from 'telegram'; -import { optNumber } from '../args'; -import type { ApiUser } from '../apiResultTypes'; +import type { MCPTool, MCPToolResult } from "../../types"; +import type { TelegramMCPContext } from "../types"; +import { ErrorCategory, logAndFormatError } from "../../errorHandler"; +import { mtprotoService } from "../../../../services/mtprotoService"; +import { Api } from "telegram"; +import { optNumber } from "../args"; +import type { ApiUser } from "../apiResultTypes"; export const tool: MCPTool = { - name: 'search_contacts', - description: 'Search contacts by name or username', + name: "search_contacts", + description: "Search contacts by name or username", inputSchema: { - type: 'object', + type: "object", properties: { - query: { type: 'string', description: 'Search query' }, - limit: { type: 'number', description: 'Max results', default: 20 }, + query: { type: "string", description: "Search query" }, + limit: { type: "number", description: "Max results", default: 20 }, }, - required: ['query'], + required: ["query"], }, }; @@ -24,31 +24,42 @@ export async function searchContacts( _context: TelegramMCPContext, ): Promise { try { - const query = typeof args.query === 'string' ? args.query : ''; + const query = typeof args.query === "string" ? args.query : ""; if (!query) { - return { content: [{ type: 'text', text: 'query is required' }], isError: true }; + return { + content: [{ type: "text", text: "query is required" }], + isError: true, + }; } - const limit = optNumber(args, 'limit', 20); + const limit = optNumber(args, "limit", 20); const client = mtprotoService.getClient(); const result = await mtprotoService.withFloodWaitHandling(async () => { return client.invoke(new Api.contacts.Search({ q: query, limit })); }); - if (!result || !('users' in result) || !Array.isArray(result.users) || result.users.length === 0) { - return { content: [{ type: 'text', text: `No contacts found for "${query}".` }] }; + if ( + !result || + !("users" in result) || + !Array.isArray(result.users) || + result.users.length === 0 + ) { + return { + content: [{ type: "text", text: `No contacts found for "${query}".` }], + }; } const lines = result.users.map((u: ApiUser) => { - const name = [u.firstName, u.lastName].filter(Boolean).join(' ') || 'Unknown'; - const username = u.username ? `@${u.username}` : ''; + const name = + [u.firstName, u.lastName].filter(Boolean).join(" ") || "Unknown"; + const username = u.username ? `@${u.username}` : ""; return `ID: ${u.id} | ${name} ${username}`.trim(); }); - return { content: [{ type: 'text', text: lines.join('\n') }] }; + return { content: [{ type: "text", text: lines.join("\n") }] }; } catch (error) { return logAndFormatError( - 'search_contacts', + "search_contacts", error instanceof Error ? error : new Error(String(error)), ErrorCategory.CONTACT, ); diff --git a/src/lib/mcp/telegram/tools/sendReaction.ts b/src/lib/mcp/telegram/tools/sendReaction.ts index bf2ad45fb..0ea4b42d3 100644 --- a/src/lib/mcp/telegram/tools/sendReaction.ts +++ b/src/lib/mcp/telegram/tools/sendReaction.ts @@ -1,22 +1,22 @@ -import type { MCPTool, MCPToolResult } from '../../types'; -import type { TelegramMCPContext } from '../types'; -import { ErrorCategory, logAndFormatError } from '../../errorHandler'; -import { validateId } from '../../validation'; -import { getChatById } from '../telegramApi'; -import { mtprotoService } from '../../../../services/mtprotoService'; -import { Api } from 'telegram'; +import type { MCPTool, MCPToolResult } from "../../types"; +import type { TelegramMCPContext } from "../types"; +import { ErrorCategory, logAndFormatError } from "../../errorHandler"; +import { validateId } from "../../validation"; +import { getChatById } from "../telegramApi"; +import { mtprotoService } from "../../../../services/mtprotoService"; +import { Api } from "telegram"; export const tool: MCPTool = { - name: 'send_reaction', - description: 'Send a reaction to a message', + name: "send_reaction", + description: "Send a reaction to a message", inputSchema: { - type: 'object', + type: "object", properties: { - chat_id: { type: 'string', description: 'Chat ID or username' }, - message_id: { type: 'number', description: 'Message ID' }, - reaction: { type: 'string', description: 'Reaction emoji' }, + chat_id: { type: "string", description: "Chat ID or username" }, + message_id: { type: "number", description: "Message ID" }, + reaction: { type: "string", description: "Reaction emoji" }, }, - required: ['chat_id', 'message_id'], + required: ["chat_id", "message_id"], }, }; @@ -25,16 +25,29 @@ export async function sendReaction( _context: TelegramMCPContext, ): Promise { try { - const chatId = validateId(args.chat_id, 'chat_id'); - const messageId = typeof args.message_id === 'number' && Number.isInteger(args.message_id) ? args.message_id : undefined; - const emoji = typeof args.reaction === 'string' ? args.reaction : '\ud83d\udc4d'; + const chatId = validateId(args.chat_id, "chat_id"); + const messageId = + typeof args.message_id === "number" && Number.isInteger(args.message_id) + ? args.message_id + : undefined; + const emoji = + typeof args.reaction === "string" ? args.reaction : "\ud83d\udc4d"; if (messageId === undefined) { - return { content: [{ type: 'text', text: 'message_id must be a positive integer' }], isError: true }; + return { + content: [ + { type: "text", text: "message_id must be a positive integer" }, + ], + isError: true, + }; } const chat = getChatById(chatId); - if (!chat) return { content: [{ type: 'text', text: 'Chat not found: ' + chatId }], isError: true }; + if (!chat) + return { + content: [{ type: "text", text: "Chat not found: " + chatId }], + isError: true, + }; const client = mtprotoService.getClient(); const entity = chat.username ? chat.username : chat.id; @@ -50,10 +63,17 @@ export async function sendReaction( ); }); - return { content: [{ type: 'text', text: 'Reaction ' + emoji + ' sent to message ' + messageId + '.' }] }; + return { + content: [ + { + type: "text", + text: "Reaction " + emoji + " sent to message " + messageId + ".", + }, + ], + }; } catch (error) { return logAndFormatError( - 'send_reaction', + "send_reaction", error instanceof Error ? error : new Error(String(error)), ErrorCategory.MSG, ); diff --git a/src/lib/mcp/telegram/tools/setBotCommands.ts b/src/lib/mcp/telegram/tools/setBotCommands.ts index 7d15d0ac1..83e2a7be5 100644 --- a/src/lib/mcp/telegram/tools/setBotCommands.ts +++ b/src/lib/mcp/telegram/tools/setBotCommands.ts @@ -1,9 +1,9 @@ import type { MCPTool, MCPToolResult } from "../../types"; import type { TelegramMCPContext } from "../types"; -import { ErrorCategory, logAndFormatError } from '../../errorHandler'; -import { mtprotoService } from '../../../../services/mtprotoService'; -import { Api } from 'telegram'; -import type { BotCommandInput } from '../apiResultTypes'; +import { ErrorCategory, logAndFormatError } from "../../errorHandler"; +import { mtprotoService } from "../../../../services/mtprotoService"; +import { Api } from "telegram"; +import type { BotCommandInput } from "../apiResultTypes"; export const tool: MCPTool = { name: "set_bot_commands", @@ -24,28 +24,43 @@ export async function setBotCommands( ): Promise { try { const cmds = Array.isArray(args.commands) ? args.commands : []; - if (cmds.length === 0) return { content: [{ type: 'text', text: 'commands array is required' }], isError: true }; + if (cmds.length === 0) + return { + content: [{ type: "text", text: "commands array is required" }], + isError: true, + }; const client = mtprotoService.getClient(); - const botCommands = cmds.map((c: BotCommandInput) => - new Api.BotCommand({ command: String(c.command ?? ''), description: String(c.description ?? '') }), + const botCommands = cmds.map( + (c: BotCommandInput) => + new Api.BotCommand({ + command: String(c.command ?? ""), + description: String(c.description ?? ""), + }), ); await mtprotoService.withFloodWaitHandling(async () => { await client.invoke( new Api.bots.SetBotCommands({ scope: new Api.BotCommandScopeDefault(), - langCode: '', + langCode: "", commands: botCommands, }), ); }); - return { content: [{ type: 'text', text: 'Bot commands updated: ' + cmds.length + ' commands.' }] }; + return { + content: [ + { + type: "text", + text: "Bot commands updated: " + cmds.length + " commands.", + }, + ], + }; } catch (error) { return logAndFormatError( - 'set_bot_commands', + "set_bot_commands", error instanceof Error ? error : new Error(String(error)), ErrorCategory.PROFILE, ); diff --git a/src/lib/mcp/telegram/tools/setPrivacySettings.ts b/src/lib/mcp/telegram/tools/setPrivacySettings.ts index 190dd4fa9..98c84d94c 100644 --- a/src/lib/mcp/telegram/tools/setPrivacySettings.ts +++ b/src/lib/mcp/telegram/tools/setPrivacySettings.ts @@ -1,8 +1,8 @@ import type { MCPTool, MCPToolResult } from "../../types"; import type { TelegramMCPContext } from "../types"; -import { ErrorCategory, logAndFormatError } from '../../errorHandler'; -import { mtprotoService } from '../../../../services/mtprotoService'; -import { Api } from 'telegram'; +import { ErrorCategory, logAndFormatError } from "../../errorHandler"; +import { mtprotoService } from "../../../../services/mtprotoService"; +import { Api } from "telegram"; export const tool: MCPTool = { name: "set_privacy_settings", @@ -22,11 +22,19 @@ export async function setPrivacySettings( _context: TelegramMCPContext, ): Promise { try { - const keyStr = typeof args.setting === 'string' ? args.setting : ''; - const ruleStr = typeof args.value === 'string' ? args.value : ''; + const keyStr = typeof args.setting === "string" ? args.setting : ""; + const ruleStr = typeof args.value === "string" ? args.value : ""; - if (!keyStr) return { content: [{ type: 'text', text: 'setting is required' }], isError: true }; - if (!ruleStr) return { content: [{ type: 'text', text: 'value is required' }], isError: true }; + if (!keyStr) + return { + content: [{ type: "text", text: "setting is required" }], + isError: true, + }; + if (!ruleStr) + return { + content: [{ type: "text", text: "value is required" }], + isError: true, + }; const client = mtprotoService.getClient(); @@ -41,7 +49,10 @@ export async function setPrivacySettings( const key = keyMap[keyStr]; if (!key) { - return { content: [{ type: 'text', text: 'Unknown privacy key: ' + keyStr }], isError: true }; + return { + content: [{ type: "text", text: "Unknown privacy key: " + keyStr }], + isError: true, + }; } const ruleMap: Record = { @@ -52,17 +63,35 @@ export async function setPrivacySettings( const rule = ruleMap[ruleStr]; if (!rule) { - return { content: [{ type: 'text', text: 'Unknown rule: ' + ruleStr + '. Valid: allow_all, allow_contacts, disallow_all' }], isError: true }; + return { + content: [ + { + type: "text", + text: + "Unknown rule: " + + ruleStr + + ". Valid: allow_all, allow_contacts, disallow_all", + }, + ], + isError: true, + }; } await mtprotoService.withFloodWaitHandling(async () => { await client.invoke(new Api.account.SetPrivacy({ key, rules: [rule] })); }); - return { content: [{ type: 'text', text: 'Privacy setting ' + keyStr + ' set to ' + ruleStr + '.' }] }; + return { + content: [ + { + type: "text", + text: "Privacy setting " + keyStr + " set to " + ruleStr + ".", + }, + ], + }; } catch (error) { return logAndFormatError( - 'set_privacy_settings', + "set_privacy_settings", error instanceof Error ? error : new Error(String(error)), ErrorCategory.PROFILE, ); diff --git a/src/lib/mcp/telegram/tools/setProfilePhoto.ts b/src/lib/mcp/telegram/tools/setProfilePhoto.ts index d31d9e863..952a2724a 100644 --- a/src/lib/mcp/telegram/tools/setProfilePhoto.ts +++ b/src/lib/mcp/telegram/tools/setProfilePhoto.ts @@ -7,9 +7,9 @@ export const tool: MCPTool = { inputSchema: { type: "object", properties: { - file_path: { type: 'string', description: 'Path to the photo file' }, + file_path: { type: "string", description: "Path to the photo file" }, }, - required: ['file_path'], + required: ["file_path"], }, }; @@ -18,7 +18,12 @@ export async function setProfilePhoto( _context: TelegramMCPContext, ): Promise { return { - content: [{ type: 'text', text: 'set_profile_photo requires file upload which is not supported via MCP text interface. Use the Telegram client directly.' }], + content: [ + { + type: "text", + text: "set_profile_photo requires file upload which is not supported via MCP text interface. Use the Telegram client directly.", + }, + ], isError: true, }; } diff --git a/src/lib/mcp/telegram/tools/unbanUser.ts b/src/lib/mcp/telegram/tools/unbanUser.ts index f780e882d..655e85463 100644 --- a/src/lib/mcp/telegram/tools/unbanUser.ts +++ b/src/lib/mcp/telegram/tools/unbanUser.ts @@ -1,22 +1,22 @@ -import type { MCPTool, MCPToolResult } from '../../types'; -import type { TelegramMCPContext } from '../types'; -import { ErrorCategory, logAndFormatError } from '../../errorHandler'; -import { validateId } from '../../validation'; -import { getChatById } from '../telegramApi'; -import { mtprotoService } from '../../../../services/mtprotoService'; -import { Api } from 'telegram'; -import { toInputChannel, toInputPeer } from '../apiCastHelpers'; +import type { MCPTool, MCPToolResult } from "../../types"; +import type { TelegramMCPContext } from "../types"; +import { ErrorCategory, logAndFormatError } from "../../errorHandler"; +import { validateId } from "../../validation"; +import { getChatById } from "../telegramApi"; +import { mtprotoService } from "../../../../services/mtprotoService"; +import { Api } from "telegram"; +import { toInputChannel, toInputPeer } from "../apiCastHelpers"; export const tool: MCPTool = { - name: 'unban_user', - description: 'Unban a user from a group or channel', + name: "unban_user", + description: "Unban a user from a group or channel", inputSchema: { - type: 'object', + type: "object", properties: { - chat_id: { type: 'string', description: 'Chat ID or username' }, - user_id: { type: 'string', description: 'User ID to unban' }, + chat_id: { type: "string", description: "Chat ID or username" }, + user_id: { type: "string", description: "User ID to unban" }, }, - required: ['chat_id', 'user_id'], + required: ["chat_id", "user_id"], }, }; @@ -25,14 +25,26 @@ export async function unbanUser( _context: TelegramMCPContext, ): Promise { try { - const chatId = validateId(args.chat_id, 'chat_id'); - const userId = validateId(args.user_id, 'user_id'); + const chatId = validateId(args.chat_id, "chat_id"); + const userId = validateId(args.user_id, "user_id"); const chat = getChatById(chatId); - if (!chat) return { content: [{ type: 'text', text: `Chat not found: ${chatId}` }], isError: true }; + if (!chat) + return { + content: [{ type: "text", text: `Chat not found: ${chatId}` }], + isError: true, + }; - if (chat.type !== 'channel' && chat.type !== 'supergroup') { - return { content: [{ type: 'text', text: 'Unban is only available for channels/supergroups.' }], isError: true }; + if (chat.type !== "channel" && chat.type !== "supergroup") { + return { + content: [ + { + type: "text", + text: "Unban is only available for channels/supergroups.", + }, + ], + isError: true, + }; } const client = mtprotoService.getClient(); @@ -50,10 +62,17 @@ export async function unbanUser( ); }); - return { content: [{ type: 'text', text: `User ${userId} unbanned from ${chat.title ?? chatId}.` }] }; + return { + content: [ + { + type: "text", + text: `User ${userId} unbanned from ${chat.title ?? chatId}.`, + }, + ], + }; } catch (error) { return logAndFormatError( - 'unban_user', + "unban_user", error instanceof Error ? error : new Error(String(error)), ErrorCategory.ADMIN, ); diff --git a/src/lib/mcp/telegram/tools/unblockUser.ts b/src/lib/mcp/telegram/tools/unblockUser.ts index 9fe2057ea..5fc2026bb 100644 --- a/src/lib/mcp/telegram/tools/unblockUser.ts +++ b/src/lib/mcp/telegram/tools/unblockUser.ts @@ -1,20 +1,20 @@ -import type { MCPTool, MCPToolResult } from '../../types'; -import type { TelegramMCPContext } from '../types'; -import { ErrorCategory, logAndFormatError } from '../../errorHandler'; -import { validateId } from '../../validation'; -import { mtprotoService } from '../../../../services/mtprotoService'; -import { Api } from 'telegram'; -import { toInputPeer } from '../apiCastHelpers'; +import type { MCPTool, MCPToolResult } from "../../types"; +import type { TelegramMCPContext } from "../types"; +import { ErrorCategory, logAndFormatError } from "../../errorHandler"; +import { validateId } from "../../validation"; +import { mtprotoService } from "../../../../services/mtprotoService"; +import { Api } from "telegram"; +import { toInputPeer } from "../apiCastHelpers"; export const tool: MCPTool = { - name: 'unblock_user', - description: 'Unblock a user', + name: "unblock_user", + description: "Unblock a user", inputSchema: { - type: 'object', + type: "object", properties: { - user_id: { type: 'string', description: 'User ID to unblock' }, + user_id: { type: "string", description: "User ID to unblock" }, }, - required: ['user_id'], + required: ["user_id"], }, }; @@ -23,7 +23,7 @@ export async function unblockUser( _context: TelegramMCPContext, ): Promise { try { - const userId = validateId(args.user_id, 'user_id'); + const userId = validateId(args.user_id, "user_id"); const client = mtprotoService.getClient(); await mtprotoService.withFloodWaitHandling(async () => { @@ -33,10 +33,10 @@ export async function unblockUser( ); }); - return { content: [{ type: 'text', text: `User ${userId} unblocked.` }] }; + return { content: [{ type: "text", text: `User ${userId} unblocked.` }] }; } catch (error) { return logAndFormatError( - 'unblock_user', + "unblock_user", error instanceof Error ? error : new Error(String(error)), ErrorCategory.CONTACT, ); diff --git a/src/lib/mcp/telegram/tools/unpinMessage.ts b/src/lib/mcp/telegram/tools/unpinMessage.ts index 77dd1c2c3..cc0a3c49a 100644 --- a/src/lib/mcp/telegram/tools/unpinMessage.ts +++ b/src/lib/mcp/telegram/tools/unpinMessage.ts @@ -1,21 +1,21 @@ -import type { MCPTool, MCPToolResult } from '../../types'; -import type { TelegramMCPContext } from '../types'; -import { ErrorCategory, logAndFormatError } from '../../errorHandler'; -import { getChatById } from '../telegramApi'; -import { validateId } from '../../validation'; -import { mtprotoService } from '../../../../services/mtprotoService'; -import { Api } from 'telegram'; +import type { MCPTool, MCPToolResult } from "../../types"; +import type { TelegramMCPContext } from "../types"; +import { ErrorCategory, logAndFormatError } from "../../errorHandler"; +import { getChatById } from "../telegramApi"; +import { validateId } from "../../validation"; +import { mtprotoService } from "../../../../services/mtprotoService"; +import { Api } from "telegram"; export const tool: MCPTool = { - name: 'unpin_message', - description: 'Unpin a message in a chat', + name: "unpin_message", + description: "Unpin a message in a chat", inputSchema: { - type: 'object', + type: "object", properties: { - chat_id: { type: 'string', description: 'Chat ID or username' }, - message_id: { type: 'number', description: 'Message ID' }, + chat_id: { type: "string", description: "Chat ID or username" }, + message_id: { type: "number", description: "Message ID" }, }, - required: ['chat_id', 'message_id'], + required: ["chat_id", "message_id"], }, }; @@ -24,21 +24,27 @@ export async function unpinMessage( _context: TelegramMCPContext, ): Promise { try { - const chatId = validateId(args.chat_id, 'chat_id'); - const messageId = typeof args.message_id === 'number' && Number.isInteger(args.message_id) - ? args.message_id - : undefined; + const chatId = validateId(args.chat_id, "chat_id"); + const messageId = + typeof args.message_id === "number" && Number.isInteger(args.message_id) + ? args.message_id + : undefined; if (messageId === undefined) { return { - content: [{ type: 'text', text: 'message_id must be a positive integer' }], + content: [ + { type: "text", text: "message_id must be a positive integer" }, + ], isError: true, }; } const chat = getChatById(chatId); if (!chat) { - return { content: [{ type: 'text', text: `Chat not found: ${chatId}` }], isError: true }; + return { + content: [{ type: "text", text: `Chat not found: ${chatId}` }], + isError: true, + }; } const entity = chat.username ? chat.username : chat.id; @@ -56,11 +62,16 @@ export async function unpinMessage( }); return { - content: [{ type: 'text', text: `Message ${messageId} unpinned in chat ${chatId}.` }], + content: [ + { + type: "text", + text: `Message ${messageId} unpinned in chat ${chatId}.`, + }, + ], }; } catch (error) { return logAndFormatError( - 'unpin_message', + "unpin_message", error instanceof Error ? error : new Error(String(error)), ErrorCategory.MSG, ); diff --git a/src/lib/mcp/telegram/tools/updateProfile.ts b/src/lib/mcp/telegram/tools/updateProfile.ts index 3c0ae897f..b4d2f4c3e 100644 --- a/src/lib/mcp/telegram/tools/updateProfile.ts +++ b/src/lib/mcp/telegram/tools/updateProfile.ts @@ -1,19 +1,19 @@ -import type { MCPTool, MCPToolResult } from '../../types'; -import type { TelegramMCPContext } from '../types'; -import { ErrorCategory, logAndFormatError } from '../../errorHandler'; -import { mtprotoService } from '../../../../services/mtprotoService'; -import { Api } from 'telegram'; -import { optString } from '../args'; +import type { MCPTool, MCPToolResult } from "../../types"; +import type { TelegramMCPContext } from "../types"; +import { ErrorCategory, logAndFormatError } from "../../errorHandler"; +import { mtprotoService } from "../../../../services/mtprotoService"; +import { Api } from "telegram"; +import { optString } from "../args"; export const tool: MCPTool = { - name: 'update_profile', - description: 'Update your Telegram profile', + name: "update_profile", + description: "Update your Telegram profile", inputSchema: { - type: 'object', + type: "object", properties: { - first_name: { type: 'string', description: 'New first name' }, - last_name: { type: 'string', description: 'New last name' }, - about: { type: 'string', description: 'New bio/about text' }, + first_name: { type: "string", description: "New first name" }, + last_name: { type: "string", description: "New last name" }, + about: { type: "string", description: "New bio/about text" }, }, }, }; @@ -23,12 +23,20 @@ export async function updateProfile( _context: TelegramMCPContext, ): Promise { try { - const firstName = optString(args, 'first_name'); - const lastName = optString(args, 'last_name'); - const about = optString(args, 'about'); + const firstName = optString(args, "first_name"); + const lastName = optString(args, "last_name"); + const about = optString(args, "about"); if (!firstName && !lastName && !about) { - return { content: [{ type: 'text', text: 'At least one of first_name, last_name, or about is required.' }], isError: true }; + return { + content: [ + { + type: "text", + text: "At least one of first_name, last_name, or about is required.", + }, + ], + isError: true, + }; } const client = mtprotoService.getClient(); @@ -44,14 +52,18 @@ export async function updateProfile( }); const updates: string[] = []; - if (firstName) updates.push('first_name: ' + firstName); - if (lastName) updates.push('last_name: ' + lastName); - if (about) updates.push('about: ' + about); + if (firstName) updates.push("first_name: " + firstName); + if (lastName) updates.push("last_name: " + lastName); + if (about) updates.push("about: " + about); - return { content: [{ type: 'text', text: 'Profile updated: ' + updates.join(', ') }] }; + return { + content: [ + { type: "text", text: "Profile updated: " + updates.join(", ") }, + ], + }; } catch (error) { return logAndFormatError( - 'update_profile', + "update_profile", error instanceof Error ? error : new Error(String(error)), ErrorCategory.PROFILE, ); diff --git a/src/lib/mcp/transport.ts b/src/lib/mcp/transport.ts index a3deb15c7..5d79c5f83 100644 --- a/src/lib/mcp/transport.ts +++ b/src/lib/mcp/transport.ts @@ -3,14 +3,17 @@ * Handles communication between frontend MCP server and backend MCP client */ -import type { Socket } from 'socket.io-client'; -import type { MCPRequest, MCPResponse, SocketIOMCPTransport } from './types'; -import { mcpWarn } from './logger'; +import type { Socket } from "socket.io-client"; +import type { MCPRequest, MCPResponse, SocketIOMCPTransport } from "./types"; +import { mcpWarn } from "./logger"; export class SocketIOMCPTransportImpl implements SocketIOMCPTransport { private socket: Socket | null | undefined; - private requestHandlers = new Map void>(); - private readonly eventPrefix = 'mcp:'; + private requestHandlers = new Map< + string | number, + (response: MCPResponse) => void + >(); + private readonly eventPrefix = "mcp:"; private responseHandler = (response: MCPResponse): void => { const handler = this.requestHandlers.get(response.id); if (handler) { @@ -35,7 +38,7 @@ export class SocketIOMCPTransportImpl implements SocketIOMCPTransport { emit(event: string, data: unknown): void { if (!this.socket?.connected) { - mcpWarn('Cannot emit MCP event: socket not connected', { event }); + mcpWarn("Cannot emit MCP event: socket not connected", { event }); return; } this.socket.emit(`${this.eventPrefix}${event}`, data); @@ -53,7 +56,7 @@ export class SocketIOMCPTransportImpl implements SocketIOMCPTransport { async request(request: MCPRequest, timeoutMs = 30000): Promise { if (!this.socket?.connected) { - throw new Error('Socket not connected'); + throw new Error("Socket not connected"); } return new Promise((resolve, reject) => { @@ -71,7 +74,7 @@ export class SocketIOMCPTransportImpl implements SocketIOMCPTransport { } }); - this.emit('request', request); + this.emit("request", request); }); } diff --git a/src/lib/mcp/types.ts b/src/lib/mcp/types.ts index 1fae99607..5dfaf0b3a 100644 --- a/src/lib/mcp/types.ts +++ b/src/lib/mcp/types.ts @@ -8,7 +8,7 @@ export interface MCPServerConfig { } export interface MCPToolInputSchema { - type: 'object'; + type: "object"; properties: Record; required?: string[]; } @@ -27,21 +27,21 @@ export interface MCPToolCall { export interface MCPToolResult { content: Array<{ - type: 'text'; + type: "text"; text: string; }>; isError?: boolean; } export interface MCPRequest { - jsonrpc: '2.0'; + jsonrpc: "2.0"; id: string | number; method: string; params?: unknown; } export interface MCPResponse { - jsonrpc: '2.0'; + jsonrpc: "2.0"; id: string | number; result?: unknown; error?: { diff --git a/src/lib/mcp/validation.ts b/src/lib/mcp/validation.ts index 2ed962eb1..4b0cb8a50 100644 --- a/src/lib/mcp/validation.ts +++ b/src/lib/mcp/validation.ts @@ -5,7 +5,7 @@ export class ValidationError extends Error { constructor(message: string) { super(message); - this.name = 'ValidationError'; + this.name = "ValidationError"; } } @@ -13,11 +13,8 @@ export class ValidationError extends Error { * Validate chat_id or user_id parameter * Supports integer IDs, string IDs, and usernames */ -export function validateId( - value: unknown, - paramName: string, -): number | string { - if (typeof value === 'number') { +export function validateId(value: unknown, paramName: string): number | string { + if (typeof value === "number") { if (!Number.isInteger(value) || value < -(2 ** 63) || value > 2 ** 63 - 1) { throw new ValidationError( `Invalid ${paramName}: ${value}. ID is out of the valid integer range.`, @@ -26,7 +23,7 @@ export function validateId( return value; } - if (typeof value === 'string') { + if (typeof value === "string") { const intValue = Number.parseInt(value, 10); if (!Number.isNaN(intValue) && Number.isFinite(intValue)) { if (intValue < -(2 ** 63) || intValue > 2 ** 63 - 1) { @@ -38,7 +35,7 @@ export function validateId( } if (/^@?[a-zA-Z0-9_]{5,}$/.test(value)) { - return value.startsWith('@') ? value : `@${value}`; + return value.startsWith("@") ? value : `@${value}`; } throw new ValidationError( @@ -59,9 +56,7 @@ export function validateIdList( paramName: string, ): Array { if (!Array.isArray(value)) { - throw new ValidationError( - `Invalid ${paramName}: must be an array of IDs.`, - ); + throw new ValidationError(`Invalid ${paramName}: must be an array of IDs.`); } return value.map((item: unknown, index: number) => { diff --git a/src/main.tsx b/src/main.tsx index e3c4ff517..f9b5e4b33 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -7,9 +7,9 @@ import App from "./App"; import "./index.css"; // Deep link listener - lazy import to avoid running before Tauri IPC is ready -import('./utils/desktopDeepLinkListener').then(m => { - m.setupDesktopDeepLinkListener().catch(err => { - console.error('[DeepLink] setup error:', err); +import("./utils/desktopDeepLinkListener").then((m) => { + m.setupDesktopDeepLinkListener().catch((err) => { + console.error("[DeepLink] setup error:", err); }); }); diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index ea555cea8..832e72d1c 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -1,20 +1,33 @@ -import { useNavigate } from 'react-router-dom'; -import { openUrl } from '../utils/openUrl'; -import { TELEGRAM_BOT_USERNAME } from '../utils/config'; -import ConnectionIndicator from '../components/ConnectionIndicator'; -import TelegramConnectionIndicator from '../components/TelegramConnectionIndicator'; -import GmailConnectionIndicator from '../components/GmailConnectionIndicator'; -import { useUser } from '../hooks/useUser'; +import { useNavigate } from "react-router-dom"; +import { openUrl } from "../utils/openUrl"; +import { TELEGRAM_BOT_USERNAME } from "../utils/config"; +import ConnectionIndicator from "../components/ConnectionIndicator"; +import TelegramConnectionIndicator from "../components/TelegramConnectionIndicator"; +import GmailConnectionIndicator from "../components/GmailConnectionIndicator"; +import { useUser } from "../hooks/useUser"; const Home = () => { const navigate = useNavigate(); const { user } = useUser(); - const userName = user?.firstName || 'User'; + const userName = user?.firstName || "User"; // Get current date const getCurrentDate = () => { - const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; - const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + const months = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + ]; const now = new Date(); return `${days[now.getDay()]}, ${months[now.getMonth()]} ${now.getDate()}`; }; @@ -22,9 +35,9 @@ const Home = () => { // Get greeting based on time const getGreeting = () => { const hour = new Date().getHours(); - if (hour < 12) return 'Good morning'; - if (hour < 18) return 'Good afternoon'; - return 'Good evening'; + if (hour < 12) return "Good morning"; + if (hour < 18) return "Good afternoon"; + return "Good evening"; }; // Handle Telegram bot link @@ -33,10 +46,9 @@ const Home = () => { }; const handleManageConnections = () => { - navigate('/settings'); + navigate("/settings"); }; - return (
{/* Content overlay */} @@ -75,13 +87,30 @@ const Home = () => { onClick={handleManageConnections} className="w-full flex items-center justify-between p-3 bg-black/50 hover:bg-stone-800/30 transition-all duration-200 text-left rounded-3xl focus:outline-none" > - - - + + +
Settings
-

Manage connections, privacy, profile, and app settings

+

+ Manage connections, privacy, profile, and app settings +

diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index a8419713b..3cc8e5b56 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -1,37 +1,57 @@ -import { useEffect } from 'react'; -import { useNavigate, useSearchParams } from 'react-router-dom'; -import { useAppDispatch } from '../store/hooks'; -import { setToken } from '../store/authSlice'; +import { useEffect, useState } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { useAppDispatch } from "../store/hooks"; +import { setToken } from "../store/authSlice"; +import { consumeLoginToken } from "../services/api/authApi"; const Login = () => { const navigate = useNavigate(); const [searchParams] = useSearchParams(); const dispatch = useAppDispatch(); + const [consumeError, setConsumeError] = useState(null); - // Handle JWT token from URL query parameter (backend redirect) + // Handle login token from URL (e.g. from Telegram bot "Open AlphaHuman" button) + // Consume the token with the backend and store the returned JWT useEffect(() => { - const token = searchParams.get('token'); + const loginToken = searchParams.get("token"); + if (!loginToken) return; - if (token) { + let cancelled = false; - // Store the JWT token from the backend - dispatch(setToken(token)); + (async () => { + setConsumeError(null); + try { + const jwtToken = await consumeLoginToken(loginToken); + if (cancelled) return; - // Clear the token from URL for security - const newSearchParams = new URLSearchParams(searchParams); - newSearchParams.delete('token'); - const newSearch = newSearchParams.toString(); - const newUrl = newSearch ? `${window.location.pathname}?${newSearch}` : window.location.pathname; - window.history.replaceState({}, '', newUrl); + dispatch(setToken(jwtToken)); + navigate("/onboarding/", { replace: true }); + } catch (err) { + if (!cancelled) { + setConsumeError(err instanceof Error ? err.message : "Login failed"); + } + } + })(); - // Navigate to onboarding after successful login - setTimeout(() => { - navigate('/onboarding/'); - }, 100); - } + return () => { + cancelled = true; + }; }, [searchParams, dispatch, navigate]); - + if (consumeError) { + return ( +
+
+
+

{consumeError}

+

+ Get a new link by sending '/start login' to the AlphaHuman bot on Telegram. +

+
+
+
+ ); + } return (
diff --git a/src/pages/Welcome.tsx b/src/pages/Welcome.tsx index 378adc713..28656da36 100644 --- a/src/pages/Welcome.tsx +++ b/src/pages/Welcome.tsx @@ -1,5 +1,5 @@ -import TypewriterGreeting from '../components/TypewriterGreeting'; -import TelegramLoginButton from '../components/TelegramLoginButton'; +import TypewriterGreeting from "../components/TypewriterGreeting"; +import TelegramLoginButton from "../components/TelegramLoginButton"; const Welcome = () => { const greetings = [ @@ -15,8 +15,6 @@ const Welcome = () => { // "Ready to go to the moon? Pack light! 🌙🚀" ]; - - return (
{/* Main content */} @@ -29,7 +27,8 @@ const Welcome = () => { {/*
*/}

- Welcome to AlphaHuman. Your Telegram assistant here to get you 10x more done in your crypto journey. + Welcome to AlphaHuman. Your Telegram assistant here to get you 10x + more done in your crypto journey.

diff --git a/src/pages/onboarding/Onboarding.tsx b/src/pages/onboarding/Onboarding.tsx index 91594be4a..e678bc084 100644 --- a/src/pages/onboarding/Onboarding.tsx +++ b/src/pages/onboarding/Onboarding.tsx @@ -1,28 +1,30 @@ -import { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { useAppDispatch } from '../../store/hooks'; -import { setOnboarded } from '../../store/authSlice'; -import ProgressIndicator from '../../components/ProgressIndicator'; -import LottieAnimation from '../../components/LottieAnimation'; -import FeaturesStep from './steps/FeaturesStep'; -import PrivacyStep from './steps/PrivacyStep'; -import AnalyticsStep from './steps/AnalyticsStep'; -import ConnectStep from './steps/ConnectStep'; -import GetStartedStep from './steps/GetStartedStep'; +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useAppDispatch, useAppSelector } from "../../store/hooks"; +import { setOnboardedForUser } from "../../store/authSlice"; +import { userApi } from "../../services/api/userApi"; +import ProgressIndicator from "../../components/ProgressIndicator"; +import LottieAnimation from "../../components/LottieAnimation"; +import FeaturesStep from "./steps/FeaturesStep"; +import PrivacyStep from "./steps/PrivacyStep"; +import AnalyticsStep from "./steps/AnalyticsStep"; +import ConnectStep from "./steps/ConnectStep"; +import GetStartedStep from "./steps/GetStartedStep"; const Onboarding = () => { const navigate = useNavigate(); const dispatch = useAppDispatch(); + const user = useAppSelector((state) => state.user.user); const [currentStep, setCurrentStep] = useState(1); const totalSteps = 5; // Lottie animation files for each step const stepAnimations = [ - '/lottie/wave.json', // Step 1 - Features - '/lottie/safe3.json', // Step 2 - Privacy - '/lottie/analytics.json', // Step 3 - Analytics - '/lottie/connect2.json', // Step 4 - Connect - '/lottie/trophy.json', // Step 5 - Get Started + "/lottie/wave.json", // Step 1 - Features + "/lottie/safe3.json", // Step 2 - Privacy + "/lottie/analytics.json", // Step 3 - Analytics + "/lottie/connect2.json", // Step 4 - Connect + "/lottie/trophy.json", // Step 5 - Get Started ]; const handleNext = () => { @@ -31,9 +33,23 @@ const Onboarding = () => { } }; - const handleComplete = () => { - dispatch(setOnboarded(true)); - navigate('/home'); + const handleComplete = async () => { + try { + await userApi.onboardingComplete(); + } catch (e) { + const msg = + e && + typeof e === "object" && + "error" in e && + typeof (e as { error: unknown }).error === "string" + ? (e as { error: string }).error + : "Failed to complete onboarding. Please try again."; + throw new Error(msg); + } + if (user?._id) { + dispatch(setOnboardedForUser({ userId: user._id, value: true })); + } + navigate("/home"); }; const renderStep = () => { @@ -57,7 +73,11 @@ const Onboarding = () => {

- +
{renderStep()} diff --git a/src/pages/onboarding/steps/AnalyticsStep.tsx b/src/pages/onboarding/steps/AnalyticsStep.tsx index e5ab08872..3edf9e57e 100644 --- a/src/pages/onboarding/steps/AnalyticsStep.tsx +++ b/src/pages/onboarding/steps/AnalyticsStep.tsx @@ -1,76 +1,87 @@ -import { useState } from 'react'; +import { useState } from "react"; interface AnalyticsStepProps { onNext: () => void; } const AnalyticsStep = ({ onNext }: AnalyticsStepProps) => { - const [selectedOption, setSelectedOption] = useState('shareAnalytics'); + const [selectedOption, setSelectedOption] = useState("shareAnalytics"); return (

Analytics

- We collect anonymized usage data to help us improve the app for you and for others. You can choose to skip this. + We collect anonymized usage data to help us improve the app for you + and for others. You can choose to skip this.

setSelectedOption('shareAnalytics')} + className={`p-4 rounded-xl border-2 cursor-pointer transition-all ${ + selectedOption === "shareAnalytics" + ? "border-primary-500 bg-primary-500/20" + : "border-stone-700 bg-black/50 hover:border-stone-600" + }`} + onClick={() => setSelectedOption("shareAnalytics")} >
- {selectedOption === 'shareAnalytics' && ( + {selectedOption === "shareAnalytics" && (
)}
-

Share Anonymized Usage Data

+

+ Share Anonymized Usage Data +

- Share anonymized usage data to help us improve features and performance of the app. - This helps us improve the app for you and for others. + Share anonymized usage data to help us improve features and + performance of the app. This helps us improve the app for you + and for others.

setSelectedOption('maximumPrivacy')} + className={`p-4 rounded-xl border-2 cursor-pointer transition-all ${ + selectedOption === "maximumPrivacy" + ? "border-primary-500 bg-primary-500/20" + : "border-stone-700 bg-black/50 hover:border-stone-600" + }`} + onClick={() => setSelectedOption("maximumPrivacy")} >
- {selectedOption === 'maximumPrivacy' && ( + {selectedOption === "maximumPrivacy" && (
)}
-

Don't Collect Anything

+

+ Don't Collect Anything +

- We won't collect any usage analytics, ensuring total anonymity. Keep all your data completely private. + We won't collect any usage analytics, ensuring total anonymity. + Keep all your data completely private.

@@ -79,12 +90,24 @@ const AnalyticsStep = ({ onNext }: AnalyticsStepProps) => {
- - + +
-

You can change this setting anytime

-

Your privacy preferences can be updated in your account settings

+

+ You can change this setting anytime +

+

+ Your privacy preferences can be updated in your account settings +

diff --git a/src/pages/onboarding/steps/ConnectStep.tsx b/src/pages/onboarding/steps/ConnectStep.tsx index 1fa83da8d..45db59f00 100644 --- a/src/pages/onboarding/steps/ConnectStep.tsx +++ b/src/pages/onboarding/steps/ConnectStep.tsx @@ -1,14 +1,16 @@ -import { useState, useMemo } from 'react'; -import { useAppSelector } from '../../../store/hooks'; -import { selectIsAuthenticated } from '../../../store/telegramSelectors'; -import GoogleIcon from '../../../assets/icons/GoogleIcon'; -import TelegramConnectionModal from '../../../components/TelegramConnectionModal'; - -import BinanceIcon from '../../../assets/icons/binance.svg'; -import NotionIcon from '../../../assets/icons/notion.svg'; -import TelegramIcon from '../../../assets/icons/telegram.svg'; -import MetamaskIcon from '../../../assets/icons/metamask.svg'; +import { useState } from "react"; +import { useAppSelector } from "../../../store/hooks"; +import { + selectIsAuthenticated, + selectSessionString, +} from "../../../store/telegramSelectors"; +import GoogleIcon from "../../../assets/icons/GoogleIcon"; +import TelegramConnectionModal from "../../../components/TelegramConnectionModal"; +import BinanceIcon from "../../../assets/icons/binance.svg"; +import NotionIcon from "../../../assets/icons/notion.svg"; +import TelegramIcon from "../../../assets/icons/telegram.svg"; +import MetamaskIcon from "../../../assets/icons/metamask.svg"; interface ConnectStepProps { onNext: () => void; @@ -22,30 +24,19 @@ interface ConnectOption { comingSoon?: boolean; } -// Helper to check if there's a saved session in localStorage -const hasSavedSession = (): boolean => { - try { - return !!localStorage.getItem('telegram_session'); - } catch { - return false; - } -}; - const ConnectStep = ({ onNext }: ConnectStepProps) => { const [isTelegramModalOpen, setIsTelegramModalOpen] = useState(false); const isTelegramAuthenticated = useAppSelector(selectIsAuthenticated); - const sessionString = useAppSelector((state) => state.telegram.sessionString); + const sessionString = useAppSelector(selectSessionString); - // Check if Telegram account is connected (authenticated or has saved session) - const isTelegramConnected = useMemo(() => { - return isTelegramAuthenticated || !!sessionString || hasSavedSession(); - }, [isTelegramAuthenticated, sessionString]); + const isTelegramConnected = !!sessionString && isTelegramAuthenticated; // Check if an account is connected const isAccountConnected = (accountId: string): boolean => { - if (accountId === 'telegram') { + if (accountId === "telegram") { return isTelegramConnected; } + // Add other account checks here when implemented return false; }; @@ -62,13 +53,13 @@ const ConnectStep = ({ onNext }: ConnectStepProps) => { // In a real app, this would handle OAuth console.log(`Connecting to ${provider}`); - if (provider === 'telegram') { + if (provider === "telegram") { setIsTelegramModalOpen(true); return; } // Don't auto-advance for coming soon items - if (!connectOptions.find(opt => opt.id === provider)?.comingSoon) { + if (!connectOptions.find((opt) => opt.id === provider)?.comingSoon) { onNext(); } }; @@ -80,36 +71,36 @@ const ConnectStep = ({ onNext }: ConnectStepProps) => { const connectOptions: ConnectOption[] = [ { - id: 'telegram', - name: 'Telegram', - description: 'Organize chats, automate messages and get insights.', + id: "telegram", + name: "Telegram", + description: "Organize chats, automate messages and get insights.", icon: Telegram, }, { - id: 'google', - name: 'Google', - description: 'Manage emails, contacts and calendar events', + id: "google", + name: "Google", + description: "Manage emails, contacts and calendar events", icon: , comingSoon: true, }, { - id: 'notion', - name: 'Notion', - description: 'Manage tasks, documents and everything else in your Notion', + id: "notion", + name: "Notion", + description: "Manage tasks, documents and everything else in your Notion", icon: Notion, comingSoon: true, }, { - id: 'wallet', - name: 'Web3 Wallet', - description: 'Trade the trenches in a safe and secure way.', + id: "wallet", + name: "Web3 Wallet", + description: "Trade the trenches in a safe and secure way.", icon: Metamask, comingSoon: true, }, { - id: 'exchange', - name: 'Crypto Trading Exchanges', - description: 'Connect tand make trades with deep insights.', + id: "exchange", + name: "Crypto Trading Exchanges", + description: "Connect tand make trades with deep insights.", icon: Binance, comingSoon: true, }, @@ -120,7 +111,8 @@ const ConnectStep = ({ onNext }: ConnectStepProps) => {

Connect Accounts

- The more accounts you connect, the more powerful the intelligence will be. + The more accounts you connect, the more powerful the intelligence will + be.

@@ -135,8 +127,8 @@ const ConnectStep = ({ onNext }: ConnectStepProps) => { onClick={() => handleConnect(option.id)} disabled={isDisabled} className={`w-full flex items-start space-x-3 p-3 bg-black/50 border border-stone-700 rounded-xl transition-all duration-200 text-left ${isDisabled - ? 'opacity-50 cursor-not-allowed' - : 'hover:border-stone-600 hover:shadow-medium' + ? "opacity-50 cursor-not-allowed" + : "hover:border-stone-600 hover:shadow-medium" }`} >
{option.icon}
@@ -144,10 +136,14 @@ const ConnectStep = ({ onNext }: ConnectStepProps) => {
{option.name} {option.comingSoon && ( - Coming Soon + + Coming Soon + )} {isConnected && !option.comingSoon && ( - Connected + + Connected + )}

{option.description}

@@ -161,10 +157,14 @@ const ConnectStep = ({ onNext }: ConnectStepProps) => {
-

🔒 Remember everything is private & encrypted!

-

All data and credentials are stored - locally and follows a strict zero-data retention policy so you won't have to worry about anything - getting leaked.

+

+ 🔒 Remember everything is private & encrypted! +

+

+ All data and credentials are stored locally and follows a strict + zero-data retention policy so you won't have to worry about + anything getting leaked. +

diff --git a/src/pages/onboarding/steps/FeaturesStep.tsx b/src/pages/onboarding/steps/FeaturesStep.tsx index 7cc58ba91..305063d47 100644 --- a/src/pages/onboarding/steps/FeaturesStep.tsx +++ b/src/pages/onboarding/steps/FeaturesStep.tsx @@ -1,4 +1,4 @@ -import PrivacyFeatureCard from '../../../components/PrivacyFeatureCard'; +import PrivacyFeatureCard from "../../../components/PrivacyFeatureCard"; interface FeaturesStepProps { onNext: () => void; @@ -7,16 +7,19 @@ interface FeaturesStepProps { const FeaturesStep = ({ onNext }: FeaturesStepProps) => { const features = [ { - title: 'Keep track of Everything', - description: 'Sometimes your chats, emails, tasks can get a bit too much. Stay on track, organize things and get more done.', + title: "Keep track of Everything", + description: + "Sometimes your chats, emails, tasks can get a bit too much. Stay on track, organize things and get more done.", }, { - title: 'Has Infinite Memory & Learns', - description: 'Missed something? Have a sexy assistant give you exactly what you need, every time.', + title: "Has Infinite Memory & Learns", + description: + "Missed something? Have a sexy assistant give you exactly what you need, every time.", }, { - title: 'Trades the Trenches', - description: 'With it\'s own private wallet, trade or reasearch on any exchange or shitcoin autonomously. Go big or go home.', + title: "Trades the Trenches", + description: + "With it's own private wallet, trade or reasearch on any exchange or shitcoin autonomously. Go big or go home.", }, ]; diff --git a/src/pages/onboarding/steps/GetStartedStep.tsx b/src/pages/onboarding/steps/GetStartedStep.tsx index b668f452d..1f49cf215 100644 --- a/src/pages/onboarding/steps/GetStartedStep.tsx +++ b/src/pages/onboarding/steps/GetStartedStep.tsx @@ -1,16 +1,29 @@ -import { openUrl } from '../../../utils/openUrl'; -import { TELEGRAM_BOT_USERNAME } from '../../../utils/config'; -import ConnectionIndicator from '../../../components/ConnectionIndicator'; +import { useState } from "react"; +import { openUrl } from "../../../utils/openUrl"; +import { TELEGRAM_BOT_USERNAME } from "../../../utils/config"; +import ConnectionIndicator from "../../../components/ConnectionIndicator"; interface GetStartedStepProps { - onComplete: () => void; + onComplete: () => void | Promise; } const GetStartedStep = ({ onComplete }: GetStartedStepProps) => { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const handleOpenTelegram = async () => { - // Open Telegram and navigate to home immediately - await openUrl(`https://t.me/${TELEGRAM_BOT_USERNAME}`); - onComplete(); + setError(null); + setLoading(true); + try { + await openUrl(`https://t.me/${TELEGRAM_BOT_USERNAME}`); + await onComplete(); + } catch (e) { + setError( + e instanceof Error ? e.message : "Something went wrong. Please try again.", + ); + } finally { + setLoading(false); + } }; return ( @@ -18,19 +31,24 @@ const GetStartedStep = ({ onComplete }: GetStartedStepProps) => {

You Are Ready, Soldier!

- Alright you're all set up, just message your assistant and you're ready to cook! Remember to keep this tab open to keep the connection alive. + Alright you're all set up, just message your assistant and you're + ready to cook! Remember to keep this tab open to keep the connection + alive.

- + + + {error && ( +

{error}

+ )}
); diff --git a/src/pages/onboarding/steps/PrivacyStep.tsx b/src/pages/onboarding/steps/PrivacyStep.tsx index a9bf0ee22..d67f258ad 100644 --- a/src/pages/onboarding/steps/PrivacyStep.tsx +++ b/src/pages/onboarding/steps/PrivacyStep.tsx @@ -1,4 +1,4 @@ -import PrivacyFeatureCard from '../../../components/PrivacyFeatureCard'; +import PrivacyFeatureCard from "../../../components/PrivacyFeatureCard"; interface PrivacyStepProps { onNext: () => void; @@ -7,17 +7,20 @@ interface PrivacyStepProps { const PrivacyStep = ({ onNext }: PrivacyStepProps) => { const privacyFeatures = [ { - title: '🔒 Everything is Local & Encrypted', - description: 'Your data is encrypted (AES-256-GCM) in your device and never stored elsewhere. Your encryption keys never leave your device.', + title: "🔒 Everything is Local & Encrypted", + description: + "Your data is encrypted (AES-256-GCM) in your device and never stored elsewhere. Your encryption keys never leave your device.", }, { - title: '🙈 Zero Data Retention', - description: 'Your queries are processed, immediately discarded and never stored elsewhere. Your data is NEVER used to train AI models. ', + title: "🙈 Zero Data Retention", + description: + "Your queries are processed, immediately discarded and never stored elsewhere. Your data is NEVER used to train AI models. ", }, { - title: '🔥 Delete Anytime You Want', - description: 'You can delete your data and your account anytime you want. Everything will get wiped including AI memories.', - } + title: "🔥 Delete Anytime You Want", + description: + "You can delete your data and your account anytime you want. Everything will get wiped including AI memories.", + }, ]; return ( @@ -25,7 +28,8 @@ const PrivacyStep = ({ onNext }: PrivacyStepProps) => {

A Quick Privacy Note

- Since AlphaHuman handles criticial information about you, here's how it handles your data and manages your privacy. + Since AlphaHuman handles criticial information about you, here's how + it handles your data and manages your privacy.

diff --git a/src/providers/SocketProvider.tsx b/src/providers/SocketProvider.tsx index 4c3804b26..e3aa2bc31 100644 --- a/src/providers/SocketProvider.tsx +++ b/src/providers/SocketProvider.tsx @@ -1,13 +1,14 @@ -import { useEffect, useRef } from 'react'; -import { useAppSelector } from '../store/hooks'; -import { store } from '../store'; -import { socketService } from '../services/socketService'; +import { useEffect, useRef } from "react"; +import { useAppSelector } from "../store/hooks"; +import { store } from "../store"; +import { selectSocketStatus } from "../store/socketSelectors"; +import { socketService } from "../services/socketService"; import { initTelegramMCPServer, getTelegramMCPServer, updateTelegramMCPServerSocket, cleanupTelegramMCPServer, -} from '../lib/mcp/telegram'; +} from "../lib/mcp/telegram"; /** * SocketProvider manages the socket connection based on JWT token @@ -16,7 +17,7 @@ import { */ const SocketProvider = ({ children }: { children: React.ReactNode }) => { const token = useAppSelector((state) => state.auth.token); - const socketStatus = useAppSelector((state) => state.socket.status); + const socketStatus = useAppSelector(selectSocketStatus); const previousTokenRef = useRef(null); useEffect(() => { @@ -38,7 +39,7 @@ const SocketProvider = ({ children }: { children: React.ReactNode }) => { // Handle MCP initialization when socket connects useEffect(() => { - if (socketStatus === 'connected') { + if (socketStatus === "connected") { const socket = socketService.getSocket(); const server = getTelegramMCPServer(); @@ -47,7 +48,7 @@ const SocketProvider = ({ children }: { children: React.ReactNode }) => { } else { initTelegramMCPServer(socket); } - } else if (socketStatus === 'disconnected') { + } else if (socketStatus === "disconnected") { cleanupTelegramMCPServer(); } }, [socketStatus]); diff --git a/src/providers/TelegramProvider.tsx b/src/providers/TelegramProvider.tsx index 2d19633ae..dda70d489 100644 --- a/src/providers/TelegramProvider.tsx +++ b/src/providers/TelegramProvider.tsx @@ -1,31 +1,36 @@ -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/telegram'; -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; - } -}; +import { + useEffect, + useRef, + createContext, + useContext, + ReactNode, +} from "react"; +import { useAppSelector, useAppDispatch } from "../store/hooks"; +import { + selectIsAuthenticated, + selectIsInitialized, + selectConnectionStatus, + selectSessionString, + selectTelegramCurrentUserId, +} from "../store/telegramSelectors"; +import { initializeTelegram, connectTelegram } from "../store/telegram"; +import { mtprotoService } from "../services/mtprotoService"; interface TelegramContextType { isAuthenticated: boolean; isInitialized: boolean; - connectionStatus: 'disconnected' | 'connecting' | 'connected' | 'error'; + connectionStatus: "disconnected" | "connecting" | "connected" | "error"; checkConnection: () => Promise; } -const TelegramContext = createContext(undefined); +const TelegramContext = createContext( + undefined, +); export const useTelegram = () => { const context = useContext(TelegramContext); if (!context) { - throw new Error('useTelegram must be used within TelegramProvider'); + throw new Error("useTelegram must be used within TelegramProvider"); } return context; }; @@ -36,30 +41,31 @@ interface TelegramProviderProps { /** * TelegramProvider manages the Telegram MTProto connection - * - Initializes when authenticated + * - Initializes when app-authenticated (JWT), or has Telegram session / authenticated + * - Starts init+connect in parallel with login (token) so connect modal is ready sooner * - Connects automatically * - Provides Telegram context to children */ const TelegramProvider = ({ children }: TelegramProviderProps) => { const dispatch = useAppDispatch(); + const token = useAppSelector((state) => state.auth.token); + const userId = useAppSelector(selectTelegramCurrentUserId); const isAuthenticated = useAppSelector(selectIsAuthenticated); const isInitialized = useAppSelector(selectIsInitialized); const connectionStatus = useAppSelector(selectConnectionStatus); - const sessionString = useAppSelector((state) => state.telegram.sessionString); + const sessionString = useAppSelector(selectSessionString); 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]); + const hasSession = !!sessionString; - // Initialize Telegram when authenticated or when we have a saved session + const shouldSetupTelegram = !!token && !!userId; + + // Initialize Telegram when app-authenticated (token), or have session / Telegram auth useEffect(() => { - // Reset refs if we're not authenticated and have no session - if (!isAuthenticated && !hasSession) { + if (!shouldSetupTelegram) { initializedRef.current = false; connectedRef.current = false; setupInProgressRef.current = false; @@ -68,11 +74,14 @@ const TelegramProvider = ({ children }: TelegramProviderProps) => { } // If setup is already complete and everything is connected, don't run again - if (setupCompleteRef.current && isInitialized && connectionStatus === 'connected') { + if ( + setupCompleteRef.current && + isInitialized && + connectionStatus === "connected" + ) { return; } - // Prevent multiple simultaneous setup attempts if (setupInProgressRef.current) { return; } @@ -85,15 +94,18 @@ const TelegramProvider = ({ children }: TelegramProviderProps) => { // Initialize if not already initialized if (!isInitialized && !initializedRef.current) { initializedRef.current = true; - await dispatch(initializeTelegram()).unwrap(); + await dispatch(initializeTelegram(userId)).unwrap(); setupInProgressRef.current = false; - return; // Exit early, will re-run when isInitialized updates + return; } - // Connect if not already connected - if (isInitialized && connectionStatus !== 'connected' && !connectedRef.current) { + if ( + isInitialized && + connectionStatus !== "connected" && + !connectedRef.current + ) { connectedRef.current = true; - await dispatch(connectTelegram()).unwrap(); + await dispatch(connectTelegram(userId)).unwrap(); setupInProgressRef.current = false; return; // Exit early, will re-run when connectionStatus updates } @@ -102,7 +114,7 @@ const TelegramProvider = ({ children }: TelegramProviderProps) => { setupInProgressRef.current = false; setupCompleteRef.current = true; } catch (error) { - console.error('Failed to setup Telegram:', error); + console.error("Failed to setup Telegram:", error); initializedRef.current = false; connectedRef.current = false; setupInProgressRef.current = false; @@ -111,34 +123,44 @@ const TelegramProvider = ({ children }: TelegramProviderProps) => { }; setupTelegram(); - }, [isAuthenticated, hasSession, isInitialized, connectionStatus, dispatch]); + }, [ + shouldSetupTelegram, + isInitialized, + connectionStatus, + dispatch, + userId, + ]); // Check connection status periodically to keep user online useEffect(() => { - if ((!isAuthenticated && !hasSession) || !isInitialized || connectionStatus !== 'connected') { + if ( + !shouldSetupTelegram || + !isInitialized || + connectionStatus !== "connected" + ) { return; } + const uid = userId; const checkConnection = async () => { try { - await mtprotoService.checkConnection(); + await mtprotoService.checkConnection(uid); } catch (error) { - console.warn('Telegram connection check failed:', 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]); + }, [shouldSetupTelegram, isInitialized, connectionStatus, userId]); const checkConnection = async (): Promise => { try { - return await mtprotoService.checkConnection(); + return await mtprotoService.checkConnection(userId || undefined); } catch (error) { - console.warn('Connection check failed:', error); + console.warn("Connection check failed:", error); return false; } }; @@ -150,7 +172,11 @@ const TelegramProvider = ({ children }: TelegramProviderProps) => { checkConnection, }; - return {children}; + return ( + + {children} + + ); }; export default TelegramProvider; diff --git a/src/providers/UserProvider.tsx b/src/providers/UserProvider.tsx index 02eace3b7..05693f018 100644 --- a/src/providers/UserProvider.tsx +++ b/src/providers/UserProvider.tsx @@ -1,22 +1,24 @@ -import { useEffect } from 'react'; -import { useAppSelector, useAppDispatch } from '../store/hooks'; -import { fetchCurrentUser } from '../store/userSlice'; +import { useEffect } from "react"; +import { useAppSelector, useAppDispatch } from "../store/hooks"; +import { fetchCurrentUser } from "../store/userSlice"; +import { clearToken } from "../store/authSlice"; /** - * UserProvider automatically fetches user data when JWT token is available + * UserProvider automatically fetches user data when JWT token is available. + * On fetch failure (e.g. expired token), logs out the user. */ const UserProvider = ({ children }: { children: React.ReactNode }) => { const dispatch = useAppDispatch(); const token = useAppSelector((state) => state.auth.token); - const user = useAppSelector((state) => state.user.user); - const isLoading = useAppSelector((state) => state.user.isLoading); useEffect(() => { - // Fetch user data when token is available and user is not loaded - if (token && !user && !isLoading) { - dispatch(fetchCurrentUser()); - } - }, [token, user, isLoading, dispatch]); + if (!token) return; + dispatch(fetchCurrentUser()).then((result) => { + if (fetchCurrentUser.rejected.match(result)) { + dispatch(clearToken()); + } + }); + }, [token, dispatch]); return <>{children}; }; diff --git a/src/services/api/authApi.ts b/src/services/api/authApi.ts new file mode 100644 index 000000000..b17cf2afe --- /dev/null +++ b/src/services/api/authApi.ts @@ -0,0 +1,22 @@ +import { apiClient } from "../apiClient"; + +interface ConsumeLoginTokenResponse { + success: boolean; + data: { jwtToken: string }; +} + +/** + * Consume a verified login token and return the JWT. + * POST /telegram/login-tokens/:token/consume (no auth required) + */ +export async function consumeLoginToken(loginToken: string): Promise { + const response = await apiClient.post( + `/telegram/login-tokens/${encodeURIComponent(loginToken)}/consume`, + undefined, + { requireAuth: false }, + ); + if (!response.success || !response.data?.jwtToken) { + throw new Error("Login token invalid or expired"); + } + return response.data.jwtToken; +} diff --git a/src/services/api/userApi.ts b/src/services/api/userApi.ts index 19e8d332e..7dc6d6638 100644 --- a/src/services/api/userApi.ts +++ b/src/services/api/userApi.ts @@ -1,5 +1,5 @@ -import { apiClient } from '../apiClient'; -import type { GetMeResponse, User } from '../../types/api'; +import { apiClient } from "../apiClient"; +import type { GetMeResponse, User } from "../../types/api"; /** * User API endpoints @@ -10,10 +10,21 @@ export const userApi = { * GET /telegram/me */ getMe: async (): Promise => { - const response = await apiClient.get('/telegram/me'); + const response = await apiClient.get("/telegram/me"); if (!response.success) { - throw new Error(response.error || 'Failed to fetch user data'); + throw new Error(response.error || "Failed to fetch user data"); } return response.data; }, + + /** + * Mark onboarding complete for the current user. + * POST /telegram/settings/onboarding-complete + */ + onboardingComplete: async (): Promise => { + await apiClient.post<{ success: boolean; data: unknown }>( + "/telegram/settings/onboarding-complete", + {}, + ); + }, }; diff --git a/src/services/apiClient.ts b/src/services/apiClient.ts index 6fd156b3b..2eaf725d9 100644 --- a/src/services/apiClient.ts +++ b/src/services/apiClient.ts @@ -1,8 +1,8 @@ -import { BACKEND_URL } from '../utils/config'; -import type { ApiError } from '../types/api'; -import { store } from '../store'; +import { BACKEND_URL } from "../utils/config"; +import type { ApiError } from "../types/api"; +import { store } from "../store"; -type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; +type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; interface RequestOptions { method?: HttpMethod; @@ -34,7 +34,7 @@ class ApiClient { */ private buildHeaders(options: RequestOptions): HeadersInit { const headers: Record = { - 'Content-Type': 'application/json', + "Content-Type": "application/json", ...options.headers, }; @@ -54,9 +54,9 @@ class ApiClient { */ private async request( endpoint: string, - options: RequestOptions = {} + options: RequestOptions = {}, ): Promise { - const { method = 'GET', body, requireAuth = true } = options; + const { method = "GET", body, requireAuth = true } = options; const url = `${this.baseUrl}${endpoint}`; const headers = this.buildHeaders({ ...options, requireAuth }); @@ -66,7 +66,7 @@ class ApiClient { headers, }; - if (body && method !== 'GET') { + if (body && method !== "GET") { config.body = JSON.stringify(body); } @@ -74,8 +74,8 @@ class ApiClient { const response = await fetch(url, config); // Handle non-JSON responses - const contentType = response.headers.get('content-type'); - if (!contentType || !contentType.includes('application/json')) { + const contentType = response.headers.get("content-type"); + if (!contentType || !contentType.includes("application/json")) { if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } @@ -88,21 +88,25 @@ class ApiClient { if (!response.ok) { const error: ApiError = data.error ? { success: false, error: data.error, message: data.message } - : { success: false, error: `HTTP ${response.status}: ${response.statusText}` }; + : { + success: false, + error: `HTTP ${response.status}: ${response.statusText}`, + }; throw error; } return data as T; } catch (error) { // Re-throw API errors as-is - if (error && typeof error === 'object' && 'error' in error) { + if (error && typeof error === "object" && "error" in error) { throw error; } // Wrap network/other errors throw { success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred', + error: + error instanceof Error ? error.message : "Unknown error occurred", } as ApiError; } } @@ -110,8 +114,11 @@ class ApiClient { /** * GET request */ - async get(endpoint: string, options?: Omit): Promise { - return this.request(endpoint, { ...options, method: 'GET' }); + async get( + endpoint: string, + options?: Omit, + ): Promise { + return this.request(endpoint, { ...options, method: "GET" }); } /** @@ -120,9 +127,9 @@ class ApiClient { async post( endpoint: string, body?: unknown, - options?: Omit + options?: Omit, ): Promise { - return this.request(endpoint, { ...options, method: 'POST', body }); + return this.request(endpoint, { ...options, method: "POST", body }); } /** @@ -131,9 +138,9 @@ class ApiClient { async put( endpoint: string, body?: unknown, - options?: Omit + options?: Omit, ): Promise { - return this.request(endpoint, { ...options, method: 'PUT', body }); + return this.request(endpoint, { ...options, method: "PUT", body }); } /** @@ -142,16 +149,19 @@ class ApiClient { async patch( endpoint: string, body?: unknown, - options?: Omit + options?: Omit, ): Promise { - return this.request(endpoint, { ...options, method: 'PATCH', body }); + return this.request(endpoint, { ...options, method: "PATCH", body }); } /** * DELETE request */ - async delete(endpoint: string, options?: Omit): Promise { - return this.request(endpoint, { ...options, method: 'DELETE' }); + async delete( + endpoint: string, + options?: Omit, + ): Promise { + return this.request(endpoint, { ...options, method: "DELETE" }); } } diff --git a/src/services/mtprotoService.ts b/src/services/mtprotoService.ts index c8ad0594e..be7610bfd 100644 --- a/src/services/mtprotoService.ts +++ b/src/services/mtprotoService.ts @@ -1,8 +1,10 @@ -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'; +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"; +import { store } from "../store"; +import { setSessionString } from "../store/telegram"; type LoginOptions = UserAuthParams | BotAuthParams; @@ -11,7 +13,8 @@ class MTProtoService { private client: TelegramClient | undefined; private isInitialized = false; private isConnected = false; - private sessionString = ''; + private sessionString = ""; + private userId: string | null = null; private readonly apiId: number; private readonly apiHash: string; @@ -19,7 +22,9 @@ class MTProtoService { // Private constructor to enforce singleton // Load API credentials from config once if (!TELEGRAM_API_ID || !TELEGRAM_API_HASH) { - throw new Error('TELEGRAM_API_ID and TELEGRAM_API_HASH must be configured'); + throw new Error( + "TELEGRAM_API_ID and TELEGRAM_API_HASH must be configured", + ); } this.apiId = TELEGRAM_API_ID; this.apiHash = TELEGRAM_API_HASH; @@ -33,30 +38,39 @@ class MTProtoService { } /** - * Initialize the MTProto client with API credentials + * Initialize the MTProto client with API credentials. + * Session is stored in Redux (telegram.byUser[userId].sessionString). */ - async initialize(): Promise { - if (this.isInitialized && this.client) { - console.log('MTProto client already initialized'); + async initialize(userId: string): Promise { + if (this.isInitialized && this.client && this.userId === userId) { return; } + if (this.isInitialized && this.userId !== null && this.userId !== userId) { + await this.clearSessionAndDisconnect(this.userId); + } - const sessionString = this.loadSession() || ''; + this.userId = userId; + const sessionString = this.loadSession() || ""; try { const stringSession = new StringSession(sessionString); this.sessionString = sessionString; - 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.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; - console.log('MTProto client initialized successfully'); + console.log("MTProto client initialized successfully"); } catch (error) { - console.error('Failed to initialize MTProto client:', error); + console.error("Failed to initialize MTProto client:", error); throw error; } } @@ -66,28 +80,30 @@ class MTProtoService { */ async connect(): Promise { if (!this.client) { - throw new Error('MTProto client not initialized. Call initialize() first.'); + throw new Error( + "MTProto client not initialized. Call initialize() first.", + ); } if (this.isConnected) { - console.log('Already connected to Telegram'); + console.log("Already connected to Telegram"); return; } try { await this.client.connect(); this.isConnected = true; - console.log('Connected to Telegram successfully'); + console.log("Connected to Telegram successfully"); // Save session string if it changed const newSessionString = this.client.session.save() as string | undefined; if (newSessionString && newSessionString !== this.sessionString) { this.sessionString = newSessionString; this.saveSession(newSessionString); - console.log('Session updated and saved'); + console.log("Session updated and saved"); } } catch (error) { - console.error('Failed to connect to Telegram:', error); + console.error("Failed to connect to Telegram:", error); throw error; } } @@ -97,7 +113,9 @@ class MTProtoService { */ async start(options: LoginOptions): Promise { if (!this.client) { - throw new Error('MTProto client not initialized. Call initialize() first.'); + throw new Error( + "MTProto client not initialized. Call initialize() first.", + ); } try { @@ -108,10 +126,10 @@ class MTProtoService { if (newSessionString && newSessionString !== this.sessionString) { this.sessionString = newSessionString; this.saveSession(newSessionString); - console.log('Authentication successful, session saved'); + console.log("Authentication successful, session saved"); } } catch (error) { - console.error('Authentication failed:', error); + console.error("Authentication failed:", error); throw error; } } @@ -122,10 +140,13 @@ class MTProtoService { async signInWithQrCode( qrCodeCallback: (qrCode: { token: Buffer; expires: number }) => void, passwordCallback?: (hint?: string) => Promise, - onError?: (err: Error) => Promise | void + onError?: (err: Error) => Promise | void, ): Promise { + console.log("signInWithQrCode"); if (!this.client) { - throw new Error('MTProto client not initialized. Call initialize() first.'); + throw new Error( + "MTProto client not initialized. Call initialize() first.", + ); } try { @@ -143,8 +164,11 @@ class MTProtoService { // 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 - const errorMessage = err.message || ''; - if (errorMessage.includes('SESSION_PASSWORD_NEEDED') && passwordCallback) { + const errorMessage = err.message || ""; + if ( + errorMessage.includes("SESSION_PASSWORD_NEEDED") && + passwordCallback + ) { // Don't stop - let the password callback handle it if (onError) { const result = await onError(err); @@ -152,15 +176,15 @@ class MTProtoService { } return false; } - + if (onError) { const result = await onError(err); return result ?? false; } - console.error('QR code auth error:', err); + console.error("QR code auth error:", err); return false; }, - } + }, ); // Save session after successful login @@ -168,25 +192,28 @@ class MTProtoService { if (newSessionString && newSessionString !== this.sessionString) { this.sessionString = newSessionString; this.saveSession(newSessionString); - console.log('QR code authentication successful, session saved'); + console.log("QR code authentication successful, session saved"); } return user; } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - + const errorMessage = + error instanceof Error ? error.message : String(error); + // If it's a password needed error, the password callback should have been invoked // by the library. If we're here, it means either: // 1. The password callback wasn't provided // 2. The password callback failed // 3. The library threw before invoking the callback // In any case, we should let the caller handle it - if (errorMessage.includes('SESSION_PASSWORD_NEEDED')) { - console.log('SESSION_PASSWORD_NEEDED - password callback should handle this'); + if (errorMessage.includes("SESSION_PASSWORD_NEEDED")) { + console.log( + "SESSION_PASSWORD_NEEDED - password callback should handle this", + ); // Don't log as error - this is expected when 2FA is enabled // The password callback should be invoked by the library } else { - console.error('QR code authentication failed:', error); + console.error("QR code authentication failed:", error); } throw error; } @@ -198,7 +225,9 @@ class MTProtoService { */ getClient(): TelegramClient { if (!this.client || !this.isInitialized) { - throw new Error('MTProto client not initialized. Call initialize() first.'); + throw new Error( + "MTProto client not initialized. Call initialize() first.", + ); } return this.client; } @@ -229,11 +258,11 @@ class MTProtoService { * This calls getMe() which also updates the user's online status on Telegram * Automatically initializes and connects if needed */ - async checkConnection(): Promise { + async checkConnection(userId?: string): Promise { try { - // Initialize if not already initialized if (!this.isInitialized || !this.client) { - await this.initialize(); + if (!userId) return false; + await this.initialize(userId); } // Connect if not already connected @@ -255,9 +284,11 @@ class MTProtoService { } 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`); + console.debug( + `Telegram connection check: FLOOD_WAIT ${error.seconds}s`, + ); } else { - console.warn('Telegram connection check failed:', error); + console.warn("Telegram connection check failed:", error); } return false; } @@ -271,14 +302,34 @@ class MTProtoService { try { await this.client.disconnect(); this.isConnected = false; - console.log('Disconnected from Telegram'); + console.log("Disconnected from Telegram"); } catch (error) { - console.error('Error disconnecting from Telegram:', error); + console.error("Error disconnecting from Telegram:", error); throw error; } } } + /** + * Clear session from Redux, disconnect, and reset client state. + * Use when the logged-in Telegram account does not match the app user (e.g. after QR connect). + */ + async clearSessionAndDisconnect(userId?: string): Promise { + const uid = userId ?? this.userId; + if (uid) { + try { + store.dispatch(setSessionString({ userId: uid, sessionString: null })); + } catch (e) { + console.warn("Failed to clear Telegram session from Redux:", e); + } + } + await this.disconnect(); + this.client = undefined; + this.isInitialized = false; + this.sessionString = ""; + this.userId = null; + } + /** * Send a message using the client with FLOOD_WAIT handling */ @@ -287,7 +338,7 @@ class MTProtoService { if (!this.isClientConnected()) { await this.connect(); } - + return this.handleFloodWait(async () => { await client.sendMessage(entity, { message }); }); @@ -303,7 +354,7 @@ class MTProtoService { private async handleFloodWait( operation: () => Promise, maxRetries = 3, - retryCount = 0 + retryCount = 0, ): Promise { try { return await operation(); @@ -311,26 +362,32 @@ class MTProtoService { // 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.`); + 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.`); + 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})`); - + 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; } @@ -345,7 +402,7 @@ class MTProtoService { */ async withFloodWaitHandling( operation: () => Promise, - maxRetries = 3 + maxRetries = 3, ): Promise { return this.handleFloodWait(operation, maxRetries); } @@ -353,41 +410,42 @@ class MTProtoService { /** * Invoke a raw Telegram API method with FLOOD_WAIT handling */ - async invoke(request: Parameters[0]): Promise { + async invoke( + request: Parameters[0], + ): Promise { const client = this.getClient(); if (!this.isClientConnected()) { await this.connect(); } - + return this.handleFloodWait(async () => { return client.invoke(request) as Promise; }); } - /** - * Load session from localStorage - */ private loadSession(): string | null { try { - return localStorage.getItem('telegram_session'); + if (!this.userId) return null; + const state = store.getState(); + const u = state.telegram.byUser[this.userId]; + return u?.sessionString ?? null; } catch (error) { - console.error('Failed to load session from localStorage:', error); + console.error("Failed to load Telegram session from Redux:", error); return null; } } - /** - * Save session to localStorage - */ private saveSession(session: string): void { try { - localStorage.setItem('telegram_session', session); + if (!this.userId) return; + store.dispatch( + setSessionString({ userId: this.userId, sessionString: session }), + ); } catch (error) { - console.error('Failed to save session to localStorage:', error); + console.error("Failed to save Telegram session to Redux:", error); } } } -// Export singleton instance export const mtprotoService = MTProtoService.getInstance(); export default mtprotoService; diff --git a/src/services/socketService.ts b/src/services/socketService.ts index 9f883cc00..22b861ffc 100644 --- a/src/services/socketService.ts +++ b/src/services/socketService.ts @@ -1,7 +1,15 @@ import { io, Socket } from "socket.io-client"; import { BACKEND_URL } from "../utils/config"; import { store } from "../store"; -import { setStatus, setSocketId, reset } from "../store/socketSlice"; +import { + setStatusForUser, + setSocketIdForUser, + resetForUser, +} from "../store/socketSlice"; + +function getSocketUserId(): string { + return store.getState().user.user?._id ?? "__pending__"; +} class SocketService { private socket: Socket | null = null; @@ -34,8 +42,9 @@ class SocketService { this.token = token; - // Update status to connecting - store.dispatch(setStatus("connecting")); + store.dispatch( + setStatusForUser({ userId: getSocketUserId(), status: "connecting" }), + ); const backendUrl = BACKEND_URL; @@ -67,8 +76,9 @@ class SocketService { // Connection event handlers this.socket.on("connect", () => { const socketId = this.socket?.id || null; - store.dispatch(setStatus("connected")); - store.dispatch(setSocketId(socketId)); + const uid = getSocketUserId(); + store.dispatch(setStatusForUser({ userId: uid, status: "connected" })); + store.dispatch(setSocketIdForUser({ userId: uid, socketId })); }); this.socket.on("ready", () => { @@ -80,12 +90,14 @@ class SocketService { }); this.socket.on("disconnect", () => { - store.dispatch(setStatus("disconnected")); - store.dispatch(setSocketId(null)); + const uid = getSocketUserId(); + store.dispatch(setStatusForUser({ userId: uid, status: "disconnected" })); + store.dispatch(setSocketIdForUser({ userId: uid, socketId: null })); }); this.socket.on("connect_error", () => { - store.dispatch(setStatus("disconnected")); + const uid = getSocketUserId(); + store.dispatch(setStatusForUser({ userId: uid, status: "disconnected" })); }); this.socket.connect(); @@ -99,7 +111,7 @@ class SocketService { this.socket.disconnect(); this.socket = null; this.token = null; - store.dispatch(reset()); + store.dispatch(resetForUser({ userId: getSocketUserId() })); } } diff --git a/src/store/authSelectors.ts b/src/store/authSelectors.ts new file mode 100644 index 000000000..b55e9fdc5 --- /dev/null +++ b/src/store/authSelectors.ts @@ -0,0 +1,7 @@ +import type { RootState } from "./index"; + +export const selectIsOnboarded = (state: RootState): boolean => { + const userId = state.user.user?._id; + if (!userId) return false; + return state.auth.isOnboardedByUser[userId] ?? false; +}; diff --git a/src/store/authSlice.ts b/src/store/authSlice.ts index b7a39ca3f..bad11339e 100644 --- a/src/store/authSlice.ts +++ b/src/store/authSlice.ts @@ -1,18 +1,19 @@ -import { createSlice, PayloadAction, createAsyncThunk } from '@reduxjs/toolkit'; -import { clearUser } from './userSlice'; +import { createSlice, PayloadAction, createAsyncThunk } from "@reduxjs/toolkit"; +import { clearUser } from "./userSlice"; -interface AuthState { +export interface AuthState { token: string | null; - isOnboarded: boolean; + /** Onboarding completion per user id */ + isOnboardedByUser: Record; } const initialState: AuthState = { token: null, - isOnboarded: false, + isOnboardedByUser: {}, }; const authSlice = createSlice({ - name: 'auth', + name: "auth", initialState, reducers: { setToken: (state, action: PayloadAction) => { @@ -20,19 +21,25 @@ const authSlice = createSlice({ }, _clearToken: (state) => { state.token = null; - state.isOnboarded = false; }, - setOnboarded: (state, action: PayloadAction) => { - state.isOnboarded = action.payload; + setOnboardedForUser: ( + state, + action: PayloadAction<{ userId: string; value: boolean }>, + ) => { + const { userId, value } = action.payload; + state.isOnboardedByUser[userId] = value; }, }, }); // Thunk that clears both token and user data -export const clearToken = createAsyncThunk('auth/clearToken', async (_, { dispatch }) => { - dispatch(authSlice.actions._clearToken()); - dispatch(clearUser()); -}); +export const clearToken = createAsyncThunk( + "auth/clearToken", + async (_, { dispatch }) => { + dispatch(authSlice.actions._clearToken()); + dispatch(clearUser()); + }, +); -export const { setToken, setOnboarded } = authSlice.actions; +export const { setToken, setOnboardedForUser } = authSlice.actions; export default authSlice.reducer; diff --git a/src/store/hooks.ts b/src/store/hooks.ts index dc2a0c539..798052b22 100644 --- a/src/store/hooks.ts +++ b/src/store/hooks.ts @@ -1,5 +1,5 @@ -import { useDispatch, useSelector, TypedUseSelectorHook } from 'react-redux'; -import type { RootState, AppDispatch } from './index'; +import { useDispatch, useSelector, TypedUseSelectorHook } from "react-redux"; +import type { RootState, AppDispatch } from "./index"; export const useAppDispatch = () => useDispatch(); export const useAppSelector: TypedUseSelectorHook = useSelector; diff --git a/src/store/index.ts b/src/store/index.ts index 2824215c4..64595f6b8 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -21,48 +21,21 @@ import { IS_DEV } from "../utils/config"; const authPersistConfig = { key: "auth", storage, - // Persist token and onboarding status - whitelist: ["token", "isOnboarded"], + whitelist: ["token", "isOnboardedByUser"], }; -// Persist config for telegram state +// Persist config for telegram state (scoped by user in byUser) const telegramPersistConfig = { key: "telegram", storage, - // Persist important state but not connection status (will reconnect on app start) - whitelist: [ - "authStatus", - "phoneNumber", - "sessionString", - "currentUser", - "chats", - "chatsOrder", - "selectedChatId", - "messages", - "messagesOrder", - "threads", - "threadsOrder", - "selectedThreadId", - "hasMoreChats", - "hasMoreMessages", - "hasMoreThreads", - ], - // Don't persist connection status, errors, or loading states - blacklist: [ - "connectionStatus", - "connectionError", - "authError", - "isInitialized", - "isLoadingChats", - "isLoadingMessages", - "isLoadingThreads", - "searchQuery", - "filteredChatIds", - ], + whitelist: ["byUser"], }; const persistedAuthReducer = persistReducer(authPersistConfig, authReducer); -const persistedTelegramReducer = persistReducer(telegramPersistConfig, telegramReducer); +const persistedTelegramReducer = persistReducer( + telegramPersistConfig, + telegramReducer, +); export const store = configureStore({ reducer: { diff --git a/src/store/socketSelectors.ts b/src/store/socketSelectors.ts new file mode 100644 index 000000000..32f3d87dd --- /dev/null +++ b/src/store/socketSelectors.ts @@ -0,0 +1,19 @@ +import type { RootState } from "./index"; + +const PENDING_USER = "__pending__"; + +function selectCurrentUserId(state: RootState): string { + return state.user.user?._id ?? PENDING_USER; +} + +export const selectSocketStatus = (state: RootState) => { + const userId = selectCurrentUserId(state); + const userState = state.socket.byUser[userId]; + return userState?.status ?? "disconnected"; +}; + +export const selectSocketId = (state: RootState): string | null => { + const userId = selectCurrentUserId(state); + const userState = state.socket.byUser[userId]; + return userState?.socketId ?? null; +}; diff --git a/src/store/socketSlice.ts b/src/store/socketSlice.ts index d210b6328..b8318c35d 100644 --- a/src/store/socketSlice.ts +++ b/src/store/socketSlice.ts @@ -1,33 +1,66 @@ -import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; -export type SocketConnectionStatus = 'connected' | 'disconnected' | 'connecting'; +export type SocketConnectionStatus = + | "connected" + | "disconnected" + | "connecting"; -interface SocketState { +export interface SocketUserState { status: SocketConnectionStatus; socketId: string | null; } -const initialState: SocketState = { - status: 'disconnected', +const initialUserState: SocketUserState = { + status: "disconnected", socketId: null, }; +interface SocketState { + /** Socket state per user id. Use __pending__ when user not loaded yet. */ + byUser: Record; +} + +const initialState: SocketState = { + byUser: {}, +}; + +const ensureUserState = (state: SocketState, userId: string): SocketUserState => { + if (!state.byUser[userId]) { + state.byUser[userId] = { ...initialUserState }; + } + return state.byUser[userId]; +}; + const socketSlice = createSlice({ - name: 'socket', + name: "socket", initialState, reducers: { - setStatus: (state, action: PayloadAction) => { - state.status = action.payload; + setStatusForUser: ( + state, + action: PayloadAction<{ userId: string; status: SocketConnectionStatus }>, + ) => { + const { userId, status } = action.payload; + const user = ensureUserState(state, userId); + user.status = status; + if (status === "disconnected" || status === "connecting") { + user.socketId = null; + } }, - setSocketId: (state, action: PayloadAction) => { - state.socketId = action.payload; + setSocketIdForUser: ( + state, + action: PayloadAction<{ userId: string; socketId: string | null }>, + ) => { + const { userId, socketId } = action.payload; + const user = ensureUserState(state, userId); + user.socketId = socketId; }, - reset: (state) => { - state.status = 'disconnected'; - state.socketId = null; + resetForUser: (state, action: PayloadAction<{ userId: string }>) => { + const { userId } = action.payload; + state.byUser[userId] = { ...initialUserState }; }, }, }); -export const { setStatus, setSocketId, reset } = socketSlice.actions; +export const { setStatusForUser, setSocketIdForUser, resetForUser } = + socketSlice.actions; export default socketSlice.reducer; diff --git a/src/store/telegram/extraReducers.ts b/src/store/telegram/extraReducers.ts index 4a8e5994a..cd07d3079 100644 --- a/src/store/telegram/extraReducers.ts +++ b/src/store/telegram/extraReducers.ts @@ -1,63 +1,109 @@ -import { ActionReducerMapBuilder } from '@reduxjs/toolkit'; -import type { TelegramState } from './types'; +import { ActionReducerMapBuilder } from "@reduxjs/toolkit"; +import type { TelegramRootState, TelegramState } from "./types"; +import { initialState } from "./types"; import { initializeTelegram, connectTelegram, checkAuthStatus, fetchChats, fetchMessages, -} from './thunks'; -import type { TelegramUser } from './types'; +} from "./thunks"; +import type { TelegramUser } from "./types"; -export const buildExtraReducers = (builder: ActionReducerMapBuilder) => { - // Initialize +function ensureUser( + state: TelegramRootState, + userId: string, +): TelegramState { + if (!state.byUser[userId]) { + state.byUser[userId] = { ...initialState }; + } + return state.byUser[userId]; +} + +function userIdFromMeta(action: { + meta: { arg?: string | { userId: string } }; +}): string { + const arg = action.meta?.arg; + if (typeof arg === "string") return arg; + if (arg && typeof arg === "object" && typeof arg.userId === "string") { + return arg.userId; + } + throw new Error("telegram thunk requires userId"); +} + +export const buildExtraReducers = ( + builder: ActionReducerMapBuilder, +) => { builder - .addCase(initializeTelegram.pending, (state) => { - state.isInitialized = false; + .addCase(initializeTelegram.pending, (state, action) => { + const uid = userIdFromMeta(action); + const u = ensureUser(state, uid); + u.isInitialized = false; }) .addCase(initializeTelegram.fulfilled, (state, action) => { - state.isInitialized = true; - state.sessionString = action.payload.sessionString; + const uid = userIdFromMeta(action); + const u = ensureUser(state, uid); + u.isInitialized = true; + u.sessionString = action.payload.sessionString; }) .addCase(initializeTelegram.rejected, (state, action) => { - state.isInitialized = false; - state.connectionError = action.payload as string; + const uid = + typeof action.meta?.arg === "string" ? action.meta.arg : undefined; + if (!uid) return; + const u = ensureUser(state, uid); + u.isInitialized = false; + u.connectionError = (action.payload as string) ?? null; }); - // Connect builder - .addCase(connectTelegram.pending, (state) => { - state.connectionStatus = 'connecting'; - state.connectionError = null; + .addCase(connectTelegram.pending, (state, action) => { + const uid = userIdFromMeta(action); + const u = ensureUser(state, uid); + u.connectionStatus = "connecting"; + u.connectionError = null; }) - .addCase(connectTelegram.fulfilled, (state) => { - state.connectionStatus = 'connected'; - state.connectionError = null; + .addCase(connectTelegram.fulfilled, (state, action) => { + const uid = userIdFromMeta(action); + const u = ensureUser(state, uid); + u.connectionStatus = "connected"; + u.connectionError = null; }) .addCase(connectTelegram.rejected, (state, action) => { - state.connectionStatus = 'error'; - state.connectionError = action.payload as string; + const uid = + typeof action.meta?.arg === "string" ? action.meta.arg : undefined; + if (!uid) return; + const u = ensureUser(state, uid); + u.connectionStatus = "error"; + u.connectionError = (action.payload as string) ?? null; }); - // Check auth builder - .addCase(checkAuthStatus.pending, (state) => { - state.authStatus = 'authenticating'; + .addCase(checkAuthStatus.pending, (state, action) => { + const uid = userIdFromMeta(action); + const u = ensureUser(state, uid); + u.authStatus = "authenticating"; }) .addCase(checkAuthStatus.fulfilled, (state, action) => { + const uid = userIdFromMeta(action); + const u = ensureUser(state, uid); if (action.payload) { - state.authStatus = 'authenticated'; - // Convert Api.User to TelegramUser - // 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; + u.authStatus = "authenticated"; + const payload = action.payload as + | TelegramUser + | { + id: number | string; + firstName?: string; + lastName?: string; + username?: string; + bot?: boolean; + accessHash?: bigint | string; + }; + if ("isBot" in payload) { + u.currentUser = payload; } else { - // Convert from API User format - state.currentUser = { + u.currentUser = { id: String(payload.id), - firstName: payload.firstName || '', + firstName: payload.firstName || "", lastName: payload.lastName, username: payload.username, isBot: Boolean(payload.bot), @@ -65,40 +111,53 @@ export const buildExtraReducers = (builder: ActionReducerMapBuilder { - state.authStatus = 'error'; - state.authError = action.payload as string; + const uid = + typeof action.meta?.arg === "string" ? action.meta.arg : undefined; + if (!uid) return; + const u = ensureUser(state, uid); + u.authStatus = "error"; + u.authError = (action.payload as string) ?? null; }); - // Fetch chats builder - .addCase(fetchChats.pending, (state) => { - state.isLoadingChats = true; + .addCase(fetchChats.pending, (state, action) => { + const uid = userIdFromMeta(action); + ensureUser(state, uid).isLoadingChats = true; }) - .addCase(fetchChats.fulfilled, (state) => { - state.isLoadingChats = false; - // Convert dialogs to chats - // This is a placeholder - adjust based on actual API response - // action.payload should be an array of dialogs + .addCase(fetchChats.fulfilled, (state, action) => { + const uid = userIdFromMeta(action); + ensureUser(state, uid).isLoadingChats = false; }) - .addCase(fetchChats.rejected, (state) => { - state.isLoadingChats = false; + .addCase(fetchChats.rejected, (state, action) => { + const uid = + typeof action.meta?.arg === "string" ? action.meta.arg : undefined; + if (!uid) return; + ensureUser(state, uid).isLoadingChats = false; }); - // Fetch messages builder - .addCase(fetchMessages.pending, (state) => { - state.isLoadingMessages = true; + .addCase(fetchMessages.pending, (state, action) => { + const uid = userIdFromMeta(action); + ensureUser(state, uid).isLoadingMessages = true; }) - .addCase(fetchMessages.fulfilled, (state) => { - state.isLoadingMessages = false; - // Messages will be added via addMessages action + .addCase(fetchMessages.fulfilled, (state, action) => { + const uid = userIdFromMeta(action); + ensureUser(state, uid).isLoadingMessages = false; }) - .addCase(fetchMessages.rejected, (state) => { - state.isLoadingMessages = false; + .addCase(fetchMessages.rejected, (state, action) => { + const arg = action.meta?.arg; + const uid = + typeof arg === "string" + ? arg + : arg && typeof arg === "object" && typeof arg.userId === "string" + ? arg.userId + : undefined; + if (!uid) return; + ensureUser(state, uid).isLoadingMessages = false; }); }; diff --git a/src/store/telegram/index.ts b/src/store/telegram/index.ts index 09077615c..4136400c1 100644 --- a/src/store/telegram/index.ts +++ b/src/store/telegram/index.ts @@ -1,14 +1,15 @@ -import { createSlice } from '@reduxjs/toolkit'; -import { initialState } from './types'; -import { reducers } from './reducers'; -import { buildExtraReducers } from './extraReducers'; +import { createSlice } from "@reduxjs/toolkit"; +import type { TelegramRootState } from "./types"; +import { reducers } from "./reducers"; +import { buildExtraReducers } from "./extraReducers"; + +const telegramInitialState: TelegramRootState = { byUser: {} }; const telegramSlice = createSlice({ - name: 'telegram', - initialState, + name: "telegram", + initialState: telegramInitialState, reducers: { ...reducers, - resetTelegram: () => initialState, }, extraReducers: buildExtraReducers, }); @@ -37,7 +38,7 @@ export const { setSelectedThread, setSearchQuery, setFilteredChatIds, - resetTelegram, + resetTelegramForUser, resetChats, resetMessages, } = telegramSlice.actions; @@ -49,7 +50,7 @@ export { checkAuthStatus, fetchChats, fetchMessages, -} from './thunks'; +} from "./thunks"; // Re-export types export type { @@ -60,6 +61,8 @@ export type { TelegramMessage, TelegramThread, TelegramState, -} from './types'; + TelegramRootState, +} from "./types"; +export { initialState } from "./types"; export default telegramSlice.reducer; diff --git a/src/store/telegram/reducers.ts b/src/store/telegram/reducers.ts index 93e6db5a9..c0a9f6d6d 100644 --- a/src/store/telegram/reducers.ts +++ b/src/store/telegram/reducers.ts @@ -1,5 +1,6 @@ import { PayloadAction } from "@reduxjs/toolkit"; import type { + TelegramRootState, TelegramState, TelegramConnectionStatus, TelegramAuthStatus, @@ -8,244 +9,279 @@ import type { TelegramMessage, TelegramThread, } from "./types"; +import { initialState } from "./types"; + +function ensureUser( + state: TelegramRootState, + userId: string, +): TelegramState { + if (!state.byUser[userId]) { + state.byUser[userId] = { ...initialState }; + } + return state.byUser[userId]; +} export const reducers = { - // Connection actions setConnectionStatus: ( - state: TelegramState, - action: PayloadAction, + state: TelegramRootState, + action: PayloadAction<{ userId: string; status: TelegramConnectionStatus }>, ) => { - state.connectionStatus = action.payload; - if (action.payload !== "error") { - state.connectionError = null; - } + const u = ensureUser(state, action.payload.userId); + u.connectionStatus = action.payload.status; + if (action.payload.status !== "error") u.connectionError = null; }, setConnectionError: ( - state: TelegramState, - action: PayloadAction, + state: TelegramRootState, + action: PayloadAction<{ userId: string; error: string | null }>, ) => { - state.connectionError = action.payload; - if (action.payload) { - state.connectionStatus = "error"; - } + const u = ensureUser(state, action.payload.userId); + u.connectionError = action.payload.error; + if (action.payload.error) u.connectionStatus = "error"; }, - - // Authentication actions setAuthStatus: ( - state: TelegramState, - action: PayloadAction, + state: TelegramRootState, + action: PayloadAction<{ userId: string; status: TelegramAuthStatus }>, ) => { - state.authStatus = action.payload; - if (action.payload !== "error") { - state.authError = null; - } + const u = ensureUser(state, action.payload.userId); + u.authStatus = action.payload.status; + if (action.payload.status !== "error") u.authError = null; }, setAuthError: ( - state: TelegramState, - action: PayloadAction, + state: TelegramRootState, + action: PayloadAction<{ userId: string; error: string | null }>, ) => { - state.authError = action.payload; - if (action.payload) { - state.authStatus = "error"; - } + const u = ensureUser(state, action.payload.userId); + u.authError = action.payload.error; + if (action.payload.error) u.authStatus = "error"; }, setPhoneNumber: ( - state: TelegramState, - action: PayloadAction, + state: TelegramRootState, + action: PayloadAction<{ userId: string; phoneNumber: string | null }>, ) => { - state.phoneNumber = action.payload; + ensureUser(state, action.payload.userId).phoneNumber = + action.payload.phoneNumber; }, setSessionString: ( - state: TelegramState, - action: PayloadAction, + state: TelegramRootState, + action: PayloadAction<{ userId: string; sessionString: string | null }>, ) => { - state.sessionString = action.payload; + ensureUser(state, action.payload.userId).sessionString = + action.payload.sessionString; }, - - // User actions setCurrentUser: ( - state: TelegramState, - action: PayloadAction, + state: TelegramRootState, + action: PayloadAction<{ userId: string; user: TelegramUser | null }>, ) => { - state.currentUser = action.payload; + ensureUser(state, action.payload.userId).currentUser = action.payload.user; }, - - // Chat actions setChats: ( - state: TelegramState, - action: PayloadAction>, + state: TelegramRootState, + action: PayloadAction<{ + userId: string; + chats: Record; + }>, ) => { - state.chats = action.payload; + ensureUser(state, action.payload.userId).chats = action.payload.chats; }, - addChat: (state: TelegramState, action: PayloadAction) => { - const chat = action.payload; - state.chats[chat.id] = chat; - if (!state.chatsOrder.includes(chat.id)) { - state.chatsOrder.unshift(chat.id); - } + addChat: ( + state: TelegramRootState, + action: PayloadAction<{ userId: string; chat: TelegramChat }>, + ) => { + const u = ensureUser(state, action.payload.userId); + const chat = action.payload.chat; + u.chats[chat.id] = chat; + if (!u.chatsOrder.includes(chat.id)) u.chatsOrder.unshift(chat.id); }, updateChat: ( - state: TelegramState, - action: PayloadAction & { id: string }>, + state: TelegramRootState, + action: PayloadAction<{ + userId: string; + id: string; + updates: Partial; + }>, ) => { - const { id, ...updates } = action.payload; - if (state.chats[id]) { - state.chats[id] = { ...state.chats[id], ...updates }; - } + const u = ensureUser(state, action.payload.userId); + const { id, updates } = action.payload; + if (u.chats[id]) u.chats[id] = { ...u.chats[id], ...updates }; }, - removeChat: (state: TelegramState, action: PayloadAction) => { - const chatId = action.payload; - delete state.chats[chatId]; - state.chatsOrder = state.chatsOrder.filter((id) => id !== chatId); - if (state.selectedChatId === chatId) { - state.selectedChatId = null; - } + removeChat: ( + state: TelegramRootState, + action: PayloadAction<{ userId: string; chatId: string }>, + ) => { + const u = ensureUser(state, action.payload.userId); + const chatId = action.payload.chatId; + delete u.chats[chatId]; + u.chatsOrder = u.chatsOrder.filter((id) => id !== chatId); + if (u.selectedChatId === chatId) u.selectedChatId = null; }, setSelectedChat: ( - state: TelegramState, - action: PayloadAction, + state: TelegramRootState, + action: PayloadAction<{ userId: string; chatId: string | null }>, ) => { - state.selectedChatId = action.payload; - // Clear selected thread when changing chat - if (action.payload !== state.selectedChatId) { - state.selectedThreadId = null; - } + const u = ensureUser(state, action.payload.userId); + const prev = u.selectedChatId; + u.selectedChatId = action.payload.chatId; + if (action.payload.chatId !== prev) u.selectedThreadId = null; }, - setChatsOrder: (state: TelegramState, action: PayloadAction) => { - state.chatsOrder = action.payload; + setChatsOrder: ( + state: TelegramRootState, + action: PayloadAction<{ userId: string; order: string[] }>, + ) => { + ensureUser(state, action.payload.userId).chatsOrder = action.payload.order; }, - - // Message actions addMessage: ( - state: TelegramState, - action: PayloadAction, + state: TelegramRootState, + action: PayloadAction<{ userId: string; message: TelegramMessage }>, ) => { - const message = action.payload; - const { chatId, id } = message; - - if (!state.messages[chatId]) { - state.messages[chatId] = {}; - state.messagesOrder[chatId] = []; + const u = ensureUser(state, action.payload.userId); + const { chatId, id } = action.payload.message; + if (!u.messages[chatId]) { + u.messages[chatId] = {}; + u.messagesOrder[chatId] = []; } - - if (!state.messages[chatId][id]) { - state.messages[chatId][id] = message; - state.messagesOrder[chatId].push(id); + if (!u.messages[chatId][id]) { + u.messages[chatId][id] = action.payload.message; + u.messagesOrder[chatId].push(id); } }, addMessages: ( - state: TelegramState, - action: PayloadAction<{ chatId: string; messages: TelegramMessage[] }>, + state: TelegramRootState, + action: PayloadAction<{ + userId: string; + chatId: string; + messages: TelegramMessage[]; + }>, ) => { + const u = ensureUser(state, action.payload.userId); const { chatId, messages } = action.payload; - - if (!state.messages[chatId]) { - state.messages[chatId] = {}; - state.messagesOrder[chatId] = []; + if (!u.messages[chatId]) { + u.messages[chatId] = {}; + u.messagesOrder[chatId] = []; } - - messages.forEach((message) => { - if (!state.messages[chatId][message.id]) { - state.messages[chatId][message.id] = message; - state.messagesOrder[chatId].push(message.id); + messages.forEach((m) => { + if (!u.messages[chatId][m.id]) { + u.messages[chatId][m.id] = m; + u.messagesOrder[chatId].push(m.id); } }); }, updateMessage: ( - state: TelegramState, + state: TelegramRootState, action: PayloadAction<{ + userId: string; chatId: string; messageId: string; updates: Partial; }>, ) => { + const u = ensureUser(state, action.payload.userId); const { chatId, messageId, updates } = action.payload; - if (state.messages[chatId]?.[messageId]) { - state.messages[chatId][messageId] = { - ...state.messages[chatId][messageId], + if (u.messages[chatId]?.[messageId]) { + u.messages[chatId][messageId] = { + ...u.messages[chatId][messageId], ...updates, }; } }, removeMessage: ( - state: TelegramState, - action: PayloadAction<{ chatId: string; messageId: string }>, + state: TelegramRootState, + action: PayloadAction<{ + userId: string; + chatId: string; + messageId: string; + }>, ) => { + const u = ensureUser(state, action.payload.userId); const { chatId, messageId } = action.payload; - if (state.messages[chatId]?.[messageId]) { - delete state.messages[chatId][messageId]; - state.messagesOrder[chatId] = state.messagesOrder[chatId].filter( + if (u.messages[chatId]?.[messageId]) { + delete u.messages[chatId][messageId]; + u.messagesOrder[chatId] = u.messagesOrder[chatId].filter( (id) => id !== messageId, ); } }, - clearMessages: (state: TelegramState, action: PayloadAction) => { - const chatId = action.payload; - delete state.messages[chatId]; - delete state.messagesOrder[chatId]; + clearMessages: ( + state: TelegramRootState, + action: PayloadAction<{ userId: string; chatId: string }>, + ) => { + const u = ensureUser(state, action.payload.userId); + delete u.messages[action.payload.chatId]; + delete u.messagesOrder[action.payload.chatId]; }, - - // Thread actions - addThread: (state: TelegramState, action: PayloadAction) => { - const thread = action.payload; - const { chatId, id } = thread; - - if (!state.threads[chatId]) { - state.threads[chatId] = {}; - state.threadsOrder[chatId] = []; + addThread: ( + state: TelegramRootState, + action: PayloadAction<{ userId: string; thread: TelegramThread }>, + ) => { + const u = ensureUser(state, action.payload.userId); + const { chatId, id } = action.payload.thread; + if (!u.threads[chatId]) { + u.threads[chatId] = {}; + u.threadsOrder[chatId] = []; } - - if (!state.threads[chatId][id]) { - state.threads[chatId][id] = thread; - state.threadsOrder[chatId].push(id); + if (!u.threads[chatId][id]) { + u.threads[chatId][id] = action.payload.thread; + u.threadsOrder[chatId].push(id); } }, updateThread: ( - state: TelegramState, + state: TelegramRootState, action: PayloadAction<{ + userId: string; chatId: string; threadId: string; updates: Partial; }>, ) => { + const u = ensureUser(state, action.payload.userId); const { chatId, threadId, updates } = action.payload; - if (state.threads[chatId]?.[threadId]) { - state.threads[chatId][threadId] = { - ...state.threads[chatId][threadId], + if (u.threads[chatId]?.[threadId]) { + u.threads[chatId][threadId] = { + ...u.threads[chatId][threadId], ...updates, }; } }, setSelectedThread: ( - state: TelegramState, - action: PayloadAction, + state: TelegramRootState, + action: PayloadAction<{ userId: string; threadId: string | null }>, ) => { - state.selectedThreadId = action.payload; + ensureUser(state, action.payload.userId).selectedThreadId = + action.payload.threadId; }, - - // Search actions setSearchQuery: ( - state: TelegramState, - action: PayloadAction, + state: TelegramRootState, + action: PayloadAction<{ userId: string; query: string | null }>, ) => { - state.searchQuery = action.payload; + ensureUser(state, action.payload.userId).searchQuery = action.payload.query; }, setFilteredChatIds: ( - state: TelegramState, - action: PayloadAction, + state: TelegramRootState, + action: PayloadAction<{ userId: string; chatIds: string[] | null }>, ) => { - state.filteredChatIds = action.payload; + ensureUser(state, action.payload.userId).filteredChatIds = + action.payload.chatIds; }, - - // Reset actions - // Note: resetTelegram is handled in index.ts to return initialState - resetChats: (state: TelegramState) => { - state.chats = {}; - state.chatsOrder = []; - state.selectedChatId = null; + resetChats: ( + state: TelegramRootState, + action: PayloadAction<{ userId: string }>, + ) => { + const u = ensureUser(state, action.payload.userId); + u.chats = {}; + u.chatsOrder = []; + u.selectedChatId = null; }, - resetMessages: (state: TelegramState) => { - state.messages = {}; - state.messagesOrder = {}; + resetMessages: ( + state: TelegramRootState, + action: PayloadAction<{ userId: string }>, + ) => { + const u = ensureUser(state, action.payload.userId); + u.messages = {}; + u.messagesOrder = {}; + }, + resetTelegramForUser: ( + state: TelegramRootState, + action: PayloadAction<{ userId: string }>, + ) => { + state.byUser[action.payload.userId] = { ...initialState }; }, }; diff --git a/src/store/telegram/thunks.ts b/src/store/telegram/thunks.ts index 26ecfe5b6..c42bce87d 100644 --- a/src/store/telegram/thunks.ts +++ b/src/store/telegram/thunks.ts @@ -1,50 +1,54 @@ -import { createAsyncThunk } from '@reduxjs/toolkit'; -import { mtprotoService } from '../../services/mtprotoService'; -import type { TelegramUser } from './types'; +import { createAsyncThunk } from "@reduxjs/toolkit"; +import { mtprotoService } from "../../services/mtprotoService"; +import type { TelegramUser } from "./types"; +import type { RootState } from "../index"; // Global flag to prevent concurrent checkAuthStatus calls let isCheckingAuth = false; let lastCheckTime = 0; -const MIN_CHECK_INTERVAL = 5000; // 5 seconds minimum between checks +const MIN_CHECK_INTERVAL = 5000; export const initializeTelegram = createAsyncThunk( - 'telegram/initialize', - async (_, { rejectWithValue }) => { + "telegram/initialize", + async (userId: string, { rejectWithValue }) => { try { - await mtprotoService.initialize(); + await mtprotoService.initialize(userId); const sessionString = mtprotoService.getSessionString(); return { sessionString }; } catch (error) { return rejectWithValue( - error instanceof Error ? error.message : 'Failed to initialize Telegram client' + error instanceof Error + ? error.message + : "Failed to initialize Telegram client", ); } - } + }, ); export const connectTelegram = createAsyncThunk( - 'telegram/connect', - async (_, { rejectWithValue }) => { + "telegram/connect", + async (_userId: string, { rejectWithValue }) => { try { await mtprotoService.connect(); return true; } catch (error) { return rejectWithValue( - error instanceof Error ? error.message : 'Failed to connect to Telegram' + error instanceof Error + ? error.message + : "Failed to connect to Telegram", ); } - } + }, ); export const checkAuthStatus = createAsyncThunk( - 'telegram/checkAuthStatus', - async (_, { rejectWithValue, getState }) => { - // Prevent concurrent calls + "telegram/checkAuthStatus", + async (userId: string, { rejectWithValue, getState }) => { 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; + const state = getState() as RootState; + const u = state.telegram.byUser[userId]; + return (u?.currentUser as TelegramUser) || null; } isCheckingAuth = true; @@ -52,16 +56,13 @@ export const checkAuthStatus = createAsyncThunk( try { const client = mtprotoService.getClient(); - - // First check if we're authorized without making API calls const isAuthorized = await client.checkAuthorization(); - + if (!isAuthorized) { isCheckingAuth = false; return null; } - - // Only call getMe() if we're authorized, with FLOOD_WAIT handling + try { const me = await mtprotoService.withFloodWaitHandling(async () => { return client.getMe(); @@ -69,29 +70,28 @@ export const checkAuthStatus = createAsyncThunk( 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); + 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')) { + if ( + error instanceof Error && + error.message.includes("AUTH_KEY_UNREGISTERED") + ) { return null; } return rejectWithValue( - error instanceof Error ? error.message : 'Failed to check auth status' + error instanceof Error ? error.message : "Failed to check auth status", ); } - } + }, ); export const fetchChats = createAsyncThunk( - 'telegram/fetchChats', - async (_, { rejectWithValue }) => { + "telegram/fetchChats", + async (_userId: string, { rejectWithValue }) => { try { const client = mtprotoService.getClient(); const dialogs = await mtprotoService.withFloodWaitHandling(async () => { @@ -100,30 +100,34 @@ export const fetchChats = createAsyncThunk( return dialogs; } catch (error) { return rejectWithValue( - error instanceof Error ? error.message : 'Failed to fetch chats' + error instanceof Error ? error.message : "Failed to fetch chats", ); } - } + }, ); export const fetchMessages = createAsyncThunk( - 'telegram/fetchMessages', + "telegram/fetchMessages", async ( - { chatId, limit = 50, offsetId }: { chatId: string; limit?: number; offsetId?: number }, - { rejectWithValue } + { + userId: _userId, + chatId, + limit = 50, + offsetId, + }: { userId: string; chatId: string; limit?: number; offsetId?: number }, + { rejectWithValue }, ) => { try { + void _userId; const client = mtprotoService.getClient(); - // Implementation depends on GramJS API - // This is a placeholder - adjust based on actual API const messages = await mtprotoService.withFloodWaitHandling(async () => { return client.getMessages(chatId, { limit, offsetId }); }); return { chatId, messages }; } catch (error) { return rejectWithValue( - error instanceof Error ? error.message : 'Failed to fetch messages' + error instanceof Error ? error.message : "Failed to fetch messages", ); } - } + }, ); diff --git a/src/store/telegram/types.ts b/src/store/telegram/types.ts index 5d5fdd70a..2e5a56293 100644 --- a/src/store/telegram/types.ts +++ b/src/store/telegram/types.ts @@ -1,6 +1,14 @@ // Types for Telegram entities -export type TelegramConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error'; -export type TelegramAuthStatus = 'not_authenticated' | 'authenticating' | 'authenticated' | 'error'; +export type TelegramConnectionStatus = + | "disconnected" + | "connecting" + | "connected" + | "error"; +export type TelegramAuthStatus = + | "not_authenticated" + | "authenticating" + | "authenticated" + | "error"; export interface TelegramUser { id: string; @@ -17,7 +25,7 @@ export interface TelegramUser { export interface TelegramChat { id: string; title?: string; - type: 'private' | 'group' | 'supergroup' | 'channel'; + type: "private" | "group" | "supergroup" | "channel"; username?: string; accessHash?: string; unreadCount: number; @@ -111,11 +119,11 @@ export interface TelegramState { export const initialState: TelegramState = { // Connection - connectionStatus: 'disconnected', + connectionStatus: "disconnected", connectionError: null, // Authentication - authStatus: 'not_authenticated', + authStatus: "not_authenticated", authError: null, isInitialized: false, phoneNumber: null, @@ -152,3 +160,8 @@ export const initialState: TelegramState = { searchQuery: null, filteredChatIds: null, }; + +/** Root telegram slice state: per-user */ +export interface TelegramRootState { + byUser: Record; +} diff --git a/src/store/telegramSelectors.ts b/src/store/telegramSelectors.ts index aa6d4c8d1..64d741725 100644 --- a/src/store/telegramSelectors.ts +++ b/src/store/telegramSelectors.ts @@ -1,60 +1,88 @@ -import { createSelector } from '@reduxjs/toolkit'; -import type { RootState } from './index'; -import type { TelegramChat, TelegramMessage, TelegramThread } from './telegram'; +import { createSelector } from "@reduxjs/toolkit"; +import type { RootState } from "./index"; +import type { + TelegramChat, + TelegramMessage, + TelegramThread, + TelegramState, +} from "./telegram"; +import { initialState } from "./telegram/types"; + +function selectCurrentUserId(state: RootState): string { + return state.user.user?._id ?? ""; +} + +const defaultUserState: TelegramState = { ...initialState }; -// Base selectors export const selectTelegramState = (state: RootState) => state.telegram; -export const selectConnectionStatus = (state: RootState) => state.telegram.connectionStatus; -export const selectConnectionError = (state: RootState) => state.telegram.connectionError; -export const selectIsConnected = (state: RootState) => - state.telegram.connectionStatus === 'connected'; +export const selectTelegramUserState = (state: RootState): TelegramState => + state.telegram.byUser[selectCurrentUserId(state)] ?? defaultUserState; -export const selectAuthStatus = (state: RootState) => state.telegram.authStatus; -export const selectAuthError = (state: RootState) => state.telegram.authError; +export const selectConnectionStatus = (state: RootState) => + selectTelegramUserState(state).connectionStatus; +export const selectConnectionError = (state: RootState) => + selectTelegramUserState(state).connectionError; +export const selectIsConnected = (state: RootState) => + selectTelegramUserState(state).connectionStatus === "connected"; + +export const selectAuthStatus = (state: RootState) => + selectTelegramUserState(state).authStatus; +export const selectAuthError = (state: RootState) => + selectTelegramUserState(state).authError; export const selectIsAuthenticated = (state: RootState) => - state.telegram.authStatus === 'authenticated'; -export const selectIsInitialized = (state: RootState) => state.telegram.isInitialized; -export const selectPhoneNumber = (state: RootState) => state.telegram.phoneNumber; -export const selectCurrentUser = (state: RootState) => state.telegram.currentUser; + selectTelegramUserState(state).authStatus === "authenticated"; +export const selectIsInitialized = (state: RootState) => + selectTelegramUserState(state).isInitialized; +export const selectPhoneNumber = (state: RootState) => + selectTelegramUserState(state).phoneNumber; +export const selectCurrentUser = (state: RootState) => + selectTelegramUserState(state).currentUser; + +export const selectSessionString = (state: RootState) => + selectTelegramUserState(state).sessionString; // Chat selectors -export const selectAllChats = (state: RootState) => state.telegram.chats; -export const selectChatsOrder = (state: RootState) => state.telegram.chatsOrder; -export const selectSelectedChatId = (state: RootState) => state.telegram.selectedChatId; -export const selectIsLoadingChats = (state: RootState) => state.telegram.isLoadingChats; +export const selectAllChats = (state: RootState) => + selectTelegramUserState(state).chats; +export const selectChatsOrder = (state: RootState) => + selectTelegramUserState(state).chatsOrder; +export const selectSelectedChatId = (state: RootState) => + selectTelegramUserState(state).selectedChatId; +export const selectIsLoadingChats = (state: RootState) => + selectTelegramUserState(state).isLoadingChats; -// Ordered chats selector export const selectOrderedChats = createSelector( [selectAllChats, selectChatsOrder], - (chats, order): TelegramChat[] => { - return order.map((id) => chats[id]).filter(Boolean); - } + (chats, order): TelegramChat[] => + order.map((id) => chats[id]).filter(Boolean), ); -// Selected chat selector export const selectSelectedChat = createSelector( [selectAllChats, selectSelectedChatId], - (chats, selectedId): TelegramChat | null => { - return selectedId ? chats[selectedId] || null : null; - } + (chats, selectedId): TelegramChat | null => + selectedId ? chats[selectedId] || null : null, ); -// Filtered chats selector (for search) export const selectFilteredChats = createSelector( - [selectOrderedChats, (state: RootState) => state.telegram.filteredChatIds], + [ + selectOrderedChats, + (state: RootState) => selectTelegramUserState(state).filteredChatIds, + ], (chats, filteredIds): TelegramChat[] => { if (!filteredIds) return chats; return chats.filter((chat) => filteredIds.includes(chat.id)); - } + }, ); // Message selectors -export const selectAllMessages = (state: RootState) => state.telegram.messages; -export const selectMessagesOrder = (state: RootState) => state.telegram.messagesOrder; -export const selectIsLoadingMessages = (state: RootState) => state.telegram.isLoadingMessages; +export const selectAllMessages = (state: RootState) => + selectTelegramUserState(state).messages; +export const selectMessagesOrder = (state: RootState) => + selectTelegramUserState(state).messagesOrder; +export const selectIsLoadingMessages = (state: RootState) => + selectTelegramUserState(state).isLoadingMessages; -// Messages for a specific chat export const selectChatMessages = createSelector( [ selectAllMessages, @@ -66,24 +94,25 @@ export const selectChatMessages = createSelector( const order = messagesOrder[chatId] || []; if (!chatMessages) return []; return order.map((id) => chatMessages[id]).filter(Boolean); - } + }, ); -// Latest message for a chat export const selectChatLatestMessage = createSelector( [selectChatMessages], - (messages): TelegramMessage | null => { - return messages.length > 0 ? messages[messages.length - 1] : null; - } + (messages): TelegramMessage | null => + messages.length > 0 ? messages[messages.length - 1] : null, ); // Thread selectors -export const selectAllThreads = (state: RootState) => state.telegram.threads; -export const selectThreadsOrder = (state: RootState) => state.telegram.threadsOrder; -export const selectSelectedThreadId = (state: RootState) => state.telegram.selectedThreadId; -export const selectIsLoadingThreads = (state: RootState) => state.telegram.isLoadingThreads; +export const selectAllThreads = (state: RootState) => + selectTelegramUserState(state).threads; +export const selectThreadsOrder = (state: RootState) => + selectTelegramUserState(state).threadsOrder; +export const selectSelectedThreadId = (state: RootState) => + selectTelegramUserState(state).selectedThreadId; +export const selectIsLoadingThreads = (state: RootState) => + selectTelegramUserState(state).isLoadingThreads; -// Threads for a specific chat export const selectChatThreads = createSelector( [ selectAllThreads, @@ -95,72 +124,63 @@ export const selectChatThreads = createSelector( const order = threadsOrder[chatId] || []; if (!chatThreads) return []; return order.map((id) => chatThreads[id]).filter(Boolean); - } + }, ); -// Selected thread selector export const selectSelectedThread = createSelector( [selectAllThreads, selectSelectedChatId, selectSelectedThreadId], (threads, chatId, threadId): TelegramThread | null => { if (!chatId || !threadId) return null; return threads[chatId]?.[threadId] || null; - } + }, ); -// Messages for selected thread export const selectThreadMessages = createSelector( - [ - selectChatMessages, - selectSelectedChatId, - selectSelectedThreadId, - ], + [selectChatMessages, selectSelectedChatId, selectSelectedThreadId], (messages, _chatId, threadId): TelegramMessage[] => { if (!threadId) return []; return messages.filter((msg) => msg.threadId === threadId); - } + }, ); // Search selectors -export const selectSearchQuery = (state: RootState) => state.telegram.searchQuery; -export const selectIsSearching = (state: RootState) => state.telegram.searchQuery !== null; +export const selectSearchQuery = (state: RootState) => + selectTelegramUserState(state).searchQuery; +export const selectIsSearching = (state: RootState) => + selectTelegramUserState(state).searchQuery !== null; // Pagination selectors -export const selectHasMoreChats = (state: RootState) => state.telegram.hasMoreChats; -export const selectHasMoreMessages = (state: RootState) => state.telegram.hasMoreMessages; -export const selectHasMoreThreads = (state: RootState) => state.telegram.hasMoreThreads; +export const selectHasMoreChats = (state: RootState) => + selectTelegramUserState(state).hasMoreChats; +export const selectHasMoreMessages = (state: RootState) => + selectTelegramUserState(state).hasMoreMessages; +export const selectHasMoreThreads = (state: RootState) => + selectTelegramUserState(state).hasMoreThreads; -// Helper: Check if chat has more messages export const selectChatHasMoreMessages = createSelector( [selectHasMoreMessages, (_: RootState, chatId: string) => chatId], - (hasMore, chatId) => hasMore[chatId] ?? true + (hasMore, chatId) => hasMore[chatId] ?? true, ); -// Helper: Check if chat has more threads export const selectChatHasMoreThreads = createSelector( [selectHasMoreThreads, (_: RootState, chatId: string) => chatId], - (hasMore, chatId) => hasMore[chatId] ?? true + (hasMore, chatId) => hasMore[chatId] ?? true, ); -// Combined state selectors export const selectTelegramReady = createSelector( [selectIsConnected, selectIsAuthenticated, selectIsInitialized], - (isConnected, isAuthenticated, isInitialized) => { - return isConnected && isAuthenticated && isInitialized; - } + (isConnected, isAuthenticated, isInitialized) => + isConnected && isAuthenticated && isInitialized, ); -// Unread counts export const selectTotalUnreadCount = createSelector( [selectOrderedChats], - (chats) => { - return chats.reduce((total, chat) => total + (chat.unreadCount || 0), 0); - } + (chats) => chats.reduce((total, chat) => total + (chat.unreadCount || 0), 0), ); -// Pinned chats export const selectPinnedChats = createSelector( [selectOrderedChats], - (chats): TelegramChat[] => { - return chats.filter((chat) => chat.isPinned); - } + (chats): TelegramChat[] => chats.filter((chat) => chat.isPinned), ); + +export { selectCurrentUserId as selectTelegramCurrentUserId }; diff --git a/src/store/userSlice.ts b/src/store/userSlice.ts index 3c102848b..b1fe82238 100644 --- a/src/store/userSlice.ts +++ b/src/store/userSlice.ts @@ -1,6 +1,6 @@ -import { createSlice, PayloadAction, createAsyncThunk } from '@reduxjs/toolkit'; -import { userApi } from '../services/api/userApi'; -import type { User } from '../types/api'; +import { createSlice, PayloadAction, createAsyncThunk } from "@reduxjs/toolkit"; +import { userApi } from "../services/api/userApi"; +import type { User } from "../types/api"; interface UserState { user: User | null; @@ -18,23 +18,23 @@ const initialState: UserState = { * Async thunk to fetch current user data */ export const fetchCurrentUser = createAsyncThunk( - 'user/fetchCurrentUser', + "user/fetchCurrentUser", async (_, { rejectWithValue }) => { try { const user = await userApi.getMe(); return user; } catch (error) { const errorMessage = - error && typeof error === 'object' && 'error' in error + error && typeof error === "object" && "error" in error ? String(error.error) - : 'Failed to fetch user data'; + : "Failed to fetch user data"; return rejectWithValue(errorMessage); } - } + }, ); const userSlice = createSlice({ - name: 'user', + name: "user", initialState, reducers: { setUser: (state, action: PayloadAction) => { diff --git a/src/styles/theme.css b/src/styles/theme.css index a71b48643..79ee32f0a 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -3,13 +3,13 @@ ============================================ */ /* Import premium fonts */ -@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap'); -@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&display=swap'); +@import url("https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap"); +@import url("https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&display=swap"); /* Custom font declarations for premium typography */ @font-face { - font-family: 'Cabinet Grotesk'; - src: url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap'); + font-family: "Cabinet Grotesk"; + src: url("https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap"); font-display: swap; } @@ -18,28 +18,28 @@ ============================================ */ :root { /* Semantic color tokens */ - --color-background: theme('colors.canvas.50'); - --color-surface: theme('colors.white'); - --color-surface-elevated: theme('colors.canvas.100'); - --color-border: theme('colors.stone.200'); - --color-border-subtle: theme('colors.stone.100'); + --color-background: theme("colors.canvas.50"); + --color-surface: theme("colors.white"); + --color-surface-elevated: theme("colors.canvas.100"); + --color-border: theme("colors.stone.200"); + --color-border-subtle: theme("colors.stone.100"); /* Text colors with hierarchy */ - --color-text-primary: theme('colors.stone.900'); - --color-text-secondary: theme('colors.stone.600'); - --color-text-tertiary: theme('colors.stone.500'); - --color-text-muted: theme('colors.stone.400'); + --color-text-primary: theme("colors.stone.900"); + --color-text-secondary: theme("colors.stone.600"); + --color-text-tertiary: theme("colors.stone.500"); + --color-text-muted: theme("colors.stone.400"); /* Interaction states */ - --color-hover: theme('colors.primary.50'); - --color-active: theme('colors.primary.100'); - --color-focus: theme('colors.primary.500'); + --color-hover: theme("colors.primary.50"); + --color-active: theme("colors.primary.100"); + --color-focus: theme("colors.primary.500"); /* Semantic status colors */ - --color-success: theme('colors.sage.500'); - --color-warning: theme('colors.amber.500'); - --color-error: theme('colors.coral.500'); - --color-info: theme('colors.primary.500'); + --color-success: theme("colors.sage.500"); + --color-warning: theme("colors.amber.500"); + --color-error: theme("colors.coral.500"); + --color-info: theme("colors.primary.500"); /* Spacing rhythm */ --spacing-unit: 0.25rem; @@ -75,7 +75,10 @@ html { -moz-osx-font-smoothing: grayscale; text-rendering: optimizeLegibility; scroll-behavior: smooth; - font-feature-settings: 'kern' 1, 'liga' 1, 'calt' 1; + font-feature-settings: + "kern" 1, + "liga" 1, + "calt" 1; } body { @@ -89,7 +92,7 @@ body { /* Subtle noise texture overlay for depth */ body::before { - content: ''; + content: ""; position: fixed; top: 0; left: 0; @@ -105,7 +108,7 @@ body::before { Typography Enhancements ============================================ */ .text-display { - font-family: theme('fontFamily.display'); + font-family: theme("fontFamily.display"); font-weight: 700; letter-spacing: -0.04em; line-height: 1.1; @@ -116,16 +119,20 @@ body::before { } .text-gradient { - background: linear-gradient(135deg, theme('colors.primary.600') 0%, theme('colors.accent.lavender') 100%); + background: linear-gradient( + 135deg, + theme("colors.primary.600") 0%, + theme("colors.accent.lavender") 100% + ); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; } .text-crypto-price { - font-family: theme('fontFamily.mono'); + font-family: theme("fontFamily.mono"); font-variant-numeric: tabular-nums; - font-feature-settings: 'tnum' 1; + font-feature-settings: "tnum" 1; } /* ============================================ @@ -169,13 +176,13 @@ body::before { box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.03), 0 4px 8px rgba(0, 0, 0, 0.04), - 0 16px 24px -8px rgba(0, 0, 0, 0.10), + 0 16px 24px -8px rgba(0, 0, 0, 0.1), 0 32px 64px -16px rgba(0, 0, 0, 0.14); } .card-interactive { @apply bg-white rounded-xl p-6; - border: 1px solid theme('colors.stone.200'); + border: 1px solid theme("colors.stone.200"); transition: all var(--transition-fast); cursor: pointer; position: relative; @@ -183,23 +190,25 @@ body::before { } .card-interactive::before { - content: ''; + content: ""; position: absolute; top: 0; left: 0; right: 0; height: 2px; - background: linear-gradient(90deg, - theme('colors.primary.500') 0%, - theme('colors.accent.lavender') 50%, - theme('colors.accent.sky') 100%); + background: linear-gradient( + 90deg, + theme("colors.primary.500") 0%, + theme("colors.accent.lavender") 50%, + theme("colors.accent.sky") 100% + ); transform: scaleX(0); transition: transform var(--transition-base); } .card-interactive:hover { - border-color: theme('colors.primary.200'); - background: theme('colors.primary.50'); + border-color: theme("colors.primary.200"); + background: theme("colors.primary.50"); } .card-interactive:hover::before { @@ -211,27 +220,33 @@ body::before { ============================================ */ .btn-premium { @apply relative inline-flex items-center justify-center px-6 py-3 rounded-xl font-medium; - background: linear-gradient(135deg, theme('colors.primary.500') 0%, theme('colors.primary.600') 100%); + background: linear-gradient( + 135deg, + theme("colors.primary.500") 0%, + theme("colors.primary.600") 100% + ); color: white; box-shadow: 0 2px 4px rgba(91, 155, 243, 0.24), - 0 8px 16px -4px rgba(91, 155, 243, 0.20), + 0 8px 16px -4px rgba(91, 155, 243, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.12); transition: all var(--transition-base); overflow: hidden; } .btn-premium::before { - content: ''; + content: ""; position: absolute; top: 0; left: 0; width: 100%; height: 100%; - background: linear-gradient(135deg, + background: linear-gradient( + 135deg, transparent 0%, rgba(255, 255, 255, 0.2) 50%, - transparent 100%); + transparent 100% + ); transform: translateX(-100%); transition: transform 0.6s; } @@ -257,7 +272,7 @@ body::before { background: rgba(255, 255, 255, 0.1); backdrop-filter: blur(10px); border: 1px solid rgba(255, 255, 255, 0.2); - color: theme('colors.stone.700'); + color: theme("colors.stone.700"); transition: all var(--transition-base); } @@ -270,29 +285,31 @@ body::before { .btn-outline { @apply relative inline-flex items-center justify-center px-6 py-3 rounded-xl font-medium; background: transparent; - border: 1.5px solid theme('colors.stone.300'); - color: theme('colors.stone.700'); + border: 1.5px solid theme("colors.stone.300"); + color: theme("colors.stone.700"); transition: all var(--transition-base); position: relative; overflow: hidden; } .btn-outline::before { - content: ''; + content: ""; position: absolute; top: 50%; left: 50%; width: 0; height: 0; border-radius: 50%; - background: theme('colors.primary.50'); + background: theme("colors.primary.50"); transform: translate(-50%, -50%); - transition: width 0.6s, height 0.6s; + transition: + width 0.6s, + height 0.6s; } .btn-outline:hover { - border-color: theme('colors.primary.500'); - color: theme('colors.primary.600'); + border-color: theme("colors.primary.500"); + color: theme("colors.primary.600"); } .btn-outline:hover::before { @@ -306,23 +323,23 @@ body::before { .input-elevated { @apply w-full px-4 py-3 rounded-xl; background: white; - border: 1px solid theme('colors.stone.200'); - color: theme('colors.stone.900'); - font-size: theme('fontSize.base'); + border: 1px solid theme("colors.stone.200"); + color: theme("colors.stone.900"); + font-size: theme("fontSize.base"); transition: all var(--transition-fast); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); } .input-elevated:focus { outline: none; - border-color: theme('colors.primary.500'); + border-color: theme("colors.primary.500"); box-shadow: - 0 0 0 3px theme('colors.primary.50'), + 0 0 0 3px theme("colors.primary.50"), 0 1px 2px rgba(0, 0, 0, 0.04); } .input-elevated::placeholder { - color: theme('colors.stone.400'); + color: theme("colors.stone.400"); } /* ============================================ @@ -330,30 +347,30 @@ body::before { ============================================ */ .nav-item-premium { @apply relative flex items-center px-4 py-2.5 rounded-xl; - color: theme('colors.stone.600'); + color: theme("colors.stone.600"); transition: all var(--transition-fast); font-weight: 500; } .nav-item-premium:hover { - color: theme('colors.stone.900'); - background: theme('colors.stone.50'); + color: theme("colors.stone.900"); + background: theme("colors.stone.50"); } .nav-item-premium.active { - color: theme('colors.primary.600'); - background: theme('colors.primary.50'); + color: theme("colors.primary.600"); + background: theme("colors.primary.50"); } .nav-item-premium.active::before { - content: ''; + content: ""; position: absolute; left: 0; top: 50%; transform: translateY(-50%); width: 3px; height: 20px; - background: theme('colors.primary.500'); + background: theme("colors.primary.500"); border-radius: 0 3px 3px 0; } @@ -362,11 +379,13 @@ body::before { ============================================ */ .badge-premium { @apply inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-semibold; - background: linear-gradient(135deg, - theme('colors.primary.50') 0%, - theme('colors.accent.lavender/10') 100%); - color: theme('colors.primary.700'); - border: 1px solid theme('colors.primary.200'); + background: linear-gradient( + 135deg, + theme("colors.primary.50") 0%, + theme("colors.accent.lavender/10") 100% + ); + color: theme("colors.primary.700"); + border: 1px solid theme("colors.primary.200"); } .status-indicator { @@ -408,18 +427,26 @@ body::before { .market-card { @apply relative overflow-hidden rounded-2xl p-6; - background: linear-gradient(135deg, white 0%, theme('colors.canvas.100') 100%); - border: 1px solid theme('colors.stone.200'); + background: linear-gradient( + 135deg, + white 0%, + theme("colors.canvas.100") 100% + ); + border: 1px solid theme("colors.stone.200"); } .market-card::before { - content: ''; + content: ""; position: absolute; top: -50%; right: -50%; width: 200%; height: 200%; - background: radial-gradient(circle, theme('colors.primary.500/5') 0%, transparent 70%); + background: radial-gradient( + circle, + theme("colors.primary.500/5") 0%, + transparent 70% + ); animation: float 20s ease-in-out infinite; } @@ -429,14 +456,14 @@ body::before { .chart-container { @apply relative rounded-xl p-4; background: white; - border: 1px solid theme('colors.stone.200'); + border: 1px solid theme("colors.stone.200"); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); } .chart-grid { background-image: - linear-gradient(0deg, theme('colors.stone.100') 1px, transparent 1px), - linear-gradient(90deg, theme('colors.stone.100') 1px, transparent 1px); + linear-gradient(0deg, theme("colors.stone.100") 1px, transparent 1px), + linear-gradient(90deg, theme("colors.stone.100") 1px, transparent 1px); background-size: 20px 20px; } @@ -448,7 +475,7 @@ body::before { } .skeleton::after { - content: ''; + content: ""; position: absolute; top: 0; right: 0; @@ -457,7 +484,7 @@ body::before { background: linear-gradient( 90deg, transparent 0%, - theme('colors.stone.100') 50%, + theme("colors.stone.100") 50%, transparent 100% ); animation: shimmer 2s linear infinite; @@ -477,7 +504,7 @@ body::before { ============================================ */ .scrollbar-elegant { scrollbar-width: thin; - scrollbar-color: theme('colors.stone.300') transparent; + scrollbar-color: theme("colors.stone.300") transparent; } .scrollbar-elegant::-webkit-scrollbar { @@ -490,14 +517,14 @@ body::before { } .scrollbar-elegant::-webkit-scrollbar-thumb { - background: theme('colors.stone.300'); + background: theme("colors.stone.300"); border-radius: 4px; border: 2px solid transparent; background-clip: padding-box; } .scrollbar-elegant::-webkit-scrollbar-thumb:hover { - background: theme('colors.stone.400'); + background: theme("colors.stone.400"); } /* ============================================ @@ -519,7 +546,7 @@ body::before { Accessibility Enhancements ============================================ */ .focus-visible:focus { - outline: 2px solid theme('colors.primary.500'); + outline: 2px solid theme("colors.primary.500"); outline-offset: 2px; } @@ -540,20 +567,20 @@ body::before { ============================================ */ @media (prefers-color-scheme: dark) { :root { - --color-background: theme('colors.slate.950'); - --color-surface: theme('colors.slate.900'); - --color-surface-elevated: theme('colors.slate.800'); - --color-border: theme('colors.slate.700'); - --color-border-subtle: theme('colors.slate.800'); + --color-background: theme("colors.slate.950"); + --color-surface: theme("colors.slate.900"); + --color-surface-elevated: theme("colors.slate.800"); + --color-border: theme("colors.slate.700"); + --color-border-subtle: theme("colors.slate.800"); - --color-text-primary: theme('colors.slate.50'); - --color-text-secondary: theme('colors.slate.300'); - --color-text-tertiary: theme('colors.slate.400'); - --color-text-muted: theme('colors.slate.500'); + --color-text-primary: theme("colors.slate.50"); + --color-text-secondary: theme("colors.slate.300"); + --color-text-tertiary: theme("colors.slate.400"); + --color-text-muted: theme("colors.slate.500"); } .glass-surface { background: rgba(15, 23, 42, 0.7); border: 1px solid rgba(255, 255, 255, 0.1); } -} \ No newline at end of file +} diff --git a/src/types/api.ts b/src/types/api.ts index e060d5785..39532bc8c 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -16,7 +16,7 @@ export interface ApiError { // User types based on backend ITgUser model export interface UserSubscription { hasActiveSubscription: boolean; - plan: 'FREE' | 'BASIC' | 'PRO'; + plan: "FREE" | "BASIC" | "PRO"; planExpiry?: string; stripeCustomerId?: string; } @@ -42,20 +42,20 @@ export interface UserSettings { dailySummaryUtcTriggerHour?: number; dailySummaryChatIds: number[]; autoCompleteEnabled: boolean; - autoCompleteVisibility: 'always' | 'groups_only' | 'private_chats_only'; + autoCompleteVisibility: "always" | "groups_only" | "private_chats_only"; autoCompleteWhitelistChatIds: number[]; autoCompleteBlacklistChatIds: number[]; } export interface User { - id: string; + _id: string; telegramId: number; hasAccess: boolean; magicWord: string; referral: UserReferral; subscription: UserSubscription; usage: UserUsage; - role: 'admin' | 'team' | 'user'; + role: "admin" | "team" | "user"; settings: UserSettings; autoDeleteTelegramMessagesAfterDays: number; autoDeleteThreadsAfterDays: number; diff --git a/src/types/onboarding.ts b/src/types/onboarding.ts index bb42c3178..88e612edf 100644 --- a/src/types/onboarding.ts +++ b/src/types/onboarding.ts @@ -39,4 +39,4 @@ export interface Country { name: string; flag: string; dialCode: string; -} \ No newline at end of file +} diff --git a/src/utils/deeplink.ts b/src/utils/deeplink.ts index 96ef6216e..9856793ae 100644 --- a/src/utils/deeplink.ts +++ b/src/utils/deeplink.ts @@ -1,7 +1,7 @@ -export type WebLoginMethod = 'phone' | 'telegram'; +export type WebLoginMethod = "phone" | "telegram"; export interface PhoneLoginContext { - method: 'phone'; + method: "phone"; phoneNumber: string; countryCode: string; } @@ -9,13 +9,13 @@ export interface PhoneLoginContext { // The shape of the Telegram user object is defined by Telegram. // We keep it as unknown here and let the backend interpret it. export interface TelegramLoginContext { - method: 'telegram'; + method: "telegram"; telegramUser: unknown; } export type WebLoginContext = PhoneLoginContext | TelegramLoginContext; -const DESKTOP_SCHEME = 'outsourced'; +const DESKTOP_SCHEME = "outsourced"; export const buildDesktopDeeplink = (token: string): string => { const encoded = encodeURIComponent(token); @@ -32,25 +32,24 @@ export const buildDesktopDeeplink = (token: string): string => { export const completeWebLoginAndOpenDesktop = async ( context: WebLoginContext, ): Promise => { - const response = await fetch('/api/auth/web-complete', { - method: 'POST', + const response = await fetch("/api/auth/web-complete", { + method: "POST", headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", }, body: JSON.stringify(context), - credentials: 'include', + credentials: "include", }); if (!response.ok) { - throw new Error('Failed to complete web login for desktop handoff'); + throw new Error("Failed to complete web login for desktop handoff"); } const data = (await response.json()) as { loginToken?: string }; if (!data.loginToken) { - throw new Error('Backend response did not include a loginToken'); + throw new Error("Backend response did not include a loginToken"); } const deeplink = buildDesktopDeeplink(data.loginToken); window.location.href = deeplink; }; - diff --git a/src/utils/desktopDeepLinkListener.ts b/src/utils/desktopDeepLinkListener.ts index bc9e055dc..8e118190b 100644 --- a/src/utils/desktopDeepLinkListener.ts +++ b/src/utils/desktopDeepLinkListener.ts @@ -1,14 +1,14 @@ -import { getCurrent, onOpenUrl } from '@tauri-apps/plugin-deep-link'; -import { invoke } from '@tauri-apps/api/core'; -import { BACKEND_URL } from './config'; -import { store } from '../store'; -import { setToken } from '../store/authSlice'; +import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link"; +import { invoke } from "@tauri-apps/api/core"; +import { BACKEND_URL } from "./config"; +import { store } from "../store"; +import { setToken } from "../store/authSlice"; /** * Check if running in Tauri desktop app */ const isTauri = (): boolean => { - return typeof window !== 'undefined' && !!(window as any).__TAURI__; + return typeof window !== "undefined" && !!(window as any).__TAURI__; }; /** @@ -25,17 +25,17 @@ const handleDeepLinkUrls = async (urls: string[] | null | undefined) => { try { const parsed = new URL(url); - if (parsed.protocol !== 'outsourced:') { + if (parsed.protocol !== "outsourced:") { return; } - const token = parsed.searchParams.get('token'); + const token = parsed.searchParams.get("token"); if (!token) { - console.warn('[DeepLink] URL did not contain a token query parameter'); + console.warn("[DeepLink] URL did not contain a token query parameter"); return; } - console.log('[DeepLink] Received token'); + console.log("[DeepLink] Received token"); let sessionToken: string | undefined; @@ -44,15 +44,15 @@ const handleDeepLinkUrls = async (urls: string[] | null | undefined) => { // Use Tauri invoke to call Rust backend (bypasses CORS) const data = await invoke<{ sessionToken?: string; - }>('exchange_token', { backendUrl: BACKEND_URL, token }); + }>("exchange_token", { backendUrl: BACKEND_URL, token }); sessionToken = data.sessionToken; } else { // Browser fallback: use fetch API const response = await fetch(`${BACKEND_URL}/auth/desktop-exchange`, { - method: 'POST', + method: "POST", headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", }, body: JSON.stringify({ token }), }); @@ -63,7 +63,7 @@ const handleDeepLinkUrls = async (urls: string[] | null | undefined) => { } } } catch (err) { - console.warn('[DeepLink] Token exchange failed:', err); + console.warn("[DeepLink] Token exchange failed:", err); } // If the backend didn't return a session, store the raw token so the @@ -78,9 +78,9 @@ const handleDeepLinkUrls = async (urls: string[] | null | undefined) => { // Navigate to post-login flow. This listener runs outside the React // router context, so we assign the path directly and reload. - window.location.replace('/onboarding/step1'); + window.location.replace("/onboarding/step1"); } catch (error) { - console.error('[DeepLink] Failed to handle deep link URL:', url, error); + console.error("[DeepLink] Failed to handle deep link URL:", url, error); } }; @@ -101,10 +101,10 @@ export const setupDesktopDeepLinkListener = async () => { await handleDeepLinkUrls(startUrls); } - await onOpenUrl(urls => { + await onOpenUrl((urls) => { void handleDeepLinkUrls(urls); }); } catch (err) { - console.error('[DeepLink] Setup failed:', err); + console.error("[DeepLink] Setup failed:", err); } }; diff --git a/src/utils/openUrl.ts b/src/utils/openUrl.ts index 678791199..9adda2be6 100644 --- a/src/utils/openUrl.ts +++ b/src/utils/openUrl.ts @@ -1,4 +1,4 @@ -import { openUrl as tauriOpenUrl } from '@tauri-apps/plugin-opener'; +import { openUrl as tauriOpenUrl } from "@tauri-apps/plugin-opener"; /** * Opens a URL in the default browser or app. @@ -11,11 +11,11 @@ export const openUrl = async (url: string): Promise => { await tauriOpenUrl(url); return; } catch (error) { - console.error('Failed to open URL with Tauri:', error); + console.error("Failed to open URL with Tauri:", error); // Fall through to browser fallback } } // Browser fallback - window.open(url, '_blank', 'noopener,noreferrer'); + window.open(url, "_blank", "noopener,noreferrer"); };