"""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},
)