""" 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 from aiogram import Bot, Dispatcher, types, F from aiogram.filters import Command from aiogram.types import Message import redis import json 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_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: Generate binding code via API 3. Send binding link to user with code **After binding:** - User clicks link and confirms - User's browser calls POST /api/v1/auth/telegram/confirm - Bot calls GET /api/v1/auth/telegram/authenticate?chat_id=XXXX - Bot stores JWT in Redis for future API calls """ 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" "Use /balance to check wallets\n" "Use /add to add transactions\n" "Use /help for all commands", parse_mode="Markdown" ) return # Generate binding code try: logger.info(f"Starting binding for chat_id={chat_id}") 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 for validation # (expires in 10 minutes as per backend TTL) 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 (replace with actual frontend URL) # Example: https://yourapp.com/auth/telegram/confirm?code=XXX&chat_id=123 binding_url = ( f"https://your-finance-app.com/auth/telegram/confirm" f"?code={binding_code}&chat_id={chat_id}" ) # Send binding link to user await message.answer( f"🔗 **Click to bind your account:**\n\n" f"[📱 Open Account Binding]({binding_url})\n\n" f"⏱ Code expires in 10 minutes\n\n" f"❓ Already have an account? Just log in and click the 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"Binding start error: {e}", exc_info=True) await message.answer("❌ Could not start binding. Try again later.") # ========== 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""" help_text = """ 🤖 **Finance Bot Commands:** /start - Bind your Telegram account /balance - Show wallet balances /add - Add new transaction /reports - View reports (daily/weekly/monthly) /help - This message """ 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 auth headers. Headers: - Authorization: Bearer - X-Client-Id: telegram_bot - X-Signature: HMAC_SHA256(...) - X-Timestamp: unix timestamp """ if not self.session: raise RuntimeError("Session not initialized") # Build headers headers = { "X-Client-Id": "telegram_bot", "Content-Type": "application/json", } # Add JWT if provided if use_jwt and jwt_token: headers["Authorization"] = f"Bearer {jwt_token}" # Add HMAC signature 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 url = f"{self.api_base_url}{endpoint}" async with self.session.request( method=method, url=url, json=data, params=params, headers=headers, ) as response: if response.status >= 400: error_text = await response.text() raise Exception(f"API error {response.status}: {error_text}") return await response.json() 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) return token.decode() if token else None 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