From 6fad5e1f4aa1ff417790ffadbbd821ab3e3f03fb Mon Sep 17 00:00:00 2001 From: luciano Date: Wed, 23 Sep 2026 17:45:14 +0200 Subject: [PATCH] Plugin sms: lettura SMS/iMessage da Messaggi, invio con scelta del servizio, fonte per il monitor Co-Authored-By: Claude Fable 5.1 --- README.md | 7 +-- avatar/monitor.py | 27 ++++++++++- avatar/phonebook.py | 60 +++++++++++++++++++++++ plugins/messaggi.py | 26 ++++++++-- plugins/sms.py | 115 ++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 1 + uv.lock | 14 ++++++ 7 files changed, 241 insertions(+), 9 deletions(-) create mode 100644 avatar/phonebook.py create mode 100644 plugins/sms.py diff --git a/README.md b/README.md index d82589e..47888b0 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Trascina un file nella zona "File upload" (o clicca per sceglierlo) e poi fai la ## Monitor con avvisi (Mail, WhatsApp, Telegram) -Attivabile in "Motore e Voce" › Monitor. Ogni minuto (intervallo regolabile) l'app raccoglie i messaggi nuovi da Mail (posta in arrivo), WhatsApp (ponte in tempo reale) e Telegram (account collegato), li passa a un modello veloce con le regole che scrivi tu, e per quelli che meritano attenzione crea un avviso: riga rossa nel log, notifica di macOS e, se vuoi, annuncio a voce. Le regole predefinite segnalano richieste di aiuto o assistenza, domande che aspettano risposta, problemi, urgenze, scadenze, pagamenti e appuntamenti da confermare, e ignorano newsletter, promozioni e notifiche automatiche. +Attivabile in "Motore e Voce" › Monitor. Ogni minuto (intervallo regolabile) l'app raccoglie i messaggi nuovi da Mail (posta in arrivo), WhatsApp (ponte in tempo reale), Messaggi (SMS e iMessage) e Telegram (account collegato), li passa a un modello veloce con le regole che scrivi tu, e per quelli che meritano attenzione crea un avviso: riga rossa nel log, notifica di macOS e, se vuoi, annuncio a voce. Le regole predefinite segnalano richieste di aiuto o assistenza, domande che aspettano risposta, problemi, urgenze, scadenze, pagamenti e appuntamenti da confermare, e ignorano newsletter, promozioni e notifiche automatiche. A voce: "ci sono avvisi?", "chiudi l'avviso di Marco", "controlla adesso". Il primo giro dopo l'attivazione prende solo la linea di base: vengono valutati i messaggi arrivati da quel momento in poi. Con il motore Claude Code la valutazione usa Haiku a basso sforzo; con Claude API usa Haiku 4.5; con il server locale usa il modello locale. @@ -62,7 +62,8 @@ Altri plugin inclusi, tutti locali: | `timer` | timer, sveglie, attività programmate anche giornaliere | "timer di 10 minuti per la pasta", "svegliami alle 7", "ogni mattina alle 8 leggimi la posta" | | `meteo` | previsioni (Open-Meteo, senza chiave) | "che tempo fa domani a Milano" | | `contatti` | Contatti | "qual è il numero di Anna", "l'email di Luca" | -| `messaggi` | iMessage con conferma | "manda un messaggio a Luca che arrivo alle 9" | +| `messaggi` | invio iMessage o SMS con conferma | "manda un messaggio a Luca che arrivo alle 9", "mandagli un SMS" | +| `sms` | lettura di SMS e iMessage ricevuti sul Mac (app Messaggi): non letti, conversazioni, ricerca | "ho SMS nuovi?", "cosa mi ha scritto Anna per messaggio?", "cerca il codice nei messaggi" | | `file` | Spotlight, cartelle, lettura txt/PDF/Word | "cerca il PDF del contratto", "cosa c'è sul Desktop", "riassumi il documento X" | | `comandi_rapidi` | Comandi Rapidi, anche HomeKit | "accendi le luci del soggiorno" (se esiste il comando rapido) | | `browser` | legge pagine, cerca su Google/YouTube/Maps/Amazon/Wikipedia | "leggimi questa pagina", "metti su YouTube i Pink Floyd" | @@ -85,7 +86,7 @@ Per scriverne uno nuovo copia la struttura di `plugins/calendario.py`: una lista - macOS su Apple Silicon, Python 3.12, [uv](https://docs.astral.sh/uv/). - `brew install espeak-ng portaudio` (fonetica italiana per Kokoro e audio). -- Permessi macOS richiesti al primo uso: Microfono, Calendario. +- Permessi macOS richiesti al primo uso: Microfono, Calendario, Promemoria, Contatti, Automazione; per leggere SMS e iMessage serve «Accesso completo al disco» all'app (Impostazioni di Sistema › Privacy e sicurezza). - Per il motore Claude Code: Claude Code installato e collegato. ## Avvio diff --git a/avatar/monitor.py b/avatar/monitor.py index 5a898bb..20c84c2 100644 --- a/avatar/monitor.py +++ b/avatar/monitor.py @@ -101,6 +101,30 @@ class Monitor: 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") + try: + rows = sms._query("AND m.is_from_me = 0 AND m.ROWID > ?", (last if last is not None else 10**12,), 40) if not (last is None or baseline) else [] + if last is None or baseline: + top = sms._query("", (), 1) + self.state["sms_rowid"] = top[0]["id"] if top else 0 + return [] + except Exception as err: + print(f"[monitor] messaggi: {err}") + return [] + 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 @@ -176,7 +200,8 @@ class Monitor: 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 = self._collect_mail(baseline or first) + self._collect_whatsapp(baseline or first) + self._collect_telegram(baseline or first) + items = (self._collect_mail(baseline or first) + self._collect_whatsapp(baseline or first) + + self._collect_sms(baseline or first) + self._collect_telegram(baseline or first)) seen = set(self.state.get("seen", [])) items = [it for it in items if it["ref"] not in seen] self.state.setdefault("seen", []).extend(it["ref"] for it in items) diff --git a/avatar/phonebook.py b/avatar/phonebook.py new file mode 100644 index 0000000..3850098 --- /dev/null +++ b/avatar/phonebook.py @@ -0,0 +1,60 @@ +"""Rubrica: numeri di telefono ed email → nomi, dai Contatti di macOS (cache in memoria).""" +from __future__ import annotations + +import re +import threading +import time + +_map: dict[str, str] = {} +_loaded_at = 0.0 +_lock = threading.Lock() + + +def normalize(number: str) -> str: + digits = re.sub(r"\D", "", number or "") + if digits.startswith("00"): + digits = digits[2:] + if len(digits) == 10 and digits.startswith("3"): + digits = "39" + digits + return digits + + +def _load() -> None: + global _loaded_at + import Contacts + store = Contacts.CNContactStore.alloc().init() + keys = [Contacts.CNContactGivenNameKey, Contacts.CNContactFamilyNameKey, Contacts.CNContactOrganizationNameKey, + Contacts.CNContactPhoneNumbersKey, Contacts.CNContactEmailAddressesKey] + req = Contacts.CNContactFetchRequest.alloc().initWithKeysToFetch_(keys) + new: dict[str, str] = {} + + def visit(c, stop): + name = f"{c.givenName()} {c.familyName()}".strip() or c.organizationName() + if not name: + return + for ph in c.phoneNumbers(): + d = normalize(ph.value().stringValue()) + if len(d) >= 8: + new[d] = name + for e in c.emailAddresses(): + new[str(e.value()).lower()] = name + + store.enumerateContactsWithFetchRequest_error_usingBlock_(req, None, visit) + _map.clear() + _map.update(new) + _loaded_at = time.time() + + +def name_for(handle: str) -> str: + """Nome del contatto per un numero o un'email; altrimenti il valore originale.""" + with _lock: + if time.time() - _loaded_at > 3600: + try: + _load() + except Exception: + pass + h = (handle or "").strip() + if "@" in h: + return _map.get(h.lower(), h) + d = normalize(h) + return _map.get(d) or _map.get(d[-10:]) or h diff --git a/plugins/messaggi.py b/plugins/messaggi.py index 8096e55..6e1d337 100644 --- a/plugins/messaggi.py +++ b/plugins/messaggi.py @@ -8,8 +8,13 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from avatar.macos import osa, confirm_or_param, CONFERMATO # noqa: E402 SEND = '''on run argv + set kind to item 3 of argv tell application "Messages" - set svc to 1st account whose service type = iMessage + if kind is "sms" then + set svc to 1st account whose service type = SMS + else + set svc to 1st account whose service type = iMessage + end if set b to participant (item 1 of argv) of svc send (item 2 of argv) to b end tell @@ -25,9 +30,20 @@ def invia(params: dict, ctx: dict) -> str: if "@" not in a and not a.replace("+", "").replace(" ", "").isdigit(): return "Il destinatario deve essere un numero di telefono o un'email: cerca il contatto con contatti_cerca." + servizio = str(params.get("servizio", "auto")).lower() + def do() -> str: - osa(SEND, a.replace(" ", ""), testo, timeout=30) - msg = f"Messaggio inviato a {a}." + dest = a.replace(" ", "") + try: + osa(SEND, dest, testo, "sms" if servizio == "sms" else "imessage", timeout=30) + via = "SMS" if servizio == "sms" else "iMessage" + except Exception as err: + if servizio == "auto" and "@" not in dest: + osa(SEND, dest, testo, "sms", timeout=30) + via = "SMS" + else: + raise + msg = f"Messaggio inviato a {a} via {via}." if ctx.get("say"): ctx["say"](msg) return msg @@ -36,6 +52,6 @@ def invia(params: dict, ctx: dict) -> str: TOOLS = [ - {"name": "messaggi_invia", "description": "Invia un iMessage a un numero di telefono (con prefisso) o a un'email. Azione irreversibile con conferma sullo schermo; con [CONFIRMATION_PENDING] di' all'utente di confermare e non dire che è inviato. Per i nomi usa prima contatti_cerca.", - "parameters": {"type": "object", "properties": {"a": {"type": "string"}, "testo": {"type": "string"}, "confermato": CONFERMATO}, "required": ["a", "testo"]}, "run": invia}, + {"name": "messaggi_invia", "description": "Invia un iMessage o un SMS a un numero di telefono (con prefisso) o a un'email. Azione irreversibile con conferma sullo schermo; con [CONFIRMATION_PENDING] di' all'utente di confermare e non dire che è inviato. Per i nomi usa prima contatti_cerca.", + "parameters": {"type": "object", "properties": {"a": {"type": "string"}, "testo": {"type": "string"}, "servizio": {"type": "string", "enum": ["auto", "imessage", "sms"], "description": "auto = iMessage con ripiego su SMS."}, "confermato": CONFERMATO}, "required": ["a", "testo"]}, "run": invia}, ] diff --git a/plugins/sms.py b/plugins/sms.py new file mode 100644 index 0000000..f407575 --- /dev/null +++ b/plugins/sms.py @@ -0,0 +1,115 @@ +"""SMS e iMessage ricevuti sul Mac (app Messaggi): non letti, conversazioni, ricerca.""" +from __future__ import annotations + +import re +import sqlite3 +import sys +from datetime import datetime, timedelta +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from avatar.phonebook import name_for # noqa: E402 + +DB = Path.home() / "Library" / "Messages" / "chat.db" +EPOCH = datetime(2001, 1, 1) + + +def _ts(raw) -> datetime: + if raw is None: + return EPOCH + raw = int(raw) + if raw > 10**12: # nanosecondi + raw //= 10**9 + return EPOCH + timedelta(seconds=raw) + + +def decode_body(blob: bytes | None) -> str: + """Estrae il testo da attributedBody (archivio NSAttributedString).""" + if not blob: + return "" + i = blob.find(b"NSString") + if i < 0: + return "" + j = blob.find(b"+", i) + if j < 0: + return "" + k = j + 1 + length = blob[k] + k += 1 + if length == 0x81: + length = int.from_bytes(blob[k:k + 2], "little"); k += 2 + elif length == 0x82: + length = int.from_bytes(blob[k:k + 3], "little"); k += 3 + return blob[k:k + length].decode("utf-8", errors="replace") + + +def _query(where: str, params: tuple, limit: int): + if not DB.exists(): + raise RuntimeError("Database di Messaggi non trovato.") + try: + con = sqlite3.connect(f"file:{DB}?mode=ro", uri=True, timeout=5) + except sqlite3.OperationalError as err: + raise RuntimeError("Non posso leggere Messaggi: concedi «Accesso completo al disco» all'app in Impostazioni di Sistema > Privacy e sicurezza.") from err + try: + rows = con.execute(f"""SELECT m.ROWID, m.text, m.attributedBody, m.date, m.is_from_me, m.is_read, m.service, COALESCE(h.id, ''), COALESCE(m.cache_roomnames, '') + FROM message m LEFT JOIN handle h ON h.ROWID = m.handle_id WHERE 1=1 {where} ORDER BY m.date DESC LIMIT ?""", (*params, limit)).fetchall() + except sqlite3.OperationalError as err: + if "unable to open" in str(err) or "authorization" in str(err): + raise RuntimeError("Non posso leggere Messaggi: concedi «Accesso completo al disco» all'app in Impostazioni di Sistema > Privacy e sicurezza.") from err + raise + finally: + con.close() + out = [] + for rid, text, body, date, from_me, is_read, service, handle, room in rows: + t = (text or "").strip() or decode_body(body).strip() + if not t: + continue + out.append({"id": rid, "testo": t, "data": _ts(date), "mio": bool(from_me), "letto": bool(is_read), "servizio": service or "?", + "chi": name_for(handle), "handle": handle, "gruppo": bool(room)}) + return out + + +def _fmt(rows) -> str: + return "\n".join(f"- [{r['data']:%d/%m %H:%M}] {'io → ' + r['chi'] if r['mio'] else r['chi']} ({r['servizio']}): {r['testo'][:220]}" for r in rows) + + +def non_letti(params: dict, ctx: dict) -> str: + lim = max(1, min(int(params.get("limite") or 15), 50)) + rows = _query("AND m.is_from_me = 0 AND m.is_read = 0", (), lim) + return f"Messaggi non letti ({len(rows)}):\n{_fmt(rows)}" if rows else "Nessun SMS o iMessage non letto." + + +def recenti(params: dict, ctx: dict) -> str: + lim = max(1, min(int(params.get("limite") or 15), 60)) + chi = str(params.get("contatto", "")).strip() + ore = int(params.get("ore") or 0) + where, args = "", [] + if ore: + since = int((datetime.now() - timedelta(hours=ore) - EPOCH).total_seconds()) * 10**9 + where += " AND m.date >= ?"; args.append(since) + rows = _query(where, tuple(args), 400 if chi else lim) + if chi: + q = chi.lower() + rows = [r for r in rows if q in r["chi"].lower() or q in r["handle"].lower()][:lim] + rows = rows[:lim] + if not rows: + return "Nessun messaggio trovato." + return f"Messaggi{' con ' + chi if chi else ''} (dal più recente):\n{_fmt(rows)}" + + +def cerca(params: dict, ctx: dict) -> str: + q = str(params.get("query", "")).strip().lower() + if not q: + return "Errore: serve la query." + rows = [r for r in _query("", (), 3000) if q in r["testo"].lower()][:20] + return f"Messaggi con '{q}':\n{_fmt(rows)}" if rows else f"Nessun messaggio contiene '{q}'." + + +TOOLS = [ + {"name": "sms_non_letti", "description": "SMS e iMessage non letti ricevuti sul Mac (app Messaggi), con mittente risolto dalla rubrica.", + "parameters": {"type": "object", "properties": {"limite": {"type": "integer"}}}, "run": non_letti}, + {"name": "sms_recenti", "description": "Ultimi SMS/iMessage, di tutti o di un contatto (nome o numero), opzionalmente nelle ultime N ore.", + "parameters": {"type": "object", "properties": {"contatto": {"type": "string"}, "limite": {"type": "integer"}, "ore": {"type": "integer"}}}, "run": recenti}, + {"name": "sms_cerca", "description": "Cerca una parola negli SMS/iMessage.", + "parameters": {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}, "run": cerca}, +] diff --git a/pyproject.toml b/pyproject.toml index db5dc60..0f54c28 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ dependencies = [ "faster-whisper>=1.2.1", "keyring>=25.7.0", "kokoro>=0.9.4", + "mido>=1.3.3", "mlx-whisper>=0.4.3", "numpy>=2.5.3", "openai>=3.17.0", diff --git a/uv.lock b/uv.lock index 5fa29a7..b782d0d 100644 --- a/uv.lock +++ b/uv.lock @@ -104,6 +104,7 @@ dependencies = [ { name = "faster-whisper" }, { name = "keyring" }, { name = "kokoro" }, + { name = "mido" }, { name = "mlx-whisper" }, { name = "numpy" }, { name = "openai" }, @@ -129,6 +130,7 @@ requires-dist = [ { name = "faster-whisper", specifier = ">=1.2.1" }, { name = "keyring", specifier = ">=25.7.0" }, { name = "kokoro", specifier = ">=0.9.4" }, + { name = "mido", specifier = ">=1.3.3" }, { name = "mlx-whisper", specifier = ">=0.4.3" }, { name = "numpy", specifier = ">=2.5.3" }, { name = "openai", specifier = ">=3.17.0" }, @@ -1425,6 +1427,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "mido" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/14/cfda3fe61ce4c0f50a9f707ae02b46cb53211732b2cd4522bf06272848f4/mido-1.3.3.tar.gz", hash = "sha256:1aecb30b7f282404f17e43768cbf74a6a31bf22b3b783bdd117a1ce9d22cb74c", size = 124288, upload-time = "2024-10-25T15:05:21.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/28/45deb15c11859d2f10702b32e71de9328a9fa494f989626916db39a9617f/mido-1.3.3-py3-none-any.whl", hash = "sha256:01033c9b10b049e4436fca2762194ca839b09a4334091dd3c34e7f4ae674fd8a", size = 54614, upload-time = "2024-10-25T15:05:20.349Z" }, +] + [[package]] name = "misaki" version = "0.9.4"