feat: Migrate to ultrathin monolith architecture

Consolidate 3 separate applications (reports-app, data-entry-app, telegram-bot) into a unified
architecture with single backend and frontend:

Backend Changes:
- Unified FastAPI backend at backend/ with modular structure
- Modules: reports, data_entry, telegram in backend/modules/
- Centralized config.py and main.py with all routers registered
- Single worker mode (--workers 1) for Telegram bot compatibility
- Shared Oracle connection pool and JWT authentication
- Unified requirements.txt and environment configuration

Frontend Changes:
- Single Vue.js SPA with module-based routing
- Unified frontend at src/ with modules in src/modules/{reports,data-entry}/
- Shared components and stores in src/shared/
- Error boundaries for module isolation
- Dual API proxy in Vite for module communication

Infrastructure:
- New unified startup scripts: start-prod.sh, start-test.sh, start-backend.sh
- Environment templates: .env.dev.example, .env.test.example, .env.prod.example
- Updated deployment scripts for Windows IIS
- Simplified SSH tunnel management

Documentation:
- Comprehensive CLAUDE.md with architecture overview
- Module-specific docs in docs/{data-entry,telegram}/
- Architecture decision records in docs/ARCHITECTURE-DECISIONS.md
- Deployment guides consolidated in deployment/windows/docs/

This migration reduces complexity, improves maintainability, and enables easier
deployment while maintaining all existing functionality.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-29 23:48:14 +02:00
parent 2a101f1ef5
commit c5e051ad80
378 changed files with 7566 additions and 73730 deletions

View File

View 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 backend.modules.telegram.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'
]

View File

View File

@@ -0,0 +1,917 @@
"""
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, ConnectError
logger = logging.getLogger(__name__)
# Backend configuration from environment
# Default to port 8000 (production) instead of 8001 (development)
BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:8000")
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 or self.client.is_closed:
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 ConnectError as e:
logger.error(f"Cannot connect to backend at {self.base_url}: {e}")
logger.error("Verify that backend service is running and BACKEND_URL is correct")
return None
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 or self.client.is_closed:
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 verify_email(self, email: str) -> dict:
"""
Verify if email exists in Oracle database
Args:
email: Email address to verify
Returns:
dict with 'success' (bool), 'username' (str or None), and 'message' (str)
Raises:
httpx.HTTPError: On network or HTTP errors
"""
try:
if not self.client or self.client.is_closed:
self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT)
response = await self.client.post(
"/api/telegram/auth/verify-email",
json={"email": email}
)
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as e:
logger.error(f"HTTP error verifying email {email}: {e.response.status_code}")
return {
"success": False,
"username": None,
"message": "Eroare la verificarea email-ului"
}
except Exception as e:
logger.error(f"Failed to verify email {email}: {e}")
return {
"success": False,
"username": None,
"message": "Eroare la verificarea email-ului"
}
async def login_with_email(
self,
email: str,
password: str,
telegram_user_id: int,
session_token: str
) -> dict:
"""
Login via email + password with session token
Args:
email: User email address
password: Oracle password
telegram_user_id: Telegram user ID
session_token: Signed token from code validation
Returns:
Login response with JWT tokens and user data
Raises:
httpx.HTTPError: On network or HTTP errors
"""
try:
if not self.client or self.client.is_closed:
self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT)
response = await self.client.post(
"/api/telegram/auth/login-with-email",
json={
"email": email,
"password": password,
"telegram_user_id": telegram_user_id,
"session_token": session_token
},
timeout=30.0 # 30 seconds timeout
)
response.raise_for_status()
data = response.json()
logger.info(f"Email login successful for user {telegram_user_id}")
return data
except httpx.HTTPStatusError as e:
logger.error(f"Email login HTTP error: {e.response.status_code} - {e.response.text}")
# Parse error detail if available
try:
error_data = e.response.json()
return {
"success": False,
"message": error_data.get("detail", "Autentificare eșuată")
}
except:
return {
"success": False,
"message": "Autentificare eșuată"
}
except httpx.TimeoutException:
logger.error("Email login timeout")
return {
"success": False,
"message": "Timeout. Te rugăm să încerci din nou."
}
except Exception as e:
logger.error(f"Email login error: {e}", exc_info=True)
return {
"success": False,
"message": "Eroare de conexiune"
}
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 or self.client.is_closed:
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.)
Includes _cache_hit and _response_time_ms metadata
"""
try:
if not self.client or self.client.is_closed:
self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT)
# Add cache metadata header for Telegram Bot
headers = self._get_auth_headers(jwt_token)
headers['X-Include-Cache-Metadata'] = 'true'
response = await self.client.get(
"/api/reports/dashboard/summary",
params={"company": str(company_id)},
headers=headers
)
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 or self.client.is_closed:
self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT)
# Add cache metadata header for Telegram Bot
headers = self._get_auth_headers(jwt_token)
headers['X-Include-Cache-Metadata'] = 'true'
response = await self.client.get(
f"/api/reports/dashboard/treasury-breakdown?company={company_id}",
headers=headers
)
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 or self.client.is_closed:
self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT)
# Add cache metadata header for Telegram Bot
headers = self._get_auth_headers(jwt_token)
headers['X-Include-Cache-Metadata'] = 'true'
response = await self.client.get(
f"/api/reports/dashboard/detailed-data?company={company_id}&data_type={data_type}",
headers=headers
)
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 or self.client.is_closed:
self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT)
# Add cache metadata header for Telegram Bot
headers = self._get_auth_headers(jwt_token)
headers['X-Include-Cache-Metadata'] = 'true'
response = await self.client.get(
f"/api/reports/dashboard/maturity?company={company_id}&period={period}",
headers=headers
)
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 or self.client.is_closed:
self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT)
# Add cache metadata header for Telegram Bot
headers = self._get_auth_headers(jwt_token)
headers['X-Include-Cache-Metadata'] = 'true'
response = await self.client.get(
f"/api/reports/dashboard/performance?company={company_id}",
headers=headers
)
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 or self.client.is_closed:
self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT)
# Add cache metadata header for Telegram Bot
headers = self._get_auth_headers(jwt_token)
headers['X-Include-Cache-Metadata'] = 'true'
response = await self.client.get(
f"/api/reports/dashboard/monthly-flows?company={company_id}&months={months}",
headers=headers
)
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
async def get_trends(
self,
company_id: int,
jwt_token: str,
period: str = "12m"
) -> Optional[Dict[str, Any]]:
"""
Get trends data (12-month historical data for collections/payments).
Args:
company_id: Company ID
jwt_token: JWT access token
period: Period for trends (e.g., "12m", "6m", "ytd")
Returns:
Dict with trends data including periods, clienti_incasat, furnizori_achitat arrays
"""
try:
if not self.client or self.client.is_closed:
self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT)
# Add cache metadata header for Telegram Bot
headers = self._get_auth_headers(jwt_token)
headers['X-Include-Cache-Metadata'] = 'true'
response = await self.client.get(
f"/api/reports/dashboard/trends?company={company_id}&period={period}",
headers=headers
)
return await self._handle_response(response)
except Exception as e:
logger.error(f"Failed to get trends 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 or self.client.is_closed:
self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT)
params = {"company": company_id}
if filters:
params.update(filters)
response = await self.client.get(
"/api/reports/invoices/",
params=params,
headers=self._get_auth_headers(jwt_token)
)
data = await self._handle_response(response)
if isinstance(data, dict) and 'invoices' in data:
invoice_list = data['invoices']
return invoice_list
elif isinstance(data, 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 or self.client.is_closed:
self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT)
response = await self.client.get(
"/api/reports/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 or self.client.is_closed:
self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT)
response = await self.client.get(
"/api/reports/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 or self.client.is_closed:
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
# =========================================================================
# CACHE MANAGEMENT
# =========================================================================
async def invalidate_cache(
self,
jwt_token: str,
company_id: Optional[int] = None,
cache_type: Optional[str] = None
) -> bool:
"""
Invalidate cache entries.
Args:
jwt_token: JWT access token
company_id: Optional company ID (None = all companies)
cache_type: Optional cache type (None = all types)
Returns:
bool: True if successful
"""
try:
if not self.client or self.client.is_closed:
self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT)
request_data = {}
if company_id is not None:
request_data['company_id'] = company_id
if cache_type is not None:
request_data['cache_type'] = cache_type
response = await self.client.post(
"/api/reports/cache/invalidate",
json=request_data,
headers=self._get_auth_headers(jwt_token)
)
response.raise_for_status()
logger.info(f"Cache invalidated: company_id={company_id}, cache_type={cache_type}")
return True
except Exception as e:
logger.error(f"Failed to invalidate cache: {e}")
return False
async def toggle_user_cache(
self,
jwt_token: str,
enabled: bool
) -> bool:
"""
Toggle cache for current user.
Args:
jwt_token: JWT access token
enabled: True to enable cache, False to disable
Returns:
bool: True if successful
"""
try:
if not self.client or self.client.is_closed:
self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT)
response = await self.client.post(
"/api/reports/cache/toggle-user",
json={"enabled": enabled},
headers=self._get_auth_headers(jwt_token)
)
response.raise_for_status()
logger.info(f"User cache toggled: enabled={enabled}")
return True
except Exception as e:
logger.error(f"Failed to toggle user cache: {e}")
return False
async def get_cache_stats(
self,
jwt_token: str
) -> Optional[Dict[str, Any]]:
"""
Get cache statistics including user-specific settings.
Args:
jwt_token: JWT access token
Returns:
Dict with cache stats including 'user_enabled' field
"""
try:
if not self.client or self.client.is_closed:
self.client = AsyncClient(base_url=self.base_url, timeout=REQUEST_TIMEOUT)
response = await self.client.get(
"/api/reports/cache/stats",
headers=self._get_auth_headers(jwt_token)
)
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(f"Failed to get cache stats: {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 or self.client.is_closed:
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'
]

View File

@@ -0,0 +1,171 @@
"""
Email authentication logic with crypto-secure code generation
"""
import secrets
import re
import logging
from datetime import datetime, timedelta
from typing import Optional, Dict
from collections import defaultdict
logger = logging.getLogger(__name__)
# ============================================================================
# RATE LIMITING (In-Memory)
# ============================================================================
# NOTE: For production with multiple bot instances, migrate to Redis
# See "Optional Future Enhancements" section in plan
_rate_limit_store: Dict[str, list] = defaultdict(list)
async def check_rate_limit(
identifier: str,
max_attempts: int = 3,
window_minutes: int = 60
) -> bool:
"""
Check if identifier is within rate limit
Args:
identifier: Email or telegram_user_id (as string)
max_attempts: Maximum attempts allowed
window_minutes: Time window in minutes
Returns:
True if within limit (can proceed), False if exceeded
NOTE: In-memory implementation - resets on bot restart
"""
now = datetime.now()
cutoff = now - timedelta(minutes=window_minutes)
# Clean old attempts
_rate_limit_store[identifier] = [
attempt for attempt in _rate_limit_store[identifier]
if attempt > cutoff
]
# Check limit
if len(_rate_limit_store[identifier]) >= max_attempts:
logger.warning(f"Rate limit exceeded for {identifier}")
return False
# Add new attempt
_rate_limit_store[identifier].append(now)
return True
def clear_rate_limit(identifier: str) -> None:
"""Clear rate limit for identifier (e.g., after successful auth)"""
if identifier in _rate_limit_store:
del _rate_limit_store[identifier]
logger.debug(f"Rate limit cleared for {identifier}")
# ============================================================================
# CODE GENERATION (Crypto-Secure)
# ============================================================================
def generate_email_code() -> str:
"""
Generate crypto-secure 6-digit code
Uses secrets module (not random) for cryptographic security
Returns:
6-digit string (000000 - 999999)
"""
# Generate 6-digit code using secrets (crypto-secure)
code = ''.join(secrets.choice('0123456789') for _ in range(6))
logger.debug(f"Generated email auth code (length: {len(code)})")
return code
# ============================================================================
# EMAIL VALIDATION
# ============================================================================
def is_valid_email_format(email: str) -> bool:
"""
Validate email format (basic regex)
Args:
email: Email address to validate
Returns:
True if format is valid
"""
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
return bool(re.match(pattern, email))
async def verify_email_in_oracle(email: str) -> Optional[str]:
"""
Verify email exists in Oracle UTILIZATORI table via backend API
Args:
email: Email address to check
Returns:
Oracle username if found and active, None otherwise
NOTE: Uses backend API endpoint /api/telegram/auth/verify-email
"""
try:
from backend.modules.telegram.api.client import get_backend_client
backend_client = get_backend_client()
# Call backend API to verify email
response = await backend_client.verify_email(email)
if response.get('success'):
username = response.get('username')
logger.info(f"Email verified via backend: {email} -> {username}")
return username
else:
logger.warning(f"Email not found or inactive: {email}")
return None
except Exception as e:
logger.error(f"Error verifying email via backend: {e}", exc_info=True)
return None
# ============================================================================
# SESSION TOKEN GENERATION (Prevent User ID Spoofing)
# ============================================================================
def generate_session_token(telegram_user_id: int, email: str) -> str:
"""
Generate signed session token for backend verification
This prevents user ID spoofing attacks where malicious clients
could impersonate Telegram users by sending arbitrary user IDs
Args:
telegram_user_id: Telegram user ID
email: Verified email address
Returns:
Signed token (simple implementation - upgrade to JWT in future)
NOTE: For production, use proper JWT signing with shared secret
"""
import hashlib
import os
# Get secret from env (should match backend)
secret = os.getenv("AUTH_SESSION_SECRET", "change-me-in-production")
# Create signature: HMAC-like hash
payload = f"{telegram_user_id}:{email}:{secret}"
signature = hashlib.sha256(payload.encode()).hexdigest()[:16]
# Token format: user_id:email:signature
token = f"{telegram_user_id}:{email}:{signature}"
logger.debug(f"Generated session token for user {telegram_user_id}")
return token

