250 lines
9.8 KiB
Python
250 lines
9.8 KiB
Python
"""'Your digest is ready' — a push with enough of the digest to judge it by.
|
|
|
|
The digest is generated four times a day and shown only when somebody asks for it
|
|
— a spoken "play my digest", or the button in Home Assistant. Nothing displays it
|
|
because a person walked past a screen. That makes the notification the only thing
|
|
that reaches you unprompted, and its job is exactly one decision: is this run
|
|
worth going and asking for, or does it keep until tonight?
|
|
|
|
So it pushes a short summary to ntfy after every run, readable on a phone or a
|
|
wrist (Android relays notifications to a Pebble with nothing else installed),
|
|
without opening the canvas at all.
|
|
|
|
WHAT IT SENDS, AND WHY IT COSTS NO EXTRA LLM CALL
|
|
-------------------------------------------------
|
|
Every section already produces a `narration` at `compact` detail — two to four
|
|
sentences written to be read aloud, which is exactly the register a notification
|
|
wants. The summary is those narrations, trimmed, plus the few things that are
|
|
worth knowing before you open anything: how many political to-dos this run
|
|
produced, and whether any section was withheld or came back unverified. Nothing
|
|
here is generated: it is assembled from the documents the run already wrote, so a
|
|
notification can never claim something the digest itself does not say.
|
|
|
|
WHO GETS WHAT
|
|
-------------
|
|
The per-person section settings (see preferences.py) decide the content of the
|
|
push, not just the canvas. Somebody who has switched the political section off
|
|
does not get political content pushed to their phone — that would make the
|
|
setting a lie in the one place it is most visible.
|
|
|
|
People are grouped by their own ntfy topic (identity's `notify_topic`, the same
|
|
one arrival notifications use). Each topic gets the union of the sections wanted
|
|
by the people who share it: a topic IS its audience, so two people sharing one
|
|
have already agreed to share what arrives on it. Anyone without a topic of their
|
|
own falls back to `NTFY_TOPIC`, the household topic — and with no preferences
|
|
known at all (identity down, nobody registered), one message about everything
|
|
generated goes there.
|
|
|
|
NETWORK PLACEMENT
|
|
-----------------
|
|
This POSTs to the self-hosted ntfy already running in this compose stack for
|
|
`chores` and `identity` — container to container, never near the firewall. Getting
|
|
it onto a phone away from home is the WireGuard split tunnel described in
|
|
docs/network-integration.md §2.2, not a port forward. Nothing new is exposed.
|
|
|
|
Best-effort throughout: a failed push logs a warning. The digest is already
|
|
written by the time this runs, and no notification is worth failing a run over.
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
from urllib.parse import urlencode
|
|
|
|
import requests
|
|
|
|
LOG = logging.getLogger(__name__)
|
|
|
|
HTTP_TIMEOUT = 10
|
|
|
|
# How much of a section's narration survives into the push. ntfy shows a few lines
|
|
# before truncating, and a Pebble shows fewer still — the point is to be enough to
|
|
# judge by, not to be the digest.
|
|
MAX_SECTION_CHARS = 220
|
|
MAX_BODY_CHARS = 1400
|
|
|
|
SECTION_LABELS = {
|
|
"personal": "Social",
|
|
"political": "Political",
|
|
"household": "Household",
|
|
"network": "Network",
|
|
}
|
|
|
|
# ntfy renders these as icons next to the title.
|
|
SECTION_TAGS = {
|
|
"personal": "speech_balloon",
|
|
"political": "newspaper",
|
|
"household": "house",
|
|
"network": "shield",
|
|
}
|
|
|
|
|
|
def _trim(text, limit):
|
|
text = " ".join(str(text or "").split())
|
|
if len(text) <= limit:
|
|
return text
|
|
# Cut at a sentence end when there is one in reach, so the push doesn't stop
|
|
# mid-clause; otherwise at a word boundary.
|
|
window = text[:limit]
|
|
for stop in (". ", "! ", "? "):
|
|
cut = window.rfind(stop)
|
|
if cut > limit * 0.5:
|
|
return window[: cut + 1].strip()
|
|
cut = window.rfind(" ")
|
|
return (window[:cut] if cut > 0 else window).rstrip() + "…"
|
|
|
|
|
|
def _todo_count(document):
|
|
"""How many political to-dos this run produced, or None if there is no todo
|
|
window. Counted from the window's own content rather than inferred from prose —
|
|
a number in a notification has to be a number the digest actually contains."""
|
|
for window in document.get("windows") or []:
|
|
if str(window.get("id")) != "political-todo":
|
|
continue
|
|
content = window.get("content")
|
|
if isinstance(content, list):
|
|
return len(content)
|
|
# A todo window that came back as prose still means "there are some".
|
|
return 1 if str(content or "").strip() else 0
|
|
return None
|
|
|
|
|
|
def _section_lines(documents, sections):
|
|
"""One line per section, in the order the canvas lays them out."""
|
|
by_section = {
|
|
document.get("section"): document
|
|
for document in documents.get("compact") or []
|
|
if isinstance(document, dict)
|
|
}
|
|
|
|
lines = {}
|
|
for section in sections:
|
|
document = by_section.get(section)
|
|
if not document:
|
|
continue
|
|
|
|
label = SECTION_LABELS.get(section, section.title())
|
|
if document.get("withheld"):
|
|
lines[section] = f"{label}: withheld — nothing could be verified against its sources."
|
|
continue
|
|
|
|
text = _trim(document.get("narration"), MAX_SECTION_CHARS)
|
|
if not text:
|
|
# A section with no narration still ran; saying so is more useful than
|
|
# leaving a gap the reader has to interpret.
|
|
text = "generated, no narration."
|
|
if document.get("degraded"):
|
|
text = f"unavailable — {text}"
|
|
elif document.get("unverified"):
|
|
text = f"[unverified] {text}"
|
|
|
|
if section == "political":
|
|
todos = _todo_count(document)
|
|
if todos:
|
|
text = f"{todos} todo{'s' if todos != 1 else ''}. {text}"
|
|
|
|
lines[section] = f"{label}: {text}"
|
|
return lines
|
|
|
|
|
|
def _click_url(person_name):
|
|
"""Where the notification takes you when tapped: this person's own digest."""
|
|
base = os.environ.get("DIGEST_WEB_URL", "").strip().rstrip("/")
|
|
if not base:
|
|
return None
|
|
if not person_name:
|
|
return f"{base}/full.html"
|
|
return f"{base}/full.html?{urlencode({'person': person_name})}"
|
|
|
|
|
|
def _audiences(prefs, sections, default_topic):
|
|
"""topic -> (sections that topic may see, the names behind it).
|
|
|
|
Grouping rather than one message per person: without it a household where
|
|
nobody has set a personal topic would get one identical push per registered
|
|
person, four times a day, which is how a useful notification becomes one people
|
|
turn off.
|
|
"""
|
|
if prefs is None:
|
|
return {default_topic: (list(sections), [])} if default_topic else {}
|
|
|
|
grouped = {}
|
|
for person in prefs["people"]:
|
|
topic = (person.get("notify_topic") or default_topic or "").strip()
|
|
wanted = [section for section in sections if section in person["digest_sections"]]
|
|
if not topic:
|
|
LOG.info("notify: %s has no ntfy topic and no household default, skipping", person["name"])
|
|
continue
|
|
if not wanted:
|
|
continue
|
|
visible, names = grouped.setdefault(topic, (set(), []))
|
|
visible.update(wanted)
|
|
names.append(person["name"])
|
|
|
|
return {
|
|
topic: ([section for section in sections if section in visible], names)
|
|
for topic, (visible, names) in grouped.items()
|
|
}
|
|
|
|
|
|
def _post(base_url, topic, title, body, tags, click):
|
|
"""Published as JSON to ntfy's root rather than as text to /<topic>.
|
|
|
|
The header-based form (`Title:`, `Tags:`) is what `chores` uses and is fine for
|
|
its plain-English nudges, but HTTP headers are latin-1: a title or a name with
|
|
an em dash, an umlaut or a euro sign in it raises UnicodeEncodeError before the
|
|
request is even sent. This digest quotes news headlines and household member
|
|
names, so that would have failed on real content and worked on every test that
|
|
used ASCII. The JSON body is UTF-8 throughout.
|
|
"""
|
|
payload = {"topic": topic, "title": title, "message": body}
|
|
if tags:
|
|
payload["tags"] = tags
|
|
if click:
|
|
payload["click"] = click
|
|
response = requests.post(base_url, json=payload, timeout=HTTP_TIMEOUT)
|
|
response.raise_for_status()
|
|
|
|
|
|
def send(context, documents, sections, prefs):
|
|
"""Pushes one summary per audience. Never raises; returns how many went out."""
|
|
if os.environ.get("ENABLE_DIGEST_NOTIFY", "true").strip().lower() != "true":
|
|
return 0
|
|
|
|
base_url = os.environ.get("NTFY_URL", "").strip().rstrip("/")
|
|
if not base_url:
|
|
LOG.info("notify: NTFY_URL is not set, not pushing a digest notification")
|
|
return 0
|
|
|
|
default_topic = os.environ.get("NTFY_TOPIC", "").strip()
|
|
audiences = _audiences(prefs, sections, default_topic)
|
|
if not audiences:
|
|
LOG.info("notify: nobody to notify about this run")
|
|
return 0
|
|
|
|
lines = _section_lines(documents, sections)
|
|
slot = str(context.get("slot_hour", "")).zfill(2)
|
|
sent = 0
|
|
|
|
for topic, (visible, names) in audiences.items():
|
|
body = "\n".join(lines[section] for section in visible if section in lines)
|
|
if not body:
|
|
continue
|
|
if len(body) > MAX_BODY_CHARS:
|
|
body = body[:MAX_BODY_CHARS].rstrip() + "…"
|
|
|
|
# One name in the click-through: a shared topic has no single owner, so it
|
|
# gets the unfiltered page rather than an arbitrary person's.
|
|
click = _click_url(names[0] if len(names) == 1 else None)
|
|
labels = [SECTION_LABELS.get(section) or str(section) for section in visible]
|
|
title = f"Digest {slot}:00 — {', '.join(labels)}"
|
|
tags = [SECTION_TAGS[section] for section in visible if section in SECTION_TAGS]
|
|
|
|
try:
|
|
_post(base_url, topic, title, body, tags, click)
|
|
sent += 1
|
|
LOG.info("notify: pushed the digest summary to %r (%s)", topic, ", ".join(visible))
|
|
except Exception:
|
|
LOG.warning("notify: could not push to %r", topic, exc_info=True)
|
|
|
|
return sent
|