lizerk 14f1e52425
Some checks failed
Deploy production / deploy (push) Failing after 4s
fix(messages): make HTTP authoritative for chat reads
2026-09-10 11:41:59 +03:00

435 lines
17 KiB
Python

"""
Web Push отправитель для browser push notifications
"""
import json
from datetime import timedelta
from urllib.parse import urlencode
from django.conf import settings
from django.utils import timezone
from push_notifications.models import WebPushDevice
from notifications.chat_suppression import suppress_native_for_active_chat
from notifications.push_actions import (
build_push_avatar_token,
build_push_read_token,
)
from ..config import get
from ..models import NotificationDeliveryRouting, NotificationInstallation
from ..routing import APP_MAIN, APP_MESSENGER, normalize_routes, notification_category
from .base import BaseNotificationSender
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_path = avatar.name
except Exception:
return ""
if not avatar_path:
return ""
token = build_push_avatar_token(avatar_path)
query = urlencode({"token": token})
return f"{self._get_site_url()}/api/v1/notifications/push/avatar/?{query}"
def _build_title(self, notification, source_data: dict) -> str:
if notification.verb in self.CHAT_NOTIFICATION_VERBS:
chat_type = source_data.get("chat_type")
chat_name = source_data.get("chat_name")
if chat_type and chat_type != "private" and chat_name:
return str(chat_name).strip()
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
actor_name = self._get_actor_name(notification)
title = self._build_title(notification, source_data)
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 (
notification.verb in self.CHAT_NOTIFICATION_VERBS
and source_data.get("chat_type") not in (None, "private")
and actor_name
):
body = f"{actor_name}: {body}"
if 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,
"chat_name": source_data.get("chat_name"),
"chat_type": source_data.get("chat_type"),
"actor_name": actor_name,
},
}
if notification.verb in self.CHAT_NOTIFICATION_VERBS and chat_id and message_id:
try:
read_token = build_push_read_token(
notification_id=notification.id,
user_id=notification.recipient_id,
chat_id=int(chat_id),
message_id=int(message_id),
)
except (TypeError, ValueError):
read_token = ""
if read_token:
message_data["actions"] = [
{
"action": "mark-read",
"type": "button",
"title": "Прочитано",
}
]
message_data["data"].update(
{
"mark_read_url": "/api/v1/notifications/push/action/read/",
"mark_read_token": read_token,
}
)
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_native_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