fix telegram

This commit is contained in:
Claude Agent
2026-02-23 15:12:33 +00:00
parent 6c78fec8a7
commit 8bc567a9c5
426 changed files with 112478 additions and 1 deletions

View File

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

View File

@@ -0,0 +1,693 @@
"""
Response formatters for bot commands.
Formats API responses into user-friendly Telegram messages.
"""
from typing import Dict, List, Any
def format_dashboard_response(data: Dict[str, Any], company_name: str = None) -> str:
"""
Format dashboard data for Telegram (content only, no header).
Note: company_name parameter kept for backwards compatibility but not used.
Use format_response_with_company() in handlers to add company header.
"""
text = ""
# Sold total trezorerie (casa + banca) - rotunjit la leu
treasury_totals = data.get('treasury_totals_by_currency', {})
sold_trezorerie = round(float(treasury_totals.get('RON', 0)))
text += f"**Sold Trezorerie:** {sold_trezorerie:,}\n\n"
# Sold Clienți - rotunjit la leu
clienti_sold = round(float(data.get('clienti_sold_total', 0)))
clienti_in_termen = round(float(data.get('clienti_sold_in_termen', 0)))
clienti_restant = round(float(data.get('clienti_sold_restant', 0)))
text += f"**Sold Clienți:** {clienti_sold:,}\n"
text += f" - În termen: {clienti_in_termen:,}\n"
text += f" - Restanță: {clienti_restant:,}\n\n"
# Sold Furnizori BRUT (pentru consistență cu detaliile) - rotunjit la leu
furnizori_in_termen = round(float(data.get('furnizori_sold_in_termen', 0)))
furnizori_restant = round(float(data.get('furnizori_sold_restant', 0)))
furnizori_sold_brut = furnizori_in_termen + furnizori_restant
furnizori_avansuri = round(float(data.get('furnizori_avansuri', 0)))
furnizori_sold_net = round(float(data.get('furnizori_sold_total', 0)))
text += f"**Sold Furnizori:** {furnizori_sold_brut:,}\n"
text += f" - În termen: {furnizori_in_termen:,}\n"
text += f" - Restanță: {furnizori_restant:,}\n"
if furnizori_avansuri != 0:
text += f" - Avansuri: {furnizori_avansuri:,}\n"
text += f" - Net (după avansuri): {furnizori_sold_net:,}"
else:
text += f" - Net: {furnizori_sold_net:,}"
# Datorii la Buget - două secțiuni: luna precedentă și luna curentă
budget_breakdown = data.get('budget_debt_breakdown', [])
if budget_breakdown:
grupe_prec = [g for g in budget_breakdown if round(float(g.get('datorat', g.get('precedent', 0)))) > 0]
grupe_crt = [g for g in budget_breakdown if round(float(g.get('curent', 0))) > 0]
if grupe_prec or grupe_crt:
text += "\n\n**Datorii la Buget:**\n"
if grupe_prec:
total_sold = sum(round(float(g.get('sold', 0))) for g in grupe_prec)
total_dat = sum(round(float(g.get('datorat', g.get('precedent', 0)))) for g in grupe_prec)
sold_total_str = f"{total_sold:,}" if total_sold > 0 else "0 \u2713"
text += f"\n _Precedent: dat: {total_dat:,}, sold: {sold_total_str}_\n"
for g in grupe_prec:
datorat = round(float(g.get('datorat', g.get('precedent', 0))))
sold = round(float(g.get('sold', 0)))
label = g.get('label', '')
sold_str = f"{sold:,}" if sold > 0 else "0 \u2713"
text += f" {label:<6} {datorat:,} · {sold_str}\n"
if grupe_crt:
items = [f"{g.get('label', '')} {round(float(g.get('curent', 0))):,}" for g in grupe_crt]
total_crt = sum(round(float(g.get('curent', 0))) for g in grupe_crt)
sep = ' · '
text += f"\n _Curent: {sep.join(items)} = {total_crt:,}_\n"
return text
def format_invoices_response(
invoices: List[Dict[str, Any]],
company_name: str = None,
limit: int = 10
) -> str:
"""
Format invoices list for Telegram - COMPACT TABLE FORMAT.
Args:
invoices: List of invoice dicts
company_name: Company name (kept for compatibility, not used)
limit: Maximum number of invoices to display
Returns:
Formatted Markdown string for Telegram (compact, no emojis)
"""
if not invoices:
return "Nu s-au gasit facturi cu aceste criterii."
# Header (o singură dată)
text = f"**Facturi** ({len(invoices)} total)\n\n"
text += "Nr | Client | Suma | Status\n"
text += "---|--------|------|-------\n"
# Lista facturi - compact, o linie per factură
for idx, inv in enumerate(invoices[:limit], 1):
seria = inv.get('seria', '')
numar = inv.get('numar', '')
client = inv.get('client', 'N/A')
suma = inv.get('suma_totala', 0)
status = inv.get('status', 'N/A')
# Truncate long client names for compact display
client_short = client[:20] + "..." if len(client) > 20 else client
# Status marker (no emoji)
status_marker = "PLATIT" if status == "platit" else "NEPLATIT"
text += f"{seria}{numar} | {client_short} | {suma:,.0f} | {status_marker}\n"
if len(invoices) > limit:
text += f"\n+{len(invoices) - limit} facturi"
return text
# =========================================================================
# FAZA 2: New Formatter Functions for Button Interface
# =========================================================================
def format_treasury_casa_response(data: Dict[str, Any], company_name: str = None) -> str:
"""
Format treasury CASH data for Telegram (content only, no header).
Args:
data: Dict with casa accounts and total from treasury breakdown
company_name: Company name (kept for compatibility, not used)
Returns:
Formatted Markdown string for Telegram
Example:
data = {'accounts': [...], 'total': 5000}
text = format_treasury_casa_response(data)
"""
text = ""
# Total cash balance - rotunjit la leu (0 zecimale)
total_cash = round(data.get('total', 0))
text += f"**Sold Total Cash:** {total_cash:,} RON\n\n"
# Cash accounts
casa_accounts = data.get('accounts', [])
if casa_accounts:
text += "**Conturi de Casa:**\n"
for acc in casa_accounts: # Show all accounts
name = acc.get('name', 'N/A')
balance = round(acc.get('balance', 0))
text += f" - {name}: {balance:,} RON\n"
else:
text += "Nu exista conturi de casa configurate."
return text
def format_treasury_banca_response(data: Dict[str, Any], company_name: str = None) -> str:
"""
Format treasury BANK data for Telegram (content only, no header).
Args:
data: Dict with banca accounts and total from treasury breakdown
company_name: Company name (kept for compatibility, not used)
Returns:
Formatted Markdown string for Telegram
Example:
data = {'accounts': [...], 'total': 15000}
text = format_treasury_banca_response(data)
"""
text = ""
# Total bank balance - rotunjit la leu (0 zecimale)
total_bank = round(data.get('total', 0))
text += f"**Sold Total Banca:** {total_bank:,} RON\n\n"
# Bank accounts
bank_accounts = data.get('accounts', [])
if bank_accounts:
text += "**Conturi Bancare:**\n"
for acc in bank_accounts: # Show all accounts
name = acc.get('name', 'N/A')
balance = round(acc.get('balance', 0))
text += f" - {name}: {balance:,} RON\n"
else:
text += "Nu exista conturi bancare configurate."
return text
def format_treasury_combined_response(data: Dict[str, Any], company_name: str = None) -> str:
"""
Format combined treasury data (Casa + Banca) for Telegram.
Shows grand total, Casa section with accounts, and Banca section with accounts
in a single unified message. Compact format without section titles.
Args:
data: Dict with 'casa' and 'banca' keys from get_treasury_breakdown_split()
company_name: Company name (kept for compatibility, not used)
Returns:
Formatted Markdown string with grand total and both sections
Example:
data = {'casa': {...}, 'banca': {...}}
text = format_treasury_combined_response(data)
"""
def format_amount(amount: int) -> str:
"""Format amount with period as thousands separator (Romanian style)."""
return f"{amount:,}".replace(",", ".")
text = ""
# Extract totals - rounded to whole RON
casa_total = round(data.get('casa', {}).get('total', 0))
banca_total = round(data.get('banca', {}).get('total', 0))
grand_total = casa_total + banca_total
# Grand total header
text += f"**Sold Trezorerie:** {format_amount(grand_total)} RON\n\n"
# Casa section - compact
text += f"**Casa:** {format_amount(casa_total)} RON\n"
casa_accounts = data.get('casa', {}).get('accounts', [])
if casa_accounts:
for acc in casa_accounts:
name = acc.get('name', 'N/A')
balance = round(acc.get('balance', 0))
text += f" - {name}: {format_amount(balance)} RON\n"
text += "\n"
# Banca section - compact
text += f"**Banca:** {format_amount(banca_total)} RON\n"
banca_accounts = data.get('banca', {}).get('accounts', [])
if banca_accounts:
for acc in banca_accounts:
name = acc.get('name', 'N/A')
balance = round(acc.get('balance', 0))
text += f" - {name}: {format_amount(balance)} RON\n"
return text
def format_clients_balance_response(
clients: List[Dict[str, Any]],
maturity_data: Dict[str, Any],
company_name: str = None
) -> str:
"""
Format clients balance with maturity breakdown (content only, no header).
Args:
clients: List of client dicts with id, name, balance
maturity_data: Dict with in_term, overdue, total
company_name: Company name (kept for compatibility, not used)
Returns:
Formatted Markdown string for Telegram
Example:
clients = [{'id': 1, 'name': 'Client A', 'balance': 15000}]
maturity = {'in_term': 10000, 'overdue': 5000, 'total': 15000}
text = format_clients_balance_response(clients, maturity)
"""
text = ""
# Maturity breakdown - rotunjit la leu (0 zecimale)
total = round(maturity_data.get('total', 0))
in_term = round(maturity_data.get('in_term', 0))
overdue = round(maturity_data.get('overdue', 0))
text += f"**Sold Total:** {total:,} RON\n\n"
text += "**Defalcare:**\n"
text += f" - In termen: {in_term:,} RON\n"
text += f" - Restanta: {overdue:,} RON\n\n"
# Top 10 clients
if clients:
text += f"**Top 10 Clienti** ({len(clients)} total):\n"
# Sort by balance descending
sorted_clients = sorted(
clients,
key=lambda x: x.get('balance', 0),
reverse=True
)
for idx, client in enumerate(sorted_clients[:10], 1):
name = client.get('name', 'N/A')
balance = round(client.get('balance', 0))
text += f"{idx}. {name}: {balance:,} RON\n"
if len(clients) > 10:
text += f"\nApasa butonul pentru lista completa"
else:
text += "Nu exista clienti cu solduri."
return text
def format_suppliers_balance_response(
suppliers: List[Dict[str, Any]],
maturity_data: Dict[str, Any],
company_name: str = None
) -> str:
"""
Format suppliers balance with maturity breakdown (content only, no header).
Args:
suppliers: List of supplier dicts with id, name, balance
maturity_data: Dict with in_term, overdue, total
company_name: Company name (kept for compatibility, not used)
Returns:
Formatted Markdown string for Telegram
Example:
suppliers = [{'id': 1, 'name': 'Supplier A', 'balance': 5000}]
maturity = {'in_term': 4000, 'overdue': 1000, 'total': 5000}
text = format_suppliers_balance_response(suppliers, maturity)
"""
text = ""
# Maturity breakdown - rotunjit la leu (0 zecimale)
total = round(maturity_data.get('total', 0))
in_term = round(maturity_data.get('in_term', 0))
overdue = round(maturity_data.get('overdue', 0))
text += f"**Sold Total:** {total:,} RON\n\n"
text += "**Defalcare:**\n"
text += f" - In termen: {in_term:,} RON\n"
text += f" - Restanta: {overdue:,} RON\n\n"
# Top 10 suppliers
if suppliers:
text += f"**Top 10 Furnizori** ({len(suppliers)} total):\n"
# Sort by balance descending
sorted_suppliers = sorted(
suppliers,
key=lambda x: x.get('balance', 0),
reverse=True
)
for idx, supplier in enumerate(sorted_suppliers[:10], 1):
name = supplier.get('name', 'N/A')
balance = round(supplier.get('balance', 0))
text += f"{idx}. {name}: {balance:,} RON\n"
if len(suppliers) > 10:
text += f"\nApasa butonul pentru lista completa"
else:
text += "Nu exista furnizori cu solduri."
return text
def format_cashflow_evolution_response(
performance_data: Dict[str, Any],
monthly_data: Dict[str, Any],
company_name: str = None
) -> str:
"""
Format cash flow evolution data - Table format with mini-charts.
Args:
performance_data: Dict with current_year and previous_year YTD data
monthly_data: Dict with months, incasari, plati arrays + prev year data
company_name: Company name (kept for compatibility, not used)
Returns:
Formatted Markdown string for Telegram (monospace table)
Example:
YTD 2024 vs 2023:
2024 2023 Δ Trend
Inc: 500,000 480,000 +4.2% ████░
Plt: 450,000 440,000 +2.3% ███░
Net: 50,000 40,000 +25.0% █████
"""
text = ""
# Helper functions
def calc_percent_change(current: float, previous: float) -> str:
"""Calculate percentage change: +4.2% or -3.5%"""
if previous == 0:
return "+100%" if current > 0 else "0.0%"
change = ((current - previous) / previous) * 100
sign = "+" if change >= 0 else ""
return f"{sign}{change:.1f}%"
def create_mini_chart(current: float, previous: float, width: int = 5) -> str:
"""Create mini bar chart: ████░ (proportional bars)"""
if current == 0 and previous == 0:
return "" * width
max_val = max(current, previous)
if max_val == 0:
return "" * width
curr_bars = int((current / max_val) * width)
prev_bars = int((previous / max_val) * width)
# Use filled and light blocks
filled = "" * curr_bars
light = "" * (width - curr_bars)
return filled + light
def get_trend_arrow(current: float, previous: float) -> str:
"""Get trend arrow: ↑ or ↓ or →"""
if current > previous * 1.02: # More than 2% increase
return ""
elif current < previous * 0.98: # More than 2% decrease
return ""
else:
return ""
# Extract YTD data
current = performance_data.get('current_year', {})
previous = performance_data.get('previous_year', {})
current_year = current.get('year', '2024')
previous_year = previous.get('year', '2023')
inc_cur = round(current.get('incasari', 0))
plt_cur = round(current.get('plati', 0))
net_cur = round(current.get('net', 0))
inc_prev = round(previous.get('incasari', 0))
plt_prev = round(previous.get('plati', 0))
net_prev = round(previous.get('net', 0))
# YTD Table Header
text += f"**YTD {current_year} vs {previous_year}:**\n"
text += f"` {current_year:>10} {previous_year:>10} Δ `\n"
# YTD Rows
inc_pct = calc_percent_change(inc_cur, inc_prev)
text += f"`Inc: {inc_cur:>10,} {inc_prev:>10,} {inc_pct:>6}`\n"
plt_pct = calc_percent_change(plt_cur, plt_prev)
text += f"`Plt: {plt_cur:>10,} {plt_prev:>10,} {plt_pct:>6}`\n"
net_pct = calc_percent_change(net_cur, net_prev)
text += f"`Net: {net_cur:>10,} {net_prev:>10,} {net_pct:>6}`\n\n"
# Monthly Evolution Table - Simplified (Net only)
months = monthly_data.get('months', [])
incasari = monthly_data.get('incasari', [])
plati = monthly_data.get('plati', [])
incasari_prev = monthly_data.get('incasari_prev', [])
plati_prev = monthly_data.get('plati_prev', [])
if months and len(months) > 0:
text += "**Evolutie Net (12 luni):**\n"
text += f"` {current_year:>10} {previous_year:>10} Δ `\n"
for i, month in enumerate(months):
inc = incasari[i] if i < len(incasari) else 0
plt = plati[i] if i < len(plati) else 0
inc_p = incasari_prev[i] if i < len(incasari_prev) else 0
plt_p = plati_prev[i] if i < len(plati_prev) else 0
net = inc - plt
net_p = inc_p - plt_p
# Extract short month name (first 3 chars before apostrophe)
month_short = month.split("'")[0][:3] if "'" in month else month[:3]
# Calculate percentage change
net_pct = calc_percent_change(net, net_p)
# Format row: Luna Net'current Net'prev Δ (aligned with YTD)
text += f"`{month_short:<4} {int(net):>10,} {int(net_p):>10,} {net_pct:>6}`\n"
else:
text += "Nu exista date lunare disponibile."
return text
def format_client_detail_response(
client: Dict[str, Any],
invoices: List[Dict[str, Any]],
company_name: str = None
) -> str:
"""
Format client details with invoices - COMPACT TABLE FORMAT.
Args:
client: Dict with client info (id, name, balance)
invoices: List of invoice dicts for this client
company_name: Company name (kept for compatibility, not used)
Returns:
Formatted Markdown string for Telegram (compact, no emojis)
Example:
client = {'id': 1, 'name': 'Client A', 'balance': 15000}
invoices = [{'id': 1, 'number': 'FV001', 'amount': 5000, 'status': 'unpaid'}]
text = format_client_detail_response(client, invoices)
"""
client_name = client.get('name', 'N/A')
balance = client.get('balance', 0)
# Header with client info
text = f"**{client_name}**\n"
text += f"**Sold total: {balance:,.2f} RON**"
if invoices and len(invoices) > 1:
text += f"{len(invoices)} facturi"
text += "\n\n"
# Invoices - compact table format (no emojis)
if invoices:
from datetime import datetime
# Sort invoices by date (most recent first)
sorted_invoices = sorted(invoices, key=lambda x: x.get('dataact') or datetime.min, reverse=True)
# Invoice list - simple format without table
text += "Facturi cu sold:\n"
text += "━━━━━━━━━━━━━━━━━━━━\n"
# Invoice rows - one line each, simple format
for inv in sorted_invoices[:10]:
# Backend returns: nract, totctva, soldfinal, datascad, dataact, achitat
number = str(inv.get('nract', 'N/A'))
dataact = inv.get('dataact')
# Parse date - handle various formats to ensure dd.mm.yyyy
if dataact:
if isinstance(dataact, str):
try:
# Try ISO format first: "2024-10-25" or "2024-10-25 00:00:00"
if '-' in dataact and len(dataact) >= 10:
parsed_date = datetime.strptime(dataact[:10], '%Y-%m-%d')
date_str = parsed_date.strftime('%d.%m.%Y')
# Already in dd.mm.yyyy format
elif '.' in dataact:
date_str = dataact.split()[0][:10] # Take just date part
else:
date_str = dataact[:10] if len(dataact) >= 10 else dataact
except:
date_str = dataact[:10] if len(dataact) >= 10 else dataact
else:
# Datetime object - format as dd.mm.yyyy
date_str = dataact.strftime('%d.%m.%Y')
else:
date_str = 'N/A'
sold = float(inv.get('soldfinal', 0) or 0)
# Simple format: Nr • Data • Sold
text += f"Nr {number}{date_str}{sold:,.2f} RON\n"
if len(invoices) > 10:
text += f"\n\n+{len(invoices) - 10} facturi"
else:
text += "Nu exista facturi neachitate"
return text
def format_supplier_detail_response(
supplier: Dict[str, Any],
invoices: List[Dict[str, Any]],
company_name: str = None
) -> str:
"""
Format supplier details with invoices - COMPACT TABLE FORMAT.
Args:
supplier: Dict with supplier info (id, name, balance)
invoices: List of invoice dicts for this supplier
company_name: Company name (kept for compatibility, not used)
Returns:
Formatted Markdown string for Telegram (compact, no emojis)
Example:
supplier = {'id': 1, 'name': 'Supplier A', 'balance': 5000}
invoices = [{'id': 1, 'number': 'FC001', 'amount': 2000, 'status': 'unpaid'}]
text = format_supplier_detail_response(supplier, invoices)
"""
supplier_name = supplier.get('name', 'N/A')
balance = supplier.get('balance', 0)
# Header with supplier info
text = f"**{supplier_name}**\n"
text += f"**Sold total: {balance:,.2f} RON**"
if invoices and len(invoices) > 1:
text += f"{len(invoices)} facturi"
text += "\n\n"
# Invoices - compact table format (no emojis)
if invoices:
from datetime import datetime
# Sort invoices by date (most recent first)
sorted_invoices = sorted(invoices, key=lambda x: x.get('dataact') or datetime.min, reverse=True)
# Invoice list - simple format without table
text += "Facturi cu sold:\n"
text += "━━━━━━━━━━━━━━━━━━━━\n"
# Invoice rows - one line each, simple format
for inv in sorted_invoices[:10]:
# Backend returns: nract, totctva, soldfinal, datascad, dataact, achitat
number = str(inv.get('nract', 'N/A'))
dataact = inv.get('dataact')
# Parse date - handle various formats to ensure dd.mm.yyyy
if dataact:
if isinstance(dataact, str):
try:
# Try ISO format first: "2024-10-25" or "2024-10-25 00:00:00"
if '-' in dataact and len(dataact) >= 10:
parsed_date = datetime.strptime(dataact[:10], '%Y-%m-%d')
date_str = parsed_date.strftime('%d.%m.%Y')
# Already in dd.mm.yyyy format
elif '.' in dataact:
date_str = dataact.split()[0][:10] # Take just date part
else:
date_str = dataact[:10] if len(dataact) >= 10 else dataact
except:
date_str = dataact[:10] if len(dataact) >= 10 else dataact
else:
# Datetime object - format as dd.mm.yyyy
date_str = dataact.strftime('%d.%m.%Y')
else:
date_str = 'N/A'
sold = float(inv.get('soldfinal', 0) or 0)
# Simple format: Nr • Data • Sold
text += f"Nr {number}{date_str}{sold:,.2f} RON\n"
if len(invoices) > 10:
text += f"\n\n+{len(invoices) - 10} facturi"
else:
text += "Nu exista facturi neachitate"
return text
# =========================================================================
# FAZA 6: Performance Footer for Cache Monitoring
# =========================================================================
def add_performance_footer(message: str, cache_hit: bool, time_ms: float, cache_source: str = None) -> str:
"""
Add compact performance footer to bot responses.
Shows data source (cached L1/L2 or database) and response time.
Format: "cached L1 | 15ms", "cached L2 | 25ms" or "db | 285ms"
Args:
message: Existing message text
cache_hit: True if data came from cache
time_ms: Response time in milliseconds
cache_source: Cache source ("L1" for memory, "L2" for SQLite) if cache_hit is True
Returns:
Message with performance footer appended
Example:
>>> add_performance_footer("Dashboard data...", True, 52.3, "L1")
"Dashboard data...\n\ncached L1 | 52ms"
>>> add_performance_footer("Dashboard data...", True, 25.8, "L2")
"Dashboard data...\n\ncached L2 | 26ms"
>>> add_performance_footer("Dashboard data...", False, 285.7)
"Dashboard data...\n\ndb | 286ms"
"""
if cache_hit and cache_source:
source = f"cached {cache_source}"
elif cache_hit:
source = "cached" # Fallback if source not provided
else:
source = "db"
footer = f"\n\n`{source} | {time_ms:.0f}ms`"
return message + footer

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,877 @@
"""
Helper functions for Telegram bot command handlers.
Provides utilities for company selection, API calls, and response formatting.
"""
import logging
from typing import Optional, Dict, List, Any
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
from backend.modules.telegram.api.client import get_backend_client
from backend.modules.telegram.agent.session import SessionManager
from backend.modules.telegram.bot.menus import pad_message_for_wide_buttons
logger = logging.getLogger(__name__)
async def get_active_company_or_prompt(
update: Update,
session_manager: SessionManager,
telegram_user_id: int
) -> Optional[Dict[str, Any]]:
"""
Get active company from session or prompt user to select one with buttons.
This function checks if the user has an active company set in their session.
If not, it fetches companies and displays selection buttons directly.
Args:
update: Telegram Update object (for sending messages)
session_manager: SessionManager instance
telegram_user_id: Telegram user ID
Returns:
Dict with company info (id, name, cui) if set, None if user needs to select
Example:
company = await get_active_company_or_prompt(update, session_manager, user_id)
if not company:
return # User was shown company selection buttons
# Continue with company operations...
"""
session = await session_manager.get_or_create_session(telegram_user_id)
company = session.get_active_company()
if not company:
# Get auth data and companies
from backend.modules.telegram.auth.linking import get_user_auth_data
auth_data = await get_user_auth_data(telegram_user_id)
jwt_token = auth_data['jwt_token']
client = get_backend_client()
async with client:
companies = await client.get_user_companies(jwt_token=jwt_token)
if companies:
keyboard = create_company_selection_keyboard_paginated(companies, page=0)
message = (
f"**Selecteaza mai intai o companie**\n\n"
f"Companiile tale ({len(companies)}):"
)
# Apply padding to make inline keyboard buttons wider
message = pad_message_for_wide_buttons(message)
await update.message.reply_text(
message,
reply_markup=keyboard,
parse_mode="Markdown"
)
else:
await update.message.reply_text(
"Nu ai acces la nicio companie.\n"
"Contacteaza administratorul.",
parse_mode="Markdown"
)
return None
return company
async def search_companies_by_name(
name_query: str,
jwt_token: str
) -> List[Dict[str, Any]]:
"""
Search companies by partial name match (case-insensitive).
Fetches all companies from backend and filters them by name.
Uses case-insensitive partial matching for flexible search.
Args:
name_query: Search term (partial match, e.g., "ACME")
jwt_token: JWT authentication token
Returns:
List of matching company dicts (each with id, nume_firma, cui, etc.)
Example:
companies = await search_companies_by_name("acme", token)
# Returns all companies with "acme" in their name (case-insensitive)
"""
client = get_backend_client()
async with client:
all_companies = await client.get_user_companies(jwt_token=jwt_token)
# Filter by name (case-insensitive partial match)
query_lower = name_query.lower()
matches = [
comp for comp in all_companies
if query_lower in comp.get('name', comp.get('nume_firma', '')).lower()
]
logger.info(
f"Search '{name_query}': {len(matches)} matches out of {len(all_companies)} total"
)
return matches
def create_company_selection_keyboard(
companies: List[Dict[str, Any]],
max_buttons: int = 10
) -> InlineKeyboardMarkup:
"""
Create inline keyboard for company selection (legacy - without pagination).
Generates a vertical list of buttons, one per company.
Each button shows company name and CUI, and triggers a callback.
NOTE: This function is deprecated in favor of create_company_selection_keyboard_paginated.
It's kept for backwards compatibility only.
Args:
companies: List of company dicts (with id, nume_firma, cui)
max_buttons: Maximum number of buttons to show (default: 10)
Returns:
InlineKeyboardMarkup with company selection buttons
Example:
keyboard = create_company_selection_keyboard(companies)
await update.message.reply_text("Select company:", reply_markup=keyboard)
"""
keyboard = []
for company in companies[:max_buttons]:
company_id = company.get('id_firma', company.get('id'))
company_name = company.get('name', company.get('nume_firma', 'N/A'))
company_cui = company.get('fiscal_code', company.get('cui', ''))
# Button text: "ACME SRL (CUI: 12345)"
button_text = f"{company_name}"
if company_cui:
button_text += f" ({company_cui})"
# Callback data: "select_company:123"
callback_data = f"select_company:{company_id}"
keyboard.append([InlineKeyboardButton(button_text, callback_data=callback_data)])
# Add overflow indicator if there are more companies
if len(companies) > max_buttons:
keyboard.append([InlineKeyboardButton(
f"... și încă {len(companies) - max_buttons} companii",
callback_data="noop"
)])
return InlineKeyboardMarkup(keyboard)
def create_company_selection_keyboard_paginated(
companies: List[Dict[str, Any]],
page: int = 0,
per_page: int = 20,
back_callback: str = "action:menu",
page_callback_prefix: str = "select_company_page",
page_callback_suffix: str = ""
) -> InlineKeyboardMarkup:
"""
Create paginated inline keyboard for company selection.
Generates a vertical list of buttons for one page of companies,
with navigation buttons for previous/next pages.
Args:
companies: Full list of company dicts (with id, nume_firma, cui)
page: Current page number (0-indexed)
per_page: Number of companies per page (default: 20)
back_callback: Callback data for the back button (default: "action:menu")
page_callback_prefix: Prefix for pagination callbacks (default: "select_company_page")
page_callback_suffix: Suffix appended after page number in pagination callbacks
Returns:
InlineKeyboardMarkup with company buttons and pagination controls
Example:
keyboard = create_company_selection_keyboard_paginated(companies, page=0)
await update.message.reply_text("Select company:", reply_markup=keyboard)
"""
keyboard = []
# Calculate pagination
total_companies = len(companies)
total_pages = (total_companies + per_page - 1) // per_page # Ceiling division
start_idx = page * per_page
end_idx = min(start_idx + per_page, total_companies)
# Display companies for current page
page_companies = companies[start_idx:end_idx]
for company in page_companies:
company_id = company.get('id_firma', company.get('id'))
company_name = company.get('name', company.get('nume_firma', 'N/A'))
company_cui = company.get('fiscal_code', company.get('cui', ''))
# Button text: "ACME SRL (CUI: 12345)"
button_text = f"{company_name}"
if company_cui:
button_text += f" ({company_cui})"
# Callback data: "select_company:123"
callback_data = f"select_company:{company_id}"
keyboard.append([InlineKeyboardButton(button_text, callback_data=callback_data)])
# Pagination controls (only if more than one page)
if total_pages > 1:
nav_buttons = []
# Previous button
if page > 0:
prev_cb = f"{page_callback_prefix}:{page-1}{page_callback_suffix}"
nav_buttons.append(
InlineKeyboardButton("< Anterior", callback_data=prev_cb)
)
# Page indicator (non-clickable)
nav_buttons.append(
InlineKeyboardButton(f"Pagina {page+1}/{total_pages}", callback_data="noop")
)
# Next button
if page < total_pages - 1:
next_cb = f"{page_callback_prefix}:{page+1}{page_callback_suffix}"
nav_buttons.append(
InlineKeyboardButton("Urmator >", callback_data=next_cb)
)
keyboard.append(nav_buttons)
# A-Z filter + back button
keyboard.append([
InlineKeyboardButton("Filtrare A-Z", callback_data="select_company_alpha_menu")
])
keyboard.append([
InlineKeyboardButton("« Înapoi", callback_data=back_callback)
])
return InlineKeyboardMarkup(keyboard)
def create_alpha_filter_keyboard() -> InlineKeyboardMarkup:
"""
Create inline keyboard with AZ letter buttons for filtering companies.
Displays 26 letter buttons in rows of 6, plus a 'Toată lista' button
that shows all companies without filtering.
Returns:
InlineKeyboardMarkup with letter buttons and navigation
"""
letters = list("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
keyboard = []
row_size = 6
for i in range(0, len(letters), row_size):
row = [
InlineKeyboardButton(l, callback_data=f"select_company_alpha:{l}")
for l in letters[i:i + row_size]
]
keyboard.append(row)
keyboard.append([
InlineKeyboardButton("Toată lista", callback_data="select_company_alpha:ALL"),
InlineKeyboardButton("« Meniu", callback_data="action:menu")
])
return InlineKeyboardMarkup(keyboard)
def create_alpha_filter_keyboard_partner(partner_type: str) -> InlineKeyboardMarkup:
"""
Create inline keyboard with AZ letter buttons for filtering clients or suppliers.
Args:
partner_type: "clients" or "suppliers"
Returns:
InlineKeyboardMarkup with letter buttons and navigation
"""
letters = list("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
keyboard = []
row_size = 6
for i in range(0, len(letters), row_size):
row = [
InlineKeyboardButton(l, callback_data=f"{partner_type}_alpha:{l}")
for l in letters[i:i + row_size]
]
keyboard.append(row)
keyboard.append([
InlineKeyboardButton("Toata lista", callback_data=f"{partner_type}_alpha:ALL"),
InlineKeyboardButton("« Meniu", callback_data="action:menu")
])
return InlineKeyboardMarkup(keyboard)
def format_company_context_footer(company_name: str) -> str:
"""
Format discrete footer with company context.
Adds a subtle footer to command responses showing the active company
and a quick link to change it.
Args:
company_name: Active company name
Returns:
Formatted footer string with separator and company name
Example:
footer = format_company_context_footer("ACME SRL")
message = f"Dashboard data...\n{footer}"
# Output: "Dashboard data...\n\n━━━━━━━━━━━━━━\nCompanie: ACME SRL"
"""
return f"\n\n━━━━━━━━━━━━━━\nCompanie: {company_name}"
# =========================================================================
# FAZA 2: New Helper Functions for Button Interface
# =========================================================================
async def get_treasury_breakdown_split(
company_id: int,
jwt_token: str
) -> Optional[Dict[str, Any]]:
"""
Get treasury breakdown split into casa and banca.
Fetches treasury breakdown from backend and transforms it
to the format expected by formatters.
Backend returns:
{
"total": float,
"breakdown": {
"casa": {"total": float, "items": [{"nume": str, "cont": str, "sold": float}]},
"banca": {"total": float, "items": [{"nume": str, "cont": str, "sold": float}]}
},
"currency": "RON"
}
Args:
company_id: Company ID
jwt_token: JWT authentication token
Returns:
Dict with two keys:
- 'casa': Dict with 'accounts' (list) and 'total' (float)
- 'banca': Dict with 'accounts' (list) and 'total' (float)
None if request fails
Example:
data = await get_treasury_breakdown_split(1, token)
casa_total = data['casa']['total'] # Total cash balance
bank_accounts = data['banca']['accounts'] # List of bank accounts
"""
try:
client = get_backend_client()
async with client:
breakdown = await client.get_treasury_breakdown(
company_id=company_id,
jwt_token=jwt_token
)
if not breakdown:
return None
# Backend already splits data into casa and banca
# Transform backend structure to match formatter expectations
breakdown_data = breakdown.get('breakdown', {})
casa_data = breakdown_data.get('casa', {})
banca_data = breakdown_data.get('banca', {})
# Transform items to accounts format (nume->name, sold->balance)
casa_accounts = [
{
'name': item.get('nume', f"Cont {item.get('cont', 'N/A')}"),
'balance': float(item.get('sold', 0)),
'cont': item.get('cont', '')
}
for item in casa_data.get('items', [])
]
banca_accounts = [
{
'name': item.get('nume', f"Cont {item.get('cont', 'N/A')}"),
'balance': float(item.get('sold', 0)),
'cont': item.get('cont', '')
}
for item in banca_data.get('items', [])
]
result = {
'casa': {
'accounts': casa_accounts,
'total': float(casa_data.get('total', 0))
},
'banca': {
'accounts': banca_accounts,
'total': float(banca_data.get('total', 0))
}
}
# Pass through cache metadata if present
if 'cache_hit' in breakdown:
result['cache_hit'] = breakdown['cache_hit']
if 'response_time_ms' in breakdown:
result['response_time_ms'] = breakdown['response_time_ms']
if 'cache_source' in breakdown:
result['cache_source'] = breakdown['cache_source']
return result
except Exception as e:
logger.error(f"Error getting treasury breakdown split: {e}", exc_info=True)
return None
async def get_clients_with_maturity(
company_id: int,
jwt_token: str
) -> Optional[Dict[str, Any]]:
"""
Get clients list with maturity breakdown.
Uses maturity analysis endpoint which returns client summaries
with amounts and overdue status.
Backend returns:
{
"clients": [{"name": str, "amount": float, "dueDate": str, "daysOverdue": int}],
"suppliers": [...],
"balance": float,
"metadata": {...}
}
Args:
company_id: Company ID
jwt_token: JWT authentication token
Returns:
Dict with:
- 'clients': List of client dicts (id, name, balance)
- 'maturity': Dict with 'in_term', 'overdue', 'total' amounts
None if request fails
Example:
data = await get_clients_with_maturity(1, token)
clients = data['clients'] # List of all clients
overdue = data['maturity']['overdue'] # Overdue amount
"""
try:
client = get_backend_client()
async with client:
# Get maturity analysis (contains client summaries)
maturity_response = await client.get_maturity_data(
company_id=company_id,
jwt_token=jwt_token,
period='all'
)
if not maturity_response:
return None
# Extract clients from maturity response
clients_raw = maturity_response.get('clients', [])
# Transform to expected format: amount → balance
clients = [
{
'name': c.get('name', 'N/A'),
'balance': float(c.get('amount', 0)),
'daysOverdue': c.get('daysOverdue', 0)
}
for c in clients_raw
]
# Calculate maturity breakdown from clients data
total = sum(c['balance'] for c in clients)
overdue = sum(c['balance'] for c in clients if c.get('daysOverdue', 0) > 0)
in_term = total - overdue
result = {
'clients': clients,
'maturity': {
'in_term': in_term,
'overdue': overdue,
'total': total
}
}
# Pass through cache metadata if present
if 'cache_hit' in maturity_response:
result['cache_hit'] = maturity_response['cache_hit']
if 'response_time_ms' in maturity_response:
result['response_time_ms'] = maturity_response['response_time_ms']
if 'cache_source' in maturity_response:
result['cache_source'] = maturity_response['cache_source']
return result
except Exception as e:
logger.error(f"Error getting clients with maturity: {e}", exc_info=True)
return None
async def get_suppliers_with_maturity(
company_id: int,
jwt_token: str
) -> Optional[Dict[str, Any]]:
"""
Get suppliers list with maturity breakdown.
Uses maturity analysis endpoint which returns supplier summaries
with amounts and overdue status.
Backend returns:
{
"clients": [...],
"suppliers": [{"name": str, "amount": float, "dueDate": str, "daysOverdue": int}],
"balance": float,
"metadata": {...}
}
Args:
company_id: Company ID
jwt_token: JWT authentication token
Returns:
Dict with:
- 'suppliers': List of supplier dicts (id, name, balance)
- 'maturity': Dict with 'in_term', 'overdue', 'total' amounts
None if request fails
Example:
data = await get_suppliers_with_maturity(1, token)
suppliers = data['suppliers'] # List of all suppliers
in_term = data['maturity']['in_term'] # In-term amount
"""
try:
client = get_backend_client()
async with client:
# Get maturity analysis (contains supplier summaries)
maturity_response = await client.get_maturity_data(
company_id=company_id,
jwt_token=jwt_token,
period='all'
)
if not maturity_response:
return None
# Extract suppliers from maturity response
suppliers_raw = maturity_response.get('suppliers', [])
# Transform to expected format: amount → balance
suppliers = [
{
'name': s.get('name', 'N/A'),
'balance': float(s.get('amount', 0)),
'daysOverdue': s.get('daysOverdue', 0)
}
for s in suppliers_raw
]
# Calculate maturity breakdown from suppliers data
total = sum(s['balance'] for s in suppliers)
overdue = sum(s['balance'] for s in suppliers if s.get('daysOverdue', 0) > 0)
in_term = total - overdue
result = {
'suppliers': suppliers,
'maturity': {
'in_term': in_term,
'overdue': overdue,
'total': total
}
}
# Pass through cache metadata if present
if 'cache_hit' in maturity_response:
result['cache_hit'] = maturity_response['cache_hit']
if 'response_time_ms' in maturity_response:
result['response_time_ms'] = maturity_response['response_time_ms']
if 'cache_source' in maturity_response:
result['cache_source'] = maturity_response['cache_source']
return result
except Exception as e:
logger.error(f"Error getting suppliers with maturity: {e}", exc_info=True)
return None
async def get_cashflow_evolution_data(
company_id: int,
jwt_token: str,
period: str = "12m"
) -> Optional[Dict[str, Any]]:
"""
Get cash flow evolution data with YTD comparison.
Uses trends endpoint which returns 12-month historical data for current and previous year.
Calculates YTD for comparison and extracts last 12 months in reverse chronological order.
Args:
company_id: Company ID
jwt_token: JWT authentication token
period: Period for trends data (default: "12m")
Returns:
Dict with:
- 'performance': Dict with YTD data for current and previous year
- 'monthly': Dict with last 12 months data (reverse chronological) + prev year comparison
None if request fails
Example:
data = await get_cashflow_evolution_data(1, token)
ytd_2025 = data['performance']['current_year']
ytd_2024 = data['performance']['previous_year']
"""
try:
client = get_backend_client()
async with client:
# Get trends data (12 months of historical data)
trends_data = await client.get_trends(
company_id=company_id,
jwt_token=jwt_token,
period="12m"
)
if not trends_data:
return None
# Extract current year data
periods = trends_data.get('periods', []) # ["2024-01", "2024-02", ...]
clienti_incasat = trends_data.get('clienti_incasat', [])
furnizori_achitat = trends_data.get('furnizori_achitat', [])
# Extract previous year data
previous_periods = trends_data.get('previous_periods', [])
clienti_incasat_prev = trends_data.get('clienti_incasat_prev', [])
furnizori_achitat_prev = trends_data.get('furnizori_achitat_prev', [])
if not periods or not clienti_incasat or not furnizori_achitat:
logger.warning("Trends data missing required fields")
return None
# Calculate YTD (Year-To-Date) = sum of all available months
incasari_ytd = sum(clienti_incasat)
plati_ytd = sum(furnizori_achitat)
net_ytd = incasari_ytd - plati_ytd
incasari_ytd_prev = sum(clienti_incasat_prev) if clienti_incasat_prev else 0
plati_ytd_prev = sum(furnizori_achitat_prev) if furnizori_achitat_prev else 0
net_ytd_prev = incasari_ytd_prev - plati_ytd_prev
# Extract years from periods
current_year = periods[-1].split('-')[0] if periods else "2025"
previous_year = previous_periods[-1].split('-')[0] if previous_periods else "2024"
# Take last 12 months (current year)
last_12_periods = periods[-12:]
last_12_incasari = clienti_incasat[-12:]
last_12_plati = furnizori_achitat[-12:]
# Take corresponding previous year months
last_12_periods_prev = previous_periods[-12:] if previous_periods else []
last_12_incasari_prev = clienti_incasat_prev[-12:] if clienti_incasat_prev else [0] * 12
last_12_plati_prev = furnizori_achitat_prev[-12:] if furnizori_achitat_prev else [0] * 12
# Month abbreviations (Romanian)
month_abbr = {
'01': 'Ian', '02': 'Feb', '03': 'Mar', '04': 'Apr',
'05': 'Mai', '06': 'Iun', '07': 'Iul', '08': 'Aug',
'09': 'Sep', '10': 'Oct', '11': 'Noi', '12': 'Dec'
}
# Format months as "Noi'25/'24"
formatted_months = []
for i, period_str in enumerate(last_12_periods):
if '-' in period_str:
year = period_str.split('-')[0][-2:] # Last 2 digits: "25"
month_num = period_str.split('-')[1]
month_name = month_abbr.get(month_num, month_num)
# Get previous year month
prev_year = previous_year[-2:] if previous_year else "24"
formatted_months.append(f"{month_name}'{year}/'{prev_year}")
else:
formatted_months.append(period_str)
# Reverse chronological order (newest first)
formatted_months.reverse()
last_12_incasari.reverse()
last_12_plati.reverse()
last_12_incasari_prev.reverse()
last_12_plati_prev.reverse()
# Build performance summary (YTD)
performance = {
'current_year': {
'year': current_year,
'incasari': incasari_ytd,
'plati': plati_ytd,
'net': net_ytd
},
'previous_year': {
'year': previous_year,
'incasari': incasari_ytd_prev,
'plati': plati_ytd_prev,
'net': net_ytd_prev
}
}
# Build monthly breakdown (reverse chronological with prev year comparison)
monthly = {
'months': formatted_months,
'incasari': last_12_incasari,
'plati': last_12_plati,
'incasari_prev': last_12_incasari_prev,
'plati_prev': last_12_plati_prev
}
result = {
'performance': performance,
'monthly': monthly
}
# Pass through cache metadata if present
if 'cache_hit' in trends_data:
result['cache_hit'] = trends_data['cache_hit']
if 'response_time_ms' in trends_data:
result['response_time_ms'] = trends_data['response_time_ms']
if 'cache_source' in trends_data:
result['cache_source'] = trends_data['cache_source']
return result
except Exception as e:
logger.error(f"Error getting cashflow evolution data: {e}", exc_info=True)
return None
async def get_client_invoices(
company_id: int,
client_name: str,
jwt_token: str
) -> List[Dict[str, Any]]:
"""
Get invoices for a specific client.
Args:
company_id: Company ID
client_name: Client name to filter by
jwt_token: JWT authentication token
Returns:
List of invoice dicts for the specified client
Example:
invoices = await get_client_invoices(1, "ACME Corp", token)
for inv in invoices:
print(inv['number'], inv['amount'])
"""
try:
logger.info(f"Fetching invoices for client '{client_name}' (company_id={company_id})")
client = get_backend_client()
async with client:
# Filter only by unpaid invoices (with balance > 0)
invoices = await client.search_invoices(
company_id=company_id,
jwt_token=jwt_token,
filters={
'partner_type': 'CLIENTI',
'partner_name': client_name,
'only_unpaid': True # Only show unpaid invoices (matching balance > 0)
}
)
logger.info(f"Found {len(invoices) if invoices else 0} invoices for client '{client_name}'")
if invoices:
logger.debug(f"First invoice sample: {invoices[0]}")
return invoices or []
except Exception as e:
logger.error(f"Error getting client invoices for '{client_name}': {e}", exc_info=True)
return []
async def get_supplier_invoices(
company_id: int,
supplier_name: str,
jwt_token: str
) -> List[Dict[str, Any]]:
"""
Get invoices for a specific supplier.
Args:
company_id: Company ID
supplier_name: Supplier name to filter by
jwt_token: JWT authentication token
Returns:
List of invoice dicts for the specified supplier
Example:
invoices = await get_supplier_invoices(1, "Supplier Inc", token)
for inv in invoices:
print(inv['number'], inv['amount'])
"""
try:
logger.info(f"Fetching invoices for supplier '{supplier_name}' (company_id={company_id})")
client = get_backend_client()
async with client:
# Filter only by unpaid invoices (with balance > 0)
invoices = await client.search_invoices(
company_id=company_id,
jwt_token=jwt_token,
filters={
'partner_type': 'FURNIZORI',
'partner_name': supplier_name,
'only_unpaid': True # Only show unpaid invoices (matching balance > 0)
}
)
logger.info(f"Found {len(invoices) if invoices else 0} invoices for supplier '{supplier_name}'")
if invoices:
logger.debug(f"First invoice sample: {invoices[0]}")
return invoices or []
except Exception as e:
logger.error(f"Error getting supplier invoices for '{supplier_name}': {e}", exc_info=True)
return []
# Export all helper functions
__all__ = [
'get_active_company_or_prompt',
'search_companies_by_name',
'create_company_selection_keyboard',
'create_company_selection_keyboard_paginated',
'format_company_context_footer',
'get_treasury_breakdown_split',
'get_clients_with_maturity',
'get_suppliers_with_maturity',
'get_cashflow_evolution_data',
'get_client_invoices',
'get_supplier_invoices'
]

View File

@@ -0,0 +1,642 @@
"""
Menu builders for Telegram bot inline keyboards.
This module provides functions to create InlineKeyboardMarkup objects
for different menu levels and navigation patterns in the bot.
NOTE: All button texts are plain text WITHOUT emojis/icons as per requirements.
BUTTON WIDTH: Inline keyboard width is determined by the message text width.
To make buttons wider, we pad message text with invisible characters.
"""
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
from typing import List, Dict, Optional
from datetime import datetime
# ============================================================================
# IMPORTANT: BUTTON WIDTH CONFIGURATION
# ============================================================================
# Inline keyboard button width is determined by MESSAGE TEXT WIDTH!
# DO NOT REMOVE PADDING - it makes buttons wide like BotFather!
# ============================================================================
# Zero-Width Joiner character - invisible but prevents Telegram from trimming spaces
# This character has ZERO width (invisible) but prevents space trimming
ZERO_WIDTH_JOINER = '\u200D'
# Target character count per line to make buttons VERY WIDE
# Higher value = wider buttons (BotFather uses ~45-50 chars)
# DO NOT DECREASE THIS VALUE - buttons will become narrow!
TARGET_WIDTH = 50 # Increased from 40 to make buttons WIDER
# Enable/disable padding globally (useful for testing)
# KEEP THIS TRUE - disabling makes buttons narrow!
ENABLE_BUTTON_PADDING = True
def _get_current_month_ro() -> str:
"""Get current month name in Romanian."""
months_ro = {
1: "Ianuarie", 2: "Februarie", 3: "Martie", 4: "Aprilie",
5: "Mai", 6: "Iunie", 7: "Iulie", 8: "August",
9: "Septembrie", 10: "Octombrie", 11: "Noiembrie", 12: "Decembrie"
}
now = datetime.now()
return f"{months_ro[now.month]} {now.year}"
def _pad_line_for_wide_buttons(text: str, target_width: int = TARGET_WIDTH) -> str:
"""
Pad a single line of text with invisible characters to make inline buttons wider.
⚠️ CRITICAL: DO NOT REMOVE THIS FUNCTION - it makes buttons wide!
The width of InlineKeyboardMarkup buttons is determined by the message text width.
By padding text with spaces + zero-width joiner, we force wider buttons.
How it works:
1. Calculate how many characters needed to reach target_width
2. Add spaces + Zero-Width Joiner (invisible character)
3. Result: wider message = wider buttons (like BotFather)
Args:
text: The text line to pad
target_width: Target character count (default 50 for VERY WIDE buttons)
Returns:
Padded text with invisible characters (user sees normal text, Telegram sees wider text)
"""
current_length = len(text)
if current_length >= target_width:
return text
# ⚠️ DO NOT REMOVE: Add spaces + zero-width joiner at the end
# This makes buttons WIDE without changing visible text!
padding_needed = target_width - current_length
padding = ' ' * padding_needed + ZERO_WIDTH_JOINER
return text + padding
def pad_message_for_wide_buttons(message: str, target_width: int = TARGET_WIDTH, force: bool = False) -> str:
"""
Pad all lines in a message to make inline keyboard buttons wider.
⚠️ CRITICAL: DO NOT REMOVE THIS FUNCTION - it makes buttons wide!
This is the MAIN function that applies padding to ALL messages with keyboards.
Why we need this:
- Telegram determines button width based on MESSAGE TEXT width
- Short messages = narrow buttons
- Wide messages (with invisible padding) = WIDE buttons like BotFather
Args:
message: Multi-line message text
target_width: Target character count per line (default 50)
force: Force padding even if ENABLE_BUTTON_PADDING is False
Returns:
Message with all lines padded (if enabled or forced)
"""
# ⚠️ DO NOT REMOVE: Check if padding is enabled
if not ENABLE_BUTTON_PADDING and not force:
return message
# ⚠️ DO NOT REMOVE: Apply padding to each line
lines = message.split('\n')
padded_lines = [_pad_line_for_wide_buttons(line, target_width) for line in lines]
return '\n'.join(padded_lines)
def format_response_with_company(
content: str,
company_name: Optional[str] = None,
apply_padding: bool = True
) -> str:
"""
Format a response with company name at the top (simplified format).
⚠️ IMPORTANT: Applies padding by default to make buttons WIDE!
Format:
Company Name
[Content]
Args:
content: The main content text
company_name: Company name to show at top (if None, just returns content)
apply_padding: Whether to apply invisible padding for wider buttons (default TRUE)
Returns:
Formatted response with company name header AND padding for wide buttons
"""
if company_name:
message = f"{company_name}\n\n{content}"
else:
message = content
# ⚠️ DO NOT REMOVE: Apply padding to make inline keyboard buttons WIDE!
# Without this, buttons become narrow like before
if apply_padding:
message = pad_message_for_wide_buttons(message)
return message
def get_menu_message(
company_name: Optional[str] = None,
company_cui: Optional[str] = None,
apply_padding: bool = True
) -> str:
"""
Get the menu message text with company details (simplified format).
⚠️ IMPORTANT: Applies padding by default to make menu buttons WIDE!
Format without labels - just values:
- Line 1: Company name
- Line 2: CUI
- Line 3: Accounting month
Args:
company_name: Active company name
company_cui: Company fiscal code (CUI)
apply_padding: Whether to apply invisible padding for wider buttons (default TRUE)
Returns:
Formatted message text for menu WITH padding for wide buttons
"""
if company_name:
# Simplified format: just values, no labels
message = f"{company_name}\n"
if company_cui:
message += f"{company_cui}\n"
message += f"{_get_current_month_ro()}"
else:
# No company selected - just prompt
message = "Selectează o companie pentru a continua"
# ⚠️ DO NOT REMOVE: Apply padding to make inline keyboard buttons WIDE!
# This makes buttons look like BotFather (wide, not narrow)
if apply_padding:
message = pad_message_for_wide_buttons(message)
return message
def create_main_menu(
company_name: Optional[str] = None,
company_cui: Optional[str] = None,
is_authenticated: bool = True,
cache_enabled: Optional[bool] = None
) -> InlineKeyboardMarkup:
"""
Create main menu keyboard (Level 1) with financial options.
Layout: Full-width buttons with company selection at top
Args:
company_name: Active company name, or None if no company selected
company_cui: Company fiscal code (CUI), or None
is_authenticated: Whether user is authenticated (affects Login/Logout button)
cache_enabled: Cache state for user (True=ON, False=OFF, None=unknown)
Returns:
InlineKeyboardMarkup with main menu buttons
"""
keyboard = []
# Only show financial menu if authenticated
if is_authenticated:
# Row 1: Company selection (full width, single line - InlineKeyboardButton doesn't support multiline)
if company_name:
# Short company name for button (CUI and month will be shown in message text)
# Truncate long names to fit in button
max_length = 35
display_name = company_name if len(company_name) <= max_length else company_name[:max_length-3] + "..."
keyboard.append([
InlineKeyboardButton(
f"{display_name}",
callback_data="menu:select_company"
)
])
else:
keyboard.append([
InlineKeyboardButton(
"Selectare Companie",
callback_data="menu:select_company"
)
])
# Rows 2-4: Financial options (compact layout with unified Trezorerie button)
keyboard.extend([
[
InlineKeyboardButton("Sold Companie", callback_data="menu:sold"),
InlineKeyboardButton("Trezorerie", callback_data="menu:trezorerie")
],
[
InlineKeyboardButton("Sold Clienti", callback_data="menu:clienti"),
InlineKeyboardButton("Sold Furnizori", callback_data="menu:furnizori")
],
[
InlineKeyboardButton("Evolutie Incasari", callback_data="menu:evolutie")
]
])
# Row 5: Cache options (2 buttons per row, only if authenticated)
if is_authenticated:
# Dynamic cache toggle button showing current state
if cache_enabled is None:
cache_button_text = "Toggle Cache"
elif cache_enabled:
cache_button_text = "Cache: ON"
else:
cache_button_text = "Cache: OFF"
keyboard.append([
InlineKeyboardButton(cache_button_text, callback_data="menu:togglecache"),
InlineKeyboardButton("Clear Cache", callback_data="menu:clearcache")
])
# Row 6: Switch Server button (authenticated only)
if is_authenticated:
keyboard.append([
InlineKeyboardButton("Schimba Server", callback_data="menu:switch_server"),
])
# Row 7: Help/Logout buttons (authenticated) or Login button (non-authenticated)
if is_authenticated:
keyboard.append([
InlineKeyboardButton("Help", callback_data="action:help"),
InlineKeyboardButton("Logout", callback_data="action:logout")
])
else:
keyboard.append([
InlineKeyboardButton("Login", callback_data="action:login")
])
return InlineKeyboardMarkup(keyboard)
def create_action_buttons(current_view: str, show_export: bool = True, show_back: bool = False, show_refresh: bool = True) -> InlineKeyboardMarkup:
"""
Create action buttons for responses (Refresh, Export, Back, Menu).
Layout (buttons made wide by message text padding):
[Refresh] [Export] (if show_refresh=True and show_export=True)
[Refresh] (if show_refresh=True and show_export=False)
[Înapoi] (if show_back=True, full width)
[Menu] (full width, always shown)
Args:
current_view: View identifier for refresh callback (e.g., "sold", "clienti")
show_export: Whether to show Export button
show_back: Whether to show Back button to list
show_refresh: Whether to show Refresh button
Returns:
InlineKeyboardMarkup with action buttons
"""
keyboard = []
# Row 1: Refresh and optionally Export (only if show_refresh is True)
if show_refresh:
if show_export:
keyboard.append([
InlineKeyboardButton("Refresh", callback_data=f"action:refresh:{current_view}"),
InlineKeyboardButton("Export", callback_data=f"action:export:{current_view}")
])
else:
keyboard.append([
InlineKeyboardButton("Refresh", callback_data=f"action:refresh:{current_view}")
])
# Row 2: Back to List (if show_back is True)
if show_back:
# Determine back callback based on current view
# ✅ FIX: Handle detail views (client_detail:name, supplier_detail:name)
if current_view.startswith("client_detail:"):
back_callback = "menu:clienti" # Back to client list
elif current_view.startswith("supplier_detail:"):
back_callback = "menu:furnizori" # Back to supplier list
elif current_view == "clienti":
back_callback = "clients_page:0" # Match handlers.py:1689
elif current_view == "furnizori":
back_callback = "suppliers_page:0" # Match handlers.py:1721
else:
back_callback = "action:menu" # Fallback to menu
keyboard.append([
InlineKeyboardButton("« Înapoi", callback_data=back_callback)
])
# Row 3: Back to Menu (full width)
keyboard.append([
InlineKeyboardButton("Meniu Principal", callback_data="action:menu")
])
return InlineKeyboardMarkup(keyboard)
def create_client_list_keyboard(clients: List[Dict], max_items: int = 20, page: int = 0, letter: str = None) -> InlineKeyboardMarkup:
"""
Create client list keyboard (Level 2) with client buttons and pagination.
Layout: 1 column for clients, pagination controls, 2 columns for navigation
Args:
clients: List of client dicts with keys: id, name, balance
max_items: Maximum number of clients per page (default: 10)
page: Current page number (0-indexed)
letter: Optional letter filter (e.g. "A", "ALL") - when set, uses alpha pagination
Returns:
InlineKeyboardMarkup with client list buttons and pagination
"""
keyboard = []
# Sort clients alphabetically by name
sorted_clients = sorted(clients, key=lambda x: x.get('name', '').lower())
# Calculate pagination
total_clients = len(sorted_clients)
total_pages = (total_clients + max_items - 1) // max_items # Ceiling division
start_idx = page * max_items
end_idx = min(start_idx + max_items, total_clients)
# Display clients for current page
display_clients = sorted_clients[start_idx:end_idx]
# Add client buttons (1 per row)
for client in display_clients:
client_name = client.get('name', 'N/A')
balance = client.get('balance', 0)
# Format balance with thousands separator
balance_str = f"{balance:,.0f}" if balance else "0"
button_text = f"{client_name} - {balance_str} RON"
# Limit callback_data to 64 bytes (Telegram limit)
# Use only first 40 chars of name to stay within limit
safe_name = client_name[:40] if len(client_name) > 40 else client_name
keyboard.append([
InlineKeyboardButton(
button_text,
callback_data=f"details:client:{safe_name}:0" # name:page
)
])
# Pagination controls (only if more than one page)
if total_pages > 1:
nav_buttons = []
# Choose pagination callback based on whether letter filter is active
if letter:
prev_cb = f"clients_alpha_page:{page-1}:{letter}"
next_cb = f"clients_alpha_page:{page+1}:{letter}"
else:
prev_cb = f"clients_page:{page-1}"
next_cb = f"clients_page:{page+1}"
# Previous button
if page > 0:
nav_buttons.append(
InlineKeyboardButton("< Anterior", callback_data=prev_cb)
)
# Page indicator (non-clickable)
nav_buttons.append(
InlineKeyboardButton(f"Pagina {page+1}/{total_pages}", callback_data="noop")
)
# Next button
if page < total_pages - 1:
nav_buttons.append(
InlineKeyboardButton("Urmator >", callback_data=next_cb)
)
keyboard.append(nav_buttons)
# Filtrare A-Z button
keyboard.append([
InlineKeyboardButton("Filtrare A-Z", callback_data="clients_alpha_menu")
])
# Back button: to A-Z menu if filtering, otherwise to main menu
back_callback = "clients_alpha_menu" if letter else "action:menu"
keyboard.append([
InlineKeyboardButton("< Inapoi", callback_data=back_callback)
])
return InlineKeyboardMarkup(keyboard)
def create_supplier_list_keyboard(suppliers: List[Dict], max_items: int = 20, page: int = 0, letter: str = None) -> InlineKeyboardMarkup:
"""
Create supplier list keyboard (Level 2) with supplier buttons and pagination.
Layout: 1 column for suppliers, pagination controls, 2 columns for navigation
Args:
suppliers: List of supplier dicts with keys: id, name, balance
max_items: Maximum number of suppliers per page (default: 10)
page: Current page number (0-indexed)
letter: Optional letter filter (e.g. "A", "ALL") - when set, uses alpha pagination
Returns:
InlineKeyboardMarkup with supplier list buttons and pagination
"""
keyboard = []
# Sort suppliers alphabetically by name
sorted_suppliers = sorted(suppliers, key=lambda x: x.get('name', '').lower())
# Calculate pagination
total_suppliers = len(sorted_suppliers)
total_pages = (total_suppliers + max_items - 1) // max_items # Ceiling division
start_idx = page * max_items
end_idx = min(start_idx + max_items, total_suppliers)
# Display suppliers for current page
display_suppliers = sorted_suppliers[start_idx:end_idx]
# Add supplier buttons (1 per row)
for supplier in display_suppliers:
supplier_name = supplier.get('name', 'N/A')
balance = supplier.get('balance', 0)
# Format balance with thousands separator
balance_str = f"{balance:,.0f}" if balance else "0"
button_text = f"{supplier_name} - {balance_str} RON"
# Limit callback_data to 64 bytes (Telegram limit)
# Use only first 40 chars of name to stay within limit
safe_name = supplier_name[:40] if len(supplier_name) > 40 else supplier_name
keyboard.append([
InlineKeyboardButton(
button_text,
callback_data=f"details:supplier:{safe_name}:0" # name:page
)
])
# Pagination controls (only if more than one page)
if total_pages > 1:
nav_buttons = []
# Choose pagination callback based on whether letter filter is active
if letter:
prev_cb = f"suppliers_alpha_page:{page-1}:{letter}"
next_cb = f"suppliers_alpha_page:{page+1}:{letter}"
else:
prev_cb = f"suppliers_page:{page-1}"
next_cb = f"suppliers_page:{page+1}"
# Previous button
if page > 0:
nav_buttons.append(
InlineKeyboardButton("< Anterior", callback_data=prev_cb)
)
# Page indicator (non-clickable)
nav_buttons.append(
InlineKeyboardButton(f"Pagina {page+1}/{total_pages}", callback_data="noop")
)
# Next button
if page < total_pages - 1:
nav_buttons.append(
InlineKeyboardButton("Urmator >", callback_data=next_cb)
)
keyboard.append(nav_buttons)
# Filtrare A-Z button
keyboard.append([
InlineKeyboardButton("Filtrare A-Z", callback_data="suppliers_alpha_menu")
])
# Back button: to A-Z menu if filtering, otherwise to main menu
back_callback = "suppliers_alpha_menu" if letter else "action:menu"
keyboard.append([
InlineKeyboardButton("< Inapoi", callback_data=back_callback)
])
return InlineKeyboardMarkup(keyboard)
def create_invoice_list_keyboard(
invoices: List[Dict],
partner_type: str,
partner_name: str,
max_items: int = 10,
page: int = 0
) -> InlineKeyboardMarkup:
"""
Create invoice list keyboard (Level 3) with invoice buttons and pagination.
Layout: 1 column for invoices, pagination controls, 2 columns for navigation
Args:
invoices: List of invoice dicts with keys: id, number, amount, status
partner_type: "CLIENTI" or "FURNIZORI"
partner_name: Client/supplier name (for back navigation)
max_items: Maximum number of invoices per page (default: 10)
page: Current page number (0-indexed)
Returns:
InlineKeyboardMarkup with invoice list buttons and pagination
"""
keyboard = []
# Limit partner_name to 30 chars for Telegram callback_data limit (64 bytes)
safe_partner_name = partner_name[:30] if len(partner_name) > 30 else partner_name
# Calculate pagination
total_invoices = len(invoices)
total_pages = (total_invoices + max_items - 1) // max_items # Ceiling division
start_idx = page * max_items
end_idx = min(start_idx + max_items, total_invoices)
# Display invoices for current page
display_invoices = invoices[start_idx:end_idx]
# Add invoice buttons (1 per row)
for invoice in display_invoices:
invoice_id = invoice.get('id', 0)
invoice_number = invoice.get('number', 'N/A')
amount = invoice.get('amount', 0)
status = invoice.get('status', 'unknown')
# Format amount with thousands separator
amount_str = f"{amount:,.0f}" if amount else "0"
# Status text indicator (no emojis)
status_text = "[NEPLATIT]" if status in ['unpaid', 'overdue'] else "[PLATIT]"
button_text = f"{status_text} {invoice_number} - {amount_str} RON"
keyboard.append([
InlineKeyboardButton(
button_text,
callback_data=f"invoice:{partner_type}:{invoice_id}"
)
])
# Pagination controls (only if more than one page)
if total_pages > 1:
nav_buttons = []
# Previous button
if page > 0:
nav_buttons.append(
InlineKeyboardButton("< Anterior", callback_data=f"invoices_page:{partner_type}:{safe_partner_name}:{page-1}")
)
# Page indicator (non-clickable)
nav_buttons.append(
InlineKeyboardButton(f"Pagina {page+1}/{total_pages}", callback_data="noop")
)
# Next button
if page < total_pages - 1:
nav_buttons.append(
InlineKeyboardButton("Următor >", callback_data=f"invoices_page:{partner_type}:{safe_partner_name}:{page+1}")
)
keyboard.append(nav_buttons)
# Navigation row: Back and Export (2 buttons per row)
back_target = "clienti" if partner_type == "CLIENTI" else "furnizori"
keyboard.append([
InlineKeyboardButton("< Înapoi", callback_data=f"nav:back:{back_target}"),
InlineKeyboardButton("Export", callback_data=f"action:export:{partner_type.lower()}")
])
return InlineKeyboardMarkup(keyboard)
def create_navigation_buttons(back_to: str) -> InlineKeyboardMarkup:
"""
Create simple navigation buttons (just Back button).
Args:
back_to: Target location identifier (e.g., "menu", "clienti", "furnizori")
Returns:
InlineKeyboardMarkup with navigation button
"""
keyboard = [
[
InlineKeyboardButton(
f"< Înapoi la {back_to}",
callback_data=f"nav:back:{back_to}"
)
]
]
return InlineKeyboardMarkup(keyboard)