Files
loogle-scripts/services/telegram-bot/daily_report.py
T

374 lines
11 KiB
Python

#!/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
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)