diff --git a/services/telegram-bot/bot.py b/services/telegram-bot/bot.py index d12f600..8faf6d6 100644 --- a/services/telegram-bot/bot.py +++ b/services/telegram-bot/bot.py @@ -1,22 +1,53 @@ import logging import subprocess import os +import datetime +import requests from functools import wraps +from zoneinfo import ZoneInfo +from dateutil import parser +from typing import Dict, List, Optional, Tuple + from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update from telegram.ext import ( Application, CommandHandler, CallbackQueryHandler, ContextTypes, + JobQueue ) +# ============================================================================= +# LOOGLE BOT V7.0 (ULTIMATE) +# - Dashboard Sistema (SSH/Ping) +# - Meteo Arome ASCII (On-Demand + Schedulato) +# - Multi-User Security +# ============================================================================= + # --- CONFIGURAZIONE --- BOT_TOKEN = os.environ.get('BOT_TOKEN') -OWNER_ID = int(os.environ.get('ALLOWED_USER_ID')) + +# Gestione Multi-Utente +allowed_users_raw = os.environ.get('ALLOWED_USER_ID', '') +ALLOWED_IDS = [int(x.strip()) for x in allowed_users_raw.split(',') if x.strip().isdigit()] + +# Configurazione Sistema SSH_USER = "daniely" NAS_USER = "daniely" MASTER_IP = "192.168.128.80" +# Configurazione Meteo +HOME_LAT = 43.9356 +HOME_LON = 12.4296 +HOME_NAME = "🏠 Casa (Strada Cà Toro)" +TZ = "Europe/Rome" +TZINFO = ZoneInfo(TZ) + +OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast" +GEOCODING_URL = "https://geocoding-api.open-meteo.com/v1/search" +MODEL = "meteofrance_arome_france_hd" +HTTP_HEADERS = {"User-Agent": "loogle-bot-v7"} + # --- LISTE DISPOSITIVI --- CORE_DEVICES = [ {"name": "🍓 Pi-1 (Master)", "ip": MASTER_IP, "type": "pi", "user": SSH_USER}, @@ -40,7 +71,9 @@ INFRA_DEVICES = [ logging.basicConfig(format="%(asctime)s - %(levelname)s - %(message)s", level=logging.INFO) logger = logging.getLogger(__name__) -# --- FUNZIONI --- +# ============================================================================= +# SEZIONE 1: FUNZIONI SISTEMA (SSH, PING, UTILS) +# ============================================================================= def run_cmd(command, ip=None, user=None): try: @@ -53,24 +86,10 @@ def run_cmd(command, ip=None, user=None): except Exception: return "Err" def get_ping_icon(ip): - print(f"DEBUG: Pinging {ip}...") # LOG PER CAPIRE DOVE SI BLOCCA try: - # Timeout aggressivo: 0.5 secondi (-W 1 è il minimo di ping standard, ma Python taglia a 0.8) - subprocess.run( - ["ping", "-c", "1", "-W", "1", ip], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - timeout=0.8, # Timeout Python brutale - check=True - ) + subprocess.run(["ping", "-c", "1", "-W", "1", ip], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=0.8, check=True) return "✅" - except subprocess.TimeoutExpired: - return "🔴" # Timeout Python - except subprocess.CalledProcessError: - return "🔴" # Risposta "Host Unreachable" - except Exception as e: - print(f"Errore Ping {ip}: {e}") - return "❓" + except Exception: return "🔴" def get_device_stats(device): ip, user, dtype = device['ip'], device['user'], device['type'] @@ -90,10 +109,8 @@ def get_device_stats(device): if dtype == "nas": ram_cmd = "free | grep Mem | awk '{printf \"%.0f%%\", $3*100/$2}'" else: ram_cmd = "free -m | awk 'NR==2{if ($2>0) printf \"%.0f%%\", $3*100/$2; else print \"0%\"}'" - disk_path = "/" if dtype != "nas" else "/volume1" disk_cmd = f"df -h {disk_path} | awk 'NR==2{{print $5}}'" - return f"✅ **ONLINE**\n⏱️ Up: {uptime}\n🌡️ Temp: {temp} | 🧠 RAM: {run_cmd(ram_cmd, ip, user)} | 💾 Disk: {run_cmd(disk_cmd, ip, user)}" def read_log_file(filepath, lines=15): @@ -105,11 +122,145 @@ def run_speedtest(): try: return subprocess.check_output("speedtest-cli --simple", shell=True, timeout=50).decode('utf-8') except: return "Errore Speedtest" -# --- BOT HANDLERS --- +# ============================================================================= +# SEZIONE 2: FUNZIONI METEO (AROME ASCII) +# ============================================================================= + +def now_local() -> datetime.datetime: + return datetime.datetime.now(TZINFO) + +def parse_time(t: str) -> datetime.datetime: + dt = parser.isoparse(t) + if dt.tzinfo is None: return dt.replace(tzinfo=TZINFO) + return dt.astimezone(TZINFO) + +def degrees_to_cardinal(d: int) -> str: + dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"] + return dirs[round(d / 45) % 8] + +def get_icon_set(prec, snow, code, is_day, cloud, vis, temp, rain, gust, cape): + sky = "☁️" + if code in (95, 96, 99): sky = "⛈️" if prec > 0 else "🌩️" + elif (code in (45, 48) or vis < 1000) and prec < 1: sky = "🌫️" + elif prec >= 0.1: sky = "🌨️" if snow > 0 else "🌧️" + elif cloud <= 20: sky = "☀️" if is_day else "🌙" + elif cloud <= 40: sky = "🌤️" if is_day else "🌙" + elif cloud <= 60: sky = "⛅️" + elif cloud <= 80: sky = "🌥️" + + sgx = "-" + if snow > 0 or (code in (71,73,75,77,85,86) if code else False): sgx = "☃️" + elif temp < 0 or (code in (66,67) if code else False): sgx = "🧊" + elif cape > 2000: sgx = "🌪️" + elif cape > 1000: sgx = "⚡" + elif temp > 35: sgx = "🥵" + elif rain > 4: sgx = "☔️" + elif gust > 50: sgx = "💨" + return sky, sgx + +def get_coordinates(city_name: str) -> Optional[Tuple[float, float, str]]: + params = {"name": city_name, "count": 1, "language": "it", "format": "json"} + try: + r = requests.get(GEOCODING_URL, params=params, headers=HTTP_HEADERS, timeout=10) + data = r.json() + if "results" in data and len(data["results"]) > 0: + res = data["results"][0] + name = f"{res.get('name')} ({res.get('country_code','')})" + return res["latitude"], res["longitude"], name + except Exception as e: logger.error(f"Geocoding error: {e}") + return None + +def get_forecast(lat, lon) -> Optional[Dict]: + params = { + "latitude": lat, "longitude": lon, "timezone": TZ, + "forecast_days": 3, "models": MODEL, + "wind_speed_unit": "kmh", "precipitation_unit": "mm", + "hourly": "temperature_2m,relative_humidity_2m,cloudcover,windspeed_10m,winddirection_10m,windgusts_10m,precipitation,rain,snowfall,weathercode,is_day,cape,visibility", + } + try: + r = requests.get(OPEN_METEO_URL, params=params, headers=HTTP_HEADERS, timeout=25) + r.raise_for_status() + return r.json() + except Exception as e: logger.error(f"Meteo API error: {e}"); return None + +def generate_weather_report(lat, lon, location_name) -> str: + data = get_forecast(lat, lon) + if not data: return "❌ Errore API Meteo." + + hourly = data.get("hourly", {}) + times = hourly.get("time", []) + if not times: return "❌ Dati orari mancanti." + + now = now_local().replace(minute=0, second=0, microsecond=0) + blocks = [] + + for (label, hours_duration, step) in [("Prime 24h", 24, 1), ("Successive 24h", 24, 2)]: + end_time = now + datetime.timedelta(hours=hours_duration) + lines = [f"{'LT':<2} {'T°':>3} {'h%':>3} {'mm':>2} {'Vento':<5} {'Nv%':>3} {'Sky':<2} {'Sgx':<3}", "-" * 30] + count = 0 + + for i, t_str in enumerate(times): + try: dt = parse_time(t_str) + except: continue + if dt < now or dt >= end_time: continue + if dt.hour % step != 0: continue + + try: + T = float(hourly["temperature_2m"][i]) + Rh = int(hourly["relative_humidity_2m"][i] or 0) + Cl = int(hourly["cloudcover"][i] or 0) + Pr = float(hourly["precipitation"][i] or 0) + Rn = float(hourly["rain"][i] or 0) + Sn = float(hourly["snowfall"][i] or 0) + Wspd = float(hourly["windspeed_10m"][i] or 0) + Gust = float(hourly["windgusts_10m"][i] or 0) + Wdir = int(hourly["winddirection_10m"][i] or 0) + Cape = float(hourly["cape"][i] or 0) + Vis = float(hourly["visibility"][i] or 10000) + Code = int(hourly["weathercode"][i]) if hourly["weathercode"][i] is not None else None + IsDay = int(hourly["is_day"][i] if hourly["is_day"][i] is not None else 1) + + t_s = f"{int(round(T))}" + p_s = "--" if Pr < 0.2 else f"{int(round(Pr))}" + + card = degrees_to_cardinal(Wdir) + w_val = Wspd + is_g = (Gust - Wspd) > 15 + if is_g: w_val = Gust + + w_txt = f"{card} {int(round(w_val))}" + if is_g: + 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}" + sky, sgx = get_icon_set(Pr, Sn, Code, IsDay, Cl, Vis, T, Rn, Gust, Cape) + + lines.append(f"{dt.strftime('%H'):<2} {t_s:>3} {Rh:>3} {p_s:>2} {w_fmt} {Cl:>3} {sky:<2} {sgx:<3}") + count += 1 + except: continue + + if count > 0: + day_label = f"{['Lun','Mar','Mer','Gio','Ven','Sab','Dom'][now.weekday()]} {now.day}" + blocks.append(f"*{day_label} ({label})*\n```text\n" + "\n".join(lines) + "\n```") + now = end_time + + return f"🌤️ *METEO REPORT*\n📍 {location_name}\n\n" + "\n\n".join(blocks) + +# ============================================================================= +# SEZIONE 3: BOT HANDLERS & SCHEDULER +# ============================================================================= + +# Decoratore Sicurezza Multi-Utente def restricted(func): @wraps(func) async def wrapped(update: Update, context: ContextTypes.DEFAULT_TYPE, *args, **kwargs): - if update.effective_user.id != OWNER_ID: return + user_id = update.effective_user.id + if user_id not in ALLOWED_IDS: + logger.warning(f"⚠️ ACCESSO NEGATO: User {user_id}") + return return await func(update, context, *args, **kwargs) return wrapped @@ -118,14 +269,40 @@ async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: keyboard = [ [InlineKeyboardButton("🖥️ Core Server", callback_data="menu_core"), InlineKeyboardButton("🔍 Scan LAN", callback_data="menu_lan")], [InlineKeyboardButton("🛡️ Pi-hole", callback_data="menu_pihole"), InlineKeyboardButton("🌐 Rete", callback_data="menu_net")], - [InlineKeyboardButton("📜 Logs", callback_data="menu_logs")] + [InlineKeyboardButton("🌤️ Meteo Casa", callback_data="req_meteo_home"), InlineKeyboardButton("📜 Logs", callback_data="menu_logs")] ] - text = "🎛 **Loogle Control Center v6.2**\nSeleziona un pannello:" + text = "🎛 **Loogle Control Center v7.0**\nComandi disponibili:\n🔹 `/meteo `\n🔹 Pulsanti sotto" - if update.message: - await update.message.reply_text(text, reply_markup=InlineKeyboardMarkup(keyboard), parse_mode="Markdown") - else: - await update.callback_query.edit_message_text(text, reply_markup=InlineKeyboardMarkup(keyboard), parse_mode="Markdown") + if update.message: await update.message.reply_text(text, reply_markup=InlineKeyboardMarkup(keyboard), parse_mode="Markdown") + else: await update.callback_query.edit_message_text(text, reply_markup=InlineKeyboardMarkup(keyboard), parse_mode="Markdown") + +@restricted +async def meteo_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if not context.args: + await update.message.reply_text("⚠️ Usa: `/meteo ` (es. `/meteo Rimini`)", parse_mode="Markdown") + return + + city = " ".join(context.args) + await update.message.reply_text(f"🔄 Cerco '{city}'...", parse_mode="Markdown") + + coords = get_coordinates(city) + if coords: + lat, lon, name = coords + report = generate_weather_report(lat, lon, name) + await update.message.reply_text(report, parse_mode="Markdown") + else: + await update.message.reply_text(f"❌ Città '{city}' non trovata.", parse_mode="Markdown") + +async def scheduled_morning_report(context: ContextTypes.DEFAULT_TYPE) -> None: + """Funzione lanciata dallo scheduler alle 08:00""" + logger.info("⏰ Invio report automatico meteo...") + report = generate_weather_report(HOME_LAT, HOME_LON, HOME_NAME) + + for uid in ALLOWED_IDS: + try: + await context.bot.send_message(chat_id=uid, text=report, parse_mode="Markdown") + except Exception as e: + logger.error(f"Errore invio report a {uid}: {e}") @restricted async def button_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -134,16 +311,22 @@ async def button_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> data = query.data if data == "main_menu": await start(update, context) + + elif data == "req_meteo_home": + await query.edit_message_text("⏳ **Scaricamento Meteo Casa...**", parse_mode="Markdown") + report = generate_weather_report(HOME_LAT, HOME_LON, HOME_NAME) + keyboard = [[InlineKeyboardButton("⬅️ Indietro", callback_data="main_menu")]] + await query.edit_message_text(report, reply_markup=InlineKeyboardMarkup(keyboard), parse_mode="Markdown") elif data == "menu_core": keyboard = [] for i, dev in enumerate(CORE_DEVICES): keyboard.append([InlineKeyboardButton(dev['name'], callback_data=f"stat_{i}")]) keyboard.append([InlineKeyboardButton("📊 Report Completo", callback_data="stat_all")]) keyboard.append([InlineKeyboardButton("⬅️ Indietro", callback_data="main_menu")]) - await query.edit_message_text("🖥️ **Core Servers**\nDettagli CPU/RAM/Temp:", reply_markup=InlineKeyboardMarkup(keyboard), parse_mode="Markdown") + await query.edit_message_text("🖥️ **Core Servers**", reply_markup=InlineKeyboardMarkup(keyboard), parse_mode="Markdown") elif data == "stat_all": - await query.edit_message_text("⏳ **Analisi Core Servers...**", parse_mode="Markdown") + await query.edit_message_text("⏳ **Analisi...**", parse_mode="Markdown") report = "📊 **REPORT CORE**\n" for dev in CORE_DEVICES: report += f"\n🔹 **{dev['name']}**\n{get_device_stats(dev)}\n" await query.edit_message_text(report, reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ Indietro", callback_data="menu_core")]]), parse_mode="Markdown") @@ -154,27 +337,14 @@ async def button_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> await query.edit_message_text(f"🔹 **{dev['name']}**\n\n{get_device_stats(dev)}", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ Indietro", callback_data="menu_core")]]), parse_mode="Markdown") elif data == "menu_lan": - await query.edit_message_text("⏳ **Scansione LAN rapida...**", parse_mode="Markdown") + await query.edit_message_text("⏳ **Scansione LAN...**", parse_mode="Markdown") report = "🔍 **DIAGNOSTICA LAN**\n\n" - try: - # Core Devices - for dev in CORE_DEVICES: - report += f"{get_ping_icon(dev['ip'])} `{dev['ip']}` - {dev['name']}\n" - + for dev in CORE_DEVICES: report += f"{get_ping_icon(dev['ip'])} `{dev['ip']}` - {dev['name']}\n" report += "\n" - - # Infra Devices - for dev in INFRA_DEVICES: - report += f"{get_ping_icon(dev['ip'])} `{dev['ip']}` - {dev['name']}\n" - - except Exception as e: - report += f"\n⚠️ Errore imprevisto durante scansione: {e}" - - keyboard = [ - [InlineKeyboardButton("⚡ Menu Riavvio", callback_data="menu_reboot")], - [InlineKeyboardButton("🔄 Aggiorna", callback_data="menu_lan"), InlineKeyboardButton("⬅️ Indietro", callback_data="main_menu")] - ] + for dev in INFRA_DEVICES: report += f"{get_ping_icon(dev['ip'])} `{dev['ip']}` - {dev['name']}\n" + except Exception as e: report += f"\n⚠️ Errore: {e}" + keyboard = [[InlineKeyboardButton("⚡ Menu Riavvio", callback_data="menu_reboot")], [InlineKeyboardButton("🔄 Aggiorna", callback_data="menu_lan"), InlineKeyboardButton("⬅️ Indietro", callback_data="main_menu")]] await query.edit_message_text(report, reply_markup=InlineKeyboardMarkup(keyboard), parse_mode="Markdown") elif data == "menu_reboot": @@ -182,14 +352,13 @@ async def button_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> for i, dev in enumerate(INFRA_DEVICES): if "Router" not in dev['name']: keyboard.append([InlineKeyboardButton(f"⚡ {dev['name']}", callback_data=f"reboot_{i}")]) keyboard.append([InlineKeyboardButton("⬅️ Indietro", callback_data="menu_lan")]) - await query.edit_message_text("⚠️ **RIAVVIO REMOTO**\nFunziona solo se il dispositivo supporta SSH e hai le chiavi.", reply_markup=InlineKeyboardMarkup(keyboard), parse_mode="Markdown") + await query.edit_message_text("⚠️ **RIAVVIO REMOTO**", reply_markup=InlineKeyboardMarkup(keyboard), parse_mode="Markdown") elif data.startswith("reboot_"): dev = INFRA_DEVICES[int(data.split("_")[1])] res = run_cmd("reboot", dev['ip'], "admin") - await query.edit_message_text(f"⚡ Comando inviato a {dev['name']}...\n\nRisposta:\n`{res}`", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ Indietro", callback_data="menu_reboot")]]), parse_mode="Markdown") + await query.edit_message_text(f"⚡ Inviato a {dev['name']}...\n\n`{res}`", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ Indietro", callback_data="menu_reboot")]]), parse_mode="Markdown") - # --- ALTRI MENU STANDARD --- elif data == "menu_pihole": status_raw = run_cmd("sudo pihole status", MASTER_IP, SSH_USER) icon = "✅" if "Enabled" in status_raw or "enabled" in status_raw else "🔴" @@ -209,7 +378,7 @@ async def button_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> await query.edit_message_text(f"🌐 **Rete**\n🌍 IP: `{ip}`", reply_markup=InlineKeyboardMarkup(keyboard), parse_mode="Markdown") elif data == "net_speedtest": - await query.edit_message_text("🚀 **Speedtest in corso...**", parse_mode="Markdown") + await query.edit_message_text("🚀 **Speedtest...**", parse_mode="Markdown") res = run_speedtest() await query.edit_message_text(f"🚀 **Risultato:**\n\n`{res}`", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ Indietro", callback_data="menu_net")]]), parse_mode="Markdown") @@ -223,9 +392,19 @@ async def button_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> await query.edit_message_text(f"📜 **Log:**\n\n`{log_c}`", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ Indietro", callback_data="menu_logs")]]), parse_mode="Markdown") def main(): + logger.info("Avvio Loogle Bot v7.0 (Ultimate)...") application = Application.builder().token(BOT_TOKEN).build() + + # Handlers application.add_handler(CommandHandler("start", start)) + application.add_handler(CommandHandler("meteo", meteo_command)) application.add_handler(CallbackQueryHandler(button_handler)) + + # SCHEDULER (Sostituisce CRON) + # Esegue il meteo tutti i giorni alle 08:00 Europe/Rome + job_queue = application.job_queue + job_queue.run_daily(scheduled_morning_report, time=datetime.time(hour=8, minute=0, tzinfo=TZINFO), days=(0, 1, 2, 3, 4, 5, 6)) + application.run_polling() if __name__ == "__main__": diff --git a/services/telegram-bot/daily_flight_report.py b/services/telegram-bot/daily_flight_report.py new file mode 100644 index 0000000..f994b35 --- /dev/null +++ b/services/telegram-bot/daily_flight_report.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import os +import datetime +import requests +import logging +from zoneinfo import ZoneInfo +from dateutil import parser + +# --- CONFIGURAZIONE --- + +# 1. Chiave API Voli (Tua) +RAPIDAPI_KEY = "841975fb1fmshd139bc1a12cd454p100114jsn8acf1ccede63" + +# 2. Chat ID (Esclusivo per te) +CHAT_IDS = ["64463169"] + +# 3. Lettura Token da file /etc +TOKEN_FILE = "/etc/telegram_dpc_bot_token" + +def get_bot_token(): + try: + if os.path.exists(TOKEN_FILE): + with open(TOKEN_FILE, "r", encoding="utf-8") as f: + return f.read().strip() + except Exception as e: + print(f"Errore lettura file token {TOKEN_FILE}: {e}") + return None + +BOT_TOKEN = get_bot_token() +TZ = ZoneInfo("Europe/Rome") + +# --- FUNZIONI --- + +def fetch_segment(iata, start_ts, end_ts): + """Esegue la chiamata API per un segmento orario specifico""" + url = f"https://aerodatabox.p.rapidapi.com/flights/airports/iata/{iata}/{start_ts}/{end_ts}" + + querystring = { + "withLeg": "true", + "direction": "Both", + "withCancelled": "false", + "withCodeshared": "false", + "withCargo": "false", + "withPrivate": "false" + } + + headers = { + "X-RapidAPI-Key": RAPIDAPI_KEY, + "X-RapidAPI-Host": "aerodatabox.p.rapidapi.com" + } + + try: + response = requests.get(url, headers=headers, params=querystring, timeout=15) + + if response.status_code == 204: return [] # Nessun volo in questa fascia + + if response.status_code != 200: + print(f"DEBUG {iata} Error {response.status_code}: {response.text}") # Debug approfondito + return None # Segnala errore + + return response.json() + except Exception as e: + print(f"Exception fetching {iata}: {e}") + return None + +def get_flights(iata, name): + """Scarica arrivi e partenze dividendo la giornata in 2 blocchi da 12h""" + now = datetime.datetime.now(TZ) + date_str = now.strftime("%Y-%m-%d") + + # AeroDataBox limita le richieste a finestre di 12 ore. + # Dividiamo in Mattina (00:00-11:59) e Pomeriggio (12:00-23:59) + segments = [ + (f"{date_str}T00:00", f"{date_str}T11:59"), + (f"{date_str}T12:00", f"{date_str}T23:59") + ] + + raw_arrivals = [] + raw_departures = [] + + error_occurred = False + + for start_t, end_t in segments: + data = fetch_segment(iata, start_t, end_t) + if data is None: + error_occurred = True + continue + + if isinstance(data, dict): + raw_arrivals.extend(data.get("arrivals", [])) + raw_departures.extend(data.get("departures", [])) + + if error_occurred and not raw_arrivals and not raw_departures: + return f"\n⚠️ *{name} ({iata})* - Errore API (Vedi log)" + + flight_list = [] + + # Processa Arrivi (Deduplica basata su orario+numero per sicurezza) + seen = set() + for f in raw_arrivals: + try: + time_local = f["movement"]["scheduledTimeLocal"] + dt = parser.isoparse(time_local) + flight_no = f.get("number", "") + + uid = f"{dt}_{flight_no}_ARR" + if uid in seen: continue + seen.add(uid) + + airline = f.get("airline", {}).get("name", "Unknown") + origin = f.get("movement", {}).get("airport", {}).get("name", "Unknown") + + flight_list.append({ + "time": dt, + "text": f"🛬 `{dt.strftime('%H:%M')}` da {origin}\n └ *{airline}* ({flight_no})" + }) + except: continue + + # Processa Partenze + for f in raw_departures: + try: + time_local = f["movement"]["scheduledTimeLocal"] + dt = parser.isoparse(time_local) + flight_no = f.get("number", "") + + uid = f"{dt}_{flight_no}_DEP" + if uid in seen: continue + seen.add(uid) + + airline = f.get("airline", {}).get("name", "Unknown") + dest = f.get("movement", {}).get("airport", {}).get("name", "Unknown") + + flight_list.append({ + "time": dt, + "text": f"🛫 `{dt.strftime('%H:%M')}` per {dest}\n └ *{airline}* ({flight_no})" + }) + except: continue + + if not flight_list: + return f"\n✈️ *{name} ({iata})*\n_Nessun volo programmato oggi._" + + # Ordina per orario + flight_list.sort(key=lambda x: x["time"]) + + msg = f"\n✈️ *{name} ({iata})*" + for item in flight_list: + msg += f"\n{item['text']}" + + return msg + +def send_telegram(text): + if not BOT_TOKEN: + print(f"❌ ERRORE CRITICO: Token non trovato in {TOKEN_FILE}") + return + + url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage" + + for chat_id in CHAT_IDS: + payload = { + "chat_id": chat_id, + "text": text, + "parse_mode": "Markdown", + "disable_web_page_preview": True + } + try: + requests.post(url, json=payload, timeout=10) + print(f"Messaggio inviato a {chat_id}") + except Exception as e: + print(f"Errore invio a {chat_id}: {e}") + +# --- MAIN --- + +def main(): + print("--- Elaborazione Voli (Split 12h) ---") + + today = datetime.datetime.now(TZ).strftime("%d/%m/%Y") + report = f"📆 *PROGRAMMA VOLI {today}*\n" + + report += get_flights("RMI", "Rimini") + report += "\n" + report += get_flights("FRL", "Forlì") + + print(report) + send_telegram(report) + +if __name__ == "__main__": + main() diff --git a/services/telegram-bot/morning_weather_report.py b/services/telegram-bot/morning_weather_report.py index 518eaa6..6110170 100644 --- a/services/telegram-bot/morning_weather_report.py +++ b/services/telegram-bot/morning_weather_report.py @@ -2,392 +2,373 @@ # -*- coding: utf-8 -*- import datetime -import html import json import logging import os import time +import signal +import sys from logging.handlers import RotatingFileHandler from typing import Dict, List, Optional, Tuple from zoneinfo import ZoneInfo - import requests from dateutil import parser # ============================================================================= -# morning_weather_report.py +# morning_weather_report.py (v3.3 - Full Bot Mode) # -# Report meteo giornaliero (08:00) per Casa (San Marino): -# - prossime 24h e 48h: Tmin/Tmax, nuvolosità, vento/raffiche, precipitazioni, fenomeni -# -# Telegram: -# - nessun token in chiaro (env oppure ~/.telegram_dpc_bot_token oppure /etc/telegram_dpc_bot_token) -# - invia SOLO il report (niente notifiche errori) -# -# Log: -# - ./morning_weather_report.log -# - DEBUG=1 -> anche su stdout +# - Gira 24/7 (Daemon). +# - ORE 08:00: Invia report automatico Casa. +# - COMANDI: Supporta "/meteo " in chat. +# - BOTTONI: Supporta interazione click. +# - AUTO-KILL: Gestione processo unico. # ============================================================================= DEBUG = os.environ.get("DEBUG", "0").strip() == "1" -# --- POSIZIONE CASA (Strada Cà Toro, 12 - San Marino) --- -LAT = 43.9356 -LON = 12.4296 -LOCATION_NAME = "🏠 Casa (Strada Cà Toro)" +# --- POSIZIONE DEFAULT (CASA) --- +HOME_LAT = 43.9356 +HOME_LON = 12.4296 +HOME_NAME = "🏠 Casa (Strada Cà Toro)" -# --- TIMEZONE --- +# --- CONFIG --- TZ = "Europe/Rome" TZINFO = ZoneInfo(TZ) - -# --- TELEGRAM (multi-chat) --- +ADMIN_CHAT_ID = "64463169" 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" +PID_FILE = "/tmp/morning_weather_bot.pid" -# --- OPEN-METEO --- OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast" +GEOCODING_URL = "https://geocoding-api.open-meteo.com/v1/search" MODEL = "meteofrance_arome_france_hd" -HTTP_HEADERS = {"User-Agent": "rpi-morning-weather-report/1.0"} +HTTP_HEADERS = {"User-Agent": "rpi-morning-weather-report/3.3"} -# --- REPORT WINDOW --- -HOURS_1 = 24 -HOURS_2 = 48 # includiamo fino a 48h, poi dividiamo 0–24 e 24–48 - -# --- LOG FILE --- +# --- LOGGING --- BASE_DIR = os.path.dirname(os.path.abspath(__file__)) LOG_FILE = os.path.join(BASE_DIR, "morning_weather_report.log") - def setup_logger() -> logging.Logger: logger = logging.getLogger("morning_weather_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() +# --- GESTIONE PROCESSO UNICO --- +def kill_old_instance(): + if os.path.exists(PID_FILE): + try: + with open(PID_FILE, "r") as f: + old_pid = int(f.read().strip()) + if old_pid != os.getpid(): + try: + os.kill(old_pid, signal.SIGTERM) + LOGGER.warning("💀 Uccisa vecchia istanza PID %s", old_pid) + time.sleep(1) + except ProcessLookupError: pass + except Exception as e: LOGGER.error("Errore kill PID: %s", e) + except Exception: pass + + with open(PID_FILE, "w") as f: + f.write(str(os.getpid())) + +# --- UTILS --- + +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_file(TOKEN_FILE_HOME) + if tok: return tok + tok = _read_file(TOKEN_FILE_ETC) + return tok.strip() if tok else "" + +def _read_file(path: str) -> str: + try: + with open(path, "r", encoding="utf-8") as f: return f.read().strip() + except: return "" + +def get_token() -> str: + t = load_bot_token() + if not t: raise ValueError("Token Telegram mancante") + return t def now_local() -> datetime.datetime: return datetime.datetime.now(TZINFO) - -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: - LOGGER.debug("Permission denied reading %s", path) - return "" - except Exception as e: - LOGGER.exception("Error reading %s: %s", path, e) - 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 parse_time_to_local(t: str) -> datetime.datetime: +def parse_time(t: str) -> datetime.datetime: dt = parser.isoparse(t) - if dt.tzinfo is None: - return dt.replace(tzinfo=TZINFO) + if dt.tzinfo is None: return dt.replace(tzinfo=TZINFO) return dt.astimezone(TZINFO) +# --- TELEGRAM API WRAPPER --- -def telegram_send_markdown(message: str) -> bool: - """ - Invia il report. In caso di errori, SOLO log. - """ - token = load_bot_token() - if not token: - LOGGER.error("Telegram token missing: report NOT sent.") - return False - - url = f"https://api.telegram.org/bot{token}/sendMessage" - payload_base = { - "text": message, +def tg_send_message(chat_id: str, text: str, reply_markup: dict = None) -> Optional[dict]: + url = f"https://api.telegram.org/bot{get_token()}/sendMessage" + payload = { + "chat_id": chat_id, + "text": text, "parse_mode": "Markdown", - "disable_web_page_preview": True, + "disable_web_page_preview": True } - - ok_any = False - with requests.Session() as s: - for chat_id in TELEGRAM_CHAT_IDS: - payload = dict(payload_base) - payload["chat_id"] = chat_id - try: - r = s.post(url, json=payload, timeout=20) - if r.status_code == 200: - ok_any = True - else: - LOGGER.error("Telegram HTTP %s chat_id=%s body=%s", r.status_code, chat_id, r.text[:500]) - time.sleep(0.25) - except Exception as e: - LOGGER.exception("Telegram send exception chat_id=%s err=%s", chat_id, e) - - return ok_any - - -# --- Weathercode categories (Open-Meteo / WMO-style codes) --- -def code_to_category(code: int) -> Optional[Tuple[str, str]]: - """ - Ritorna (emoji, label) per fenomeni "rilevanti" da elencare. - Non elenchiamo 'sereno/parzialmente nuvoloso' come fenomeno. - """ - if code is None: - return None - + if reply_markup: payload["reply_markup"] = reply_markup try: - c = int(code) - except Exception: + r = requests.post(url, json=payload, timeout=10) + return r.json() if r.status_code == 200 else None + except Exception as e: + LOGGER.error("Tg Send Error: %s", e) return None - # Nebbia - if c in (45, 48): - return ("🌫️", "nebbia") +def tg_get_updates(offset: int = 0) -> List[dict]: + url = f"https://api.telegram.org/bot{get_token()}/getUpdates" + payload = {"offset": offset, "timeout": 30} + try: + r = requests.post(url, json=payload, timeout=35) + if r.status_code == 200: + return r.json().get("result", []) + except Exception as e: + LOGGER.error("Tg Updates Error: %s", e) + time.sleep(2) + return [] - # Pioviggine - if 51 <= c <= 57: - return ("🌦️", "pioviggine") +def tg_answer_callback(callback_id: str, text: str = None): + url = f"https://api.telegram.org/bot{get_token()}/answerCallbackQuery" + payload = {"callback_query_id": callback_id} + if text: payload["text"] = text + try: requests.post(url, json=payload, timeout=5) + except: pass - # Pioggia / gelicidio - if 61 <= c <= 65: - return ("🌧️", "pioggia") - if c in (66, 67): - return ("🧊", "gelicidio") - - # Neve - if 71 <= c <= 77: - return ("❄️", "neve") - - # Rovesci - if 80 <= c <= 82: - return ("🌧️", "rovesci") - if c in (85, 86): - return ("❄️", "rovesci di neve") - - # Temporali / grandine - if c == 95: - return ("⛈️", "temporali") - if c in (96, 99): - return ("⛈️", "temporali con grandine") +# --- METEO & GEOCODING --- +def get_coordinates(city_name: str) -> Optional[Tuple[float, float, str]]: + params = {"name": city_name, "count": 1, "language": "it", "format": "json"} + try: + r = requests.get(GEOCODING_URL, params=params, headers=HTTP_HEADERS, timeout=10) + data = r.json() + if "results" in data and len(data["results"]) > 0: + res = data["results"][0] + name = f"{res.get('name')} ({res.get('country_code','')})" + return res["latitude"], res["longitude"], name + except Exception as e: + LOGGER.error("Geocoding error: %s", e) return None - -def get_forecast() -> Optional[Dict]: +def get_forecast(lat, lon) -> Optional[Dict]: params = { - "latitude": LAT, - "longitude": LON, - "timezone": TZ, - "forecast_days": 3, # copre bene 48h - "models": MODEL, - "wind_speed_unit": "kmh", - "precipitation_unit": "mm", - "hourly": ",".join([ - "temperature_2m", - "cloudcover", - "windspeed_10m", - "windgusts_10m", - "precipitation", - "weathercode", - ]), + "latitude": lat, "longitude": lon, "timezone": TZ, + "forecast_days": 3, "models": MODEL, + "wind_speed_unit": "kmh", "precipitation_unit": "mm", + "hourly": "temperature_2m,relative_humidity_2m,cloudcover,windspeed_10m,winddirection_10m,windgusts_10m,precipitation,rain,snowfall,weathercode,is_day,cape,visibility", } - try: r = requests.get(OPEN_METEO_URL, params=params, headers=HTTP_HEADERS, timeout=25) - if r.status_code == 400: - try: - j = r.json() - LOGGER.error("Open-Meteo 400: %s", j.get("reason", j)) - except Exception: - LOGGER.error("Open-Meteo 400: %s", r.text[:500]) - return None r.raise_for_status() return r.json() except Exception as e: - LOGGER.exception("Open-Meteo request error: %s", e) + LOGGER.error("Meteo API Error: %s", e) return None +# --- GENERAZIONE ASCII --- -def window_indices(times: List[str], start: datetime.datetime, end: datetime.datetime) -> List[int]: - idx = [] - for i, t in enumerate(times): - try: - dt = parse_time_to_local(t) - except Exception: - continue - if dt >= start and dt < end: - idx.append(i) - return idx +def degrees_to_cardinal(d: int) -> str: + dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"] + return dirs[round(d / 45) % 8] +def get_icon_set(prec, snow, code, is_day, cloud, vis, temp, rain, gust, cape): + sky = "☁️" + if code in (95, 96, 99): sky = "⛈️" if prec > 0 else "🌩️" + elif (code in (45, 48) or vis < 1000) and prec < 1: sky = "🌫️" + elif prec >= 0.1: sky = "🌨️" if snow > 0 else "🌧️" + elif cloud <= 20: sky = "☀️" if is_day else "🌙" + elif cloud <= 40: sky = "🌤️" if is_day else "🌙" + elif cloud <= 60: sky = "⛅️" + elif cloud <= 80: sky = "🌥️" + + sgx = "-" + if snow > 0 or (code in (71,73,75,77,85,86) if code else False): sgx = "☃️" + elif temp < 0 or (code in (66,67) if code else False): sgx = "🧊" + elif cape > 2000: sgx = "🌪️" + elif cape > 1000: sgx = "⚡" + elif temp > 35: sgx = "🥵" + elif rain > 4: sgx = "☔️" + elif gust > 50: sgx = "💨" + return sky, sgx -def summarize_window(hourly: Dict, idx: List[int]) -> Dict: - def safe_list(key: str) -> List[float]: - arr = hourly.get(key, []) or [] - out = [] - for i in idx: +def generate_report(lat, lon, location_name) -> str: + data = get_forecast(lat, lon) + if not data: return "❌ Errore scaricamento dati meteo." + + hourly = data.get("hourly", {}) + times = hourly.get("time", []) + if not times: return "❌ Dati orari mancanti." + + now = now_local().replace(minute=0, second=0, microsecond=0) + blocks = [] + + for (label, hours_duration, step) in [("Prime 24h", 24, 1), ("Successive 24h", 24, 2)]: + end_time = now + datetime.timedelta(hours=hours_duration) + lines = [f"{'LT':<2} {'T°':>3} {'h%':>3} {'mm':>2} {'Vento':<5} {'Nv%':>3} {'Sky':<2} {'Sgx':<3}", "-" * 30] + count = 0 + + for i, t_str in enumerate(times): + try: dt = parse_time(t_str) + except: continue + if dt < now or dt >= end_time: continue + if dt.hour % step != 0: continue + try: - v = arr[i] - out.append(float(v) if v is not None else 0.0) - except Exception: - out.append(0.0) - return out + T = float(hourly["temperature_2m"][i]) + Rh = int(hourly["relative_humidity_2m"][i] or 0) + Cl = int(hourly["cloudcover"][i] or 0) + Pr = float(hourly["precipitation"][i] or 0) + Rn = float(hourly["rain"][i] or 0) + Sn = float(hourly["snowfall"][i] or 0) + Wspd = float(hourly["windspeed_10m"][i] or 0) + Gust = float(hourly["windgusts_10m"][i] or 0) + Wdir = int(hourly["winddirection_10m"][i] or 0) + Cape = float(hourly["cape"][i] or 0) + Vis = float(hourly["visibility"][i] or 10000) + Code = int(hourly["weathercode"][i]) if hourly["weathercode"][i] is not None else None + IsDay = int(hourly["is_day"][i] if hourly["is_day"][i] is not None else 1) + + t_s = f"{int(round(T))}" + p_s = "--" if Pr < 0.2 else f"{int(round(Pr))}" + + card = degrees_to_cardinal(Wdir) + w_val = Wspd + is_g = (Gust - Wspd) > 15 + if is_g: w_val = Gust + + w_txt = f"{card} {int(round(w_val))}" + if is_g: + 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}" + sky, sgx = get_icon_set(Pr, Sn, Code, IsDay, Cl, Vis, T, Rn, Gust, Cape) + + lines.append(f"{dt.strftime('%H'):<2} {t_s:>3} {Rh:>3} {p_s:>2} {w_fmt} {Cl:>3} {sky:<2} {sgx:<3}") + count += 1 + except: continue + + if count > 0: + day_label = f"{['Lun','Mar','Mer','Gio','Ven','Sab','Dom'][now.weekday()]} {now.day}" + blocks.append(f"*{day_label} ({label})*\n```text\n" + "\n".join(lines) + "\n```") + now = end_time - temps = safe_list("temperature_2m") - clouds = safe_list("cloudcover") - wind = safe_list("windspeed_10m") - gust = safe_list("windgusts_10m") - prec = safe_list("precipitation") + return f"🌤️ *METEO REPORT*\n📍 {location_name}\n\n" + "\n\n".join(blocks) - # weathercode: manteniamo int - wcodes_raw = (hourly.get("weathercode", []) or []) - wcodes = [] - for i in idx: - try: - wcodes.append(int(wcodes_raw[i])) - except Exception: - wcodes.append(-1) +# --- MAIN LOOP --- - # Base stats - tmin = min(temps) if temps else None - tmax = max(temps) if temps else None - - cavg = (sum(clouds) / len(clouds)) if clouds else None - cmax = max(clouds) if clouds else None - - wmax = max(wind) if wind else None - gmax = max(gust) if gust else None - - psum = sum(prec) if prec else 0.0 - pmax = max(prec) if prec else 0.0 - - # Fenomeni (categorie uniche con prima occorrenza) - phenomena: List[str] = [] - seen = set() - for c in wcodes: - cat = code_to_category(c) - if not cat: - continue - em, label = cat - key = label - if key in seen: - continue - seen.add(key) - phenomena.append(f"{em} {label}") - - # Se nessuna categoria ma piove un po' (precip) e code non classificato - if not phenomena and psum > 0.0: - phenomena.append("🌧️ precipitazioni") - - return { - "tmin": tmin, "tmax": tmax, - "cavg": cavg, "cmax": cmax, - "wmax": wmax, "gmax": gmax, - "psum": psum, "pmax": pmax, - "phenomena": phenomena, +def main(): + kill_old_instance() + LOGGER.info("--- Avvio Bot v3.3 (Full Daemon) ---") + + keyboard_main = { + "inline_keyboard": [ + [ + {"text": "🔎 Altra Località", "callback_data": "ask_city"}, + {"text": "❌ Chiudi", "callback_data": "stop_bot"} + ] + ] } + + offset = 0 + user_states = {} + + # Variabile per evitare doppio invio nello stesso minuto + last_report_date = None + while True: + # 1. SCHEDULER INTERNO (Ore 08:00) + now = now_local() + today_str = now.strftime("%Y-%m-%d") + + if now.hour == 8 and now.minute == 0 and last_report_date != today_str: + LOGGER.info("⏰ Invio report automatico delle 08:00") + report_home = generate_report(HOME_LAT, HOME_LON, HOME_NAME) + destinations = [ADMIN_CHAT_ID] if DEBUG else TELEGRAM_CHAT_IDS + for chat_id in destinations: + tg_send_message(chat_id, report_home, keyboard_main) + last_report_date = today_str -def fmt_num(v: Optional[float], dec: int = 1) -> str: - if v is None: - return "—" - try: - return f"{v:.{dec}f}" - except Exception: - return "—" + # 2. TELEGRAM POLLING + updates = tg_get_updates(offset) + + for u in updates: + offset = u["update_id"] + 1 + + # --- Gestione Bottoni (Callback) --- + if "callback_query" in u: + cb = u["callback_query"] + cid = str(cb["from"]["id"]) + cb_id = cb["id"] + data = cb.get("data") + + if data == "stop_bot": + tg_answer_callback(cb_id, "Sessione chiusa") + tg_send_message(cid, "👋 Sessione meteo terminata.") + if cid in user_states: del user_states[cid] + elif data == "ask_city": + tg_answer_callback(cb_id, "Inserisci nome città") + tg_send_message(cid, "✍️ Scrivi il nome della città:") + user_states[cid] = "waiting_city_name" + + # --- Gestione Messaggi Testo --- + elif "message" in u: + msg = u["message"] + cid = str(msg["chat"]["id"]) + text = msg.get("text", "").strip() + + # A) Gestione comando /meteo + if text.lower().startswith("/meteo"): + query = text[6:].strip() # Rimuove "/meteo " + if query: + tg_send_message(cid, f"🔄 Cerco '{query}'...") + coords = get_coordinates(query) + if coords: + lat, lon, name = coords + LOGGER.info("CMD /meteo: User %s found %s", cid, name) + report = generate_report(lat, lon, name) + tg_send_message(cid, report, keyboard_main) + else: + tg_send_message(cid, f"❌ Città '{query}' non trovata.", keyboard_main) + else: + tg_send_message(cid, "⚠️ Usa: /meteo \nEs: /meteo Rimini") -def build_message(s0: Dict, s1: Dict, generated_at: datetime.datetime) -> str: - # Nota: Markdown. Manteniamo righe brevi. - dt_str = generated_at.strftime("%d/%m %H:%M") - - def block(title: str, s: Dict) -> str: - phen = ", ".join(s["phenomena"]) if s["phenomena"] else "—" - return ( - f"*{title}*\n" - f"🌡️ Tmin/Tmax: `{fmt_num(s['tmin'])}°C` / `{fmt_num(s['tmax'])}°C`\n" - f"☁️ Nuvolosità: avg `{fmt_num(s['cavg'],0)}%` max `{fmt_num(s['cmax'],0)}%`\n" - f"💨 Vento/Raff.: `{fmt_num(s['wmax'],0)} km/h` / `{fmt_num(s['gmax'],0)} km/h`\n" - f"🌧️ Pioggia: `{fmt_num(s['psum'])} mm` (max/h `{fmt_num(s['pmax'])} mm`)\n" - f"🔎 Fenomeni: {phen}" - ) - - msg = ( - f"🌤️ *REPORT METEO*\n" - f"📍 {LOCATION_NAME}\n" - f"🕗 Agg. `{dt_str}` (modello: `{MODEL}`)\n\n" - f"{block('0–24h', s0)}\n\n" - f"{block('24–48h', s1)}\n" - ) - return msg - - -def main() -> None: - LOGGER.info("--- Morning weather report ---") - - data = get_forecast() - if not data: - # errori: solo log - return - - hourly = data.get("hourly", {}) or {} - times = hourly.get("time", []) or [] - if not times: - LOGGER.error("Open-Meteo: hourly.time missing/empty") - return - - now = now_local() - start0 = now - end0 = now + datetime.timedelta(hours=HOURS_1) - start1 = end0 - end1 = now + datetime.timedelta(hours=HOURS_2) - - idx0 = window_indices(times, start0, end0) - idx1 = window_indices(times, start1, end1) - - if not idx0 or not idx1: - LOGGER.error("Insufficient hourly coverage for windows: idx0=%s idx1=%s", len(idx0), len(idx1)) - return - - s0 = summarize_window(hourly, idx0) - s1 = summarize_window(hourly, idx1) - - msg = build_message(s0, s1, now) - - ok = telegram_send_markdown(msg) - if ok: - LOGGER.info("Weather report sent.") - else: - LOGGER.error("Weather report NOT sent (token/telegram error).") - + # B) Gestione attesa risposta dopo click bottone + elif cid in user_states and user_states[cid] == "waiting_city_name" and text: + tg_send_message(cid, f"🔄 Cerco '{text}'...") + coords = get_coordinates(text) + if coords: + lat, lon, name = coords + LOGGER.info("Button Flow: User %s found %s", cid, name) + report = generate_report(lat, lon, name) + tg_send_message(cid, report, keyboard_main) + else: + tg_send_message(cid, f"❌ Città '{text}' non trovata.", keyboard_main) + + del user_states[cid] + + time.sleep(1) if __name__ == "__main__": main()