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:
0
backend/modules/telegram/__init__.py
Normal file
0
backend/modules/telegram/__init__.py
Normal file
0
backend/modules/telegram/agent/__init__.py
Normal file
0
backend/modules/telegram/agent/__init__.py
Normal file
313
backend/modules/telegram/agent/session.py
Normal file
313
backend/modules/telegram/agent/session.py
Normal file
@@ -0,0 +1,313 @@
|
||||
"""
|
||||
Session Management for Telegram Bot
|
||||
|
||||
This module handles session state for Telegram users, specifically managing
|
||||
the active company selection for command handlers.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import json
|
||||
from typing import Dict, Any, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from 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'
|
||||
]
|
||||
0
backend/modules/telegram/api/__init__.py
Normal file
0
backend/modules/telegram/api/__init__.py
Normal file
917
backend/modules/telegram/api/client.py
Normal file
917
backend/modules/telegram/api/client.py
Normal 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'
|
||||
]
|
||||
0
backend/modules/telegram/auth/__init__.py
Normal file
0
backend/modules/telegram/auth/__init__.py
Normal file
171
backend/modules/telegram/auth/email_auth.py
Normal file
171
backend/modules/telegram/auth/email_auth.py
Normal 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
|
||||
350
backend/modules/telegram/auth/linking.py
Normal file
350
backend/modules/telegram/auth/linking.py
Normal 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'
|
||||
]
|
||||
0
backend/modules/telegram/bot/__init__.py
Normal file
0
backend/modules/telegram/bot/__init__.py
Normal file
768
backend/modules/telegram/bot/email_handlers.py
Normal file
768
backend/modules/telegram/bot/email_handlers.py
Normal 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"
|
||||
)
|
||||
629
backend/modules/telegram/bot/formatters.py
Normal file
629
backend/modules/telegram/bot/formatters.py
Normal 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
|
||||
|
||||
2777
backend/modules/telegram/bot/handlers.py
Normal file
2777
backend/modules/telegram/bot/handlers.py
Normal file
File diff suppressed because it is too large
Load Diff
814
backend/modules/telegram/bot/helpers.py
Normal file
814
backend/modules/telegram/bot/helpers.py
Normal 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'
|
||||
]
|
||||
0
backend/modules/telegram/bot/keyboards.py
Normal file
0
backend/modules/telegram/bot/keyboards.py
Normal file
607
backend/modules/telegram/bot/menus.py
Normal file
607
backend/modules/telegram/bot/menus.py
Normal 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)
|
||||
316
backend/modules/telegram/bot_main.py
Normal file
316
backend/modules/telegram/bot_main.py
Normal 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)
|
||||
86
backend/modules/telegram/db/__init__.py
Normal file
86
backend/modules/telegram/db/__init__.py
Normal 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',
|
||||
]
|
||||
310
backend/modules/telegram/db/database.py
Normal file
310
backend/modules/telegram/db/database.py
Normal 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',
|
||||
]
|
||||
813
backend/modules/telegram/db/operations.py
Normal file
813
backend/modules/telegram/db/operations.py
Normal 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',
|
||||
]
|
||||
32
backend/modules/telegram/routers/__init__.py
Normal file
32
backend/modules/telegram/routers/__init__.py
Normal 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
|
||||
840
backend/modules/telegram/routers/auth_codes.py
Normal file
840
backend/modules/telegram/routers/auth_codes.py
Normal 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()
|
||||
}
|
||||
353
backend/modules/telegram/routers/internal_api.py
Normal file
353
backend/modules/telegram/routers/internal_api.py
Normal 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']
|
||||
0
backend/modules/telegram/utils/__init__.py
Normal file
0
backend/modules/telegram/utils/__init__.py
Normal file
263
backend/modules/telegram/utils/email_service.py
Normal file
263
backend/modules/telegram/utils/email_service.py
Normal 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
|
||||
Reference in New Issue
Block a user