From 520bfdb74c8fb1d40404cef4c2eb94ec3a4aee90 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Thu, 29 Jan 2026 07:38:34 +0530 Subject: [PATCH] Fix/telegram login (#4) * Add UserProvider to App component and enhance user data fetching logic - Integrated UserProvider into the App component to manage user state. - Updated UserProvider to fetch current user data on token availability and handle token expiration by clearing the token on fetch failure. - Improved user data fetching logic for better error handling and user experience. * Enhance Telegram connection handling and error management - Added logic to handle Telegram account mismatch, providing user feedback and resetting connection state. - Improved user verification during authentication to ensure the logged-in account matches the Telegram account. - Refactored connection status checks and error handling for better user experience. - Introduced a method to clear session and disconnect when account mismatch occurs. - Updated polling mechanism for authentication status to ensure timely updates. This update improves the robustness of the Telegram connection process and enhances user feedback during authentication failures. * ran prettier * Refactor socket and auth state management for user-specific handling - Updated socket state management to support multiple users by introducing user-specific selectors and actions. - Refactored connection status handling in the socket service to dispatch user-specific status updates. - Enhanced onboarding state management to track completion per user, allowing for more granular control. - Introduced new selectors for socket and auth states to improve readability and maintainability. - Updated relevant components to utilize the new user-specific state management. This update improves the application's ability to handle multiple users and enhances the overall user experience during onboarding and socket connections. * Enhance MTProto client session management and introduce user-specific session handling - Updated the MTProtoService to manage sessions per user by storing session data under a user-specific key. - Modified the initialize method to accept a userId parameter, allowing for user-specific session initialization and management. - Improved session loading and saving logic to ensure proper handling of user sessions. - Added a utility function to generate session keys based on userId for better maintainability. This update enhances the application's ability to handle multiple user sessions effectively. * Refactor Telegram state management for user-specific handling - Introduced user-specific state management in the Telegram store, allowing for better handling of multiple users. - Updated selectors to retrieve state based on the current user, improving data encapsulation and reducing global state dependencies. - Enhanced reducers and thunks to ensure actions are dispatched with user context, maintaining user-specific data integrity. - Modified persistence configuration to store Telegram state scoped by user, ensuring isolated state management. This update significantly improves the application's ability to manage multiple user sessions effectively and enhances overall user experience. * Enhance MTProtoService connection handling with user-specific initialization - Updated the checkConnection method to accept an optional userId parameter, allowing for user-specific connection initialization. - Modified the connection logic to ensure proper initialization only occurs when a userId is provided, improving session management. - Refactored related thunks to use a consistent naming convention for userId parameters, enhancing code clarity. This update improves the handling of user sessions in the MTProtoService, aligning with recent enhancements in user-specific state management. * Refactor Telegram connection handling for user-specific management - Updated the TelegramConnectionModal to utilize user-specific identifiers for connection status and authentication processes, enhancing session management. - Refactored related components and selectors to ensure userId is consistently passed and utilized, improving clarity and maintainability. - Enhanced the ConnectionsPanel and ConnectStep to check for saved sessions based on userId, allowing for better handling of multiple user connections. This update significantly improves the application's ability to manage user-specific Telegram connections and enhances the overall user experience. * Refactor Telegram connection logic to simplify session management - Removed unnecessary userId checks and localStorage interactions from ConnectionsPanel and ConnectStep components, streamlining the connection status determination. - Updated MTProtoService to manage session data through Redux instead of localStorage, enhancing consistency and maintainability. - Improved session loading and saving logic to focus solely on Redux state, ensuring better integration with user-specific session handling. This update enhances the clarity and efficiency of the Telegram connection management process, aligning with recent improvements in user-specific state management. * Update CLAUDE.md to clarify localStorage usage and emphasize Redux for state management - Revised documentation to discourage the use of localStorage and sessionStorage for app state, advocating for Redux and Redux Persist instead. - Added guidelines for removing existing localStorage usage and migrating relevant data to Redux. - Updated service layer documentation to reflect changes in session management, specifying that session data is now stored in Redux rather than localStorage. - Enhanced clarity on deep link handling and the prevention of infinite reload loops. This update aligns with recent improvements in user-specific state management and reinforces best practices for state handling. * Enhance onboarding process with error handling and API integration - Updated the Onboarding component to include an API call for marking onboarding as complete, with error handling to provide user feedback. - Refactored the GetStartedStep component to support asynchronous completion handling, including loading state and error messages. - Introduced a new onboardingComplete method in the userApi service to facilitate the onboarding completion process. This update improves the user experience during onboarding by ensuring proper error management and feedback. * Enhance TelegramConnectionModal with socket connection management and improved initialization flow - Integrated socket connection handling in the TelegramConnectionModal to ensure the socket is connected when the modal opens, enhancing real-time features. - Refactored the initialization logic to streamline the connection process, including user verification and error handling. - Introduced a reference to track the QR code flow state, preventing multiple initiations and improving user experience during authentication. This update improves the reliability of the Telegram connection process and enhances user feedback during the onboarding experience. * Refactor Telegram connection checks for improved logic consistency - Updated the ConnectionsPanel and ConnectStep components to refine the logic for determining if a Telegram connection is established, ensuring that both session string and authentication status are required for a valid connection. - Simplified className definitions in the UI components for better readability and maintainability. This update enhances the clarity of connection status checks and improves the overall user experience during the onboarding process. * Refactor Telegram authentication flow and improve error handling - Simplified the TelegramLoginButton component by removing complex authentication logic and replacing it with a direct link to the Telegram bot for login. - Enhanced the Login page to consume a login token from the URL, providing better error handling and user feedback during the authentication process. - Introduced a new API service for consuming login tokens, ensuring secure retrieval of JWT tokens from the backend. This update streamlines the authentication experience and improves error management, enhancing overall user experience during login. --- CLAUDE.md | 14 +- src/App.tsx | 31 +- src/AppRoutes.tsx | 30 +- src/assets/icons/GmailIcon.tsx | 4 +- src/assets/icons/GoogleIcon.tsx | 20 +- src/components/ConnectionIndicator.tsx | 33 +- src/components/DefaultRedirect.tsx | 7 +- src/components/DesignSystemShowcase.tsx | 179 +++++-- src/components/GmailConnectionIndicator.tsx | 18 +- src/components/LottieAnimation.tsx | 15 +- src/components/PrivacyFeatureCard.tsx | 9 +- src/components/ProgressIndicator.tsx | 7 +- src/components/ProtectedRoute.tsx | 11 +- src/components/PublicRoute.tsx | 9 +- .../TelegramConnectionIndicator.tsx | 28 +- src/components/TelegramConnectionModal.tsx | 470 ++++++++++++------ src/components/TelegramLoginButton.tsx | 294 +---------- src/components/TypewriterGreeting.tsx | 17 +- src/components/settings/SettingsHome.tsx | 235 ++++++--- src/components/settings/SettingsLayout.tsx | 18 +- src/components/settings/SettingsModal.tsx | 26 +- .../components/SettingsBackButton.tsx | 6 +- .../settings/components/SettingsHeader.tsx | 47 +- .../settings/components/SettingsMenuItem.tsx | 29 +- .../components/SettingsPanelLayout.tsx | 16 +- .../settings/hooks/useSettingsAnimation.ts | 31 +- .../settings/hooks/useSettingsNavigation.ts | 56 ++- .../settings/panels/AdvancedPanel.tsx | 34 +- .../settings/panels/BillingPanel.tsx | 24 +- .../settings/panels/ConnectionsPanel.tsx | 125 ++--- .../settings/panels/MessagingPanel.tsx | 27 +- .../settings/panels/PrivacyPanel.tsx | 27 +- .../settings/panels/ProfilePanel.tsx | 24 +- src/data/countries.ts | 44 +- src/hooks/useSocket.ts | 30 +- src/hooks/useUser.ts | 6 +- src/lib/mcp/errorHandler.ts | 58 +-- src/lib/mcp/index.ts | 10 +- src/lib/mcp/logger.ts | 17 +- src/lib/mcp/telegram/index.ts | 22 +- src/lib/mcp/telegram/server.ts | 4 +- src/lib/mcp/telegram/telegramApi.ts | 60 ++- src/lib/mcp/telegram/toolActionParser.ts | 218 ++++---- src/lib/mcp/telegram/tools/addContact.ts | 59 ++- src/lib/mcp/telegram/tools/blockUser.ts | 32 +- src/lib/mcp/telegram/tools/clearDraft.ts | 28 +- src/lib/mcp/telegram/tools/createChannel.ts | 3 +- src/lib/mcp/telegram/tools/createPoll.ts | 56 ++- src/lib/mcp/telegram/tools/deleteContact.ts | 35 +- src/lib/mcp/telegram/tools/deleteMessage.ts | 48 +- .../mcp/telegram/tools/deleteProfilePhoto.ts | 35 +- src/lib/mcp/telegram/tools/editChatPhoto.ts | 11 +- src/lib/mcp/telegram/tools/editMessage.ts | 54 +- .../mcp/telegram/tools/exportChatInvite.ts | 63 ++- src/lib/mcp/telegram/tools/forwardMessage.ts | 62 ++- src/lib/mcp/telegram/tools/getBlockedUsers.ts | 46 +- src/lib/mcp/telegram/tools/getBotInfo.ts | 40 +- src/lib/mcp/telegram/tools/getChat.ts | 38 +- src/lib/mcp/telegram/tools/getChats.ts | 38 +- src/lib/mcp/telegram/tools/getContactChats.ts | 35 +- src/lib/mcp/telegram/tools/getContactIds.ts | 34 +- .../telegram/tools/getDirectChatByContact.ts | 53 +- src/lib/mcp/telegram/tools/getDrafts.ts | 24 +- src/lib/mcp/telegram/tools/getGifSearch.ts | 45 +- src/lib/mcp/telegram/tools/getHistory.ts | 45 +- .../mcp/telegram/tools/getLastInteraction.ts | 53 +- src/lib/mcp/telegram/tools/getMediaInfo.ts | 52 +- .../mcp/telegram/tools/getMessageContext.ts | 75 +-- .../mcp/telegram/tools/getMessageReactions.ts | 81 ++- .../mcp/telegram/tools/getPinnedMessages.ts | 59 ++- .../mcp/telegram/tools/getPrivacySettings.ts | 50 +- .../mcp/telegram/tools/getRecentActions.ts | 52 +- src/lib/mcp/telegram/tools/getStickerSets.ts | 37 +- src/lib/mcp/telegram/tools/getUserPhotos.ts | 60 ++- src/lib/mcp/telegram/tools/getUserStatus.ts | 63 ++- .../mcp/telegram/tools/importChatInvite.ts | 3 +- src/lib/mcp/telegram/tools/importContacts.ts | 3 +- src/lib/mcp/telegram/tools/index.ts | 221 +++++--- src/lib/mcp/telegram/tools/joinChatByLink.ts | 43 +- src/lib/mcp/telegram/tools/listContacts.ts | 37 +- .../mcp/telegram/tools/listInlineButtons.ts | 70 ++- src/lib/mcp/telegram/tools/listTopics.ts | 56 ++- src/lib/mcp/telegram/tools/markAsRead.ts | 37 +- src/lib/mcp/telegram/tools/muteChat.ts | 47 +- src/lib/mcp/telegram/tools/notImplemented.ts | 4 +- src/lib/mcp/telegram/tools/pinMessage.ts | 51 +- .../mcp/telegram/tools/pressInlineButton.ts | 50 +- src/lib/mcp/telegram/tools/removeReaction.ts | 59 ++- src/lib/mcp/telegram/tools/replyToMessage.ts | 19 +- src/lib/mcp/telegram/tools/saveDraft.ts | 32 +- src/lib/mcp/telegram/tools/searchContacts.ts | 55 +- src/lib/mcp/telegram/tools/sendReaction.ts | 62 ++- src/lib/mcp/telegram/tools/setBotCommands.ts | 35 +- .../mcp/telegram/tools/setPrivacySettings.ts | 51 +- src/lib/mcp/telegram/tools/setProfilePhoto.ts | 11 +- src/lib/mcp/telegram/tools/unbanUser.ts | 61 ++- src/lib/mcp/telegram/tools/unblockUser.ts | 30 +- src/lib/mcp/telegram/tools/unpinMessage.ts | 53 +- src/lib/mcp/telegram/tools/updateProfile.ts | 54 +- src/lib/mcp/transport.ts | 19 +- src/lib/mcp/types.ts | 8 +- src/lib/mcp/validation.ts | 17 +- src/main.tsx | 6 +- src/pages/Home.tsx | 67 ++- src/pages/Login.tsx | 62 ++- src/pages/Welcome.tsx | 9 +- src/pages/onboarding/Onboarding.tsx | 60 ++- src/pages/onboarding/steps/AnalyticsStep.tsx | 87 ++-- src/pages/onboarding/steps/ConnectStep.tsx | 102 ++-- src/pages/onboarding/steps/FeaturesStep.tsx | 17 +- src/pages/onboarding/steps/GetStartedStep.tsx | 44 +- src/pages/onboarding/steps/PrivacyStep.tsx | 22 +- src/providers/SocketProvider.tsx | 17 +- src/providers/TelegramProvider.tsx | 112 +++-- src/providers/UserProvider.tsx | 24 +- src/services/api/authApi.ts | 22 + src/services/api/userApi.ts | 19 +- src/services/apiClient.ts | 56 ++- src/services/mtprotoService.ts | 200 +++++--- src/services/socketService.ts | 30 +- src/store/authSelectors.ts | 7 + src/store/authSlice.ts | 35 +- src/store/hooks.ts | 4 +- src/store/index.ts | 41 +- src/store/socketSelectors.ts | 19 + src/store/socketSlice.ts | 61 ++- src/store/telegram/extraReducers.ts | 171 ++++--- src/store/telegram/index.ts | 23 +- src/store/telegram/reducers.ts | 330 ++++++------ src/store/telegram/thunks.ts | 90 ++-- src/store/telegram/types.ts | 23 +- src/store/telegramSelectors.ts | 168 ++++--- src/store/userSlice.ts | 16 +- src/styles/theme.css | 205 ++++---- src/types/api.ts | 8 +- src/types/onboarding.ts | 2 +- src/utils/deeplink.ts | 21 +- src/utils/desktopDeepLinkListener.ts | 36 +- src/utils/openUrl.ts | 6 +- 139 files changed, 4365 insertions(+), 2901 deletions(-) create mode 100644 src/services/api/authApi.ts create mode 100644 src/store/authSelectors.ts create mode 100644 src/store/socketSelectors.ts 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"); };