375 lines
14 KiB
Python
375 lines
14 KiB
Python
#!/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 <città>" 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 <città>
|
|
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 <nome città>\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()
|