diff --git a/.gitignore b/.gitignore
index 907adbe..5e96d29 100644
--- a/.gitignore
+++ b/.gitignore
@@ -444,3 +444,24 @@ yarn-error.log*
# Allow proper test files (pytest, unittest) but exclude temporary test scripts
!**/tests/test_*.py
!**/test_*.py
+
+# ============================================================================
+# 🧹 TEMPORARY FILES FROM DEBUGGING SESSION - DO NOT COMMIT
+# ============================================================================
+
+# PID files from bot processes
+*.pid
+bot.pid
+
+# Requirements checksums (generated by start-dev.sh)
+*.checksum
+
+# Temporary debugging/testing scripts in telegram-bot
+reports-app/telegram-bot/check_db.py
+reports-app/telegram-bot/test_email.py
+
+# Temporary planning documents
+TELEGRAM_EMAIL_AUTH_PLAN*.md
+
+# Weird pip artifacts
+=*
diff --git a/reports-app/backend/.env.example b/reports-app/backend/.env.example
index f09741e..a0e34c8 100644
--- a/reports-app/backend/.env.example
+++ b/reports-app/backend/.env.example
@@ -30,6 +30,11 @@ JWT_SECRET_KEY=GENERATE_STRONG_SECRET_IN_PRODUCTION
ACCESS_TOKEN_EXPIRE_MINUTES=30
REFRESH_TOKEN_EXPIRE_DAYS=7
+# Session Security (Email Authentication)
+# Must match telegram-bot AUTH_SESSION_SECRET for email login flow
+# Generate with: python3 -c "import secrets; print(secrets.token_urlsafe(32))"
+AUTH_SESSION_SECRET=your-secure-random-secret-here-min-32-chars
+
# Application Configuration
API_HOST=0.0.0.0
API_PORT=8000
diff --git a/reports-app/backend/app/auth_middleware_wrapper.py b/reports-app/backend/app/auth_middleware_wrapper.py
index ad5a40a..9669035 100644
--- a/reports-app/backend/app/auth_middleware_wrapper.py
+++ b/reports-app/backend/app/auth_middleware_wrapper.py
@@ -34,7 +34,12 @@ class FixedAuthenticationMiddleware(BaseHTTPMiddleware):
print(f"[FIXED MIDDLEWARE] Processing path: {path}")
# Verifică dacă path-ul trebuie exclus
- excluded_paths = ["/docs", "/health", "/api/auth/login", "/redoc", "/openapi.json"]
+ excluded_paths = [
+ "/docs", "/health", "/api/auth/login", "/redoc", "/openapi.json",
+ "/api/telegram/health", "/api/telegram/auth/verify-user",
+ "/api/telegram/auth/verify-email", "/api/telegram/auth/login-with-email",
+ "/api/telegram/auth/refresh-token"
+ ]
is_excluded = (path == "/" or any(path.startswith(excluded) for excluded in excluded_paths))
print(f"[FIXED MIDDLEWARE] Checking exclusions for {path}")
print(f"[FIXED MIDDLEWARE] Excluded paths: {excluded_paths}")
diff --git a/reports-app/backend/app/main.py b/reports-app/backend/app/main.py
index afa2321..51f39c8 100644
--- a/reports-app/backend/app/main.py
+++ b/reports-app/backend/app/main.py
@@ -301,6 +301,8 @@ app.add_middleware(
excluded_paths=[
"/", "/docs", "/health", "/api/auth/login", "/redoc", "/openapi.json",
"/api/telegram/auth/verify-user", # Public endpoint for Telegram bot
+ "/api/telegram/auth/verify-email", # Public endpoint for email verification (2FA flow)
+ "/api/telegram/auth/login-with-email", # Public endpoint for email + password login (2FA flow)
"/api/telegram/auth/refresh-token", # Public endpoint for token refresh
"/api/telegram/health" # Health check for Telegram router
]
diff --git a/reports-app/backend/app/routers/telegram.py b/reports-app/backend/app/routers/telegram.py
index 0ea6d2b..e00ef06 100644
--- a/reports-app/backend/app/routers/telegram.py
+++ b/reports-app/backend/app/routers/telegram.py
@@ -98,8 +98,105 @@ class ExportReportResponse(BaseModel):
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
@@ -473,6 +570,191 @@ async def refresh_token_endpoint(request: RefreshTokenRequest):
)
+@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,
diff --git a/reports-app/telegram-bot/.env.example b/reports-app/telegram-bot/.env.example
index c56a41a..375fcb5 100644
--- a/reports-app/telegram-bot/.env.example
+++ b/reports-app/telegram-bot/.env.example
@@ -41,6 +41,32 @@ CLAUDE_API_KEY=
# Docker: http://roa-backend:8000
BACKEND_URL=http://roa-backend:8000
+# ============================================================================
+# EMAIL AUTHENTICATION (SMTP) CONFIGURATION
+# ============================================================================
+# Required for email-based 2FA authentication flow
+# Users can login with email + password instead of web app linking
+
+# SMTP Server Configuration
+SMTP_HOST=mail.romfast.ro
+SMTP_PORT=587
+SMTP_USER=ups@romfast.ro
+SMTP_PASSWORD=your_smtp_password_here
+SMTP_FROM_EMAIL=ups@romfast.ro
+SMTP_FROM_NAME=ROA2WEB
+SMTP_USE_TLS=true
+
+# Email Sending Settings
+EMAIL_MAX_RETRIES=3
+EMAIL_RETRY_DELAY=2.0
+EMAIL_CODE_EXPIRY_MINUTES=5
+EMAIL_CODE_LENGTH=6
+MAX_EMAIL_ATTEMPTS_PER_HOUR=3
+
+# Session Security (must match backend AUTH_SESSION_SECRET)
+# Generate with: python3 -c "import secrets; print(secrets.token_urlsafe(32))"
+AUTH_SESSION_SECRET=your-secure-random-secret-here-min-32-chars
+
# ============================================================================
# DATABASE CONFIGURATION
# ============================================================================
diff --git a/reports-app/telegram-bot/app/api/client.py b/reports-app/telegram-bot/app/api/client.py
index ce15286..dac3a72 100644
--- a/reports-app/telegram-bot/app/api/client.py
+++ b/reports-app/telegram-bot/app/api/client.py
@@ -185,6 +185,119 @@ class BackendAPIClient:
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:
+ 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:
+ 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.
diff --git a/reports-app/telegram-bot/app/auth/email_auth.py b/reports-app/telegram-bot/app/auth/email_auth.py
new file mode 100644
index 0000000..0070c7b
--- /dev/null
+++ b/reports-app/telegram-bot/app/auth/email_auth.py
@@ -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 app.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
diff --git a/reports-app/telegram-bot/app/bot/email_handlers.py b/reports-app/telegram-bot/app/bot/email_handlers.py
new file mode 100644
index 0000000..36ea177
--- /dev/null
+++ b/reports-app/telegram-bot/app/bot/email_handlers.py
@@ -0,0 +1,667 @@
+"""
+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
+
+from app.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 app.utils.email_service import get_email_service
+from app.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 app.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
+
+
+# ============================================================================
+# 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)
+
+ await update.message.reply_text(
+ "**Alege metoda de autentificare:**\n\n"
+ "**Email + Parolă (2FA)**\n"
+ " • Primești cod pe email\n"
+ " • Introduci codul\n"
+ " • Introduci parola Oracle\n\n"
+ "**Web App**\n"
+ " • Login în aplicația web\n"
+ " • Generează cod de linking\n"
+ " • Trimite codul cu /start",
+ reply_markup=reply_markup,
+ parse_mode="Markdown"
+ )
+
+ 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)
+
+ await query.edit_message_text(
+ "**Alege metoda de autentificare:**\n\n"
+ "**Email + Parolă (2FA)**\n"
+ " • Primești cod pe email\n"
+ " • Introduci codul\n"
+ " • Introduci parola Oracle\n\n"
+ "**Web App**\n"
+ " • Login în aplicația web\n"
+ " • Generează cod de linking\n"
+ " • Trimite codul cu /start",
+ reply_markup=reply_markup,
+ parse_mode="Markdown"
+ )
+
+ 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()
+
+ await query.edit_message_text(
+ "**Autentificare prin Email + Parolă**\n\n"
+ "Te rugăm să introduci adresa ta de **email Oracle**:\n\n"
+ "Exemplu: nume.prenume@companie.ro\n\n"
+ "Vei primi un cod de 6 cifre pe email.\n\n"
+ "Scrie /cancel pentru a anula.",
+ 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"
+ )
+
+ 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
+
+ # Validare format email
+ if not is_valid_email_format(email):
+ await update.message.reply_text(
+ "**Email invalid**\n\n"
+ "Te rugăm să introduci o adresă de email validă.\n\n"
+ "Format: nume@domeniu.ro",
+ parse_mode="Markdown"
+ )
+ 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}")
+
+ # Loading message
+ loading_msg = await update.message.reply_text("Verificare email...")
+
+ 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 loading_msg.edit_text(
+ "Eroare la salvarea codului. Te rugăm să încerci 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
+
+ # ALWAYS show this message (prevent enumeration)
+ await loading_msg.edit_text(
+ "**Cod trimis**\n\n"
+ f"Am trimis un cod de 6 cifre pe **{email}**\n\n"
+ "Verifică:\n"
+ " • Inbox-ul\n"
+ " • Folderul Spam/Junk\n\n"
+ "Codul expiră în **5 minute**\n\n"
+ "Introdu codul aici sau apasă butonul de mai jos.",
+ parse_mode="Markdown",
+ 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 loading_msg.edit_text(
+ "Eroare internă. Te rugăm să încerci 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
+
+ # Validare format cod (6 digits)
+ if not (code.isdigit() and len(code) == 6):
+ await update.message.reply_text(
+ "**Cod invalid**\n\n"
+ "Te rugăm să introduci cele **6 cifre** din email.\n\n"
+ "Format: 123456",
+ parse_mode="Markdown"
+ )
+ return AWAITING_CODE
+
+ # Verifică cod în DB
+ try:
+ code_data = await get_email_auth_code(code)
+
+ if not code_data:
+ await update.message.reply_text(
+ "**Cod invalid sau expirat**\n\n"
+ "Te rugăm să:\n"
+ "• Verifici codul din email\n"
+ "• Sau reîncepi cu /login"
+ )
+ return ConversationHandler.END
+
+ # Verificări de securitate
+
+ # 1. Check if already used
+ if code_data['used']:
+ await update.message.reply_text(
+ "**Cod deja folosit**\n\n"
+ "Fiecare cod poate fi folosit o singură dată.\n\n"
+ "Te rugăm să reîncepi cu /login"
+ )
+ return ConversationHandler.END
+
+ # 2. Check if expired
+ if datetime.now() > code_data['expires_at']:
+ await update.message.reply_text(
+ "**Cod expirat**\n\n"
+ "Codul era valabil 5 minute.\n\n"
+ "Te rugăm să reîncepi 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 update.message.reply_text(
+ "**Cod invalid**"
+ )
+ return ConversationHandler.END
+
+ # 4. Check failed attempts (max 3)
+ if code_data['failed_attempts'] >= MAX_CODE_ATTEMPTS:
+ await update.message.reply_text(
+ "**Prea multe încercări greșite**\n\n"
+ "Te rugăm să reîncepi cu /login pentru un cod nou."
+ )
+ 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']
+ )
+
+ await update.message.reply_text(
+ "**Cod validat cu succes**\n\n"
+ "Acum introdu **parola ta Oracle**:\n\n"
+ "**Important:**\n"
+ " • Parola va fi ștearsă automat\n"
+ " • Nu va fi vizibilă în chat\n"
+ " • Verificată direct în Oracle\n\n"
+ "Scrie /cancel pentru a anula.",
+ parse_mode="Markdown"
+ )
+
+ return AWAITING_PASSWORD
+
+ except Exception as e:
+ logger.error(f"Error validating code: {e}", exc_info=True)
+ await update.message.reply_text(
+ "Eroare la validarea codului. Te rugăm să încerci 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 query.edit_message_text("Eroare. Te rugăm să reîncepi 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 query.edit_message_text(
+ "Prea multe solicitări de retrimitere.\n\n"
+ "Te rugăm să aștepți 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 query.edit_message_text(
+ "Eroare. Te rugăm să reîncepi 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)
+
+ await query.edit_message_text(
+ f"**Cod retrimis pe {email}**\n\n"
+ "Verifică inbox-ul (și spam).\n\n"
+ "Introdu codul aici.",
+ parse_mode="Markdown"
+ )
+
+ 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 update.effective_chat.send_message(
+ "Sesiune expirată. Te rugăm să reîncepi cu /login"
+ )
+ return ConversationHandler.END
+
+ # Loading message
+ loading_msg = await update.effective_chat.send_message(
+ "Verificare credențiale în Oracle..."
+ )
+
+ 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 loading_msg.edit_text(
+ "**Credențiale invalide**\n\n"
+ "Parolă incorectă sau cont inactiv.\n\n"
+ "Te rugăm să reîncepi 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}")
+
+ # Delete loading message
+ try:
+ await loading_msg.delete()
+ except Exception:
+ pass
+
+ # Show main menu with buttons (user is now authenticated)
+ from app.agent.session import get_session_manager
+ from app.bot.menus import create_main_menu, pad_message_for_wide_buttons
+
+ # Get session and active company
+ 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
+ )
+
+ # Success message with company info
+ companies_count = len(response.get('companies', []))
+
+ if company_name:
+ welcome_message = pad_message_for_wide_buttons(
+ f"**Autentificat cu succes**\n\n"
+ f"Bun venit, **{response['username']}**\n\n"
+ f"{company_name}"
+ )
+ else:
+ welcome_message = pad_message_for_wide_buttons(
+ f"**Autentificat cu succes**\n\n"
+ f"Bun venit, **{response['username']}**\n\n"
+ f"Companii disponibile: **{companies_count}**\n\n"
+ f"Selectează o companie pentru a continua"
+ )
+
+ # Send menu with buttons
+ await update.effective_chat.send_message(
+ welcome_message,
+ reply_markup=keyboard,
+ parse_mode="Markdown"
+ )
+
+ # 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 loading_msg.edit_text(
+ "Eroare la autentificare.\n\n"
+ "Te rugăm să încerci din nou cu /login"
+ )
+ return ConversationHandler.END
+
+
+# ============================================================================
+# CANCEL HANDLER
+# ============================================================================
+
+async def cancel_login(update: Update, context: ContextTypes.DEFAULT_TYPE):
+ """Cancel conversation"""
+ context.user_data.clear()
+
+ if update.message:
+ await update.message.reply_text(
+ "Autentificare anulată.\n\n"
+ "Folosește /login pentru a încerca din nou."
+ )
+ elif update.callback_query:
+ await update.callback_query.edit_message_text(
+ "Autentificare anulată."
+ )
+
+ return ConversationHandler.END
+
+
+async def conversation_timeout(update: Update, context: ContextTypes.DEFAULT_TYPE):
+ """Handler for conversation timeout"""
+ context.user_data.clear()
+
+ await update.effective_chat.send_message(
+ "**Sesiune expirată**\n\n"
+ "Conversația de autentificare a expirat după 5 minute de inactivitate.\n\n"
+ "Te rugăm să reîncepi cu /login",
+ parse_mode="Markdown"
+ )
+
+ 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"
+)
diff --git a/reports-app/telegram-bot/app/bot/handlers.py b/reports-app/telegram-bot/app/bot/handlers.py
index b0de287..280f72b 100644
--- a/reports-app/telegram-bot/app/bot/handlers.py
+++ b/reports-app/telegram-bot/app/bot/handlers.py
@@ -158,16 +158,24 @@ async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
)
else:
- # User not linked - show instructions with interactive buttons
- keyboard = InlineKeyboardMarkup([
- [InlineKeyboardButton("Cum obtin codul de link?", callback_data="login_help")],
- [InlineKeyboardButton("Am deja cod - Linkez contul", callback_data="login_prompt")]
- ])
+ # User not linked - show main menu with Login button
+ from app.bot.menus import create_main_menu, pad_message_for_wide_buttons
+
+ keyboard = create_main_menu(
+ company_name=None,
+ company_cui=None,
+ is_authenticated=False, # Not authenticated - shows Login button
+ cache_enabled=None
+ )
+
+ welcome_text = pad_message_for_wide_buttons(
+ "**Bun venit la ROA2WEB Bot**\n\n"
+ "Pentru a incepe, te rog să te autentifici.\n\n"
+ "Selectează o companie pentru a continua"
+ )
await update.message.reply_text(
- "**Bun venit la ROA2WEB Bot**\n\n"
- "Pentru a incepe, conecteaza contul tau ROA2WEB.\n\n"
- "Alege o optiune:",
+ welcome_text,
reply_markup=keyboard,
parse_mode=ParseMode.MARKDOWN
)
@@ -1792,41 +1800,8 @@ async def handle_action_callback(query, telegram_user_id: int, callback_data: st
parse_mode=ParseMode.MARKDOWN
)
- elif action_type == "login":
- # Prompt user to enter link code directly (same as login_prompt functionality)
- from telegram import ForceReply
- from app.bot.menus import pad_message_for_wide_buttons
-
- # Edit the current message with instructions
- login_text = pad_message_for_wide_buttons(
- "**Conectare Cont ROA2WEB**\n\n"
- "Trimite-mi codul primit din aplicația web.\n\n"
- "Poți trimite:\n"
- "• Doar codul: ABC12XYZ\n"
- "• Sau comanda: /start ABC12XYZ\n\n"
- "Codul expiră în 15 minute."
- )
-
- # Buttons for help or cancel
- keyboard = InlineKeyboardMarkup([
- [InlineKeyboardButton("Cum obțin codul?", callback_data="login_help")],
- [InlineKeyboardButton("« Anulează", callback_data="action:menu")]
- ])
-
- await query.edit_message_text(
- login_text,
- reply_markup=keyboard,
- parse_mode=ParseMode.MARKDOWN
- )
-
- # Send a follow-up message with ForceReply to prompt input
- await query.message.reply_text(
- "Scrie sau lipește codul aici:",
- reply_markup=ForceReply(
- selective=True,
- input_field_placeholder="ABC12XYZ"
- )
- )
+ # NOTE: action:login is handled by email_login_handler ConversationHandler
+ # See app/bot/email_handlers.py for the complete email authentication flow
async def handle_details_callback(query, telegram_user_id: int, callback_data: str):
@@ -1997,10 +1972,32 @@ async def button_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""
try:
query = update.callback_query
+ callback_data = query.data
+
+ # ========== IGNORE CALLBACKS HANDLED BY CONVERSATION HANDLER ==========
+ # These callbacks are managed by email_login_handler ConversationHandler
+ # Return immediately without answering to let ConversationHandler process them
+ conversation_patterns = [
+ 'action:login', # Login button from menu
+ 'email_login', # Email login button
+ 'web_login_info', # Web app login info button
+ 'cancel', # Cancel button
+ ]
+
+ # Check exact matches
+ if callback_data in conversation_patterns:
+ logger.info(f"[BUTTON_CALLBACK] Ignoring {callback_data} - handled by ConversationHandler")
+ return
+
+ # Check prefix matches (e.g., resend:email@example.com)
+ if callback_data.startswith('resend:'):
+ logger.info(f"[BUTTON_CALLBACK] Ignoring {callback_data} - handled by ConversationHandler")
+ return
+
+ # ========== PROCESS ALL OTHER CALLBACKS ==========
await query.answer()
telegram_user_id = update.effective_user.id
- callback_data = query.data
logger.info(f"Button callback: {callback_data} from user {telegram_user_id}")
diff --git a/reports-app/telegram-bot/app/bot/menus.py b/reports-app/telegram-bot/app/bot/menus.py
index 6c25c15..aa68782 100644
--- a/reports-app/telegram-bot/app/bot/menus.py
+++ b/reports-app/telegram-bot/app/bot/menus.py
@@ -207,42 +207,44 @@ def create_main_menu(
"""
keyboard = []
- # Row 1: Company selection (full width, single line - InlineKeyboardButton doesn't support multiline)
- if company_name:
- # Short company name for button (CUI and month will be shown in message text)
- # Truncate long names to fit in button
- max_length = 35
- display_name = company_name if len(company_name) <= max_length else company_name[:max_length-3] + "..."
+ # 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"
- )
- ])
+ 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")
- ]
- ])
+ # 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:
diff --git a/reports-app/telegram-bot/app/db/__init__.py b/reports-app/telegram-bot/app/db/__init__.py
index ddfe42d..7be9f02 100644
--- a/reports-app/telegram-bot/app/db/__init__.py
+++ b/reports-app/telegram-bot/app/db/__init__.py
@@ -12,6 +12,7 @@ from .database import (
get_db_connection,
cleanup_expired_codes,
cleanup_expired_sessions,
+ cleanup_expired_email_codes,
get_database_stats,
DB_PATH,
)
@@ -24,11 +25,19 @@ from .operations import (
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,
@@ -44,6 +53,7 @@ __all__ = [
'get_db_connection',
'cleanup_expired_codes',
'cleanup_expired_sessions',
+ 'cleanup_expired_email_codes',
'get_database_stats',
'DB_PATH',
# User operations
@@ -53,11 +63,19 @@ __all__ = [
'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',
diff --git a/reports-app/telegram-bot/app/db/database.py b/reports-app/telegram-bot/app/db/database.py
index cbcdb8f..2db991d 100644
--- a/reports-app/telegram-bot/app/db/database.py
+++ b/reports-app/telegram-bot/app/db/database.py
@@ -88,6 +88,22 @@ async def init_database() -> None:
)
""")
+ # 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
@@ -109,6 +125,22 @@ async def init_database() -> None:
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")
@@ -184,6 +216,40 @@ async def cleanup_expired_sessions() -> int:
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.
@@ -238,6 +304,7 @@ __all__ = [
'init_database',
'cleanup_expired_codes',
'cleanup_expired_sessions',
+ 'cleanup_expired_email_codes',
'get_database_stats',
'DB_PATH',
]
diff --git a/reports-app/telegram-bot/app/db/operations.py b/reports-app/telegram-bot/app/db/operations.py
index 939bf20..e51830f 100644
--- a/reports-app/telegram-bot/app/db/operations.py
+++ b/reports-app/telegram-bot/app/db/operations.py
@@ -234,6 +234,45 @@ async def is_user_linked(telegram_user_id: int) -> bool:
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
# ============================================================================
@@ -377,6 +416,181 @@ async def get_pending_codes_for_user(telegram_user_id: int) -> List[Dict[str, An
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
# ============================================================================
@@ -576,11 +790,19 @@ __all__ = [
'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',
diff --git a/reports-app/telegram-bot/app/main.py b/reports-app/telegram-bot/app/main.py
index 5a895b5..3a36630 100644
--- a/reports-app/telegram-bot/app/main.py
+++ b/reports-app/telegram-bot/app/main.py
@@ -30,7 +30,12 @@ from telegram.ext import (
)
# Import database initialization
-from app.db import init_database, cleanup_expired_codes, cleanup_expired_sessions
+from app.db import (
+ init_database,
+ cleanup_expired_codes,
+ cleanup_expired_sessions,
+ cleanup_expired_email_codes
+)
# Import bot handlers
from app.bot.handlers import (
@@ -61,6 +66,9 @@ from app.bot.handlers import (
error_handler
)
+# Import email authentication handler
+from app.bot.email_handlers import email_login_handler
+
# Import internal API
from app.internal_api import internal_api
@@ -93,6 +101,9 @@ def create_telegram_application() -> 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))
@@ -186,7 +197,8 @@ async def startup():
logger.info("Cleaning up expired data...")
expired_codes = await cleanup_expired_codes()
expired_sessions = await cleanup_expired_sessions()
- logger.info(f"✅ Cleanup complete: {expired_codes} codes, {expired_sessions} sessions removed")
+ 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}")
@@ -204,7 +216,7 @@ async def shutdown():
async def scheduled_cleanup():
"""
Background task to periodically clean up expired data.
- Runs every hour to remove expired auth codes and sessions.
+ Runs every hour to remove expired auth codes, sessions, and email codes.
"""
while True:
try:
@@ -212,7 +224,8 @@ async def scheduled_cleanup():
logger.info("🧹 Running scheduled cleanup...")
expired_codes = await cleanup_expired_codes()
expired_sessions = await cleanup_expired_sessions()
- logger.info(f"✅ Scheduled cleanup: {expired_codes} codes, {expired_sessions} sessions removed")
+ 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}")
diff --git a/reports-app/telegram-bot/app/utils/__init__.py b/reports-app/telegram-bot/app/utils/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/reports-app/telegram-bot/app/utils/email_service.py b/reports-app/telegram-bot/app/utils/email_service.py
new file mode 100644
index 0000000..126a2be
--- /dev/null
+++ b/reports-app/telegram-bot/app/utils/email_service.py
@@ -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"""
+
+
+
+
+
+
+
+
+
+
+
+
+
Salut {username},
+
+
Ai solicitat autentificarea în aplicația ROA2WEB Telegram Bot.
+
+
+
+ Codul tău de autentificare:
+
+
{code}
+
+ Introdu acest cod în conversația Telegram
+
+
+
+
+ Important: Acest cod expiră în 5 minute
+ și poate fi folosit o singură dată.
+
+
+
După introducerea codului, vei fi solicitat să introduci parola ta Oracle.
+
+
+
+
+ Nu ai solicitat acest cod?
+ 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.
+
+
+
+
+
+
+
+ """
+
+
+# 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
diff --git a/reports-app/telegram-bot/requirements.txt b/reports-app/telegram-bot/requirements.txt
index 5ea05fc..d49ebea 100644
--- a/reports-app/telegram-bot/requirements.txt
+++ b/reports-app/telegram-bot/requirements.txt
@@ -10,6 +10,9 @@ pydantic>=2.5.0
# Environment Variables
python-dotenv>=1.0.0
+# Email (SMTP)
+aiosmtplib>=3.0.0
+
# SQLite Async Database (STANDALONE)
aiosqlite>=0.19.0
diff --git a/start-dev.sh b/start-dev.sh
index 2d9eef1..dbe2f92 100644
--- a/start-dev.sh
+++ b/start-dev.sh
@@ -39,6 +39,76 @@ check_port() {
fi
}
+# Function to check if requirements.txt has changed
+check_requirements_changed() {
+ local requirements_file=$1
+ local checksum_file="${requirements_file}.checksum"
+
+ if [ ! -f "$requirements_file" ]; then
+ return 1 # Requirements file doesn't exist
+ fi
+
+ # Calculate current checksum
+ current_checksum=$(md5sum "$requirements_file" | cut -d' ' -f1)
+
+ # Check if checksum file exists and compare
+ if [ -f "$checksum_file" ]; then
+ stored_checksum=$(cat "$checksum_file")
+ if [ "$current_checksum" = "$stored_checksum" ]; then
+ return 1 # No change
+ fi
+ fi
+
+ return 0 # Changed or first time
+}
+
+# Function to save requirements checksum
+save_requirements_checksum() {
+ local requirements_file=$1
+ local checksum_file="${requirements_file}.checksum"
+
+ if [ -f "$requirements_file" ]; then
+ md5sum "$requirements_file" | cut -d' ' -f1 > "$checksum_file"
+ fi
+}
+
+# Function to install or update Python dependencies
+install_python_dependencies() {
+ local project_name=$1
+ local venv_path=$2
+ local requirements_file=$3
+
+ # Check if venv exists
+ if [ ! -d "$venv_path" ]; then
+ print_message "Creating Python virtual environment for $project_name..."
+ python3 -m venv "$venv_path"
+ fi
+
+ # Activate virtual environment
+ source "$venv_path/bin/activate"
+
+ # Check if requirements have changed or dependencies are missing
+ local should_install=false
+
+ if check_requirements_changed "$requirements_file"; then
+ print_message "Requirements changed for $project_name - updating dependencies..."
+ should_install=true
+ elif ! python -c "import sys; import importlib; [importlib.import_module(line.split('>=')[0].split('==')[0]) for line in open('$requirements_file').read().splitlines() if line and not line.startswith('#')]" 2>/dev/null; then
+ print_message "Missing dependencies detected for $project_name - installing..."
+ should_install=true
+ fi
+
+ if [ "$should_install" = true ]; then
+ print_message "Installing/updating $project_name dependencies..."
+ pip install --upgrade pip > /dev/null 2>&1
+ pip install -r "$requirements_file"
+ save_requirements_checksum "$requirements_file"
+ print_success "$project_name dependencies installed/updated successfully"
+ else
+ print_message "$project_name dependencies are up to date"
+ fi
+}
+
# Function to cleanup processes on exit
cleanup() {
print_message "Stopping services..."
@@ -284,15 +354,7 @@ start_service() {
fi
cd reports-app/backend/
- if [ ! -d "venv" ]; then
- print_message "Creating Python virtual environment..."
- python3 -m venv venv
- fi
- source venv/bin/activate
- if ! python -c "import fastapi, uvicorn" 2>/dev/null; then
- print_message "Installing backend dependencies..."
- pip install -r requirements.txt
- fi
+ install_python_dependencies "Backend" "venv" "requirements.txt"
print_message "Starting uvicorn server..."
# NOTE: --reload disabled for cache to work properly (global variables issue)
@@ -375,15 +437,7 @@ start_service() {
exit 1
fi
- if [ ! -d "venv" ]; then
- print_message "Creating Python virtual environment for Telegram bot..."
- python3 -m venv venv
- fi
- source venv/bin/activate
- if ! python -c "import telegram" 2>/dev/null; then
- print_message "Installing Telegram bot dependencies..."
- pip install -r requirements.txt
- fi
+ install_python_dependencies "Telegram Bot" "venv" "requirements.txt"
print_message "Starting Telegram bot..."
nohup python -m app.main > /tmp/roa2web_telegram.log 2>&1 &