"""Cairn's phone pushes, through ntfy (Jacob's phone already runs the ntfy app for the printers). What is pushed, and when (server.py runs check() every 30 s; `cairn tick` calls back_on_list()): - 07:00: what's due today and what's late, one push, or none when nothing is. Sent once a day, up to noon if the server was down at seven. - At a card's due time (a due: with a time): its title, with a Done button. Up to 2 h late if the server was down. - When a Later card's day comes: "Back on the list". ⚠ An ntfy.sh topic can be read by anyone who knows its name, so the name is long and random and lives only in notify.env (gitignored, mode 600). A #client card's title never goes in a push, the same rule as bambu-p1s' NTFY_DISCREET. With no notify.env, nothing is sent and nothing fails. """ import datetime as dt, json, os, urllib.request from pathlib import Path import board as B ROOT = Path(__file__).resolve().parent ENV = ROOT / "notify.env" STATE = B.BOARD / ".pushed.json" # what was already sent: gitignored APP_URL = "https://board.your-tailnet.ts.net:8443" DIGEST_AT, DIGEST_UNTIL = dt.time(7, 0), dt.time(12, 0) LATE_LIMIT = dt.timedelta(hours=2) def config(): out = {} if ENV.exists(): for line in ENV.read_text().splitlines(): key, eq, value = line.partition("=") if eq and not key.strip().startswith("#"): out[key.strip()] = value.strip().strip('"') return out def send(title, body, priority=3, tags=(), click=None, actions=None): cfg = config() if not cfg.get("NTFY_TOPIC"): return False msg = {"topic": cfg["NTFY_TOPIC"], "title": title, "message": body, "priority": priority, "tags": list(tags)} if click: msg["click"] = click if actions: msg["actions"] = actions req = urllib.request.Request(cfg.get("NTFY_SERVER", "https://ntfy.sh"), data=json.dumps(msg).encode(), headers={"Content-Type": "application/json"}) with urllib.request.urlopen(req, timeout=15): return True def shown(card): """A card's title as a push may show it.""" return "Client task" if card.cat == "client" else card.title def done_button(card): """ntfy's http action: the phone itself POSTs the op to the board over the tailnet. The opid is fixed per card and due time, so a second tap changes nothing.""" body = {"ops": [{"opid": f"push-done-{card.id}-{card.due}", "op": "done", "id": card.id}], "who": "push"} return {"action": "http", "label": "Done", "url": f"{APP_URL}/api/sync", "method": "POST", "headers": {"Content-Type": "application/json"}, "body": json.dumps(body), "clear": True} def open_cards(board): return board.columns["Doing"] + board.columns["Todo"] def digest(board, today): """(title, body) for the morning push, or None when nothing is due or late.""" day = today.isoformat() late = [c for c in open_cards(board) if c.due and c.due[:10] < day] due = [c for c in open_cards(board) if c.due and c.due[:10] == day] if not late and not due: return None lines = [f"late {c.due[5:10]} · {shown(c)}" for c in sorted(late, key=lambda c: c.due)] lines += [f"{c.due[11:] or 'today'} · {shown(c)}" for c in sorted(due, key=lambda c: c.due)] if len(lines) > 8: lines = lines[:7] + [f"… {len(lines) - 7} more"] doing = len(board.columns["Doing"]) if doing: lines.append(f"{doing} in hand") title = ", ".join(p for p in (f"{len(due)} due today" if due else "", f"{len(late)} late" if late else "") if p) return title, "\n".join(lines) def timed_due(board, now): """Cards whose due time has come in the last LATE_LIMIT.""" out = [] for c in open_cards(board): if len(c.due) > 10: at = dt.datetime.fromisoformat(c.due) if at <= now < at + LATE_LIMIT: out.append(c) return out def check(now=None): """The server's 30-second check: the morning digest, then any due time that has come. Never raises.""" now = now or dt.datetime.now().replace(second=0, microsecond=0) try: state = json.loads(STATE.read_text()) if STATE.exists() else {} except ValueError: state = {} sent = set(state.get("due", [])) try: board = B.load() if DIGEST_AT <= now.time() < DIGEST_UNTIL and state.get("digest") != now.date().isoformat(): d = digest(board, now.date()) if not d or send(d[0], d[1], 3, ["spiral_calendar"], APP_URL): state["digest"] = now.date().isoformat() for c in timed_due(board, now): key = f"{c.id}@{c.due}" if key not in sent and send(shown(c), f"due {c.due[11:]}", 4, ["alarm_clock"], f"{APP_URL}/#{c.id}", [done_button(c)]): sent.add(key) except Exception as e: # a failed push must never take the server down; it tries again in 30 s print(f"notify: {e}", flush=True) state["due"] = sorted(sent)[-500:] tmp = STATE.with_suffix(".part") tmp.write_text(json.dumps(state)) tmp.replace(STATE) def back_on_list(cards): for c in cards: try: send("Back on the list", shown(c), 3, ["leftwards_arrow_with_hook"], f"{APP_URL}/#{c.id}") except Exception as e: print(f"notify: {e}", flush=True)