from __future__ import annotations import json import logging from typing import Any import aiosqlite from pywebpush import WebPushException, webpush from server.db import utc_now_iso from server.settings import settings logger = logging.getLogger(__name__) async def save_subscription(db: aiosqlite.Connection, sub: dict[str, Any]) -> None: keys = sub.get("keys", {}) await db.execute( """ INSERT INTO push_subscription (endpoint, p256dh, auth, created_at) VALUES (?, ?, ?, ?) ON CONFLICT(endpoint) DO UPDATE SET p256dh=excluded.p256dh, auth=excluded.auth """, (sub["endpoint"], keys.get("p256dh", ""), keys.get("auth", ""), utc_now_iso()), ) await db.commit() async def get_subscriptions(db: aiosqlite.Connection) -> list[dict[str, str]]: cur = await db.execute("SELECT endpoint, p256dh, auth FROM push_subscription") rows = await cur.fetchall() await cur.close() return [{"endpoint": r[0], "p256dh": r[1], "auth": r[2]} for r in rows] def _vapid_claims() -> dict[str, str]: return {"sub": settings.vapid_claims_email} def _endpoint_label(endpoint: str) -> str: return endpoint[:60] + ("..." if len(endpoint) > 60 else "") async def send_push( db: aiosqlite.Connection, payload: dict[str, Any], ) -> int: tag = payload.get("tag", "?") slot_id = payload.get("slot_id", "?") if not settings.vapid_private_key or not settings.vapid_public_key: logger.warning("Push skipped slot=%s tag=%s reason=vapid_not_configured", slot_id, tag) return 0 subs = await get_subscriptions(db) if not subs: logger.warning("Push skipped slot=%s tag=%s reason=no_subscriptions", slot_id, tag) return 0 sent = 0 failed = 0 dead: list[str] = [] logger.info( "Push start slot=%s tag=%s subscriptions=%d title=%r", slot_id, tag, len(subs), payload.get("title"), ) for sub in subs: endpoint = sub["endpoint"] subscription = { "endpoint": endpoint, "keys": {"p256dh": sub["p256dh"], "auth": sub["auth"]}, } try: webpush( subscription_info=subscription, data=json.dumps(payload), vapid_private_key=settings.vapid_private_key, vapid_claims=_vapid_claims(), ) sent += 1 logger.info("Push ok slot=%s endpoint=%s", slot_id, _endpoint_label(endpoint)) except WebPushException as exc: status = exc.response.status_code if exc.response else None if status in (404, 410): dead.append(endpoint) logger.info( "Push dead slot=%s endpoint=%s status=%s", slot_id, _endpoint_label(endpoint), status, ) else: failed += 1 logger.warning( "Push failed slot=%s endpoint=%s status=%s error=%s", slot_id, _endpoint_label(endpoint), status, exc, ) for endpoint in dead: await db.execute("DELETE FROM push_subscription WHERE endpoint = ?", (endpoint,)) if dead: await db.commit() logger.info("Push removed %d dead subscription(s)", len(dead)) logger.info( "Push done slot=%s tag=%s sent=%d failed=%d dead=%d total=%d", slot_id, tag, sent, failed, len(dead), len(subs), ) return sent async def send_slot_reminder( db: aiosqlite.Connection, slot_id: str, title: str, body: str, *, silent: bool = True, ) -> int: payload = { "title": title, "body": body, "slot_id": slot_id, "silent": silent, "tag": f"med-{slot_id}", } return await send_push(db, payload)