391 lines
15 KiB
Python

"""
Web Push отправитель для browser push notifications
"""
import json
from datetime import timedelta
from django.conf import settings
from django.utils import timezone
from push_notifications.models import WebPushDevice
from .base import BaseNotificationSender
from ..config import get
from ..models import NotificationDeliveryRouting, NotificationInstallation
from ..routing import APP_MAIN, APP_MESSENGER, normalize_routes, notification_category
from notifications.chat_suppression import suppress_for_active_chat
class PushNotificationSender(BaseNotificationSender):
"""
Отправитель Web Push уведомлений через django-push-notifications.
Доставляет уведомления в браузер даже когда вкладка закрыта.
"""
CHAT_NOTIFICATION_VERBS = {
"chat_new_message",
"chat_mention",
"chat_reply",
"announcement_new_message",
}
DEFAULT_TITLE = "Новое уведомление"
def can_send(self, notification, user_preferences) -> bool:
"""Проверяет, включены ли push уведомления"""
if not user_preferences.push_enabled:
self.log_skip(notification, "push_enabled=False")
return False
return True
def _get_actor_name(self, notification) -> str:
actor = notification.actor
if not actor:
return ""
return str(actor).strip()
def _get_data_title(self, notification) -> str:
data = notification.data if isinstance(notification.data, dict) else {}
title = data.get("title")
if not isinstance(title, str):
return ""
return title.strip()
def _get_site_url(self) -> str:
site_url = getattr(settings, "SITE_URL", "").strip()
if site_url:
return site_url.rstrip("/")
if settings.ALLOWED_HOSTS and settings.ALLOWED_HOSTS[0] != "*":
protocol = (
"https" if getattr(settings, "SECURE_SSL_REDIRECT", False) else "http"
)
return f"{protocol}://{settings.ALLOWED_HOSTS[0]}"
return "http://localhost:9000"
def _get_actor_avatar_url(self, notification) -> str:
actor = notification.actor
if not actor:
return ""
try:
avatar = getattr(actor, "avatar", None)
if not avatar:
return ""
avatar_url = avatar.url
except Exception:
return ""
if not avatar_url:
return ""
if avatar_url.startswith(("http://", "https://")):
return avatar_url
if avatar_url.startswith("/"):
return f"{self._get_site_url()}{avatar_url}"
return f"{self._get_site_url()}/{avatar_url}"
def _build_title(self, notification) -> str:
if notification.verb in self.CHAT_NOTIFICATION_VERBS:
return self._get_actor_name(notification) or self.DEFAULT_TITLE
return self._get_data_title(notification) or self.DEFAULT_TITLE
def _chat_preferences(self, notification):
data = notification.data if isinstance(notification.data, dict) else {}
chat_id = data.get("chat_id")
if not chat_id or notification.verb not in self.CHAT_NOTIFICATION_VERBS:
return None
from communications.models import ChatUserSettings
return ChatUserSettings.objects.filter(
chat_id=chat_id,
user_id=notification.recipient_id,
).first()
def build_message_data(self, notification) -> dict:
"""Build a browser payload with stable chat identity and privacy flags."""
source_data = notification.data if isinstance(notification.data, dict) else {}
chat_id = source_data.get("chat_id")
message_id = source_data.get("message_id")
preferences = self._chat_preferences(notification)
show_preview = (
preferences is None or preferences.show_notification_preview
)
title = self._build_title(notification)
body = notification.description or ""
if not show_preview and notification.verb in self.CHAT_NOTIFICATION_VERBS:
title = source_data.get("chat_name") or self.DEFAULT_TITLE
body = "Новое сообщение"
elif len(body) > 300:
body = body[:297] + "..."
message_data = {
"title": title,
"head": title,
"body": body,
"url": notification.action_url or "/",
"tag": f"chat-{chat_id}" if chat_id else f"notification-{notification.id}",
"requireInteraction": False,
"silent": bool(
preferences is not None
and not preferences.notification_sound_enabled
),
"data": {
"url": notification.action_url or "/",
"notification_id": notification.id,
"chat_id": chat_id,
"message_id": message_id,
},
}
actor_icon = (
self._get_actor_avatar_url(notification)
if notification.verb in self.CHAT_NOTIFICATION_VERBS and show_preview
else ""
)
default_icon = get("PUSH_DEFAULT_ICON")
default_badge = get("PUSH_DEFAULT_BADGE")
if actor_icon:
message_data["icon"] = actor_icon
elif default_icon:
message_data["icon"] = default_icon
if default_badge:
message_data["badge"] = default_badge
return message_data
def _is_permanent_device_error(self, error: Exception) -> bool:
error_text = str(error).lower()
permanent_markers = (
"expired",
"unregistered",
"invalid subscription",
"invalidregistration",
"not found",
"gone",
"404",
"410",
"400 bad request",
)
return any(marker in error_text for marker in permanent_markers)
def _deactivate_device(self, device, reason: str) -> None:
if device.active:
device.active = False
device.save(update_fields=["active"])
NotificationInstallation.objects.filter(
push_device_id=device.id,
).update(endpoint=None, push_device_id=None)
self.logger.info(
"Push device deactivated after permanent delivery error",
extra={
"event": "notification.push.device_deactivated",
"device_id": device.id,
"reason": "permanent_delivery_error",
},
)
@staticmethod
def _push_result_succeeded(result) -> bool:
if not isinstance(result, dict):
return True
return bool(result.get("success", 0)) and not result.get("failure", 0)
def _delivery_devices(self, notification):
user = notification.recipient
active_devices = list(WebPushDevice.objects.filter(user=user, active=True))
if not active_devices:
return [], {}
devices_by_endpoint = {
device.registration_id: device for device in active_devices
}
installations = list(
NotificationInstallation.objects.filter(
user=user,
active=True,
endpoint__in=devices_by_endpoint,
)
)
represented_endpoints = {
installation.endpoint for installation in installations
}
selected = [
device
for device in active_devices
if device.registration_id not in represented_endpoints
]
fallback_by_device_id = {}
category = notification_category(notification.verb)
routing_by_group = {
str(routing.device_group_id): normalize_routes(routing.routes)
for routing in NotificationDeliveryRouting.objects.filter(
user=user,
device_group_id__in={
installation.device_group_id for installation in installations
},
)
}
grouped = {}
messenger_cutoff = timezone.now() - timedelta(
days=getattr(settings, "MESSENGER_PUSH_LEASE_DAYS", 30),
)
for installation in installations:
if (
installation.app_kind == APP_MESSENGER
and installation.last_seen_at < messenger_cutoff
):
continue
grouped.setdefault(str(installation.device_group_id), {}).setdefault(
installation.app_kind,
[],
).append(devices_by_endpoint[installation.endpoint])
for group_id, app_devices in grouped.items():
routes = routing_by_group.get(group_id, normalize_routes({}))
destinations = routes[category]
main_devices = app_devices.get(APP_MAIN, [])
messenger_devices = app_devices.get(APP_MESSENGER, [])
if APP_MAIN in destinations:
selected.extend(main_devices)
if APP_MESSENGER in destinations:
if messenger_devices:
selected.extend(messenger_devices)
if APP_MAIN not in destinations and main_devices:
for messenger_device in messenger_devices:
fallback_by_device_id[messenger_device.id] = main_devices
elif APP_MAIN not in destinations:
selected.extend(main_devices)
unique_selected = {device.id: device for device in selected}
return list(unique_selected.values()), fallback_by_device_id
def send(self, notification, **kwargs) -> bool:
"""
Отправляет Web Push уведомление.
Args:
notification: Объект Notification
**kwargs: Дополнительные параметры
Returns:
True если отправлено успешно, False иначе
"""
try:
if notification.deleted or not notification.unread:
self.log_skip(notification, "notification no longer active")
return False
if suppress_for_active_chat(notification):
self.log_skip(notification, "recipient is viewing chat")
return False
chat_preferences = self._chat_preferences(notification)
if (
chat_preferences is not None
and not chat_preferences.allows_notification(notification.verb)
):
self.log_skip(notification, "chat notification policy")
return False
devices, fallback_by_device_id = self._delivery_devices(notification)
if not devices:
self.log_skip(notification, "no active push devices")
return False
message_data = self.build_message_data(notification)
# Преобразуем в JSON-строку для send_message()
message = json.dumps(message_data)
sent_count = 0
failed_count = 0
# Отправляем через все устройства пользователя
sent_device_ids = set()
def send_to_device(device):
nonlocal sent_count, failed_count
try:
result = device.send_message(message)
if not self._push_result_succeeded(result):
failed_count += 1
device.refresh_from_db(fields=["active"])
if not device.active:
self._deactivate_device(device, "provider rejected subscription")
self.logger.warning(
"Push provider rejected delivery",
extra={
"event": "notification.push.provider_rejected",
"device_id": device.id,
"notification_id": notification.id,
},
)
return False
sent_count += 1
sent_device_ids.add(device.id)
self.logger.debug(
"Push delivered to device",
extra={
"event": "notification.push.device_delivered",
"device_id": device.id,
"browser": device.browser,
},
)
return True
except Exception as e:
failed_count += 1
# Вычисляем размер payload для диагностики
message_size = len(message.encode("utf-8"))
error_text = str(e)
self.logger.warning(
"Push delivery to device failed",
extra={
"event": "notification.push.device_failed",
"device_id": device.id,
"payload_size_bytes": message_size,
"error_type": type(e).__name__,
},
exc_info=True,
)
if self._is_permanent_device_error(e):
self._deactivate_device(device, error_text)
return False
for device in devices:
delivered = send_to_device(device)
if delivered:
continue
for fallback_device in fallback_by_device_id.get(device.id, []):
if fallback_device.id in sent_device_ids:
continue
if send_to_device(fallback_device):
break
if sent_count > 0:
self.log_success(notification, f"{sent_count} devices")
if failed_count > 0:
self.logger.warning(
"Push delivered partially",
extra={
"event": "notification.push.partially_delivered",
"notification_id": notification.id,
"sent_count": sent_count,
"failed_count": failed_count,
},
)
return True
else:
self.log_skip(
notification,
f"no successful sends ({failed_count} failures)",
)
return False
except ImportError:
self.log_skip(notification, "django-push-notifications not installed")
return False
except Exception as e:
self.log_error(notification, e, f"user_{notification.recipient.id}")
return False