#!/usr/bin/env python3 """Python 3 standard-library client for the public push API.""" import json import os import re import socket import sys import time from urllib.error import HTTPError, URLError from urllib.parse import quote, urlencode, urljoin, urlsplit from urllib.request import Request, build_opener, HTTPRedirectHandler for stream in (sys.stdout, sys.stderr): if hasattr(stream, "reconfigure"): stream.reconfigure(encoding="utf-8") BASE_URL = os.environ.get("PUSH_BASE_URL", "").rstrip("/") SEND_KEY = os.environ.get("PUSH_SEND_KEY", "") class NoRedirect(HTTPRedirectHandler): def redirect_request(self, req, fp, code, msg, headers, newurl): return None OPENER = build_opener(NoRedirect()) class ApiError(Exception): def __init__(self, status, body): self.status = status self.code = body.get("code", "HTTP_ERROR") if isinstance(body, dict) else "HTTP_ERROR" message = body.get("message", f"HTTP {status}") if isinstance(body, dict) else f"HTTP {status}" super().__init__(message) def request(path, method="GET", body=None, idempotency_key=None): headers = {"Authorization": f"Bearer {SEND_KEY}", "Accept": "application/json"} data = None if body is not None: headers["Content-Type"] = "application/json" data = json.dumps(body, ensure_ascii=False).encode("utf-8") if idempotency_key: headers["X-Idempotency-Key"] = idempotency_key req = Request(urljoin(BASE_URL + "/", path.lstrip("/")), data=data, headers=headers, method=method) try: with OPENER.open(req, timeout=10) as response: raw = response.read() return json.loads(raw) if raw else {} except HTTPError as exc: try: payload = json.loads(exc.read().decode("utf-8")) except (ValueError, UnicodeDecodeError): payload = {} raise ApiError(exc.code, payload) from None def print_json(value): print(json.dumps(value, ensure_ascii=False, indent=2)) def read_fields(): raw = os.environ.get("PUSH_FIELDS_JSON", "") if not raw: return {} fields = json.loads(raw) if not isinstance(fields, dict): raise ValueError("PUSH_FIELDS_JSON must be a JSON object") return fields def is_transient(error): if isinstance(error, (TimeoutError, socket.timeout, URLError)): return True return isinstance(error, ApiError) and error.status >= 500 def send(): # One unique stable value per business event; reuse the same value and body after any later timeout. idempotency_key = os.environ.get("PUSH_IDEMPOTENCY_KEY", "") if not re.fullmatch(r"[A-Za-z0-9_.:-]{1,128}", idempotency_key): raise ValueError("Set a stable, unique PUSH_IDEMPOTENCY_KEY (1-128 allowed ASCII characters) before sending") body = { "title": os.environ.get("PUSH_TITLE", "Service update"), "desp": os.environ.get("PUSH_DESCRIPTION", "The requested task has been completed."), "fields": read_fields(), } if os.environ.get("PUSH_BUSINESS_URL"): body["url"] = os.environ["PUSH_BUSINESS_URL"] result = None for attempt in (1, 2): try: result = request("/api/send", "POST", body, idempotency_key) break except Exception as error: if not is_transient(error) or attempt == 2: if is_transient(error): raise RuntimeError("Send result is still unknown. Retain PUSH_IDEMPOTENCY_KEY and retry this same event with the unchanged body") from None raise # Retry exactly once using the original key and body. print_json({"ok": result.get("ok"), "messageId": result.get("messageId"), "status": result.get("status"), "existing": result.get("existing"), "detailUrlAvailable": bool(result.get("detailUrl"))}) message_id = result.get("messageId") if not message_id: return for _ in range(12): current = request(f"/api/messages/{quote(str(message_id), safe='')}") message = current["message"] print_json({"messageId": message.get("id"), "status": message.get("status")}) if message.get("status") in {"delivered", "failed", "partial", "uncertain"}: return time.sleep(1) def main(): if not BASE_URL or not SEND_KEY: raise ValueError("Set PUSH_BASE_URL and PUSH_SEND_KEY in the process environment") target = urlsplit(BASE_URL) local_http = target.scheme == "http" and target.hostname in {"127.0.0.1", "localhost", "::1"} if (target.scheme != "https" and not local_http) or not target.hostname or target.username or target.password or target.path not in {"", "/"} or target.query or target.fragment: raise ValueError("PUSH_BASE_URL must be a public HTTPS root URL or a loopback HTTP root URL for local testing") args = sys.argv[1:] command = args[0] if args else "quota" if command == "send": send() elif command == "quota": print_json(request("/api/quota")) elif command == "list": params = {"limit": "20"} if len(args) > 1: params["before"] = args[1] result = request("/api/messages?" + urlencode(params)) print_json({"ok": result.get("ok"), "messages": result.get("messages"), "nextCursor": result.get("nextCursor")}) elif command == "status" and len(args) > 1: print_json(request("/api/messages/" + quote(args[1], safe=""))) elif command == "detail-link" and len(args) > 1: print("Warning: the detail URL grants access to message contents; do not put it in shared logs.", file=sys.stderr) print_json(request("/api/messages/" + quote(args[1], safe="") + "/detail-link")) else: raise ValueError("Usage: python examples/python-client.py |detail-link |send>") if __name__ == "__main__": try: main() except Exception as error: # Never include request headers or the SendKey in diagnostic output. code = getattr(error, "code", "ERROR") status = getattr(error, "status", None) suffix = f" (HTTP {status})" if status else "" print(f"{code}{suffix}: {error}", file=sys.stderr) sys.exit(1)