#!/usr/bin/env python3 import requests import datetime import argparse import sys import logging import os import time from typing import Optional, List from zoneinfo import ZoneInfo from dateutil import parser as date_parser from open_meteo_client import open_meteo_get # Setup logging logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) # --- CONFIGURAZIONE METEO --- HOME_LAT = 43.9356 HOME_LON = 12.4296 HOME_NAME = "🏠 Casa" TZ = "Europe/Berlin" TZINFO = ZoneInfo(TZ) OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast" GEOCODING_URL = "https://geocoding-api.open-meteo.com/v1/search" HTTP_HEADERS = {"User-Agent": "loogle-bot-v10.5"} # --- TELEGRAM CONFIG --- TOKEN_FILE_HOME = os.path.expanduser("~/.telegram_dpc_bot_token") TOKEN_FILE_ETC = "/etc/telegram_dpc_bot_token" def read_text_file(path: str) -> str: try: with open(path, "r", encoding="utf-8") as f: return f.read().strip() except FileNotFoundError: return "" except PermissionError: return "" except Exception as e: logger.error(f"Error reading {path}: {e}") return "" def load_bot_token() -> str: tok = os.environ.get("TELEGRAM_BOT_TOKEN", "").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 telegram_send_markdown(message_md: str, chat_ids: Optional[List[str]] = None) -> bool: """Invia messaggio Markdown a Telegram. Returns True se almeno un invio è riuscito.""" token = load_bot_token() if not token: logger.warning("Telegram token missing: message not sent.") return False if chat_ids is None: return False # Se non specificato, non inviare url = f"https://api.telegram.org/bot{token}/sendMessage" base_payload = { "text": message_md, "parse_mode": "Markdown", "disable_web_page_preview": True, } sent_ok = False with requests.Session() as s: for chat_id in chat_ids: payload = dict(base_payload) payload["chat_id"] = chat_id try: resp = s.post(url, json=payload, timeout=15) if resp.status_code == 200: sent_ok = True else: logger.error("Telegram error chat_id=%s status=%s body=%s", chat_id, resp.status_code, resp.text[:500]) time.sleep(0.25) except Exception as e: logger.exception("Telegram exception chat_id=%s err=%s", chat_id, e) return sent_ok def now_local() -> datetime.datetime: return datetime.datetime.now(TZINFO) def parse_time(t: str, tz: Optional[ZoneInfo] = None) -> datetime.datetime: """Interpreta un timestamp ISO dell'API nel fuso richiesto (default: Casa / Europe/Berlin).""" target = tz if tz is not None else TZINFO try: dt = date_parser.isoparse(t) if dt.tzinfo is None: return dt.replace(tzinfo=target) return dt.astimezone(target) except Exception as e: logger.error(f"Time parse error: {e}") return datetime.datetime.now(target) def degrees_to_cardinal(d: int) -> str: dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"] try: return dirs[round(d / 45) % 8] except: return "N" # --- HELPER SICUREZZA DATI --- def get_val(val, default=0.0): if val is None: return default return float(val) def safe_get_list(hourly_data, key, length, default=None): if key in hourly_data and hourly_data[key] is not None: return hourly_data[key] return [default] * length def get_icon_set(prec, snow, code, is_day, cloud, vis, temp, rain, gust, cape, cloud_type): sky = "☁️" try: # LOGICA NEVE (v10.5 Fix): # È neve se c'è accumulo OPPURE se il codice meteo dice neve (anche senza accumulo) is_snowing = snow > 0 or (code in [71, 73, 75, 77, 85, 86]) if cloud_type == 'F': sky = "🌫️" elif code in (95, 96, 99): sky = "⛈️" if prec > 0 else "🌩️" elif prec >= 0.1: sky = "🌨️" if is_snowing else "🌧️" else: # LOGICA PERCEZIONE UMANA (Nubi Alte vs Basse) if cloud_type == 'H': if cloud <= 40: sky = "☀️" if is_day else "🌙" elif cloud <= 80: sky = "🌤️" if is_day else "🌙" else: sky = "🌥️" else: if cloud <= 15: sky = "☀️" if is_day else "🌙" elif cloud <= 35: sky = "🌤️" if is_day else "🌙" elif cloud <= 60: sky = "⛅️" elif cloud <= 85: sky = "🌥️" else: sky = "☁️" sgx = "-" # Simbolo laterale (Priorità agli eventi pericolosi) if is_snowing: sgx = "☃️" elif temp < 0 or (code is not None and code in (66,67)): sgx = "🧊" elif cape > 2000: sgx = "🌪️" elif cape > 1000: sgx = "⚡" elif temp > 35: sgx = "🥵" elif rain > 4: sgx = "☔️" elif gust > 50: sgx = "💨" return sky, sgx except Exception as e: logger.error(f"Icon error: {e}") return "❓", "-" def get_coordinates(city_name: str): params = {"name": city_name, "count": 1, "language": "it", "format": "json"} try: r = open_meteo_get(GEOCODING_URL, params=params, headers=HTTP_HEADERS, timeout=(5, 15)) data = r.json() if "results" in data and data["results"]: res = data["results"][0] cc = res.get("country_code", "IT").upper() name = f"{res.get('name')} ({cc})" geo_tz = res.get("timezone") return res["latitude"], res["longitude"], name, cc, geo_tz except Exception as e: logger.error(f"Geocoding error: {e}") return None def choose_best_model(lat, lon, cc, is_home=False): """ Sceglie il modello meteo. - Per Casa: usa ICON Italia (ARPAE 2i) - migliore risoluzione spaziale per Italia/San Marino. - Per altre località: usa best match di Open-Meteo (senza specificare models) """ if is_home: # Per Casa, usa ICON Italia (risoluzione spaziale migliore per Italia/San Marino) return "italia_meteo_arpae_icon_2i", "ICON Italia" else: # Per query worldwide, usa best match (Open-Meteo sceglie automaticamente) return None, "Best Match" def get_forecast(lat, lon, model=None, is_home=False, timezone=None, retry_after_60s=False): """ Recupera forecast. Se model è None, usa best match di Open-Meteo. Per Casa (is_home=True), usa ICON Italia. Args: retry_after_60s: Se True, attende 10 secondi prima di riprovare (per retry) """ # Usa timezone personalizzata se fornita, altrimenti default tz_to_use = timezone if timezone else TZ # Se è un retry, attendi 10 secondi (ridotto da 60s per evitare timeout esterni) if retry_after_60s: logger.info("Attendo 10 secondi prima del retry...") time.sleep(10) # Singola coordinata: cielo sopra il punto richiesto (casa o località). params = { "latitude": lat, "longitude": lon, "timezone": tz_to_use, "forecast_days": 3, "wind_speed_unit": "kmh", "precipitation_unit": "mm", "hourly": "temperature_2m,apparent_temperature,relative_humidity_2m,cloud_cover,cloud_cover_low,cloud_cover_mid,cloud_cover_high,windspeed_10m,winddirection_10m,windgusts_10m,precipitation,rain,showers,snowfall,weathercode,is_day,cape,visibility,uv_index" } # Aggiungi models solo se specificato (per Casa usa ICON Italia, per altre località best match) if model: params["models"] = model # Nota: minutely_15 non è usato in meteo.py (solo per script di allerta) try: t0 = time.time() # Timeout ridotto a 20s per fallire più velocemente in caso di problemi r = open_meteo_get(OPEN_METEO_URL, params=params, headers=HTTP_HEADERS, timeout=(5, 20)) if r.status_code != 200: # Dettagli errore più specifici error_details = f"Status {r.status_code}" try: error_json = r.json() if "reason" in error_json: error_details += f": {error_json['reason']}" elif "error" in error_json: error_details += f": {error_json['error']}" else: error_details += f": {r.text[:200]}" except: error_details += f": {r.text[:200]}" logger.error(f"API Error {error_details}") return None, error_details # Restituisce anche i dettagli dell'errore response_data = r.json() logger.info("get_forecast ok model=%s elapsed=%.2fs", model or "best_match", time.time() - t0) return response_data, None except requests.exceptions.Timeout as e: error_details = f"Timeout dopo 20s: {str(e)}" logger.error("Request timeout: %s elapsed=%.2fs", error_details, time.time() - t0) return None, error_details except requests.exceptions.ConnectionError as e: error_details = f"Errore connessione: {str(e)}" logger.error("Connection error: %s elapsed=%.2fs", error_details, time.time() - t0) return None, error_details except Exception as e: error_details = f"Errore richiesta: {type(e).__name__}: {str(e)}" logger.error("Request error: %s elapsed=%.2fs", error_details, time.time() - t0) return None, error_details def get_visibility_forecast(lat, lon): """ Recupera visibilità per località dove il modello principale non la fornisce. Prova prima ECMWF IFS, poi fallback a best match (GFS o ICON-D2). """ # Prova prima con ECMWF IFS params_ecmwf = { "latitude": lat, "longitude": lon, "timezone": TZ, "forecast_days": 3, "models": "ecmwf_ifs04", "hourly": "visibility" } try: t0 = time.time() # Timeout ridotto a 12s per fallire più velocemente r = open_meteo_get(OPEN_METEO_URL, params=params_ecmwf, headers=HTTP_HEADERS, timeout=(5, 12)) if r.status_code == 200: data = r.json() hourly = data.get("hourly", {}) vis = hourly.get("visibility", []) # Verifica se ci sono valori validi (non tutti None) if vis and any(v is not None for v in vis): logger.info("get_visibility_forecast ok model=ecmwf_ifs04 elapsed=%.2fs", time.time() - t0) return vis except Exception as e: logger.debug("ECMWF IFS visibility request error: %s elapsed=%.2fs", e, time.time() - t0) # Fallback: usa best match (senza models) che seleziona automaticamente GFS o ICON-D2 params_best = { "latitude": lat, "longitude": lon, "timezone": TZ, "forecast_days": 3, "hourly": "visibility" } try: t0 = time.time() # Timeout ridotto a 12s per fallire più velocemente r = open_meteo_get(OPEN_METEO_URL, params=params_best, headers=HTTP_HEADERS, timeout=(5, 12)) if r.status_code == 200: data = r.json() hourly = data.get("hourly", {}) logger.info("get_visibility_forecast ok model=best_match elapsed=%.2fs", time.time() - t0) return hourly.get("visibility", []) except Exception as e: logger.error("Visibility request error: %s elapsed=%.2fs", e, time.time() - t0) return None def generate_weather_report(lat, lon, location_name, debug_mode=False, cc="IT", timezone=None) -> str: t_total = time.time() # Determina se è Casa is_home = (abs(lat - HOME_LAT) < 0.01 and abs(lon - HOME_LON) < 0.01) # Fuso per l'API: Casa = TZ; località = timezone esplicito/geocoding, altrimenti "auto" (Open-Meteo risolve da lat/lon) tz_for_api = timezone if timezone else (TZ if is_home else "auto") model_id, model_name = choose_best_model(lat, lon, cc, is_home=is_home) # Tentativo 1: Richiesta iniziale data_list, error_details = get_forecast(lat, lon, model_id, is_home=is_home, timezone=tz_for_api, retry_after_60s=False) # Se fallisce e siamo a Casa con ICON Italia, prova retry dopo 10 secondi if not data_list and is_home and model_id == "italia_meteo_arpae_icon_2i": logger.warning(f"Primo tentativo ICON Italia fallito: {error_details}. Retry dopo 10 secondi...") data_list, error_details = get_forecast(lat, lon, model_id, is_home=is_home, timezone=tz_for_api, retry_after_60s=True) # Se ancora fallisce e siamo a Casa, fallback a best match if not data_list and is_home: logger.warning(f"ICON Italia fallito anche dopo retry: {error_details}. Fallback a best match...") data_list, error_details = get_forecast(lat, lon, None, is_home=False, timezone=tz_for_api, retry_after_60s=False) if data_list: model_name = "Best Match (fallback)" logger.info("Fallback a best match riuscito") # Se ancora fallisce, restituisci errore dettagliato if not data_list: error_msg = f"❌ Errore API Meteo ({model_name})" if error_details: error_msg += f"\n\nDettagli: {error_details}" return error_msg if not isinstance(data_list, list): data_list = [data_list] data_center = data_list[0] # Ora e giorno LT: fuso della località (non San Marino se non è Casa) if timezone is not None: tz_to_use = timezone elif is_home: tz_to_use = TZ else: tz_to_use = data_center.get("timezone") or TZ try: tz_to_use_info = ZoneInfo(tz_to_use) except Exception: tz_to_use_info = TZINFO tz_to_use = TZ hourly_c = data_center.get("hourly", {}) times = hourly_c.get("time", []) if not times: return "❌ Dati orari mancanti." L = len(times) # --- DATI LOCALI --- l_temp = safe_get_list(hourly_c, "temperature_2m", L, 0) l_app = safe_get_list(hourly_c, "apparent_temperature", L, 0) l_rh = safe_get_list(hourly_c, "relative_humidity_2m", L, 50) l_prec = safe_get_list(hourly_c, "precipitation", L, 0) l_rain = safe_get_list(hourly_c, "rain", L, 0) l_showers = safe_get_list(hourly_c, "showers", L, 0) l_snow = safe_get_list(hourly_c, "snowfall", L, 0) l_wspd = safe_get_list(hourly_c, "windspeed_10m", L, 0) l_gust = safe_get_list(hourly_c, "windgusts_10m", L, 0) l_wdir = safe_get_list(hourly_c, "winddirection_10m", L, 0) l_code = safe_get_list(hourly_c, "weathercode", L, 0) l_day = safe_get_list(hourly_c, "is_day", L, 1) l_cape = safe_get_list(hourly_c, "cape", L, 0) l_vis = safe_get_list(hourly_c, "visibility", L, 10000) l_uv = safe_get_list(hourly_c, "uv_index", L, 0) # Se è Casa e ICON Italia non fornisce visibilità (tutti None), recuperala da best match if is_home and model_id == "italia_meteo_arpae_icon_2i": vis_check = [v for v in l_vis if v is not None] if not vis_check: # Tutti None, recupera da best match vis_data = get_visibility_forecast(lat, lon) if vis_data and len(vis_data) >= L: l_vis = vis_data[:L] # Dati nuvole LOCALI per decidere il TIPO (L, M, H, F) l_cl_tot_loc = safe_get_list(hourly_c, "cloud_cover", L, 0) # Copertura totale locale l_cl_low_loc = safe_get_list(hourly_c, "cloud_cover_low", L, 0) l_cl_mid_loc = safe_get_list(hourly_c, "cloud_cover_mid", L, 0) l_cl_hig_loc = safe_get_list(hourly_c, "cloud_cover_high", L, 0) # Nuvolosità (stesso punto della località) avg_cl_tot = [] for i in range(L): cc = get_val(l_cl_tot_loc[i], 0) cl = get_val(l_cl_low_loc[i], 0) cm = get_val(l_cl_mid_loc[i], 0) ch = get_val(l_cl_hig_loc[i], 0) avg_cl_tot.append(max(cc, cl, cm, ch)) # --- DEBUG MODE --- if debug_mode: output = f"🔍 **DEBUG METEO (v10.5)**\n" now_h = datetime.datetime.now(tz_to_use_info).replace(minute=0, second=0, microsecond=0) idx = 0 for i, t_str in enumerate(times): if parse_time(t_str, tz_to_use_info) >= now_h: idx = i break # Valori Locali loc_L = get_val(l_cl_low_loc[idx]) loc_H = get_val(l_cl_hig_loc[idx]) code_now = int(get_val(l_code[idx])) output += f"Ora: {parse_time(times[idx], tz_to_use_info).strftime('%H:%M')} (LT)\n" output += f"📍 **LOCALE**: L:{int(loc_L)}% | H:{int(loc_H)}%\n" output += f"☁️ **Nv%**: {int(avg_cl_tot[idx])}%\n" output += f"❄️ **NEVE**: Codice={code_now}, Accumulo={get_val(l_snow[idx])}cm\n" decision = "H" if loc_L > 40: decision = "L (Priorità Locale)" output += f"👉 **Decisione Nuvole**: {decision}\n" return output # --- GENERAZIONE TABELLA --- now_local_tz = datetime.datetime.now(tz_to_use_info) # Inizia dall'ora corrente (arrotondata all'ora) current_hour = now_local_tz.replace(minute=0, second=0, microsecond=0) # Fine finestra: 48 ore dopo current_hour end_hour = current_hour + datetime.timedelta(hours=48) # Raccogli tutti i timestamp validi nelle 48 ore successive valid_indices = [] for i, t_str in enumerate(times): try: dt = parse_time(t_str, tz_to_use_info) # Include solo timestamp >= current_hour e < end_hour if current_hour <= dt < end_hour: valid_indices.append((i, dt)) except Exception as e: logger.error(f"Errore parsing timestamp {i}: {e}") continue if not valid_indices: return f"❌ Nessun dato disponibile per le prossime 48 ore (da {current_hour.strftime('%H:%M')})." # Separa in blocchi per giorno: cambia intestazione quando passa da 23 a 00 blocks = [] header = f"{'LT':<2} {'T°':>4} {'h%':>3} {'mm':<3} {'Vento':<5} {'Nv%':>5} {'Sk':<2} {'Sx':<2}" separator = "-" * 31 current_day = None current_block_lines = [] hours_from_start = 0 # Contatore ore dall'inizio (0-47) for idx, dt in valid_indices: # Determina se questo timestamp appartiene a un nuovo giorno # (passaggio da 23 a 00) day_date = dt.date() is_new_day = (current_day is not None and day_date != current_day) # Determina se mostrare questo timestamp in base alla posizione nelle 48h # Prime 24h: ogni ora (step=1) # Dalla 25a alla 48a: ogni 2 ore (step=2) if hours_from_start < 24: step = 1 # Prime 24h: dettaglio 1 ora else: step = 2 # Dalla 25a alla 48a: dettaglio 2 ore # Controlla se questo timestamp deve essere mostrato should_show = (hours_from_start % step == 0) # Se è un nuovo giorno, chiudi il blocco precedente if is_new_day and current_block_lines: # Chiudi blocco precedente (solo se ha contenuto oltre header e separator) if len(current_block_lines) > 2: day_label = f"{['Lun','Mar','Mer','Gio','Ven','Sab','Dom'][current_day.weekday()]} {current_day.day}" blocks.append(f"*{day_label}*\n```text\n" + "\n".join(current_block_lines) + "\n```") current_block_lines = [] # Aggiorna current_day se è cambiato if current_day is None or is_new_day: current_day = day_date # Mostra questo timestamp solo se deve essere incluso if should_show: # Se è il primo elemento di questo blocco (o primo elemento dopo cambio giorno), aggiungi header if not current_block_lines: # Assicurati che current_day corrisponda al giorno della prima riga mostrata current_day = day_date current_block_lines.append(header) current_block_lines.append(separator) # --- DATI BASE --- T = get_val(l_temp[idx], 0) App = get_val(l_app[idx], 0) Rh = int(get_val(l_rh[idx], 50)) t_suffix = "" diff = App - T if diff <= -2.5: t_suffix = "W" elif diff >= 2.5: t_suffix = "H" t_s = f"{int(round(T))}{t_suffix}" Pr = get_val(l_prec[idx], 0) Sn = get_val(l_snow[idx], 0) Code = int(get_val(l_code[idx], 0)) Rain = get_val(l_rain[idx], 0) Showers = get_val(l_showers[idx], 0) if idx < len(l_showers) else 0 # Per modelli che espongono rain+showers (es. ICON Italia), usa il totale se precipitation è assente/zero Pr_display = max(Pr, Rain + Showers) # Determina se è neve is_snowing = Sn > 0 or (Code in [71, 73, 75, 77, 85, 86]) # Formattazione MM: 0 se nulla, altrimenti il valore (i modelli danno 0 o il valore orario) p_suffix = "" if Code in [96, 99]: p_suffix = "G" elif Code in [66, 67]: p_suffix = "Z" elif is_snowing and Pr_display > 0: p_suffix = "N" p_s = "0" if Pr_display <= 0 else f"{int(round(Pr_display))}{p_suffix}" # --- CLOUD LOGIC --- Cl = int(get_val(l_cl_tot_loc[idx], 0)) Vis = get_val(l_vis[idx], 10000) # Calcola tipo nuvole per get_icon_set (L/M/H/F) loc_L = get_val(l_cl_low_loc[idx]) loc_M = get_val(l_cl_mid_loc[idx]) loc_H = get_val(l_cl_hig_loc[idx]) types = {'L': loc_L, 'M': loc_M, 'H': loc_H} dominant_type = max(types, key=types.get) # Override: Se nubi basse locali > 40%, vincono loro if loc_L > 40: dominant_type = 'L' # Nebbia is_fog = False if Vis < 1500: is_fog = True elif Code in [45, 48]: is_fog = True if is_fog: dominant_type = 'F' # Formattazione Nv% if is_fog: cl_str = "FOG" else: cl_str = f"{Cl}" UV = get_val(l_uv[idx], 0) uv_suffix = "" if UV >= 10: uv_suffix = "E" elif UV >= 7: uv_suffix = "H" # --- VENTO --- Wspd = get_val(l_wspd[idx], 0) Gust = get_val(l_gust[idx], 0) Wdir = int(get_val(l_wdir[idx], 0)) Cape = get_val(l_cape[idx], 0) IsDay = int(get_val(l_day[idx], 1)) card = degrees_to_cardinal(Wdir) w_val = Gust if (Gust - Wspd) > 15 else Wspd w_txt = f"{card} {int(round(w_val))}" if (Gust - Wspd) > 15: g_txt = f"G{int(round(w_val))}" if len(card)+len(g_txt) <= 5: w_txt = f"{card}{g_txt}" elif len(card)+1+len(g_txt) <= 5: w_txt = f"{card} {g_txt}" else: w_txt = g_txt w_fmt = f"{w_txt:<5}" # --- ICONE --- sky, sgx = get_icon_set(Pr_display, Sn, Code, IsDay, Cl, Vis, T, Rain, Gust, Cape, dominant_type) # Se c'è precipitazione nevosa, mostra ❄️ nella colonna Sk (invece di 🌨️) if is_snowing and Pr_display > 0: sky = "❄️" sky_fmt = f"{sky}{uv_suffix}" current_block_lines.append(f"{dt.strftime('%H'):<2} {t_s:>4} {Rh:>3} {p_s:>3} {w_fmt} {cl_str:>5} {sky_fmt:<2} {sgx:<2}") hours_from_start += 1 # Chiudi ultimo blocco (solo se ha contenuto oltre header e separator) if current_block_lines and len(current_block_lines) > 2: # Header + separator + almeno 1 riga dati day_label = f"{['Lun','Mar','Mer','Gio','Ven','Sab','Dom'][current_day.weekday()]} {current_day.day}" blocks.append(f"*{day_label}*\n```text\n" + "\n".join(current_block_lines) + "\n```") if not blocks: return f"❌ Nessun dato da mostrare nelle prossime 48 ore (da {current_hour.strftime('%H:%M')})." report = f"🌤️ *METEO REPORT*\n📍 {location_name}\n🧠 Fonte: {model_name}\n\n" + "\n\n".join(blocks) logger.info("generate_weather_report ok elapsed=%.2fs", time.time() - t_total) return report if __name__ == "__main__": args_parser = argparse.ArgumentParser() args_parser.add_argument("--query", help="Nome città") args_parser.add_argument("--home", action="store_true", help="Usa coordinate casa") args_parser.add_argument("--debug", action="store_true", help="Mostra dettaglio debug (nuvole, neve)") args_parser.add_argument("--chat_id", help="Chat ID Telegram per invio diretto (opzionale, può essere multiplo separato da virgola)") args_parser.add_argument("--timezone", help="Timezone IANA (es: Europe/Rome, America/New_York)") args = args_parser.parse_args() # Determina chat_ids se specificato chat_ids = None if args.chat_id: chat_ids = [cid.strip() for cid in args.chat_id.split(",") if cid.strip()] # Genera report report = None if args.home: report = generate_weather_report(HOME_LAT, HOME_LON, HOME_NAME, args.debug, "SM") elif args.query: coords = get_coordinates(args.query) if coords: lat, lon, name, cc, geo_tz = coords tz = args.timezone or geo_tz report = generate_weather_report(lat, lon, name, args.debug, cc, timezone=tz) else: error_msg = f"❌ Città '{args.query}' non trovata." if chat_ids: telegram_send_markdown(error_msg, chat_ids) else: print(error_msg) sys.exit(1) else: usage_msg = "Uso: meteo.py --query 'Nome Città' oppure --home [--debug] [--chat_id ID]" if chat_ids: telegram_send_markdown(usage_msg, chat_ids) else: print(usage_msg) sys.exit(1) # Invia o stampa if chat_ids: telegram_send_markdown(report, chat_ids) else: print(report)