Newer
Older
navi-1 / navi / push / service.py
"""Turn-completion web push service.

Fire-and-forget: `notify_turn_complete` schedules the fan-out as a background
task so it can never slow down or disturb the agent run that triggered it.
pywebpush is sync — every send runs in a thread.
"""

from __future__ import annotations

import asyncio
import re
import time

import structlog

log = structlog.get_logger()

_MARKDOWN_STRIP = re.compile(
    r"```.*?```|`([^`]*)`|!\[[^\]]*\]\([^)]*\)|\[([^\]]*)\]\([^)]*\)|"
    r"^\s{0,3}#{1,6}\s+|(\*\*|__|\*|_|~~)",
    re.DOTALL | re.MULTILINE,
)

_PREVIEW_MAX = 140


def _preview(content: str) -> str:
    """First ~140 chars of the answer, markdown stripped, whitespace squashed."""
    text = _MARKDOWN_STRIP.sub(lambda m: m.group(1) or m.group(2) or "", content or "")
    text = re.sub(r"\s+", " ", text).strip()
    if len(text) > _PREVIEW_MAX:
        text = text[: _PREVIEW_MAX - 1].rstrip() + "…"
    return text


class PushService:
    def __init__(self, store, settings):
        self._store = store
        self._settings = settings
        self._cooldowns: dict[str, float] = {}

    @property
    def enabled(self) -> bool:
        return bool(self._settings.navi_push_vapid_public_key
                    and self._settings.navi_push_vapid_private_key)

    @property
    def public_key(self) -> str:
        return self._settings.navi_push_vapid_public_key

    async def subscribe(self, user_id: str, endpoint: str, p256dh: str, auth: str,
                        user_agent: str | None = None) -> str:
        sub = await self._store.upsert(user_id, endpoint, p256dh, auth, user_agent)
        return sub.id

    async def unsubscribe(self, endpoint: str) -> None:
        await self._store.delete(endpoint)

    async def notify_turn_complete(
        self, session_id: str, user_id: str | None, content: str
    ) -> None:
        """Push "Navi answered" to the user's subscriptions (no-op if disabled,
        unowned session, or inside the per-session cooldown)."""
        if not self.enabled or not user_id or not (content or "").strip():
            return
        cooldown = self._settings.navi_push_cooldown_sec
        now = time.monotonic()
        last = self._cooldowns.get(session_id, 0.0)
        if now - last < cooldown:
            return
        self._cooldowns[session_id] = now

        payload = {
            "title": "Navi ответила",
            "body": _preview(content),
            "url": f"/#{session_id}",
            "session_id": session_id,
        }
        asyncio.create_task(self._send_all(user_id, payload))

    async def _send_all(self, user_id: str, payload: dict) -> None:
        try:
            subs = await self._store.list_for_user(user_id)
        except Exception:
            log.exception("push.list_failed", user_id=user_id)
            return
        if not subs:
            return
        results = await asyncio.gather(
            *(self._send_one(sub, payload) for sub in subs),
            return_exceptions=True,
        )
        sent = sum(1 for r in results if r is True)
        log.info("push.sent", user_id=user_id, total=len(subs), sent=sent)

    async def _send_one(self, sub, payload: dict) -> bool:
        """Deliver to one subscription; prune it on permanent rejection."""
        try:
            await asyncio.to_thread(
                self._webpush_sync,
                sub,
                payload,
                self._settings.navi_push_vapid_private_key,
                self._settings.navi_push_vapid_subject,
            )
        except Exception as e:
            status = getattr(e, "response", None) and getattr(e.response, "status_code", None)
            if e.__class__.__name__ == "WebPushException" and status in (404, 410):
                try:
                    await self._store.delete(sub.endpoint)
                    log.info("push.subscription_pruned", endpoint=sub.endpoint, status=status)
                except Exception:
                    log.exception("push.prune_failed")
                return False
            log.warning("push.send_failed", endpoint=sub.endpoint,
                        error=f"{type(e).__name__}: {e}")
            return False
        try:
            await self._store.mark_pushed(sub.id)
        except Exception:
            log.exception("push.mark_failed")
        return True

    @staticmethod
    def _webpush_sync(sub, payload: dict, vapid_private_key: str, vapid_subject: str) -> None:
        import json

        from pywebpush import webpush

        webpush(
            subscription_info={
                "endpoint": sub.endpoint,
                "keys": {"p256dh": sub.p256dh, "auth": sub.auth},
            },
            data=json.dumps(payload),
            vapid_private_key=vapid_private_key,
            vapid_claims={"sub": vapid_subject},
        )