diff --git a/.env.example b/.env.example index 300d2c9..fdf226a 100644 --- a/.env.example +++ b/.env.example @@ -48,6 +48,24 @@ CLAUDE_SESSION_KEY= # touched. To never tag specific repos, list their names here (comma-separated). #CODEX_REPO_TAG_IGNORE=some-repo,another-repo +# --- Notifications (ntfy) --- +# Push the result of a run to ntfy so an unattended archive reports back — the +# log file, the systemd journal and Task Scheduler's exit code are all pull-only. +# Unset NTFY_TOPIC disables notifications entirely. +#NTFY_TOPIC=my-archive-topic +# +# Self-hosting? Point at your own server. +#NTFY_SERVER=https://ntfy.sh +# +# Bearer token, for access-controlled topics. A topic on public ntfy.sh is +# readable by anyone who knows its name — notifications therefore carry counts +# and a machine name only, never conversation titles. +#NTFY_TOKEN= +# +# always (default) — notify on every run; failure — only when something failed; +# off — never. +#NTFY_NOTIFY=always + # --- Output --- # Where exported Markdown files are written (default: ./exports) EXPORT_DIR=./exports diff --git a/CHANGELOG.md b/CHANGELOG.md index c22c965..ba5694a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] ### Fixed +- **An em dash in a notification title silently dropped the notification.** HTTP header values are latin-1 at best and `requests` raises on anything outside it, so the first real send failed with `'latin-1' codec can't encode character '\u2014'`. Header values are now flattened to ASCII (smart punctuation mapped to its plain equivalent); the body is unaffected, being sent as UTF-8 bytes. Found by sending a test push rather than by reading the code. - **The terms-of-service gate exited 0 without a terminal.** `click.prompt` raises `Abort` on a closed stdin, which the handler treated as a user Ctrl-C and exited 0 — so a scheduled run on a machine that had never acknowledged the notice would report success having archived nothing. Non-interactive invocations now exit 1 with an explanation of how to clear the gate once by hand. Found by running the new systemd unit rather than by reading the code. - **A single U+0085 in a transcript silently dropped a whole record.** Both local providers split session files with `str.splitlines()`, which breaks not just on `\n` but on U+0085 (NEL), U+2028 and U+2029 — all of which are legal *inside* a JSON string and are written literally by Codex (Rust does not escape non-ASCII). One NEL in captured command output shredded one record into unparseable fragments; the parser logged "skipped 3 unparseable line(s)" and lost the record. Found while exporting a real rollout. Both providers now split on `\n` only, and both have regression tests that write their fixtures with `ensure_ascii=False` — with `json.dumps`' default the hazardous characters are escaped and the bug cannot reproduce. - **Deleted uploads are no longer reported as permission errors.** ChatGPT's `/backend-api/files/{id}/download` answers a *missing* asset with `403 {"detail":"Forbidden"}`, which reads like an auth failure and isn't one. Measured live 2026-08-17 across 18 such assets: every one returned `404 {"detail":"File not found"}` on `/files/{id}`, while assets that downloaded fine returned 200 on both in the same session, and `ChatGPT-Account-Id` made no difference. A 403 is now confirmed against the metadata endpoint before being reported (one extra request on the failure path only, none on success) and a confirmed-missing asset is logged as gone and counted as `expired-or-missing`. A 403 on an asset that *does* still exist is left alone as `forbidden` — that one would be a real problem. @@ -14,6 +15,9 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - **`tests/test_config.py::TestSessionLimiterConfig::test_defaults` depended on the developer's `.env`.** `load_config()` calls `load_dotenv(override=False)`, which re-populated the variable the test had just deleted — so it passed only on a machine with no `.env`. The test now stubs dotenv discovery. ### Added +- **ntfy push notifications for unattended runs (`NTFY_TOPIC`).** A scheduled archive reports only to places you have to go and look at — the log file, the systemd journal, Task Scheduler's exit code — so a run that quietly failed every morning would stay quiet. `sync` now pushes its result: success carries per-provider counts at low/default priority, failure carries the reason at high priority with an alert tag, so the two are distinguishable at a glance on a phone. `ai-chat-exporter notify` shows the settings and `--test` sends a test push. `NTFY_NOTIFY=failure` limits it to failures, `off` disables, `--notify`/`--no-notify` override per run. Never fatal: an unreachable ntfy logs a warning and the run still reports its real exit code. + + The payload is **counts and a machine name only, never conversation titles** — a topic on public ntfy.sh is readable by anyone who knows its name, and a title is the first line of what you asked. The machine name is included because several machines archive into one topic, where "2 exported" means nothing on its own. `NTFY_SERVER` points at a self-hosted instance and `NTFY_TOKEN` authenticates against an access-controlled topic. - **`ai-chat-exporter` / `ai-chat-exporter.cmd` launchers — no virtualenv ceremony.** `cd` into the repo and run; the wrapper creates `.venv`, installs dependencies on first run, and reinstalls when `pyproject.toml` changes. A fresh clone goes from nothing to a working command in one step (measured: ~10s), on Linux/macOS and Windows alike, which matters for a tool meant to run on several machines. `cmd.exe` searches the current directory before `PATH`, so Windows needs no `.\` prefix. The working directory is deliberately not changed — `.env`, `cache/` and `exports/` still resolve against it, which is what lets one checkout archive different machines into different places — but the wrapper now warns when you run it from elsewhere, because a different `cache/manifest.json` silently starts a *second* archive rather than failing. - **`sync` command — `export` then `joplin` in one invocation, with a real exit code.** Intended for schedulers (and the "trivial add-on" FUTURE.md §7 anticipated): it exits non-zero if any conversation failed to export or any note failed to sync, so a scheduled run that achieved nothing is distinguishable from one that had nothing to do. `--skip-joplin` exports only; `--joplin-optional` downgrades an unreachable Joplin to a warning, since the export has already captured the local transcripts and the notes rebuild from the cache on the next run that finds Joplin up. - **Daily scheduling for both platforms.** `scheduling/install-systemd-timer.sh` (systemd user timer, `Persistent=true` so a machine that was off catches up at boot) and `scheduling/Register-AiChatSyncTask.ps1` (per-user Task Scheduler entry, `-StartWhenAvailable`). `--provider` is repeatable in both, because the right set differs per machine: the local providers need no credentials and always work unattended, while a web provider whose session token has expired would fail the job every single day and train you to ignore it. diff --git a/README.md b/README.md index 8ad581c..6ded433 100644 --- a/README.md +++ b/README.md @@ -378,6 +378,41 @@ providers in a single action rather than one action each, because systemd the last action's result. Every provider is attempted; the run still exits non-zero if any failed. +### Getting notified + +A scheduled run is silent by default. Output goes to three pull-only places: the +exporter's own log (`cache/logs/exporter.log`), the systemd journal on Linux +(`journalctl --user -u aichat-sync.service`), and Task Scheduler's +`LastTaskResult` on Windows. + +To have runs report back, set an [ntfy](https://ntfy.sh) topic in `.env`: + +```bash +NTFY_TOPIC=my-archive-topic +``` + +Then check it works before waiting on a scheduled run: + +```bash +ai-chat-exporter notify # show settings +ai-chat-exporter notify --test # send a test push +``` + +`sync` pushes a result whenever a topic is configured — a success carries the +per-provider counts at low/default priority, a failure carries the reason at +high priority with an alert tag, so a failed archive is distinguishable from a +quiet one on your phone. `NTFY_NOTIFY=failure` notifies only on failure; `off` +disables it; `--notify` / `--no-notify` override per run. + +The message includes the **machine name**, which matters because both machines +archive into one topic. It contains counts only — never conversation titles. A +topic on public ntfy.sh is readable by anyone who knows its name, so if you want +it private, self-host (`NTFY_SERVER`) or use an access-controlled topic with +`NTFY_TOKEN`. + +A notification is never fatal: if ntfy is unreachable, the run logs a warning and +still reports its real exit code. + **Acknowledge the ToS notice once, interactively.** It's stored in the cache manifest per machine. Until then a scheduled run exits 1 with an explanation rather than hanging on a prompt no one can answer. diff --git a/src/main.py b/src/main.py index 4da2ad3..51327be 100644 --- a/src/main.py +++ b/src/main.py @@ -1231,6 +1231,15 @@ def _print_export_summary(summary: dict[str, dict[str, int]]) -> None: "scheduled runs, where Joplin desktop may simply not be open yet." ), ) +@click.option( + "--notify/--no-notify", + "notify_flag", + default=None, + help=( + "Send an ntfy push with the result. Defaults to on whenever NTFY_TOPIC " + "is configured (see NTFY_NOTIFY to notify only on failure)." + ), +) @click.option("--dry-run", is_flag=True, help="Show what would happen without writing or sending anything.") @click.pass_context def sync( @@ -1241,6 +1250,7 @@ def sync( max_conversations: int | None, skip_joplin: bool, joplin_optional: bool, + notify_flag: bool | None, dry_run: bool, ) -> None: """Export, then sync to Joplin — the whole archive run in one command. @@ -1297,6 +1307,8 @@ def sync( if counts.get("failed"): failures.append(f"{prov_name}: {counts['failed']} note(s) failed to sync") + _maybe_notify(ctx, failures, notify_flag, dry_run) + if failures: err_console.print("\n[red]Sync completed with failures:[/red]") for line in failures: @@ -1306,6 +1318,95 @@ def sync( console.print("\n[green]Sync complete.[/green]") +def _maybe_notify( + ctx: click.Context, failures: list[str], notify_flag: bool | None, dry_run: bool +) -> None: + """Push the run result to ntfy, if configured. Never raises.""" + from src import notify as notify_mod + + if dry_run: + return + # --notify/--no-notify overrides; otherwise notify whenever a topic is set. + if notify_flag is False: + return + if notify_flag is None and not notify_mod.is_configured(): + return + if notify_flag is None and ( + notify_mod.resolve_notify_policy() == notify_mod.NOTIFY_FAILURE and not failures + ): + return + + title, message, tags, priority = notify_mod.format_summary( + ctx.obj.get("last_export_summary"), + ctx.obj.get("last_joplin_summary"), + failures, + ) + notify_mod.send(title, message, tags=tags, priority=priority) + + +# ────────────────────────────────────────────────────────────────────────────── +# notify command +# ────────────────────────────────────────────────────────────────────────────── + + +@cli.command() +@click.option("--test", "send_test", is_flag=True, help="Send a test notification now.") +@click.pass_context +def notify(ctx: click.Context, send_test: bool) -> None: + """Show ntfy notification settings, and optionally send a test push. + + Notifications are how an unattended run reports back: the log file, the + systemd journal and Task Scheduler's exit code are all pull-only. + """ + from src import notify as notify_mod + + topic = os.getenv("NTFY_TOPIC", "").strip() + server = (os.getenv("NTFY_SERVER", "").strip() or notify_mod.DEFAULT_SERVER).rstrip("/") + policy = notify_mod.resolve_notify_policy() + has_token = bool(os.getenv("NTFY_TOKEN", "").strip()) + + table = Table(title="Notification settings") + table.add_column("Setting", style="bold") + table.add_column("Value") + table.add_row("NTFY_TOPIC", topic or "[dim]not set — notifications disabled[/dim]") + table.add_row("NTFY_SERVER", server) + table.add_row("NTFY_NOTIFY", policy) + table.add_row("NTFY_TOKEN", "set" if has_token else "[dim]not set[/dim]") + table.add_row("This machine", notify_mod.machine_name()) + console.print(table) + + if not topic: + console.print( + "\nSet [bold]NTFY_TOPIC[/bold] in .env to enable push notifications, " + "then re-run with --test." + ) + return + + if not has_token and server == notify_mod.DEFAULT_SERVER: + console.print( + "\n[yellow]Note:[/yellow] a topic on public ntfy.sh is readable by " + "anyone who knows its name. Notifications carry counts and a machine " + "name only — never conversation titles." + ) + + if send_test: + ok = notify_mod.send( + f"AI archive test - {notify_mod.machine_name()}", + "Test notification from ai-chat-exporter. If you can read this, " + "scheduled runs will reach you here.", + tags="test_tube", + priority="default", + ) + if ok: + console.print(f"\n[green]Sent to {server}/{topic}[/green]") + else: + err_console.print( + f"\n[red]Could not send to {server}/{topic}[/red] — see the log " + "for the reason (run with --debug for detail)." + ) + sys.exit(1) + + # ────────────────────────────────────────────────────────────────────────────── # list command # ────────────────────────────────────────────────────────────────────────────── diff --git a/src/notify.py b/src/notify.py new file mode 100644 index 0000000..b8a4ed9 --- /dev/null +++ b/src/notify.py @@ -0,0 +1,179 @@ +"""Push notifications for unattended runs, via ntfy (https://ntfy.sh). + +A scheduled archive is invisible by design: the exporter logs to +``cache/logs/exporter.log``, systemd logs to the journal and Task Scheduler +records an exit code, but all three are *pull* — you only learn a run failed by +going to look. This module pushes a one-line result instead. + +Configured entirely from the environment (see ``.env.example``): + + NTFY_TOPIC topic name; unset disables notifications entirely + NTFY_SERVER default https://ntfy.sh — set to your own host if self-hosting + NTFY_TOKEN optional bearer token, for access-controlled topics + NTFY_NOTIFY always (default) | failure | off + +**Payload is deliberately counts-only.** A public ntfy topic is readable by +anyone who guesses the name — there is no per-topic secret unless you add +NTFY_TOKEN against a server that enforces it. Conversation titles are sensitive +(they are the first line of what you asked), so nothing but provider names and +numbers ever goes into a notification. The machine name is included because two +machines archive into one topic and "2 exported" is meaningless without knowing +which box it came from. + +Never fatal: notification is a courtesy, not part of the archive. Every failure +path here logs a warning and returns False, so a down ntfy server can't fail a +run that actually captured your data. +""" + +import logging +import os +import socket + +import requests + +logger = logging.getLogger(__name__) + +DEFAULT_SERVER = "https://ntfy.sh" + +NOTIFY_ALWAYS = "always" +NOTIFY_FAILURE = "failure" +NOTIFY_OFF = "off" +VALID_NOTIFY_POLICIES = {NOTIFY_ALWAYS, NOTIFY_FAILURE, NOTIFY_OFF} + +# ntfy request timeout (connect, read). Short: a notification is never worth +# holding a scheduled run open for. +_TIMEOUT = (5, 10) + + +def resolve_notify_policy() -> str: + """Read NTFY_NOTIFY from the environment, defaulting to ``always``.""" + value = os.getenv("NTFY_NOTIFY", "").strip().lower() + if not value: + return NOTIFY_ALWAYS + if value not in VALID_NOTIFY_POLICIES: + logger.warning( + "NTFY_NOTIFY=%r is invalid (expected always|failure|off) — using 'always'.", + value, + ) + return NOTIFY_ALWAYS + return value + + +def is_configured() -> bool: + """True when a topic is set and the policy isn't ``off``.""" + return bool(os.getenv("NTFY_TOPIC", "").strip()) and resolve_notify_policy() != NOTIFY_OFF + + +def machine_name() -> str: + """Short hostname — two machines share one topic, so this is load-bearing.""" + try: + return socket.gethostname().split(".")[0] or "unknown-host" + except Exception: + return "unknown-host" + + +def _ascii_header(value: str) -> str: + """Reduce a header value to plain ASCII. + + HTTP headers are latin-1 at best, and requests raises on anything outside + it — an em dash in a title is enough to lose the notification entirely + (observed 2026-08-18). The body has no such limit: it is sent as UTF-8 + bytes, so only header values need flattening. + """ + replacements = {"\u2014": "-", "\u2013": "-", "\u2018": "'", "\u2019": "'", + "\u201c": '"', "\u201d": '"', "\u2026": "..."} + for bad, good in replacements.items(): + value = value.replace(bad, good) + return value.encode("ascii", "replace").decode("ascii") + + +def send( + title: str, + message: str, + tags: str = "", + priority: str = "", +) -> bool: + """POST a notification to ntfy. Returns True on success, never raises. + + ``tags`` is a comma-separated list of ntfy tag names (emoji shortcodes are + rendered as emoji); ``priority`` is one of min/low/default/high/urgent. + """ + topic = os.getenv("NTFY_TOPIC", "").strip() + if not topic: + return False + + server = (os.getenv("NTFY_SERVER", "").strip() or DEFAULT_SERVER).rstrip("/") + url = f"{server}/{topic}" + + headers = {"Title": _ascii_header(title)} + if tags: + headers["Tags"] = _ascii_header(tags) + if priority: + headers["Priority"] = _ascii_header(priority) + token = os.getenv("NTFY_TOKEN", "").strip() + if token: + headers["Authorization"] = f"Bearer {token}" + + try: + resp = requests.post( + url, + data=message.encode("utf-8"), + headers=headers, + timeout=_TIMEOUT, + ) + resp.raise_for_status() + logger.debug("[notify] Sent to %s", url) + return True + except Exception as e: + # Deliberately broad: a notification must never fail an archive run. + logger.warning("[notify] Could not send to %s: %s", url, e) + return False + + +def format_summary( + export_summary: dict | None, + joplin_summary: dict | None, + failures: list[str] | None, +) -> tuple[str, str, str, str]: + """Build ``(title, message, tags, priority)`` for a completed sync run. + + Counts only — see the module docstring on why no titles appear here. + """ + failures = failures or [] + host = machine_name() + lines: list[str] = [] + + total_exported = 0 + for prov, counts in (export_summary or {}).items(): + exported = counts.get("exported", 0) + skipped = counts.get("skipped", 0) + failed = counts.get("failed", 0) + total_exported += exported + line = f"{prov}: {exported} exported, {skipped} up to date" + if failed: + line += f", {failed} FAILED" + lines.append(line) + + synced = sum( + counts.get("created", 0) + counts.get("updated", 0) + for counts in (joplin_summary or {}).values() + ) + if joplin_summary: + lines.append(f"joplin: {synced} note(s) created/updated") + + if failures: + lines.append("") + lines.extend(failures) + title = f"AI archive FAILED - {host}" + tags = "rotating_light" + priority = "high" + else: + title = f"AI archive OK - {host}" + # A run that captured something is more interesting than a quiet one. + tags = "white_check_mark" if total_exported else "zzz" + priority = "default" if total_exported else "low" + + if not lines: + lines = ["nothing to do"] + + return title, "\n".join(lines), tags, priority diff --git a/tests/test_cli.py b/tests/test_cli.py index 6233794..020a491 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -535,3 +535,92 @@ class TestNonInteractiveTosGate: ) assert result.exit_code == 1 assert "no terminal to" in result.output + + +# --------------------------------------------------------------------------- +# ntfy notifications +# --------------------------------------------------------------------------- + + +class TestNotifyFormatting: + """The payload must stay counts-only: a public ntfy topic is world-readable.""" + + def test_success_summary(self): + from src.notify import format_summary + + title, body, tags, priority = format_summary( + {"codex": {"exported": 2, "skipped": 5, "failed": 0}}, None, [] + ) + assert title.startswith("AI archive OK") + assert "codex: 2 exported, 5 up to date" in body + assert tags == "white_check_mark" + assert priority == "default" + + def test_quiet_run_is_low_priority(self): + from src.notify import format_summary + + _, _, tags, priority = format_summary( + {"codex": {"exported": 0, "skipped": 7, "failed": 0}}, None, [] + ) + assert tags == "zzz" + assert priority == "low" + + def test_failure_summary_is_high_priority(self): + from src.notify import format_summary + + title, body, tags, priority = format_summary( + {"chatgpt": {"exported": 0, "skipped": 0, "failed": 12}}, + None, + ["chatgpt: 12 conversation(s) failed to export"], + ) + assert "FAILED" in title + assert "12 FAILED" in body + assert tags == "rotating_light" + assert priority == "high" + + def test_hostname_present(self): + """Two machines share one topic — counts are meaningless without it.""" + from src.notify import format_summary, machine_name + + title, _, _, _ = format_summary({}, None, []) + assert machine_name() in title + + +class TestNotifyHeaderEncoding: + """HTTP headers are latin-1; an em dash in a title loses the notification.""" + + def test_smart_punctuation_flattened(self): + from src.notify import _ascii_header + + out = _ascii_header("AI archive — don’t “fail”…") + assert out == 'AI archive - don\'t "fail"...' + out.encode("ascii") # must not raise + + def test_arbitrary_unicode_survives_as_ascii(self): + from src.notify import _ascii_header + + _ascii_header("héllo — 世界").encode("ascii") + + +class TestNotifySend: + def test_no_topic_is_a_no_op(self, monkeypatch): + from src import notify as notify_mod + + monkeypatch.delenv("NTFY_TOPIC", raising=False) + assert notify_mod.send("t", "m") is False + assert notify_mod.is_configured() is False + + def test_network_failure_never_raises(self, monkeypatch): + """A down ntfy server must not fail a run that captured data.""" + from src import notify as notify_mod + + monkeypatch.setenv("NTFY_TOPIC", "unit-test-topic") + monkeypatch.setenv("NTFY_SERVER", "http://127.0.0.1:9") + assert notify_mod.send("t", "m") is False + + def test_off_policy_disables(self, monkeypatch): + from src import notify as notify_mod + + monkeypatch.setenv("NTFY_TOPIC", "unit-test-topic") + monkeypatch.setenv("NTFY_NOTIFY", "off") + assert notify_mod.is_configured() is False