#!/usr/bin/env python3 # -*- coding: utf-8 -*- import argparse import sqlite3 import os import datetime import urllib.request import urllib.parse import logging import subprocess import tempfile import time from logging.handlers import RotatingFileHandler from typing import Optional, List, Tuple DEBUG = os.environ.get("DEBUG", "0").strip() == "1" SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) LOG_FILE = os.path.join(SCRIPT_DIR, "daily_report.log") TELEGRAM_CHAT_IDS = ["64463169", "24827341", "132455422", "5405962012"] TOKEN_FILE_HOME = os.path.expanduser("~/.telegram_dpc_bot_token") TOKEN_FILE_ETC = "/etc/telegram_dpc_bot_token" DB_CANDIDATES = [ (os.environ.get("SPEEDTEST_DB") or "").strip(), "/data/speedtest.sqlite", os.path.join(SCRIPT_DIR, "speedtest.sqlite"), os.path.join(SCRIPT_DIR, "database.sqlite"), os.path.join(SCRIPT_DIR, "data", "speedtest.sqlite"), os.path.join(SCRIPT_DIR, "data", "database.sqlite"), ] SPEEDTEST_CONTAINER = (os.environ.get("SPEEDTEST_CONTAINER") or "speedtest-tracker").strip() CONTAINER_DB_PATH = (os.environ.get("SPEEDTEST_CONTAINER_DB_PATH") or "/config/database.sqlite").strip() WARN_DOWN = 400 WARN_UP = 100 # Unità download/upload nel DB: # - default: byte/s => Mbps = (val*8)/1e6 # - se già bit/s: SPEEDTEST_VALUES_ARE_BITS=1 VALUES_ARE_BITS = os.environ.get("SPEEDTEST_VALUES_ARE_BITS", "0").strip() == "1" # Quante righe recenti leggere dal DB (poi filtriamo in Python sulle ultime 24h) MAX_ROWS = int(os.environ.get("SPEEDTEST_MAX_ROWS", "2000").strip()) def setup_logger() -> logging.Logger: logger = logging.getLogger("daily_report") logger.setLevel(logging.DEBUG if DEBUG else logging.INFO) logger.handlers.clear() fh = RotatingFileHandler(LOG_FILE, maxBytes=1_000_000, backupCount=5, encoding="utf-8") fh.setLevel(logging.DEBUG) fmt = logging.Formatter("%(asctime)s %(levelname)s %(message)s") fh.setFormatter(fmt) logger.addHandler(fh) if DEBUG: sh = logging.StreamHandler() sh.setLevel(logging.DEBUG) sh.setFormatter(fmt) logger.addHandler(sh) return logger LOGGER = setup_logger() def _read_text_file(path: str) -> str: try: with open(path, "r", encoding="utf-8") as f: return f.read().strip() except Exception: return "" def load_bot_token() -> str: tok = (os.environ.get("TELEGRAM_BOT_TOKEN") or "").strip() if tok: return tok tok = (os.environ.get("BOT_TOKEN") or "").strip() if tok: return tok tok = _read_text_file(TOKEN_FILE_HOME) if tok: return tok tok = _read_text_file(TOKEN_FILE_ETC) return tok.strip() if tok else "" def send_telegram_message(message: str, chat_ids: Optional[List[str]] = None) -> None: """ Args: message: Messaggio da inviare chat_ids: Lista di chat IDs (default: TELEGRAM_CHAT_IDS) """ if not message: return bot_token = load_bot_token() if not bot_token: LOGGER.error("Token Telegram mancante (env/file). Messaggio NON inviato.") return if chat_ids is None: chat_ids = TELEGRAM_CHAT_IDS url = f"https://api.telegram.org/bot{bot_token}/sendMessage" for chat_id in chat_ids: payload = { "chat_id": chat_id, "text": message, "parse_mode": "Markdown", "disable_web_page_preview": True, } try: data = urllib.parse.urlencode(payload).encode("utf-8") req = urllib.request.Request(url, data=data) with urllib.request.urlopen(req, timeout=20) as response: if response.status == 200: LOGGER.info("Report inviato a chat_id=%s", chat_id) else: LOGGER.error("Telegram HTTP %s chat_id=%s", response.status, chat_id) time.sleep(0.25) except Exception as e: LOGGER.exception("Errore invio Telegram chat_id=%s: %s", chat_id, e) def _to_mbps(val) -> float: try: v = float(val) except Exception: return 0.0 if VALUES_ARE_BITS: return v / 1_000_000.0 return (v * 8.0) / 1_000_000.0 def _parse_created_at_utc(created_at) -> Optional[datetime.datetime]: """ Robust parsing: - ISO con Z / offset - "YYYY-MM-DD HH:MM:SS[.ms]" (assumiamo UTC se naive, perché spesso il DB è UTC) """ try: if isinstance(created_at, datetime.datetime): dt = created_at else: s = str(created_at).strip() if not s: return None # normalizza Z s = s.replace("Z", "+00:00") # prova ISO (anche con T) try: dt = datetime.datetime.fromisoformat(s) except Exception: # fallback: "YYYY-MM-DD HH:MM:SS(.ms)" s2 = s.split(".")[0].replace("T", " ") dt = datetime.datetime.strptime(s2, "%Y-%m-%d %H:%M:%S") if dt.tzinfo is None: # assumi UTC se naive dt = dt.replace(tzinfo=datetime.timezone.utc) else: dt = dt.astimezone(datetime.timezone.utc) return dt except Exception: return None def find_local_db_path() -> str: for p in DB_CANDIDATES: if not p: continue if os.path.exists(p) and os.path.isfile(p): return p return "" def docker_copy_db_to_temp() -> str: try: subprocess.run(["docker", "inspect", SPEEDTEST_CONTAINER], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True) except Exception as e: LOGGER.error("Docker/container non disponibile (%s).", e) return "" try: tmpdir = tempfile.mkdtemp(prefix="speedtest_db_") dst = os.path.join(tmpdir, "database.sqlite") except Exception as e: LOGGER.exception("Impossibile creare directory temporanea: %s", e) return "" src = f"{SPEEDTEST_CONTAINER}:{CONTAINER_DB_PATH}" try: LOGGER.info("DB non trovato localmente. Copio da Docker: %s -> %s", src, dst) subprocess.run(["docker", "cp", src, dst], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) if os.path.exists(dst) and os.path.getsize(dst) > 0: return dst LOGGER.error("Copia Docker riuscita ma file assente/vuoto: %s", dst) return "" except Exception as e: LOGGER.exception("Errore docker cp: %s", e) return "" def generate_report(db_path: str) -> Optional[str]: now_utc = datetime.datetime.now(datetime.timezone.utc) window_start_utc = now_utc - datetime.timedelta(hours=24) try: conn = sqlite3.connect(db_path) cursor = conn.cursor() # NOTA: non filtriamo su created_at (string compare fragile). # Prendiamo le ultime MAX_ROWS righe completate e filtriamo in Python. query = """ SELECT download, upload, ping, created_at FROM results WHERE status = 'completed' ORDER BY created_at DESC LIMIT ? """ cursor.execute(query, (MAX_ROWS,)) raw_rows = cursor.fetchall() except Exception as e: LOGGER.exception("Errore DB (%s): %s", db_path, e) return None finally: try: conn.close() except Exception: pass if not raw_rows: LOGGER.info("Nessun test trovato.") return None # Filtra realmente per datetime (ultime 24h) e ordina crescente rows: List[Tuple[datetime.datetime, float, float, float]] = [] for d_raw, u_raw, ping_raw, created_at in raw_rows: dt_utc = _parse_created_at_utc(created_at) if not dt_utc: continue if dt_utc < window_start_utc or dt_utc > now_utc: continue d_mbps = _to_mbps(d_raw) u_mbps = _to_mbps(u_raw) try: ping_ms = float(ping_raw) except Exception: ping_ms = 0.0 rows.append((dt_utc, d_mbps, u_mbps, ping_ms)) rows.sort(key=lambda x: x[0]) LOGGER.debug("DB rows read=%s filtered_24h=%s (start=%s now=%s)", len(raw_rows), len(rows), window_start_utc.isoformat(timespec="seconds"), now_utc.isoformat(timespec="seconds")) if not rows: LOGGER.info("Nessun test nelle ultime 24h dopo filtro datetime.") return None header = "ORA | Dn | Up | Pg |!" sep = "-----+-----+-----+----+-" total_down = 0.0 total_up = 0.0 count = 0 issues = 0 now_local = datetime.datetime.now() msg = f"📊 **REPORT VELOCITÀ 24H**\n📅 {now_local.strftime('%d/%m/%Y')}\n\n" msg += "```text\n" msg += header + "\n" msg += sep + "\n" for dt_utc, d_mbps, u_mbps, ping_ms in rows: total_down += d_mbps total_up += u_mbps count += 1 flag = " " if d_mbps < WARN_DOWN or u_mbps < WARN_UP: issues += 1 flag = "!" time_str = dt_utc.astimezone().strftime("%H:%M") msg += f"{time_str:<5}|{int(round(d_mbps)):>5}|{int(round(u_mbps)):>5}|{int(round(ping_ms)):>4}|{flag}\n" msg += "```\n" avg_d = total_down / count avg_u = total_up / count icon_d = "✅" if avg_d >= WARN_DOWN else "⚠️" icon_u = "✅" if avg_u >= WARN_UP else "⚠️" msg += f"Ø ⬇️{icon_d}`{avg_d:.0f} Mbps` ⬆️{icon_u}`{avg_u:.0f} Mbps`" if issues > 0: msg += f"\n\n⚠️ **{issues}** test sotto soglia (!)" return msg def main(chat_ids: Optional[List[str]] = None) -> None: db_path = find_local_db_path() if not db_path: db_path = docker_copy_db_to_temp() if not db_path: LOGGER.error("Database non trovato. Locali provati=%s; Docker=%s:%s", [p for p in DB_CANDIDATES if p], SPEEDTEST_CONTAINER, CONTAINER_DB_PATH) print("❌ Database non trovato. Vedi daily_report.log.") return LOGGER.info("Uso database: %s", db_path) report = generate_report(db_path) if report: send_telegram_message(report, chat_ids=chat_ids) else: LOGGER.info("Nessun report da inviare.") if __name__ == "__main__": parser = argparse.ArgumentParser(description="Daily report") parser.add_argument("--debug", action="store_true", help="Invia messaggi solo all'admin (chat ID: %s)" % TELEGRAM_CHAT_IDS[0]) args = parser.parse_args() # In modalità debug, invia solo al primo chat ID (admin) chat_ids = [TELEGRAM_CHAT_IDS[0]] if args.debug else None main(chat_ids=chat_ids)