278 lines
14 KiB
Python
278 lines
14 KiB
Python
"""Monitor di Mail, WhatsApp e Telegram: raccoglie i messaggi nuovi, li fa valutare e crea avvisi."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import sqlite3
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
from avatar.settings import DATA_DIR, Settings
|
|
|
|
STATE_FILE = DATA_DIR / "monitor.json"
|
|
MAIL_INDEX = Path.home() / "Library" / "Mail" / "V10" / "MailData" / "Envelope Index"
|
|
WA_DB = DATA_DIR / "whatsapp" / "index.sqlite"
|
|
DEFAULT_RULES = ("richieste di assistenza o di aiuto, domande rivolte a me che aspettano una risposta, problemi o cose che non funzionano, "
|
|
"urgenze, scadenze e pagamenti, appuntamenti da confermare o spostare, preventivi e lavori richiesti. "
|
|
"NON contano: newsletter, promozioni, notifiche automatiche, conferme d'ordine, saluti e chiacchiere senza richieste.")
|
|
KEYWORDS = re.compile(r"\?|urgent|aiut|assistenz|problem|non funziona|non riesco|errore|bloccat|guast|richie|preventiv|fattur|pagament|scadenz|"
|
|
r"conferm|appuntament|puoi|potresti|riesci|serve|servirebbe|quando|mi dici|fammi sapere|rispond|chiam|ti prego|per favore|subito|entro", re.I)
|
|
_lock = threading.Lock()
|
|
|
|
|
|
class Monitor:
|
|
def __init__(self, ui, say, settings: Settings) -> None:
|
|
self.ui, self.say, self.settings = ui, say, settings
|
|
self.state = self._load()
|
|
self._thread: threading.Thread | None = None
|
|
self._stop = threading.Event()
|
|
|
|
# ── stato ────────────────────────────────────────────────────────────
|
|
def _load(self) -> dict:
|
|
try:
|
|
return json.loads(STATE_FILE.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
return {"mail_rowid": None, "wa_ts": None, "tg": {}, "alerts": [], "seen": []}
|
|
|
|
def _save(self) -> None:
|
|
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
self.state["alerts"] = self.state.get("alerts", [])[-100:]
|
|
self.state["seen"] = self.state.get("seen", [])[-2000:]
|
|
STATE_FILE.write_text(json.dumps(self.state, ensure_ascii=False, indent=1), encoding="utf-8")
|
|
|
|
def start(self) -> None:
|
|
if self._thread and self._thread.is_alive():
|
|
return
|
|
self._stop.clear()
|
|
self._thread = threading.Thread(target=self._loop, name="monitor", daemon=True)
|
|
self._thread.start()
|
|
|
|
def stop(self) -> None:
|
|
self._stop.set()
|
|
|
|
def _loop(self) -> None:
|
|
time.sleep(20)
|
|
last_mood = 0.0
|
|
while not self._stop.is_set():
|
|
if self.settings.get("monitor_enabled"):
|
|
try:
|
|
self.scan()
|
|
except Exception as err:
|
|
print(f"[monitor] {err}")
|
|
if self.settings.get("umore_enabled") and time.time() - last_mood > 3600:
|
|
last_mood = time.time()
|
|
try:
|
|
import sys as _s; _s.path.insert(0, str(Path(__file__).resolve().parent.parent / "plugins"))
|
|
import importlib
|
|
r = importlib.import_module("come_me").analizza_umore(6)
|
|
if r.get("tono"):
|
|
self.ui.write_log(f"SYS: Umore stimato: {r['tono']} ({r.get('nota', '')[:80]})")
|
|
except Exception as err:
|
|
print(f"[umore] {err}")
|
|
if self.settings.get("abitudini_enabled", True):
|
|
try:
|
|
import sys as _s; _s.path.insert(0, str(Path(__file__).resolve().parent.parent / "plugins"))
|
|
import importlib
|
|
ab = importlib.import_module("abitudini")
|
|
if ab.da_aggiornare():
|
|
r = ab.aggiorna()
|
|
self.ui.write_log(f"SYS: Abitudini aggiornate: {len(r)} voci.")
|
|
except Exception as err:
|
|
print(f"[abitudini] {err}")
|
|
self._stop.wait(int(self.settings.get("monitor_intervallo") or 60))
|
|
|
|
# ── raccolta ─────────────────────────────────────────────────────────
|
|
def _collect_mail(self, baseline: bool) -> list[dict]:
|
|
if not MAIL_INDEX.exists():
|
|
return []
|
|
con = sqlite3.connect(f"file:{MAIL_INDEX}?mode=ro", uri=True, timeout=5)
|
|
try:
|
|
last = self.state.get("mail_rowid")
|
|
if last is None or baseline:
|
|
self.state["mail_rowid"] = con.execute("SELECT MAX(ROWID) FROM messages").fetchone()[0] or 0
|
|
return []
|
|
rows = con.execute("""SELECT m.ROWID, COALESCE(a.comment,''), COALESCE(a.address,''), COALESCE(s.subject,''), COALESCE(su.summary,'')
|
|
FROM messages m JOIN mailboxes mb ON m.mailbox = mb.ROWID LEFT JOIN addresses a ON m.sender = a.ROWID
|
|
LEFT JOIN subjects s ON m.subject = s.ROWID LEFT JOIN summaries su ON m.summary = su.ROWID
|
|
WHERE m.ROWID > ? AND m.deleted = 0 AND (mb.url LIKE '%/INBOX' OR mb.url LIKE '%/Inbox') ORDER BY m.ROWID LIMIT 40""", (last,)).fetchall()
|
|
if rows:
|
|
self.state["mail_rowid"] = max(r[0] for r in rows)
|
|
return [{"fonte": "Mail", "chi": f"{n} <{ad}>" if n else ad, "testo": f"{sub}\n{' '.join(str(summ).split())[:300]}", "ref": f"mail:{rid}"} for rid, n, ad, sub, summ in rows]
|
|
finally:
|
|
con.close()
|
|
|
|
def _collect_whatsapp(self, baseline: bool) -> list[dict]:
|
|
if not WA_DB.exists():
|
|
return []
|
|
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
last = self.state.get("wa_ts")
|
|
if last is None or baseline:
|
|
self.state["wa_ts"] = now
|
|
return []
|
|
con = sqlite3.connect(f"file:{WA_DB}?mode=ro", uri=True, timeout=5)
|
|
try:
|
|
rows = con.execute("SELECT id, ts, chat, sender, text FROM messages WHERE source = 'live' AND from_me = 0 AND ts > ? ORDER BY ts LIMIT 40", (last,)).fetchall()
|
|
finally:
|
|
con.close()
|
|
if rows:
|
|
self.state["wa_ts"] = max(r[1] for r in rows)
|
|
return [{"fonte": "WhatsApp", "chi": chat if sender == chat else f"{sender} (gruppo {chat})", "testo": text[:300], "ref": f"wa:{rid}"} for rid, ts, chat, sender, text in rows]
|
|
|
|
def _collect_sms(self, baseline: bool) -> list[dict]:
|
|
try:
|
|
import importlib
|
|
from avatar.plugins import registry
|
|
registry.load()
|
|
sms = importlib.import_module("avatar_plugins.sms")
|
|
except Exception:
|
|
return []
|
|
if not sms.DB.exists():
|
|
return []
|
|
last = self.state.get("sms_rowid")
|
|
if last is None or baseline:
|
|
top = sms._query("", (), 1) # può fallire senza «Accesso completo al disco»: l'errore arriva a scan()
|
|
self.state["sms_rowid"] = top[0]["id"] if top else 0
|
|
return []
|
|
rows = sms._query("AND m.is_from_me = 0 AND m.ROWID > ?", (last,), 40)
|
|
if rows:
|
|
self.state["sms_rowid"] = max(r["id"] for r in rows)
|
|
return [{"fonte": "Messaggi", "chi": r["chi"], "testo": r["testo"][:300], "ref": f"sms:{r['id']}"} for r in rows]
|
|
|
|
def _collect_telegram(self, baseline: bool) -> list[dict]:
|
|
try:
|
|
from avatar import telegram_client as tg
|
|
tg.credentials()
|
|
except Exception:
|
|
return []
|
|
if not (Path(str(tg.SESSION) + ".session")).exists():
|
|
return []
|
|
state = self.state.setdefault("tg", {})
|
|
out: list[dict] = []
|
|
|
|
async def go(client):
|
|
async for d in client.iter_dialogs(limit=60):
|
|
if d.is_channel and not d.is_group:
|
|
continue
|
|
key = str(d.id)
|
|
top = d.message.id if d.message else 0
|
|
last = state.get(key)
|
|
if last is None or baseline:
|
|
state[key] = top
|
|
continue
|
|
if top <= last:
|
|
continue
|
|
async for m in client.iter_messages(d.entity, min_id=last, limit=20):
|
|
if m.out or not (m.message or "").strip():
|
|
continue
|
|
who = d.name
|
|
if d.is_group:
|
|
try:
|
|
s = await m.get_sender()
|
|
who = f"{getattr(s, 'first_name', '') or getattr(s, 'title', '')} (gruppo {d.name})"
|
|
except Exception:
|
|
pass
|
|
out.append({"fonte": "Telegram", "chi": who, "testo": m.message[:300], "ref": f"tg:{d.id}:{m.id}"})
|
|
state[key] = top
|
|
|
|
try:
|
|
tg.run(go)
|
|
except Exception as err:
|
|
print(f"[monitor] telegram: {err}")
|
|
return out
|
|
|
|
# ── valutazione ──────────────────────────────────────────────────────
|
|
def _classify(self, items: list[dict]) -> list[dict]:
|
|
candidates = [it for it in items if KEYWORDS.search(it["testo"]) or it["fonte"] == "Mail" or "gruppo" not in it["chi"]]
|
|
if not candidates:
|
|
return []
|
|
rules = self.settings.get("monitor_regole") or DEFAULT_RULES
|
|
listing = "\n".join(f"{i}. [{it['fonte']}] {it['chi']}: {it['testo'].replace(chr(10), ' ')[:300]}" for i, it in enumerate(candidates[:30]))
|
|
prompt = (f"Sei il filtro di attenzione di un assistente personale. L'utente vuole essere avvisato solo per: {rules}\n\n"
|
|
f"Messaggi nuovi:\n{listing}\n\n"
|
|
'Rispondi SOLO con un JSON: {"avvisi":[{"n":<numero>,"motivo":"<max 12 parole>","priorita":"alta|media"}]}. '
|
|
"Se nessuno merita attenzione: {\"avvisi\":[]}.")
|
|
from avatar.quick_llm import ask
|
|
raw = ask(prompt, system="Rispondi solo con JSON valido, senza testo attorno.")
|
|
m = re.search(r"\{.*\}", raw, flags=re.S)
|
|
if not m:
|
|
return []
|
|
try:
|
|
data = json.loads(m.group(0))
|
|
except Exception:
|
|
return []
|
|
alerts = []
|
|
for a in data.get("avvisi", []):
|
|
try:
|
|
it = candidates[int(a["n"])]
|
|
except Exception:
|
|
continue
|
|
alerts.append({**it, "motivo": str(a.get("motivo", ""))[:120], "priorita": "alta" if str(a.get("priorita", "")).lower() == "alta" else "media"})
|
|
return alerts
|
|
|
|
# ── ciclo ────────────────────────────────────────────────────────────
|
|
def scan(self, baseline: bool = False) -> list[dict]:
|
|
with _lock:
|
|
first = self.state.get("mail_rowid") is None and self.state.get("wa_ts") is None and not self.state.get("tg")
|
|
items: list[dict] = []
|
|
for name, fn in (("Mail", self._collect_mail), ("WhatsApp", self._collect_whatsapp), ("Messaggi", self._collect_sms), ("Telegram", self._collect_telegram)):
|
|
try:
|
|
items += fn(baseline or first)
|
|
except Exception as err:
|
|
key = f"warned_{name}"
|
|
if not self.state.get(key):
|
|
self.state[key] = True
|
|
self.ui.write_log(f"SYS: Monitor, {name} non leggibile — {str(err)[:160]}")
|
|
print(f"[monitor] {name}: {err}")
|
|
seen = set(self.state.get("seen", []))
|
|
items = [it for it in items if it["ref"] not in seen]
|
|
excluded = [w.strip().lower() for w in str(self.settings.get("monitor_escludi") or "").split(",") if w.strip()]
|
|
if excluded:
|
|
def _keep(it: dict) -> bool:
|
|
hay = f"{it.get('chi', '')} {it.get('testo', '')}".lower()
|
|
return not any(w in hay for w in excluded)
|
|
skipped = [it for it in items if not _keep(it)]
|
|
items = [it for it in items if _keep(it)]
|
|
for it in skipped:
|
|
print(f"[monitor] escluso per parola chiave: {it['fonte']} {it['chi']}")
|
|
self.state.setdefault("seen", []).extend(it["ref"] for it in items)
|
|
alerts = self._classify(items) if items else []
|
|
for a in alerts:
|
|
a["id"] = uuid.uuid4().hex[:6]
|
|
a["quando"] = datetime.now().strftime("%d/%m %H:%M")
|
|
a["aperto"] = True
|
|
self.state.setdefault("alerts", []).append(a)
|
|
self._notify(a)
|
|
self._save()
|
|
return alerts
|
|
|
|
def _notify(self, a: dict) -> None:
|
|
riga = f"{a['fonte']} · {a['chi']}: {a['motivo']}"
|
|
self.ui.write_log(f"ERR: ATTENZIONE — {riga}")
|
|
try:
|
|
title = "Ava: richiede attenzione" if a["priorita"] != "alta" else "Ava: URGENTE"
|
|
safe = lambda s: s.replace('"', "'").replace("\\", "")
|
|
subprocess.run(["osascript", "-e", f'display notification "{safe(a["chi"] + ": " + a["testo"][:120])}" with title "{safe(title)}" subtitle "{safe(a["fonte"] + " — " + a["motivo"])}"'], timeout=10)
|
|
except Exception:
|
|
pass
|
|
if self.settings.get("monitor_annuncia"):
|
|
self.say(f"Attenzione: {a['fonte']}, {a['chi']}. {a['motivo']}.")
|
|
|
|
def pending(self) -> list[dict]:
|
|
return [a for a in self.state.get("alerts", []) if a.get("aperto")]
|
|
|
|
def close(self, key: str) -> int:
|
|
n = 0
|
|
for a in self.state.get("alerts", []):
|
|
if a.get("aperto") and (not key or key in (a.get("id"), a.get("chi")) or key.lower() in a.get("chi", "").lower()):
|
|
a["aperto"] = False
|
|
n += 1
|
|
self._save()
|
|
return n
|
|
|
|
|
|
monitor: Monitor | None = None
|