This commit is contained in:
2025-12-10 22:18:07 +09:00
parent b79adf1c69
commit b642d1e9e9
21 changed files with 6781 additions and 86 deletions

View File

@@ -8,11 +8,12 @@ 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
import redis
import json
from app.security.hmac_manager import hmac_manager
@@ -63,10 +64,16 @@ class TelegramBotClient:
"""
/start - Begin Telegram binding process.
Flow:
1. Check if user already bound
2. If not: Generate binding code
3. Send link to user
**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
@@ -75,70 +82,150 @@ class TelegramBotClient:
existing_token = self.redis_client.get(jwt_key)
if existing_token:
await message.answer("✅ You're already connected!\n\nUse /help for commands.")
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:
code = await self._api_call(
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.get("code")
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
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"
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}")
await message.answer("Binding failed. Try again later.")
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:
**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
# Get JWT token from Redis
jwt_token = self._get_user_jwt(chat_id)
if not jwt_token:
await message.answer("❌ Not connected. Use /start to bind your account.")
# 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/wallets/summary?family_id=1
wallets = await self._api_call(
# Call API: GET /api/v1/accounts?family_id=1
accounts_response = await self._api_call(
method="GET",
endpoint="/api/v1/wallets/summary",
endpoint="/api/v1/accounts",
jwt_token=jwt_token,
params={"family_id": 1}, # TODO: Get from context
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 Wallets:**\n\n"
for wallet in wallets:
response += f"📊 {wallet['name']}: ${wallet['balance']}\n"
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: {e}")
await message.answer("❌ Could not fetch balance. Try again later.")
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):
@@ -230,16 +317,51 @@ class TelegramBotClient:
# ========== Handler: /help ==========
async def cmd_help(self, message: Message):
"""Show available commands"""
help_text = """
🤖 **Finance Bot Commands:**
"""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:**
/start - Bind your Telegram account
/balance - Show wallet balances
🔗 **Account**
/start - Re-bind account (if needed)
/balance - Show all account balances
/settings - Account settings
💰 **Transactions**
/add - Add new transaction
/reports - View reports (daily/weekly/monthly)
/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**
**Step 1: Bind Your Account**
/start - Click the link to bind your Telegram account
**Step 2: Login**
Use your email and password on the binding page
**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 ==========
@@ -253,29 +375,40 @@ class TelegramBotClient:
use_jwt: bool = True,
) -> Dict[str, Any]:
"""
Make HTTP request to API with proper auth headers.
Make HTTP request to API with proper authentication headers.
Headers:
- Authorization: Bearer <jwt_token>
**Headers:**
- Authorization: Bearer <jwt_token> (if use_jwt=True)
- X-Client-Id: telegram_bot
- X-Signature: HMAC_SHA256(...)
- 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
# Add JWT if provided and requested
if use_jwt and jwt_token:
headers["Authorization"] = f"Bearer {jwt_token}"
# Add HMAC signature
# Add HMAC signature for integrity verification
timestamp = int(time.time())
headers["X-Timestamp"] = str(timestamp)
@@ -288,20 +421,39 @@ class TelegramBotClient:
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()
try:
async with self.session.request(
method=method,
url=url,
json=data,
params=params,
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"""