View File

@@ -0,0 +1,350 @@
"""
Authentication and User Linking Logic
This module handles the linking process between Telegram users and Oracle ERP accounts.
It manages authentication codes, verifies users through the backend API, and maintains
user sessions with JWT tokens.
"""
import logging
from typing import Optional, Dict, Any
from datetime import datetime, timedelta
from telegram import User as TelegramUser
from backend.modules.telegram.db.operations import (
get_user,
create_or_update_user,
link_user_to_oracle,
update_user_tokens,
verify_and_use_auth_code,
is_user_linked
)
from backend.modules.telegram.api.client import get_backend_client
logger = logging.getLogger(__name__)
async def link_telegram_account(
telegram_user: TelegramUser,
auth_code: str
) -> Optional[Dict[str, Any]]:
"""
Link a Telegram account to an Oracle ERP account using an authentication code.
Flow:
1. Verify auth code in database (check exists, not used, not expired)
2. Extract oracle_username from code
3. Call backend API to verify user in Oracle and get JWT token
4. Create/update Telegram user record
5. Link user to Oracle account with JWT tokens
6. Return success with user data
Args:
telegram_user: Telegram User object from python-telegram-bot
auth_code: 8-character authentication code from web frontend
Returns:
Dict with:
- success: True if linking succeeded
- username: Oracle username
- jwt_token: JWT access token
- companies: List of companies user has access to
OR None if linking failed
Example:
result = await link_telegram_account(telegram_user, "ABC12345")
if result:
print(f"Linked to {result['username']}")
else:
print("Linking failed")
"""
try:
telegram_user_id = telegram_user.id
telegram_username = telegram_user.username
first_name = telegram_user.first_name
last_name = telegram_user.last_name
logger.info(
f"Attempting to link Telegram user {telegram_user_id} "
f"(@{telegram_username}) with code {auth_code}"
)
# Step 1: Verify auth code
code_data = await verify_and_use_auth_code(auth_code)
if not code_data:
logger.warning(f"Invalid, expired, or already used auth code: {auth_code}")
return None
oracle_username = code_data.get('oracle_username')
logger.info(f"Auth code valid for Oracle user: {oracle_username}")
# Step 2: Create/update Telegram user record (basic info)
user_created = await create_or_update_user(
telegram_user_id=telegram_user_id,
username=telegram_username,
first_name=first_name,
last_name=last_name
)
if not user_created:
logger.error(f"Failed to create/update Telegram user {telegram_user_id}")
return None
# Step 3: Verify user in Oracle and get JWT token via backend API (auto-linking flow)
backend_client = get_backend_client()
async with backend_client:
user_data = await backend_client.verify_user(
oracle_username=oracle_username,
linking_code=auth_code
)
if not user_data or not user_data.get('success'):
logger.error(f"Failed to verify Oracle user {oracle_username} via backend")
return None
# Extract tokens and user info from response
jwt_token = user_data.get('access_token')
jwt_refresh_token = user_data.get('refresh_token', jwt_token)
user_info = user_data.get('user', {})
companies = user_info.get('companies', [])
permissions = user_info.get('permissions', [])
# Token expiration (typically 30 minutes for access token)
token_expires_at = datetime.now() + timedelta(minutes=30)
# Step 4: Link Telegram user to Oracle account
linked = await link_user_to_oracle(
telegram_user_id=telegram_user_id,
oracle_username=oracle_username,
jwt_token=jwt_token,
jwt_refresh_token=jwt_refresh_token,
token_expires_at=token_expires_at
)
if not linked:
logger.error(f"Failed to link user {telegram_user_id} to Oracle account")
return None
logger.info(
f"Successfully linked Telegram user {telegram_user_id} "
f"to Oracle user {oracle_username}"
)
return {
"success": True,
"telegram_user_id": telegram_user_id,
"username": oracle_username,
"jwt_token": jwt_token,
"jwt_refresh_token": jwt_refresh_token,
"companies": companies,
"permissions": permissions,
"linked_at": datetime.now().isoformat()
}
except Exception as e:
logger.error(f"Error linking Telegram account: {e}", exc_info=True)
return None
async def get_user_auth_data(telegram_user_id: int) -> Optional[Dict[str, Any]]:
"""
Get authentication data for a linked Telegram user.
This function retrieves the user's Oracle account information and JWT tokens.
If the token is expired, it automatically refreshes it.
Args:
telegram_user_id: Telegram user ID
Returns:
Dict with:
- telegram_user_id: Telegram user ID
- username: Oracle username
- jwt_token: Valid JWT access token (refreshed if needed)
- jwt_refresh_token: JWT refresh token
- companies: List of companies (fetched if not cached)
OR None if user is not linked or error occurred
Example:
auth_data = await get_user_auth_data(12345)
if auth_data:
jwt = auth_data['jwt_token']
# Use JWT for API calls
"""
try:
# Get user from database
user_data = await get_user(telegram_user_id)
if not user_data:
logger.warning(f"User {telegram_user_id} not found in database")
return None
if not user_data.get('oracle_username'):
logger.warning(f"User {telegram_user_id} is not linked to Oracle account")
return None
oracle_username = user_data['oracle_username']
jwt_token = user_data['jwt_token']
jwt_refresh_token = user_data['jwt_refresh_token']
token_expires_at_str = user_data['token_expires_at']
# Parse token expiration
token_expires_at = datetime.fromisoformat(token_expires_at_str) if token_expires_at_str else None
# Check if token is expired or about to expire (< 5 minutes remaining)
token_expired = (
token_expires_at is None or
datetime.now() >= token_expires_at - timedelta(minutes=5)
)
if token_expired:
logger.info(f"Token expired for user {telegram_user_id}, refreshing...")
# Refresh token via backend API
backend_client = get_backend_client()
async with backend_client:
new_token = await backend_client.refresh_token(jwt_refresh_token)
if new_token:
# Update token in database
new_expires_at = datetime.now() + timedelta(minutes=30)
await update_user_tokens(
telegram_user_id=telegram_user_id,
jwt_token=new_token,
jwt_refresh_token=jwt_refresh_token, # Keep same refresh token
token_expires_at=new_expires_at
)
jwt_token = new_token
logger.info(f"Token refreshed for user {telegram_user_id}")
else:
logger.error(f"Failed to refresh token for user {telegram_user_id}")
return None
# Fetch user companies (fresh from backend)
backend_client = get_backend_client()
async with backend_client:
companies = await backend_client.get_user_companies(jwt_token)
return {
"telegram_user_id": telegram_user_id,
"username": oracle_username,
"jwt_token": jwt_token,
"jwt_refresh_token": jwt_refresh_token,
"companies": companies
}
except Exception as e:
logger.error(f"Error getting user auth data: {e}", exc_info=True)
return None
async def check_user_linked(telegram_user_id: int) -> bool:
"""
Check if a Telegram user is linked to an Oracle account.
Args:
telegram_user_id: Telegram user ID
Returns:
bool: True if user is linked, False otherwise
Example:
if await check_user_linked(12345):
print("User is linked")
else:
print("User needs to link account")
"""
try:
return await is_user_linked(telegram_user_id)
except Exception as e:
logger.error(f"Error checking if user is linked: {e}")
return False
async def get_user_companies(telegram_user_id: int) -> Optional[list]:
"""
Get list of companies a user has access to.
This is a convenience function that fetches user auth data and returns
just the companies list.
Args:
telegram_user_id: Telegram user ID
Returns:
List of company dicts, or None if user not linked
Example:
companies = await get_user_companies(12345)
if companies:
for company in companies:
print(f"{company['id']}: {company['nume_firma']}")
"""
try:
auth_data = await get_user_auth_data(telegram_user_id)
if auth_data:
return auth_data.get('companies', [])
return None
except Exception as e:
logger.error(f"Error getting user companies: {e}")
return None
async def unlink_user(telegram_user_id: int) -> bool:
"""
Unlink a Telegram user from their Oracle account.
This removes the linking but keeps the Telegram user record.
Used for account disconnection or security purposes.
Args:
telegram_user_id: Telegram user ID
Returns:
bool: True if successfully unlinked
Example:
if await unlink_user(12345):
print("Account unlinked")
"""
try:
# Set Oracle username and tokens to NULL
from backend.modules.telegram.db.database import DB_PATH
import aiosqlite
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
await db.execute("""
UPDATE telegram_users
SET oracle_username = NULL,
jwt_token = NULL,
jwt_refresh_token = NULL,
token_expires_at = NULL,
linked_at = NULL
WHERE telegram_user_id = ?
""", (telegram_user_id,))
await db.commit()
logger.info(f"User {telegram_user_id} unlinked from Oracle account")
return True
except Exception as e:
logger.error(f"Error unlinking user {telegram_user_id}: {e}")
return False
# Export main functions
__all__ = [
'link_telegram_account',
'get_user_auth_data',
'check_user_linked',
'get_user_companies',
'unlink_user'
]

View File

View File

