#!/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 (Wide View ±12km)" TZ = "Europe/Berlin" TZINFO = ZoneInfo(TZ) # Offset ~12-15km per i 5 punti OFFSET_LAT = 0.12 OFFSET_LON = 0.16 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) -> datetime.datetime: try: dt = date_parser.isoparse(t) if dt.tzinfo is None: return dt.replace(tzinfo=TZINFO) return dt.astimezone(TZINFO) except Exception as e: logger.error(f"Time parse error: {e}") return now_local() 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})" return res["latitude"], res["longitude"], name, cc 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 AROME Seamless (ha snowfall) - Per altre località: usa best match di Open-Meteo (senza specificare models) """ if is_home: # Per Casa, usa AROME Seamless (ha snowfall e dati dettagliati) return "meteofrance_seamless", "AROME HD" 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 AROME Seamless. 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) # Generiamo 5 punti: Centro, N, S, E, W lats = [lat, lat + OFFSET_LAT, lat - OFFSET_LAT, lat, lat] lons = [lon, lon, lon, lon + OFFSET_LON, lon - OFFSET_LON] lat_str = ",".join(map(str, lats)) lon_str = ",".join(map(str, lons)) params = { "latitude": lat_str, "longitude": lon_str, "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,snowfall,weathercode,is_day,cape,visibility,uv_index" } # Aggiungi models solo se specificato (per Casa usa AROME, 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 points=5 elapsed=%.2fs", model or "best_match", time.time() - t0) # Open-Meteo per multiple locations (lat/lon separati da virgola) restituisce # direttamente un dict con "hourly", "daily", etc. che contiene liste di valori # per ogni location. Per semplicità, restituiamo il dict così com'è # e lo gestiamo nel codice chiamante 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) # Usa timezone personalizzata se fornita, altrimenti default tz_to_use = timezone if timezone else TZ 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_to_use, retry_after_60s=False) # Se fallisce e siamo a Casa con AROME, prova retry dopo 10 secondi if not data_list and is_home and model_id == "meteofrance_seamless": logger.warning(f"Primo tentativo AROME fallito: {error_details}. Retry dopo 10 secondi...") data_list, error_details = get_forecast(lat, lon, model_id, is_home=is_home, timezone=tz_to_use, 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"AROME fallito anche dopo retry: {error_details}. Fallback a best match...") data_list, error_details = get_forecast(lat, lon, None, is_home=False, timezone=tz_to_use, 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] # Punto centrale (Casa) per dati specifici data_center = data_list[0] hourly_c = data_center.get("hourly", {}) times = hourly_c.get("time", []) if not times: return "❌ Dati orari mancanti." L = len(times) # --- DATI LOCALI (CASA) --- 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_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 AROME non fornisce visibilità (tutti None), recuperala da best match if is_home and model_id == "meteofrance_seamless": 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) # --- DATI GLOBALI (MEDIA 5 PUNTI) --- acc_cl_tot = [0.0] * L points_cl_tot = [ [] for _ in range(L) ] for d in data_list: h = d.get("hourly", {}) for i in range(L): cc = get_val(safe_get_list(h, "cloud_cover", L)[i]) cl = get_val(safe_get_list(h, "cloud_cover_low", L)[i]) cm = get_val(safe_get_list(h, "cloud_cover_mid", L)[i]) ch = get_val(safe_get_list(h, "cloud_cover_high", L)[i]) # Calcolo robusto del totale per singolo punto real_point_total = max(cc, cl, cm, ch) acc_cl_tot[i] += real_point_total points_cl_tot[i].append(real_point_total) num_points = len(data_list) avg_cl_tot = [x / num_points for x in acc_cl_tot] # --- DEBUG MODE --- if debug_mode: output = f"🔍 **DEBUG METEO (v10.5)**\n" now_h = now_local().replace(minute=0, second=0, microsecond=0) idx = 0 for i, t_str in enumerate(times): if parse_time(t_str) >= 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]).strftime('%H:%M')}\n" output += f"📍 **LOCALE (Casa)**: L:{int(loc_L)}% | H:{int(loc_H)}%\n" output += f"🌍 **MEDIA GLOBALE**: {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 --- # Usa timezone personalizzata se fornita tz_to_use_info = ZoneInfo(tz_to_use) if tz_to_use else TZINFO 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) if dt.tzinfo is None: dt = dt.replace(tzinfo=tz_to_use_info) else: dt = dt.astimezone(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) # Determina se è neve is_snowing = Sn > 0 or (Code in [71, 73, 75, 77, 85, 86]) # Formattazione MM p_suffix = "" if Code in [96, 99]: p_suffix = "G" elif Code in [66, 67]: p_suffix = "Z" elif is_snowing and Pr >= 0.2: p_suffix = "N" p_s = "--" if Pr < 0.2 else f"{int(round(Pr))}{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, 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 >= 0.2: 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 i valori dei 5 punti") 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 = coords report = generate_weather_report(lat, lon, name, args.debug, cc) 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)