From fdb954b92e1cb03a47e75c2d87a12e02b788d408 Mon Sep 17 00:00:00 2001 From: luciano Date: Wed, 23 Sep 2026 21:27:45 +0200 Subject: [PATCH] Monitor: parole chiave di esclusione dagli avvisi Co-Authored-By: Claude Fable 5.1 --- README.md | 2 ++ avatar/monitor.py | 9 ++++++ avatar/settings.py | 1 + avatar/settings_dialog.py | 3 ++ scripts/crea_app.py | 59 +++++++++++++++++++++++++++++++++++++-- 5 files changed, 71 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 8ce83eb..7eb3710 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,8 @@ Trascina un file nella zona "File upload" (o clicca per sceglierlo) e poi fai la 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. +Il campo "Escludi dagli avvisi" accetta parole o frasi separate da virgola (per esempio `newsletter, offerta, gruppo Calcetto`): i messaggi in cui compaiono, nel mittente, nel nome della chat o nel testo, vengono scartati prima della valutazione. + 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. ## Plugin: comandare il Mac a voce diff --git a/avatar/monitor.py b/avatar/monitor.py index 9f42732..fea1c2d 100644 --- a/avatar/monitor.py +++ b/avatar/monitor.py @@ -208,6 +208,15 @@ class Monitor: 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: diff --git a/avatar/settings.py b/avatar/settings.py index d2bdf46..d7891c4 100644 --- a/avatar/settings.py +++ b/avatar/settings.py @@ -35,6 +35,7 @@ DEFAULTS: dict[str, Any] = { "monitor_annuncia": True, # annuncia a voce gli avvisi "monitor_intervallo": 60, # secondi tra un controllo e l'altro "monitor_regole": "", # cosa merita attenzione (vuoto = regole predefinite) + "monitor_escludi": "", # parole/frasi (separate da virgola) che escludono un messaggio dagli avvisi } SECRET_KEYS = ("anthropic_api_key", "local_api_key", "telegram_api_hash") diff --git a/avatar/settings_dialog.py b/avatar/settings_dialog.py index 6e87bae..637acab 100644 --- a/avatar/settings_dialog.py +++ b/avatar/settings_dialog.py @@ -165,6 +165,8 @@ class SettingsDialog(QDialog): from .monitor import DEFAULT_RULES self.mon_rules = QLineEdit(s.get("monitor_regole") or ""); self.mon_rules.setPlaceholderText(DEFAULT_RULES[:110] + "…") form5.addRow("Cosa merita un avviso", self.mon_rules) + self.mon_excl = QLineEdit(s.get("monitor_escludi") or ""); self.mon_excl.setPlaceholderText("es. newsletter, offerta, gruppo Calcetto, Amazon — parole separate da virgola, cercate in mittente, chat e testo") + form5.addRow("Escludi dagli avvisi", self.mon_excl) root.addLayout(form5) btns = QHBoxLayout(); btns.addStretch() @@ -348,6 +350,7 @@ class SettingsDialog(QDialog): "monitor_annuncia": self.mon_say.isChecked(), "monitor_intervallo": int(self.mon_int.currentData() or 60), "monitor_regole": self.mon_rules.text().strip(), + "monitor_escludi": self.mon_excl.text().strip(), } if self.tg_hash.text().strip(): values["telegram_api_hash"] = self.tg_hash.text().strip() diff --git a/scripts/crea_app.py b/scripts/crea_app.py index 4c15eac..5ef3edf 100644 --- a/scripts/crea_app.py +++ b/scripts/crea_app.py @@ -53,6 +53,56 @@ def make_icon(dest_icns: Path) -> None: shutil.rmtree(iconset, ignore_errors=True) +LAUNCHER_C = r''' +// Lanciatore nativo di LuZa: avvia Python come processo figlio, così i permessi +// di macOS (microfono, disco, contatti…) concessi all'app valgono anche per Python. +#include +#include +#include +#include +#include +#include +#include +#include +extern char **environ; +int main(void) { + const char *home = getenv("HOME"); if (!home) home = "/tmp"; + char path[4096]; snprintf(path, sizeof path, "%s/.local/bin:/opt/homebrew/bin:/usr/local/bin:%s", home, getenv("PATH") ? getenv("PATH") : "/usr/bin:/bin"); + setenv("PATH", path, 1); + unsetenv("CLAUDECODE"); unsetenv("CLAUDE_CONFIG_DIR"); unsetenv("ANTHROPIC_API_KEY"); + setenv("PYTHONUNBUFFERED", "1", 1); + if (chdir(PROJECT_DIR) != 0) { perror("chdir"); return 1; } + char logdir[4096]; snprintf(logdir, sizeof logdir, "%s/Library/Logs", home); mkdir(logdir, 0755); + char logfile[4096]; snprintf(logfile, sizeof logfile, "%s/Library/Logs/" APP_NAME ".log", home); + int fd = open(logfile, O_WRONLY | O_CREAT | O_APPEND, 0644); + posix_spawn_file_actions_t fa; posix_spawn_file_actions_init(&fa); + if (fd >= 0) { posix_spawn_file_actions_adddup2(&fa, fd, 1); posix_spawn_file_actions_adddup2(&fa, fd, 2); } + char *argv[] = { PYTHON_BIN, "main.py", NULL }; + pid_t pid; int rc = posix_spawn(&pid, PYTHON_BIN, &fa, NULL, argv, environ); + if (rc != 0) { fprintf(stderr, "posix_spawn: %s\n", strerror(rc)); return 1; } + int status = 0; waitpid(pid, &status, 0); + return WIFEXITED(status) ? WEXITSTATUS(status) : 1; +} +''' + + +def build_native_launcher(dest: Path, python: Path) -> bool: + """Compila il lanciatore in C (serve clang, presente con Xcode o i Command Line Tools).""" + if not shutil.which("xcrun"): + return False + src = dest.parent / "launcher.c" + src.write_text(LAUNCHER_C.replace("PROJECT_DIR", f'"{PROJECT}"').replace("PYTHON_BIN", f'"{python}"').replace("APP_NAME", f'"{APP_NAME}"')) + # Compilatore di Xcode con ambiente pulito (le variabili di Homebrew possono rompere il link). + env = {k: v for k, v in os.environ.items() if k not in ("LDFLAGS", "CPPFLAGS", "CFLAGS", "LIBRARY_PATH", "CPATH", "SDKROOT")} + res = subprocess.run(["xcrun", "clang", "-O2", "-o", str(dest), str(src)], capture_output=True, text=True, env=env) + src.unlink(missing_ok=True) + if res.returncode != 0: + print(res.stderr[-400:]) + return False + dest.chmod(0o755) + return True + + def build(dest_dir: Path) -> Path: app = dest_dir / f"{APP_NAME}.app" shutil.rmtree(app, ignore_errors=True) @@ -62,7 +112,10 @@ def build(dest_dir: Path) -> Path: if not python.exists(): sys.exit("Ambiente .venv non trovato: esegui prima `uv sync` nella cartella del progetto.") launcher = app / "Contents" / "MacOS" / APP_NAME - launcher.write_text(f'''#!/bin/bash + if build_native_launcher(launcher, python): + print("Lanciatore nativo compilato.") + else: + launcher.write_text(f'''#!/bin/bash # Avvio di {APP_NAME}: usa l'ambiente Python del progetto. export PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH" unset CLAUDECODE CLAUDE_CONFIG_DIR ANTHROPIC_API_KEY @@ -71,7 +124,7 @@ cd "{PROJECT}" || exit 1 mkdir -p "$HOME/Library/Logs" exec "{python}" main.py >> "$HOME/Library/Logs/{APP_NAME}.log" 2>&1 ''') - launcher.chmod(0o755) + launcher.chmod(0o755) make_icon(app / "Contents" / "Resources" / f"{APP_NAME}.icns") info = { "CFBundleName": APP_NAME, "CFBundleDisplayName": APP_NAME, "CFBundleIdentifier": BUNDLE_ID, @@ -89,7 +142,7 @@ exec "{python}" main.py >> "$HOME/Library/Logs/{APP_NAME}.log" 2>&1 } with open(app / "Contents" / "Info.plist", "wb") as f: plistlib.dump(info, f) - subprocess.run(["codesign", "--force", "--deep", "--sign", "-", str(app)], check=False, capture_output=True) + subprocess.run(["codesign", "--force", "--deep", "--sign", "-", "--identifier", BUNDLE_ID, str(app)], check=False, capture_output=True) return app