@@ -0,0 +1,768 @@
"""
Telegram bot handlers for email-based authentication flow
"""
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
from telegram.ext import (
ContextTypes,
ConversationHandler,
CommandHandler,
MessageHandler,
CallbackQueryHandler,
filters
)
import logging
from datetime import datetime
import asyncio
from backend.modules.telegram.auth.email_auth import (
is_valid_email_format,
verify_email_in_oracle,
generate_email_code,
generate_session_token,
check_rate_limit,
clear_rate_limit
)
from backend.modules.telegram.utils.email_service import get_email_service
from backend.modules.telegram.db.operations import (
create_email_auth_code,
get_email_auth_code,
get_pending_email_code,
mark_email_code_used,
increment_failed_attempts,
delete_user_email_codes,
is_user_authenticated,
link_user_to_oracle,
create_or_update_user
)
from backend.modules.telegram.api.client import get_backend_client
logger = logging.getLogger(__name__)
# Conversation states
AWAITING_EMAIL, AWAITING_CODE, AWAITING_PASSWORD = range(3)
# Constants
MAX_CODE_ATTEMPTS = 3
# ============================================================================
# HELPER FUNCTIONS
# ============================================================================
async def edit_login_message(
context: ContextTypes.DEFAULT_TYPE,
chat_id: int,
text: str,
reply_markup=None,
parse_mode="Markdown"
):
"""
Helper function to edit the login message stored in context.
If message_id is not stored, creates a new message instead.
"""
message_id = context.user_data.get('login_message_id')
if message_id:
try:
await context.bot.edit_message_text(
chat_id=chat_id,
message_id=message_id,
text=text,
reply_markup=reply_markup,
parse_mode=parse_mode
)
except Exception as e:
logger.warning(f"Could not edit message {message_id}: {e}")
# Fallback: send new message and update ID
msg = await context.bot.send_message(
chat_id=chat_id,
text=text,
reply_markup=reply_markup,
parse_mode=parse_mode
)
context.user_data['login_message_id'] = msg.message_id
else:
# No message ID stored - create new message
msg = await context.bot.send_message(
chat_id=chat_id,
text=text,
reply_markup=reply_markup,
parse_mode=parse_mode
)
context.user_data['login_message_id'] = msg.message_id
async def delete_login_message(context: ContextTypes.DEFAULT_TYPE, chat_id: int):
"""Delete the login message and clear the message_id from context"""
message_id = context.user_data.get('login_message_id')
if message_id:
try:
await context.bot.delete_message(chat_id=chat_id, message_id=message_id)
except Exception as e:
logger.warning(f"Could not delete message {message_id}: {e}")
# Clear from context
context.user_data.pop('login_message_id', None)
# ============================================================================
# ENTRY POINTS: /login command and action:login button
# ============================================================================
async def login_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""
Handler pentru /login command
Oferă opțiuni de autentificare: Email sau Web App
"""
user = update.effective_user
# Check dacă e deja autentificat
if await is_user_authenticated(user.id):
await update.message.reply_text(
"Ești deja autentificat.\n\n"
"Folosește:\n"
"• /companies - Vezi companiile tale\n"
"• /help - Comenzi disponibile\n"
"• /unlink - Deautentifică-te"
)
return ConversationHandler.END
# Check rate limiting (3 requests per hour)
if not await check_rate_limit(f"login_{user.id}", max_attempts=3, window_minutes=60):
await update.message.reply_text(
"Prea multe încercări de autentificare.\n\n"
"Te rugăm să aștepți 60 de minute înainte de a încerca din nou."
)
return ConversationHandler.END
# Afișează opțiuni de autentificare
keyboard = [
[InlineKeyboardButton("Login cu Email + Parolă", callback_data="email_login")],
[InlineKeyboardButton("Login din Web App", callback_data="web_login_info")],
[InlineKeyboardButton("Anulează", callback_data="cancel")]
]
reply_markup = InlineKeyboardMarkup(keyboard)
# CREATE message and SAVE message_id
msg = await update.message.reply_text(
"Alege metoda de autentificare:",
reply_markup=reply_markup,
parse_mode="Markdown"
)
# Save message ID for future edits
context.user_data['login_message_id'] = msg.message_id
return AWAITING_EMAIL
async def action_login_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""
Handler pentru butonul Login din meniu (action:login)
Oferă opțiuni de autentificare: Email sau Web App
"""
query = update.callback_query
user = update.effective_user
logger.info(f"[EMAIL_AUTH] action_login_callback triggered for user {user.id}")
await query.answer()
# Check dacă e deja autentificat
if await is_user_authenticated(user.id):
await query.edit_message_text(
"Ești deja autentificat.\n\n"
"Folosește:\n"
"• /companies - Vezi companiile tale\n"
"• /help - Comenzi disponibile\n"
"• /unlink - Deautentifică-te"
)
return ConversationHandler.END
# Check rate limiting (3 requests per hour)
if not await check_rate_limit(f"login_{user.id}", max_attempts=3, window_minutes=60):
await query.edit_message_text(
"Prea multe încercări de autentificare.\n\n"
"Te rugăm să aștepți 60 de minute înainte de a încerca din nou."
)
return ConversationHandler.END
# Afișează opțiuni de autentificare
keyboard = [
[InlineKeyboardButton("Login cu Email + Parolă", callback_data="email_login")],
[InlineKeyboardButton("Login din Web App", callback_data="web_login_info")],
[InlineKeyboardButton("Anulează", callback_data="cancel")]
]
reply_markup = InlineKeyboardMarkup(keyboard)
# EDIT existing menu message and SAVE message_id
await query.edit_message_text(
"Alege metoda de autentificare:",
reply_markup=reply_markup,
parse_mode="Markdown"
)
# Save message ID for future edits
context.user_data['login_message_id'] = query.message.message_id
return AWAITING_EMAIL
# ============================================================================
# CALLBACK: Email Login
# ============================================================================
async def email_login_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Callback pentru butonul 'Login cu Email'"""
query = update.callback_query
user = update.effective_user
logger.info(f"[EMAIL_AUTH] email_login_callback triggered for user {user.id}")
await query.answer()
# IMPORTANT: Salvează message_id înainte de a edita
context.user_data['login_message_id'] = query.message.message_id
# EDIT same message - remove buttons, ask for email
await query.edit_message_text(
text="Introdu adresa de email ROA:",
parse_mode="Markdown"
)
return AWAITING_EMAIL
async def web_login_info_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Info despre web app login"""
query = update.callback_query
await query.answer()
await query.edit_message_text(
"**Login din Web App**\n\n"
"Pentru această metodă:\n\n"
"1. Accesează aplicația web ROA2WEB\n"
"2. Autentifică-te cu username + parolă\n"
"3. Apasă butonul \"Link Telegram\"\n"
"4. Copiază codul generat (8 caractere)\n"
"5. Trimite-mi codul: /start ABC123XY\n\n"
"Vei fi autentificat automat.",
parse_mode="Markdown"
)
# IMPORTANT: Salvează message_id pentru ca /start să poată edita același mesaj
context.user_data['web_login_message_id'] = query.message.message_id
return ConversationHandler.END
# ============================================================================
# STATE: AWAITING_EMAIL
# ============================================================================
async def receive_email(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Handler pentru primirea email-ului"""
email = update.message.text.strip().lower()
user_id = update.effective_user.id
# ȘTERG mesajul utilizatorului imediat (chat curat)
try:
await update.message.delete()
except Exception as e:
logger.warning(f"Could not delete email message: {e}")
# Validare format email
if not is_valid_email_format(email):
# Show error in main message
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text="Email invalid\n\nIntrodu o adresă validă (nume@domeniu.ro)",
reply_markup=InlineKeyboardMarkup([
[InlineKeyboardButton("Anulează", callback_data="cancel")]
])
)
return AWAITING_EMAIL
# Check for existing pending code
existing_code = await get_pending_email_code(user_id)
if existing_code:
# Delete old pending code
await delete_user_email_codes(user_id)
logger.info(f"Deleted existing pending code for user {user_id}")
# EDIT login message to show loading
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text="Verificare email...",
reply_markup=None
)
try:
# Verifică email în Oracle
username = await verify_email_in_oracle(email)
# IMPORTANT: Generic response to prevent email enumeration
# We always say "code sent" even if email doesn't exist
if username:
# Email exists - generate and send code
code = generate_email_code()
# Save code in database
code_saved = await create_email_auth_code(
code=code,
email=email,
username=username,
telegram_user_id=user_id,
expiry_minutes=5
)
if not code_saved:
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text="Eroare la salvarea codului.\n\nIncearcă din nou cu /login"
)
return ConversationHandler.END
# Send email (async with retry)
email_service = get_email_service()
email_sent = await email_service.send_auth_code(email, code, username)
if not email_sent:
logger.error(f"Failed to send email to {email}")
# Don't reveal this to user - they'll timeout naturally
# Wait 1 second for better UX (looks like verification happened)
await asyncio.sleep(1)
# ALWAYS show this message (prevent enumeration)
# EDIT same message with success + buttons
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text=f"Cod trimis pe {email}\n\nIntrodu codul primit pe email:",
reply_markup=InlineKeyboardMarkup([
[InlineKeyboardButton("Retrimite Cod", callback_data=f"resend:{email}")],
[InlineKeyboardButton("Anulează", callback_data="cancel")]
])
)
# Save email in context for resend functionality
context.user_data['pending_email'] = email
context.user_data['pending_username'] = username
return AWAITING_CODE
except Exception as e:
logger.error(f"Error in receive_email: {e}", exc_info=True)
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text="Eroare internă.\n\nIncearcă din nou mai târziu."
)
return ConversationHandler.END
# ============================================================================
# STATE: AWAITING_CODE
# ============================================================================
async def receive_code(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Handler pentru primirea codului din email"""
code = update.message.text.strip()
user_id = update.effective_user.id
# ȘTERG mesajul utilizatorului imediat (chat curat)
try:
await update.message.delete()
except Exception as e:
logger.warning(f"Could not delete code message: {e}")
# Validare format cod (6 digits)
if not (code.isdigit() and len(code) == 6):
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text="Cod invalid\n\nIntrodu cele 6 cifre din email.",
reply_markup=InlineKeyboardMarkup([
[InlineKeyboardButton("Retrimite Cod", callback_data=f"resend:{context.user_data.get('pending_email', '')}")],
[InlineKeyboardButton("Anulează", callback_data="cancel")]
])
)
return AWAITING_CODE
# Verifică cod în DB
try:
code_data = await get_email_auth_code(code)
if not code_data:
# EDIT login message to show error
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text="Cod invalid sau expirat\n\nIncearcă din nou cu /login"
)
return ConversationHandler.END
# Verificări de securitate
# 1. Check if already used
if code_data['used']:
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text="Cod deja folosit\n\nIncearcă din nou cu /login"
)
return ConversationHandler.END
# 2. Check if expired
if datetime.now() > code_data['expires_at']:
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text="Cod expirat\n\nIncearcă din nou cu /login"
)
return ConversationHandler.END
# 3. Check if belongs to this user
if code_data['telegram_user_id'] != user_id:
logger.warning(
f"User {user_id} tried to use code belonging to "
f"user {code_data['telegram_user_id']}"
)
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text="Cod invalid"
)
return ConversationHandler.END
# 4. Check failed attempts (max 3)
if code_data['failed_attempts'] >= MAX_CODE_ATTEMPTS:
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text="Prea multe încercări greșite\n\nIncearcă din nou cu /login"
)
return ConversationHandler.END
# Cod valid - Marchează ca folosit
await mark_email_code_used(code)
# Salvează date verificate în context
context.user_data['verified_username'] = code_data['oracle_username']
context.user_data['verified_email'] = code_data['email']
context.user_data['session_token'] = generate_session_token(
user_id,
code_data['email']
)
# EDIT same message - ask for password
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text="Cod validat!\n\nIntroduci parola ROA:",
reply_markup=None # No buttons for security
)
return AWAITING_PASSWORD
except Exception as e:
logger.error(f"Error validating code: {e}", exc_info=True)
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text="Eroare la validarea codului.\n\nIncearcă din nou."
)
return ConversationHandler.END
async def resend_code_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Retrimite codul pe email"""
query = update.callback_query
await query.answer("Retrimitem codul...")
# Extract email from callback data
callback_data = query.data # Format: "resend:email@example.com"
if not callback_data.startswith("resend:"):
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text="Eroare\n\nIncearcă din nou cu /login"
)
return ConversationHandler.END
email = callback_data.split(":", 1)[1]
user_id = update.effective_user.id
# Check rate limiting for resend (max 2 per 10 minutes)
if not await check_rate_limit(f"resend_{user_id}", max_attempts=2, window_minutes=10):
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text="Prea multe solicitări\n\nAșteaptă 10 minute."
)
return ConversationHandler.END
# Get username from context or re-verify
username = context.user_data.get('pending_username')
if not username:
username = await verify_email_in_oracle(email)
if not username:
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text="Eroare\n\nIncearcă din nou cu /login"
)
return ConversationHandler.END
# Delete old code and generate new one
await delete_user_email_codes(user_id)
code = generate_email_code()
# Save new code
await create_email_auth_code(
code=code,
email=email,
username=username,
telegram_user_id=user_id,
expiry_minutes=5
)
# Send email
email_service = get_email_service()
await email_service.send_auth_code(email, code, username)
# FIX BUG: EDIT message and KEEP buttons!
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text=f"Cod retrimis pe {email}\n\nIntrodu codul primit pe email:",
reply_markup=InlineKeyboardMarkup([
[InlineKeyboardButton("Retrimite Cod", callback_data=f"resend:{email}")],
[InlineKeyboardButton("Anulează", callback_data="cancel")]
])
)
return AWAITING_CODE
# ============================================================================
# STATE: AWAITING_PASSWORD
# ============================================================================
async def receive_password(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Handler pentru primirea parolei Oracle"""
password = update.message.text.strip()
user_id = update.effective_user.id
# Șterge IMEDIAT mesajul cu parola (securitate)
try:
await update.message.delete()
logger.info(f"Password message deleted for user {user_id}")
except Exception as e:
logger.warning(f"Could not delete password message: {e}")
# Get verified data from context
username = context.user_data.get('verified_username')
email = context.user_data.get('verified_email')
session_token = context.user_data.get('session_token')
if not all([username, email, session_token]):
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text="Sesiune expirată\n\nIncearcă din nou cu /login"
)
return ConversationHandler.END
# EDIT login message to show loading
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text="Verificare...",
reply_markup=None
)
try:
# Call backend endpoint pentru verificare parolă + JWT
backend_client = get_backend_client()
response = await backend_client.login_with_email(
email=email,
password=password,
telegram_user_id=user_id,
session_token=session_token
)
if not response.get('success'):
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text="Credențiale invalide\n\nParolă incorectă sau cont inactiv.\n\nIncearcă din nou cu /login"
)
return ConversationHandler.END
# Success - Salvează user în telegram_users
# First create or update user record
await create_or_update_user(
telegram_user_id=user_id,
username=update.effective_user.username,
first_name=update.effective_user.first_name,
last_name=update.effective_user.last_name
)
# Then link to Oracle
from datetime import datetime, timedelta
token_expires_at = datetime.now() + timedelta(minutes=30) # Default expiry
await link_user_to_oracle(
telegram_user_id=user_id,
oracle_username=response['username'],
jwt_token=response['access_token'],
jwt_refresh_token=response['refresh_token'],
token_expires_at=token_expires_at
)
# Clear rate limits on successful auth
clear_rate_limit(f"login_{user_id}")
clear_rate_limit(f"resend_{user_id}")
# Get session and active company BEFORE editing message
from backend.modules.telegram.agent.session import get_session_manager
from backend.modules.telegram.bot.menus import create_main_menu, pad_message_for_wide_buttons
session_manager = get_session_manager()
session = await session_manager.get_or_create_session(user_id)
company = session.get_active_company()
company_name = company['name'] if company else None
company_cui = company.get('cui') if company else None
# Create main menu keyboard
keyboard = create_main_menu(
company_name=company_name,
company_cui=company_cui,
is_authenticated=True, # Now authenticated
cache_enabled=True # Default enabled
)
# Menu message with company info
companies_count = len(response.get('companies', []))
if company_name:
menu_text = f"{company_name}"
else:
menu_text = f"Companii disponibile: {companies_count}\n\nSelectează o companie pentru a continua"
menu_message = pad_message_for_wide_buttons(menu_text)
# EDIT login message to show menu (no deletion, direct edit)
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text=menu_message,
reply_markup=keyboard
)
# Clear sensitive data from context
context.user_data.clear()
logger.info(f"User {user_id} authenticated successfully via email")
return ConversationHandler.END
except Exception as e:
logger.error(f"Error during password verification: {e}", exc_info=True)
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text="Eroare la autentificare.\n\nIncearcă din nou cu /login"
)
return ConversationHandler.END
# ============================================================================
# CANCEL HANDLER
# ============================================================================
async def cancel_login(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Cancel conversation"""
# EDIT login message to show cancellation (don't delete)
if update.callback_query:
# Called from button
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text="Login anulat",
reply_markup=None
)
elif update.message:
# Called from /cancel command
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text="Login anulat",
reply_markup=None
)
# Clear context
context.user_data.clear()
return ConversationHandler.END
async def conversation_timeout(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Handler for conversation timeout"""
# EDIT login message to show timeout
await edit_login_message(
context=context,
chat_id=update.effective_chat.id,
text="Sesiune expirată\n\nConversația a expirat după 5 minute.\n\nIncearcă din nou cu /login"
)
# Clear context
context.user_data.clear()
return ConversationHandler.END
# ============================================================================
# CONVERSATION HANDLER SETUP
# ============================================================================
email_login_handler = ConversationHandler(
entry_points=[
CommandHandler('login', login_command),
CallbackQueryHandler(action_login_callback, pattern='^action:login$'),
CallbackQueryHandler(email_login_callback, pattern='^email_login$')
],
states={
AWAITING_EMAIL: [
MessageHandler(filters.TEXT & ~filters.COMMAND, receive_email)
],
AWAITING_CODE: [
MessageHandler(filters.TEXT & ~filters.COMMAND, receive_code),
CallbackQueryHandler(resend_code_callback, pattern='^resend:')
],
AWAITING_PASSWORD: [
MessageHandler(filters.TEXT & ~filters.COMMAND, receive_password)
],
},
fallbacks=[
CommandHandler('cancel', cancel_login),
CallbackQueryHandler(cancel_login, pattern='^cancel$'),
CallbackQueryHandler(web_login_info_callback, pattern='^web_login_info$')
],
per_message=False, # Track conversation per user, not per message
allow_reentry=True, # Allow starting new conversation even if previous one is active
name="email_login_conversation"
)

View File

@@ -0,0 +1,629 @@
"""
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"
# Solduri TVA - rotunjit la leu
tva_plata_prec = round(float(data.get('tva_plata_precedent', 0)))
tva_recup_prec = round(float(data.get('tva_recuperat_precedent', 0)))
tva_plata_cur = round(float(data.get('tva_plata_curent', 0)))
tva_recup_cur = round(float(data.get('tva_recuperat_curent', 0)))
# Afișează secțiunea doar dacă există cel puțin o valoare > 0
if tva_plata_prec > 0 or tva_recup_prec > 0 or tva_plata_cur > 0 or tva_recup_cur > 0:
text += "\n\n**Solduri TVA:**\n"
if tva_plata_prec > 0:
text += f" - TVA de plată precedent: {tva_plata_prec:,} RON\n"
if tva_recup_prec > 0:
text += f" - TVA de recuperat precedent: {tva_recup_prec:,} RON\n"
if tva_plata_cur > 0:
text += f" - TVA de plată curent: {tva_plata_cur:,} RON\n"
if tva_recup_cur > 0:
text += f" - TVA de recuperat curent: {tva_recup_cur:,} RON\n"
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: # Show all accounts
name = acc.get('name', 'N/A')
balance = round(acc.get('balance', 0))
text += f" - {name}: {balance:,} RON\n"
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: # Show all accounts
name = acc.get('name', 'N/A')
balance = round(acc.get('balance', 0))
text += f" - {name}: {balance:,} RON\n"
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 10 clients
if clients:
text += f"**Top 10 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[:10], 1):
name = client.get('name', 'N/A')
balance = round(client.get('balance', 0))
text += f"{idx}. {name}: {balance:,} RON\n"
if len(clients) > 10:
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 10 suppliers
if suppliers:
text += f"**Top 10 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[:10], 1):
name = supplier.get('name', 'N/A')
balance = round(supplier.get('balance', 0))
text += f"{idx}. {name}: {balance:,} RON\n"
if len(suppliers) > 10:
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 - Table format with mini-charts.
Args:
performance_data: Dict with current_year and previous_year YTD data
monthly_data: Dict with months, incasari, plati arrays + prev year data
company_name: Company name (kept for compatibility, not used)
Returns:
Formatted Markdown string for Telegram (monospace table)
Example:
YTD 2024 vs 2023:
2024 2023 Δ Trend
Inc: 500,000 480,000 +4.2% ████░
Plt: 450,000 440,000 +2.3% ███░
Net: 50,000 40,000 +25.0% █████
"""
text = ""
# Helper functions
def calc_percent_change(current: float, previous: float) -> str:
"""Calculate percentage change: +4.2% or -3.5%"""
if previous == 0:
return "+100%" if current > 0 else "0.0%"
change = ((current - previous) / previous) * 100
sign = "+" if change >= 0 else ""
return f"{sign}{change:.1f}%"
def create_mini_chart(current: float, previous: float, width: int = 5) -> str:
"""Create mini bar chart: ████░ (proportional bars)"""
if current == 0 and previous == 0:
return "" * width
max_val = max(current, previous)
if max_val == 0:
return "" * width
curr_bars = int((current / max_val) * width)
prev_bars = int((previous / max_val) * width)
# Use filled and light blocks
filled = "" * curr_bars
light = "" * (width - curr_bars)
return filled + light
def get_trend_arrow(current: float, previous: float) -> str:
"""Get trend arrow: ↑ or ↓ or →"""
if current > previous * 1.02: # More than 2% increase
return ""
elif current < previous * 0.98: # More than 2% decrease
return ""
else:
return ""
# Extract YTD data
current = performance_data.get('current_year', {})
previous = performance_data.get('previous_year', {})
current_year = current.get('year', '2024')
previous_year = previous.get('year', '2023')
inc_cur = round(current.get('incasari', 0))
plt_cur = round(current.get('plati', 0))
net_cur = round(current.get('net', 0))
inc_prev = round(previous.get('incasari', 0))
plt_prev = round(previous.get('plati', 0))
net_prev = round(previous.get('net', 0))
# YTD Table Header
text += f"**YTD {current_year} vs {previous_year}:**\n"
text += f"` {current_year:>10} {previous_year:>10} Δ `\n"
# YTD Rows
inc_pct = calc_percent_change(inc_cur, inc_prev)
text += f"`Inc: {inc_cur:>10,} {inc_prev:>10,} {inc_pct:>6}`\n"
plt_pct = calc_percent_change(plt_cur, plt_prev)
text += f"`Plt: {plt_cur:>10,} {plt_prev:>10,} {plt_pct:>6}`\n"
net_pct = calc_percent_change(net_cur, net_prev)
text += f"`Net: {net_cur:>10,} {net_prev:>10,} {net_pct:>6}`\n\n"
# Monthly Evolution Table - Simplified (Net only)
months = monthly_data.get('months', [])
incasari = monthly_data.get('incasari', [])
plati = monthly_data.get('plati', [])
incasari_prev = monthly_data.get('incasari_prev', [])
plati_prev = monthly_data.get('plati_prev', [])
if months and len(months) > 0:
text += "**Evolutie Net (12 luni):**\n"
text += f"` {current_year:>10} {previous_year:>10} Δ `\n"
for i, month in enumerate(months):
inc = incasari[i] if i < len(incasari) else 0
plt = plati[i] if i < len(plati) else 0
inc_p = incasari_prev[i] if i < len(incasari_prev) else 0
plt_p = plati_prev[i] if i < len(plati_prev) else 0
net = inc - plt
net_p = inc_p - plt_p
# Extract short month name (first 3 chars before apostrophe)
month_short = month.split("'")[0][:3] if "'" in month else month[:3]
# Calculate percentage change
net_pct = calc_percent_change(net, net_p)
# Format row: Luna Net'current Net'prev Δ (aligned with YTD)
text += f"`{month_short:<4} {int(net):>10,} {int(net_p):>10,} {net_pct:>6}`\n"
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
# =========================================================================
# FAZA 6: Performance Footer for Cache Monitoring
# =========================================================================
def add_performance_footer(message: str, cache_hit: bool, time_ms: float, cache_source: str = None) -> str:
"""
Add compact performance footer to bot responses.
Shows data source (cached L1/L2 or database) and response time.
Format: "cached L1 | 15ms", "cached L2 | 25ms" or "db | 285ms"
Args:
message: Existing message text
cache_hit: True if data came from cache
time_ms: Response time in milliseconds
cache_source: Cache source ("L1" for memory, "L2" for SQLite) if cache_hit is True
Returns:
Message with performance footer appended
Example:
>>> add_performance_footer("Dashboard data...", True, 52.3, "L1")
"Dashboard data...\n\ncached L1 | 52ms"
>>> add_performance_footer("Dashboard data...", True, 25.8, "L2")
"Dashboard data...\n\ncached L2 | 26ms"
>>> add_performance_footer("Dashboard data...", False, 285.7)
"Dashboard data...\n\ndb | 286ms"
"""
if cache_hit and cache_source:
source = f"cached {cache_source}"
elif cache_hit:
source = "cached" # Fallback if source not provided
else:
source = "db"
footer = f"\n\n`{source} | {time_ms:.0f}ms`"
return message + footer

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,814 @@
"""
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 backend.modules.telegram.api.client import get_backend_client
from backend.modules.telegram.agent.session import SessionManager
from backend.modules.telegram.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 backend.modules.telegram.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', [])
]
result = {
'casa': {
'accounts': casa_accounts,
'total': float(casa_data.get('total', 0))
},
'banca': {
'accounts': banca_accounts,
'total': float(banca_data.get('total', 0))
}
}
# Pass through cache metadata if present
if 'cache_hit' in breakdown:
result['cache_hit'] = breakdown['cache_hit']
if 'response_time_ms' in breakdown:
result['response_time_ms'] = breakdown['response_time_ms']
if 'cache_source' in breakdown:
result['cache_source'] = breakdown['cache_source']
return result
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
result = {
'clients': clients,
'maturity': {
'in_term': in_term,
'overdue': overdue,
'total': total
}
}
# Pass through cache metadata if present
if 'cache_hit' in maturity_response:
result['cache_hit'] = maturity_response['cache_hit']
if 'response_time_ms' in maturity_response:
result['response_time_ms'] = maturity_response['response_time_ms']
if 'cache_source' in maturity_response:
result['cache_source'] = maturity_response['cache_source']
return result
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
result = {
'suppliers': suppliers,
'maturity': {
'in_term': in_term,
'overdue': overdue,
'total': total
}
}
# Pass through cache metadata if present
if 'cache_hit' in maturity_response:
result['cache_hit'] = maturity_response['cache_hit']
if 'response_time_ms' in maturity_response:
result['response_time_ms'] = maturity_response['response_time_ms']
if 'cache_source' in maturity_response:
result['cache_source'] = maturity_response['cache_source']
return result
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 with YTD comparison.
Uses trends endpoint which returns 12-month historical data for current and previous year.
Calculates YTD for comparison and extracts last 12 months in reverse chronological order.
Args:
company_id: Company ID
jwt_token: JWT authentication token
period: Period for trends data (default: "12m")
Returns:
Dict with:
- 'performance': Dict with YTD data for current and previous year
- 'monthly': Dict with last 12 months data (reverse chronological) + prev year comparison
None if request fails
Example:
data = await get_cashflow_evolution_data(1, token)
ytd_2025 = data['performance']['current_year']
ytd_2024 = data['performance']['previous_year']
"""
try:
client = get_backend_client()
async with client:
# Get trends data (12 months of historical data)
trends_data = await client.get_trends(
company_id=company_id,
jwt_token=jwt_token,
period="12m"
)
if not trends_data:
return None
# Extract current year data
periods = trends_data.get('periods', []) # ["2024-01", "2024-02", ...]
clienti_incasat = trends_data.get('clienti_incasat', [])
furnizori_achitat = trends_data.get('furnizori_achitat', [])
# Extract previous year data
previous_periods = trends_data.get('previous_periods', [])
clienti_incasat_prev = trends_data.get('clienti_incasat_prev', [])
furnizori_achitat_prev = trends_data.get('furnizori_achitat_prev', [])
if not periods or not clienti_incasat or not furnizori_achitat:
logger.warning("Trends data missing required fields")
return None
# Calculate YTD (Year-To-Date) = sum of all available months
incasari_ytd = sum(clienti_incasat)
plati_ytd = sum(furnizori_achitat)
net_ytd = incasari_ytd - plati_ytd
incasari_ytd_prev = sum(clienti_incasat_prev) if clienti_incasat_prev else 0
plati_ytd_prev = sum(furnizori_achitat_prev) if furnizori_achitat_prev else 0
net_ytd_prev = incasari_ytd_prev - plati_ytd_prev
# Extract years from periods
current_year = periods[-1].split('-')[0] if periods else "2025"
previous_year = previous_periods[-1].split('-')[0] if previous_periods else "2024"
# Take last 12 months (current year)
last_12_periods = periods[-12:]
last_12_incasari = clienti_incasat[-12:]
last_12_plati = furnizori_achitat[-12:]
# Take corresponding previous year months
last_12_periods_prev = previous_periods[-12:] if previous_periods else []
last_12_incasari_prev = clienti_incasat_prev[-12:] if clienti_incasat_prev else [0] * 12
last_12_plati_prev = furnizori_achitat_prev[-12:] if furnizori_achitat_prev else [0] * 12
# Month abbreviations (Romanian)
month_abbr = {
'01': 'Ian', '02': 'Feb', '03': 'Mar', '04': 'Apr',
'05': 'Mai', '06': 'Iun', '07': 'Iul', '08': 'Aug',
'09': 'Sep', '10': 'Oct', '11': 'Noi', '12': 'Dec'
}
# Format months as "Noi'25/'24"
formatted_months = []
for i, period_str in enumerate(last_12_periods):
if '-' in period_str:
year = period_str.split('-')[0][-2:] # Last 2 digits: "25"
month_num = period_str.split('-')[1]
month_name = month_abbr.get(month_num, month_num)
# Get previous year month
prev_year = previous_year[-2:] if previous_year else "24"
formatted_months.append(f"{month_name}'{year}/'{prev_year}")
else:
formatted_months.append(period_str)
# Reverse chronological order (newest first)
formatted_months.reverse()
last_12_incasari.reverse()
last_12_plati.reverse()
last_12_incasari_prev.reverse()
last_12_plati_prev.reverse()
# Build performance summary (YTD)
performance = {
'current_year': {
'year': current_year,
'incasari': incasari_ytd,
'plati': plati_ytd,
'net': net_ytd
},
'previous_year': {
'year': previous_year,
'incasari': incasari_ytd_prev,
'plati': plati_ytd_prev,
'net': net_ytd_prev
}
}
# Build monthly breakdown (reverse chronological with prev year comparison)
monthly = {
'months': formatted_months,
'incasari': last_12_incasari,
'plati': last_12_plati,
'incasari_prev': last_12_incasari_prev,
'plati_prev': last_12_plati_prev
}
result = {
'performance': performance,
'monthly': monthly
}
# Pass through cache metadata if present
if 'cache_hit' in trends_data:
result['cache_hit'] = trends_data['cache_hit']
if 'response_time_ms' in trends_data:
result['response_time_ms'] = trends_data['response_time_ms']
if 'cache_source' in trends_data:
result['cache_source'] = trends_data['cache_source']
return result
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'
]

