- Add email/password registration endpoint (/api/v1/auth/register) - Add JWT token endpoints for Telegram users (/api/v1/auth/token/get, /api/v1/auth/token/refresh-telegram) - Enhance User model to support both email and Telegram authentication - Fix JWT token handling: convert sub to string (RFC compliance with PyJWT 2.10.1+) - Fix bot API calls: filter None values from query parameters - Fix JWT extraction from Redis: handle both bytes and string returns - Add public endpoints to JWT middleware: /api/v1/auth/register, /api/v1/auth/token/* - Update bot commands: /register (one-tap), /link (account linking), /start (options) - Create complete database schema migration with email auth support - Remove deprecated version attribute from docker-compose.yml - Add service dependency: bot waits for web service startup Features: - Dual authentication: email/password OR Telegram ID - JWT tokens with 15-min access + 30-day refresh lifetime - Redis-based token storage with TTL - Comprehensive API documentation and integration guides - Test scripts and Python examples - Full deployment checklist Database changes: - User model: added email, password_hash, email_verified (nullable fields) - telegram_id now nullable to support email-only users - Complete schema with families, accounts, categories, transactions, budgets, goals Status: Production-ready with all tests passing
630 lines
22 KiB
Python
630 lines
22 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
|
||
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 <jwt_token> (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
|