861 lines
38 KiB
Python
861 lines
38 KiB
Python
import logging
|
||
import subprocess
|
||
import os
|
||
import datetime
|
||
import requests
|
||
import shlex
|
||
import json
|
||
import time
|
||
from functools import wraps
|
||
from typing import Optional
|
||
from zoneinfo import ZoneInfo
|
||
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
|
||
from telegram.ext import (
|
||
Application,
|
||
CommandHandler,
|
||
CallbackQueryHandler,
|
||
ContextTypes,
|
||
JobQueue
|
||
)
|
||
|
||
# =============================================================================
|
||
# LOOGLE BOT V8.1 (MODULARE + ON-DEMAND METEO)
|
||
# =============================================================================
|
||
|
||
# --- CONFIGURAZIONE ---
|
||
BOT_TOKEN = os.environ.get('BOT_TOKEN')
|
||
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()]
|
||
|
||
SSH_USER = "daniely"
|
||
NAS_USER = "daniely"
|
||
MASTER_IP = "192.168.128.80"
|
||
TZ = "Europe/Rome"
|
||
TZINFO = ZoneInfo(TZ)
|
||
|
||
# PERCORSI SCRIPT
|
||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||
METEO_SCRIPT = os.path.join(SCRIPT_DIR, "meteo.py") # SCRIPT METEO SEPARATO
|
||
METEO7_SCRIPT = os.path.join(SCRIPT_DIR, "previsione7.py")
|
||
SEVERE_SCRIPT = os.path.join(SCRIPT_DIR, "severe_weather.py")
|
||
ICE_CHECK_SCRIPT = os.path.join(SCRIPT_DIR, "check_ghiaccio.py")
|
||
IRRIGATION_SCRIPT = os.path.join(SCRIPT_DIR, "smart_irrigation_advisor.py")
|
||
SNOW_RADAR_SCRIPT = os.path.join(SCRIPT_DIR, "snow_radar.py")
|
||
|
||
# FILE STATO VIAGGI
|
||
VIAGGI_STATE_FILE = os.path.join(SCRIPT_DIR, "viaggi_attivi.json")
|
||
|
||
# --- LISTE DISPOSITIVI (CORE/INFRA) ---
|
||
CORE_DEVICES = [
|
||
{"name": "🍓 Pi-1 (Master)", "ip": MASTER_IP, "type": "pi", "user": SSH_USER},
|
||
{"name": "🍓 Pi-2 (Backup)", "ip": "127.0.0.1", "type": "local", "user": ""},
|
||
{"name": "🗄️ NAS 920+", "ip": "192.168.128.100", "type": "nas", "user": NAS_USER},
|
||
{"name": "🗄️ NAS 214", "ip": "192.168.128.90", "type": "nas", "user": NAS_USER}
|
||
]
|
||
INFRA_DEVICES = [
|
||
{"name": "📡 Router", "ip": "192.168.128.1"},
|
||
{"name": "📶 WiFi Sala", "ip": "192.168.128.101"},
|
||
{"name": "📶 WiFi Luca", "ip": "192.168.128.102"},
|
||
{"name": "📶 WiFi Taverna", "ip": "192.168.128.103"},
|
||
{"name": "📶 WiFi Dado", "ip": "192.168.128.104"},
|
||
{"name": "🔌 Sw Sala", "ip": "192.168.128.105"},
|
||
{"name": "🔌 Sw Tav", "ip": "192.168.128.106"},
|
||
{"name": "🔌 Sw Lav", "ip": "192.168.128.107"}
|
||
]
|
||
|
||
logging.basicConfig(format="%(asctime)s - %(levelname)s - %(message)s", level=logging.INFO)
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# --- FUNZIONI SISTEMA (SSH/PING) ---
|
||
def run_cmd(command, ip=None, user=None):
|
||
try:
|
||
if ip == "127.0.0.1" or ip is None:
|
||
return subprocess.check_output(command, shell=True, stderr=subprocess.STDOUT, timeout=5).decode('utf-8').strip()
|
||
else:
|
||
safe_cmd = command.replace("'", "'\\''")
|
||
full_cmd = f"ssh -o LogLevel=ERROR -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=3 {user}@{ip} '{safe_cmd}'"
|
||
return subprocess.check_output(full_cmd, shell=True, stderr=subprocess.STDOUT, timeout=8).decode('utf-8').strip()
|
||
except Exception: return "Err"
|
||
|
||
def get_ping_icon(ip):
|
||
try:
|
||
subprocess.run(["ping", "-c", "1", "-W", "1", ip], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=0.8, check=True)
|
||
return "✅"
|
||
except Exception: return "🔴"
|
||
|
||
def get_device_stats(device):
|
||
ip, user, dtype = device['ip'], device['user'], device['type']
|
||
uptime_raw = run_cmd("uptime -p 2>/dev/null || uptime", ip, user)
|
||
if not uptime_raw or "Err" in uptime_raw:
|
||
# Retry once to reduce transient SSH hiccups.
|
||
time.sleep(0.5)
|
||
uptime_raw = run_cmd("uptime -p 2>/dev/null || uptime", ip, user)
|
||
if not uptime_raw or "Err" in uptime_raw:
|
||
# If ping is OK but SSH failed, mark as online with warning.
|
||
if get_ping_icon(ip) == "✅":
|
||
return "🟡 **ONLINE (SSH non raggiungibile)**"
|
||
return "🔴 **OFFLINE**"
|
||
uptime = uptime_raw.replace("up ", "").split(", load")[0].split(", ")[0]
|
||
temp = "N/A"
|
||
if dtype in ["pi", "local"]:
|
||
t = run_cmd("cat /sys/class/thermal/thermal_zone0/temp", ip, user)
|
||
if t.isdigit(): temp = f"{int(t)/1000:.1f}°C"
|
||
elif dtype == "nas":
|
||
t = run_cmd("cat /sys/class/hwmon/hwmon0/temp1_input 2>/dev/null || cat /sys/class/thermal/thermal_zone0/temp", ip, user)
|
||
if t.isdigit():
|
||
v = int(t); temp = f"{v/1000:.1f}°C" if v > 1000 else f"{v}°C"
|
||
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):
|
||
if not os.path.exists(filepath): return "⚠️ File non trovato."
|
||
try: return subprocess.check_output(['tail', '-n', str(lines), filepath], stderr=subprocess.STDOUT).decode('utf-8')
|
||
except Exception as e: return f"Errore: {str(e)}"
|
||
|
||
def run_speedtest():
|
||
try: return subprocess.check_output("speedtest-cli --simple", shell=True, timeout=50).decode('utf-8')
|
||
except: return "Errore Speedtest"
|
||
|
||
# --- GESTIONE VIAGGI ATTIVI ---
|
||
def load_viaggi_state() -> dict:
|
||
"""Carica lo stato dei viaggi attivi da file JSON"""
|
||
if os.path.exists(VIAGGI_STATE_FILE):
|
||
try:
|
||
with open(VIAGGI_STATE_FILE, "r", encoding="utf-8") as f:
|
||
return json.load(f) or {}
|
||
except Exception as e:
|
||
logger.error(f"Errore lettura viaggi state: {e}")
|
||
return {}
|
||
return {}
|
||
|
||
def save_viaggi_state(state: dict) -> None:
|
||
"""Salva lo stato dei viaggi attivi su file JSON"""
|
||
try:
|
||
with open(VIAGGI_STATE_FILE, "w", encoding="utf-8") as f:
|
||
json.dump(state, f, ensure_ascii=False, indent=2)
|
||
except Exception as e:
|
||
logger.error(f"Errore scrittura viaggi state: {e}")
|
||
|
||
def get_timezone_from_coords(lat: float, lon: float) -> str:
|
||
"""Ottiene la timezone da coordinate usando timezonefinder o fallback"""
|
||
try:
|
||
from timezonefinder import TimezoneFinder
|
||
tf = TimezoneFinder()
|
||
tz = tf.timezone_at(lng=lon, lat=lat)
|
||
if tz:
|
||
return tz
|
||
except ImportError:
|
||
logger.warning("timezonefinder non installato, uso fallback")
|
||
except Exception as e:
|
||
logger.warning(f"Errore timezonefinder: {e}")
|
||
|
||
# Fallback: stima timezone da longitudine (approssimativo)
|
||
# Ogni 15 gradi = 1 ora di differenza da UTC
|
||
offset_hours = int(lon / 15)
|
||
# Mappatura approssimativa a timezone IANA
|
||
if -10 <= offset_hours <= 2: # Europa
|
||
return "Europe/Rome"
|
||
elif 3 <= offset_hours <= 5: # Medio Oriente
|
||
return "Asia/Dubai"
|
||
elif 6 <= offset_hours <= 8: # Asia centrale
|
||
return "Asia/Kolkata"
|
||
elif 9 <= offset_hours <= 11: # Asia orientale
|
||
return "Asia/Tokyo"
|
||
elif -5 <= offset_hours <= -3: # Americhe orientali
|
||
return "America/New_York"
|
||
elif -8 <= offset_hours <= -6: # Americhe occidentali
|
||
return "America/Los_Angeles"
|
||
else:
|
||
return "UTC"
|
||
|
||
def add_viaggio(chat_id: str, location: str, lat: float, lon: float, name: str, timezone: Optional[str] = None) -> None:
|
||
"""Aggiunge o aggiorna un viaggio attivo per un chat_id (sovrascrive se esiste)"""
|
||
if timezone is None:
|
||
timezone = get_timezone_from_coords(lat, lon)
|
||
|
||
state = load_viaggi_state()
|
||
state[chat_id] = {
|
||
"location": location,
|
||
"lat": lat,
|
||
"lon": lon,
|
||
"name": name,
|
||
"timezone": timezone,
|
||
"activated": datetime.datetime.now().isoformat()
|
||
}
|
||
save_viaggi_state(state)
|
||
|
||
def remove_viaggio(chat_id: str) -> bool:
|
||
"""Rimuove un viaggio attivo per un chat_id. Ritorna True se rimosso, False se non esisteva"""
|
||
state = load_viaggi_state()
|
||
if chat_id in state:
|
||
del state[chat_id]
|
||
save_viaggi_state(state)
|
||
return True
|
||
return False
|
||
|
||
def get_viaggio(chat_id: str) -> dict:
|
||
"""Ottiene il viaggio attivo per un chat_id, o None se non esiste"""
|
||
state = load_viaggi_state()
|
||
return state.get(chat_id)
|
||
|
||
# --- HELPER PER LANCIARE SCRIPT ESTERNI ---
|
||
def call_meteo_script(args_list):
|
||
"""Lancia meteo.py e cattura l'output testuale"""
|
||
try:
|
||
# Esegui: python3 meteo.py --arg1 val1 ...
|
||
cmd = ["python3", METEO_SCRIPT] + args_list
|
||
# Timeout aumentato a 90s per gestire retry e chiamate API multiple
|
||
# (get_forecast può fare retry + fallback, get_visibility_forecast può fare 2 chiamate)
|
||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=90)
|
||
return result.stdout if result.returncode == 0 else f"Errore Script: {result.stderr}"
|
||
except subprocess.TimeoutExpired:
|
||
return f"Errore esecuzione script: Timeout dopo 90 secondi (script troppo lento)"
|
||
except Exception as e:
|
||
return f"Errore esecuzione script: {e}"
|
||
|
||
def call_meteo7_script(args_list):
|
||
"""Lancia previsione7.py e cattura l'output testuale"""
|
||
try:
|
||
# Esegui: python3 previsione7.py arg1 arg2 ...
|
||
cmd = ["python3", METEO7_SCRIPT] + args_list
|
||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
||
# previsione7.py invia direttamente a Telegram, quindi l'output potrebbe essere vuoto
|
||
# Ritorniamo un messaggio di conferma se lo script è eseguito correttamente
|
||
if result.returncode == 0:
|
||
return "✅ Report previsione 7 giorni generato e inviato"
|
||
else:
|
||
return f"⚠️ Errore Script: {result.stderr[:500]}"
|
||
except Exception as e:
|
||
return f"⚠️ Errore esecuzione script: {e}"
|
||
|
||
# --- HANDLERS BOT ---
|
||
def restricted(func):
|
||
@wraps(func)
|
||
async def wrapped(update: Update, context: ContextTypes.DEFAULT_TYPE, *args, **kwargs):
|
||
user_id = update.effective_user.id
|
||
if user_id not in ALLOWED_IDS:
|
||
return
|
||
return await func(update, context, *args, **kwargs)
|
||
return wrapped
|
||
|
||
@restricted
|
||
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("🌤️ Meteo Casa", callback_data="req_meteo_home"), InlineKeyboardButton("📜 Logs", callback_data="menu_logs")]
|
||
]
|
||
text = "🎛 **Loogle Control Center v8.1**\nComandi disponibili:\n🔹 `/meteo <città>`\n🔹 `/meteo7 <città>` (Previsione 7gg)\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")
|
||
|
||
@restricted
|
||
async def meteo_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||
chat_id = str(update.effective_chat.id)
|
||
|
||
if not context.args:
|
||
# Se non ci sono argomenti, controlla se c'è un viaggio attivo
|
||
viaggio_attivo = get_viaggio(chat_id)
|
||
if viaggio_attivo:
|
||
# Invia report per Casa + località viaggio
|
||
await update.message.reply_text(
|
||
f"🔄 Generazione report meteo per Casa e {viaggio_attivo['name']}...",
|
||
parse_mode="Markdown"
|
||
)
|
||
|
||
# Report Casa
|
||
report_casa = call_meteo_script(["--home"])
|
||
await update.message.reply_text(
|
||
f"🏠 **Report Meteo - Casa**\n\n{report_casa}",
|
||
parse_mode="Markdown"
|
||
)
|
||
|
||
# Report località viaggio
|
||
report_viaggio = call_meteo_script([
|
||
"--query", viaggio_attivo["location"],
|
||
"--timezone", viaggio_attivo.get("timezone", "Europe/Rome")
|
||
])
|
||
await update.message.reply_text(
|
||
f"✈️ **Report Meteo - {viaggio_attivo['name']}**\n\n{report_viaggio}",
|
||
parse_mode="Markdown"
|
||
)
|
||
else:
|
||
# Nessun viaggio attivo: invia report per Casa
|
||
await update.message.reply_text("🔄 Generazione report meteo per Casa...", parse_mode="Markdown")
|
||
report_casa = call_meteo_script(["--home"])
|
||
await update.message.reply_text(
|
||
f"🏠 **Report Meteo - Casa**\n\n{report_casa}",
|
||
parse_mode="Markdown"
|
||
)
|
||
return
|
||
|
||
city = " ".join(context.args)
|
||
await update.message.reply_text(f"🔄 Cerco '{city}'...", parse_mode="Markdown")
|
||
|
||
# LANCIAMO LO SCRIPT ESTERNO!
|
||
report = call_meteo_script(["--query", city])
|
||
await update.message.reply_text(report, parse_mode="Markdown")
|
||
|
||
@restricted
|
||
async def meteo7_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||
chat_id = str(update.effective_chat.id)
|
||
|
||
if not context.args:
|
||
# Se non ci sono argomenti, controlla se c'è un viaggio attivo
|
||
viaggio_attivo = get_viaggio(chat_id)
|
||
if viaggio_attivo:
|
||
# Invia previsione 7gg per Casa + località viaggio
|
||
await update.message.reply_text(
|
||
f"📡 Calcolo previsione 7gg per Casa e {viaggio_attivo['name']}...",
|
||
parse_mode="Markdown"
|
||
)
|
||
|
||
# Previsione Casa
|
||
subprocess.Popen([
|
||
"python3", METEO7_SCRIPT,
|
||
"casa",
|
||
"--chat_id", chat_id
|
||
])
|
||
|
||
# Previsione località viaggio
|
||
subprocess.Popen([
|
||
"python3", METEO7_SCRIPT,
|
||
viaggio_attivo["location"],
|
||
"--chat_id", chat_id,
|
||
"--timezone", viaggio_attivo.get("timezone", "Europe/Rome")
|
||
])
|
||
|
||
await update.message.reply_text(
|
||
f"✅ Previsioni 7 giorni in arrivo per:\n"
|
||
f"🏠 Casa\n"
|
||
f"✈️ {viaggio_attivo['name']}",
|
||
parse_mode="Markdown"
|
||
)
|
||
else:
|
||
# Nessun viaggio attivo, invia solo Casa
|
||
await update.message.reply_text(f"📡 Calcolo previsione 7gg per Casa...", parse_mode="Markdown")
|
||
subprocess.Popen(["python3", METEO7_SCRIPT, "casa", "--chat_id", chat_id])
|
||
return
|
||
|
||
query = " ".join(context.args)
|
||
await update.message.reply_text(f"📡 Calcolo previsione 7gg per: {query}...", parse_mode="Markdown")
|
||
# Lancia in background
|
||
subprocess.Popen(["python3", METEO7_SCRIPT, query, "--chat_id", chat_id])
|
||
|
||
@restricted
|
||
async def snowradar_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||
"""Comando /snowradar: analisi neve in griglia 30km da San Marino"""
|
||
chat_id = str(update.effective_chat.id)
|
||
|
||
# Costruisci comando base
|
||
# --debug: quando chiamato da Telegram, invia solo al chat_id richiedente
|
||
# --chat_id: passa il chat_id specifico per inviare il messaggio
|
||
cmd = ["python3", SNOW_RADAR_SCRIPT, "--debug", "--chat_id", chat_id]
|
||
|
||
# Messaggio di avvio
|
||
await update.message.reply_text(
|
||
"❄️ **Snow Radar**\n\n"
|
||
"Analisi neve in corso... Il report verrà inviato a breve.",
|
||
parse_mode="Markdown"
|
||
)
|
||
|
||
# Avvia in background
|
||
subprocess.Popen(cmd, cwd=SCRIPT_DIR)
|
||
|
||
@restricted
|
||
async def irrigazione_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||
"""Comando /irrigazione: consulente agronomico per gestione irrigazione"""
|
||
chat_id = str(update.effective_chat.id)
|
||
|
||
# Costruisci comando base
|
||
# --force: quando chiamato da Telegram, sempre invia (bypassa logica auto-reporting)
|
||
cmd = ["python3", IRRIGATION_SCRIPT, "--telegram", "--chat_id", chat_id, "--force"]
|
||
|
||
# Opzioni: --debug, o parametri posizionali per location
|
||
if context.args:
|
||
args_str = " ".join(context.args).lower()
|
||
|
||
# Flag opzionali
|
||
if "--debug" in args_str or "debug" in args_str:
|
||
cmd.append("--debug")
|
||
|
||
# Se ci sono altri argomenti non-flag, assumi siano per location
|
||
remaining_args = [a for a in context.args if not a.startswith("--") and a.lower() not in ["debug", "force"]]
|
||
if remaining_args:
|
||
# Prova a interpretare come location (potrebbero essere coordinate o nome)
|
||
location_str = " ".join(remaining_args)
|
||
# Se sembra essere coordinate numeriche, usa --lat e --lon
|
||
parts = location_str.split()
|
||
if len(parts) == 2:
|
||
try:
|
||
lat = float(parts[0])
|
||
lon = float(parts[1])
|
||
cmd.extend(["--lat", str(lat), "--lon", str(lon)])
|
||
except ValueError:
|
||
# Non sono numeri, probabilmente è un nome location
|
||
cmd.extend(["--location", location_str])
|
||
else:
|
||
cmd.extend(["--location", location_str])
|
||
|
||
# Messaggio di avvio
|
||
await update.message.reply_text(
|
||
"🌱 **Consulente Irrigazione**\n\n"
|
||
"Analisi in corso... Il report verrà inviato a breve.",
|
||
parse_mode="Markdown"
|
||
)
|
||
|
||
# Esegui in background
|
||
subprocess.Popen(cmd)
|
||
|
||
@restricted
|
||
async def road_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||
"""Comando /road: analizza tutti i rischi meteo lungo percorso stradale"""
|
||
chat_id = str(update.effective_chat.id)
|
||
|
||
if not context.args or len(context.args) < 2:
|
||
await update.message.reply_text(
|
||
"⚠️ **Uso:** `/road <località1> <località2>`\n\n"
|
||
"Esempio: `/road Bologna Rimini`\n"
|
||
"Esempio: `/road \"San Marino\" Rimini`\n"
|
||
"Esempio: `/road \"San Marino di Castrozza\" \"San Martino di Castrozza\"`\n"
|
||
"Usa virgolette per nomi con spazi multipli.\n\n"
|
||
"Analizza tutti i rischi meteo lungo il percorso: ghiaccio, neve, pioggia, rovesci, nebbia, grandine, temporali.",
|
||
parse_mode="Markdown"
|
||
)
|
||
return
|
||
|
||
# Parsing intelligente degli argomenti con supporto virgolette usando shlex
|
||
def parse_quoted_args(args):
|
||
"""Parsa argomenti considerando virgolette per nomi multipli usando shlex."""
|
||
# Unisci tutti gli argomenti in una stringa e usa shlex per parsing corretto
|
||
args_str = " ".join(args)
|
||
try:
|
||
# shlex.split gestisce correttamente virgolette singole e doppie
|
||
parsed = shlex.split(args_str, posix=True)
|
||
return parsed
|
||
except ValueError:
|
||
# Fallback: se shlex fallisce, usa metodo semplice
|
||
result = []
|
||
current = []
|
||
in_quotes = False
|
||
quote_char = None
|
||
|
||
for arg in args:
|
||
# Se inizia con virgolette, entra in modalità quote
|
||
if arg.startswith('"') or arg.startswith("'"):
|
||
in_quotes = True
|
||
quote_char = arg[0]
|
||
arg_clean = arg[1:] # Rimuovi virgolette iniziali
|
||
current = [arg_clean]
|
||
# Se finisce con virgolette, esci dalla modalità quote
|
||
elif arg.endswith('"') or arg.endswith("'"):
|
||
if in_quotes and (arg.endswith(quote_char) if quote_char else True):
|
||
arg_clean = arg[:-1] # Rimuovi virgolette finali
|
||
current.append(arg_clean)
|
||
result.append(" ".join(current))
|
||
current = []
|
||
in_quotes = False
|
||
quote_char = None
|
||
else:
|
||
result.append(arg)
|
||
# Se siamo dentro le virgolette, aggiungi all'argomento corrente
|
||
elif in_quotes:
|
||
current.append(arg)
|
||
# Altrimenti, argomento normale
|
||
else:
|
||
result.append(arg)
|
||
|
||
# Se rimangono argomenti non chiusi, uniscili
|
||
if current:
|
||
result.append(" ".join(current))
|
||
|
||
return result
|
||
|
||
parsed_args = parse_quoted_args(context.args)
|
||
|
||
if len(parsed_args) < 2:
|
||
await update.message.reply_text(
|
||
"⚠️ Errore: servono almeno 2 località.\n"
|
||
"Usa virgolette per nomi multipli: `/road \"San Marino\" Rimini`",
|
||
parse_mode="Markdown"
|
||
)
|
||
return
|
||
|
||
city1 = parsed_args[0]
|
||
city2 = parsed_args[1]
|
||
|
||
await update.message.reply_text(
|
||
f"🔄 Analisi rischi meteo stradali: {city1} → {city2}...",
|
||
parse_mode="Markdown"
|
||
)
|
||
|
||
try:
|
||
# Importa funzioni da road_weather.py
|
||
import sys
|
||
sys.path.insert(0, SCRIPT_DIR)
|
||
from road_weather import (
|
||
analyze_route_weather_risks,
|
||
format_route_weather_report,
|
||
generate_route_weather_map,
|
||
PANDAS_AVAILABLE
|
||
)
|
||
|
||
# Verifica disponibilità pandas
|
||
if not PANDAS_AVAILABLE:
|
||
await update.message.reply_text(
|
||
"❌ **Errore: dipendenze mancanti**\n\n"
|
||
"`pandas` e `numpy` sono richiesti per l'analisi avanzata.\n\n"
|
||
"**Installazione nel container Docker:**\n"
|
||
"```bash\n"
|
||
"docker exec -it <container_name> pip install --break-system-packages pandas numpy\n"
|
||
"```\n\n"
|
||
"Oppure aggiungi al Dockerfile:\n"
|
||
"```dockerfile\n"
|
||
"RUN pip install --break-system-packages pandas numpy\n"
|
||
"```",
|
||
parse_mode="Markdown"
|
||
)
|
||
return
|
||
|
||
# Analizza percorso (auto-detect del miglior modello disponibile per la zona)
|
||
df = analyze_route_weather_risks(city1, city2, model_slug=None)
|
||
|
||
if df is None or df.empty:
|
||
await update.message.reply_text(
|
||
f"❌ Errore: Impossibile ottenere dati per il percorso {city1} → {city2}.\n"
|
||
f"Verifica che i nomi delle località siano corretti.",
|
||
parse_mode="Markdown"
|
||
)
|
||
return
|
||
|
||
# Formatta e invia report (compatto, sempre in un singolo messaggio)
|
||
report = format_route_weather_report(df, city1, city2)
|
||
await update.message.reply_text(report, parse_mode="Markdown")
|
||
|
||
# Genera e invia mappa del percorso (sempre, dopo il messaggio testuale)
|
||
try:
|
||
import tempfile
|
||
map_file = tempfile.NamedTemporaryFile(delete=False, suffix='.png', dir=SCRIPT_DIR)
|
||
map_path = map_file.name
|
||
map_file.close()
|
||
|
||
map_generated = generate_route_weather_map(df, city1, city2, map_path)
|
||
if map_generated:
|
||
now = datetime.datetime.now()
|
||
caption = (
|
||
f"🛣️ <b>Mappa Rischi Meteo Stradali</b>\n"
|
||
f"📍 {city1} → {city2}\n"
|
||
f"🕒 {now.strftime('%d/%m/%Y %H:%M')}"
|
||
)
|
||
|
||
# Invia foto via Telegram
|
||
with open(map_path, 'rb') as photo_file:
|
||
await update.message.reply_photo(
|
||
photo=photo_file,
|
||
caption=caption,
|
||
parse_mode="HTML"
|
||
)
|
||
|
||
# Pulisci file temporaneo
|
||
try:
|
||
os.unlink(map_path)
|
||
except:
|
||
pass
|
||
except Exception as map_error:
|
||
logger.warning(f"Errore generazione mappa road: {map_error}")
|
||
# Non bloccare l'esecuzione se la mappa fallisce
|
||
|
||
except ImportError as e:
|
||
# Gestione specifica per ImportError con messaggio dettagliato
|
||
error_msg = str(e)
|
||
await update.message.reply_text(
|
||
f"❌ **Errore: dipendenze mancanti**\n\n{error_msg}",
|
||
parse_mode="Markdown"
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"Errore road_command: {e}", exc_info=True)
|
||
await update.message.reply_text(
|
||
f"❌ Errore durante l'analisi: {str(e)}",
|
||
parse_mode="Markdown"
|
||
)
|
||
|
||
@restricted
|
||
async def meteo_viaggio_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||
"""Comando /meteo_viaggio: attiva/disattiva monitoraggio meteo per viaggio"""
|
||
chat_id = str(update.effective_chat.id)
|
||
|
||
# Gestione comando "fine"
|
||
if context.args and len(context.args) == 1 and context.args[0].lower() in ["fine", "stop", "termina"]:
|
||
viaggio_rimosso = remove_viaggio(chat_id)
|
||
if viaggio_rimosso:
|
||
await update.message.reply_text(
|
||
"🎉 **Viaggio terminato!**\n\n"
|
||
"✅ Il monitoraggio meteo personalizzato è stato disattivato.\n"
|
||
"🏠 Ora riceverai solo gli avvisi per Casa.\n\n"
|
||
"Bentornato a Casa! 👋",
|
||
parse_mode="Markdown"
|
||
)
|
||
else:
|
||
await update.message.reply_text(
|
||
"ℹ️ Nessun viaggio attivo da terminare.\n"
|
||
"Usa `/meteo_viaggio <località>` per attivare un nuovo viaggio.",
|
||
parse_mode="Markdown"
|
||
)
|
||
return
|
||
|
||
# Gestione attivazione viaggio
|
||
if not context.args:
|
||
viaggio_attivo = get_viaggio(chat_id)
|
||
if viaggio_attivo:
|
||
await update.message.reply_text(
|
||
f"ℹ️ **Viaggio attivo**\n\n"
|
||
f"📍 **{viaggio_attivo['name']}**\n"
|
||
f"Attivato: {viaggio_attivo.get('activated', 'N/A')}\n\n"
|
||
f"Usa `/meteo_viaggio fine` per terminare.",
|
||
parse_mode="Markdown"
|
||
)
|
||
else:
|
||
await update.message.reply_text(
|
||
"⚠️ Usa: `/meteo_viaggio <località>`\n\n"
|
||
"Esempio: `/meteo_viaggio Roma`\n\n"
|
||
"Per terminare: `/meteo_viaggio fine`",
|
||
parse_mode="Markdown"
|
||
)
|
||
return
|
||
|
||
location = " ".join(context.args)
|
||
|
||
await update.message.reply_text(f"🔄 Attivazione monitoraggio viaggio per: **{location}**\n⏳ Elaborazione in corso...", parse_mode="Markdown")
|
||
|
||
# Ottieni coordinate dalla localizzazione (usa meteo.py per geocoding)
|
||
try:
|
||
# Importa funzione get_coordinates da meteo.py
|
||
import sys
|
||
sys.path.insert(0, SCRIPT_DIR)
|
||
from meteo import get_coordinates
|
||
|
||
coords = get_coordinates(location)
|
||
if not coords:
|
||
await update.message.reply_text(f"❌ Località '{location}' non trovata. Verifica il nome e riprova.", parse_mode="Markdown")
|
||
return
|
||
|
||
lat, lon, name, cc = coords
|
||
|
||
# Ottieni timezone per questa localizzazione
|
||
timezone = get_timezone_from_coords(lat, lon)
|
||
|
||
# Conferma riconoscimento località
|
||
await update.message.reply_text(
|
||
f"✅ **Località riconosciuta!**\n\n"
|
||
f"📍 **{name}**\n"
|
||
f"🌍 Coordinate: {lat:.4f}, {lon:.4f}\n"
|
||
f"🕐 Fuso orario: {timezone}\n\n"
|
||
f"⏳ Generazione report meteo in corso...",
|
||
parse_mode="Markdown"
|
||
)
|
||
|
||
# Salva viaggio attivo (sovrascrive se esiste già)
|
||
add_viaggio(chat_id, location, lat, lon, name, timezone)
|
||
|
||
# Esegui meteo.py in modo sincrono e invia output come conferma
|
||
try:
|
||
report_meteo = call_meteo_script([
|
||
"--query", location,
|
||
"--timezone", timezone
|
||
])
|
||
|
||
if report_meteo and not report_meteo.startswith("Errore") and not report_meteo.startswith("⚠️"):
|
||
# Invia report meteo come conferma
|
||
await update.message.reply_text(
|
||
f"📊 **Report Meteo - {name}**\n\n{report_meteo}",
|
||
parse_mode="Markdown"
|
||
)
|
||
else:
|
||
await update.message.reply_text(
|
||
f"⚠️ Errore nella generazione del report meteo:\n{report_meteo}",
|
||
parse_mode="Markdown"
|
||
)
|
||
except Exception as e:
|
||
logger.exception(f"Errore esecuzione meteo.py: {e}")
|
||
await update.message.reply_text(
|
||
f"⚠️ Errore durante la generazione del report meteo: {str(e)}",
|
||
parse_mode="Markdown"
|
||
)
|
||
|
||
# Esegui previsione7.py (invia direttamente a Telegram)
|
||
try:
|
||
# Nota: previsione7.py invia direttamente a Telegram, quindi eseguiamo lo script
|
||
result_meteo7 = subprocess.run(
|
||
["python3", METEO7_SCRIPT, location, "--chat_id", chat_id, "--timezone", timezone],
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=60
|
||
)
|
||
|
||
if result_meteo7.returncode == 0:
|
||
await update.message.reply_text(
|
||
f"✅ **Monitoraggio viaggio attivato!**\n\n"
|
||
f"📨 **Report inviati:**\n"
|
||
f"• Report meteo dettagliato ✓\n"
|
||
f"• Previsione 7 giorni ✓\n\n"
|
||
f"🎯 **Monitoraggio attivo per:**\n"
|
||
f"📍 {name}\n"
|
||
f"🕐 Fuso orario: {timezone}\n\n"
|
||
f"📬 **Riceverai automaticamente:**\n"
|
||
f"• Report meteo alle 8:00 AM (ora locale)\n"
|
||
f"• Previsione 7 giorni alle 7:30 AM (ora locale)\n"
|
||
f"• Avvisi meteo severi in tempo reale\n\n"
|
||
f"Per terminare: `/meteo_viaggio fine`",
|
||
parse_mode="Markdown"
|
||
)
|
||
else:
|
||
await update.message.reply_text(
|
||
f"✅ Report meteo inviato!\n"
|
||
f"⚠️ Errore nella previsione 7 giorni:\n{result_meteo7.stderr[:500]}\n\n"
|
||
f"🎯 **Monitoraggio attivo per:** {name}",
|
||
parse_mode="Markdown"
|
||
)
|
||
except Exception as e:
|
||
logger.exception(f"Errore esecuzione previsione7.py: {e}")
|
||
await update.message.reply_text(
|
||
f"✅ Report meteo inviato!\n"
|
||
f"⚠️ Errore nella previsione 7 giorni: {str(e)}\n\n"
|
||
f"🎯 **Monitoraggio attivo per:** {name}",
|
||
parse_mode="Markdown"
|
||
)
|
||
|
||
# Lancia severe_weather.py in background (non blocca la risposta)
|
||
subprocess.Popen([
|
||
"python3", SEVERE_SCRIPT,
|
||
"--lat", str(lat),
|
||
"--lon", str(lon),
|
||
"--location", name,
|
||
"--timezone", timezone,
|
||
"--chat_id", chat_id
|
||
])
|
||
except Exception as e:
|
||
logger.exception("Errore in meteo_viaggio: %s", e)
|
||
await update.message.reply_text(f"❌ Errore durante l'elaborazione: {str(e)}", parse_mode="Markdown")
|
||
|
||
async def scheduled_morning_report(context: ContextTypes.DEFAULT_TYPE) -> None:
|
||
# Esegui una sola chiamata e invia il report a tutti i chat_id
|
||
report = call_meteo_script(["--home"])
|
||
for uid in ALLOWED_IDS:
|
||
try:
|
||
await context.bot.send_message(chat_id=uid, text=report, parse_mode="Markdown")
|
||
except Exception:
|
||
pass
|
||
|
||
@restricted
|
||
async def button_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||
query = update.callback_query
|
||
await query.answer()
|
||
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")
|
||
# LANCIAMO LO SCRIPT ESTERNO
|
||
report = call_meteo_script(["--home"])
|
||
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**", reply_markup=InlineKeyboardMarkup(keyboard), parse_mode="Markdown")
|
||
|
||
elif data == "stat_all":
|
||
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")
|
||
|
||
elif data.startswith("stat_"):
|
||
dev = CORE_DEVICES[int(data.split("_")[1])]
|
||
await query.edit_message_text(f"⏳ Controllo {dev['name']}...", parse_mode="Markdown")
|
||
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...**", parse_mode="Markdown")
|
||
report = "🔍 **DIAGNOSTICA LAN**\n\n"
|
||
try:
|
||
for dev in CORE_DEVICES: report += f"{get_ping_icon(dev['ip'])} `{dev['ip']}` - {dev['name']}\n"
|
||
report += "\n"
|
||
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":
|
||
keyboard = []
|
||
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**", 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"⚡ Inviato a {dev['name']}...\n\n`{res}`", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ Indietro", callback_data="menu_reboot")]]), parse_mode="Markdown")
|
||
|
||
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 "🔴"
|
||
text = f"🛡️ **Pi-hole Master**\nStato: {icon}\n\n`{status_raw}`"
|
||
keyboard = [[InlineKeyboardButton("⏸️ 5m", callback_data="ph_disable_300"), InlineKeyboardButton("⏸️ 30m", callback_data="ph_disable_1800")], [InlineKeyboardButton("▶️ Attiva", callback_data="ph_enable"), InlineKeyboardButton("🔄 Restart", callback_data="ph_restart")], [InlineKeyboardButton("⬅️ Indietro", callback_data="main_menu")]]
|
||
await query.edit_message_text(text, reply_markup=InlineKeyboardMarkup(keyboard), parse_mode="Markdown")
|
||
|
||
elif data.startswith("ph_"):
|
||
if "disable" in data: run_cmd(f"sudo pihole disable {data.split('_')[2]}s", MASTER_IP, SSH_USER)
|
||
elif "enable" in data: run_cmd("sudo pihole enable", MASTER_IP, SSH_USER)
|
||
elif "restart" in data: run_cmd("sudo systemctl restart pihole-FTL", MASTER_IP, SSH_USER)
|
||
await query.edit_message_text("✅ Comando inviato.", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ Indietro", callback_data="menu_pihole")]]), parse_mode="Markdown")
|
||
|
||
elif data == "menu_net":
|
||
ip = run_cmd("curl -s ifconfig.me")
|
||
keyboard = [[InlineKeyboardButton("🚀 Speedtest", callback_data="net_speedtest")], [InlineKeyboardButton("⬅️ Indietro", callback_data="main_menu")]]
|
||
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...**", 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")
|
||
|
||
elif data == "menu_logs":
|
||
keyboard = [[InlineKeyboardButton("🐶 Watchdog", callback_data="log_wd"), InlineKeyboardButton("💾 Backup", callback_data="log_bk")], [InlineKeyboardButton("🔄 NPM Sync", callback_data="log_npm"), InlineKeyboardButton("⬅️ Indietro", callback_data="main_menu")]]
|
||
await query.edit_message_text("📜 **Logs**", reply_markup=InlineKeyboardMarkup(keyboard), parse_mode="Markdown")
|
||
|
||
elif data.startswith("log_"):
|
||
paths = {"log_wd": "/logs/dhcp-watchdog.log", "log_bk": "/logs/raspiBackup.log", "log_npm": "/logs/sync-npm.log"}
|
||
log_c = read_log_file(paths[data])
|
||
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 v8.1 (Modulare)...")
|
||
application = Application.builder().token(BOT_TOKEN).build()
|
||
|
||
application.add_handler(CommandHandler("start", start))
|
||
application.add_handler(CommandHandler("meteo", meteo_command))
|
||
application.add_handler(CommandHandler("meteo7", meteo7_command))
|
||
application.add_handler(CommandHandler("meteo_viaggio", meteo_viaggio_command))
|
||
application.add_handler(CommandHandler("road", road_command))
|
||
application.add_handler(CommandHandler("irrigazione", irrigazione_command))
|
||
application.add_handler(CommandHandler("snowradar", snowradar_command))
|
||
application.add_handler(CallbackQueryHandler(button_handler))
|
||
|
||
job_queue = application.job_queue
|
||
job_queue.run_daily(scheduled_morning_report, time=datetime.time(hour=7, minute=15, tzinfo=TZINFO), days=(0, 1, 2, 3, 4, 5, 6))
|
||
|
||
application.run_polling()
|
||
|
||
if __name__ == "__main__":
|
||
main()
|