Files
finance_bot/app/bot/client.py
2025-12-10 22:09:31 +09:00

329 lines
11 KiB
Python

"""
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
2. If not: Generate binding code
3. Send link to user
"""
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\nUse /help for commands.")
return
# Generate binding code
try:
code = await self._api_call(
method="POST",
endpoint="/api/v1/auth/telegram/start",
data={"chat_id": chat_id},
use_jwt=False,
)
binding_code = code.get("code")
# Send binding link to user
binding_url = f"https://your-app.com/auth/telegram?code={binding_code}&chat_id={chat_id}"
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.",
parse_mode="Markdown"
)
except Exception as e:
logger.error(f"Binding start error: {e}")
await message.answer("❌ Binding failed. 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)
- API call with JWT auth
"""
chat_id = message.chat.id
# Get JWT token
jwt_token = self._get_user_jwt(chat_id)
if not jwt_token:
await message.answer("❌ Not connected. Use /start to bind your account.")
return
try:
# Call API: GET /api/v1/wallets/summary?family_id=1
wallets = await self._api_call(
method="GET",
endpoint="/api/v1/wallets/summary",
jwt_token=jwt_token,
params={"family_id": 1}, # TODO: Get from context
)
# Format response
response = "💰 **Your Wallets:**\n\n"
for wallet in wallets:
response += f"📊 {wallet['name']}: ${wallet['balance']}\n"
await message.answer(response, parse_mode="Markdown")
except Exception as e:
logger.error(f"Balance fetch error: {e}")
await message.answer("❌ Could not fetch balance. Try again later.")
# ========== 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 <jwt_token>
- 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