Initial commit: ROA2WEB - FastAPI + Vue.js + Telegram Bot
Modern ERP Reports Application with microservices architecture Tech Stack: - Backend: FastAPI + python-oracledb (Oracle DB integration) - Frontend: Vue.js 3 + PrimeVue + Vite - Telegram Bot: python-telegram-bot + SQLite - Infrastructure: Shared database pool, JWT authentication, SSH tunnel Features: - FastAPI backend with async Oracle connection pool - Vue.js 3 responsive frontend with PrimeVue components - Telegram bot alternative interface - Microservices architecture with shared components - Complete deployment support (Linux Docker + Windows IIS) - Comprehensive testing (Playwright E2E + pytest) Repository Structure: - reports-app/ - Main application (backend, frontend, telegram-bot) - shared/ - Shared components (database pool, auth, utils) - deployment/ - Deployment scripts (Linux & Windows) - docs/ - Project documentation - security/ - Security scanning and git hooks
This commit is contained in:
0
reports-app/telegram-bot/app/__init__.py
Normal file
0
reports-app/telegram-bot/app/__init__.py
Normal file
0
reports-app/telegram-bot/app/agent/__init__.py
Normal file
0
reports-app/telegram-bot/app/agent/__init__.py
Normal file
313
reports-app/telegram-bot/app/agent/session.py
Normal file
313
reports-app/telegram-bot/app/agent/session.py
Normal file
@@ -0,0 +1,313 @@
|
||||
"""
|
||||
Session Management for Telegram Bot
|
||||
|
||||
This module handles session state for Telegram users, specifically managing
|
||||
the active company selection for command handlers.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import json
|
||||
from typing import Dict, Any, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from app.db.operations import (
|
||||
create_session,
|
||||
get_user_active_session,
|
||||
update_session_state,
|
||||
delete_user_sessions
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConversationSession:
|
||||
"""
|
||||
Manages session state for a single user.
|
||||
|
||||
Attributes:
|
||||
telegram_user_id: Telegram user ID
|
||||
session_id: UUID of the session
|
||||
active_company_id: Selected company ID
|
||||
active_company_name: Selected company name
|
||||
active_company_cui: Selected company CUI
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
telegram_user_id: int,
|
||||
session_id: Optional[str] = None
|
||||
):
|
||||
"""
|
||||
Initialize a session.
|
||||
|
||||
Args:
|
||||
telegram_user_id: Telegram user ID
|
||||
session_id: Existing session ID (if resuming), or None for new session
|
||||
"""
|
||||
self.telegram_user_id = telegram_user_id
|
||||
self.session_id = session_id
|
||||
self.created_at = datetime.now()
|
||||
self.updated_at = datetime.now()
|
||||
|
||||
# Active company for this session
|
||||
self.active_company_id: Optional[int] = None
|
||||
self.active_company_name: Optional[str] = None
|
||||
self.active_company_cui: Optional[str] = None
|
||||
|
||||
def set_active_company(
|
||||
self,
|
||||
company_id: int,
|
||||
company_name: str,
|
||||
company_cui: Optional[str] = None
|
||||
):
|
||||
"""
|
||||
Set the active company for this session.
|
||||
|
||||
Args:
|
||||
company_id: Company ID
|
||||
company_name: Company name
|
||||
company_cui: Company CUI (optional)
|
||||
"""
|
||||
self.active_company_id = company_id
|
||||
self.active_company_name = company_name
|
||||
self.active_company_cui = company_cui
|
||||
self.updated_at = datetime.now()
|
||||
logger.info(
|
||||
f"Active company set for user {self.telegram_user_id}: "
|
||||
f"{company_name} (ID: {company_id})"
|
||||
)
|
||||
|
||||
def get_active_company(self) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get the active company information.
|
||||
|
||||
Returns:
|
||||
Dict with company info (id, name, cui) or None if no company selected
|
||||
"""
|
||||
if self.active_company_id is not None:
|
||||
return {
|
||||
"id": self.active_company_id,
|
||||
"name": self.active_company_name,
|
||||
"cui": self.active_company_cui
|
||||
}
|
||||
return None
|
||||
|
||||
def clear_active_company(self):
|
||||
"""
|
||||
Clear the active company selection.
|
||||
"""
|
||||
logger.info(
|
||||
f"Clearing active company for user {self.telegram_user_id} "
|
||||
f"(was: {self.active_company_name})"
|
||||
)
|
||||
self.active_company_id = None
|
||||
self.active_company_name = None
|
||||
self.active_company_cui = None
|
||||
self.updated_at = datetime.now()
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Serialize session to dictionary (for database storage).
|
||||
|
||||
Returns:
|
||||
Dict representation of session
|
||||
"""
|
||||
return {
|
||||
"telegram_user_id": self.telegram_user_id,
|
||||
"session_id": self.session_id,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
"active_company_id": self.active_company_id,
|
||||
"active_company_name": self.active_company_name,
|
||||
"active_company_cui": self.active_company_cui
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'ConversationSession':
|
||||
"""
|
||||
Deserialize session from dictionary.
|
||||
|
||||
Args:
|
||||
data: Dict representation of session
|
||||
|
||||
Returns:
|
||||
ConversationSession instance
|
||||
"""
|
||||
session = cls(
|
||||
telegram_user_id=data["telegram_user_id"],
|
||||
session_id=data.get("session_id")
|
||||
)
|
||||
|
||||
# Restore active company
|
||||
session.active_company_id = data.get("active_company_id")
|
||||
session.active_company_name = data.get("active_company_name")
|
||||
session.active_company_cui = data.get("active_company_cui")
|
||||
|
||||
if "created_at" in data:
|
||||
session.created_at = datetime.fromisoformat(data["created_at"])
|
||||
if "updated_at" in data:
|
||||
session.updated_at = datetime.fromisoformat(data["updated_at"])
|
||||
|
||||
return session
|
||||
|
||||
|
||||
class SessionManager:
|
||||
"""
|
||||
Manages sessions for all users.
|
||||
|
||||
Provides methods to create, retrieve, update, and delete sessions.
|
||||
Sessions are stored both in memory (for quick access) and in database
|
||||
(for persistence).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""
|
||||
Initialize the session manager.
|
||||
"""
|
||||
self._sessions: Dict[int, ConversationSession] = {}
|
||||
logger.info("SessionManager initialized")
|
||||
|
||||
async def get_or_create_session(
|
||||
self,
|
||||
telegram_user_id: int
|
||||
) -> ConversationSession:
|
||||
"""
|
||||
Get existing session for a user or create a new one.
|
||||
|
||||
Args:
|
||||
telegram_user_id: Telegram user ID
|
||||
|
||||
Returns:
|
||||
ConversationSession for the user
|
||||
"""
|
||||
# Check in-memory cache first
|
||||
if telegram_user_id in self._sessions:
|
||||
logger.debug(f"Found session in cache for user {telegram_user_id}")
|
||||
return self._sessions[telegram_user_id]
|
||||
|
||||
# Check database for existing session
|
||||
session_data = await get_user_active_session(telegram_user_id)
|
||||
|
||||
if session_data:
|
||||
# Restore session from database
|
||||
conversation_state_json = session_data.get('conversation_state')
|
||||
|
||||
if conversation_state_json:
|
||||
try:
|
||||
session_dict = json.loads(conversation_state_json)
|
||||
session = ConversationSession.from_dict(session_dict)
|
||||
session.session_id = session_data['session_id']
|
||||
self._sessions[telegram_user_id] = session
|
||||
logger.info(f"Restored session from database for user {telegram_user_id}")
|
||||
return session
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Failed to parse session state: {e}")
|
||||
|
||||
# Create new session
|
||||
session = ConversationSession(telegram_user_id)
|
||||
|
||||
# Save to database
|
||||
session_id = await create_session(
|
||||
telegram_user_id=telegram_user_id,
|
||||
conversation_state=json.dumps(session.to_dict()),
|
||||
expires_in_hours=24
|
||||
)
|
||||
|
||||
session.session_id = session_id
|
||||
self._sessions[telegram_user_id] = session
|
||||
|
||||
logger.info(f"Created new session for user {telegram_user_id} (ID: {session_id})")
|
||||
return session
|
||||
|
||||
async def save_session(self, telegram_user_id: int) -> bool:
|
||||
"""
|
||||
Save session to database.
|
||||
|
||||
Args:
|
||||
telegram_user_id: Telegram user ID
|
||||
|
||||
Returns:
|
||||
bool: True if saved successfully
|
||||
"""
|
||||
session = self._sessions.get(telegram_user_id)
|
||||
|
||||
if not session or not session.session_id:
|
||||
logger.warning(f"No session to save for user {telegram_user_id}")
|
||||
return False
|
||||
|
||||
try:
|
||||
conversation_state = json.dumps(session.to_dict())
|
||||
|
||||
success = await update_session_state(
|
||||
session_id=session.session_id,
|
||||
conversation_state=conversation_state
|
||||
)
|
||||
|
||||
if success:
|
||||
logger.debug(f"Saved session for user {telegram_user_id}")
|
||||
else:
|
||||
logger.warning(f"Failed to save session for user {telegram_user_id}")
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving session for user {telegram_user_id}: {e}")
|
||||
return False
|
||||
|
||||
async def delete_session(self, telegram_user_id: int) -> bool:
|
||||
"""
|
||||
Delete session completely (from memory and database).
|
||||
|
||||
Args:
|
||||
telegram_user_id: Telegram user ID
|
||||
|
||||
Returns:
|
||||
bool: True if deleted successfully
|
||||
"""
|
||||
# Remove from memory
|
||||
if telegram_user_id in self._sessions:
|
||||
del self._sessions[telegram_user_id]
|
||||
|
||||
# Delete from database
|
||||
success = await delete_user_sessions(telegram_user_id)
|
||||
|
||||
if success:
|
||||
logger.info(f"Deleted session for user {telegram_user_id}")
|
||||
else:
|
||||
logger.warning(f"Failed to delete session for user {telegram_user_id}")
|
||||
|
||||
return success
|
||||
|
||||
def get_active_sessions_count(self) -> int:
|
||||
"""
|
||||
Get count of active sessions in memory.
|
||||
|
||||
Returns:
|
||||
int: Number of active sessions
|
||||
"""
|
||||
return len(self._sessions)
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_session_manager_instance: Optional[SessionManager] = None
|
||||
|
||||
|
||||
def get_session_manager() -> SessionManager:
|
||||
"""
|
||||
Get or create the singleton SessionManager instance.
|
||||
|
||||
Returns:
|
||||
SessionManager: Singleton instance
|
||||
"""
|
||||
global _session_manager_instance
|
||||
if _session_manager_instance is None:
|
||||
_session_manager_instance = SessionManager()
|
||||
return _session_manager_instance
|
||||
|
||||
|
||||
# Export main classes and functions
|
||||
__all__ = [
|
||||
'ConversationSession',
|
||||
'SessionManager',
|
||||
'get_session_manager'
|
||||
]
|
||||
0
reports-app/telegram-bot/app/api/__init__.py
Normal file
0
reports-app/telegram-bot/app/api/__init__.py
Normal file
637
reports-app/telegram-bot/app/api/client.py
Normal file
637
reports-app/telegram-bot/app/api/client.py
Normal file
@@ -0,0 +1,637 @@
|
||||
"""
|
||||
API Client for ROA2WEB Backend Communication
|
||||
|
||||
This module provides an async HTTP client for communicating with the FastAPI backend.
|
||||
Handles authentication, requests, error handling, and response parsing.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional, Dict, Any, List
|
||||
from datetime import datetime
|
||||
|
||||
import httpx
|
||||
from httpx import AsyncClient, Response, HTTPError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Backend configuration from environment
|
||||
BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:8001")
|
||||
REQUEST_TIMEOUT = float(os.getenv("API_TIMEOUT", "30.0")) # 30 seconds default
|
||||
|
||||
|
||||
class BackendAPIClient:
|
||||
"""
|
||||
Async HTTP client for ROA2WEB FastAPI backend.
|
||||
|
||||
Provides methods for all API endpoints used by the Telegram bot:
|
||||
- Dashboard data
|
||||
- Invoices search and retrieval
|
||||
- Treasury/payment data
|
||||
- Report exports
|
||||
- Company listings
|
||||
- User authentication and token management
|
||||
"""
|
||||
|
||||
def __init__(self, base_url: str = BACKEND_URL):
|
||||
"""
|
||||
Initialize the API client.
|
||||
|
||||
Args:
|
||||
base_url: Base URL of the FastAPI backend
|
||||
"""
|
||||
self.base_url = base_url.rstrip('/')
|
||||
self.client: Optional[AsyncClient] = None
|
||||
logger.info(f"Backend API client initialized with base URL: {self.base_url}")
|
||||
|
||||
async def __aenter__(self):
|
||||
"""Async context manager entry."""
|
||||
self.client = AsyncClient(
|
||||
base_url=self.base_url,
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
follow_redirects=True
|
||||
)
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Async context manager exit."""
|
||||
if self.client:
|
||||
await self.client.aclose()
|
||||
|
||||
def _get_auth_headers(self, jwt_token: str) -> Dict[str, str]:
|
||||
"""
|
||||
Generate authentication headers with JWT token.
|
||||
|
||||
Args:
|
||||
jwt_token: JWT access token
|
||||
|
||||
Returns:
|
||||
Dict with Authorization header
|
||||
"""
|
||||
return {
|
||||
"Authorization": f"Bearer {jwt_token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
async def _handle_response(self, response: Response) -> Dict[str, Any]:
|
||||
"""
|
||||
Handle API response and extract data.
|
||||
|
||||
Args:
|
||||
response: HTTP response object
|
||||
|
||||
Returns:
|
||||
Dict: Response JSON data
|
||||
|
||||
Raises:
|
||||
HTTPError: If response status is not successful
|
||||
"""
|
||||
try:
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except HTTPError as e:
|
||||
logger.error(f"API request failed: {e}")
|
||||
logger.error(f"Response body: {response.text}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to parse response: {e}")
|
||||
raise
|
||||
|
||||
# =========================================================================
|
||||
# AUTHENTICATION & USER ENDPOINTS
|
||||
# =========================================================================
|
||||
|
||||
async def verify_user(
|
||||
self,
|
||||
oracle_username: str,
|
||||
linking_code: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Verify user exists in Oracle and get JWT token.
|
||||
Called during Telegram linking process (auto-linking flow).
|
||||
|
||||
Args:
|
||||
oracle_username: Oracle username extracted from linking code
|
||||
linking_code: The 8-character linking code for validation
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- success: True if verification succeeded
|
||||
- access_token: JWT access token
|
||||
- refresh_token: JWT refresh token
|
||||
- user: Dict with user_id, username, companies, permissions
|
||||
- message: Status message
|
||||
|
||||
None if user not found or error
|
||||
|
||||
Example:
|
||||
result = await client.verify_user("JOHN.DOE", "ABC12345")
|
||||
if result and result['success']:
|
||||
jwt_token = result['access_token']
|
||||
"""
|
||||
try:
|
||||
if not self.client:
|
||||
self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT)
|
||||
|
||||
# Flow A: Auto-linking (no password required)
|
||||
response = await self.client.post(
|
||||
"/api/telegram/auth/verify-user",
|
||||
json={
|
||||
"linking_code": linking_code,
|
||||
"oracle_username": oracle_username
|
||||
}
|
||||
)
|
||||
|
||||
return await self._handle_response(response)
|
||||
|
||||
except HTTPError as e:
|
||||
if e.response.status_code == 404:
|
||||
logger.warning(f"User {oracle_username} not found in Oracle")
|
||||
return None
|
||||
logger.error(f"Failed to verify user {oracle_username}: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error verifying user: {e}")
|
||||
return None
|
||||
|
||||
async def refresh_token(self, refresh_token: str) -> Optional[str]:
|
||||
"""
|
||||
Refresh JWT token for a user.
|
||||
|
||||
Args:
|
||||
refresh_token: JWT refresh token
|
||||
|
||||
Returns:
|
||||
str: New JWT access token, None if failed
|
||||
"""
|
||||
try:
|
||||
if not self.client:
|
||||
self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT)
|
||||
|
||||
response = await self.client.post(
|
||||
"/api/telegram/auth/refresh-token",
|
||||
json={"refresh_token": refresh_token}
|
||||
)
|
||||
|
||||
data = await self._handle_response(response)
|
||||
return data.get('access_token')
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to refresh token: {e}")
|
||||
return None
|
||||
|
||||
async def get_user_companies(self, jwt_token: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get list of companies the user has access to.
|
||||
|
||||
Args:
|
||||
jwt_token: JWT access token
|
||||
|
||||
Returns:
|
||||
List of company dicts with id, nume_firma, cui, etc.
|
||||
"""
|
||||
try:
|
||||
if not self.client:
|
||||
self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT)
|
||||
|
||||
response = await self.client.get(
|
||||
"/api/companies",
|
||||
headers=self._get_auth_headers(jwt_token)
|
||||
)
|
||||
|
||||
data = await self._handle_response(response)
|
||||
|
||||
# Backend returns {"companies": [...], "total_count": N}
|
||||
if isinstance(data, dict) and "companies" in data:
|
||||
return data["companies"]
|
||||
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get companies: {e}")
|
||||
return []
|
||||
|
||||
# =========================================================================
|
||||
# DASHBOARD ENDPOINTS
|
||||
# =========================================================================
|
||||
|
||||
async def get_dashboard_data(
|
||||
self,
|
||||
company_id: int,
|
||||
jwt_token: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get dashboard statistics for a company.
|
||||
|
||||
Args:
|
||||
company_id: Company ID
|
||||
jwt_token: JWT access token
|
||||
|
||||
Returns:
|
||||
Dict with dashboard data (sold_total, facturi, plati, etc.)
|
||||
"""
|
||||
try:
|
||||
if not self.client:
|
||||
self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT)
|
||||
|
||||
response = await self.client.get(
|
||||
"/api/dashboard/summary",
|
||||
params={"company": str(company_id)},
|
||||
headers=self._get_auth_headers(jwt_token)
|
||||
)
|
||||
|
||||
return await self._handle_response(response)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get dashboard data for company {company_id}: {e}")
|
||||
return None
|
||||
|
||||
async def get_treasury_breakdown(
|
||||
self,
|
||||
company_id: int,
|
||||
jwt_token: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get detailed treasury breakdown (casa + banca accounts).
|
||||
|
||||
Args:
|
||||
company_id: Company ID
|
||||
jwt_token: JWT access token
|
||||
|
||||
Returns:
|
||||
Dict with treasury breakdown data (accounts by type)
|
||||
"""
|
||||
try:
|
||||
if not self.client:
|
||||
self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT)
|
||||
|
||||
response = await self.client.get(
|
||||
f"/api/dashboard/treasury-breakdown?company={company_id}",
|
||||
headers=self._get_auth_headers(jwt_token)
|
||||
)
|
||||
|
||||
return await self._handle_response(response)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get treasury breakdown for company {company_id}: {e}")
|
||||
return None
|
||||
|
||||
async def get_detailed_data(
|
||||
self,
|
||||
company_id: int,
|
||||
jwt_token: str,
|
||||
data_type: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get detailed data for clients or suppliers.
|
||||
|
||||
Args:
|
||||
company_id: Company ID
|
||||
jwt_token: JWT access token
|
||||
data_type: Type of data ('clients' or 'suppliers')
|
||||
|
||||
Returns:
|
||||
Dict with detailed data (list of clients/suppliers with balances)
|
||||
"""
|
||||
try:
|
||||
if not self.client:
|
||||
self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT)
|
||||
|
||||
response = await self.client.get(
|
||||
f"/api/dashboard/detailed-data?company={company_id}&data_type={data_type}",
|
||||
headers=self._get_auth_headers(jwt_token)
|
||||
)
|
||||
|
||||
return await self._handle_response(response)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get detailed data ({data_type}) for company {company_id}: {e}")
|
||||
return None
|
||||
|
||||
async def get_maturity_data(
|
||||
self,
|
||||
company_id: int,
|
||||
jwt_token: str,
|
||||
period: str = "all"
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get maturity data (in term/overdue breakdown).
|
||||
|
||||
Args:
|
||||
company_id: Company ID
|
||||
jwt_token: JWT access token
|
||||
period: Period filter ('all', '30', '60', '90')
|
||||
|
||||
Returns:
|
||||
Dict with maturity data (in_term, overdue, total)
|
||||
"""
|
||||
try:
|
||||
if not self.client:
|
||||
self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT)
|
||||
|
||||
response = await self.client.get(
|
||||
f"/api/dashboard/maturity?company={company_id}&period={period}",
|
||||
headers=self._get_auth_headers(jwt_token)
|
||||
)
|
||||
|
||||
return await self._handle_response(response)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get maturity data for company {company_id}: {e}")
|
||||
return None
|
||||
|
||||
async def get_performance_data(
|
||||
self,
|
||||
company_id: int,
|
||||
jwt_token: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get performance data (incasari/plati totals).
|
||||
|
||||
Args:
|
||||
company_id: Company ID
|
||||
jwt_token: JWT access token
|
||||
|
||||
Returns:
|
||||
Dict with performance data (incasari_total, plati_total, net)
|
||||
"""
|
||||
try:
|
||||
if not self.client:
|
||||
self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT)
|
||||
|
||||
response = await self.client.get(
|
||||
f"/api/dashboard/performance?company={company_id}",
|
||||
headers=self._get_auth_headers(jwt_token)
|
||||
)
|
||||
|
||||
return await self._handle_response(response)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get performance data for company {company_id}: {e}")
|
||||
return None
|
||||
|
||||
async def get_monthly_flows(
|
||||
self,
|
||||
company_id: int,
|
||||
jwt_token: str,
|
||||
months: int = 12
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get monthly cash flows data.
|
||||
|
||||
Args:
|
||||
company_id: Company ID
|
||||
jwt_token: JWT access token
|
||||
months: Number of months to retrieve
|
||||
|
||||
Returns:
|
||||
Dict with monthly flows (months, incasari, plati arrays)
|
||||
"""
|
||||
try:
|
||||
if not self.client:
|
||||
self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT)
|
||||
|
||||
response = await self.client.get(
|
||||
f"/api/dashboard/monthly-flows?company={company_id}&months={months}",
|
||||
headers=self._get_auth_headers(jwt_token)
|
||||
)
|
||||
|
||||
return await self._handle_response(response)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get monthly flows for company {company_id}: {e}")
|
||||
return None
|
||||
|
||||
# =========================================================================
|
||||
# INVOICES ENDPOINTS
|
||||
# =========================================================================
|
||||
|
||||
async def search_invoices(
|
||||
self,
|
||||
company_id: int,
|
||||
jwt_token: str,
|
||||
filters: Optional[Dict[str, Any]] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search invoices with optional filters.
|
||||
|
||||
Args:
|
||||
company_id: Company ID
|
||||
jwt_token: JWT access token
|
||||
filters: Optional filters dict:
|
||||
- date_from: str (YYYY-MM-DD)
|
||||
- date_to: str (YYYY-MM-DD)
|
||||
- status: str (paid, unpaid, overdue)
|
||||
- client_name: str
|
||||
- partner_type: str (CLIENTI, FURNIZORI)
|
||||
- partner_name: str
|
||||
- series: str
|
||||
- number: str
|
||||
|
||||
Returns:
|
||||
List of invoice dicts
|
||||
"""
|
||||
try:
|
||||
if not self.client:
|
||||
self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT)
|
||||
|
||||
params = {"company": company_id}
|
||||
if filters:
|
||||
params.update(filters)
|
||||
|
||||
# ⚠️ DEBUGGING: Log exact parameters being sent
|
||||
logger.info(f"📤 Searching invoices with params: {params}")
|
||||
|
||||
response = await self.client.get(
|
||||
"/api/invoices/",
|
||||
params=params,
|
||||
headers=self._get_auth_headers(jwt_token)
|
||||
)
|
||||
|
||||
data = await self._handle_response(response)
|
||||
|
||||
# ⚠️ DEBUGGING: Log response
|
||||
if isinstance(data, dict) and 'invoices' in data:
|
||||
invoice_list = data['invoices']
|
||||
logger.info(f"📥 Received {len(invoice_list)} invoices from backend")
|
||||
return invoice_list
|
||||
elif isinstance(data, list):
|
||||
logger.info(f"📥 Received {len(data)} invoices from backend (direct list)")
|
||||
return data
|
||||
else:
|
||||
logger.warning(f"📥 Unexpected response format: {type(data)}")
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to search invoices for company {company_id}: {e}")
|
||||
return []
|
||||
|
||||
async def get_invoice_summary(
|
||||
self,
|
||||
company_id: int,
|
||||
jwt_token: str,
|
||||
partner_type: str = "CLIENTI"
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get invoice summary statistics.
|
||||
|
||||
Args:
|
||||
company_id: Company ID
|
||||
jwt_token: JWT access token
|
||||
|
||||
Returns:
|
||||
Dict with summary (total_count, total_amount, paid, unpaid, etc.)
|
||||
"""
|
||||
try:
|
||||
if not self.client:
|
||||
self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT)
|
||||
|
||||
response = await self.client.get(
|
||||
"/api/invoices/summary",
|
||||
params={
|
||||
"company": str(company_id),
|
||||
"partner_type": partner_type
|
||||
},
|
||||
headers=self._get_auth_headers(jwt_token)
|
||||
)
|
||||
|
||||
return await self._handle_response(response)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get invoice summary for company {company_id}: {e}")
|
||||
return None
|
||||
|
||||
# =========================================================================
|
||||
# TREASURY ENDPOINTS
|
||||
# =========================================================================
|
||||
|
||||
async def get_treasury_data(
|
||||
self,
|
||||
company_id: int,
|
||||
jwt_token: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get treasury/cash flow data for a company.
|
||||
|
||||
Args:
|
||||
company_id: Company ID
|
||||
jwt_token: JWT access token
|
||||
|
||||
Returns:
|
||||
Dict with treasury data (cash_balance, incoming, outgoing, etc.)
|
||||
"""
|
||||
try:
|
||||
if not self.client:
|
||||
self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT)
|
||||
|
||||
response = await self.client.get(
|
||||
"/api/treasury/bank-cash-register",
|
||||
params={
|
||||
"company": str(company_id),
|
||||
"page": 1,
|
||||
"page_size": 1000
|
||||
},
|
||||
headers=self._get_auth_headers(jwt_token)
|
||||
)
|
||||
|
||||
return await self._handle_response(response)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get treasury data for company {company_id}: {e}")
|
||||
return None
|
||||
|
||||
# =========================================================================
|
||||
# EXPORT ENDPOINTS
|
||||
# =========================================================================
|
||||
|
||||
async def export_report(
|
||||
self,
|
||||
jwt_token: str,
|
||||
report_type: str,
|
||||
company_id: int,
|
||||
format: str = "xlsx",
|
||||
filters: Optional[Dict[str, Any]] = None
|
||||
) -> Optional[bytes]:
|
||||
"""
|
||||
Generate and export a report.
|
||||
|
||||
Args:
|
||||
jwt_token: JWT access token
|
||||
report_type: Type of report ('dashboard', 'invoices', 'treasury')
|
||||
company_id: Company ID
|
||||
format: Export format ('xlsx', 'csv', 'pdf')
|
||||
filters: Optional filters for data
|
||||
|
||||
Returns:
|
||||
bytes: File content, None if failed
|
||||
"""
|
||||
try:
|
||||
if not self.client:
|
||||
self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT)
|
||||
|
||||
request_data = {
|
||||
"type": report_type,
|
||||
"company_id": company_id,
|
||||
"format": format,
|
||||
"filters": filters or {}
|
||||
}
|
||||
|
||||
response = await self.client.post(
|
||||
"/api/telegram/export",
|
||||
json=request_data,
|
||||
headers=self._get_auth_headers(jwt_token)
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
return response.content
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to export report: {e}")
|
||||
return None
|
||||
|
||||
# =========================================================================
|
||||
# HEALTH CHECK
|
||||
# =========================================================================
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
"""
|
||||
Check if backend is healthy and reachable.
|
||||
|
||||
Returns:
|
||||
bool: True if backend is healthy
|
||||
"""
|
||||
try:
|
||||
if not self.client:
|
||||
self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT)
|
||||
|
||||
response = await self.client.get("/api/telegram/health")
|
||||
return response.status_code == 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Backend health check failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# Singleton instance for global use
|
||||
_backend_client_instance: Optional[BackendAPIClient] = None
|
||||
|
||||
|
||||
def get_backend_client() -> BackendAPIClient:
|
||||
"""
|
||||
Get or create the singleton BackendAPIClient instance.
|
||||
|
||||
Returns:
|
||||
BackendAPIClient: Singleton instance
|
||||
"""
|
||||
global _backend_client_instance
|
||||
if _backend_client_instance is None:
|
||||
_backend_client_instance = BackendAPIClient()
|
||||
return _backend_client_instance
|
||||
|
||||
|
||||
# Export main classes and functions
|
||||
__all__ = [
|
||||
'BackendAPIClient',
|
||||
'get_backend_client',
|
||||
'BACKEND_URL'
|
||||
]
|
||||
0
reports-app/telegram-bot/app/bot/__init__.py
Normal file
0
reports-app/telegram-bot/app/bot/__init__.py
Normal file
515
reports-app/telegram-bot/app/bot/formatters.py
Normal file
515
reports-app/telegram-bot/app/bot/formatters.py
Normal file
@@ -0,0 +1,515 @@
|
||||
"""
|
||||
Response formatters for bot commands.
|
||||
Formats API responses into user-friendly Telegram messages.
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Any
|
||||
|
||||
|
||||
def format_dashboard_response(data: Dict[str, Any], company_name: str = None) -> str:
|
||||
"""
|
||||
Format dashboard data for Telegram (content only, no header).
|
||||
|
||||
Note: company_name parameter kept for backwards compatibility but not used.
|
||||
Use format_response_with_company() in handlers to add company header.
|
||||
"""
|
||||
text = ""
|
||||
|
||||
# Sold total trezorerie (casa + banca) - rotunjit la leu
|
||||
treasury_totals = data.get('treasury_totals_by_currency', {})
|
||||
sold_trezorerie = round(float(treasury_totals.get('RON', 0)))
|
||||
text += f"**Sold Trezorerie:** {sold_trezorerie:,} RON\n\n"
|
||||
|
||||
# Sold Clienți - rotunjit la leu
|
||||
clienti_sold = round(float(data.get('clienti_sold_total', 0)))
|
||||
clienti_in_termen = round(float(data.get('clienti_sold_in_termen', 0)))
|
||||
clienti_restant = round(float(data.get('clienti_sold_restant', 0)))
|
||||
|
||||
text += f"**Sold Clienți:** {clienti_sold:,} RON\n"
|
||||
text += f" - În termen: {clienti_in_termen:,} RON\n"
|
||||
text += f" - Restanță: {clienti_restant:,} RON\n\n"
|
||||
|
||||
# Sold Furnizori BRUT (pentru consistență cu detaliile) - rotunjit la leu
|
||||
furnizori_in_termen = round(float(data.get('furnizori_sold_in_termen', 0)))
|
||||
furnizori_restant = round(float(data.get('furnizori_sold_restant', 0)))
|
||||
furnizori_sold_brut = furnizori_in_termen + furnizori_restant
|
||||
furnizori_avansuri = round(float(data.get('furnizori_avansuri', 0)))
|
||||
furnizori_sold_net = round(float(data.get('furnizori_sold_total', 0)))
|
||||
|
||||
text += f"**Sold Furnizori:** {furnizori_sold_brut:,} RON\n"
|
||||
text += f" - În termen: {furnizori_in_termen:,} RON\n"
|
||||
text += f" - Restanță: {furnizori_restant:,} RON\n"
|
||||
if furnizori_avansuri != 0:
|
||||
text += f" - Avansuri: {furnizori_avansuri:,} RON\n"
|
||||
text += f" - Net (după avansuri): {furnizori_sold_net:,} RON"
|
||||
else:
|
||||
text += f" - Net: {furnizori_sold_net:,} RON"
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def format_invoices_response(
|
||||
invoices: List[Dict[str, Any]],
|
||||
company_name: str = None,
|
||||
limit: int = 10
|
||||
) -> str:
|
||||
"""
|
||||
Format invoices list for Telegram - COMPACT TABLE FORMAT.
|
||||
|
||||
Args:
|
||||
invoices: List of invoice dicts
|
||||
company_name: Company name (kept for compatibility, not used)
|
||||
limit: Maximum number of invoices to display
|
||||
|
||||
Returns:
|
||||
Formatted Markdown string for Telegram (compact, no emojis)
|
||||
"""
|
||||
if not invoices:
|
||||
return "Nu s-au gasit facturi cu aceste criterii."
|
||||
|
||||
# Header (o singură dată)
|
||||
text = f"**Facturi** ({len(invoices)} total)\n\n"
|
||||
text += "Nr | Client | Suma | Status\n"
|
||||
text += "---|--------|------|-------\n"
|
||||
|
||||
# Lista facturi - compact, o linie per factură
|
||||
for idx, inv in enumerate(invoices[:limit], 1):
|
||||
seria = inv.get('seria', '')
|
||||
numar = inv.get('numar', '')
|
||||
client = inv.get('client', 'N/A')
|
||||
suma = inv.get('suma_totala', 0)
|
||||
status = inv.get('status', 'N/A')
|
||||
|
||||
# Truncate long client names for compact display
|
||||
client_short = client[:20] + "..." if len(client) > 20 else client
|
||||
|
||||
# Status marker (no emoji)
|
||||
status_marker = "PLATIT" if status == "platit" else "NEPLATIT"
|
||||
|
||||
text += f"{seria}{numar} | {client_short} | {suma:,.0f} | {status_marker}\n"
|
||||
|
||||
if len(invoices) > limit:
|
||||
text += f"\n+{len(invoices) - limit} facturi"
|
||||
|
||||
return text
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# FAZA 2: New Formatter Functions for Button Interface
|
||||
# =========================================================================
|
||||
|
||||
|
||||
def format_treasury_casa_response(data: Dict[str, Any], company_name: str = None) -> str:
|
||||
"""
|
||||
Format treasury CASH data for Telegram (content only, no header).
|
||||
|
||||
Args:
|
||||
data: Dict with casa accounts and total from treasury breakdown
|
||||
company_name: Company name (kept for compatibility, not used)
|
||||
|
||||
Returns:
|
||||
Formatted Markdown string for Telegram
|
||||
|
||||
Example:
|
||||
data = {'accounts': [...], 'total': 5000}
|
||||
text = format_treasury_casa_response(data)
|
||||
"""
|
||||
text = ""
|
||||
|
||||
# Total cash balance - rotunjit la leu (0 zecimale)
|
||||
total_cash = round(data.get('total', 0))
|
||||
text += f"**Sold Total Cash:** {total_cash:,} RON\n\n"
|
||||
|
||||
# Cash accounts
|
||||
casa_accounts = data.get('accounts', [])
|
||||
if casa_accounts:
|
||||
text += "**Conturi de Casa:**\n"
|
||||
for acc in casa_accounts[:5]: # Max 5
|
||||
name = acc.get('name', 'N/A')
|
||||
balance = round(acc.get('balance', 0))
|
||||
text += f" - {name}: {balance:,} RON\n"
|
||||
|
||||
if len(casa_accounts) > 5:
|
||||
text += f" ... si inca {len(casa_accounts) - 5} conturi"
|
||||
else:
|
||||
text += "Nu exista conturi de casa configurate."
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def format_treasury_banca_response(data: Dict[str, Any], company_name: str = None) -> str:
|
||||
"""
|
||||
Format treasury BANK data for Telegram (content only, no header).
|
||||
|
||||
Args:
|
||||
data: Dict with banca accounts and total from treasury breakdown
|
||||
company_name: Company name (kept for compatibility, not used)
|
||||
|
||||
Returns:
|
||||
Formatted Markdown string for Telegram
|
||||
|
||||
Example:
|
||||
data = {'accounts': [...], 'total': 15000}
|
||||
text = format_treasury_banca_response(data)
|
||||
"""
|
||||
text = ""
|
||||
|
||||
# Total bank balance - rotunjit la leu (0 zecimale)
|
||||
total_bank = round(data.get('total', 0))
|
||||
text += f"**Sold Total Banca:** {total_bank:,} RON\n\n"
|
||||
|
||||
# Bank accounts
|
||||
bank_accounts = data.get('accounts', [])
|
||||
if bank_accounts:
|
||||
text += "**Conturi Bancare:**\n"
|
||||
for acc in bank_accounts[:5]: # Max 5
|
||||
name = acc.get('name', 'N/A')
|
||||
balance = round(acc.get('balance', 0))
|
||||
text += f" - {name}: {balance:,} RON\n"
|
||||
|
||||
if len(bank_accounts) > 5:
|
||||
text += f" ... si inca {len(bank_accounts) - 5} conturi"
|
||||
else:
|
||||
text += "Nu exista conturi bancare configurate."
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def format_clients_balance_response(
|
||||
clients: List[Dict[str, Any]],
|
||||
maturity_data: Dict[str, Any],
|
||||
company_name: str = None
|
||||
) -> str:
|
||||
"""
|
||||
Format clients balance with maturity breakdown (content only, no header).
|
||||
|
||||
Args:
|
||||
clients: List of client dicts with id, name, balance
|
||||
maturity_data: Dict with in_term, overdue, total
|
||||
company_name: Company name (kept for compatibility, not used)
|
||||
|
||||
Returns:
|
||||
Formatted Markdown string for Telegram
|
||||
|
||||
Example:
|
||||
clients = [{'id': 1, 'name': 'Client A', 'balance': 15000}]
|
||||
maturity = {'in_term': 10000, 'overdue': 5000, 'total': 15000}
|
||||
text = format_clients_balance_response(clients, maturity)
|
||||
"""
|
||||
text = ""
|
||||
|
||||
# Maturity breakdown - rotunjit la leu (0 zecimale)
|
||||
total = round(maturity_data.get('total', 0))
|
||||
in_term = round(maturity_data.get('in_term', 0))
|
||||
overdue = round(maturity_data.get('overdue', 0))
|
||||
|
||||
text += f"**Sold Total:** {total:,} RON\n\n"
|
||||
|
||||
text += "**Defalcare:**\n"
|
||||
text += f" - In termen: {in_term:,} RON\n"
|
||||
text += f" - Restanta: {overdue:,} RON\n\n"
|
||||
|
||||
# Top clients
|
||||
if clients:
|
||||
text += f"**Top Clienti** ({len(clients)} total):\n"
|
||||
# Sort by balance descending
|
||||
sorted_clients = sorted(
|
||||
clients,
|
||||
key=lambda x: x.get('balance', 0),
|
||||
reverse=True
|
||||
)
|
||||
|
||||
for idx, client in enumerate(sorted_clients[:5], 1):
|
||||
name = client.get('name', 'N/A')
|
||||
balance = round(client.get('balance', 0))
|
||||
text += f"{idx}. {name}: {balance:,} RON\n"
|
||||
|
||||
if len(clients) > 5:
|
||||
text += f"\nApasa butonul pentru lista completa"
|
||||
else:
|
||||
text += "Nu exista clienti cu solduri."
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def format_suppliers_balance_response(
|
||||
suppliers: List[Dict[str, Any]],
|
||||
maturity_data: Dict[str, Any],
|
||||
company_name: str = None
|
||||
) -> str:
|
||||
"""
|
||||
Format suppliers balance with maturity breakdown (content only, no header).
|
||||
|
||||
Args:
|
||||
suppliers: List of supplier dicts with id, name, balance
|
||||
maturity_data: Dict with in_term, overdue, total
|
||||
company_name: Company name (kept for compatibility, not used)
|
||||
|
||||
Returns:
|
||||
Formatted Markdown string for Telegram
|
||||
|
||||
Example:
|
||||
suppliers = [{'id': 1, 'name': 'Supplier A', 'balance': 5000}]
|
||||
maturity = {'in_term': 4000, 'overdue': 1000, 'total': 5000}
|
||||
text = format_suppliers_balance_response(suppliers, maturity)
|
||||
"""
|
||||
text = ""
|
||||
|
||||
# Maturity breakdown - rotunjit la leu (0 zecimale)
|
||||
total = round(maturity_data.get('total', 0))
|
||||
in_term = round(maturity_data.get('in_term', 0))
|
||||
overdue = round(maturity_data.get('overdue', 0))
|
||||
|
||||
text += f"**Sold Total:** {total:,} RON\n\n"
|
||||
|
||||
text += "**Defalcare:**\n"
|
||||
text += f" - In termen: {in_term:,} RON\n"
|
||||
text += f" - Restanta: {overdue:,} RON\n\n"
|
||||
|
||||
# Top suppliers
|
||||
if suppliers:
|
||||
text += f"**Top Furnizori** ({len(suppliers)} total):\n"
|
||||
# Sort by balance descending
|
||||
sorted_suppliers = sorted(
|
||||
suppliers,
|
||||
key=lambda x: x.get('balance', 0),
|
||||
reverse=True
|
||||
)
|
||||
|
||||
for idx, supplier in enumerate(sorted_suppliers[:5], 1):
|
||||
name = supplier.get('name', 'N/A')
|
||||
balance = round(supplier.get('balance', 0))
|
||||
text += f"{idx}. {name}: {balance:,} RON\n"
|
||||
|
||||
if len(suppliers) > 5:
|
||||
text += f"\nApasa butonul pentru lista completa"
|
||||
else:
|
||||
text += "Nu exista furnizori cu solduri."
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def format_cashflow_evolution_response(
|
||||
performance_data: Dict[str, Any],
|
||||
monthly_data: Dict[str, Any],
|
||||
company_name: str = None
|
||||
) -> str:
|
||||
"""
|
||||
Format cash flow evolution data (content only, no header).
|
||||
|
||||
Args:
|
||||
performance_data: Dict with incasari_total, plati_total, net
|
||||
monthly_data: Dict with months, incasari, plati arrays
|
||||
company_name: Company name (kept for compatibility, not used)
|
||||
|
||||
Returns:
|
||||
Formatted Markdown string for Telegram
|
||||
|
||||
Example:
|
||||
performance = {'incasari_total': 100000, 'plati_total': 80000, 'net': 20000}
|
||||
monthly = {'months': ['Ian', 'Feb'], 'incasari': [50000, 50000], 'plati': [40000, 40000]}
|
||||
text = format_cashflow_evolution_response(performance, monthly)
|
||||
"""
|
||||
text = ""
|
||||
|
||||
# Performance summary - rotunjit la leu (0 zecimale)
|
||||
incasari_total = round(performance_data.get('incasari_total', 0))
|
||||
plati_total = round(performance_data.get('plati_total', 0))
|
||||
net = round(performance_data.get('net', 0))
|
||||
|
||||
text += "**Rezumat:**\n"
|
||||
text += f" - Total Incasari: {incasari_total:,} RON\n"
|
||||
text += f" - Total Plati: {plati_total:,} RON\n"
|
||||
text += f" - Net Cash Flow: {net:,} RON\n\n"
|
||||
|
||||
# Monthly breakdown
|
||||
months = monthly_data.get('months', [])
|
||||
incasari = monthly_data.get('incasari', [])
|
||||
plati = monthly_data.get('plati', [])
|
||||
|
||||
if months and len(months) > 0:
|
||||
text += "**Evolutie Lunara** (ultimele luni):\n"
|
||||
|
||||
# Show last 6 months
|
||||
display_count = min(6, len(months))
|
||||
for i in range(display_count):
|
||||
month = months[-(display_count - i)]
|
||||
inc = round(incasari[-(display_count - i)]) if i < len(incasari) else 0
|
||||
plt = round(plati[-(display_count - i)]) if i < len(plati) else 0
|
||||
net_month = inc - plt
|
||||
|
||||
# Simple ASCII bar
|
||||
net_indicator = "+" if net_month > 0 else "-" if net_month < 0 else "="
|
||||
|
||||
text += f"\n**{month}:**\n"
|
||||
text += f" {net_indicator} Incasari: {inc:,} RON\n"
|
||||
text += f" {net_indicator} Plati: {plt:,} RON\n"
|
||||
text += f" {net_indicator} Net: {net_month:,} RON"
|
||||
else:
|
||||
text += "Nu exista date lunare disponibile."
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def format_client_detail_response(
|
||||
client: Dict[str, Any],
|
||||
invoices: List[Dict[str, Any]],
|
||||
company_name: str = None
|
||||
) -> str:
|
||||
"""
|
||||
Format client details with invoices - COMPACT TABLE FORMAT.
|
||||
|
||||
Args:
|
||||
client: Dict with client info (id, name, balance)
|
||||
invoices: List of invoice dicts for this client
|
||||
company_name: Company name (kept for compatibility, not used)
|
||||
|
||||
Returns:
|
||||
Formatted Markdown string for Telegram (compact, no emojis)
|
||||
|
||||
Example:
|
||||
client = {'id': 1, 'name': 'Client A', 'balance': 15000}
|
||||
invoices = [{'id': 1, 'number': 'FV001', 'amount': 5000, 'status': 'unpaid'}]
|
||||
text = format_client_detail_response(client, invoices)
|
||||
"""
|
||||
client_name = client.get('name', 'N/A')
|
||||
balance = client.get('balance', 0)
|
||||
|
||||
# Header with client info
|
||||
text = f"**{client_name}**\n"
|
||||
text += f"**Sold total: {balance:,.2f} RON**"
|
||||
if invoices and len(invoices) > 1:
|
||||
text += f" • {len(invoices)} facturi"
|
||||
text += "\n\n"
|
||||
|
||||
# Invoices - compact table format (no emojis)
|
||||
if invoices:
|
||||
from datetime import datetime
|
||||
|
||||
# Sort invoices by date (most recent first)
|
||||
sorted_invoices = sorted(invoices, key=lambda x: x.get('dataact') or datetime.min, reverse=True)
|
||||
|
||||
# Invoice list - simple format without table
|
||||
text += "Facturi cu sold:\n"
|
||||
text += "━━━━━━━━━━━━━━━━━━━━\n"
|
||||
|
||||
# Invoice rows - one line each, simple format
|
||||
for inv in sorted_invoices[:10]:
|
||||
# Backend returns: nract, totctva, soldfinal, datascad, dataact, achitat
|
||||
number = str(inv.get('nract', 'N/A'))
|
||||
dataact = inv.get('dataact')
|
||||
|
||||
# Parse date - handle various formats to ensure dd.mm.yyyy
|
||||
if dataact:
|
||||
if isinstance(dataact, str):
|
||||
try:
|
||||
# Try ISO format first: "2024-10-25" or "2024-10-25 00:00:00"
|
||||
if '-' in dataact and len(dataact) >= 10:
|
||||
parsed_date = datetime.strptime(dataact[:10], '%Y-%m-%d')
|
||||
date_str = parsed_date.strftime('%d.%m.%Y')
|
||||
# Already in dd.mm.yyyy format
|
||||
elif '.' in dataact:
|
||||
date_str = dataact.split()[0][:10] # Take just date part
|
||||
else:
|
||||
date_str = dataact[:10] if len(dataact) >= 10 else dataact
|
||||
except:
|
||||
date_str = dataact[:10] if len(dataact) >= 10 else dataact
|
||||
else:
|
||||
# Datetime object - format as dd.mm.yyyy
|
||||
date_str = dataact.strftime('%d.%m.%Y')
|
||||
else:
|
||||
date_str = 'N/A'
|
||||
|
||||
sold = float(inv.get('soldfinal', 0) or 0)
|
||||
|
||||
# Simple format: Nr • Data • Sold
|
||||
text += f"Nr {number} • {date_str} • {sold:,.2f} RON\n"
|
||||
|
||||
if len(invoices) > 10:
|
||||
text += f"\n\n+{len(invoices) - 10} facturi"
|
||||
else:
|
||||
text += "Nu exista facturi neachitate"
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def format_supplier_detail_response(
|
||||
supplier: Dict[str, Any],
|
||||
invoices: List[Dict[str, Any]],
|
||||
company_name: str = None
|
||||
) -> str:
|
||||
"""
|
||||
Format supplier details with invoices - COMPACT TABLE FORMAT.
|
||||
|
||||
Args:
|
||||
supplier: Dict with supplier info (id, name, balance)
|
||||
invoices: List of invoice dicts for this supplier
|
||||
company_name: Company name (kept for compatibility, not used)
|
||||
|
||||
Returns:
|
||||
Formatted Markdown string for Telegram (compact, no emojis)
|
||||
|
||||
Example:
|
||||
supplier = {'id': 1, 'name': 'Supplier A', 'balance': 5000}
|
||||
invoices = [{'id': 1, 'number': 'FC001', 'amount': 2000, 'status': 'unpaid'}]
|
||||
text = format_supplier_detail_response(supplier, invoices)
|
||||
"""
|
||||
supplier_name = supplier.get('name', 'N/A')
|
||||
balance = supplier.get('balance', 0)
|
||||
|
||||
# Header with supplier info
|
||||
text = f"**{supplier_name}**\n"
|
||||
text += f"**Sold total: {balance:,.2f} RON**"
|
||||
if invoices and len(invoices) > 1:
|
||||
text += f" • {len(invoices)} facturi"
|
||||
text += "\n\n"
|
||||
|
||||
# Invoices - compact table format (no emojis)
|
||||
if invoices:
|
||||
from datetime import datetime
|
||||
|
||||
# Sort invoices by date (most recent first)
|
||||
sorted_invoices = sorted(invoices, key=lambda x: x.get('dataact') or datetime.min, reverse=True)
|
||||
|
||||
# Invoice list - simple format without table
|
||||
text += "Facturi cu sold:\n"
|
||||
text += "━━━━━━━━━━━━━━━━━━━━\n"
|
||||
|
||||
# Invoice rows - one line each, simple format
|
||||
for inv in sorted_invoices[:10]:
|
||||
# Backend returns: nract, totctva, soldfinal, datascad, dataact, achitat
|
||||
number = str(inv.get('nract', 'N/A'))
|
||||
dataact = inv.get('dataact')
|
||||
|
||||
# Parse date - handle various formats to ensure dd.mm.yyyy
|
||||
if dataact:
|
||||
if isinstance(dataact, str):
|
||||
try:
|
||||
# Try ISO format first: "2024-10-25" or "2024-10-25 00:00:00"
|
||||
if '-' in dataact and len(dataact) >= 10:
|
||||
parsed_date = datetime.strptime(dataact[:10], '%Y-%m-%d')
|
||||
date_str = parsed_date.strftime('%d.%m.%Y')
|
||||
# Already in dd.mm.yyyy format
|
||||
elif '.' in dataact:
|
||||
date_str = dataact.split()[0][:10] # Take just date part
|
||||
else:
|
||||
date_str = dataact[:10] if len(dataact) >= 10 else dataact
|
||||
except:
|
||||
date_str = dataact[:10] if len(dataact) >= 10 else dataact
|
||||
else:
|
||||
# Datetime object - format as dd.mm.yyyy
|
||||
date_str = dataact.strftime('%d.%m.%Y')
|
||||
else:
|
||||
date_str = 'N/A'
|
||||
|
||||
sold = float(inv.get('soldfinal', 0) or 0)
|
||||
|
||||
# Simple format: Nr • Data • Sold
|
||||
text += f"Nr {number} • {date_str} • {sold:,.2f} RON\n"
|
||||
|
||||
if len(invoices) > 10:
|
||||
text += f"\n\n+{len(invoices) - 10} facturi"
|
||||
else:
|
||||
text += "Nu exista facturi neachitate"
|
||||
|
||||
return text
|
||||
2036
reports-app/telegram-bot/app/bot/handlers.py
Normal file
2036
reports-app/telegram-bot/app/bot/handlers.py
Normal file
File diff suppressed because it is too large
Load Diff
705
reports-app/telegram-bot/app/bot/helpers.py
Normal file
705
reports-app/telegram-bot/app/bot/helpers.py
Normal file
@@ -0,0 +1,705 @@
|
||||
"""
|
||||
Helper functions for Telegram bot command handlers.
|
||||
Provides utilities for company selection, API calls, and response formatting.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional, Dict, List, Any
|
||||
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
|
||||
|
||||
from app.api.client import get_backend_client
|
||||
from app.agent.session import SessionManager
|
||||
from app.bot.menus import pad_message_for_wide_buttons
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_active_company_or_prompt(
|
||||
update: Update,
|
||||
session_manager: SessionManager,
|
||||
telegram_user_id: int
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get active company from session or prompt user to select one with buttons.
|
||||
|
||||
This function checks if the user has an active company set in their session.
|
||||
If not, it fetches companies and displays selection buttons directly.
|
||||
|
||||
Args:
|
||||
update: Telegram Update object (for sending messages)
|
||||
session_manager: SessionManager instance
|
||||
telegram_user_id: Telegram user ID
|
||||
|
||||
Returns:
|
||||
Dict with company info (id, name, cui) if set, None if user needs to select
|
||||
|
||||
Example:
|
||||
company = await get_active_company_or_prompt(update, session_manager, user_id)
|
||||
if not company:
|
||||
return # User was shown company selection buttons
|
||||
# Continue with company operations...
|
||||
"""
|
||||
session = await session_manager.get_or_create_session(telegram_user_id)
|
||||
company = session.get_active_company()
|
||||
|
||||
if not company:
|
||||
# Get auth data and companies
|
||||
from app.auth.linking import get_user_auth_data
|
||||
auth_data = await get_user_auth_data(telegram_user_id)
|
||||
jwt_token = auth_data['jwt_token']
|
||||
|
||||
client = get_backend_client()
|
||||
async with client:
|
||||
companies = await client.get_user_companies(jwt_token=jwt_token)
|
||||
|
||||
if companies:
|
||||
keyboard = create_company_selection_keyboard_paginated(companies, page=0)
|
||||
message = (
|
||||
f"**Selecteaza mai intai o companie**\n\n"
|
||||
f"Companiile tale ({len(companies)}):"
|
||||
)
|
||||
# Apply padding to make inline keyboard buttons wider
|
||||
message = pad_message_for_wide_buttons(message)
|
||||
await update.message.reply_text(
|
||||
message,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
else:
|
||||
await update.message.reply_text(
|
||||
"Nu ai acces la nicio companie.\n"
|
||||
"Contacteaza administratorul.",
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
return None
|
||||
|
||||
return company
|
||||
|
||||
|
||||
async def search_companies_by_name(
|
||||
name_query: str,
|
||||
jwt_token: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search companies by partial name match (case-insensitive).
|
||||
|
||||
Fetches all companies from backend and filters them by name.
|
||||
Uses case-insensitive partial matching for flexible search.
|
||||
|
||||
Args:
|
||||
name_query: Search term (partial match, e.g., "ACME")
|
||||
jwt_token: JWT authentication token
|
||||
|
||||
Returns:
|
||||
List of matching company dicts (each with id, nume_firma, cui, etc.)
|
||||
|
||||
Example:
|
||||
companies = await search_companies_by_name("acme", token)
|
||||
# Returns all companies with "acme" in their name (case-insensitive)
|
||||
"""
|
||||
client = get_backend_client()
|
||||
async with client:
|
||||
all_companies = await client.get_user_companies(jwt_token=jwt_token)
|
||||
|
||||
# Filter by name (case-insensitive partial match)
|
||||
query_lower = name_query.lower()
|
||||
matches = [
|
||||
comp for comp in all_companies
|
||||
if query_lower in comp.get('name', comp.get('nume_firma', '')).lower()
|
||||
]
|
||||
|
||||
logger.info(
|
||||
f"Search '{name_query}': {len(matches)} matches out of {len(all_companies)} total"
|
||||
)
|
||||
|
||||
return matches
|
||||
|
||||
|
||||
def create_company_selection_keyboard(
|
||||
companies: List[Dict[str, Any]],
|
||||
max_buttons: int = 10
|
||||
) -> InlineKeyboardMarkup:
|
||||
"""
|
||||
Create inline keyboard for company selection (legacy - without pagination).
|
||||
|
||||
Generates a vertical list of buttons, one per company.
|
||||
Each button shows company name and CUI, and triggers a callback.
|
||||
|
||||
NOTE: This function is deprecated in favor of create_company_selection_keyboard_paginated.
|
||||
It's kept for backwards compatibility only.
|
||||
|
||||
Args:
|
||||
companies: List of company dicts (with id, nume_firma, cui)
|
||||
max_buttons: Maximum number of buttons to show (default: 10)
|
||||
|
||||
Returns:
|
||||
InlineKeyboardMarkup with company selection buttons
|
||||
|
||||
Example:
|
||||
keyboard = create_company_selection_keyboard(companies)
|
||||
await update.message.reply_text("Select company:", reply_markup=keyboard)
|
||||
"""
|
||||
keyboard = []
|
||||
|
||||
for company in companies[:max_buttons]:
|
||||
company_id = company.get('id_firma', company.get('id'))
|
||||
company_name = company.get('name', company.get('nume_firma', 'N/A'))
|
||||
company_cui = company.get('fiscal_code', company.get('cui', ''))
|
||||
|
||||
# Button text: "ACME SRL (CUI: 12345)"
|
||||
button_text = f"{company_name}"
|
||||
if company_cui:
|
||||
button_text += f" ({company_cui})"
|
||||
|
||||
# Callback data: "select_company:123"
|
||||
callback_data = f"select_company:{company_id}"
|
||||
|
||||
keyboard.append([InlineKeyboardButton(button_text, callback_data=callback_data)])
|
||||
|
||||
# Add overflow indicator if there are more companies
|
||||
if len(companies) > max_buttons:
|
||||
keyboard.append([InlineKeyboardButton(
|
||||
f"... și încă {len(companies) - max_buttons} companii",
|
||||
callback_data="noop"
|
||||
)])
|
||||
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
|
||||
def create_company_selection_keyboard_paginated(
|
||||
companies: List[Dict[str, Any]],
|
||||
page: int = 0,
|
||||
per_page: int = 10
|
||||
) -> InlineKeyboardMarkup:
|
||||
"""
|
||||
Create paginated inline keyboard for company selection.
|
||||
|
||||
Generates a vertical list of buttons for one page of companies,
|
||||
with navigation buttons for previous/next pages.
|
||||
|
||||
Args:
|
||||
companies: Full list of company dicts (with id, nume_firma, cui)
|
||||
page: Current page number (0-indexed)
|
||||
per_page: Number of companies per page (default: 10)
|
||||
|
||||
Returns:
|
||||
InlineKeyboardMarkup with company buttons and pagination controls
|
||||
|
||||
Example:
|
||||
keyboard = create_company_selection_keyboard_paginated(companies, page=0)
|
||||
await update.message.reply_text("Select company:", reply_markup=keyboard)
|
||||
"""
|
||||
keyboard = []
|
||||
|
||||
# Calculate pagination
|
||||
total_companies = len(companies)
|
||||
total_pages = (total_companies + per_page - 1) // per_page # Ceiling division
|
||||
start_idx = page * per_page
|
||||
end_idx = min(start_idx + per_page, total_companies)
|
||||
|
||||
# Display companies for current page
|
||||
page_companies = companies[start_idx:end_idx]
|
||||
|
||||
for company in page_companies:
|
||||
company_id = company.get('id_firma', company.get('id'))
|
||||
company_name = company.get('name', company.get('nume_firma', 'N/A'))
|
||||
company_cui = company.get('fiscal_code', company.get('cui', ''))
|
||||
|
||||
# Button text: "ACME SRL (CUI: 12345)"
|
||||
button_text = f"{company_name}"
|
||||
if company_cui:
|
||||
button_text += f" ({company_cui})"
|
||||
|
||||
# Callback data: "select_company:123"
|
||||
callback_data = f"select_company:{company_id}"
|
||||
|
||||
keyboard.append([InlineKeyboardButton(button_text, callback_data=callback_data)])
|
||||
|
||||
# Pagination controls (only if more than one page)
|
||||
if total_pages > 1:
|
||||
nav_buttons = []
|
||||
|
||||
# Previous button
|
||||
if page > 0:
|
||||
nav_buttons.append(
|
||||
InlineKeyboardButton("< Anterior", callback_data=f"select_company_page:{page-1}")
|
||||
)
|
||||
|
||||
# Page indicator (non-clickable)
|
||||
nav_buttons.append(
|
||||
InlineKeyboardButton(f"Pagina {page+1}/{total_pages}", callback_data="noop")
|
||||
)
|
||||
|
||||
# Next button
|
||||
if page < total_pages - 1:
|
||||
nav_buttons.append(
|
||||
InlineKeyboardButton("Urmator >", callback_data=f"select_company_page:{page+1}")
|
||||
)
|
||||
|
||||
keyboard.append(nav_buttons)
|
||||
|
||||
# Back to menu button
|
||||
keyboard.append([
|
||||
InlineKeyboardButton("< Inapoi la Meniu", callback_data="action:menu")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
|
||||
def format_company_context_footer(company_name: str) -> str:
|
||||
"""
|
||||
Format discrete footer with company context.
|
||||
|
||||
Adds a subtle footer to command responses showing the active company
|
||||
and a quick link to change it.
|
||||
|
||||
Args:
|
||||
company_name: Active company name
|
||||
|
||||
Returns:
|
||||
Formatted footer string with separator and company name
|
||||
|
||||
Example:
|
||||
footer = format_company_context_footer("ACME SRL")
|
||||
message = f"Dashboard data...\n{footer}"
|
||||
# Output: "Dashboard data...\n\n━━━━━━━━━━━━━━\nCompanie: ACME SRL"
|
||||
"""
|
||||
return f"\n\n━━━━━━━━━━━━━━\nCompanie: {company_name}"
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# FAZA 2: New Helper Functions for Button Interface
|
||||
# =========================================================================
|
||||
|
||||
|
||||
async def get_treasury_breakdown_split(
|
||||
company_id: int,
|
||||
jwt_token: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get treasury breakdown split into casa and banca.
|
||||
|
||||
Fetches treasury breakdown from backend and transforms it
|
||||
to the format expected by formatters.
|
||||
|
||||
Backend returns:
|
||||
{
|
||||
"total": float,
|
||||
"breakdown": {
|
||||
"casa": {"total": float, "items": [{"nume": str, "cont": str, "sold": float}]},
|
||||
"banca": {"total": float, "items": [{"nume": str, "cont": str, "sold": float}]}
|
||||
},
|
||||
"currency": "RON"
|
||||
}
|
||||
|
||||
Args:
|
||||
company_id: Company ID
|
||||
jwt_token: JWT authentication token
|
||||
|
||||
Returns:
|
||||
Dict with two keys:
|
||||
- 'casa': Dict with 'accounts' (list) and 'total' (float)
|
||||
- 'banca': Dict with 'accounts' (list) and 'total' (float)
|
||||
|
||||
None if request fails
|
||||
|
||||
Example:
|
||||
data = await get_treasury_breakdown_split(1, token)
|
||||
casa_total = data['casa']['total'] # Total cash balance
|
||||
bank_accounts = data['banca']['accounts'] # List of bank accounts
|
||||
"""
|
||||
try:
|
||||
client = get_backend_client()
|
||||
async with client:
|
||||
breakdown = await client.get_treasury_breakdown(
|
||||
company_id=company_id,
|
||||
jwt_token=jwt_token
|
||||
)
|
||||
|
||||
if not breakdown:
|
||||
return None
|
||||
|
||||
# Backend already splits data into casa and banca
|
||||
# Transform backend structure to match formatter expectations
|
||||
breakdown_data = breakdown.get('breakdown', {})
|
||||
casa_data = breakdown_data.get('casa', {})
|
||||
banca_data = breakdown_data.get('banca', {})
|
||||
|
||||
# Transform items to accounts format (nume->name, sold->balance)
|
||||
casa_accounts = [
|
||||
{
|
||||
'name': item.get('nume', f"Cont {item.get('cont', 'N/A')}"),
|
||||
'balance': float(item.get('sold', 0)),
|
||||
'cont': item.get('cont', '')
|
||||
}
|
||||
for item in casa_data.get('items', [])
|
||||
]
|
||||
|
||||
banca_accounts = [
|
||||
{
|
||||
'name': item.get('nume', f"Cont {item.get('cont', 'N/A')}"),
|
||||
'balance': float(item.get('sold', 0)),
|
||||
'cont': item.get('cont', '')
|
||||
}
|
||||
for item in banca_data.get('items', [])
|
||||
]
|
||||
|
||||
return {
|
||||
'casa': {
|
||||
'accounts': casa_accounts,
|
||||
'total': float(casa_data.get('total', 0))
|
||||
},
|
||||
'banca': {
|
||||
'accounts': banca_accounts,
|
||||
'total': float(banca_data.get('total', 0))
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting treasury breakdown split: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
async def get_clients_with_maturity(
|
||||
company_id: int,
|
||||
jwt_token: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get clients list with maturity breakdown.
|
||||
|
||||
Uses maturity analysis endpoint which returns client summaries
|
||||
with amounts and overdue status.
|
||||
|
||||
Backend returns:
|
||||
{
|
||||
"clients": [{"name": str, "amount": float, "dueDate": str, "daysOverdue": int}],
|
||||
"suppliers": [...],
|
||||
"balance": float,
|
||||
"metadata": {...}
|
||||
}
|
||||
|
||||
Args:
|
||||
company_id: Company ID
|
||||
jwt_token: JWT authentication token
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- 'clients': List of client dicts (id, name, balance)
|
||||
- 'maturity': Dict with 'in_term', 'overdue', 'total' amounts
|
||||
|
||||
None if request fails
|
||||
|
||||
Example:
|
||||
data = await get_clients_with_maturity(1, token)
|
||||
clients = data['clients'] # List of all clients
|
||||
overdue = data['maturity']['overdue'] # Overdue amount
|
||||
"""
|
||||
try:
|
||||
client = get_backend_client()
|
||||
async with client:
|
||||
# Get maturity analysis (contains client summaries)
|
||||
maturity_response = await client.get_maturity_data(
|
||||
company_id=company_id,
|
||||
jwt_token=jwt_token,
|
||||
period='all'
|
||||
)
|
||||
|
||||
if not maturity_response:
|
||||
return None
|
||||
|
||||
# Extract clients from maturity response
|
||||
clients_raw = maturity_response.get('clients', [])
|
||||
|
||||
# Transform to expected format: amount → balance
|
||||
clients = [
|
||||
{
|
||||
'name': c.get('name', 'N/A'),
|
||||
'balance': float(c.get('amount', 0)),
|
||||
'daysOverdue': c.get('daysOverdue', 0)
|
||||
}
|
||||
for c in clients_raw
|
||||
]
|
||||
|
||||
# Calculate maturity breakdown from clients data
|
||||
total = sum(c['balance'] for c in clients)
|
||||
overdue = sum(c['balance'] for c in clients if c.get('daysOverdue', 0) > 0)
|
||||
in_term = total - overdue
|
||||
|
||||
return {
|
||||
'clients': clients,
|
||||
'maturity': {
|
||||
'in_term': in_term,
|
||||
'overdue': overdue,
|
||||
'total': total
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting clients with maturity: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
async def get_suppliers_with_maturity(
|
||||
company_id: int,
|
||||
jwt_token: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get suppliers list with maturity breakdown.
|
||||
|
||||
Uses maturity analysis endpoint which returns supplier summaries
|
||||
with amounts and overdue status.
|
||||
|
||||
Backend returns:
|
||||
{
|
||||
"clients": [...],
|
||||
"suppliers": [{"name": str, "amount": float, "dueDate": str, "daysOverdue": int}],
|
||||
"balance": float,
|
||||
"metadata": {...}
|
||||
}
|
||||
|
||||
Args:
|
||||
company_id: Company ID
|
||||
jwt_token: JWT authentication token
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- 'suppliers': List of supplier dicts (id, name, balance)
|
||||
- 'maturity': Dict with 'in_term', 'overdue', 'total' amounts
|
||||
|
||||
None if request fails
|
||||
|
||||
Example:
|
||||
data = await get_suppliers_with_maturity(1, token)
|
||||
suppliers = data['suppliers'] # List of all suppliers
|
||||
in_term = data['maturity']['in_term'] # In-term amount
|
||||
"""
|
||||
try:
|
||||
client = get_backend_client()
|
||||
async with client:
|
||||
# Get maturity analysis (contains supplier summaries)
|
||||
maturity_response = await client.get_maturity_data(
|
||||
company_id=company_id,
|
||||
jwt_token=jwt_token,
|
||||
period='all'
|
||||
)
|
||||
|
||||
if not maturity_response:
|
||||
return None
|
||||
|
||||
# Extract suppliers from maturity response
|
||||
suppliers_raw = maturity_response.get('suppliers', [])
|
||||
|
||||
# Transform to expected format: amount → balance
|
||||
suppliers = [
|
||||
{
|
||||
'name': s.get('name', 'N/A'),
|
||||
'balance': float(s.get('amount', 0)),
|
||||
'daysOverdue': s.get('daysOverdue', 0)
|
||||
}
|
||||
for s in suppliers_raw
|
||||
]
|
||||
|
||||
# Calculate maturity breakdown from suppliers data
|
||||
total = sum(s['balance'] for s in suppliers)
|
||||
overdue = sum(s['balance'] for s in suppliers if s.get('daysOverdue', 0) > 0)
|
||||
in_term = total - overdue
|
||||
|
||||
return {
|
||||
'suppliers': suppliers,
|
||||
'maturity': {
|
||||
'in_term': in_term,
|
||||
'overdue': overdue,
|
||||
'total': total
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting suppliers with maturity: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
async def get_cashflow_evolution_data(
|
||||
company_id: int,
|
||||
jwt_token: str,
|
||||
period: str = "12m"
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get cash flow evolution data.
|
||||
|
||||
Uses monthly flows endpoint which returns current month data.
|
||||
Backend returns: {'inflows': float, 'outflows': float, 'period': str, 'currency': str}
|
||||
|
||||
Args:
|
||||
company_id: Company ID
|
||||
jwt_token: JWT authentication token
|
||||
period: Period for monthly data (default: "12m")
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- 'performance': Dict with incasari_total, plati_total, net
|
||||
- 'monthly': Dict with months, incasari, plati arrays
|
||||
|
||||
None if request fails
|
||||
|
||||
Example:
|
||||
data = await get_cashflow_evolution_data(1, token)
|
||||
net = data['performance']['net'] # Net cash flow
|
||||
months = data['monthly']['months'] # List of month names
|
||||
"""
|
||||
try:
|
||||
client = get_backend_client()
|
||||
async with client:
|
||||
# Get monthly flows (current month only from backend)
|
||||
monthly_flows = await client.get_monthly_flows(
|
||||
company_id=company_id,
|
||||
jwt_token=jwt_token,
|
||||
months=12 # Note: backend ignores this and returns only current month
|
||||
)
|
||||
|
||||
if not monthly_flows:
|
||||
return None
|
||||
|
||||
# Transform backend response to expected format
|
||||
inflows = float(monthly_flows.get('inflows', 0))
|
||||
outflows = float(monthly_flows.get('outflows', 0))
|
||||
period_name = monthly_flows.get('period', 'Luna curentă')
|
||||
|
||||
# Calculate net
|
||||
net = inflows - outflows
|
||||
|
||||
# Build performance summary
|
||||
performance = {
|
||||
'incasari_total': inflows,
|
||||
'plati_total': outflows,
|
||||
'net': net
|
||||
}
|
||||
|
||||
# Build monthly breakdown (single month from backend)
|
||||
monthly = {
|
||||
'months': [period_name],
|
||||
'incasari': [inflows],
|
||||
'plati': [outflows]
|
||||
}
|
||||
|
||||
return {
|
||||
'performance': performance,
|
||||
'monthly': monthly
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting cashflow evolution data: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
async def get_client_invoices(
|
||||
company_id: int,
|
||||
client_name: str,
|
||||
jwt_token: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get invoices for a specific client.
|
||||
|
||||
Args:
|
||||
company_id: Company ID
|
||||
client_name: Client name to filter by
|
||||
jwt_token: JWT authentication token
|
||||
|
||||
Returns:
|
||||
List of invoice dicts for the specified client
|
||||
|
||||
Example:
|
||||
invoices = await get_client_invoices(1, "ACME Corp", token)
|
||||
for inv in invoices:
|
||||
print(inv['number'], inv['amount'])
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Fetching invoices for client '{client_name}' (company_id={company_id})")
|
||||
|
||||
client = get_backend_client()
|
||||
async with client:
|
||||
# Filter only by unpaid invoices (with balance > 0)
|
||||
invoices = await client.search_invoices(
|
||||
company_id=company_id,
|
||||
jwt_token=jwt_token,
|
||||
filters={
|
||||
'partner_type': 'CLIENTI',
|
||||
'partner_name': client_name,
|
||||
'only_unpaid': True # Only show unpaid invoices (matching balance > 0)
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"Found {len(invoices) if invoices else 0} invoices for client '{client_name}'")
|
||||
|
||||
if invoices:
|
||||
logger.debug(f"First invoice sample: {invoices[0]}")
|
||||
|
||||
return invoices or []
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting client invoices for '{client_name}': {e}", exc_info=True)
|
||||
return []
|
||||
|
||||
|
||||
async def get_supplier_invoices(
|
||||
company_id: int,
|
||||
supplier_name: str,
|
||||
jwt_token: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get invoices for a specific supplier.
|
||||
|
||||
Args:
|
||||
company_id: Company ID
|
||||
supplier_name: Supplier name to filter by
|
||||
jwt_token: JWT authentication token
|
||||
|
||||
Returns:
|
||||
List of invoice dicts for the specified supplier
|
||||
|
||||
Example:
|
||||
invoices = await get_supplier_invoices(1, "Supplier Inc", token)
|
||||
for inv in invoices:
|
||||
print(inv['number'], inv['amount'])
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Fetching invoices for supplier '{supplier_name}' (company_id={company_id})")
|
||||
|
||||
client = get_backend_client()
|
||||
async with client:
|
||||
# Filter only by unpaid invoices (with balance > 0)
|
||||
invoices = await client.search_invoices(
|
||||
company_id=company_id,
|
||||
jwt_token=jwt_token,
|
||||
filters={
|
||||
'partner_type': 'FURNIZORI',
|
||||
'partner_name': supplier_name,
|
||||
'only_unpaid': True # Only show unpaid invoices (matching balance > 0)
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"Found {len(invoices) if invoices else 0} invoices for supplier '{supplier_name}'")
|
||||
|
||||
if invoices:
|
||||
logger.debug(f"First invoice sample: {invoices[0]}")
|
||||
|
||||
return invoices or []
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting supplier invoices for '{supplier_name}': {e}", exc_info=True)
|
||||
return []
|
||||
|
||||
|
||||
# Export all helper functions
|
||||
__all__ = [
|
||||
'get_active_company_or_prompt',
|
||||
'search_companies_by_name',
|
||||
'create_company_selection_keyboard',
|
||||
'create_company_selection_keyboard_paginated',
|
||||
'format_company_context_footer',
|
||||
'get_treasury_breakdown_split',
|
||||
'get_clients_with_maturity',
|
||||
'get_suppliers_with_maturity',
|
||||
'get_cashflow_evolution_data',
|
||||
'get_client_invoices',
|
||||
'get_supplier_invoices'
|
||||
]
|
||||
0
reports-app/telegram-bot/app/bot/keyboards.py
Normal file
0
reports-app/telegram-bot/app/bot/keyboards.py
Normal file
565
reports-app/telegram-bot/app/bot/menus.py
Normal file
565
reports-app/telegram-bot/app/bot/menus.py
Normal file
@@ -0,0 +1,565 @@
|
||||
"""
|
||||
Menu builders for Telegram bot inline keyboards.
|
||||
|
||||
This module provides functions to create InlineKeyboardMarkup objects
|
||||
for different menu levels and navigation patterns in the bot.
|
||||
|
||||
NOTE: All button texts are plain text WITHOUT emojis/icons as per requirements.
|
||||
|
||||
BUTTON WIDTH: Inline keyboard width is determined by the message text width.
|
||||
To make buttons wider, we pad message text with invisible characters.
|
||||
"""
|
||||
|
||||
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from typing import List, Dict, Optional
|
||||
from datetime import datetime
|
||||
|
||||
# ============================================================================
|
||||
# IMPORTANT: BUTTON WIDTH CONFIGURATION
|
||||
# ============================================================================
|
||||
# Inline keyboard button width is determined by MESSAGE TEXT WIDTH!
|
||||
# DO NOT REMOVE PADDING - it makes buttons wide like BotFather!
|
||||
# ============================================================================
|
||||
|
||||
# Zero-Width Joiner character - invisible but prevents Telegram from trimming spaces
|
||||
# This character has ZERO width (invisible) but prevents space trimming
|
||||
ZERO_WIDTH_JOINER = '\u200D'
|
||||
|
||||
# Target character count per line to make buttons VERY WIDE
|
||||
# Higher value = wider buttons (BotFather uses ~45-50 chars)
|
||||
# DO NOT DECREASE THIS VALUE - buttons will become narrow!
|
||||
TARGET_WIDTH = 50 # Increased from 40 to make buttons WIDER
|
||||
|
||||
# Enable/disable padding globally (useful for testing)
|
||||
# KEEP THIS TRUE - disabling makes buttons narrow!
|
||||
ENABLE_BUTTON_PADDING = True
|
||||
|
||||
|
||||
def _get_current_month_ro() -> str:
|
||||
"""Get current month name in Romanian."""
|
||||
months_ro = {
|
||||
1: "Ianuarie", 2: "Februarie", 3: "Martie", 4: "Aprilie",
|
||||
5: "Mai", 6: "Iunie", 7: "Iulie", 8: "August",
|
||||
9: "Septembrie", 10: "Octombrie", 11: "Noiembrie", 12: "Decembrie"
|
||||
}
|
||||
now = datetime.now()
|
||||
return f"{months_ro[now.month]} {now.year}"
|
||||
|
||||
|
||||
def _pad_line_for_wide_buttons(text: str, target_width: int = TARGET_WIDTH) -> str:
|
||||
"""
|
||||
Pad a single line of text with invisible characters to make inline buttons wider.
|
||||
|
||||
⚠️ CRITICAL: DO NOT REMOVE THIS FUNCTION - it makes buttons wide!
|
||||
The width of InlineKeyboardMarkup buttons is determined by the message text width.
|
||||
By padding text with spaces + zero-width joiner, we force wider buttons.
|
||||
|
||||
How it works:
|
||||
1. Calculate how many characters needed to reach target_width
|
||||
2. Add spaces + Zero-Width Joiner (invisible character)
|
||||
3. Result: wider message = wider buttons (like BotFather)
|
||||
|
||||
Args:
|
||||
text: The text line to pad
|
||||
target_width: Target character count (default 50 for VERY WIDE buttons)
|
||||
|
||||
Returns:
|
||||
Padded text with invisible characters (user sees normal text, Telegram sees wider text)
|
||||
"""
|
||||
current_length = len(text)
|
||||
if current_length >= target_width:
|
||||
return text
|
||||
|
||||
# ⚠️ DO NOT REMOVE: Add spaces + zero-width joiner at the end
|
||||
# This makes buttons WIDE without changing visible text!
|
||||
padding_needed = target_width - current_length
|
||||
padding = ' ' * padding_needed + ZERO_WIDTH_JOINER
|
||||
|
||||
return text + padding
|
||||
|
||||
|
||||
def pad_message_for_wide_buttons(message: str, target_width: int = TARGET_WIDTH, force: bool = False) -> str:
|
||||
"""
|
||||
Pad all lines in a message to make inline keyboard buttons wider.
|
||||
|
||||
⚠️ CRITICAL: DO NOT REMOVE THIS FUNCTION - it makes buttons wide!
|
||||
This is the MAIN function that applies padding to ALL messages with keyboards.
|
||||
|
||||
Why we need this:
|
||||
- Telegram determines button width based on MESSAGE TEXT width
|
||||
- Short messages = narrow buttons
|
||||
- Wide messages (with invisible padding) = WIDE buttons like BotFather
|
||||
|
||||
Args:
|
||||
message: Multi-line message text
|
||||
target_width: Target character count per line (default 50)
|
||||
force: Force padding even if ENABLE_BUTTON_PADDING is False
|
||||
|
||||
Returns:
|
||||
Message with all lines padded (if enabled or forced)
|
||||
"""
|
||||
# ⚠️ DO NOT REMOVE: Check if padding is enabled
|
||||
if not ENABLE_BUTTON_PADDING and not force:
|
||||
return message
|
||||
|
||||
# ⚠️ DO NOT REMOVE: Apply padding to each line
|
||||
lines = message.split('\n')
|
||||
padded_lines = [_pad_line_for_wide_buttons(line, target_width) for line in lines]
|
||||
return '\n'.join(padded_lines)
|
||||
|
||||
|
||||
def format_response_with_company(
|
||||
content: str,
|
||||
company_name: Optional[str] = None,
|
||||
apply_padding: bool = True
|
||||
) -> str:
|
||||
"""
|
||||
Format a response with company name at the top (simplified format).
|
||||
|
||||
⚠️ IMPORTANT: Applies padding by default to make buttons WIDE!
|
||||
|
||||
Format:
|
||||
Company Name
|
||||
|
||||
[Content]
|
||||
|
||||
Args:
|
||||
content: The main content text
|
||||
company_name: Company name to show at top (if None, just returns content)
|
||||
apply_padding: Whether to apply invisible padding for wider buttons (default TRUE)
|
||||
|
||||
Returns:
|
||||
Formatted response with company name header AND padding for wide buttons
|
||||
"""
|
||||
if company_name:
|
||||
message = f"{company_name}\n\n{content}"
|
||||
else:
|
||||
message = content
|
||||
|
||||
# ⚠️ DO NOT REMOVE: Apply padding to make inline keyboard buttons WIDE!
|
||||
# Without this, buttons become narrow like before
|
||||
if apply_padding:
|
||||
message = pad_message_for_wide_buttons(message)
|
||||
|
||||
return message
|
||||
|
||||
|
||||
def get_menu_message(
|
||||
company_name: Optional[str] = None,
|
||||
company_cui: Optional[str] = None,
|
||||
apply_padding: bool = True
|
||||
) -> str:
|
||||
"""
|
||||
Get the menu message text with company details (simplified format).
|
||||
|
||||
⚠️ IMPORTANT: Applies padding by default to make menu buttons WIDE!
|
||||
|
||||
Format without labels - just values:
|
||||
- Line 1: Company name
|
||||
- Line 2: CUI
|
||||
- Line 3: Accounting month
|
||||
|
||||
Args:
|
||||
company_name: Active company name
|
||||
company_cui: Company fiscal code (CUI)
|
||||
apply_padding: Whether to apply invisible padding for wider buttons (default TRUE)
|
||||
|
||||
Returns:
|
||||
Formatted message text for menu WITH padding for wide buttons
|
||||
"""
|
||||
if company_name:
|
||||
# Simplified format: just values, no labels
|
||||
message = f"{company_name}\n"
|
||||
if company_cui:
|
||||
message += f"{company_cui}\n"
|
||||
message += f"{_get_current_month_ro()}"
|
||||
else:
|
||||
# No company selected - just prompt
|
||||
message = "Selectează o companie pentru a continua"
|
||||
|
||||
# ⚠️ DO NOT REMOVE: Apply padding to make inline keyboard buttons WIDE!
|
||||
# This makes buttons look like BotFather (wide, not narrow)
|
||||
if apply_padding:
|
||||
message = pad_message_for_wide_buttons(message)
|
||||
|
||||
return message
|
||||
|
||||
|
||||
def create_main_menu(
|
||||
company_name: Optional[str] = None,
|
||||
company_cui: Optional[str] = None
|
||||
) -> InlineKeyboardMarkup:
|
||||
"""
|
||||
Create main menu keyboard (Level 1) with financial options.
|
||||
|
||||
Layout: Full-width buttons with company selection at top
|
||||
|
||||
Args:
|
||||
company_name: Active company name, or None if no company selected
|
||||
company_cui: Company fiscal code (CUI), or None
|
||||
|
||||
Returns:
|
||||
InlineKeyboardMarkup with main menu buttons
|
||||
"""
|
||||
keyboard = []
|
||||
|
||||
# Row 1: Company selection (full width, single line - InlineKeyboardButton doesn't support multiline)
|
||||
if company_name:
|
||||
# Short company name for button (CUI and month will be shown in message text)
|
||||
# Truncate long names to fit in button
|
||||
max_length = 35
|
||||
display_name = company_name if len(company_name) <= max_length else company_name[:max_length-3] + "..."
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
f"{display_name}",
|
||||
callback_data="menu:select_company"
|
||||
)
|
||||
])
|
||||
else:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
"Selectare Companie",
|
||||
callback_data="menu:select_company"
|
||||
)
|
||||
])
|
||||
|
||||
# Rows 2-4: Financial options (2 buttons per row, made wide by message text padding)
|
||||
keyboard.extend([
|
||||
[
|
||||
InlineKeyboardButton("Sold Companie", callback_data="menu:sold"),
|
||||
InlineKeyboardButton("Trezorerie Casa", callback_data="menu:casa")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton("Trezorerie Banca", callback_data="menu:banca"),
|
||||
InlineKeyboardButton("Sold Clienti", callback_data="menu:clienti")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton("Sold Furnizori", callback_data="menu:furnizori"),
|
||||
InlineKeyboardButton("Evolutie Incasari", callback_data="menu:evolutie")
|
||||
]
|
||||
])
|
||||
|
||||
# Row 5: Help button (full width)
|
||||
keyboard.append([
|
||||
InlineKeyboardButton("Help", callback_data="action:help")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
|
||||
def create_action_buttons(current_view: str, show_export: bool = True, show_back: bool = False) -> InlineKeyboardMarkup:
|
||||
"""
|
||||
Create action buttons for responses (Refresh, Export, Back, Menu).
|
||||
|
||||
Layout (buttons made wide by message text padding):
|
||||
[Refresh] [Export] (if show_export=True)
|
||||
[Înapoi] (if show_back=True, full width)
|
||||
[Menu] (full width)
|
||||
|
||||
Or:
|
||||
[Refresh] (if show_export=False)
|
||||
[Înapoi] (if show_back=True, full width)
|
||||
[Menu] (full width)
|
||||
|
||||
Args:
|
||||
current_view: View identifier for refresh callback (e.g., "sold", "clienti")
|
||||
show_export: Whether to show Export button
|
||||
show_back: Whether to show Back button to list
|
||||
|
||||
Returns:
|
||||
InlineKeyboardMarkup with action buttons
|
||||
"""
|
||||
keyboard = []
|
||||
|
||||
# Row 1: Refresh and optionally Export
|
||||
if show_export:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton("Refresh", callback_data=f"action:refresh:{current_view}"),
|
||||
InlineKeyboardButton("Export", callback_data=f"action:export:{current_view}")
|
||||
])
|
||||
else:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton("Refresh", callback_data=f"action:refresh:{current_view}")
|
||||
])
|
||||
|
||||
# Row 2: Back to List (if show_back is True)
|
||||
if show_back:
|
||||
# Determine back callback based on current view
|
||||
# ✅ FIX: Handle detail views (client_detail:name, supplier_detail:name)
|
||||
if current_view.startswith("client_detail:"):
|
||||
back_callback = "menu:clienti" # Back to client list
|
||||
elif current_view.startswith("supplier_detail:"):
|
||||
back_callback = "menu:furnizori" # Back to supplier list
|
||||
elif current_view == "clienti":
|
||||
back_callback = "clients_page:0" # Match handlers.py:1689
|
||||
elif current_view == "furnizori":
|
||||
back_callback = "suppliers_page:0" # Match handlers.py:1721
|
||||
else:
|
||||
back_callback = "action:menu" # Fallback to menu
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton("« Înapoi", callback_data=back_callback)
|
||||
])
|
||||
|
||||
# Row 3: Back to Menu (full width)
|
||||
keyboard.append([
|
||||
InlineKeyboardButton("Meniu Principal", callback_data="action:menu")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
|
||||
def create_client_list_keyboard(clients: List[Dict], max_items: int = 10, page: int = 0) -> InlineKeyboardMarkup:
|
||||
"""
|
||||
Create client list keyboard (Level 2) with client buttons and pagination.
|
||||
|
||||
Layout: 1 column for clients, pagination controls, 2 columns for navigation
|
||||
|
||||
Args:
|
||||
clients: List of client dicts with keys: id, name, balance
|
||||
max_items: Maximum number of clients per page (default: 10)
|
||||
page: Current page number (0-indexed)
|
||||
|
||||
Returns:
|
||||
InlineKeyboardMarkup with client list buttons and pagination
|
||||
"""
|
||||
keyboard = []
|
||||
|
||||
# Calculate pagination
|
||||
total_clients = len(clients)
|
||||
total_pages = (total_clients + max_items - 1) // max_items # Ceiling division
|
||||
start_idx = page * max_items
|
||||
end_idx = min(start_idx + max_items, total_clients)
|
||||
|
||||
# Display clients for current page
|
||||
display_clients = clients[start_idx:end_idx]
|
||||
|
||||
# Add client buttons (1 per row)
|
||||
for client in display_clients:
|
||||
client_name = client.get('name', 'N/A')
|
||||
balance = client.get('balance', 0)
|
||||
|
||||
# Format balance with thousands separator
|
||||
balance_str = f"{balance:,.0f}" if balance else "0"
|
||||
|
||||
button_text = f"{client_name} - {balance_str} RON"
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
button_text,
|
||||
callback_data=f"details:client:{client_name}:0" # name:page
|
||||
)
|
||||
])
|
||||
|
||||
# Pagination controls (only if more than one page)
|
||||
if total_pages > 1:
|
||||
nav_buttons = []
|
||||
|
||||
# Previous button
|
||||
if page > 0:
|
||||
nav_buttons.append(
|
||||
InlineKeyboardButton("< Anterior", callback_data=f"clients_page:{page-1}")
|
||||
)
|
||||
|
||||
# Page indicator (non-clickable)
|
||||
nav_buttons.append(
|
||||
InlineKeyboardButton(f"Pagina {page+1}/{total_pages}", callback_data="noop")
|
||||
)
|
||||
|
||||
# Next button
|
||||
if page < total_pages - 1:
|
||||
nav_buttons.append(
|
||||
InlineKeyboardButton("Următor >", callback_data=f"clients_page:{page+1}")
|
||||
)
|
||||
|
||||
keyboard.append(nav_buttons)
|
||||
|
||||
# Navigation row: Back and Refresh (2 buttons per row)
|
||||
keyboard.append([
|
||||
InlineKeyboardButton("< Înapoi", callback_data="action:menu"),
|
||||
InlineKeyboardButton("Refresh", callback_data="action:refresh:clienti")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
|
||||
def create_supplier_list_keyboard(suppliers: List[Dict], max_items: int = 10, page: int = 0) -> InlineKeyboardMarkup:
|
||||
"""
|
||||
Create supplier list keyboard (Level 2) with supplier buttons and pagination.
|
||||
|
||||
Layout: 1 column for suppliers, pagination controls, 2 columns for navigation
|
||||
|
||||
Args:
|
||||
suppliers: List of supplier dicts with keys: id, name, balance
|
||||
max_items: Maximum number of suppliers per page (default: 10)
|
||||
page: Current page number (0-indexed)
|
||||
|
||||
Returns:
|
||||
InlineKeyboardMarkup with supplier list buttons and pagination
|
||||
"""
|
||||
keyboard = []
|
||||
|
||||
# Calculate pagination
|
||||
total_suppliers = len(suppliers)
|
||||
total_pages = (total_suppliers + max_items - 1) // max_items # Ceiling division
|
||||
start_idx = page * max_items
|
||||
end_idx = min(start_idx + max_items, total_suppliers)
|
||||
|
||||
# Display suppliers for current page
|
||||
display_suppliers = suppliers[start_idx:end_idx]
|
||||
|
||||
# Add supplier buttons (1 per row)
|
||||
for supplier in display_suppliers:
|
||||
supplier_name = supplier.get('name', 'N/A')
|
||||
balance = supplier.get('balance', 0)
|
||||
|
||||
# Format balance with thousands separator
|
||||
balance_str = f"{balance:,.0f}" if balance else "0"
|
||||
|
||||
button_text = f"{supplier_name} - {balance_str} RON"
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
button_text,
|
||||
callback_data=f"details:supplier:{supplier_name}:0" # name:page
|
||||
)
|
||||
])
|
||||
|
||||
# Pagination controls (only if more than one page)
|
||||
if total_pages > 1:
|
||||
nav_buttons = []
|
||||
|
||||
# Previous button
|
||||
if page > 0:
|
||||
nav_buttons.append(
|
||||
InlineKeyboardButton("< Anterior", callback_data=f"suppliers_page:{page-1}")
|
||||
)
|
||||
|
||||
# Page indicator (non-clickable)
|
||||
nav_buttons.append(
|
||||
InlineKeyboardButton(f"Pagina {page+1}/{total_pages}", callback_data="noop")
|
||||
)
|
||||
|
||||
# Next button
|
||||
if page < total_pages - 1:
|
||||
nav_buttons.append(
|
||||
InlineKeyboardButton("Următor >", callback_data=f"suppliers_page:{page+1}")
|
||||
)
|
||||
|
||||
keyboard.append(nav_buttons)
|
||||
|
||||
# Navigation row: Back and Refresh (2 buttons per row)
|
||||
keyboard.append([
|
||||
InlineKeyboardButton("< Înapoi", callback_data="action:menu"),
|
||||
InlineKeyboardButton("Refresh", callback_data="action:refresh:furnizori")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
|
||||
def create_invoice_list_keyboard(
|
||||
invoices: List[Dict],
|
||||
partner_type: str,
|
||||
partner_name: str,
|
||||
max_items: int = 10,
|
||||
page: int = 0
|
||||
) -> InlineKeyboardMarkup:
|
||||
"""
|
||||
Create invoice list keyboard (Level 3) with invoice buttons and pagination.
|
||||
|
||||
Layout: 1 column for invoices, pagination controls, 2 columns for navigation
|
||||
|
||||
Args:
|
||||
invoices: List of invoice dicts with keys: id, number, amount, status
|
||||
partner_type: "CLIENTI" or "FURNIZORI"
|
||||
partner_name: Client/supplier name (for back navigation)
|
||||
max_items: Maximum number of invoices per page (default: 10)
|
||||
page: Current page number (0-indexed)
|
||||
|
||||
Returns:
|
||||
InlineKeyboardMarkup with invoice list buttons and pagination
|
||||
"""
|
||||
keyboard = []
|
||||
|
||||
# Calculate pagination
|
||||
total_invoices = len(invoices)
|
||||
total_pages = (total_invoices + max_items - 1) // max_items # Ceiling division
|
||||
start_idx = page * max_items
|
||||
end_idx = min(start_idx + max_items, total_invoices)
|
||||
|
||||
# Display invoices for current page
|
||||
display_invoices = invoices[start_idx:end_idx]
|
||||
|
||||
# Add invoice buttons (1 per row)
|
||||
for invoice in display_invoices:
|
||||
invoice_id = invoice.get('id', 0)
|
||||
invoice_number = invoice.get('number', 'N/A')
|
||||
amount = invoice.get('amount', 0)
|
||||
status = invoice.get('status', 'unknown')
|
||||
|
||||
# Format amount with thousands separator
|
||||
amount_str = f"{amount:,.0f}" if amount else "0"
|
||||
|
||||
# Status text indicator (no emojis)
|
||||
status_text = "[NEPLATIT]" if status in ['unpaid', 'overdue'] else "[PLATIT]"
|
||||
|
||||
button_text = f"{status_text} {invoice_number} - {amount_str} RON"
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
button_text,
|
||||
callback_data=f"invoice:{partner_type}:{invoice_id}"
|
||||
)
|
||||
])
|
||||
|
||||
# Pagination controls (only if more than one page)
|
||||
if total_pages > 1:
|
||||
nav_buttons = []
|
||||
|
||||
# Previous button
|
||||
if page > 0:
|
||||
nav_buttons.append(
|
||||
InlineKeyboardButton("< Anterior", callback_data=f"invoices_page:{partner_type}:{partner_name}:{page-1}")
|
||||
)
|
||||
|
||||
# Page indicator (non-clickable)
|
||||
nav_buttons.append(
|
||||
InlineKeyboardButton(f"Pagina {page+1}/{total_pages}", callback_data="noop")
|
||||
)
|
||||
|
||||
# Next button
|
||||
if page < total_pages - 1:
|
||||
nav_buttons.append(
|
||||
InlineKeyboardButton("Următor >", callback_data=f"invoices_page:{partner_type}:{partner_name}:{page+1}")
|
||||
)
|
||||
|
||||
keyboard.append(nav_buttons)
|
||||
|
||||
# Navigation row: Back and Export (2 buttons per row)
|
||||
back_target = "clienti" if partner_type == "CLIENTI" else "furnizori"
|
||||
keyboard.append([
|
||||
InlineKeyboardButton("< Înapoi", callback_data=f"nav:back:{back_target}"),
|
||||
InlineKeyboardButton("Export", callback_data=f"action:export:{partner_type.lower()}")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
|
||||
|
||||
def create_navigation_buttons(back_to: str) -> InlineKeyboardMarkup:
|
||||
"""
|
||||
Create simple navigation buttons (just Back button).
|
||||
|
||||
Args:
|
||||
back_to: Target location identifier (e.g., "menu", "clienti", "furnizori")
|
||||
|
||||
Returns:
|
||||
InlineKeyboardMarkup with navigation button
|
||||
"""
|
||||
keyboard = [
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
f"< Înapoi la {back_to}",
|
||||
callback_data=f"nav:back:{back_to}"
|
||||
)
|
||||
]
|
||||
]
|
||||
|
||||
return InlineKeyboardMarkup(keyboard)
|
||||
68
reports-app/telegram-bot/app/db/__init__.py
Normal file
68
reports-app/telegram-bot/app/db/__init__.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""
|
||||
Database module for Telegram Bot
|
||||
|
||||
Provides SQLite database operations for:
|
||||
- User management and Oracle account linking
|
||||
- Authentication code management
|
||||
- Conversation session management
|
||||
"""
|
||||
|
||||
from .database import (
|
||||
init_database,
|
||||
get_db_connection,
|
||||
cleanup_expired_codes,
|
||||
cleanup_expired_sessions,
|
||||
get_database_stats,
|
||||
DB_PATH,
|
||||
)
|
||||
|
||||
from .operations import (
|
||||
# User operations
|
||||
create_or_update_user,
|
||||
get_user,
|
||||
link_user_to_oracle,
|
||||
update_user_tokens,
|
||||
update_user_last_active,
|
||||
is_user_linked,
|
||||
# Auth code operations
|
||||
create_auth_code,
|
||||
get_auth_code,
|
||||
verify_and_use_auth_code,
|
||||
get_pending_codes_for_user,
|
||||
# Session operations
|
||||
create_session,
|
||||
get_session,
|
||||
get_user_active_session,
|
||||
update_session_state,
|
||||
delete_session,
|
||||
delete_user_sessions,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Database setup
|
||||
'init_database',
|
||||
'get_db_connection',
|
||||
'cleanup_expired_codes',
|
||||
'cleanup_expired_sessions',
|
||||
'get_database_stats',
|
||||
'DB_PATH',
|
||||
# User operations
|
||||
'create_or_update_user',
|
||||
'get_user',
|
||||
'link_user_to_oracle',
|
||||
'update_user_tokens',
|
||||
'update_user_last_active',
|
||||
'is_user_linked',
|
||||
# Auth code operations
|
||||
'create_auth_code',
|
||||
'get_auth_code',
|
||||
'verify_and_use_auth_code',
|
||||
'get_pending_codes_for_user',
|
||||
# Session operations
|
||||
'create_session',
|
||||
'get_session',
|
||||
'get_user_active_session',
|
||||
'update_session_state',
|
||||
'delete_session',
|
||||
'delete_user_sessions',
|
||||
]
|
||||
243
reports-app/telegram-bot/app/db/database.py
Normal file
243
reports-app/telegram-bot/app/db/database.py
Normal file
@@ -0,0 +1,243 @@
|
||||
"""
|
||||
SQLite Database Setup for Telegram Bot
|
||||
|
||||
This module handles database connection, initialization, and schema creation.
|
||||
Uses aiosqlite for async SQLite operations.
|
||||
"""
|
||||
|
||||
import aiosqlite
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Database file location
|
||||
DB_DIR = Path(__file__).parent.parent.parent / "data"
|
||||
DB_PATH = DB_DIR / "telegram_bot.db"
|
||||
|
||||
|
||||
async def get_db_connection() -> aiosqlite.Connection:
|
||||
"""
|
||||
Get a database connection.
|
||||
|
||||
Returns:
|
||||
aiosqlite.Connection: Database connection
|
||||
"""
|
||||
conn = await aiosqlite.connect(DB_PATH)
|
||||
conn.row_factory = aiosqlite.Row # Enable column access by name
|
||||
return conn
|
||||
|
||||
|
||||
async def init_database() -> None:
|
||||
"""
|
||||
Initialize the database and create all tables.
|
||||
Safe to call multiple times - only creates tables if they don't exist.
|
||||
"""
|
||||
try:
|
||||
# Ensure data directory exists
|
||||
DB_DIR.mkdir(parents=True, exist_ok=True)
|
||||
logger.info(f"Database directory: {DB_DIR}")
|
||||
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
# Enable foreign keys
|
||||
await db.execute("PRAGMA foreign_keys = ON")
|
||||
|
||||
# Create telegram_users table
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS telegram_users (
|
||||
telegram_user_id INTEGER PRIMARY KEY,
|
||||
username TEXT,
|
||||
first_name TEXT NOT NULL,
|
||||
last_name TEXT,
|
||||
oracle_username TEXT,
|
||||
jwt_token TEXT,
|
||||
jwt_refresh_token TEXT,
|
||||
token_expires_at TIMESTAMP,
|
||||
linked_at TIMESTAMP,
|
||||
last_active_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
is_active BOOLEAN DEFAULT 1
|
||||
)
|
||||
""")
|
||||
|
||||
# Create telegram_auth_codes table
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS telegram_auth_codes (
|
||||
code TEXT PRIMARY KEY,
|
||||
telegram_user_id INTEGER,
|
||||
oracle_username TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
used BOOLEAN DEFAULT 0,
|
||||
used_at TIMESTAMP,
|
||||
FOREIGN KEY (telegram_user_id) REFERENCES telegram_users(telegram_user_id)
|
||||
)
|
||||
""")
|
||||
|
||||
# Create telegram_sessions table
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS telegram_sessions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
telegram_user_id INTEGER NOT NULL,
|
||||
conversation_state TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
FOREIGN KEY (telegram_user_id) REFERENCES telegram_users(telegram_user_id)
|
||||
)
|
||||
""")
|
||||
|
||||
# Create indexes for better query performance
|
||||
await db.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_auth_codes_telegram_user
|
||||
ON telegram_auth_codes(telegram_user_id)
|
||||
""")
|
||||
|
||||
await db.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_auth_codes_expires
|
||||
ON telegram_auth_codes(expires_at)
|
||||
""")
|
||||
|
||||
await db.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_telegram_user
|
||||
ON telegram_sessions(telegram_user_id)
|
||||
""")
|
||||
|
||||
await db.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires
|
||||
ON telegram_sessions(expires_at)
|
||||
""")
|
||||
|
||||
await db.commit()
|
||||
logger.info("Database initialized successfully")
|
||||
|
||||
# Log table info
|
||||
cursor = await db.execute("""
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table'
|
||||
ORDER BY name
|
||||
""")
|
||||
tables = await cursor.fetchall()
|
||||
logger.info(f"Existing tables: {[t[0] for t in tables]}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize database: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def cleanup_expired_codes() -> int:
|
||||
"""
|
||||
Delete expired authentication codes from the database.
|
||||
This should be called periodically (e.g., every hour).
|
||||
|
||||
Returns:
|
||||
int: Number of expired codes deleted
|
||||
"""
|
||||
try:
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
cursor = await db.execute("""
|
||||
DELETE FROM telegram_auth_codes
|
||||
WHERE expires_at < ?
|
||||
""", (datetime.now(),))
|
||||
|
||||
await db.commit()
|
||||
deleted = cursor.rowcount
|
||||
|
||||
if deleted > 0:
|
||||
logger.info(f"Cleaned up {deleted} expired auth codes")
|
||||
|
||||
return deleted
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to cleanup expired codes: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
async def cleanup_expired_sessions() -> int:
|
||||
"""
|
||||
Delete expired sessions from the database.
|
||||
This should be called periodically (e.g., daily).
|
||||
|
||||
Returns:
|
||||
int: Number of expired sessions deleted
|
||||
"""
|
||||
try:
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
cursor = await db.execute("""
|
||||
DELETE FROM telegram_sessions
|
||||
WHERE expires_at < ?
|
||||
""", (datetime.now(),))
|
||||
|
||||
await db.commit()
|
||||
deleted = cursor.rowcount
|
||||
|
||||
if deleted > 0:
|
||||
logger.info(f"Cleaned up {deleted} expired sessions")
|
||||
|
||||
return deleted
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to cleanup expired sessions: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
async def get_database_stats() -> dict:
|
||||
"""
|
||||
Get database statistics for monitoring.
|
||||
|
||||
Returns:
|
||||
dict: Database statistics
|
||||
"""
|
||||
try:
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
stats = {}
|
||||
|
||||
# Count users
|
||||
cursor = await db.execute("SELECT COUNT(*) FROM telegram_users")
|
||||
stats['total_users'] = (await cursor.fetchone())[0]
|
||||
|
||||
cursor = await db.execute(
|
||||
"SELECT COUNT(*) FROM telegram_users WHERE is_active = 1"
|
||||
)
|
||||
stats['active_users'] = (await cursor.fetchone())[0]
|
||||
|
||||
# Count pending codes
|
||||
cursor = await db.execute("""
|
||||
SELECT COUNT(*) FROM telegram_auth_codes
|
||||
WHERE used = 0 AND expires_at > ?
|
||||
""", (datetime.now(),))
|
||||
stats['pending_codes'] = (await cursor.fetchone())[0]
|
||||
|
||||
# Count active sessions
|
||||
cursor = await db.execute("""
|
||||
SELECT COUNT(*) FROM telegram_sessions
|
||||
WHERE expires_at > ?
|
||||
""", (datetime.now(),))
|
||||
stats['active_sessions'] = (await cursor.fetchone())[0]
|
||||
|
||||
# Database file size
|
||||
if DB_PATH.exists():
|
||||
stats['db_size_mb'] = DB_PATH.stat().st_size / (1024 * 1024)
|
||||
else:
|
||||
stats['db_size_mb'] = 0
|
||||
|
||||
return stats
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get database stats: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
# Export main functions
|
||||
__all__ = [
|
||||
'get_db_connection',
|
||||
'init_database',
|
||||
'cleanup_expired_codes',
|
||||
'cleanup_expired_sessions',
|
||||
'get_database_stats',
|
||||
'DB_PATH',
|
||||
]
|
||||
591
reports-app/telegram-bot/app/db/operations.py
Normal file
591
reports-app/telegram-bot/app/db/operations.py
Normal file
@@ -0,0 +1,591 @@
|
||||
"""
|
||||
Database Operations for Telegram Bot
|
||||
|
||||
This module provides CRUD operations for:
|
||||
- telegram_users: Telegram user management and Oracle account linking
|
||||
- telegram_auth_codes: Authentication code management
|
||||
- telegram_sessions: Conversation session management
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
import aiosqlite
|
||||
|
||||
from .database import DB_PATH
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TELEGRAM USERS OPERATIONS
|
||||
# ============================================================================
|
||||
|
||||
async def create_or_update_user(
|
||||
telegram_user_id: int,
|
||||
username: Optional[str],
|
||||
first_name: str,
|
||||
last_name: Optional[str]
|
||||
) -> bool:
|
||||
"""
|
||||
Create or update a Telegram user record.
|
||||
|
||||
Args:
|
||||
telegram_user_id: Telegram user ID
|
||||
username: Telegram username (without @)
|
||||
first_name: User's first name
|
||||
last_name: User's last name
|
||||
|
||||
Returns:
|
||||
bool: True if successful
|
||||
"""
|
||||
try:
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
await db.execute("""
|
||||
INSERT INTO telegram_users (
|
||||
telegram_user_id, username, first_name, last_name, last_active_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(telegram_user_id) DO UPDATE SET
|
||||
username = excluded.username,
|
||||
first_name = excluded.first_name,
|
||||
last_name = excluded.last_name,
|
||||
last_active_at = excluded.last_active_at
|
||||
""", (telegram_user_id, username, first_name, last_name, datetime.now()))
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"User {telegram_user_id} created/updated")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create/update user {telegram_user_id}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def get_user(telegram_user_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get user information by Telegram user ID.
|
||||
|
||||
Args:
|
||||
telegram_user_id: Telegram user ID
|
||||
|
||||
Returns:
|
||||
Optional[Dict]: User data or None if not found
|
||||
"""
|
||||
try:
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
cursor = await db.execute("""
|
||||
SELECT * FROM telegram_users
|
||||
WHERE telegram_user_id = ?
|
||||
""", (telegram_user_id,))
|
||||
|
||||
row = await cursor.fetchone()
|
||||
if row:
|
||||
return dict(row)
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get user {telegram_user_id}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def link_user_to_oracle(
|
||||
telegram_user_id: int,
|
||||
oracle_username: str,
|
||||
jwt_token: str,
|
||||
jwt_refresh_token: str,
|
||||
token_expires_at: datetime
|
||||
) -> bool:
|
||||
"""
|
||||
Link a Telegram user to an Oracle account and save JWT tokens.
|
||||
|
||||
Args:
|
||||
telegram_user_id: Telegram user ID
|
||||
oracle_username: Oracle username
|
||||
jwt_token: JWT access token
|
||||
jwt_refresh_token: JWT refresh token
|
||||
token_expires_at: Token expiration timestamp
|
||||
|
||||
Returns:
|
||||
bool: True if successful
|
||||
"""
|
||||
try:
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
await db.execute("""
|
||||
UPDATE telegram_users
|
||||
SET oracle_username = ?,
|
||||
jwt_token = ?,
|
||||
jwt_refresh_token = ?,
|
||||
token_expires_at = ?,
|
||||
linked_at = ?,
|
||||
is_active = 1
|
||||
WHERE telegram_user_id = ?
|
||||
""", (
|
||||
oracle_username,
|
||||
jwt_token,
|
||||
jwt_refresh_token,
|
||||
token_expires_at,
|
||||
datetime.now(),
|
||||
telegram_user_id
|
||||
))
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"User {telegram_user_id} linked to Oracle user {oracle_username}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to link user {telegram_user_id}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def update_user_tokens(
|
||||
telegram_user_id: int,
|
||||
jwt_token: str,
|
||||
jwt_refresh_token: str,
|
||||
token_expires_at: datetime
|
||||
) -> bool:
|
||||
"""
|
||||
Update JWT tokens for a user.
|
||||
|
||||
Args:
|
||||
telegram_user_id: Telegram user ID
|
||||
jwt_token: New JWT access token
|
||||
jwt_refresh_token: New JWT refresh token
|
||||
token_expires_at: New token expiration timestamp
|
||||
|
||||
Returns:
|
||||
bool: True if successful
|
||||
"""
|
||||
try:
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
await db.execute("""
|
||||
UPDATE telegram_users
|
||||
SET jwt_token = ?,
|
||||
jwt_refresh_token = ?,
|
||||
token_expires_at = ?
|
||||
WHERE telegram_user_id = ?
|
||||
""", (jwt_token, jwt_refresh_token, token_expires_at, telegram_user_id))
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"Tokens updated for user {telegram_user_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update tokens for user {telegram_user_id}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def update_user_last_active(telegram_user_id: int) -> bool:
|
||||
"""
|
||||
Update the last active timestamp for a user.
|
||||
|
||||
Args:
|
||||
telegram_user_id: Telegram user ID
|
||||
|
||||
Returns:
|
||||
bool: True if successful
|
||||
"""
|
||||
try:
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
await db.execute("""
|
||||
UPDATE telegram_users
|
||||
SET last_active_at = ?
|
||||
WHERE telegram_user_id = ?
|
||||
""", (datetime.now(), telegram_user_id))
|
||||
|
||||
await db.commit()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update last active for user {telegram_user_id}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def is_user_linked(telegram_user_id: int) -> bool:
|
||||
"""
|
||||
Check if a user is linked to an Oracle account.
|
||||
|
||||
Args:
|
||||
telegram_user_id: Telegram user ID
|
||||
|
||||
Returns:
|
||||
bool: True if user is linked
|
||||
"""
|
||||
try:
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
cursor = await db.execute("""
|
||||
SELECT oracle_username FROM telegram_users
|
||||
WHERE telegram_user_id = ? AND oracle_username IS NOT NULL
|
||||
""", (telegram_user_id,))
|
||||
|
||||
row = await cursor.fetchone()
|
||||
return row is not None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check if user {telegram_user_id} is linked: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# AUTHENTICATION CODES OPERATIONS
|
||||
# ============================================================================
|
||||
|
||||
async def create_auth_code(
|
||||
code: str,
|
||||
telegram_user_id: int,
|
||||
oracle_username: str,
|
||||
expires_in_minutes: int = 5
|
||||
) -> bool:
|
||||
"""
|
||||
Create a new authentication code for linking.
|
||||
|
||||
Args:
|
||||
code: 8-character authentication code
|
||||
telegram_user_id: Telegram user ID
|
||||
oracle_username: Oracle username to link
|
||||
expires_in_minutes: Code expiration time in minutes (default: 5)
|
||||
|
||||
Returns:
|
||||
bool: True if successful
|
||||
"""
|
||||
try:
|
||||
expires_at = datetime.now() + timedelta(minutes=expires_in_minutes)
|
||||
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
await db.execute("""
|
||||
INSERT INTO telegram_auth_codes (
|
||||
code, telegram_user_id, oracle_username, expires_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""", (code, telegram_user_id, oracle_username, expires_at))
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"Auth code created for user {telegram_user_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create auth code: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def get_auth_code(code: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get authentication code information.
|
||||
|
||||
Args:
|
||||
code: 8-character authentication code
|
||||
|
||||
Returns:
|
||||
Optional[Dict]: Code data or None if not found
|
||||
"""
|
||||
try:
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
cursor = await db.execute("""
|
||||
SELECT * FROM telegram_auth_codes
|
||||
WHERE code = ?
|
||||
""", (code,))
|
||||
|
||||
row = await cursor.fetchone()
|
||||
if row:
|
||||
return dict(row)
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get auth code: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def verify_and_use_auth_code(code: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Verify an authentication code and mark it as used.
|
||||
|
||||
Args:
|
||||
code: 8-character authentication code
|
||||
|
||||
Returns:
|
||||
Optional[Dict]: Code data if valid, None if invalid/expired
|
||||
"""
|
||||
try:
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
# Check if code exists, is not used, and not expired
|
||||
cursor = await db.execute("""
|
||||
SELECT * FROM telegram_auth_codes
|
||||
WHERE code = ?
|
||||
AND used = 0
|
||||
AND expires_at > ?
|
||||
""", (code, datetime.now()))
|
||||
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
logger.warning(f"Invalid or expired code: {code}")
|
||||
return None
|
||||
|
||||
# Mark code as used
|
||||
await db.execute("""
|
||||
UPDATE telegram_auth_codes
|
||||
SET used = 1, used_at = ?
|
||||
WHERE code = ?
|
||||
""", (datetime.now(), code))
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"Auth code {code} verified and used")
|
||||
|
||||
return dict(row)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to verify auth code: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def get_pending_codes_for_user(telegram_user_id: int) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get all pending (unused, non-expired) codes for a user.
|
||||
|
||||
Args:
|
||||
telegram_user_id: Telegram user ID
|
||||
|
||||
Returns:
|
||||
List[Dict]: List of pending codes
|
||||
"""
|
||||
try:
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
cursor = await db.execute("""
|
||||
SELECT * FROM telegram_auth_codes
|
||||
WHERE telegram_user_id = ?
|
||||
AND used = 0
|
||||
AND expires_at > ?
|
||||
ORDER BY created_at DESC
|
||||
""", (telegram_user_id, datetime.now()))
|
||||
|
||||
rows = await cursor.fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get pending codes for user {telegram_user_id}: {e}")
|
||||
return []
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SESSION OPERATIONS
|
||||
# ============================================================================
|
||||
|
||||
async def create_session(
|
||||
telegram_user_id: int,
|
||||
conversation_state: Optional[str] = None,
|
||||
expires_in_hours: int = 24
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Create a new conversation session.
|
||||
|
||||
Args:
|
||||
telegram_user_id: Telegram user ID
|
||||
conversation_state: JSON string of conversation state
|
||||
expires_in_hours: Session expiration time in hours (default: 24)
|
||||
|
||||
Returns:
|
||||
Optional[str]: Session ID if successful, None otherwise
|
||||
"""
|
||||
try:
|
||||
session_id = str(uuid.uuid4())
|
||||
expires_at = datetime.now() + timedelta(hours=expires_in_hours)
|
||||
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
await db.execute("""
|
||||
INSERT INTO telegram_sessions (
|
||||
session_id, telegram_user_id, conversation_state, expires_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""", (session_id, telegram_user_id, conversation_state, expires_at))
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"Session {session_id} created for user {telegram_user_id}")
|
||||
return session_id
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create session: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def get_session(session_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get session information.
|
||||
|
||||
Args:
|
||||
session_id: Session UUID
|
||||
|
||||
Returns:
|
||||
Optional[Dict]: Session data or None if not found/expired
|
||||
"""
|
||||
try:
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
cursor = await db.execute("""
|
||||
SELECT * FROM telegram_sessions
|
||||
WHERE session_id = ?
|
||||
AND expires_at > ?
|
||||
""", (session_id, datetime.now()))
|
||||
|
||||
row = await cursor.fetchone()
|
||||
if row:
|
||||
return dict(row)
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get session {session_id}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def get_user_active_session(telegram_user_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get the most recent active session for a user.
|
||||
|
||||
Args:
|
||||
telegram_user_id: Telegram user ID
|
||||
|
||||
Returns:
|
||||
Optional[Dict]: Session data or None if no active session
|
||||
"""
|
||||
try:
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
cursor = await db.execute("""
|
||||
SELECT * FROM telegram_sessions
|
||||
WHERE telegram_user_id = ?
|
||||
AND expires_at > ?
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1
|
||||
""", (telegram_user_id, datetime.now()))
|
||||
|
||||
row = await cursor.fetchone()
|
||||
if row:
|
||||
return dict(row)
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get active session for user {telegram_user_id}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def update_session_state(
|
||||
session_id: str,
|
||||
conversation_state: str
|
||||
) -> bool:
|
||||
"""
|
||||
Update the conversation state for a session.
|
||||
|
||||
Args:
|
||||
session_id: Session UUID
|
||||
conversation_state: JSON string of conversation state
|
||||
|
||||
Returns:
|
||||
bool: True if successful
|
||||
"""
|
||||
try:
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
await db.execute("""
|
||||
UPDATE telegram_sessions
|
||||
SET conversation_state = ?,
|
||||
updated_at = ?
|
||||
WHERE session_id = ?
|
||||
""", (conversation_state, datetime.now(), session_id))
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"Session {session_id} state updated")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update session {session_id}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def delete_session(session_id: str) -> bool:
|
||||
"""
|
||||
Delete a session.
|
||||
|
||||
Args:
|
||||
session_id: Session UUID
|
||||
|
||||
Returns:
|
||||
bool: True if successful
|
||||
"""
|
||||
try:
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
await db.execute("""
|
||||
DELETE FROM telegram_sessions
|
||||
WHERE session_id = ?
|
||||
""", (session_id,))
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"Session {session_id} deleted")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete session {session_id}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def delete_user_sessions(telegram_user_id: int) -> bool:
|
||||
"""
|
||||
Delete all sessions for a user.
|
||||
|
||||
Args:
|
||||
telegram_user_id: Telegram user ID
|
||||
|
||||
Returns:
|
||||
bool: True if successful
|
||||
"""
|
||||
try:
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
cursor = await db.execute("""
|
||||
DELETE FROM telegram_sessions
|
||||
WHERE telegram_user_id = ?
|
||||
""", (telegram_user_id,))
|
||||
|
||||
await db.commit()
|
||||
deleted = cursor.rowcount
|
||||
logger.info(f"Deleted {deleted} sessions for user {telegram_user_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete sessions for user {telegram_user_id}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# Export all functions
|
||||
__all__ = [
|
||||
# User operations
|
||||
'create_or_update_user',
|
||||
'get_user',
|
||||
'link_user_to_oracle',
|
||||
'update_user_tokens',
|
||||
'update_user_last_active',
|
||||
'is_user_linked',
|
||||
# Auth code operations
|
||||
'create_auth_code',
|
||||
'get_auth_code',
|
||||
'verify_and_use_auth_code',
|
||||
'get_pending_codes_for_user',
|
||||
# Session operations
|
||||
'create_session',
|
||||
'get_session',
|
||||
'get_user_active_session',
|
||||
'update_session_state',
|
||||
'delete_session',
|
||||
'delete_user_sessions',
|
||||
]
|
||||
375
reports-app/telegram-bot/app/internal_api.py
Normal file
375
reports-app/telegram-bot/app/internal_api.py
Normal file
@@ -0,0 +1,375 @@
|
||||
"""
|
||||
Internal API for Backend Communication
|
||||
|
||||
This FastAPI application provides internal endpoints for the ROA2WEB backend
|
||||
to communicate with the Telegram bot service. Main purpose is to save
|
||||
authentication codes generated in the web frontend.
|
||||
|
||||
This API runs alongside the Telegram bot and is accessible only internally
|
||||
(not exposed to public internet).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI, HTTPException, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.db.operations import create_auth_code, get_auth_code
|
||||
from app.db.database import get_database_stats
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Initialize FastAPI app
|
||||
internal_api = FastAPI(
|
||||
title="ROA2WEB Telegram Bot - Internal API",
|
||||
description="Internal API for backend communication (auth code management)",
|
||||
version="1.0.0",
|
||||
docs_url="/internal/docs" if os.getenv("ENABLE_DOCS", "false") == "true" else None,
|
||||
redoc_url=None
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# REQUEST/RESPONSE MODELS
|
||||
# ============================================================================
|
||||
|
||||
class SaveAuthCodeRequest(BaseModel):
|
||||
"""
|
||||
Request model for saving an authentication code.
|
||||
"""
|
||||
code: str = Field(
|
||||
...,
|
||||
description="8-character authentication code",
|
||||
min_length=8,
|
||||
max_length=8
|
||||
)
|
||||
telegram_user_id: int = Field(
|
||||
...,
|
||||
description="Telegram user ID (if known, otherwise 0)",
|
||||
ge=0
|
||||
)
|
||||
oracle_username: str = Field(
|
||||
...,
|
||||
description="Oracle username to link"
|
||||
)
|
||||
expires_in_minutes: int = Field(
|
||||
default=5,
|
||||
description="Code expiration time in minutes",
|
||||
ge=1,
|
||||
le=60
|
||||
)
|
||||
|
||||
|
||||
class SaveAuthCodeResponse(BaseModel):
|
||||
"""
|
||||
Response model for save auth code endpoint.
|
||||
"""
|
||||
success: bool = Field(..., description="Whether the operation succeeded")
|
||||
code: str = Field(..., description="The saved authentication code")
|
||||
expires_at: Optional[str] = Field(None, description="Expiration timestamp (ISO format)")
|
||||
message: Optional[str] = Field(None, description="Additional message")
|
||||
|
||||
|
||||
class VerifyAuthCodeRequest(BaseModel):
|
||||
"""
|
||||
Request model for verifying an authentication code.
|
||||
"""
|
||||
code: str = Field(..., description="Authentication code to verify")
|
||||
|
||||
|
||||
class VerifyAuthCodeResponse(BaseModel):
|
||||
"""
|
||||
Response model for verify auth code endpoint.
|
||||
"""
|
||||
valid: bool = Field(..., description="Whether the code is valid")
|
||||
oracle_username: Optional[str] = Field(None, description="Oracle username if valid")
|
||||
telegram_user_id: Optional[int] = Field(None, description="Telegram user ID if set")
|
||||
message: Optional[str] = Field(None, description="Additional message")
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
"""
|
||||
Response model for health check endpoint.
|
||||
"""
|
||||
status: str = Field(..., description="Service status")
|
||||
timestamp: str = Field(..., description="Current timestamp")
|
||||
database_stats: Optional[dict] = Field(None, description="Database statistics")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ENDPOINTS
|
||||
# ============================================================================
|
||||
|
||||
@internal_api.post(
|
||||
"/internal/save-code",
|
||||
response_model=SaveAuthCodeResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Save Authentication Code",
|
||||
description="Save an authentication code for Telegram linking (called by backend)"
|
||||
)
|
||||
async def save_auth_code(request: SaveAuthCodeRequest):
|
||||
"""
|
||||
Save an authentication code to SQLite database.
|
||||
|
||||
This endpoint is called by the FastAPI backend when a user generates
|
||||
a linking code in the web frontend.
|
||||
|
||||
**Flow:**
|
||||
1. User logs in to web frontend
|
||||
2. User clicks "Link Telegram Account"
|
||||
3. Backend generates 8-character code
|
||||
4. Backend calls this endpoint to save code
|
||||
5. Backend returns code to user for display
|
||||
6. User sends code to Telegram bot via /start command
|
||||
|
||||
Args:
|
||||
request: SaveAuthCodeRequest with code, oracle_username, etc.
|
||||
|
||||
Returns:
|
||||
SaveAuthCodeResponse with success status and code details
|
||||
|
||||
Raises:
|
||||
HTTPException 400: If code already exists or invalid data
|
||||
HTTPException 500: If database operation fails
|
||||
"""
|
||||
try:
|
||||
logger.info(
|
||||
f"Saving auth code for Oracle user: {request.oracle_username}, "
|
||||
f"code: {request.code}"
|
||||
)
|
||||
|
||||
# Check if code already exists
|
||||
existing_code = await get_auth_code(request.code)
|
||||
|
||||
if existing_code:
|
||||
logger.warning(f"Code {request.code} already exists")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Code {request.code} already exists. Generate a new unique code."
|
||||
)
|
||||
|
||||
# Create auth code in database
|
||||
success = await create_auth_code(
|
||||
code=request.code,
|
||||
telegram_user_id=request.telegram_user_id,
|
||||
oracle_username=request.oracle_username,
|
||||
expires_in_minutes=request.expires_in_minutes
|
||||
)
|
||||
|
||||
if not success:
|
||||
logger.error(f"Failed to save auth code {request.code}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to save authentication code to database"
|
||||
)
|
||||
|
||||
# Calculate expiration time
|
||||
from datetime import timedelta
|
||||
expires_at = (datetime.now() + timedelta(minutes=request.expires_in_minutes)).isoformat()
|
||||
|
||||
logger.info(f"Auth code {request.code} saved successfully")
|
||||
|
||||
return SaveAuthCodeResponse(
|
||||
success=True,
|
||||
code=request.code,
|
||||
expires_at=expires_at,
|
||||
message=f"Code saved successfully, expires in {request.expires_in_minutes} minutes"
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving auth code: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Internal server error: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@internal_api.post(
|
||||
"/internal/verify-code",
|
||||
response_model=VerifyAuthCodeResponse,
|
||||
summary="Verify Authentication Code",
|
||||
description="Verify if an authentication code is valid (without using it)"
|
||||
)
|
||||
async def verify_auth_code(request: VerifyAuthCodeRequest):
|
||||
"""
|
||||
Verify if an authentication code exists and is valid.
|
||||
|
||||
This is a read-only check that does NOT mark the code as used.
|
||||
Useful for backend to verify codes before user links Telegram account.
|
||||
|
||||
Args:
|
||||
request: VerifyAuthCodeRequest with code to verify
|
||||
|
||||
Returns:
|
||||
VerifyAuthCodeResponse with validation status
|
||||
|
||||
Raises:
|
||||
HTTPException 404: If code not found
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Verifying auth code: {request.code}")
|
||||
|
||||
code_data = await get_auth_code(request.code)
|
||||
|
||||
if not code_data:
|
||||
return VerifyAuthCodeResponse(
|
||||
valid=False,
|
||||
message="Code not found"
|
||||
)
|
||||
|
||||
# Check if code is expired
|
||||
expires_at_str = code_data.get('expires_at')
|
||||
expires_at = datetime.fromisoformat(expires_at_str) if expires_at_str else None
|
||||
|
||||
is_expired = expires_at and datetime.now() >= expires_at
|
||||
is_used = code_data.get('used', 0) == 1
|
||||
|
||||
if is_expired:
|
||||
return VerifyAuthCodeResponse(
|
||||
valid=False,
|
||||
oracle_username=code_data.get('oracle_username'),
|
||||
message="Code expired"
|
||||
)
|
||||
|
||||
if is_used:
|
||||
return VerifyAuthCodeResponse(
|
||||
valid=False,
|
||||
oracle_username=code_data.get('oracle_username'),
|
||||
message="Code already used"
|
||||
)
|
||||
|
||||
# Code is valid
|
||||
return VerifyAuthCodeResponse(
|
||||
valid=True,
|
||||
oracle_username=code_data.get('oracle_username'),
|
||||
telegram_user_id=code_data.get('telegram_user_id'),
|
||||
message="Code is valid"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error verifying auth code: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Internal server error: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@internal_api.get(
|
||||
"/internal/health",
|
||||
response_model=HealthResponse,
|
||||
summary="Health Check",
|
||||
description="Check if the internal API and database are healthy"
|
||||
)
|
||||
async def health_check():
|
||||
"""
|
||||
Health check endpoint.
|
||||
|
||||
Returns service status and database statistics.
|
||||
|
||||
Returns:
|
||||
HealthResponse with status and stats
|
||||
"""
|
||||
try:
|
||||
# Get database stats
|
||||
stats = await get_database_stats()
|
||||
|
||||
return HealthResponse(
|
||||
status="healthy",
|
||||
timestamp=datetime.now().isoformat(),
|
||||
database_stats=stats
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Health check failed: {e}", exc_info=True)
|
||||
return HealthResponse(
|
||||
status="unhealthy",
|
||||
timestamp=datetime.now().isoformat(),
|
||||
database_stats={"error": str(e)}
|
||||
)
|
||||
|
||||
|
||||
@internal_api.get(
|
||||
"/internal/stats",
|
||||
summary="Database Statistics",
|
||||
description="Get detailed database statistics"
|
||||
)
|
||||
async def get_stats():
|
||||
"""
|
||||
Get detailed database statistics.
|
||||
|
||||
Returns:
|
||||
JSON with database statistics
|
||||
"""
|
||||
try:
|
||||
stats = await get_database_stats()
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content={
|
||||
"success": True,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"stats": stats
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting stats: {e}", exc_info=True)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# EXCEPTION HANDLERS
|
||||
# ============================================================================
|
||||
|
||||
@internal_api.exception_handler(Exception)
|
||||
async def global_exception_handler(request, exc):
|
||||
"""
|
||||
Global exception handler for uncaught exceptions.
|
||||
"""
|
||||
logger.error(f"Unhandled exception: {exc}", exc_info=True)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={
|
||||
"success": False,
|
||||
"error": "Internal server error",
|
||||
"detail": str(exc) if os.getenv("DEBUG", "false") == "true" else "An error occurred"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# STARTUP/SHUTDOWN EVENTS
|
||||
# ============================================================================
|
||||
|
||||
@internal_api.on_event("startup")
|
||||
async def startup_event():
|
||||
"""
|
||||
Startup event handler.
|
||||
"""
|
||||
logger.info("Internal API starting up...")
|
||||
logger.info(f"Internal API ready on port {os.getenv('INTERNAL_API_PORT', '8002')}")
|
||||
|
||||
|
||||
@internal_api.on_event("shutdown")
|
||||
async def shutdown_event():
|
||||
"""
|
||||
Shutdown event handler.
|
||||
"""
|
||||
logger.info("Internal API shutting down...")
|
||||
|
||||
|
||||
# Export the FastAPI app
|
||||
__all__ = ['internal_api']
|
||||
293
reports-app/telegram-bot/app/main.py
Normal file
293
reports-app/telegram-bot/app/main.py
Normal file
@@ -0,0 +1,293 @@
|
||||
"""
|
||||
Main entry point for ROA2WEB Telegram Bot
|
||||
|
||||
This bot provides access to the ROA2WEB ERP system through Telegram
|
||||
using direct command handlers for financial data queries.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
import uvicorn
|
||||
from threading import Thread
|
||||
|
||||
# Telegram imports
|
||||
from telegram.ext import (
|
||||
Application,
|
||||
CommandHandler,
|
||||
CallbackQueryHandler,
|
||||
MessageHandler,
|
||||
filters
|
||||
)
|
||||
|
||||
# Import database initialization
|
||||
from app.db import init_database, cleanup_expired_codes, cleanup_expired_sessions
|
||||
|
||||
# Import bot handlers
|
||||
from app.bot.handlers import (
|
||||
start_command,
|
||||
help_command,
|
||||
clear_command,
|
||||
companies_command,
|
||||
unlink_command,
|
||||
selectcompany_command,
|
||||
dashboard_command,
|
||||
sold_command,
|
||||
facturi_command,
|
||||
trezorerie_command,
|
||||
# FAZA 3: New command handlers with button interface
|
||||
menu_command,
|
||||
trezorerie_casa_command,
|
||||
trezorerie_banca_command,
|
||||
clienti_command,
|
||||
furnizori_command,
|
||||
evolutie_command,
|
||||
# Text message handlers
|
||||
handle_text_message,
|
||||
# FAZA 4: Callback and error handlers
|
||||
button_callback,
|
||||
error_handler
|
||||
)
|
||||
|
||||
# Import internal API
|
||||
from app.internal_api import internal_api
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Load environment variables
|
||||
env_path = Path(__file__).parent.parent / '.env'
|
||||
load_dotenv(env_path)
|
||||
|
||||
# Environment variables
|
||||
TELEGRAM_BOT_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN')
|
||||
BACKEND_URL = os.getenv('BACKEND_URL', 'http://localhost:8001')
|
||||
INTERNAL_API_PORT = int(os.getenv('INTERNAL_API_PORT', '8002'))
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TELEGRAM BOT SETUP
|
||||
# ============================================================================
|
||||
|
||||
def create_telegram_application() -> Application:
|
||||
"""
|
||||
Create and configure the Telegram bot application.
|
||||
|
||||
Returns:
|
||||
Application: Configured Telegram application
|
||||
"""
|
||||
logger.info("Creating Telegram application...")
|
||||
|
||||
# Create application
|
||||
application = Application.builder().token(TELEGRAM_BOT_TOKEN).build()
|
||||
|
||||
# Register essential command handlers
|
||||
application.add_handler(CommandHandler("start", start_command))
|
||||
application.add_handler(CommandHandler("menu", menu_command))
|
||||
application.add_handler(CommandHandler("help", help_command))
|
||||
application.add_handler(CommandHandler("unlink", unlink_command))
|
||||
|
||||
# =========================================================================
|
||||
# LEGACY COMMAND HANDLERS (kept for backwards compatibility, hidden from help)
|
||||
# =========================================================================
|
||||
# NOTE: These commands are redundant with the button interface.
|
||||
# They're kept for users who already know them, but we push buttons in help.
|
||||
# Consider removing completely if migration is successful.
|
||||
|
||||
application.add_handler(CommandHandler("clear", clear_command))
|
||||
application.add_handler(CommandHandler("companies", companies_command))
|
||||
application.add_handler(CommandHandler("selectcompany", selectcompany_command))
|
||||
application.add_handler(CommandHandler("dashboard", dashboard_command))
|
||||
application.add_handler(CommandHandler("sold", sold_command))
|
||||
application.add_handler(CommandHandler("facturi", facturi_command))
|
||||
application.add_handler(CommandHandler("trezorerie", trezorerie_command))
|
||||
application.add_handler(CommandHandler("trezorerie_casa", trezorerie_casa_command))
|
||||
application.add_handler(CommandHandler("trezorerie_banca", trezorerie_banca_command))
|
||||
application.add_handler(CommandHandler("clienti", clienti_command))
|
||||
application.add_handler(CommandHandler("furnizori", furnizori_command))
|
||||
application.add_handler(CommandHandler("evolutie", evolutie_command))
|
||||
|
||||
# Text message handler (for direct code input and future NLP)
|
||||
# IMPORTANT: This must be registered BEFORE CallbackQueryHandler
|
||||
# filters.TEXT & ~filters.COMMAND ensures we only process non-command text messages
|
||||
application.add_handler(MessageHandler(
|
||||
filters.TEXT & ~filters.COMMAND,
|
||||
handle_text_message
|
||||
))
|
||||
|
||||
# FAZA 4: Register callback query handler (for inline buttons)
|
||||
application.add_handler(CallbackQueryHandler(button_callback))
|
||||
|
||||
# Register error handler
|
||||
application.add_error_handler(error_handler)
|
||||
|
||||
logger.info("Telegram application configured with all handlers")
|
||||
|
||||
return application
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# INTERNAL API SERVER
|
||||
# ============================================================================
|
||||
|
||||
def run_internal_api():
|
||||
"""
|
||||
Run the internal FastAPI server in a separate thread.
|
||||
|
||||
This API handles communication from the backend (saving auth codes).
|
||||
"""
|
||||
logger.info(f"Starting internal API on port {INTERNAL_API_PORT}...")
|
||||
|
||||
uvicorn.run(
|
||||
internal_api,
|
||||
host="0.0.0.0",
|
||||
port=INTERNAL_API_PORT,
|
||||
log_level="info"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# STARTUP/SHUTDOWN
|
||||
# ============================================================================
|
||||
|
||||
async def startup():
|
||||
"""
|
||||
Initialize the bot application on startup.
|
||||
"""
|
||||
logger.info("🚀 ROA2WEB Telegram Bot - Starting up...")
|
||||
|
||||
# Initialize database
|
||||
try:
|
||||
logger.info("Initializing SQLite database...")
|
||||
await init_database()
|
||||
logger.info("✅ Database initialized successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to initialize database: {e}")
|
||||
raise
|
||||
|
||||
# Cleanup expired data
|
||||
try:
|
||||
logger.info("Cleaning up expired data...")
|
||||
expired_codes = await cleanup_expired_codes()
|
||||
expired_sessions = await cleanup_expired_sessions()
|
||||
logger.info(f"✅ Cleanup complete: {expired_codes} codes, {expired_sessions} sessions removed")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Cleanup failed (non-critical): {e}")
|
||||
|
||||
logger.info("✅ Startup complete")
|
||||
|
||||
|
||||
async def shutdown():
|
||||
"""
|
||||
Clean up resources on shutdown.
|
||||
"""
|
||||
logger.info("👋 ROA2WEB Telegram Bot - Shutting down...")
|
||||
logger.info("✅ Shutdown complete")
|
||||
|
||||
|
||||
async def scheduled_cleanup():
|
||||
"""
|
||||
Background task to periodically clean up expired data.
|
||||
Runs every hour to remove expired auth codes and sessions.
|
||||
"""
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(3600) # Sleep for 1 hour
|
||||
logger.info("🧹 Running scheduled cleanup...")
|
||||
expired_codes = await cleanup_expired_codes()
|
||||
expired_sessions = await cleanup_expired_sessions()
|
||||
logger.info(f"✅ Scheduled cleanup: {expired_codes} codes, {expired_sessions} sessions removed")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error in scheduled cleanup: {e}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# MAIN APPLICATION
|
||||
# ============================================================================
|
||||
|
||||
async def main():
|
||||
"""
|
||||
Main application entry point.
|
||||
|
||||
Runs both the Telegram bot and internal API server concurrently.
|
||||
"""
|
||||
try:
|
||||
# Run startup
|
||||
await startup()
|
||||
|
||||
# Create Telegram application
|
||||
telegram_app = create_telegram_application()
|
||||
|
||||
# Start internal API in a separate thread
|
||||
api_thread = Thread(target=run_internal_api, daemon=True)
|
||||
api_thread.start()
|
||||
logger.info(f"✅ Internal API started on port {INTERNAL_API_PORT}")
|
||||
|
||||
# Start scheduled cleanup task in background
|
||||
cleanup_task = asyncio.create_task(scheduled_cleanup())
|
||||
logger.info("✅ Scheduled cleanup task started")
|
||||
|
||||
# Initialize and start Telegram bot
|
||||
logger.info("🤖 Starting Telegram bot polling...")
|
||||
await telegram_app.initialize()
|
||||
await telegram_app.start()
|
||||
await telegram_app.updater.start_polling(drop_pending_updates=True)
|
||||
|
||||
logger.info("✅ Telegram bot is now running and polling for updates")
|
||||
logger.info(f"📱 Bot ready to receive messages at @{(await telegram_app.bot.get_me()).username}")
|
||||
logger.info("🎯 Bot is operational with direct command handlers!")
|
||||
|
||||
# Keep running until interrupted
|
||||
await asyncio.Event().wait()
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("⚠️ Received interrupt signal")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Fatal error: {e}", exc_info=True)
|
||||
raise
|
||||
finally:
|
||||
# Stop Telegram bot gracefully
|
||||
try:
|
||||
if 'telegram_app' in locals():
|
||||
logger.info("Stopping Telegram bot...")
|
||||
await telegram_app.updater.stop()
|
||||
await telegram_app.stop()
|
||||
await telegram_app.shutdown()
|
||||
logger.info("✅ Telegram bot stopped")
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping Telegram bot: {e}")
|
||||
|
||||
await shutdown()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ENTRY POINT
|
||||
# ============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Check required environment variables
|
||||
if not os.getenv('TELEGRAM_BOT_TOKEN'):
|
||||
logger.error("❌ TELEGRAM_BOT_TOKEN is required")
|
||||
logger.error("Please set it in .env file")
|
||||
exit(1)
|
||||
|
||||
# Display startup banner
|
||||
logger.info("=" * 60)
|
||||
logger.info(" ROA2WEB TELEGRAM BOT")
|
||||
logger.info(" Financial ERP Assistant with Direct Commands")
|
||||
logger.info("=" * 60)
|
||||
|
||||
# Run the main application
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
logger.info("👋 Application stopped by user")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Application failed: {e}", exc_info=True)
|
||||
exit(1)
|
||||
Reference in New Issue
Block a user