From 58399e25fb55ef8f79b5866f0a30e9f101ef096b Mon Sep 17 00:00:00 2001 From: Marius Mutu Date: Wed, 12 Nov 2025 14:32:56 +0200 Subject: [PATCH] Adauga comanda /tranzactii pentru vizualizare tranzactii interactive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sistem interactiv de selectie conturi cu state management - Filtrare pe perioade: [nr cont] [nr zile] (ex: 2 30 = ultimele 30 zile) - Format compact: tranzactii grupate pe date, fara sold - Extragere inteligenta comercianti din platile POS (TID pattern) - Escape automat caractere speciale Markdown pentru Telegram - Timeout 5 minute pentru sesiuni de selectie - Suport comenzi: doar numar = ultimele 10, numar + zile = filtrare Corectari: - Fix import timedelta pentru filtrare pe date - Fix conflict nume variabila period vs csv_period - Fix Markdown parsing errors (underscore, dot, etc) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- telegram_trigger_bot.py | 346 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 345 insertions(+), 1 deletion(-) diff --git a/telegram_trigger_bot.py b/telegram_trigger_bot.py index f2d1329..bb03805 100644 --- a/telegram_trigger_bot.py +++ b/telegram_trigger_bot.py @@ -12,7 +12,7 @@ import json import csv import zipfile from pathlib import Path -from datetime import datetime +from datetime import datetime, timedelta import glob import requests from dotenv import load_dotenv @@ -51,6 +51,9 @@ class TelegramTriggerBot: self.last_update_id = 0 self.poll_timeout = POLL_TIMEOUT + # State management pentru selecție interactivă conturi + self.pending_account_selection = {} # {user_id: {'accounts': [...], 'timestamp': ...}} + if not self.bot_token: raise ValueError("TELEGRAM_BOT_TOKEN nu este setat în .env!") @@ -70,6 +73,7 @@ class TelegramTriggerBot: {"command": "scrape_zip", "description": "Rulează scraper + trimite ZIP"}, {"command": "scrape_solduri", "description": "Extrage doar soldurile (fără CSV)"}, {"command": "solduri", "description": "Afișează ultimul fișier solduri"}, + {"command": "tranzactii", "description": "Afișează tranzacții recente din cont"}, {"command": "zip", "description": "Trimite ultimele fișiere ca ZIP"}, {"command": "status", "description": "Status sistem"}, {"command": "help", "description": "Ajutor comenzi"} @@ -318,6 +322,321 @@ class TelegramTriggerBot: logging.error(f"Eroare show_cached_balances: {e}", exc_info=True) self.send_message(chat_id, f"*EROARE*\n\n```\n{str(e)}\n```", reply_to_message_id) + def show_transactions_menu(self, chat_id, user_id, reply_to_message_id=None): + """Afișează meniu cu conturi disponibile pentru selecție tranzacții""" + try: + data_dir = Path('data') + + if not data_dir.exists(): + self.send_message(chat_id, "*EROARE*\n\nDirectorul 'data' nu există!", reply_to_message_id) + return + + # Găsește toate fișierele de tranzacții + transaction_files = sorted( + data_dir.glob('tranzactii_*.csv'), + key=lambda x: x.stat().st_mtime, + reverse=True + ) + + if not transaction_files: + self.send_message( + chat_id, + "*EROARE*\n\nNu s-au găsit fișiere cu tranzacții!\n\nRulează mai întâi /scrape pentru a descărca tranzacții.", + reply_to_message_id + ) + return + + # Extrage nume conturi unice din numele fișierelor + # Format: tranzactii_Nume_Cont_YYYY-MM-DD_HH-MM-SS.csv + accounts = {} + for file_path in transaction_files: + filename = file_path.stem # fără extensie + # Elimină prefixul "tranzactii_" și sufixul timestamp + # rsplit('_', 3) split-uiește: ['Nume', 'Cont', 'YYYY-MM-DD', 'HH-MM-SS'] + parts = filename.replace('tranzactii_', '').rsplit('_', 3) + if len(parts) >= 4: + # parts[0] = nume de bază, parts[1] = număr cont + account_name = f"{parts[0]}_{parts[1]}" # "Nume_Cont" + # Convertește Nume_Cont → Nume Cont + display_name = account_name.replace('_', ' ') + + # Păstrează doar cel mai recent fișier pentru fiecare cont + if display_name not in accounts: + accounts[display_name] = file_path + + if not accounts: + self.send_message( + chat_id, + "*EROARE*\n\nNu s-au putut procesa fișierele de tranzacții!", + reply_to_message_id + ) + return + + # Sortează conturile alfabetic + sorted_accounts = sorted(accounts.items()) + + # Construiește mesaj + message = "*TRANZACTII BANCARE*\n\n" + message += "Conturi disponibile:\n\n" + + for idx, (account_name, file_path) in enumerate(sorted_accounts, 1): + message += f"{idx}. {account_name}\n" + + message += f"\n*Scrie numarul contului (1-{len(sorted_accounts)}):*\n" + message += " • Doar numar (ex: 2) = ultimele 10\n" + message += " • Numar + zile (ex: 2 7, 2 30)" + + # Salvează starea pentru user + self.pending_account_selection[user_id] = { + 'accounts': sorted_accounts, + 'timestamp': datetime.now().timestamp() + } + + self.send_message(chat_id, message, reply_to_message_id) + logging.info(f"Afișat meniu tranzacții pentru user {user_id}: {len(sorted_accounts)} conturi") + + except Exception as e: + logging.error(f"Eroare show_transactions_menu: {e}", exc_info=True) + self.send_message(chat_id, f"*EROARE*\n\n```\n{str(e)}\n```", reply_to_message_id) + + def show_account_transactions(self, chat_id, user_id, account_index, reply_to_message_id=None, period="10"): + """Afișează tranzacții pentru contul selectat (perioada: 10/luna/sapt)""" + try: + # Verifică dacă userul are o selecție pending + if user_id not in self.pending_account_selection: + self.send_message( + chat_id, + "*EROARE*\n\nNu există selecție activă. Folosește /tranzactii pentru a începe.", + reply_to_message_id + ) + return + + pending_data = self.pending_account_selection[user_id] + accounts = pending_data['accounts'] + + # Verifică timeout (5 minute) + if datetime.now().timestamp() - pending_data['timestamp'] > 300: + del self.pending_account_selection[user_id] + self.send_message( + chat_id, + "*TIMEOUT*\n\nSelecția a expirat. Folosește /tranzactii pentru a începe din nou.", + reply_to_message_id + ) + return + + # Verifică index valid + if account_index < 1 or account_index > len(accounts): + self.send_message( + chat_id, + f"*EROARE*\n\nNumăr invalid! Scrie un număr între 1 și {len(accounts)}.", + reply_to_message_id + ) + return + + # Obține contul selectat + account_name, csv_path = accounts[account_index - 1] + + # Verifică dacă fișierul există + if not csv_path.exists(): + del self.pending_account_selection[user_id] + self.send_message( + chat_id, + f"*EROARE*\n\nFișierul pentru contul {account_name} nu mai există!", + reply_to_message_id + ) + return + + # Citește CSV-ul + # Format BT: Primele 17 linii = metadata, linia 18 = header, linia 19+ = date + transactions = [] + account_iban = "" + csv_period = "" # Perioada din CSV (nu parametrul funcției!) + + with open(csv_path, 'r', encoding='utf-8') as f: + lines = f.readlines() + + # Extrage metadate + if len(lines) > 8: + # Linia 9: Perioada:,29.10.2025-12.11.2025 + period_line = lines[8].strip() + if 'Perioada:' in period_line: + csv_period = period_line.split(',')[1] if ',' in period_line else "" + + # Linia 8: Numar cont:,RO32BTRLRONCRT0637236701 RON + iban_line = lines[7].strip() + if 'Numar cont:' in iban_line: + account_iban = iban_line.split(',')[1] if ',' in iban_line else "" + + # Citește tranzacțiile (de la header - linia 18, index 17) + if len(lines) > 17: + csv_content = ''.join(lines[17:]) # Include header + date + reader = csv.DictReader(csv_content.splitlines()) + + for row in reader: + if row.get('Data tranzactie'): # Skip linii goale + transactions.append(row) + + # Șterge selecția pending + del self.pending_account_selection[user_id] + + if not transactions: + self.send_message( + chat_id, + f"*{account_name}*\n\nNu există tranzacții în acest fișier.", + reply_to_message_id + ) + return + + # Filtrează tranzacțiile în funcție de perioada selectată (nr zile) + from collections import defaultdict + + recent_transactions = [] + period_description = "" + + # Convertește period în număr de zile + try: + num_days = int(period) + except (ValueError, TypeError): + num_days = 10 # Default + + if num_days <= 0: + num_days = 10 # Sigură + + # Filtrează pe bază de zile + if num_days == 10: + # Optimizare: ultimele 10 tranzacții direct + recent_transactions = transactions[:10] + period_description = "Ultimele 10 tranzactii" + else: + # Filtrare pe bază de dată + now = datetime.now() + cutoff_date = now - timedelta(days=num_days) + + for tx in transactions: + tx_date_str = tx.get('Data tranzactie', '') + if tx_date_str: + try: + tx_date = datetime.strptime(tx_date_str, '%Y-%m-%d') + if tx_date >= cutoff_date: + recent_transactions.append(tx) + except ValueError: + continue + + period_description = f"Ultimele {num_days} zile" + + # Grupează tranzacțiile pe date + transactions_by_date = defaultdict(list) + + for tx in recent_transactions: + date = tx.get('Data tranzactie', '') + if date: + transactions_by_date[date].append(tx) + + # Construiește mesaj + message = f"*TRANZACTII - {account_name}*\n\n" + + if period_description: + message += f"Perioada: {period_description}\n" + + message += f"Total: {len(recent_transactions)} tranzactii\n" + message += "=" * 30 + "\n\n" + + # Sortează datele descrescător (cele mai recente primul) + sorted_dates = sorted(transactions_by_date.keys(), reverse=True) + + for date in sorted_dates: + # Header pentru dată + message += f"*{date}*\n" + + # Tranzacțiile din acea zi + for tx in transactions_by_date[date]: + description = tx.get('Descriere', '') + debit = tx.get('Debit', '').strip().replace('"', '').replace(',', '') + credit = tx.get('Credit', '').strip().replace('"', '').replace(',', '') + + # Extrage nume mai inteligent din descriere + display_name = description + desc_parts = description.split(';') + + if len(desc_parts) > 2: + # Caz special: Plăți POS - extrage comerciantul din Parts[1] + if 'POS' in desc_parts[0] and len(desc_parts) > 1: + # Căutăm pattern: "TID:XXXXXXX " + import re + tid_match = re.search(r'TID:\S+\s+(.+?)\s{2,}', desc_parts[1]) + if tid_match: + candidate = tid_match.group(1).strip() + else: + # Fallback: încearcă să extragă după TID până la două spații + if 'TID:' in desc_parts[1]: + after_tid = desc_parts[1].split('TID:')[1] + # Skip ID-ul TID și ia textul până la două spații consecutive + parts_after = after_tid.split(None, 1) # Split la primul spațiu + if len(parts_after) > 1: + # Ia textul până la " " sau până la sfârșitul + merchant_text = parts_after[1] + double_space_idx = merchant_text.find(' ') + if double_space_idx > 0: + candidate = merchant_text[:double_space_idx].strip() + else: + candidate = merchant_text.strip() + else: + candidate = desc_parts[2].strip() + else: + candidate = desc_parts[2].strip() + else: + # Încearcă part[2] (de obicei numele) + candidate = desc_parts[2].strip() + + # Dacă part[2] este doar număr/scurt/REF, încearcă part[3] + if (candidate.isdigit() or + len(candidate) < 3 or + candidate.startswith('REF:')): + if len(desc_parts) > 3: + candidate = desc_parts[3].strip() + + # Dacă tot e invalid, folosește tipul tranzacției (part[0]) + if len(candidate) < 3 or candidate.startswith('REF:'): + candidate = desc_parts[0].strip() + + display_name = candidate + + # Truncate dacă prea lung + if len(display_name) > 35: + display_name = display_name[:32] + "..." + + # Escape caractere speciale Markdown pentru Telegram + # Caracterele care trebuie escapate: _ * [ ] ( ) ~ ` > # + - = | { } . ! + markdown_chars = ['_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!'] + for char in markdown_chars: + display_name = display_name.replace(char, '\\' + char) + + # Determină suma (debit poate avea deja minus în CSV) + if credit: + amount_str = f"+{credit}" + elif debit: + # Elimină minus-ul dacă există deja + debit_clean = debit.lstrip('-') + amount_str = f"-{debit_clean}" + else: + amount_str = "0.00" + + # Format compact: " • Nume: +suma RON" + message += f" {display_name}: {amount_str} RON\n" + + message += "\n" + + self.send_message(chat_id, message, reply_to_message_id) + logging.info(f"Afișat {len(recent_transactions)} tranzacții pentru {account_name}") + + except Exception as e: + # Curăță selecția în caz de eroare + if user_id in self.pending_account_selection: + del self.pending_account_selection[user_id] + + logging.error(f"Eroare show_account_transactions: {e}", exc_info=True) + self.send_message(chat_id, f"*EROARE*\n\n```\n{str(e)}\n```", reply_to_message_id) + def send_zip_files(self, chat_id, reply_to_message_id=None): """Trimite ultimele fișiere ca arhivă ZIP""" try: @@ -471,6 +790,25 @@ class TelegramTriggerBot: self.send_message(chat_id, "*ACCES INTERZIS*\n\nNu ai permisiunea sa folosesti acest bot.", message_id) return + # Procesează răspuns numeric pentru selecție cont tranzacții + if user_id in self.pending_account_selection: + # Parse număr cont + opțional nr zile + # Formate acceptate: "2" (default 10), "2 7" (7 zile), "2 30" (30 zile) + parts = text.strip().split() + + + if len(parts) >= 1 and parts[0].isdigit(): + account_index = int(parts[0]) + + # Determină numărul de zile + period = "10" # default: ultimele 10 tranzacții + if len(parts) >= 2 and parts[1].isdigit(): + period = parts[1] # număr de zile + + logging.info(f"Răspuns selecție cont: {account_index}, perioada: {period} zile de la user {user_id}") + self.show_account_transactions(chat_id, user_id, account_index, message_id, period) + return + # Procesează comenzi if text == '/start': welcome_msg = "*BTGO Scraper Trigger Bot*\n\n" @@ -482,6 +820,7 @@ class TelegramTriggerBot: "`/scrape_zip` - Ruleaza scraper + trimite ZIP\n" "`/scrape_solduri` - Extrage doar soldurile (rapid)\n" "`/solduri` - Afiseaza ultimul fisier solduri\n" + "`/tranzactii` - Afiseaza tranzactii recente\n" "`/zip` - Trimite ultimele fisiere ca ZIP\n" "`/status` - Status sistem\n" "`/help` - Ajutor" @@ -504,6 +843,10 @@ class TelegramTriggerBot: logging.info(f"Comandă /solduri primită în {context}") self.show_cached_balances(chat_id, message_id) + elif text == '/tranzactii': + logging.info(f"Comandă /tranzactii primită în {context}") + self.show_transactions_menu(chat_id, user_id, message_id) + elif text == '/zip': logging.info(f"Comandă /zip primită în {context}") self.send_zip_files(chat_id, message_id) @@ -539,6 +882,7 @@ class TelegramTriggerBot: "`/scrape_zip` - Ruleaza scraper + trimite arhiva ZIP\n" "`/scrape_solduri` - Extrage doar soldurile (fara CSV tranzactii)\n" "`/solduri` - Afiseaza ultimul fisier solduri (instant)\n" + "`/tranzactii` - Afiseaza tranzactii recente din cont (interactiv)\n" "`/zip` - Trimite ultimele fisiere ca arhiva ZIP (fara scraping)\n" "`/status` - Informatii sistem\n" "`/help` - Acest mesaj\n\n"