#!/usr/bin/env python3 # -*- coding: utf-8 -*- import datetime 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 (v3.3 - Full Bot Mode) # # - 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 DEFAULT (CASA) --- HOME_LAT = 43.9356 HOME_LON = 12.4296 HOME_NAME = "🏠 Casa (Strada Cà Toro)" # --- CONFIG --- TZ = "Europe/Rome" TZINFO = ZoneInfo(TZ) 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_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/3.3"} # --- 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 parse_time(t: str) -> datetime.datetime: dt = parser.isoparse(t) if dt.tzinfo is None: return dt.replace(tzinfo=TZINFO) return dt.astimezone(TZINFO) # --- TELEGRAM API WRAPPER --- 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 } if reply_markup: payload["reply_markup"] = reply_markup try: 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 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 [] 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 # --- 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(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("Meteo API Error: %s", e) return None # --- GENERAZIONE ASCII --- 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 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: 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) # --- MAIN LOOP --- 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 # 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") # 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()