Implement email-based 2FA authentication for Telegram bot with Oracle integration fixes
This commit adds a complete email authentication flow for the Telegram bot, allowing users to login with email + password instead of web app linking codes. Includes critical bug fixes for Oracle integration. **New Features:** - Email-based 2FA authentication with 6-digit codes sent via SMTP - Backend endpoints: verify-email and login-with-email - ConversationHandler for email authentication flow in Telegram bot - Session token verification to prevent user ID spoofing - Rate limiting (5 attempts per 5 minutes) - Email code expiry (5 minutes) with automatic cleanup **Bug Fixes:** - Fixed Oracle column name: ACTIV → INACTIV (with inverted logic) - Fixed Oracle password verification: verificautilizator returns checksum, not user_id - Fixed username case sensitivity: Oracle usernames must be uppercase - Fixed SMTP connection: use start_tls parameter instead of manual STARTTLS - Added middleware exclusions for public email auth endpoints **Backend Changes:** - Added verify-email endpoint (public) in telegram.py - Added login-with-email endpoint (public) with rate limiting and session verification - Updated middleware exclusions in main.py and auth_middleware_wrapper.py - Added AUTH_SESSION_SECRET configuration for session token signing **Telegram Bot Changes:** - New modules: app/auth/email_auth.py, app/bot/email_handlers.py - New utilities: app/utils/email_service.py (SMTP email sending) - Updated handlers.py: ignore callbacks handled by ConversationHandler - Updated menus.py: show Login button for unauthenticated users - Updated API client: verify_email() and login_with_email() methods - Database: email_auth_codes table with cleanup task **Configuration:** - Added SMTP configuration to telegram-bot .env.example - Added AUTH_SESSION_SECRET to backend .env.example - Updated .gitignore: exclude temporary files (*.pid, *.checksum, test scripts) **Dependencies:** - Added aiosmtplib for async SMTP email sending 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
667
reports-app/telegram-bot/app/bot/email_handlers.py
Normal file
667
reports-app/telegram-bot/app/bot/email_handlers.py
Normal file
@@ -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"
|
||||
)
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user