"""Nowlark client. Standard library only: no requests, nothing to install. Dropped into a script that used to post to Prowl, this replaces its whole "send a notification" function. Scripts that each grew their own sender tend to disagree about timeouts, about whether a failure is logged or raised, and about whether the URL is sent. This is one sender for all of them. from nowlark import notify notify("Rewards watcher", "Gift card claimed", "$25 card, code ABC-123", url="https://example.com/rewards", priority=1) Or from a shell: python3 nowlark.py "Backup" "Finished" "412 GB in 38 minutes" python3 nowlark.py --check python3 nowlark.py --register "Backup" The key is read from NOWLARK_API_KEY. A launchd agent does not inherit the crontab's environment, so as a fallback the key is read back out of `crontab -l`: one place that knows the trick, rather than one per script. https://nowlark.com/api has everything a notification can carry. """ from __future__ import annotations import hashlib import json import os import re import subprocess import urllib.error import urllib.parse import urllib.request from typing import Optional __all__ = ["notify", "register", "check", "api_key", "NowlarkError", "NowlarkResult"] BASE_URL = os.environ.get("NOWLARK_BASE_URL", "https://nowlark.com") TIMEOUT = float(os.environ.get("NOWLARK_TIMEOUT", "20")) # Prowl's ceilings were 256 / 1024 / 10000 and Nowlark keeps them, so a payload # that fitted Prowl fits here. Longer values are clipped by the server; they are # clipped here too so the caller sees what was actually sent. MAX_SOURCE = 256 MAX_TITLE = 1024 MAX_BODY = 10000 class NowlarkError(Exception): """A refusal from the server, or an unreachable server. `code` is the machine-readable reason: invalid_key, rate_limited, invalid_request, wrong_credential, offline. """ def __init__(self, code: str, message: str, status: int = 0): super().__init__(f"{code}: {message}") self.code = code self.message = message self.status = status class NowlarkResult: """What happened to one notification.""" def __init__(self, payload: dict): self.id: Optional[str] = payload.get("id") self.duplicate: bool = bool(payload.get("duplicate")) self.muted: bool = bool(payload.get("muted")) self.delivered: int = int(payload.get("delivered") or 0) self.devices: int = int(payload.get("devices") or 0) self.remaining: int = int(payload.get("remaining") or 0) self.reset_at: float = float(payload.get("reset_at") or 0) def __bool__(self) -> bool: """True when the server accepted it. Deliberately not "a phone got it". A muted source, a duplicate, and a phone that is off are all successful calls; treating them as failures would make every scanner retry things it should not. """ return True def __repr__(self) -> str: return (f"") def api_key() -> Optional[str]: """NOWLARK_API_KEY, or the same name read back out of the crontab. Cron jobs inherit the crontab's environment block; a launchd agent does not. Rather than copy the key into a plist where it would drift, read it from the one place it is already written down. """ key = os.environ.get("NOWLARK_API_KEY") if key: return key.strip() try: out = subprocess.run(["/usr/bin/crontab", "-l"], capture_output=True, text=True, timeout=10).stdout except Exception: # noqa: BLE001 return None match = re.search(r"^\s*NOWLARK_API_KEY\s*=\s*['\"]?([^'\"\s]+)", out, re.M) return match.group(1) if match else None def _post(path: str, payload: dict, key: str) -> dict: request = urllib.request.Request( urllib.parse.urljoin(BASE_URL, path), data=json.dumps(payload).encode("utf-8"), headers={"Content-Type": "application/json", "X-Nowlark-Key": key}, method="POST", ) try: with urllib.request.urlopen(request, timeout=TIMEOUT) as response: return json.loads(response.read().decode("utf-8") or "{}") except urllib.error.HTTPError as exc: raw = exc.read().decode("utf-8", "replace") try: body = json.loads(raw) detail = body.get("detail") if isinstance(body.get("detail"), dict) else body raise NowlarkError(detail.get("error", f"http_{exc.code}"), detail.get("message", raw[:200]), exc.code) from None except (ValueError, AttributeError): raise NowlarkError(f"http_{exc.code}", raw[:200], exc.code) from None except Exception as exc: # noqa: BLE001 - DNS, TLS, timeout raise NowlarkError("offline", str(exc)) from None def notify(source: str, title: Optional[str] = None, body: Optional[str] = None, *, url: Optional[str] = None, priority: int = 0, group: Optional[str] = None, dedupe: Optional[str] = None, replace: Optional[str] = None, key: Optional[str] = None, raise_on_error: bool = False ) -> Optional[NowlarkResult]: """Send one notification. source the sender's name, shown on the phone and listed in Sources. One per script or job ("Nightly backup", "Rewards watcher"), the same on every run. Not a shared machine name: every job under one name is one row, muted together. (Was Prowl's `application`.) title the banner's headline (was `event`) body the detail (was `description`) url opened when the notification is tapped; http(s) only priority -2 to 2, Prowl's scale. 1 and 2 arrive as time-sensitive, which is what lets them through a Focus. Prowl could not do that. group notifications sharing a group stack together on the phone dedupe send the same dedupe value twice and only the first is delivered. A cron job that fires twice for one event wants this. replace a later notification from the same source with the same replace value takes this one's place, in the feed and on the Lock Screen. For a job reporting progress or state ("40%", "Done"). Returns a NowlarkResult, or None when it could not be sent and `raise_on_error` is False. Silence by default is deliberate: a scanner should not die because a notification service was briefly unreachable. """ resolved = key or api_key() if not resolved: if raise_on_error: raise NowlarkError("no_key", "NOWLARK_API_KEY is not set") return None payload = { "source": source[:MAX_SOURCE], "priority": max(-2, min(2, int(priority))), } if title: payload["title"] = title[:MAX_TITLE] if body: payload["body"] = body[:MAX_BODY] if url: payload["url"] = url if group: payload["group_key"] = group if dedupe: payload["dedupe_key"] = dedupe if replace: payload["replace_key"] = replace try: return NowlarkResult(_post("/v1/notify", payload, resolved)) except NowlarkError: if raise_on_error: raise return None def _registered_path() -> str: state = os.environ.get("XDG_STATE_HOME") or os.path.expanduser("~/.local/state") return os.path.join(state, "nowlark", "registered.json") def register(source: str, *, key: Optional[str] = None, again: bool = False, raise_on_error: bool = False) -> bool: """List this source in the app now, before it has sent anything. For a job that only speaks when something goes wrong: without this it is missing from Sources, so it cannot be muted, capped or given a tone, until the night it fails. Nothing is sent and nothing is charged. Safe to call at the top of every run. It asks the server once per name per machine and remembers the answer (in ~/.local/state/nowlark), so a source removed in the app stays removed until it actually sends again. `again=True` asks anyway. Returns True when the name is listed (now or already), False when it could not be done and `raise_on_error` is False. """ resolved = key or api_key() if not resolved: if raise_on_error: raise NowlarkError("no_key", "NOWLARK_API_KEY is not set") return False name = source.strip()[:MAX_SOURCE] # Remembered per key, so the same name on another account is asked for. tag = hashlib.sha256(resolved.encode("utf-8")).hexdigest()[:12] + ":" + name path = _registered_path() try: with open(path, encoding="utf-8") as handle: seen = set(json.load(handle)) except (OSError, ValueError, TypeError): seen = set() if tag in seen and not again: return True try: _post("/v1/sources", {"source": name}, resolved) except NowlarkError: if raise_on_error: raise return False seen.add(tag) try: os.makedirs(os.path.dirname(path), exist_ok=True) temporary = path + ".tmp" with open(temporary, "w", encoding="utf-8") as handle: json.dump(sorted(seen), handle) os.replace(temporary, path) except OSError: pass # it is listed; not remembering only means asking again next run return True def check(key: Optional[str] = None) -> dict: """Prove a key works without spending one of its hourly calls. Raises NowlarkError if the key is unknown, revoked or unreachable. """ resolved = key or api_key() if not resolved: raise NowlarkError("no_key", "NOWLARK_API_KEY is not set") request = urllib.request.Request( urllib.parse.urljoin(BASE_URL, "/v1/key/check"), headers={"X-Nowlark-Key": resolved}, method="GET", ) try: with urllib.request.urlopen(request, timeout=TIMEOUT) as response: return json.loads(response.read().decode("utf-8")) except urllib.error.HTTPError as exc: raise NowlarkError(f"http_{exc.code}", exc.read().decode("utf-8", "replace")[:200], exc.code) from None except Exception as exc: # noqa: BLE001 raise NowlarkError("offline", str(exc)) from None if __name__ == "__main__": # pragma: no cover import argparse parser = argparse.ArgumentParser(description="Send a Nowlark notification.") parser.add_argument("source", nargs="?", help="which scanner this is") parser.add_argument("title", nargs="?", default=None) parser.add_argument("body", nargs="?", default=None) parser.add_argument("--url") parser.add_argument("--priority", type=int, default=0) parser.add_argument("--group") parser.add_argument("--dedupe") parser.add_argument("--check", action="store_true", help="report on the key instead of sending") parser.add_argument("--register", action="store_true", help="list the source in the app without sending") args = parser.parse_args() if args.check: print(json.dumps(check(), indent=2)) raise SystemExit(0) if args.register: if not args.source: parser.error("--register needs a source") register(args.source, again=True, raise_on_error=True) print(f"registered {args.source!r}") raise SystemExit(0) if not args.source: parser.error("source is required unless --check is given") result = notify(args.source, args.title, args.body, url=args.url, priority=args.priority, group=args.group, dedupe=args.dedupe, raise_on_error=True) print(repr(result))