- 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 <noreply@anthropic.com>
951 lines
40 KiB
Python
951 lines
40 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Telegram Trigger Bot - Declanșează BTGO Scraper prin comandă Telegram
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import io
|
|
import subprocess
|
|
import logging
|
|
import json
|
|
import csv
|
|
import zipfile
|
|
from pathlib import Path
|
|
from datetime import datetime, timedelta
|
|
import glob
|
|
import requests
|
|
from dotenv import load_dotenv
|
|
from config import Config
|
|
from notifications import EmailNotifier
|
|
|
|
# Load environment
|
|
load_dotenv()
|
|
|
|
# Configuration
|
|
BOT_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN')
|
|
ALLOWED_USER_IDS = os.getenv('TELEGRAM_ALLOWED_USER_IDS', '').split(',') # Ex: "123456,789012"
|
|
CHAT_ID = os.getenv('TELEGRAM_CHAT_ID')
|
|
POLL_TIMEOUT = int(os.getenv('TELEGRAM_POLL_TIMEOUT', 60)) # Default 60 secunde
|
|
|
|
# Logging - force stdout instead of stderr (for Windows service logging)
|
|
# Set UTF-8 encoding for stdout to support Romanian characters
|
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
|
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format='[%(asctime)s] [%(levelname)s] %(message)s',
|
|
datefmt='%Y-%m-%d %H:%M:%S',
|
|
stream=sys.stdout,
|
|
force=True
|
|
)
|
|
|
|
class TelegramTriggerBot:
|
|
def __init__(self):
|
|
self.bot_token = BOT_TOKEN
|
|
self.allowed_users = [int(uid.strip()) for uid in ALLOWED_USER_IDS
|
|
if uid.strip() and not uid.strip().startswith('#')]
|
|
self.allowed_group_id = CHAT_ID.strip() if CHAT_ID else None
|
|
self.base_url = f"https://api.telegram.org/bot{self.bot_token}"
|
|
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!")
|
|
|
|
logging.info(f"Bot inițializat. Useri autorizați: {self.allowed_users}")
|
|
logging.info(f"Grup autorizat: {self.allowed_group_id}")
|
|
logging.info(f"Long polling timeout: {self.poll_timeout}s")
|
|
|
|
# Înregistrare comenzi în meniul Telegram
|
|
self._register_commands()
|
|
|
|
def _register_commands(self):
|
|
"""Înregistrează comenzile bot în meniul Telegram (pentru DM și grupuri)"""
|
|
try:
|
|
url = f"{self.base_url}/setMyCommands"
|
|
commands = [
|
|
{"command": "scrape", "description": "Rulează scraper-ul BTGO"},
|
|
{"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"}
|
|
]
|
|
|
|
response = requests.post(url, json={"commands": commands})
|
|
if response.status_code == 200 and response.json().get('ok'):
|
|
logging.info("✓ Comenzi înregistrate în meniul Telegram")
|
|
else:
|
|
logging.warning(f"Nu am putut înregistra comenzile: {response.text}")
|
|
except Exception as e:
|
|
logging.warning(f"Eroare înregistrare comenzi: {e}")
|
|
|
|
def send_message(self, chat_id, text, reply_to_message_id=None):
|
|
"""Trimite mesaj text"""
|
|
url = f"{self.base_url}/sendMessage"
|
|
data = {
|
|
'chat_id': chat_id,
|
|
'text': text,
|
|
'parse_mode': 'Markdown'
|
|
}
|
|
if reply_to_message_id:
|
|
data['reply_to_message_id'] = reply_to_message_id
|
|
response = requests.post(url, json=data)
|
|
return response
|
|
|
|
def send_document(self, chat_id, file_path, caption=None):
|
|
"""Trimite document (CSV/JSON)"""
|
|
url = f"{self.base_url}/sendDocument"
|
|
with open(file_path, 'rb') as file:
|
|
files = {'document': file}
|
|
data = {'chat_id': chat_id}
|
|
if caption:
|
|
data['caption'] = caption
|
|
response = requests.post(url, data=data, files=files)
|
|
return response.json()
|
|
|
|
def is_member_of_group(self, user_id, group_id):
|
|
"""Verifică dacă user_id este membru al group_id prin Telegram API"""
|
|
try:
|
|
url = f"{self.base_url}/getChatMember"
|
|
params = {
|
|
'chat_id': group_id,
|
|
'user_id': user_id
|
|
}
|
|
response = requests.get(url, params=params, timeout=5)
|
|
|
|
if response.status_code == 200 and response.json().get('ok'):
|
|
result = response.json().get('result', {})
|
|
status = result.get('status', '')
|
|
|
|
# Statusuri valide: creator, administrator, member
|
|
if status in ['creator', 'administrator', 'member']:
|
|
logging.info(f"User {user_id} este membru al grupului {group_id} (status: {status})")
|
|
return True
|
|
else:
|
|
logging.info(f"User {user_id} NU este membru al grupului {group_id} (status: {status})")
|
|
return False
|
|
else:
|
|
logging.warning(f"Eroare verificare membership: {response.text}")
|
|
return False
|
|
except Exception as e:
|
|
logging.error(f"Excepție verificare membership pentru user {user_id}: {e}")
|
|
return False
|
|
|
|
def is_user_allowed(self, user_id):
|
|
"""Verifică dacă user-ul are permisiune (whitelist sau membru al grupului autorizat)"""
|
|
# 1. Verifică dacă e în whitelist explicit
|
|
if user_id in self.allowed_users:
|
|
logging.info(f"User {user_id} autorizat prin TELEGRAM_ALLOWED_USER_IDS")
|
|
return True
|
|
|
|
# 2. Verifică dacă e membru al grupului autorizat
|
|
if self.allowed_group_id:
|
|
if self.is_member_of_group(user_id, self.allowed_group_id):
|
|
logging.info(f"User {user_id} autorizat prin membership în grup {self.allowed_group_id}")
|
|
return True
|
|
|
|
# 3. Dacă ambele liste sunt goale, permite oricui (backwards compatible)
|
|
if not self.allowed_users and not self.allowed_group_id:
|
|
logging.warning("Nicio restricție configurată - bot DESCHIS pentru toți userii!")
|
|
return True
|
|
|
|
# 4. Altfel, respinge
|
|
logging.warning(f"User {user_id} RESPINS - nu e în whitelist și nu e membru al grupului")
|
|
return False
|
|
|
|
def run_scraper(self, chat_id, reply_to_message_id=None, send_as_zip=False, balances_only=False):
|
|
"""Execută scraper-ul"""
|
|
# Trimite mesaj inițial și salvează message_id pentru editare ulterioară
|
|
zip_msg = " (arhiva ZIP)" if send_as_zip else ""
|
|
balances_msg = " - DOAR SOLDURI" if balances_only else ""
|
|
response = self.send_message(chat_id, f"*BTGO Scraper pornit{zip_msg}{balances_msg}*\n\nAsteapta 2FA pe telefon.", reply_to_message_id)
|
|
message_id = None
|
|
try:
|
|
message_id = response.json()['result']['message_id']
|
|
logging.info(f"Mesaj progress creat cu ID: {message_id}")
|
|
except:
|
|
logging.warning("Nu am putut salva message_id pentru progress updates")
|
|
|
|
try:
|
|
# Șterge fișierele CSV, ZIP și PNG anterioare
|
|
data_dir = Path('data')
|
|
if data_dir.exists():
|
|
deleted_count = 0
|
|
|
|
# Șterge CSV-uri de solduri
|
|
for f in data_dir.glob('solduri_*.csv'):
|
|
f.unlink()
|
|
deleted_count += 1
|
|
logging.info(f"Șters: {f.name}")
|
|
|
|
# Șterge CSV-uri de tranzacții
|
|
for f in data_dir.glob('tranzactii_*.csv'):
|
|
f.unlink()
|
|
deleted_count += 1
|
|
logging.info(f"Șters: {f.name}")
|
|
|
|
# Șterge JSON-uri
|
|
for f in data_dir.glob('solduri_*.json'):
|
|
f.unlink()
|
|
deleted_count += 1
|
|
logging.info(f"Șters: {f.name}")
|
|
|
|
# Șterge ZIP-uri
|
|
for f in data_dir.glob('btgo_export_*.zip'):
|
|
f.unlink()
|
|
deleted_count += 1
|
|
logging.info(f"Șters: {f.name}")
|
|
|
|
# Șterge PNG-uri (screenshot-uri Playwright)
|
|
for f in data_dir.glob('*.png'):
|
|
f.unlink()
|
|
deleted_count += 1
|
|
logging.info(f"Șters: {f.name}")
|
|
|
|
if deleted_count > 0:
|
|
logging.info(f"Total {deleted_count} fisiere sterse inainte de scraping")
|
|
|
|
# Rulează scraper-ul
|
|
logging.info(f"Pornire scraper (send_as_zip={send_as_zip}, balances_only={balances_only})...")
|
|
|
|
# Prepare environment with global playwright path + Telegram progress info
|
|
env = os.environ.copy()
|
|
env['PLAYWRIGHT_BROWSERS_PATH'] = 'C:\\playwright-browsers'
|
|
|
|
# Setează progress updates pentru Telegram
|
|
if message_id:
|
|
env['TELEGRAM_CHAT_ID'] = str(chat_id)
|
|
env['TELEGRAM_MESSAGE_ID'] = str(message_id)
|
|
logging.info(f"Setting environment: TELEGRAM_CHAT_ID={chat_id}, TELEGRAM_MESSAGE_ID={message_id}")
|
|
|
|
# Dacă send_as_zip, comunică să trimită ZIP în loc de fișiere individuale
|
|
if send_as_zip:
|
|
env['SEND_AS_ZIP'] = 'true'
|
|
logging.info("Mod ZIP activat - va trimite arhivă ZIP")
|
|
|
|
# Dacă balances_only, comunică să nu descarce tranzacții
|
|
if balances_only:
|
|
env['BALANCES_ONLY'] = 'true'
|
|
logging.info("Mod DOAR SOLDURI activat - nu va descărca tranzacții")
|
|
else:
|
|
logging.warning("No message_id available for progress updates")
|
|
|
|
result = subprocess.run(
|
|
[sys.executable, 'btgo_scraper.py'],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=600, # 10 minute timeout
|
|
cwd=os.path.dirname(os.path.abspath(__file__)), # Run in bot's directory
|
|
env=env # Pass environment with playwright path
|
|
)
|
|
|
|
if result.returncode == 0:
|
|
logging.info("Scraper finalizat cu succes")
|
|
# Mesajul final va fi editat de notifications.py (cu ZIP sau fișiere individuale)
|
|
|
|
else:
|
|
# Eroare
|
|
logging.error(f"Scraper eșuat cu cod {result.returncode}")
|
|
error_msg = result.stderr[-1000:] if result.stderr else "Eroare necunoscută"
|
|
self.send_message(
|
|
chat_id,
|
|
f"*EROARE SCRAPER*\n\n```\n{error_msg}\n```",
|
|
reply_to_message_id
|
|
)
|
|
|
|
except subprocess.TimeoutExpired:
|
|
logging.error("Timeout scraper")
|
|
self.send_message(chat_id, "*TIMEOUT*\n\nScraper-ul a depasit 10 minute.", reply_to_message_id)
|
|
|
|
except Exception as e:
|
|
logging.error(f"Eroare execuție: {e}")
|
|
self.send_message(chat_id, f"*EROARE EXECUTIE*\n\n```\n{str(e)}\n```", reply_to_message_id)
|
|
|
|
def show_cached_balances(self, chat_id, reply_to_message_id=None):
|
|
"""Afișează soldurile din cel mai recent fișier solduri.csv"""
|
|
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 ultimul fișier solduri
|
|
solduri_files = sorted(data_dir.glob('solduri_*.csv'), key=lambda x: x.stat().st_mtime, reverse=True)
|
|
|
|
if not solduri_files:
|
|
self.send_message(chat_id, "*EROARE*\n\nNu s-au găsit fișiere solduri!", reply_to_message_id)
|
|
return
|
|
|
|
latest_solduri = solduri_files[0]
|
|
solduri_time = latest_solduri.stat().st_mtime
|
|
file_datetime = datetime.fromtimestamp(solduri_time).strftime('%Y-%m-%d %H:%M:%S')
|
|
|
|
# Citește fișierul CSV
|
|
accounts = []
|
|
with open(latest_solduri, 'r', encoding='utf-8') as f:
|
|
reader = csv.DictReader(f)
|
|
for row in reader:
|
|
accounts.append({
|
|
'nume_cont': row['nume_cont'],
|
|
'sold': float(row['sold']),
|
|
'moneda': row['moneda']
|
|
})
|
|
|
|
# Construiește mesaj cu solduri
|
|
total_ron = sum(acc['sold'] for acc in accounts if acc.get('moneda') == 'RON')
|
|
|
|
message = f"*SOLDURI BANCARE*\n\n"
|
|
message += f"Data: {file_datetime}\n"
|
|
message += f"Conturi: {len(accounts)}\n\n"
|
|
|
|
for acc in accounts:
|
|
nume = acc['nume_cont']
|
|
sold = acc['sold']
|
|
moneda = acc['moneda']
|
|
message += f" • {nume}: {sold:,.2f} {moneda}\n"
|
|
|
|
message += f"\n*TOTAL: {total_ron:,.2f} RON*"
|
|
|
|
self.send_message(chat_id, message, reply_to_message_id)
|
|
logging.info(f"Afișat solduri cached din {latest_solduri.name}")
|
|
|
|
except Exception as e:
|
|
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 <COMERCIANT> <REST>"
|
|
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:
|
|
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 ultimul fișier solduri
|
|
solduri_files = sorted(data_dir.glob('solduri_*.csv'), key=lambda x: x.stat().st_mtime, reverse=True)
|
|
|
|
if not solduri_files:
|
|
self.send_message(chat_id, "*EROARE*\n\nNu s-au găsit fișiere solduri!", reply_to_message_id)
|
|
return
|
|
|
|
latest_solduri = solduri_files[0]
|
|
solduri_time = latest_solduri.stat().st_mtime
|
|
|
|
# Găsește fișierele tranzacții din aceeași sesiune (ultimele 5 minute)
|
|
time_window = 300 # 5 minute
|
|
transaction_files = []
|
|
|
|
for tf in data_dir.glob('tranzactii_*.csv'):
|
|
if abs(tf.stat().st_mtime - solduri_time) <= time_window:
|
|
transaction_files.append(tf)
|
|
|
|
# Găsește fișierul JSON corespunzător
|
|
json_file = data_dir / (latest_solduri.stem + '.json')
|
|
accounts_data = []
|
|
|
|
if json_file.exists():
|
|
try:
|
|
with open(json_file, 'r', encoding='utf-8') as f:
|
|
json_data = json.load(f)
|
|
accounts_data = json_data.get('conturi', [])
|
|
except Exception as e:
|
|
logging.warning(f"Nu s-a putut citi JSON: {e}")
|
|
|
|
# Creează arhiva ZIP
|
|
timestamp = datetime.now().strftime('%Y-%m-%d_%H-%M-%S')
|
|
zip_filename = f'btgo_export_{timestamp}.zip'
|
|
zip_path = data_dir / zip_filename
|
|
|
|
files_to_zip = [latest_solduri] + transaction_files
|
|
if json_file.exists():
|
|
files_to_zip.append(json_file)
|
|
|
|
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
|
for file_path in files_to_zip:
|
|
zipf.write(file_path, file_path.name)
|
|
|
|
zip_size = zip_path.stat().st_size / (1024 * 1024) # MB
|
|
|
|
logging.info(f"Arhivă ZIP creată: {zip_filename} ({zip_size:.2f} MB)")
|
|
|
|
# Verifică limita Telegram (50 MB)
|
|
if zip_size > 50:
|
|
self.send_message(
|
|
chat_id,
|
|
f"*EROARE*\n\nArhiva ZIP este prea mare ({zip_size:.2f} MB)\n"
|
|
f"Limita Telegram: 50 MB",
|
|
reply_to_message_id
|
|
)
|
|
zip_path.unlink() # Șterge fișierul
|
|
return
|
|
|
|
# Construiește mesaj cu solduri
|
|
caption = f"*BTGO Export (ZIP)*\n\n"
|
|
caption += f"Timp: {datetime.fromtimestamp(solduri_time).strftime('%Y-%m-%d %H:%M:%S')}\n"
|
|
caption += f"Dimensiune: {zip_size:.2f} MB\n"
|
|
caption += f"Fișiere: {len(files_to_zip)}\n\n"
|
|
|
|
if accounts_data:
|
|
total_ron = sum(acc['sold'] for acc in accounts_data if acc.get('moneda') == 'RON')
|
|
caption += "*SOLDURI:*\n"
|
|
for acc in accounts_data:
|
|
nume = acc['nume_cont']
|
|
sold = acc['sold']
|
|
moneda = acc['moneda']
|
|
caption += f" • {nume}: {sold:,.2f} {moneda}\n"
|
|
caption += f"\n*TOTAL: {total_ron:,.2f} RON*"
|
|
else:
|
|
caption += f"Conturi: {len(transaction_files)}"
|
|
|
|
# Trimite ZIP-ul
|
|
self.send_message(chat_id, "*Creare arhivă ZIP...*", reply_to_message_id)
|
|
|
|
url = f"{self.base_url}/sendDocument"
|
|
with open(zip_path, 'rb') as f:
|
|
files = {'document': f}
|
|
data = {
|
|
'chat_id': chat_id,
|
|
'caption': caption,
|
|
'parse_mode': 'Markdown'
|
|
}
|
|
if reply_to_message_id:
|
|
data['reply_to_message_id'] = reply_to_message_id
|
|
|
|
response = requests.post(url, data=data, files=files)
|
|
|
|
if response.status_code == 200:
|
|
logging.info("✓ ZIP trimis cu succes pe Telegram")
|
|
else:
|
|
logging.error(f"Eroare trimitere ZIP: {response.text}")
|
|
self.send_message(chat_id, f"*EROARE*\n\nNu s-a putut trimite arhiva.", reply_to_message_id)
|
|
|
|
# Trimite și pe email dacă este configurat
|
|
try:
|
|
config = Config()
|
|
if config.EMAIL_ENABLED:
|
|
email_notifier = EmailNotifier(config)
|
|
logging.info("Trimitere ZIP pe email...")
|
|
if email_notifier.send_existing_zip(zip_path, accounts_data):
|
|
logging.info("✓ ZIP trimis cu succes pe email")
|
|
else:
|
|
logging.warning("Nu s-a putut trimite ZIP-ul pe email")
|
|
else:
|
|
logging.info("Email notifications disabled - skipping email")
|
|
except Exception as e:
|
|
logging.error(f"Eroare trimitere ZIP pe email: {e}")
|
|
|
|
# Șterge fișierul ZIP temporar
|
|
zip_path.unlink()
|
|
|
|
except Exception as e:
|
|
logging.error(f"Eroare send_zip_files: {e}", exc_info=True)
|
|
self.send_message(chat_id, f"*EROARE*\n\n```\n{str(e)}\n```", reply_to_message_id)
|
|
|
|
def handle_command(self, message):
|
|
"""Procesează comenzi primite"""
|
|
chat_id = message['chat']['id']
|
|
chat_type = message['chat']['type'] # 'private', 'group', 'supergroup'
|
|
chat_title = message['chat'].get('title', 'DM')
|
|
user_id = message['from']['id']
|
|
username = message['from'].get('username', 'Unknown')
|
|
text = message.get('text', '')
|
|
message_id = message.get('message_id')
|
|
|
|
# Normalizează comanda - elimină @username pentru grupuri (ex: /scrape@botname → /scrape)
|
|
if '@' in text:
|
|
text = text.split('@')[0]
|
|
|
|
# Log context
|
|
context = f"grup '{chat_title}'" if chat_type in ['group', 'supergroup'] else "DM"
|
|
logging.info(f"Mesaj de la {username} (ID: {user_id}) în {context}: {text}")
|
|
|
|
# Verifică autorizare
|
|
if not self.is_user_allowed(user_id):
|
|
logging.warning(f"User neautorizat: {user_id} în {context}")
|
|
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"
|
|
if chat_type in ['group', 'supergroup']:
|
|
welcome_msg += f"Bot activ in grupul *{chat_title}*\n\n"
|
|
welcome_msg += (
|
|
"Comenzi disponibile:\n"
|
|
"`/scrape` - Ruleaza scraper-ul complet\n"
|
|
"`/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"
|
|
)
|
|
self.send_message(chat_id, welcome_msg, message_id)
|
|
|
|
elif text == '/scrape':
|
|
logging.info(f"Comandă /scrape primită în {context}")
|
|
self.run_scraper(chat_id, message_id)
|
|
|
|
elif text == '/scrape_zip':
|
|
logging.info(f"Comandă /scrape_zip primită în {context}")
|
|
self.run_scraper(chat_id, message_id, send_as_zip=True)
|
|
|
|
elif text == '/scrape_solduri':
|
|
logging.info(f"Comandă /scrape_solduri primită în {context}")
|
|
self.run_scraper(chat_id, message_id, balances_only=True)
|
|
|
|
elif text == '/solduri':
|
|
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)
|
|
|
|
elif text == '/status':
|
|
data_dir = Path('data')
|
|
csv_count = len(list(data_dir.glob('*.csv')))
|
|
json_count = len(list(data_dir.glob('*.json')))
|
|
|
|
# Ultimul fișier
|
|
all_files = sorted(data_dir.glob('solduri_*.csv'), key=os.path.getmtime, reverse=True)
|
|
last_run = "N/A"
|
|
if all_files:
|
|
last_run = datetime.fromtimestamp(os.path.getmtime(all_files[0])).strftime('%Y-%m-%d %H:%M:%S')
|
|
|
|
self.send_message(
|
|
chat_id,
|
|
f"*STATUS SISTEM*\n\n"
|
|
f"Ultima rulare: `{last_run}`\n"
|
|
f"Fisiere CSV: {csv_count}\n"
|
|
f"Fisiere JSON: {json_count}\n"
|
|
f"Working dir: `{os.getcwd()}`",
|
|
message_id
|
|
)
|
|
|
|
elif text == '/help':
|
|
help_msg = "*GHID DE UTILIZARE*\n\n"
|
|
if chat_type in ['group', 'supergroup']:
|
|
help_msg += "IN GRUP: Toti membrii vad comenzile si rezultatele\n\n"
|
|
help_msg += (
|
|
"*COMENZI:*\n"
|
|
"`/scrape` - Ruleaza scraper + trimite fisiere individuale\n"
|
|
"`/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"
|
|
"*GHID SCRAPER:*\n"
|
|
"1. Trimite `/scrape`, `/scrape_zip` sau `/scrape_solduri`\n"
|
|
"2. Asteapta notificarea de 2FA pe telefon\n"
|
|
"3. Aproba in aplicatia George\n"
|
|
"4. Primesti fisierele automat\n\n"
|
|
"*DIFERENTE:*\n"
|
|
"• `/scrape` - Fisiere individuale (CSV + JSON)\n"
|
|
"• `/scrape_zip` - Un singur ZIP cu toate fisierele\n"
|
|
"• `/scrape_solduri` - Doar solduri (RAPID - fara CSV tranzactii)\n"
|
|
"• `/solduri` - Vizualizare rapida (fara 2FA)\n"
|
|
"• `/zip` - Fisiere existente (fara scraping)\n\n"
|
|
"*NOTE:*\n"
|
|
"- Scraper complet: ~2-3 minute\n"
|
|
"- Scraper solduri: ~30-40 secunde\n"
|
|
"- VM-ul trebuie sa aiba browser vizibil"
|
|
)
|
|
self.send_message(chat_id, help_msg, message_id)
|
|
|
|
else:
|
|
self.send_message(chat_id, f"*COMANDA NECUNOSCUTA*\n\n`{text}`\n\nFoloseste /help pentru comenzi.", message_id)
|
|
|
|
def get_updates(self):
|
|
"""Preia update-uri de la Telegram"""
|
|
url = f"{self.base_url}/getUpdates"
|
|
params = {
|
|
'offset': self.last_update_id + 1,
|
|
'timeout': self.poll_timeout
|
|
}
|
|
response = requests.get(url, params=params, timeout=self.poll_timeout + 5)
|
|
return response.json()
|
|
|
|
def run(self):
|
|
"""Loop principal bot"""
|
|
logging.info("Bot pornit. Așteaptă comenzi...")
|
|
|
|
while True:
|
|
try:
|
|
updates = self.get_updates()
|
|
|
|
if updates.get('ok'):
|
|
for update in updates.get('result', []):
|
|
self.last_update_id = update['update_id']
|
|
|
|
if 'message' in update:
|
|
self.handle_command(update['message'])
|
|
|
|
except KeyboardInterrupt:
|
|
logging.info("Bot oprit de utilizator")
|
|
break
|
|
|
|
except Exception as e:
|
|
logging.error(f"Eroare loop: {e}")
|
|
import time
|
|
time.sleep(5)
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
bot = TelegramTriggerBot()
|
|
bot.run()
|
|
except Exception as e:
|
|
logging.error(f"Eroare fatală: {e}")
|
|
raise
|