added ntfy support for scheduled runs
This commit is contained in:
@@ -48,6 +48,24 @@ CLAUDE_SESSION_KEY=
|
|||||||
# touched. To never tag specific repos, list their names here (comma-separated).
|
# touched. To never tag specific repos, list their names here (comma-separated).
|
||||||
#CODEX_REPO_TAG_IGNORE=some-repo,another-repo
|
#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 ---
|
# --- Output ---
|
||||||
# Where exported Markdown files are written (default: ./exports)
|
# Where exported Markdown files are written (default: ./exports)
|
||||||
EXPORT_DIR=./exports
|
EXPORT_DIR=./exports
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Fixed
|
### 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.
|
- **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.
|
- **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.
|
- **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.
|
- **`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
|
### 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.
|
- **`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.
|
- **`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.
|
- **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.
|
||||||
|
|||||||
@@ -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
|
the last action's result. Every provider is attempted; the run still exits
|
||||||
non-zero if any failed.
|
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
|
**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
|
manifest per machine. Until then a scheduled run exits 1 with an explanation
|
||||||
rather than hanging on a prompt no one can answer.
|
rather than hanging on a prompt no one can answer.
|
||||||
|
|||||||
+101
@@ -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."
|
"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.option("--dry-run", is_flag=True, help="Show what would happen without writing or sending anything.")
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def sync(
|
def sync(
|
||||||
@@ -1241,6 +1250,7 @@ def sync(
|
|||||||
max_conversations: int | None,
|
max_conversations: int | None,
|
||||||
skip_joplin: bool,
|
skip_joplin: bool,
|
||||||
joplin_optional: bool,
|
joplin_optional: bool,
|
||||||
|
notify_flag: bool | None,
|
||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Export, then sync to Joplin — the whole archive run in one command.
|
"""Export, then sync to Joplin — the whole archive run in one command.
|
||||||
@@ -1297,6 +1307,8 @@ def sync(
|
|||||||
if counts.get("failed"):
|
if counts.get("failed"):
|
||||||
failures.append(f"{prov_name}: {counts['failed']} note(s) failed to sync")
|
failures.append(f"{prov_name}: {counts['failed']} note(s) failed to sync")
|
||||||
|
|
||||||
|
_maybe_notify(ctx, failures, notify_flag, dry_run)
|
||||||
|
|
||||||
if failures:
|
if failures:
|
||||||
err_console.print("\n[red]Sync completed with failures:[/red]")
|
err_console.print("\n[red]Sync completed with failures:[/red]")
|
||||||
for line in failures:
|
for line in failures:
|
||||||
@@ -1306,6 +1318,95 @@ def sync(
|
|||||||
console.print("\n[green]Sync complete.[/green]")
|
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
|
# list command
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
+179
@@ -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
|
||||||
@@ -535,3 +535,92 @@ class TestNonInteractiveTosGate:
|
|||||||
)
|
)
|
||||||
assert result.exit_code == 1
|
assert result.exit_code == 1
|
||||||
assert "no terminal to" in result.output
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user