Files
btgo-playwright/telegram_trigger_bot.py
Marius Mutu 91021fa530 Permite membrilor grupului sa foloseasca bot-ul in DM prin verificare API
Adauga logica de autorizare flexibila:
- Useri in TELEGRAM_ALLOWED_USER_IDS (whitelist explicit)
- SAU membrii grupului TELEGRAM_CHAT_ID (verificare getChatMember API)
- Membrii grupului pot folosi bot-ul atat in grup cat si in DM individual

Modificari:
- telegram_trigger_bot.py: metoda is_member_of_group() cu verificare API
- telegram_trigger_bot.py: is_user_allowed() cu logica OR pentru whitelist + grup
- .env.example: comentarii actualizate pentru noua logica
- TELEGRAM_BOT_SETUP.md: documentatie completa pentru autorizare

Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 11:26:48 +02:00

607 lines
25 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
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
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": "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 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ă 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"
"`/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 == '/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"
"`/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