415 lines
14 KiB
Python
415 lines
14 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 (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 <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
|