View File

@@ -0,0 +1,607 @@
"""
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,
is_authenticated: bool = True,
cache_enabled: Optional[bool] = 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
is_authenticated: Whether user is authenticated (affects Login/Logout button)
cache_enabled: Cache state for user (True=ON, False=OFF, None=unknown)
Returns:
InlineKeyboardMarkup with main menu buttons
"""
keyboard = []
# Only show financial menu if authenticated
if is_authenticated:
# 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: Cache options (2 buttons per row, only if authenticated)
if is_authenticated:
# Dynamic cache toggle button showing current state
if cache_enabled is None:
cache_button_text = "Toggle Cache"
elif cache_enabled:
cache_button_text = "Cache: ON"
else:
cache_button_text = "Cache: OFF"
keyboard.append([
InlineKeyboardButton(cache_button_text, callback_data="menu:togglecache"),
InlineKeyboardButton("Clear Cache", callback_data="menu:clearcache")
])
# Row 6: Help/Logout buttons (authenticated) or Login button (non-authenticated)
if is_authenticated:
keyboard.append([
InlineKeyboardButton("Help", callback_data="action:help"),
InlineKeyboardButton("Logout", callback_data="action:logout")
])
else:
keyboard.append([
InlineKeyboardButton("Login", callback_data="action:login")
])
return InlineKeyboardMarkup(keyboard)
def create_action_buttons(current_view: str, show_export: bool = True, show_back: bool = False, show_refresh: bool = True) -> InlineKeyboardMarkup:
"""
Create action buttons for responses (Refresh, Export, Back, Menu).
Layout (buttons made wide by message text padding):
[Refresh] [Export] (if show_refresh=True and show_export=True)
[Refresh] (if show_refresh=True and show_export=False)
[Înapoi] (if show_back=True, full width)
[Menu] (full width, always shown)
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
show_refresh: Whether to show Refresh button
Returns:
InlineKeyboardMarkup with action buttons
"""
keyboard = []
# Row 1: Refresh and optionally Export (only if show_refresh is True)
if show_refresh:
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 = []
# Sort clients alphabetically by name
sorted_clients = sorted(clients, key=lambda x: x.get('name', '').lower())
# Calculate pagination
total_clients = len(sorted_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 = sorted_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"
# Limit callback_data to 64 bytes (Telegram limit)
# Use only first 40 chars of name to stay within limit
safe_name = client_name[:40] if len(client_name) > 40 else client_name
keyboard.append([
InlineKeyboardButton(
button_text,
callback_data=f"details:client:{safe_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 button only
keyboard.append([
InlineKeyboardButton("< Înapoi", callback_data="action:menu")
])
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 = []
# Sort suppliers alphabetically by name
sorted_suppliers = sorted(suppliers, key=lambda x: x.get('name', '').lower())
# Calculate pagination
total_suppliers = len(sorted_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 = sorted_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"
# Limit callback_data to 64 bytes (Telegram limit)
# Use only first 40 chars of name to stay within limit
safe_name = supplier_name[:40] if len(supplier_name) > 40 else supplier_name
keyboard.append([
InlineKeyboardButton(
button_text,
callback_data=f"details:supplier:{safe_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 button only
keyboard.append([
InlineKeyboardButton("< Înapoi", callback_data="action:menu")
])
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 = []
# Limit partner_name to 30 chars for Telegram callback_data limit (64 bytes)
safe_partner_name = partner_name[:30] if len(partner_name) > 30 else partner_name
# 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}:{safe_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}:{safe_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)

View File

@@ -0,0 +1,316 @@
"""
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
# ============================================================================
# LOAD ENVIRONMENT VARIABLES FIRST - BEFORE ANY APP IMPORTS
# ============================================================================
# This ensures all modules can access environment variables at import time
env_path = Path(__file__).parent.parent / '.env'
load_dotenv(env_path)
# Telegram imports
from telegram.ext import (
Application,
CommandHandler,
CallbackQueryHandler,
MessageHandler,
filters
)
# Import database initialization
from backend.modules.telegram.db import (
init_database,
cleanup_expired_codes,
cleanup_expired_sessions,
cleanup_expired_email_codes
)
# Import bot handlers
from backend.modules.telegram.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,
# FAZA 6: Cache management commands
clearcache_command,
togglecache_command,
# Text message handlers
handle_text_message,
# FAZA 4: Callback and error handlers
button_callback,
error_handler
)
# Import email authentication handler
from backend.modules.telegram.bot.email_handlers import email_login_handler
# Import internal API
from backend.modules.telegram.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__)
# Environment variables (already loaded above)
TELEGRAM_BOT_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN')
BACKEND_URL = os.getenv('BACKEND_URL', 'http://localhost:8000')
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 email authentication conversation handler (must be before other handlers)
application.add_handler(email_login_handler)
# 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))
# FAZA 6: Cache management commands
application.add_handler(CommandHandler("clearcache", clearcache_command))
application.add_handler(CommandHandler("togglecache", togglecache_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()
expired_email_codes = await cleanup_expired_email_codes()
logger.info(f"✅ Cleanup complete: {expired_codes} codes, {expired_sessions} sessions, {expired_email_codes} email codes 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, sessions, and email codes.
"""
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()
expired_email_codes = await cleanup_expired_email_codes()
logger.info(f"✅ Scheduled cleanup: {expired_codes} codes, {expired_sessions} sessions, {expired_email_codes} email codes 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)

View File

@@ -0,0 +1,86 @@
"""
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,
cleanup_expired_email_codes,
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,
is_user_authenticated,
# Auth code operations
create_auth_code,
get_auth_code,
verify_and_use_auth_code,
get_pending_codes_for_user,
# Email auth code operations
get_pending_email_code,
create_email_auth_code,
get_email_auth_code,
increment_failed_attempts,
mark_email_code_used,
delete_user_email_codes,
# 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',
'cleanup_expired_email_codes',
'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',
'is_user_authenticated',
# Auth code operations
'create_auth_code',
'get_auth_code',
'verify_and_use_auth_code',
'get_pending_codes_for_user',
# Email auth code operations
'get_pending_email_code',
'create_email_auth_code',
'get_email_auth_code',
'increment_failed_attempts',
'mark_email_code_used',
'delete_user_email_codes',
# Session operations
'create_session',
'get_session',
'get_user_active_session',
'update_session_state',
'delete_session',
'delete_user_sessions',
]

View File

@@ -0,0 +1,310 @@
"""
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 email_auth_codes table (email-based authentication)
await db.execute("""
CREATE TABLE IF NOT EXISTS email_auth_codes (
code TEXT PRIMARY KEY,
email TEXT NOT NULL,
oracle_username TEXT NOT NULL,
telegram_user_id INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NOT NULL,
used INTEGER DEFAULT 0,
used_at TIMESTAMP,
failed_attempts INTEGER DEFAULT 0,
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)
""")
# Create indexes for email_auth_codes table
await db.execute("""
CREATE INDEX IF NOT EXISTS idx_email_auth_email
ON email_auth_codes(email)
""")
await db.execute("""
CREATE INDEX IF NOT EXISTS idx_email_auth_telegram_user
ON email_auth_codes(telegram_user_id)
""")
await db.execute("""
CREATE INDEX IF NOT EXISTS idx_email_auth_expires
ON email_auth_codes(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 cleanup_expired_email_codes() -> int:
"""
Delete expired and old used email codes from the database.
This should be called periodically (e.g., hourly).
Returns:
int: Number of email codes deleted
"""
try:
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
# Delete expired codes or used codes older than 1 day
cursor = await db.execute("""
DELETE FROM email_auth_codes
WHERE expires_at < ?
OR (used = 1 AND used_at < ?)
""", (
datetime.now(),
datetime.now() - timedelta(days=1)
))
await db.commit()
deleted = cursor.rowcount
if deleted > 0:
logger.info(f"Cleaned up {deleted} expired/old email auth codes")
return deleted
except Exception as e:
logger.error(f"Failed to cleanup email codes: {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',
'cleanup_expired_email_codes',
'get_database_stats',
'DB_PATH',
]

View File

@@ -0,0 +1,813 @@
"""
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
async def is_user_authenticated(telegram_user_id: int) -> bool:
"""
Check if a user is authenticated (linked and has valid token).
Args:
telegram_user_id: Telegram user ID
Returns:
bool: True if user is authenticated
"""
try:
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute("""
SELECT oracle_username, jwt_token, token_expires_at
FROM telegram_users
WHERE telegram_user_id = ?
AND oracle_username IS NOT NULL
AND jwt_token IS NOT NULL
""", (telegram_user_id,))
row = await cursor.fetchone()
if not row:
return False
# Check if token is expired (with some buffer)
if row[2]: # token_expires_at
expires_at = datetime.fromisoformat(row[2])
# Token should have at least 5 minutes remaining
if expires_at < datetime.now() + timedelta(minutes=5):
return False
return True
except Exception as e:
logger.error(f"Failed to check if user {telegram_user_id} is authenticated: {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 []
# ============================================================================
# EMAIL AUTHENTICATION CODES OPERATIONS
# ============================================================================
async def get_pending_email_code(
telegram_user_id: int
) -> Optional[Dict]:
"""
Get pending (non-expired, non-used) email code for user
Returns:
Code data dict or None if no pending code
"""
try:
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute("""
SELECT code, email, oracle_username, expires_at, failed_attempts
FROM email_auth_codes
WHERE telegram_user_id = ?
AND used = 0
AND expires_at > ?
ORDER BY created_at DESC
LIMIT 1
""", (telegram_user_id, datetime.now()))
row = await cursor.fetchone()
if row:
return {
'code': row[0],
'email': row[1],
'oracle_username': row[2],
'expires_at': datetime.fromisoformat(row[3]),
'failed_attempts': row[4]
}
return None
except Exception as e:
logger.error(f"Failed to get pending email code: {e}")
return None
async def create_email_auth_code(
code: str,
email: str,
username: str,
telegram_user_id: int,
expiry_minutes: int = 5
) -> bool:
"""
Create new email authentication code
NOTE: Caller should check for existing pending codes first
"""
expires_at = datetime.now() + timedelta(minutes=expiry_minutes)
try:
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
await db.execute("""
INSERT INTO email_auth_codes
(code, email, oracle_username, telegram_user_id, expires_at)
VALUES (?, ?, ?, ?, ?)
""", (code, email, username, telegram_user_id, expires_at))
await db.commit()
logger.info(
f"Email auth code created for user {telegram_user_id}, "
f"expires at {expires_at.isoformat()}"
)
return True
except Exception as e:
logger.error(f"Error creating email auth code: {e}", exc_info=True)
return False
async def get_email_auth_code(code: str) -> Optional[Dict]:
"""Get email auth code details"""
try:
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute("""
SELECT code, email, oracle_username, telegram_user_id,
created_at, expires_at, used, used_at, failed_attempts
FROM email_auth_codes
WHERE code = ?
""", (code,))
row = await cursor.fetchone()
if not row:
return None
return {
'code': row[0],
'email': row[1],
'oracle_username': row[2],
'telegram_user_id': row[3],
'created_at': datetime.fromisoformat(row[4]),
'expires_at': datetime.fromisoformat(row[5]),
'used': bool(row[6]),
'used_at': datetime.fromisoformat(row[7]) if row[7] else None,
'failed_attempts': row[8]
}
except Exception as e:
logger.error(f"Failed to get email auth code: {e}")
return None
async def increment_failed_attempts(code: str) -> bool:
"""Increment failed validation attempts for code"""
try:
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
await db.execute("""
UPDATE email_auth_codes
SET failed_attempts = failed_attempts + 1
WHERE code = ?
""", (code,))
await db.commit()
return True
except Exception as e:
logger.error(f"Error incrementing failed attempts: {e}")
return False
async def mark_email_code_used(code: str) -> bool:
"""Mark email code as used"""
try:
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
await db.execute("""
UPDATE email_auth_codes
SET used = 1, used_at = ?
WHERE code = ?
""", (datetime.now(), code))
await db.commit()
logger.info(f"Email auth code marked as used: {code}")
return True
except Exception as e:
logger.error(f"Error marking email code as used: {e}")
return False
async def delete_user_email_codes(telegram_user_id: int) -> int:
"""Delete all email codes for user (cleanup)"""
try:
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute("""
DELETE FROM email_auth_codes
WHERE telegram_user_id = ?
""", (telegram_user_id,))
await db.commit()
deleted = cursor.rowcount
logger.info(f"Deleted {deleted} email codes for user {telegram_user_id}")
return deleted
except Exception as e:
logger.error(f"Error deleting user email codes: {e}")
return 0
# ============================================================================
# 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',
'is_user_authenticated',
# Auth code operations
'create_auth_code',
'get_auth_code',
'verify_and_use_auth_code',
'get_pending_codes_for_user',
# Email auth code operations
'get_pending_email_code',
'create_email_auth_code',
'get_email_auth_code',
'increment_failed_attempts',
'mark_email_code_used',
'delete_user_email_codes',
# Session operations
'create_session',
'get_session',
'get_user_active_session',
'update_session_state',
'delete_session',
'delete_user_sessions',
]

View File

@@ -0,0 +1,32 @@
"""Telegram module router factory."""
from fastapi import APIRouter
def create_telegram_router() -> APIRouter:
"""
Create and configure Telegram module router.
Includes all Telegram bot internal API endpoints:
- /auth/verify-user - Verify Telegram user authentication
- /auth/generate-code - Generate auth code for linking
- /auth/verify-code - Verify auth code
- /stats - Bot database statistics
Returns:
APIRouter: Configured router for Telegram module
"""
router = APIRouter()
# Import routers here to avoid circular imports
from .auth_codes import router as auth_codes_router
from .internal_api import internal_api as internal_api_router
# Include all sub-routers (no prefix - already prefixed in main.py with /api/telegram)
# Auth codes router provides /auth/* endpoints
router.include_router(auth_codes_router, tags=["telegram-auth"])
# Internal API router provides additional endpoints like /stats
router.include_router(internal_api_router, tags=["telegram-internal"])
return router

View File

@@ -0,0 +1,840 @@
"""
API Router pentru Telegram Bot Integration
Furnizează endpoint-uri pentru autentificare, linking și export rapoarte pentru Telegram bot
"""
from fastapi import APIRouter, Depends, HTTPException, Request
from typing import List, Optional, Dict, Any
# import sys # Removed - no longer needed
import os
import secrets
import string
import httpx
from datetime import datetime, timedelta
from pydantic import BaseModel, Field
from shared.auth.dependencies import get_current_user
from shared.auth.models import CurrentUser
from shared.auth.jwt_handler import jwt_handler
from shared.database.oracle_pool import oracle_pool
# Telegram bot internal API URL (running on same server)
TELEGRAM_BOT_INTERNAL_API = os.getenv("TELEGRAM_BOT_INTERNAL_API", "http://localhost:8002")
router = APIRouter(redirect_slashes=False)
# ==================== Schemas ====================
class GenerateCodeRequest(BaseModel):
"""Request pentru generarea unui cod de linking"""
telegram_user_id: int = Field(description="ID-ul utilizatorului Telegram")
telegram_username: Optional[str] = Field(default=None, description="Username-ul Telegram")
telegram_first_name: Optional[str] = Field(default=None, description="Prenumele utilizatorului")
telegram_last_name: Optional[str] = Field(default=None, description="Numele utilizatorului")
class GenerateCodeResponse(BaseModel):
"""Response pentru generarea unui cod de linking"""
linking_code: str = Field(description="Codul de linking generat (8 caractere)")
expires_at: datetime = Field(description="Data și ora expirării codului")
expires_in_minutes: int = Field(description="Minutele până la expirare")
class VerifyUserRequest(BaseModel):
"""
Request pentru verificarea utilizatorului în Oracle
Suportă 2 flow-uri:
1. Auto-linking (recomandat): doar linking_code și oracle_username
- Bot-ul verifică codul în SQLite, extrage oracle_username
- Backend face lookup în Oracle fără verificare parolă
- Codul valid este proof-of-authorization
2. Full verification (opțional): username, password, linking_code
- Verificare completă cu parolă în Oracle
"""
linking_code: str = Field(description="Codul de linking de la /generate-code")
oracle_username: Optional[str] = Field(default=None, description="Username Oracle (pentru auto-linking)")
username: Optional[str] = Field(default=None, description="Username pentru verificare completă")
password: Optional[str] = Field(default=None, description="Parolă pentru verificare completă")
class VerifyUserResponse(BaseModel):
"""Response pentru verificarea utilizatorului"""
success: bool = Field(description="True dacă verificarea a avut succes")
access_token: Optional[str] = Field(default=None, description="JWT access token")
refresh_token: Optional[str] = Field(default=None, description="JWT refresh token")
user: Optional[Dict[str, Any]] = Field(default=None, description="Detalii utilizator")
message: str = Field(description="Mesaj de status")
class RefreshTokenRequest(BaseModel):
"""Request pentru refresh JWT token"""
refresh_token: str = Field(description="Refresh token-ul obținut la autentificare")
class RefreshTokenResponse(BaseModel):
"""Response pentru refresh token"""
access_token: str = Field(description="Noul JWT access token")
expires_in: int = Field(description="Timpul de expirare în secunde")
token_type: str = Field(default="bearer", description="Tipul token-ului")
class ExportReportRequest(BaseModel):
"""Request pentru exportul unui raport"""
company_id: int = Field(description="ID-ul firmei")
report_type: str = Field(description="Tipul raportului (invoices, payments, dashboard)")
format: str = Field(default="excel", description="Formatul exportului (excel, pdf, csv)")
filters: Optional[Dict[str, Any]] = Field(default=None, description="Filtre pentru raport")
class ExportReportResponse(BaseModel):
"""Response pentru exportul raportului"""
success: bool = Field(description="True dacă exportul a avut succes")
file_url: Optional[str] = Field(default=None, description="URL-ul fișierului generat")
file_name: Optional[str] = Field(default=None, description="Numele fișierului generat")
file_size_bytes: Optional[int] = Field(default=None, description="Mărimea fișierului în bytes")
message: str = Field(description="Mesaj de status")
class VerifyEmailRequest(BaseModel):
"""Request pentru verificarea email-ului în Oracle"""
email: str = Field(description="Adresa de email Oracle")
class VerifyEmailResponse(BaseModel):
"""Response pentru verificarea email-ului"""
success: bool = Field(description="True dacă email-ul există și este activ")
username: Optional[str] = Field(default=None, description="Username-ul Oracle asociat")
message: str = Field(description="Mesaj de status")
class TelegramEmailLoginRequest(BaseModel):
"""Request pentru autentificare prin email + parolă"""
email: str = Field(description="Adresa de email Oracle")
password: str = Field(description="Parola Oracle")
telegram_user_id: int = Field(description="ID-ul utilizatorului Telegram")
session_token: str = Field(description="Token de sesiune pentru preveni spoofing")
class TelegramEmailLoginResponse(BaseModel):
"""Response pentru autentificare prin email + parolă"""
success: bool = Field(description="True dacă autentificarea a avut succes")
access_token: Optional[str] = Field(default=None, description="JWT access token")
refresh_token: Optional[str] = Field(default=None, description="JWT refresh token")
token_type: str = Field(default="bearer", description="Tipul token-ului")
user_id: Optional[int] = Field(default=None, description="ID-ul utilizatorului Oracle")
username: Optional[str] = Field(default=None, description="Username-ul Oracle")
companies: List[Dict[str, Any]] = Field(default_factory=list, description="Lista companiilor")
message: str = Field(description="Mesaj de status")
# ==================== Helper Functions ====================
# Rate limiting storage (in-memory)
from collections import defaultdict
_endpoint_rate_limits = defaultdict(list)
def check_endpoint_rate_limit(
identifier: str,
max_attempts: int = 5,
window_minutes: int = 5
) -> bool:
"""Backend rate limiting for sensitive endpoints"""
now = datetime.now()
cutoff = now - timedelta(minutes=window_minutes)
# Clean old attempts
_endpoint_rate_limits[identifier] = [
attempt for attempt in _endpoint_rate_limits[identifier]
if attempt > cutoff
]
# Check limit
if len(_endpoint_rate_limits[identifier]) >= max_attempts:
return False
# Add attempt
_endpoint_rate_limits[identifier].append(now)
return True
def verify_session_token(
telegram_user_id: int,
email: str,
token: str
) -> bool:
"""
Verify session token from bot to prevent user ID spoofing
Token format: user_id:email:signature
"""
import hashlib
try:
parts = token.split(":")
if len(parts) != 3:
return False
token_user_id, token_email, signature = parts
# Verify user ID and email match
if int(token_user_id) != telegram_user_id or token_email != email:
return False
# Verify signature
secret = os.getenv("AUTH_SESSION_SECRET", "change-me-in-production")
payload = f"{telegram_user_id}:{email}:{secret}"
expected_signature = hashlib.sha256(payload.encode()).hexdigest()[:16]
if signature != expected_signature:
return False
return True
except Exception:
return False
def generate_linking_code(length: int = 8) -> str:
"""
Generează un cod alfanumeric aleatoriu pentru linking
Args:
length: Lungimea codului (default: 8)
Returns:
Codul generat (uppercase alphanumeric)
"""
alphabet = string.ascii_uppercase + string.digits
# Exclude caractere care pot fi confundate: 0, O, I, 1
alphabet = alphabet.replace('0', '').replace('O', '').replace('I', '').replace('1', '')
return ''.join(secrets.choice(alphabet) for _ in range(length))
async def get_oracle_user_by_username(username: str) -> Optional[Dict[str, Any]]:
"""
Obține informații despre utilizator din Oracle FĂRĂ verificare parolă.
Folosit pentru auto-linking când utilizatorul a fost deja autentificat
prin generarea unui linking code valid în aplicația web.
Args:
username: Username-ul utilizatorului Oracle
Returns:
Dict cu informații despre utilizator sau None dacă nu există
"""
try:
async with oracle_pool.get_connection() as connection:
with connection.cursor() as cursor:
# Obține detalii utilizator
cursor.execute("""
SELECT ID_UTIL, UTILIZATOR
FROM UTILIZATORI
WHERE UPPER(UTILIZATOR) = :username
""", {'username': username.upper()})
user_row = cursor.fetchone()
if not user_row:
return None
user_id = user_row[0]
actual_username = user_row[1]
# Obține companiile utilizatorului
cursor.execute("""
SELECT A.ID_FIRMA, A.FIRMA
FROM V_NOM_FIRME A
WHERE A.ID_FIRMA IN (
SELECT ID_FIRMA
FROM VDEF_UTIL_FIRME
WHERE ID_PROGRAM = 2
AND ID_UTIL = :user_id
)
ORDER BY A.FIRMA
""", {'user_id': user_id})
companies_result = cursor.fetchall()
companies = [str(row[0]) for row in companies_result]
return {
'user_id': user_id,
'username': actual_username,
'companies': companies,
'permissions': ['read', 'reports']
}
except Exception as e:
print(f"Error getting Oracle user by username: {e}")
return None
async def verify_oracle_user(username: str, password: str) -> Optional[Dict[str, Any]]:
"""
Verifică utilizatorul în Oracle folosind pack_drepturi.verificautilizator
Args:
username: Username-ul utilizatorului
password: Parola utilizatorului
Returns:
Dict cu informații despre utilizator sau None dacă verificarea eșuează
"""
try:
async with oracle_pool.get_connection() as connection:
with connection.cursor() as cursor:
# Verifică autentificarea
cursor.execute("""
SELECT pack_drepturi.verificautilizator(:username, :password)
FROM DUAL
""", {
'username': username.upper(),
'password': password
})
result = cursor.fetchone()
verification_result = result[0] if result else -1
if verification_result == -1:
return None
# Obține detalii utilizator
cursor.execute("""
SELECT ID_UTIL, UTILIZATOR
FROM UTILIZATORI
WHERE UPPER(UTILIZATOR) = :username
""", {'username': username.upper()})
user_row = cursor.fetchone()
if not user_row:
return None
user_id = user_row[0]
# Obține companiile utilizatorului
cursor.execute("""
SELECT A.ID_FIRMA, A.FIRMA
FROM V_NOM_FIRME A
WHERE A.ID_FIRMA IN (
SELECT ID_FIRMA
FROM VDEF_UTIL_FIRME
WHERE ID_PROGRAM = 2
AND ID_UTIL = :user_id
)
ORDER BY A.FIRMA
""", {'user_id': user_id})
companies_result = cursor.fetchall()
companies = [str(row[0]) for row in companies_result]
return {
'user_id': user_id,
'username': username,
'companies': companies,
'permissions': ['read', 'reports']
}
except Exception as e:
print(f"Error verifying Oracle user: {e}")
return None
# ==================== Endpoints ====================
@router.post("/auth/generate-code", response_model=GenerateCodeResponse)
async def generate_linking_code_endpoint(
current_user: CurrentUser = Depends(get_current_user)
):
"""
Generează un cod de linking pentru conectarea unui utilizator Telegram
Flow:
1. Utilizatorul autentificat în aplicație solicită un cod
2. Se generează un cod unic de 8 caractere
3. Codul este trimis la Telegram bot pentru salvare în SQLite cu TTL de 15 minute
4. Utilizatorul introduce codul în Telegram bot pentru linking
Note:
- Acest endpoint necesită autentificare JWT (utilizatorul trebuie să fie logat în aplicație)
- Codul expiră după 15 minute
- Fiecare request generează un cod nou (codurile vechi devin invalide)
- Nu este nevoie de telegram_user_id în acest moment (utilizatorul nu e încă conectat la Telegram)
"""
try:
# Generează cod unic
linking_code = generate_linking_code()
# Setează expirarea la 15 minute
expires_at = datetime.utcnow() + timedelta(minutes=15)
expires_in_minutes = 15
# Salvează codul în database-ul Telegram bot (SQLite) via internal API
try:
async with httpx.AsyncClient(timeout=5.0) as client:
save_code_response = await client.post(
f"{TELEGRAM_BOT_INTERNAL_API}/internal/save-code",
json={
"code": linking_code,
"telegram_user_id": 0, # Not known yet (user hasn't linked)
"oracle_username": current_user.username,
"expires_in_minutes": expires_in_minutes
}
)
# Accept both 200 (OK) and 201 (Created) as success
if save_code_response.status_code not in [200, 201]:
raise HTTPException(
status_code=500,
detail=f"Failed to save code to Telegram bot: {save_code_response.text}"
)
except httpx.TimeoutException:
raise HTTPException(
status_code=503,
detail="Telegram bot service is not responding. Please try again later."
)
except httpx.ConnectError:
raise HTTPException(
status_code=503,
detail="Cannot connect to Telegram bot service. Please contact administrator."
)
return GenerateCodeResponse(
linking_code=linking_code,
expires_at=expires_at,
expires_in_minutes=expires_in_minutes
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Eroare la generarea codului de linking: {str(e)}"
)
@router.post("/auth/verify-user", response_model=VerifyUserResponse)
async def verify_user_endpoint(request: VerifyUserRequest):
"""
Verifică utilizatorul în Oracle și returnează JWT tokens
Suportă 2 flow-uri de autentificare:
Flow A - Auto-linking (RECOMANDAT):
1. Bot verifică linking_code în SQLite (code valid = user s-a autentificat în web app)
2. Bot extrage oracle_username din cod
3. Bot trimite: {linking_code, oracle_username}
4. Backend face lookup în Oracle (FĂRĂ verificare parolă)
5. Backend generează și returnează JWT tokens
Flow B - Full verification (OPȚIONAL):
1. Bot cere username și parolă de la user în Telegram
2. Bot trimite: {linking_code, username, password}
3. Backend verifică credențialele în Oracle
4. Backend generează și returnează JWT tokens
Note:
- Acest endpoint NU necesită autentificare JWT (este public pentru bot)
- Flow A oferă UX superior (fără re-introducere parolă)
- Linking code-ul valid este proof-of-authorization
"""
try:
# Flow A: Auto-linking (oracle_username provided, no password)
if request.oracle_username and not request.password:
user_data = await get_oracle_user_by_username(request.oracle_username)
if not user_data:
return VerifyUserResponse(
success=False,
message=f"Utilizatorul {request.oracle_username} nu există în Oracle"
)
# Flow B: Full verification (username + password provided)
elif request.username and request.password:
user_data = await verify_oracle_user(request.username, request.password)
if not user_data:
return VerifyUserResponse(
success=False,
message="Username sau parolă incorectă"
)
# Invalid request (missing required fields)
else:
return VerifyUserResponse(
success=False,
message="Trebuie furnizat fie oracle_username (auto-linking) fie username+password (verificare completă)"
)
# Generează JWT tokens
access_token = jwt_handler.create_access_token(
username=user_data['username'],
companies=user_data['companies'],
user_id=user_data['user_id'],
permissions=user_data['permissions']
)
refresh_token = jwt_handler.create_refresh_token(
username=user_data['username'],
user_id=user_data['user_id']
)
return VerifyUserResponse(
success=True,
access_token=access_token,
refresh_token=refresh_token,
user={
'user_id': user_data['user_id'],
'username': user_data['username'],
'companies': user_data['companies'],
'permissions': user_data['permissions']
},
message="Autentificare reușită"
)
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Eroare la verificarea utilizatorului: {str(e)}"
)
@router.post("/auth/refresh-token", response_model=RefreshTokenResponse)
async def refresh_token_endpoint(request: RefreshTokenRequest):
"""
Refresh-uiește un JWT access token folosind refresh token-ul
Acest endpoint este folosit de Telegram bot pentru a obține un nou access token
când cel curent expiră, fără a solicita din nou username/password.
Flow:
1. Botul Telegram detectează că access token-ul a expirat
2. Trimite refresh token-ul la acest endpoint
3. Se validează refresh token-ul și se generează un nou access token
4. Botul stochează noul access token în SQLite
Note:
- Refresh token-ul este valid 7 zile (vs 30 minute pentru access token)
- Dacă refresh token-ul expiră, utilizatorul trebuie să se re-autentifice
"""
try:
# Verifică refresh token-ul
token_data = jwt_handler.verify_token(request.refresh_token)
if not token_data or token_data.token_type != "refresh":
raise HTTPException(
status_code=401,
detail="Refresh token invalid sau expirat"
)
# Obține companiile actualizate din Oracle
async with oracle_pool.get_connection() as connection:
with connection.cursor() as cursor:
cursor.execute("""
SELECT A.ID_FIRMA
FROM V_NOM_FIRME A
WHERE A.ID_FIRMA IN (
SELECT ID_FIRMA
FROM VDEF_UTIL_FIRME
WHERE ID_PROGRAM = 2
AND ID_UTIL = :user_id
)
ORDER BY A.FIRMA
""", {'user_id': token_data.user_id})
companies_result = cursor.fetchall()
companies = [str(row[0]) for row in companies_result]
# Generează nou access token
new_access_token = jwt_handler.create_access_token(
username=token_data.username,
companies=companies,
user_id=token_data.user_id,
permissions=token_data.permissions
)
return RefreshTokenResponse(
access_token=new_access_token,
expires_in=jwt_handler.access_token_expire_minutes * 60,
token_type="bearer"
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Eroare la refresh token: {str(e)}"
)
@router.post("/auth/verify-email", response_model=VerifyEmailResponse)
async def verify_email_endpoint(request: VerifyEmailRequest):
"""
Verify if email exists in Oracle UTILIZATORI table (PUBLIC endpoint)
This is a PUBLIC endpoint used by the telegram bot during email authentication.
Returns username if email exists and user is active.
Security: Generic error messages to prevent email enumeration.
"""
try:
async with oracle_pool.get_connection() as connection:
with connection.cursor() as cursor:
# Query to find username by email
cursor.execute("""
SELECT UTILIZATOR
FROM CONTAFIN_ORACLE.UTILIZATORI
WHERE UPPER(EMAIL) = UPPER(:email)
AND INACTIV = 0
AND STERS = 0
""", {"email": request.email})
row = cursor.fetchone()
if row:
username = row[0]
return VerifyEmailResponse(
success=True,
username=username,
message="Email verificat cu succes"
)
else:
# Generic message (no enumeration)
return VerifyEmailResponse(
success=False,
username=None,
message="Email invalid sau inactiv"
)
except Exception as e:
# Generic error message (no details exposed)
return VerifyEmailResponse(
success=False,
username=None,
message="Eroare la verificarea email-ului"
)
@router.post("/auth/login-with-email", response_model=TelegramEmailLoginResponse)
async def login_with_email_endpoint(request: TelegramEmailLoginRequest):
"""
Telegram email + password authentication endpoint
Security features:
- Rate limiting: 5 attempts per 5 minutes
- Session token verification (prevent user ID spoofing)
- Generic error messages (no username/email enumeration)
- Password verification in Oracle (not stored)
"""
# 1. Rate limiting
rate_limit_key = f"email_login_{request.telegram_user_id}"
if not check_endpoint_rate_limit(rate_limit_key, max_attempts=5, window_minutes=5):
raise HTTPException(
status_code=429,
detail="Prea multe încercări. Te rugăm să aștepți 5 minute."
)
# 2. Verify session token (prevent user ID spoofing)
if not verify_session_token(
request.telegram_user_id,
request.email,
request.session_token
):
raise HTTPException(
status_code=401,
detail="Sesiune invalidă. Te rugăm să reîncepi autentificarea."
)
try:
async with oracle_pool.get_connection() as connection:
with connection.cursor() as cursor:
# 3. Find username by email
cursor.execute("""
SELECT ID_UTIL, UTILIZATOR, INACTIV, STERS
FROM CONTAFIN_ORACLE.UTILIZATORI
WHERE UPPER(EMAIL) = UPPER(:email)
""", {"email": request.email})
user_row = cursor.fetchone()
# SECURITY: Generic error message (no email enumeration)
if not user_row:
raise HTTPException(
status_code=401,
detail="Credențiale invalide" # Generic message
)
user_id, username, inactiv, sters = user_row
# Check if user is active (INACTIV=0 means active, STERS=0 means not deleted)
if inactiv != 0 or sters != 0:
raise HTTPException(
status_code=401,
detail="Credențiale invalide" # Generic message
)
# 4. Verify password via Oracle stored procedure
# NOTE: This procedure returns a verification code, NOT the user_id!
# Returns -1 if authentication fails, any other value means success
cursor.execute("""
SELECT pack_drepturi.verificautilizator(:username, :password)
FROM DUAL
""", {
"username": username.upper(), # IMPORTANT: Oracle usernames are uppercase
"password": request.password
})
verification_result = cursor.fetchone()[0]
# SECURITY: Generic error message (no username leak)
if verification_result == -1:
raise HTTPException(
status_code=401,
detail="Credențiale invalide" # Generic message
)
# 5. Get user companies
cursor.execute("""
SELECT A.ID_FIRMA, A.FIRMA
FROM V_NOM_FIRME A
WHERE A.ID_FIRMA IN (
SELECT ID_FIRMA
FROM VDEF_UTIL_FIRME
WHERE ID_PROGRAM = 2
AND ID_UTIL = :user_id
)
ORDER BY A.FIRMA
""", {'user_id': user_id})
companies_result = cursor.fetchall()
companies = [
{"id": str(row[0]), "name": row[1]}
for row in companies_result
]
company_ids = [str(row[0]) for row in companies_result]
# 6. Get user permissions (default for Telegram)
permissions = ['read', 'reports']
# 7. Generate JWT tokens
token_data = {
"username": username,
"user_id": user_id,
"companies": company_ids,
"permissions": permissions
}
access_token = jwt_handler.create_access_token(**token_data)
refresh_token = jwt_handler.create_refresh_token(
username=username,
user_id=user_id
)
return TelegramEmailLoginResponse(
success=True,
access_token=access_token,
refresh_token=refresh_token,
user_id=user_id,
username=username,
companies=companies,
message="Autentificare reușită"
)
except HTTPException:
raise
except Exception as e:
print(f"Error in login_with_email: {e}")
raise HTTPException(
status_code=500,
detail="Eroare internă. Te rugăm să încerci din nou mai târziu."
)
@router.post("/export", response_model=ExportReportResponse)
async def export_report_endpoint(
request: ExportReportRequest,
current_user: CurrentUser = Depends(get_current_user)
):
"""
Exportă un raport în format Excel, PDF sau CSV
Acest endpoint este folosit de Telegram bot pentru a genera rapoarte
și a le trimite utilizatorului.
Flow:
1. Botul trimite cerere de export cu parametrii raportului
2. Se validează că utilizatorul are acces la firma specificată
3. Se generează raportul în formatul solicitat
4. Se returnează URL-ul sau conținutul fișierului
Tipuri de rapoarte suportate:
- invoices: Facturi (cu filtre: dată, status, client)
- payments: Încasări (cu filtre: dată, metodă plată)
- dashboard: Statistici dashboard (rezumat)
Formate suportate:
- excel: XLSX (cel mai complet)
- pdf: PDF (pentru printing)
- csv: CSV (pentru import în alte sisteme)
Note:
- Utilizatorul trebuie să aibă acces la firma specificată
- Fișierele generate sunt temporare (șterse după 1 oră)
"""
try:
# Verifică accesul la firmă
company_id_str = str(request.company_id)
if company_id_str not in current_user.companies:
raise HTTPException(
status_code=403,
detail=f"Nu aveți acces la firma {request.company_id}"
)
# TODO: Implementare export în funcție de report_type și format
# Deocamdată returnăm un placeholder
return ExportReportResponse(
success=True,
file_url=f"/api/telegram/downloads/report_{request.report_type}_{request.company_id}.{request.format}",
file_name=f"raport_{request.report_type}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.{request.format}",
file_size_bytes=0,
message=f"Raport {request.report_type} generat cu succes în format {request.format}"
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Eroare la generarea raportului: {str(e)}"
)
@router.get("/health")
async def telegram_health_check():
"""
Health check pentru routerul Telegram
Verifică conectivitatea la Oracle și disponibilitatea serviciilor
"""
try:
async with oracle_pool.get_connection() as connection:
with connection.cursor() as cursor:
cursor.execute("SELECT 1 FROM DUAL")
return {
"status": "healthy",
"service": "telegram-router",
"database": "connected",
"timestamp": datetime.utcnow().isoformat()
}
except Exception as e:
return {
"status": "degraded",
"service": "telegram-router",
"database": f"error: {str(e)}",
"timestamp": datetime.utcnow().isoformat()
}

View File

@@ -0,0 +1,353 @@
"""
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 APIRouter, HTTPException, status
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from backend.modules.telegram.db.operations import create_auth_code, get_auth_code
from backend.modules.telegram.db.database import get_database_stats
logger = logging.getLogger(__name__)
# Initialize APIRouter (converted from FastAPI app for unified backend)
internal_api = APIRouter()
# ============================================================================
# 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
# ============================================================================
# ============================================================================
# NOTE: Exception handlers and startup/shutdown events removed
# These are FastAPI-specific and don't work with APIRouter
# The unified backend (main.py) handles these at the app level
# ============================================================================
# @internal_api.exception_handler(Exception) - Not supported by APIRouter
# async def global_exception_handler(request, exc):
# """Global exception handler - moved to main.py"""
# pass
# @internal_api.on_event("startup") - Not supported by APIRouter
# async def startup_event():
# """Startup event - handled by main.py lifespan"""
# pass
# @internal_api.on_event("shutdown") - Not supported by APIRouter
# async def shutdown_event():
# """Shutdown event - handled by main.py lifespan"""
# pass
# Export the APIRouter
__all__ = ['internal_api']

View File

@@ -0,0 +1,263 @@
"""
Async SMTP Email Service with retry logic and proper error handling
"""
import aiosmtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
import os
import logging
from typing import Optional
import asyncio
logger = logging.getLogger(__name__)
class EmailService:
"""Async SMTP client for sending authentication codes"""
def __init__(self):
self.smtp_host = os.getenv("SMTP_HOST", "mail.romfast.ro")
self.smtp_port = int(os.getenv("SMTP_PORT", "587"))
self.smtp_user = os.getenv("SMTP_USER")
self.smtp_password = os.getenv("SMTP_PASSWORD")
self.from_email = os.getenv("SMTP_FROM_EMAIL")
self.from_name = os.getenv("SMTP_FROM_NAME", "ROA2WEB")
self.use_tls = os.getenv("SMTP_USE_TLS", "true").lower() == "true"
# Retry configuration
self.max_retries = int(os.getenv("EMAIL_MAX_RETRIES", "3"))
self.retry_delay = float(os.getenv("EMAIL_RETRY_DELAY", "2.0")) # seconds
# Validate required config
if not all([self.smtp_user, self.smtp_password, self.from_email]):
raise ValueError("SMTP configuration incomplete. Check .env file.")
async def send_auth_code(
self,
to_email: str,
code: str,
username: str
) -> bool:
"""
Send authentication code via email with retry logic
Args:
to_email: Recipient email address
code: 6-digit authentication code
username: Oracle username for personalization
Returns:
True if email sent successfully (after retries if needed)
Raises:
No exceptions - returns False on all failures
"""
subject = "Codul tău de autentificare ROA2WEB"
html_body = self._create_email_template(code, username)
for attempt in range(1, self.max_retries + 1):
try:
await self._send_email(to_email, subject, html_body)
logger.info(
f"Email sent successfully to {to_email} "
f"(attempt {attempt}/{self.max_retries})"
)
return True
except aiosmtplib.SMTPException as e:
logger.warning(
f"SMTP error on attempt {attempt}/{self.max_retries}: {e}"
)
if attempt < self.max_retries:
# Exponential backoff: 2s, 4s, 8s
delay = self.retry_delay * (2 ** (attempt - 1))
logger.info(f"Retrying in {delay}s...")
await asyncio.sleep(delay)
else:
logger.error(f"Failed to send email to {to_email} after {self.max_retries} attempts")
except Exception as e:
logger.error(f"Unexpected error sending email: {e}", exc_info=True)
return False
return False
async def _send_email(
self,
to_email: str,
subject: str,
html_body: str
) -> None:
"""
Internal async SMTP sender
Raises:
aiosmtplib.SMTPException: On SMTP errors
"""
message = MIMEMultipart("alternative")
message["From"] = f"{self.from_name} <{self.from_email}>"
message["To"] = to_email
message["Subject"] = subject
# Attach HTML body
html_part = MIMEText(html_body, "html", "utf-8")
message.attach(html_part)
# Send via async SMTP with STARTTLS
# Using start_tls parameter for automatic STARTTLS handling
smtp = aiosmtplib.SMTP(
hostname=self.smtp_host,
port=self.smtp_port,
start_tls=self.use_tls, # Use start_tls instead of use_tls
timeout=30
)
try:
await smtp.connect()
await smtp.login(self.smtp_user, self.smtp_password)
await smtp.send_message(message)
finally:
try:
await smtp.quit()
except:
pass
def _create_email_template(self, code: str, username: str) -> str:
"""Generate HTML email template"""
return f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
line-height: 1.6;
color: #333;
margin: 0;
padding: 0;
background-color: #f5f5f5;
}}
.container {{
max-width: 600px;
margin: 0 auto;
background: white;
}}
.header {{
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 40px 20px;
text-align: center;
}}
.header h1 {{
margin: 0;
font-size: 28px;
}}
.content {{
padding: 40px 20px;
}}
.code-box {{
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
border: 3px solid #667eea;
border-radius: 12px;
padding: 30px;
margin: 30px 0;
text-align: center;
}}
.code {{
font-size: 42px;
font-weight: bold;
letter-spacing: 12px;
color: #667eea;
font-family: 'Courier New', monospace;
display: block;
margin: 15px 0;
}}
.warning {{
background-color: #fff3cd;
border-left: 4px solid #ffc107;
padding: 15px;
margin: 20px 0;
border-radius: 4px;
}}
.footer {{
text-align: center;
color: #666;
font-size: 12px;
padding: 20px;
border-top: 1px solid #e0e0e0;
background-color: #f9f9f9;
}}
.button {{
display: inline-block;
padding: 12px 24px;
background-color: #667eea;
color: white;
text-decoration: none;
border-radius: 6px;
margin-top: 20px;
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>ROA2WEB</h1>
<p style="margin: 10px 0 0 0; opacity: 0.9;">Autentificare Telegram Bot</p>
</div>
<div class="content">
<p>Salut <strong>{username}</strong>,</p>
<p>Ai solicitat autentificarea în aplicația ROA2WEB Telegram Bot.</p>
<div class="code-box">
<p style="margin: 0; font-size: 14px; color: #666; font-weight: 500;">
Codul tău de autentificare:
</p>
<span class="code">{code}</span>
<p style="margin: 0; font-size: 12px; color: #888;">
Introdu acest cod în conversația Telegram
</p>
</div>
<div class="warning">
<strong>Important:</strong> Acest cod expiră în <strong>5 minute</strong>
și poate fi folosit o singură dată.
</div>
<p>După introducerea codului, vei fi solicitat să introduci parola ta Oracle.</p>
<hr style="border: none; border-top: 1px solid #e0e0e0; margin: 30px 0;">
<p style="font-size: 14px; color: #666;">
<strong>Nu ai solicitat acest cod?</strong><br>
Dacă nu ai inițiat această autentificare, poți ignora acest email în siguranță.
Nimeni nu va avea acces la contul tău fără parola ta Oracle.
</p>
</div>
<div class="footer">
<p><strong>ROA2WEB</strong> - ERP Reports Application</p>
<p>Acest email a fost trimis automat. Te rugăm să nu răspunzi.</p>
<p style="margin-top: 10px; color: #999;">
© 2025 ROA2WEB. All rights reserved.
</p>
</div>
</div>
</body>
</html>
"""
# Singleton instance
_email_service: Optional[EmailService] = None
def get_email_service() -> EmailService:
"""Get or create singleton email service instance"""
global _email_service
if _email_service is None:
_email_service = EmailService()
return _email_service