""" Telegram Bot - API-First Client All database operations go through API endpoints, not direct SQLAlchemy. """ import logging from datetime import datetime from typing import Optional, Dict, Any from decimal import Decimal import aiohttp import time import asyncio import json import redis from aiogram import Bot, Dispatcher, types, F from aiogram.filters import Command from aiogram.types import Message from app.security.hmac_manager import hmac_manager logger = logging.getLogger(__name__) class TelegramBotClient: """ Telegram Bot that communicates exclusively via API calls. Features: - User authentication via JWT tokens stored in Redis - All operations through API (no direct DB access) - Async HTTP requests with aiohttp - Event listening via Redis Streams """ def __init__(self, bot_token: str, api_base_url: str, redis_client: redis.Redis): self.bot = Bot(token=bot_token) self.dp = Dispatcher() self.api_base_url = api_base_url self.redis_client = redis_client self.session: Optional[aiohttp.ClientSession] = None # Register handlers self._setup_handlers() def _setup_handlers(self): """Register message handlers""" self.dp.message.register(self.cmd_start, Command("start")) self.dp.message.register(self.cmd_help, Command("help")) self.dp.message.register(self.cmd_register, Command("register")) self.dp.message.register(self.cmd_link, Command("link")) self.dp.message.register(self.cmd_balance, Command("balance")) self.dp.message.register(self.cmd_add_transaction, Command("add")) async def start(self): """Start bot polling""" self.session = aiohttp.ClientSession() logger.info("Telegram bot started") # Start polling try: await self.dp.start_polling(self.bot) finally: await self.session.close() # ========== Handler: /start (Binding) ========== async def cmd_start(self, message: Message): """ /start - Begin Telegram binding process. **Flow:** 1. Check if user already bound (JWT in Redis) 2. If not: Show registration options a) Quick bind with /register command b) Email/password registration c) Link existing account 3. After binding, store JWT in Redis **After binding:** - User has JWT token in Redis - Can use /balance, /add, etc. """ chat_id = message.chat.id # Check if already bound jwt_key = f"chat_id:{chat_id}:jwt" existing_token = self.redis_client.get(jwt_key) if existing_token: await message.answer( "✅ **You're already connected!**\n\n" "💰 /balance - Check your wallets\n" "➕ /add - Add new transaction\n" "📊 /report - View reports\n" "❓ /help - Show all commands", parse_mode="Markdown" ) return # Show registration options try: logger.info(f"Start command from chat_id={chat_id}") await message.answer( "👋 **Welcome to Finance Bot!**\n\n" "Choose how to connect:\n\n" "📱 **Quick Registration** - Fast setup\n" "Use /register to create account\n\n" "🔗 **Link Account** - Have an account already?\n" "Use /link to connect existing account\n\n" "❓ Need help? /help", parse_mode="Markdown" ) # Store state for user state_key = f"chat_id:{chat_id}:state" self.redis_client.setex(state_key, 3600, json.dumps({ "status": "awaiting_action", "chat_id": chat_id })) except Exception as e: logger.error(f"Start command error: {e}", exc_info=True) await message.answer("❌ Could not process. Try again later.") # ========== Handler: /register (Quick Registration) ========== async def cmd_register(self, message: Message): """ /register - Quick Telegram-based registration. **Flow:** 1. Generate unique username 2. Register user with Telegram binding 3. Return JWT token 4. Store in Redis """ chat_id = message.chat.id telegram_user = message.from_user # Check if already registered existing_token = self.redis_client.get(f"chat_id:{chat_id}:jwt") if existing_token: await message.answer("✅ You're already registered!") return try: logger.info(f"Quick register for chat_id={chat_id}") # Build params, filtering out None values params = { "chat_id": chat_id, "username": telegram_user.username or f"user_{chat_id}", "first_name": telegram_user.first_name, } if telegram_user.last_name: params["last_name"] = telegram_user.last_name # Call API to register register_response = await self._api_call( method="POST", endpoint="/api/v1/auth/telegram/register", params=params, use_jwt=False, ) if not register_response.get("success") and not register_response.get("jwt_token"): raise ValueError("Registration failed") # Get JWT token jwt_token = register_response.get("jwt_token") if not jwt_token: raise ValueError("No JWT token in response") # Store token in Redis self.redis_client.setex( f"chat_id:{chat_id}:jwt", 86400 * 30, # 30 days jwt_token ) await message.answer( f"✅ **Registration successful!**\n\n" f"User ID: {register_response.get('user_id')}\n" f"Username: {telegram_user.username or f'user_{chat_id}'}\n\n" f"💰 /balance - Check wallets\n" f"➕ /add - Add transaction\n" f"❓ /help - All commands", parse_mode="Markdown" ) logger.info(f"Quick registration successful for chat_id={chat_id}") except Exception as e: logger.error(f"Registration error: {e}", exc_info=True) await message.answer( "❌ Registration failed\n\n" "Try again or use /link to connect existing account" ) # ========== Handler: /link (Link Existing Account) ========== async def cmd_link(self, message: Message): """ /link - Link existing account via binding code. **Flow:** 1. Generate binding code 2. Send link with code to user 3. User confirms in web (authenticates) 4. API calls /telegram/confirm 5. Bot gets JWT via /token/get """ chat_id = message.chat.id # Check if already linked existing_token = self.redis_client.get(f"chat_id:{chat_id}:jwt") if existing_token: await message.answer("✅ You're already linked to an account!") return try: logger.info(f"Starting account link for chat_id={chat_id}") # Generate binding code code_response = await self._api_call( method="POST", endpoint="/api/v1/auth/telegram/start", data={"chat_id": chat_id}, use_jwt=False, ) binding_code = code_response.get("code") if not binding_code: raise ValueError("No code in response") # Store binding code in Redis binding_key = f"chat_id:{chat_id}:binding_code" self.redis_client.setex( binding_key, 600, json.dumps({ "code": binding_code, "created_at": datetime.utcnow().isoformat() }) ) # Build binding link # TODO: Replace with your actual frontend URL binding_url = ( f"https://your-finance-app.com/auth/telegram/confirm" f"?code={binding_code}&chat_id={chat_id}" ) await message.answer( f"🔗 **Link Your Account**\n\n" f"Click the link below to connect your existing account:\n\n" f"[🔑 Link Account]({binding_url})\n\n" f"⏱ Code expires in 10 minutes\n" f"1. Click the link\n" f"2. Log in with your email\n" f"3. Confirm to link", parse_mode="Markdown", disable_web_page_preview=True, ) logger.info(f"Binding code sent to chat_id={chat_id}") except Exception as e: logger.error(f"Link command error: {e}", exc_info=True) await message.answer( "❌ Could not start linking\n\n" "Try /register for quick registration instead" ) # ========== Handler: /balance ========== async def cmd_balance(self, message: Message): """ /balance - Show wallet balances. **Requires:** - User must be bound (JWT token in Redis) - JWT obtained via binding confirmation - API call with JWT auth **Try:** Use /start to bind your account first """ chat_id = message.chat.id # Get JWT token from Redis jwt_token = self._get_user_jwt(chat_id) if not jwt_token: # Try to authenticate if user exists try: auth_result = await self._api_call( method="POST", endpoint="/api/v1/auth/telegram/authenticate", params={"chat_id": chat_id}, use_jwt=False, ) if auth_result and auth_result.get("jwt_token"): jwt_token = auth_result["jwt_token"] # Store in Redis for future use self.redis_client.setex( f"chat_id:{chat_id}:jwt", 86400 * 30, # 30 days jwt_token ) logger.info(f"JWT obtained for chat_id={chat_id}") except Exception as e: logger.warning(f"Could not authenticate user: {e}") if not jwt_token: await message.answer( "❌ Not connected yet\n\n" "Use /start to bind your Telegram account first", parse_mode="Markdown" ) return try: # Call API: GET /api/v1/accounts?family_id=1 accounts_response = await self._api_call( method="GET", endpoint="/api/v1/accounts", jwt_token=jwt_token, params={"family_id": 1}, # TODO: Get from user context use_jwt=True, ) accounts = accounts_response if isinstance(accounts_response, list) else accounts_response.get("accounts", []) if not accounts: await message.answer( "💰 No accounts found\n\n" "Contact support to set up your first account", parse_mode="Markdown" ) return # Format response response = "💰 **Your Accounts:**\n\n" for account in accounts[:10]: # Limit to 10 balance = account.get("balance", 0) currency = account.get("currency", "USD") response += f"📊 {account.get('name', 'Account')}: {balance} {currency}\n" await message.answer(response, parse_mode="Markdown") logger.info(f"Balance shown for chat_id={chat_id}") except Exception as e: logger.error(f"Balance fetch error for {chat_id}: {e}", exc_info=True) await message.answer( "❌ Could not fetch balance\n\n" "Try again later or contact support", parse_mode="Markdown" ) # ========== Handler: /add (Create Transaction) ========== async def cmd_add_transaction(self, message: Message): """ /add - Create new transaction (interactive). Flow: 1. Ask for amount 2. Ask for category 3. Ask for wallet (from/to) 4. Create transaction via API """ chat_id = message.chat.id jwt_token = self._get_user_jwt(chat_id) if not jwt_token: await message.answer("❌ Not connected. Use /start first.") return # Store conversation state in Redis state_key = f"chat_id:{chat_id}:state" self.redis_client.setex(state_key, 300, json.dumps({ "action": "add_transaction", "step": 1, # Waiting for amount })) await message.answer("💵 How much?\n\nEnter amount (e.g., 50.00)") async def handle_transaction_input(self, message: Message, state: Dict[str, Any]): """Handle transaction creation in steps""" chat_id = message.chat.id jwt_token = self._get_user_jwt(chat_id) step = state.get("step", 1) if step == 1: # Amount entered try: amount = Decimal(message.text) except: await message.answer("❌ Invalid amount. Try again.") return state["amount"] = float(amount) state["step"] = 2 self.redis_client.setex(f"chat_id:{chat_id}:state", 300, json.dumps(state)) await message.answer("📂 Which category?\n\n/food /transport /other") elif step == 2: # Category selected state["category"] = message.text state["step"] = 3 self.redis_client.setex(f"chat_id:{chat_id}:state", 300, json.dumps(state)) await message.answer("💬 Any notes?\n\n(or /skip)") elif step == 3: # Notes entered (or skipped) state["notes"] = message.text if message.text != "/skip" else "" # Create transaction via API try: result = await self._api_call( method="POST", endpoint="/api/v1/transactions", jwt_token=jwt_token, data={ "family_id": 1, "from_wallet_id": 10, "amount": state["amount"], "category_id": 5, # TODO: Map category "description": state["category"], "notes": state["notes"], } ) tx_id = result.get("id") await message.answer(f"✅ Transaction #{tx_id} created!") except Exception as e: logger.error(f"Transaction creation error: {e}") await message.answer("❌ Creation failed. Try again.") finally: # Clean up state self.redis_client.delete(f"chat_id:{chat_id}:state") # ========== Handler: /help ========== async def cmd_help(self, message: Message): """Show available commands and binding instructions""" chat_id = message.chat.id jwt_key = f"chat_id:{chat_id}:jwt" is_bound = self.redis_client.get(jwt_key) is not None if is_bound: help_text = """🤖 **Finance Bot - Commands:** 🔗 **Account** /balance - Show all account balances /settings - Account settings /logout - Logout from this device 💰 **Transactions** /add - Add new transaction /recent - Last 10 transactions /category - View by category 📊 **Reports** /daily - Daily spending report /weekly - Weekly summary /monthly - Monthly summary ❓ **Help** /help - This message """ else: help_text = """🤖 **Finance Bot - Getting Started** **Option 1: Quick Registration (Telegram)** /register - Create account with just one tap Fast setup, perfect for Telegram users **Option 2: Link Existing Account** /link - Connect your existing account Use your email and password **What can you do?** ✅ Track family expenses ✅ Set budgets and goals ✅ View detailed reports ✅ Manage multiple accounts Need more help? Try /start **Step 3: Done!** - /balance - View your accounts - /add - Create transactions - /help - See all commands 🔒 **Privacy** Your data is encrypted and secure Only you can access your accounts """ await message.answer(help_text, parse_mode="Markdown") # ========== API Communication Methods ========== async def _api_call( self, method: str, endpoint: str, data: Dict = None, params: Dict = None, jwt_token: Optional[str] = None, use_jwt: bool = True, ) -> Dict[str, Any]: """ Make HTTP request to API with proper authentication headers. **Headers:** - Authorization: Bearer (if use_jwt=True) - X-Client-Id: telegram_bot - X-Signature: HMAC_SHA256(method + endpoint + timestamp + body) - X-Timestamp: unix timestamp **Auth Flow:** 1. For public endpoints (binding): use_jwt=False, no Authorization header 2. For user endpoints: use_jwt=True, pass jwt_token 3. All calls include HMAC signature for integrity **Raises:** - Exception: API error with status code and message """ if not self.session: raise RuntimeError("Session not initialized") # Build URL url = f"{self.api_base_url}{endpoint}" # Build headers headers = { "X-Client-Id": "telegram_bot", "Content-Type": "application/json", } # Add JWT if provided and requested if use_jwt and jwt_token: headers["Authorization"] = f"Bearer {jwt_token}" # Add HMAC signature for integrity verification timestamp = int(time.time()) headers["X-Timestamp"] = str(timestamp) signature = hmac_manager.create_signature( method=method, endpoint=endpoint, timestamp=timestamp, body=data, ) headers["X-Signature"] = signature # Make request try: # Filter out None values from params clean_params = {k: v for k, v in (params or {}).items() if v is not None} async with self.session.request( method=method, url=url, json=data, params=clean_params if clean_params else None, headers=headers, timeout=aiohttp.ClientTimeout(total=10), ) as response: response_text = await response.text() if response.status >= 400: logger.error( f"API error {response.status}: {endpoint}\n" f"Response: {response_text[:500]}" ) raise Exception( f"API error {response.status}: {response_text[:200]}" ) # Parse JSON response try: return json.loads(response_text) except json.JSONDecodeError: logger.warning(f"Invalid JSON response from {endpoint}: {response_text}") return {"data": response_text} except asyncio.TimeoutError: logger.error(f"API timeout: {endpoint}") raise Exception("Request timeout") except Exception as e: logger.error(f"API call failed ({method} {endpoint}): {e}") raise def _get_user_jwt(self, chat_id: int) -> Optional[str]: """Get JWT token for chat_id from Redis""" jwt_key = f"chat_id:{chat_id}:jwt" token = self.redis_client.get(jwt_key) if not token: return None # Handle both bytes and string returns from Redis if isinstance(token, bytes): return token.decode('utf-8') return token async def send_notification(self, chat_id: int, message: str): """Send notification to user""" try: await self.bot.send_message(chat_id=chat_id, text=message) except Exception as e: logger.error(f"Failed to send notification to {chat_id}: {e}") # Bot factory async def create_telegram_bot( bot_token: str, api_base_url: str, redis_client: redis.Redis, ) -> TelegramBotClient: """Create and start Telegram bot""" bot = TelegramBotClient(bot_token, api_base_url, redis_client) return bot