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