added ntfy support for scheduled runs

This commit is contained in:
JesseMarkowitz
2026-08-18 11:21:26 -04:00
parent b0aa05a2b1
commit 55b9ce12f6
6 changed files with 426 additions and 0 deletions
+18
View File
@@ -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
+4
View File
@@ -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.
+35
View File
@@ -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.
+101
View File
@@ -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
# ──────────────────────────────────────────────────────────────────────────────
+179
View File
@@ -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
+89
View File
@@ -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