#!/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 Any, Dict, Optional, List, Tuple DEBUG = os.environ.get("DEBUG", "0").strip() == "1" SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) _default_log = os.path.join(SCRIPT_DIR, "daily_report.log") if not os.access(SCRIPT_DIR, os.W_OK): _default_log = "/data/daily_report.log" LOG_FILE = os.environ.get("DAILY_REPORT_LOG", _default_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 try: from telegram_gate import telegram_alerts_enabled except ImportError: telegram_alerts_enabled = lambda: True # type: ignore if not telegram_alerts_enabled(): LOGGER.info("Telegram sospeso: report speedtest non inviato") 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_data(db_path: str) -> Optional[Dict[str, Any]]: """Aggrega gli speedtest completati nelle ultime 24 ore.""" 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() cursor.execute( """ SELECT download, upload, ping, created_at FROM results WHERE status = 'completed' ORDER BY created_at DESC LIMIT ? """, (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 rows: List[Dict[str, Any]] = [] total_down = 0.0 total_up = 0.0 issues = 0 for d_raw, u_raw, ping_raw, created_at in raw_rows: dt_utc = _parse_created_at_utc(created_at) if not dt_utc or 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 below = d_mbps < WARN_DOWN or u_mbps < WARN_UP if below: issues += 1 total_down += d_mbps total_up += u_mbps rows.append({ "time": dt_utc.astimezone().strftime("%H:%M"), "created_at": dt_utc.astimezone().isoformat(), "download_mbps": round(d_mbps, 1), "upload_mbps": round(u_mbps, 1), "ping_ms": round(ping_ms, 1), "below_threshold": below, }) rows.sort(key=lambda r: r["created_at"]) if not rows: LOGGER.info("Nessun test nelle ultime 24h dopo filtro datetime.") return None count = len(rows) avg_d = total_down / count avg_u = total_up / count now_local = datetime.datetime.now() return { "date": now_local.strftime("%d/%m/%Y"), "window_hours": 24, "count": count, "issues": issues, "warn_download_mbps": WARN_DOWN, "warn_upload_mbps": WARN_UP, "avg_download_mbps": round(avg_d, 1), "avg_upload_mbps": round(avg_u, 1), "avg_download_ok": avg_d >= WARN_DOWN, "avg_upload_ok": avg_u >= WARN_UP, "rows": rows, } def generate_report(db_path: str) -> Optional[str]: data = generate_report_data(db_path) if not data: return None header = "ORA | Dn | Up | Pg |!" sep = "-----+-----+-----+----+-" msg = f"📊 **REPORT VELOCITÀ 24H**\n📅 {data['date']}\n\n" msg += "```text\n" msg += header + "\n" msg += sep + "\n" for row in data["rows"]: flag = "!" if row["below_threshold"] else " " msg += ( f"{row['time']:<5}|{int(round(row['download_mbps'])):>5}|" f"{int(round(row['upload_mbps'])):>5}|{int(round(row['ping_ms'])):>4}|{flag}\n" ) msg += "```\n" icon_d = "✅" if data["avg_download_ok"] else "⚠️" icon_u = "✅" if data["avg_upload_ok"] else "⚠️" msg += f"Ø ⬇️{icon_d}`{data['avg_download_mbps']:.0f} Mbps` ⬆️{icon_u}`{data['avg_upload_mbps']:.0f} Mbps`" if data["issues"] > 0: msg += f"\n\n⚠️ **{data['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)