"""Notifiche Telegram per l'esecuzione live (ordini + errori). Stdlib only, NO-OP se non configurato. Config (gitignored): TELEGRAM_BOT_TOKEN + TELEGRAM_CHAT_ID da env, oppure da .env.mainnet / .env. Se mancano, notify() ritorna False e non rompe nulla (l'esecuzione non dipende dagli alert). Test della config: uv run python -m src.live.notifier "messaggio di prova" """ from __future__ import annotations import os import time import urllib.parse import urllib.request from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parents[2] def _cfg() -> tuple[str, str]: tok = os.environ.get("TELEGRAM_BOT_TOKEN", "") chat = os.environ.get("TELEGRAM_CHAT_ID", "") if tok and chat: return tok, chat for fn in (".env.mainnet", ".env"): p = PROJECT_ROOT / fn if not p.exists(): continue for ln in p.read_text().splitlines(): ln = ln.strip() if ln.startswith("TELEGRAM_BOT_TOKEN=") and not tok: tok = ln.split("=", 1)[1].strip() elif ln.startswith("TELEGRAM_CHAT_ID=") and not chat: chat = ln.split("=", 1)[1].strip() return tok, chat # Ultimo esito d'invio, per chi vuole SAPERE se il messaggio e' partito. Fino al 2026-08-23 # `send()` faceva un tentativo e ingoiava l'eccezione senza scriverla da nessuna parte: quando # serviva sapere se un allarme era arrivato, l'informazione non esisteva (tasso misurato di # invii falliti: 6,9%, 2 su 29). Regola gia' codificata il 29/07 su un altro percorso e mai # applicata qui: *se un errore si ingoia per non bloccare, si registra nel punto in cui lo si ingoia.* _ULTIMO_ERRORE: str | None = None def ultimo_errore() -> str | None: """Motivo dell'ultimo invio fallito, o None se l'ultimo e' riuscito. Non sopravvive a un invio riuscito: una causa vecchia accanto a un invio nuovo manda sulla pista sbagliata.""" return _ULTIMO_ERRORE def send(text: str, tentativi: int = 1, pausa_s: float = 2.0) -> bool: """`tentativi` DEFAULT 1 = comportamento invariato per tutti i chiamanti esistenti. Chi vuole il retry lo chiede: alzarlo per tutti cambierebbe la latenza degli allarmi di `venue_watch` e `book_execute` su un percorso con soldi veri, e non e' una modifica da fare di straforo dentro un'altra funzionalita'. """ global _ULTIMO_ERRORE tok, chat = _cfg() if not tok or not chat: _ULTIMO_ERRORE = "config assente (TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID)" return False url = f"https://api.telegram.org/bot{tok}/sendMessage" data = urllib.parse.urlencode({"chat_id": chat, "text": text, "parse_mode": "HTML"}).encode() ultimo = "" for i in range(max(1, tentativi)): try: urllib.request.urlopen(url, data, timeout=10) _ULTIMO_ERRORE = None return True except Exception as e: # noqa: BLE001 — si registra, non si nasconde ultimo = f"{type(e).__name__}: {str(e)[:120]}" if i + 1 < max(1, tentativi): time.sleep(pausa_s * (i + 1)) # backoff lineare _ULTIMO_ERRORE = f"{max(1, tentativi)} tentativi falliti — {ultimo}" return False def notify(title: str, data: dict | None = None, tentativi: int = 1) -> bool: """Invia un alert formattato. Ritorna True se inviato (config presente + rete ok). `tentativi` DEFAULT 1 = comportamento invariato per tutti i chiamanti esistenti; chi manda un allarme che non puo' permettersi di perdere lo alza (venue_watch: 3). """ try: from src.version import APP_VERSION ver = f" v{APP_VERSION}" except Exception: ver = "" lines = [f"{title}{ver}"] for k, v in (data or {}).items(): lines.append(f" {k}: {v}") return send("\n".join(lines), tentativi=tentativi) def is_configured() -> bool: tok, chat = _cfg() return bool(tok and chat) if __name__ == "__main__": import sys msg = sys.argv[1] if len(sys.argv) > 1 else "TP01 — test alert" ok = notify("🔔 " + msg, {"configurato": is_configured()}) print("inviato" if ok else "NON inviato (TELEGRAM_BOT_TOKEN/CHAT_ID assenti in env o .env.mainnet)")