feat: Complete API authentication system with email & Telegram support
- 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
This commit is contained in:
426
examples/bot_api_usage.py
Normal file
426
examples/bot_api_usage.py
Normal file
@@ -0,0 +1,426 @@
|
||||
"""
|
||||
Example: Using Finance Bot API from Python
|
||||
|
||||
This file shows how to use the new API endpoints
|
||||
to authenticate bot users and make API calls.
|
||||
"""
|
||||
|
||||
import aiohttp
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FinanceBotAPIClient:
|
||||
"""
|
||||
Client for Finance Bot API with authentication support.
|
||||
|
||||
Features:
|
||||
- Email/password registration
|
||||
- Telegram user quick registration
|
||||
- Telegram binding/linking
|
||||
- Token management and refresh
|
||||
- Authenticated API calls
|
||||
"""
|
||||
|
||||
def __init__(self, api_base_url: str = "http://web:8000"):
|
||||
self.api_base_url = api_base_url
|
||||
self.session: Optional[aiohttp.ClientSession] = None
|
||||
|
||||
async def start(self):
|
||||
"""Start HTTP session"""
|
||||
self.session = aiohttp.ClientSession()
|
||||
|
||||
async def close(self):
|
||||
"""Close HTTP session"""
|
||||
if self.session:
|
||||
await self.session.close()
|
||||
|
||||
# ============ EMAIL AUTHENTICATION ============
|
||||
|
||||
async def register_email_user(
|
||||
self,
|
||||
email: str,
|
||||
password: str,
|
||||
first_name: Optional[str] = None,
|
||||
last_name: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Register new user with email and password.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": True,
|
||||
"user_id": 123,
|
||||
"access_token": "eyJ...",
|
||||
"refresh_token": "eyJ...",
|
||||
"expires_in": 900
|
||||
}
|
||||
"""
|
||||
url = f"{self.api_base_url}/api/v1/auth/register"
|
||||
|
||||
payload = {
|
||||
"email": email,
|
||||
"password": password,
|
||||
"first_name": first_name,
|
||||
"last_name": last_name,
|
||||
}
|
||||
|
||||
async with self.session.post(url, json=payload) as resp:
|
||||
return await resp.json()
|
||||
|
||||
async def login_email_user(
|
||||
self,
|
||||
email: str,
|
||||
password: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Login user with email and password.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"access_token": "eyJ...",
|
||||
"refresh_token": "eyJ...",
|
||||
"user_id": 123,
|
||||
"expires_in": 900
|
||||
}
|
||||
"""
|
||||
url = f"{self.api_base_url}/api/v1/auth/login"
|
||||
|
||||
payload = {
|
||||
"email": email,
|
||||
"password": password,
|
||||
}
|
||||
|
||||
async with self.session.post(url, json=payload) as resp:
|
||||
return await resp.json()
|
||||
|
||||
# ============ TELEGRAM AUTHENTICATION ============
|
||||
|
||||
async def quick_register_telegram_user(
|
||||
self,
|
||||
chat_id: int,
|
||||
username: Optional[str] = None,
|
||||
first_name: Optional[str] = None,
|
||||
last_name: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Quick register Telegram user (one API call).
|
||||
|
||||
Perfect for bot /register command.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": True,
|
||||
"user_id": 123,
|
||||
"jwt_token": "eyJ...",
|
||||
"created": True
|
||||
}
|
||||
"""
|
||||
url = f"{self.api_base_url}/api/v1/auth/telegram/register"
|
||||
|
||||
params = {
|
||||
"chat_id": chat_id,
|
||||
"username": username or f"user_{chat_id}",
|
||||
"first_name": first_name,
|
||||
"last_name": last_name,
|
||||
}
|
||||
|
||||
# Remove None values
|
||||
params = {k: v for k, v in params.items() if v is not None}
|
||||
|
||||
async with self.session.post(url, params=params) as resp:
|
||||
return await resp.json()
|
||||
|
||||
async def start_telegram_binding(
|
||||
self,
|
||||
chat_id: int,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Start Telegram binding process (for linking existing accounts).
|
||||
|
||||
Returns:
|
||||
{
|
||||
"code": "PgmL5ZD8vK2mN3oP4qR5sT6uV7wX8yZ9",
|
||||
"expires_in": 600
|
||||
}
|
||||
"""
|
||||
url = f"{self.api_base_url}/api/v1/auth/telegram/start"
|
||||
|
||||
payload = {"chat_id": chat_id}
|
||||
|
||||
async with self.session.post(url, json=payload) as resp:
|
||||
return await resp.json()
|
||||
|
||||
async def get_token_for_telegram_user(
|
||||
self,
|
||||
chat_id: int,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get JWT token for Telegram user.
|
||||
|
||||
Use after binding confirmation or registration.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": True,
|
||||
"access_token": "eyJ...",
|
||||
"expires_in": 900,
|
||||
"user_id": 123
|
||||
}
|
||||
"""
|
||||
url = f"{self.api_base_url}/api/v1/auth/token/get"
|
||||
|
||||
payload = {"chat_id": chat_id}
|
||||
|
||||
async with self.session.post(url, json=payload) as resp:
|
||||
return await resp.json()
|
||||
|
||||
async def refresh_telegram_token(
|
||||
self,
|
||||
chat_id: int,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Refresh token for Telegram user.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": True,
|
||||
"access_token": "eyJ...",
|
||||
"expires_in": 900,
|
||||
"user_id": 123
|
||||
}
|
||||
"""
|
||||
url = f"{self.api_base_url}/api/v1/auth/token/refresh-telegram"
|
||||
|
||||
params = {"chat_id": chat_id}
|
||||
|
||||
async with self.session.post(url, params=params) as resp:
|
||||
return await resp.json()
|
||||
|
||||
# ============ API CALLS WITH AUTHENTICATION ============
|
||||
|
||||
async def make_authenticated_request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
access_token: str,
|
||||
data: Optional[Dict] = None,
|
||||
params: Optional[Dict] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Make authenticated API request.
|
||||
|
||||
Args:
|
||||
method: GET, POST, PUT, DELETE
|
||||
endpoint: /api/v1/accounts, etc.
|
||||
access_token: JWT token from login/register
|
||||
data: Request body (for POST/PUT)
|
||||
params: Query parameters
|
||||
|
||||
Returns:
|
||||
API response JSON
|
||||
"""
|
||||
url = f"{self.api_base_url}{endpoint}"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
async with self.session.request(
|
||||
method,
|
||||
url,
|
||||
json=data,
|
||||
params=params,
|
||||
headers=headers,
|
||||
) as resp:
|
||||
return await resp.json()
|
||||
|
||||
async def get_accounts(
|
||||
self,
|
||||
access_token: str,
|
||||
family_id: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Get user's accounts"""
|
||||
params = {}
|
||||
if family_id:
|
||||
params["family_id"] = family_id
|
||||
|
||||
return await self.make_authenticated_request(
|
||||
"GET",
|
||||
"/api/v1/accounts",
|
||||
access_token,
|
||||
params=params,
|
||||
)
|
||||
|
||||
async def get_balance(
|
||||
self,
|
||||
access_token: str,
|
||||
account_id: int,
|
||||
) -> Dict[str, Any]:
|
||||
"""Get account balance"""
|
||||
return await self.make_authenticated_request(
|
||||
"GET",
|
||||
f"/api/v1/accounts/{account_id}",
|
||||
access_token,
|
||||
)
|
||||
|
||||
async def create_transaction(
|
||||
self,
|
||||
access_token: str,
|
||||
amount: float,
|
||||
category: str,
|
||||
description: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> Dict[str, Any]:
|
||||
"""Create new transaction"""
|
||||
data = {
|
||||
"amount": amount,
|
||||
"category": category,
|
||||
"description": description,
|
||||
**kwargs
|
||||
}
|
||||
|
||||
return await self.make_authenticated_request(
|
||||
"POST",
|
||||
"/api/v1/transactions",
|
||||
access_token,
|
||||
data=data,
|
||||
)
|
||||
|
||||
# ============ TOKEN REFRESH ============
|
||||
|
||||
async def refresh_access_token(
|
||||
self,
|
||||
refresh_token: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Refresh access token using refresh token.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"access_token": "eyJ...",
|
||||
"expires_in": 900
|
||||
}
|
||||
"""
|
||||
url = f"{self.api_base_url}/api/v1/auth/refresh"
|
||||
|
||||
payload = {"refresh_token": refresh_token}
|
||||
|
||||
async with self.session.post(url, json=payload) as resp:
|
||||
return await resp.json()
|
||||
|
||||
|
||||
# ============ USAGE EXAMPLES ============
|
||||
|
||||
async def example_quick_registration():
|
||||
"""Example: Register Telegram user with one command"""
|
||||
api = FinanceBotAPIClient()
|
||||
await api.start()
|
||||
|
||||
try:
|
||||
# User sends /register command
|
||||
# Bot registers them
|
||||
result = await api.quick_register_telegram_user(
|
||||
chat_id=556399210,
|
||||
username="john_doe",
|
||||
first_name="John",
|
||||
last_name="Doe"
|
||||
)
|
||||
|
||||
print("Registration result:", result)
|
||||
|
||||
if result.get("success"):
|
||||
jwt_token = result.get("jwt_token")
|
||||
user_id = result.get("user_id")
|
||||
|
||||
print(f"✅ User {user_id} registered!")
|
||||
print(f"Token: {jwt_token[:50]}...")
|
||||
|
||||
# Now can make API calls with this token
|
||||
# Store in Redis for later use
|
||||
|
||||
finally:
|
||||
await api.close()
|
||||
|
||||
|
||||
async def example_get_balance():
|
||||
"""Example: Get user's account balance"""
|
||||
api = FinanceBotAPIClient()
|
||||
await api.start()
|
||||
|
||||
try:
|
||||
# First, get token
|
||||
token_result = await api.get_token_for_telegram_user(
|
||||
chat_id=556399210
|
||||
)
|
||||
|
||||
if not token_result.get("success"):
|
||||
print("❌ Could not get token")
|
||||
return
|
||||
|
||||
access_token = token_result["access_token"]
|
||||
|
||||
# Make API call
|
||||
accounts = await api.get_accounts(access_token)
|
||||
|
||||
print("Accounts:", accounts)
|
||||
|
||||
finally:
|
||||
await api.close()
|
||||
|
||||
|
||||
async def example_link_account():
|
||||
"""Example: Link existing account to Telegram"""
|
||||
api = FinanceBotAPIClient()
|
||||
await api.start()
|
||||
|
||||
try:
|
||||
# Start binding process
|
||||
code_result = await api.start_telegram_binding(
|
||||
chat_id=556399210
|
||||
)
|
||||
|
||||
binding_code = code_result.get("code")
|
||||
|
||||
print(f"Binding code: {binding_code}")
|
||||
print(f"Expires in: {code_result.get('expires_in')} seconds")
|
||||
|
||||
# User confirms binding on web
|
||||
# Bot then gets token
|
||||
|
||||
# After confirmation, get token
|
||||
token_result = await api.get_token_for_telegram_user(
|
||||
chat_id=556399210
|
||||
)
|
||||
|
||||
if token_result.get("success"):
|
||||
print(f"✅ Account linked! User ID: {token_result['user_id']}")
|
||||
|
||||
finally:
|
||||
await api.close()
|
||||
|
||||
|
||||
async def main():
|
||||
"""Run examples"""
|
||||
print("🤖 Finance Bot API Examples\n")
|
||||
|
||||
print("1. Quick Registration:")
|
||||
print("-" * 50)
|
||||
await example_quick_registration()
|
||||
|
||||
print("\n2. Get Balance:")
|
||||
print("-" * 50)
|
||||
await example_get_balance()
|
||||
|
||||
print("\n3. Link Account:")
|
||||
print("-" * 50)
|
||||
await example_link_account()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user