init commit
This commit is contained in:
6
.history/app/bot/__init___20251210201700.py
Normal file
6
.history/app/bot/__init___20251210201700.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Bot module"""
|
||||
|
||||
from app.bot.handlers import register_handlers
|
||||
from app.bot.keyboards import *
|
||||
|
||||
__all__ = ["register_handlers"]
|
||||
6
.history/app/bot/__init___20251210202255.py
Normal file
6
.history/app/bot/__init___20251210202255.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Bot module"""
|
||||
|
||||
from app.bot.handlers import register_handlers
|
||||
from app.bot.keyboards import *
|
||||
|
||||
__all__ = ["register_handlers"]
|
||||
329
.history/app/bot/client_20251210210501.py
Normal file
329
.history/app/bot/client_20251210210501.py
Normal file
@@ -0,0 +1,329 @@
|
||||
"""
|
||||
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
|
||||
from aiogram import Bot, Dispatcher, types, F
|
||||
from aiogram.filters import Command
|
||||
from aiogram.types import Message
|
||||
import redis
|
||||
import json
|
||||
|
||||
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")
|
||||
|
||||
from app.security.hmac_manager import hmac_manager
|
||||
from datetime import datetime
|
||||
import time
|
||||
|
||||
# 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
|
||||
329
.history/app/bot/client_20251210210906.py
Normal file
329
.history/app/bot/client_20251210210906.py
Normal file
@@ -0,0 +1,329 @@
|
||||
"""
|
||||
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
|
||||
from aiogram import Bot, Dispatcher, types, F
|
||||
from aiogram.filters import Command
|
||||
from aiogram.types import Message
|
||||
import redis
|
||||
import json
|
||||
|
||||
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")
|
||||
|
||||
from app.security.hmac_manager import hmac_manager
|
||||
from datetime import datetime
|
||||
import time
|
||||
|
||||
# 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
|
||||
332
.history/app/bot/client_20251210215954.py
Normal file
332
.history/app/bot/client_20251210215954.py
Normal file
@@ -0,0 +1,332 @@
|
||||
"""
|
||||
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")
|
||||
|
||||
from app.security.hmac_manager import hmac_manager
|
||||
from datetime import datetime
|
||||
import time
|
||||
|
||||
# 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
|
||||
328
.history/app/bot/client_20251210215958.py
Normal file
328
.history/app/bot/client_20251210215958.py
Normal file
@@ -0,0 +1,328 @@
|
||||
"""
|
||||
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
|
||||
328
.history/app/bot/client_20251210220144.py
Normal file
328
.history/app/bot/client_20251210220144.py
Normal file
@@ -0,0 +1,328 @@
|
||||
"""
|
||||
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
|
||||
14
.history/app/bot/handlers/__init___20251210201701.py
Normal file
14
.history/app/bot/handlers/__init___20251210201701.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""Bot handlers"""
|
||||
|
||||
from app.bot.handlers.start import register_start_handlers
|
||||
from app.bot.handlers.user import register_user_handlers
|
||||
from app.bot.handlers.family import register_family_handlers
|
||||
from app.bot.handlers.transaction import register_transaction_handlers
|
||||
|
||||
|
||||
def register_handlers(dp):
|
||||
"""Register all bot handlers"""
|
||||
register_start_handlers(dp)
|
||||
register_user_handlers(dp)
|
||||
register_family_handlers(dp)
|
||||
register_transaction_handlers(dp)
|
||||
14
.history/app/bot/handlers/__init___20251210202255.py
Normal file
14
.history/app/bot/handlers/__init___20251210202255.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""Bot handlers"""
|
||||
|
||||
from app.bot.handlers.start import register_start_handlers
|
||||
from app.bot.handlers.user import register_user_handlers
|
||||
from app.bot.handlers.family import register_family_handlers
|
||||
from app.bot.handlers.transaction import register_transaction_handlers
|
||||
|
||||
|
||||
def register_handlers(dp):
|
||||
"""Register all bot handlers"""
|
||||
register_start_handlers(dp)
|
||||
register_user_handlers(dp)
|
||||
register_family_handlers(dp)
|
||||
register_transaction_handlers(dp)
|
||||
18
.history/app/bot/handlers/family_20251210201701.py
Normal file
18
.history/app/bot/handlers/family_20251210201701.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""Family-related handlers"""
|
||||
|
||||
from aiogram import Router
|
||||
from aiogram.types import Message
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.message()
|
||||
async def family_menu(message: Message):
|
||||
"""Handle family menu interactions"""
|
||||
pass
|
||||
|
||||
|
||||
def register_family_handlers(dp):
|
||||
"""Register family handlers"""
|
||||
dp.include_router(router)
|
||||
18
.history/app/bot/handlers/family_20251210202255.py
Normal file
18
.history/app/bot/handlers/family_20251210202255.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""Family-related handlers"""
|
||||
|
||||
from aiogram import Router
|
||||
from aiogram.types import Message
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.message()
|
||||
async def family_menu(message: Message):
|
||||
"""Handle family menu interactions"""
|
||||
pass
|
||||
|
||||
|
||||
def register_family_handlers(dp):
|
||||
"""Register family handlers"""
|
||||
dp.include_router(router)
|
||||
60
.history/app/bot/handlers/start_20251210201701.py
Normal file
60
.history/app/bot/handlers/start_20251210201701.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Start and help handlers"""
|
||||
|
||||
from aiogram import Router, F
|
||||
from aiogram.filters import CommandStart
|
||||
from aiogram.types import Message
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db.database import SessionLocal
|
||||
from app.db.repositories import UserRepository, FamilyRepository
|
||||
from app.bot.keyboards import main_menu_keyboard
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.message(CommandStart())
|
||||
async def cmd_start(message: Message):
|
||||
"""Handle /start command"""
|
||||
user_repo = UserRepository(SessionLocal())
|
||||
|
||||
# Create or update user
|
||||
user = user_repo.get_or_create(
|
||||
telegram_id=message.from_user.id,
|
||||
username=message.from_user.username,
|
||||
first_name=message.from_user.first_name,
|
||||
last_name=message.from_user.last_name,
|
||||
)
|
||||
|
||||
welcome_text = (
|
||||
"👋 Добро пожаловать в Finance Bot!\n\n"
|
||||
"Я помогу вам управлять семейными финансами:\n"
|
||||
"💰 Отслеживать доходы и расходы\n"
|
||||
"👨👩👧👦 Управлять семейной группой\n"
|
||||
"📊 Видеть аналитику\n"
|
||||
"🎯 Ставить финансовые цели\n\n"
|
||||
"Выберите действие:"
|
||||
)
|
||||
|
||||
await message.answer(welcome_text, reply_markup=main_menu_keyboard())
|
||||
|
||||
|
||||
@router.message(CommandStart())
|
||||
async def cmd_help(message: Message):
|
||||
"""Handle /help command"""
|
||||
help_text = (
|
||||
"📚 **Справка по командам:**\n\n"
|
||||
"/start - Главное меню\n"
|
||||
"/help - Эта справка\n"
|
||||
"/account - Мои счета\n"
|
||||
"/transaction - Новая операция\n"
|
||||
"/budget - Управление бюджетом\n"
|
||||
"/analytics - Аналитика\n"
|
||||
"/family - Управление семьей\n"
|
||||
"/settings - Параметры\n"
|
||||
)
|
||||
await message.answer(help_text)
|
||||
|
||||
|
||||
def register_start_handlers(dp):
|
||||
"""Register start handlers"""
|
||||
dp.include_router(router)
|
||||
60
.history/app/bot/handlers/start_20251210202255.py
Normal file
60
.history/app/bot/handlers/start_20251210202255.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Start and help handlers"""
|
||||
|
||||
from aiogram import Router, F
|
||||
from aiogram.filters import CommandStart
|
||||
from aiogram.types import Message
|
||||
from sqlalchemy.orm import Session
|
||||
from app.db.database import SessionLocal
|
||||
from app.db.repositories import UserRepository, FamilyRepository
|
||||
from app.bot.keyboards import main_menu_keyboard
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.message(CommandStart())
|
||||
async def cmd_start(message: Message):
|
||||
"""Handle /start command"""
|
||||
user_repo = UserRepository(SessionLocal())
|
||||
|
||||
# Create or update user
|
||||
user = user_repo.get_or_create(
|
||||
telegram_id=message.from_user.id,
|
||||
username=message.from_user.username,
|
||||
first_name=message.from_user.first_name,
|
||||
last_name=message.from_user.last_name,
|
||||
)
|
||||
|
||||
welcome_text = (
|
||||
"👋 Добро пожаловать в Finance Bot!\n\n"
|
||||
"Я помогу вам управлять семейными финансами:\n"
|
||||
"💰 Отслеживать доходы и расходы\n"
|
||||
"👨👩👧👦 Управлять семейной группой\n"
|
||||
"📊 Видеть аналитику\n"
|
||||
"🎯 Ставить финансовые цели\n\n"
|
||||
"Выберите действие:"
|
||||
)
|
||||
|
||||
await message.answer(welcome_text, reply_markup=main_menu_keyboard())
|
||||
|
||||
|
||||
@router.message(CommandStart())
|
||||
async def cmd_help(message: Message):
|
||||
"""Handle /help command"""
|
||||
help_text = (
|
||||
"📚 **Справка по командам:**\n\n"
|
||||
"/start - Главное меню\n"
|
||||
"/help - Эта справка\n"
|
||||
"/account - Мои счета\n"
|
||||
"/transaction - Новая операция\n"
|
||||
"/budget - Управление бюджетом\n"
|
||||
"/analytics - Аналитика\n"
|
||||
"/family - Управление семьей\n"
|
||||
"/settings - Параметры\n"
|
||||
)
|
||||
await message.answer(help_text)
|
||||
|
||||
|
||||
def register_start_handlers(dp):
|
||||
"""Register start handlers"""
|
||||
dp.include_router(router)
|
||||
18
.history/app/bot/handlers/transaction_20251210201701.py
Normal file
18
.history/app/bot/handlers/transaction_20251210201701.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""Transaction-related handlers"""
|
||||
|
||||
from aiogram import Router
|
||||
from aiogram.types import Message
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.message()
|
||||
async def transaction_menu(message: Message):
|
||||
"""Handle transaction operations"""
|
||||
pass
|
||||
|
||||
|
||||
def register_transaction_handlers(dp):
|
||||
"""Register transaction handlers"""
|
||||
dp.include_router(router)
|
||||
18
.history/app/bot/handlers/transaction_20251210202255.py
Normal file
18
.history/app/bot/handlers/transaction_20251210202255.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""Transaction-related handlers"""
|
||||
|
||||
from aiogram import Router
|
||||
from aiogram.types import Message
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.message()
|
||||
async def transaction_menu(message: Message):
|
||||
"""Handle transaction operations"""
|
||||
pass
|
||||
|
||||
|
||||
def register_transaction_handlers(dp):
|
||||
"""Register transaction handlers"""
|
||||
dp.include_router(router)
|
||||
18
.history/app/bot/handlers/user_20251210201701.py
Normal file
18
.history/app/bot/handlers/user_20251210201701.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""User-related handlers"""
|
||||
|
||||
from aiogram import Router
|
||||
from aiogram.types import Message
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.message()
|
||||
async def user_menu(message: Message):
|
||||
"""Handle user menu interactions"""
|
||||
pass
|
||||
|
||||
|
||||
def register_user_handlers(dp):
|
||||
"""Register user handlers"""
|
||||
dp.include_router(router)
|
||||
18
.history/app/bot/handlers/user_20251210202255.py
Normal file
18
.history/app/bot/handlers/user_20251210202255.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""User-related handlers"""
|
||||
|
||||
from aiogram import Router
|
||||
from aiogram.types import Message
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.message()
|
||||
async def user_menu(message: Message):
|
||||
"""Handle user menu interactions"""
|
||||
pass
|
||||
|
||||
|
||||
def register_user_handlers(dp):
|
||||
"""Register user handlers"""
|
||||
dp.include_router(router)
|
||||
56
.history/app/bot/keyboards/__init___20251210201702.py
Normal file
56
.history/app/bot/keyboards/__init___20251210201702.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Bot keyboards"""
|
||||
|
||||
from aiogram.types import ReplyKeyboardMarkup, KeyboardButton
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
|
||||
def main_menu_keyboard() -> ReplyKeyboardMarkup:
|
||||
"""Main menu keyboard"""
|
||||
return ReplyKeyboardMarkup(
|
||||
keyboard=[
|
||||
[
|
||||
KeyboardButton(text="💰 Новая операция"),
|
||||
KeyboardButton(text="📊 Аналитика"),
|
||||
],
|
||||
[
|
||||
KeyboardButton(text="👨👩👧👦 Семья"),
|
||||
KeyboardButton(text="🎯 Цели"),
|
||||
],
|
||||
[
|
||||
KeyboardButton(text="💳 Счета"),
|
||||
KeyboardButton(text="⚙️ Параметры"),
|
||||
],
|
||||
[
|
||||
KeyboardButton(text="📞 Помощь"),
|
||||
],
|
||||
],
|
||||
resize_keyboard=True,
|
||||
input_field_placeholder="Выберите действие...",
|
||||
)
|
||||
|
||||
|
||||
def transaction_type_keyboard() -> InlineKeyboardMarkup:
|
||||
"""Transaction type selection"""
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💸 Расход", callback_data="tx_expense")],
|
||||
[InlineKeyboardButton(text="💵 Доход", callback_data="tx_income")],
|
||||
[InlineKeyboardButton(text="🔄 Перевод", callback_data="tx_transfer")],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def cancel_keyboard() -> InlineKeyboardMarkup:
|
||||
"""Cancel button"""
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(text="❌ Отменить", callback_data="cancel")],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"main_menu_keyboard",
|
||||
"transaction_type_keyboard",
|
||||
"cancel_keyboard",
|
||||
]
|
||||
56
.history/app/bot/keyboards/__init___20251210202255.py
Normal file
56
.history/app/bot/keyboards/__init___20251210202255.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Bot keyboards"""
|
||||
|
||||
from aiogram.types import ReplyKeyboardMarkup, KeyboardButton
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
|
||||
|
||||
def main_menu_keyboard() -> ReplyKeyboardMarkup:
|
||||
"""Main menu keyboard"""
|
||||
return ReplyKeyboardMarkup(
|
||||
keyboard=[
|
||||
[
|
||||
KeyboardButton(text="💰 Новая операция"),
|
||||
KeyboardButton(text="📊 Аналитика"),
|
||||
],
|
||||
[
|
||||
KeyboardButton(text="👨👩👧👦 Семья"),
|
||||
KeyboardButton(text="🎯 Цели"),
|
||||
],
|
||||
[
|
||||
KeyboardButton(text="💳 Счета"),
|
||||
KeyboardButton(text="⚙️ Параметры"),
|
||||
],
|
||||
[
|
||||
KeyboardButton(text="📞 Помощь"),
|
||||
],
|
||||
],
|
||||
resize_keyboard=True,
|
||||
input_field_placeholder="Выберите действие...",
|
||||
)
|
||||
|
||||
|
||||
def transaction_type_keyboard() -> InlineKeyboardMarkup:
|
||||
"""Transaction type selection"""
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(text="💸 Расход", callback_data="tx_expense")],
|
||||
[InlineKeyboardButton(text="💵 Доход", callback_data="tx_income")],
|
||||
[InlineKeyboardButton(text="🔄 Перевод", callback_data="tx_transfer")],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def cancel_keyboard() -> InlineKeyboardMarkup:
|
||||
"""Cancel button"""
|
||||
return InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[InlineKeyboardButton(text="❌ Отменить", callback_data="cancel")],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"main_menu_keyboard",
|
||||
"transaction_type_keyboard",
|
||||
"cancel_keyboard",
|
||||
]
|
||||
Reference in New Issue
Block a user