Files
roa2web-service-auto/backend/modules/telegram/bot/menus.py
Claude Agent 30f55cf18b feat: Add A-Z filter for clients/suppliers in Telegram bot
- Add A-Z alphabetical filter keyboard for clients and suppliers lists
  (same pattern as company selection, without emoji)
- Increase clients/suppliers list pagination from 10 to 20 items per page
- Remove emoji from company A-Z filter button for consistency
- Add 6 new callback handlers: clients_alpha_menu, clients_alpha:LETTER,
  clients_alpha_page:PAGE:LETTER, and supplier equivalents
- Dashboard service and models updates
- Telegram bot: email handlers, auth, DB operations, internal API improvements
- Frontend: dashboard cards updates (CashFlow, Clienti, Furnizori, Treasury)
- Frontend: SolduriCompactCard and CollapsibleCard improvements
- DashboardView enhancements
- start.sh and run-with-restart.sh script updates
- IIS web.config and service worker updates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 14:34:15 +00:00

643 lines
22 KiB
Python

"""
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)