diff --git a/backend/communications/api/viewsets.py b/backend/communications/api/viewsets.py index 23c9ec33..217e84d2 100644 --- a/backend/communications/api/viewsets.py +++ b/backend/communications/api/viewsets.py @@ -65,11 +65,14 @@ from ..mentions import ( update_unread_mention_counters, ) from ..polls import PollActionError, cast_poll_vote -from ..services import soft_delete_message, update_chat_notification_preferences +from ..services import ( + ChatReadMarkerService, + soft_delete_message, + update_chat_notification_preferences, +) from ..utils import _coerce_ts, user_can_access_chat from communications.serialization import ( serialize_message, - serialize_message_reader, serialize_poll, ) @@ -705,97 +708,10 @@ class ChatViewSet(viewsets.ModelViewSet): if not messages: return - last_message = messages[-1] - - read_state, created = ChatReadState.objects.get_or_create( + ChatReadMarkerService().mark_read( chat=chat, user=user, - defaults={ - "last_read_message": last_message, - "unread_count": 0, # Прочитали → обнуляем - "unread_mention_count": 0, - }, - ) - - if created: - logger.info( - "Chat read marker created", - extra={ - "event": "chat.read_marker.created", - "user_id": user.id, - "chat_id": chat.id, - "message_id": last_message.id, - }, - ) - self._send_marked_read_event(user, chat.id, last_message.id) - return - - # Защита от откатов: только если НОВЕЕ - if ( - read_state.last_read_message_id - and last_message.id <= read_state.last_read_message_id - ): - logger.debug( - "Chat read marker update skipped", - extra={ - "event": "chat.read_marker.update_skipped", - "user_id": user.id, - "chat_id": chat.id, - "message_id": last_message.id, - "current_message_id": read_state.last_read_message_id, - "reason": "marker_not_newer", - }, - ) - return - - read_state.last_read_message = last_message - read_state.unread_count = 0 # Прочитали → обнуляем - read_state.unread_mention_count = 0 - read_state.save( - update_fields=[ - "last_read_message", - "unread_count", - "unread_mention_count", - "updated_at", - ] - ) - - logger.info( - "Chat read marker updated", - extra={ - "event": "chat.read_marker.updated", - "user_id": user.id, - "chat_id": chat.id, - "message_id": last_message.id, - }, - ) - self._send_marked_read_event(user, chat.id, last_message.id) - - def _send_marked_read_event(self, user, chat_id, message_id): - """WebSocket событие для синхронизации и read receipts.""" - channel_layer = get_channel_layer() - reader = serialize_message_reader(user) - event_payload = { - "chat_id": chat_id, - "last_read_message_id": message_id, - "reader_user_id": user.id, - "reader": reader, - } - - async_to_sync(channel_layer.group_send)( - f"user_{user.id}", - { - "type": "chat_marked_read_sync", - **event_payload, - }, - ) - - async_to_sync(channel_layer.group_send)( - f"chat_{chat_id}", - { - "type": "chat_marked_read", - **event_payload, - }, + message=messages[-1], ) @action(detail=True, methods=["get"]) diff --git a/backend/communications/notifications/handlers.py b/backend/communications/notifications/handlers.py index 6641bf41..a5cddd99 100644 --- a/backend/communications/notifications/handlers.py +++ b/backend/communications/notifications/handlers.py @@ -140,6 +140,8 @@ def notify_message_mentions( "title": MessageTemplates.mention(author_name), "chat_id": chat.id, "chat_name": get_chat_name(chat), + "chat_type": chat.type, + "can_reply": chat.can_reply, "message_id": message.id, "author_id": author.id, "mentioned": True, @@ -218,6 +220,8 @@ def notify_new_message(message): "title": MessageTemplates.reply(author_name), "chat_id": chat.id, "chat_name": get_chat_name(chat), + "chat_type": chat.type, + "can_reply": chat.can_reply, "message_id": message.id, "reply_to_id": message.reply_to.id, "author_id": author.id, @@ -256,6 +260,8 @@ def notify_new_message(message): metadata = { "chat_id": chat.id, "chat_name": get_chat_name(chat), + "chat_type": chat.type, + "can_reply": chat.can_reply, "message_id": message.id, "author_id": author.id, } diff --git a/backend/communications/services/__init__.py b/backend/communications/services/__init__.py index 10f9dfa8..73f39292 100644 --- a/backend/communications/services/__init__.py +++ b/backend/communications/services/__init__.py @@ -1,10 +1,15 @@ from .chat_notifications import ChatNotificationReadResult, ChatNotificationReadService +from .chat_quick_reply import ChatQuickReplyError, ChatQuickReplyService +from .chat_read_marker import ChatReadMarkerService from .chat_notification_preferences import update_chat_notification_preferences from .message_deletion import soft_delete_message __all__ = [ 'ChatNotificationReadResult', 'ChatNotificationReadService', + 'ChatQuickReplyError', + 'ChatQuickReplyService', + 'ChatReadMarkerService', 'update_chat_notification_preferences', 'soft_delete_message', ] diff --git a/backend/communications/services/chat_quick_reply.py b/backend/communications/services/chat_quick_reply.py new file mode 100644 index 00000000..c4b4cfe7 --- /dev/null +++ b/backend/communications/services/chat_quick_reply.py @@ -0,0 +1,53 @@ +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer +import rules + +from communications.models import Message +from communications.serialization import serialize_message +from communications.utils import user_can_access_chat + + +class ChatQuickReplyError(Exception): + """A native push reply cannot be sent to the requested chat.""" + + +class ChatQuickReplyService: + """Create and broadcast a plain-text reply received from native push UI.""" + + MAX_CONTENT_LENGTH = 4000 + + def send(self, *, chat, user, reply_to, content: str) -> Message: + normalized_content = content.strip() + if not normalized_content: + raise ChatQuickReplyError("empty reply") + if len(normalized_content) > self.MAX_CONTENT_LENGTH: + raise ChatQuickReplyError("reply too long") + if not chat.can_reply: + raise ChatQuickReplyError("replies are disabled") + if not user_can_access_chat(chat, user) or not rules.test_rule( + "communications.send_message", + user, + chat, + ): + raise ChatQuickReplyError("sending is not allowed") + + message = Message.objects.create( + chat=chat, + author=user, + content=normalized_content, + reply_to=reply_to, + ) + self._broadcast(message) + return message + + @staticmethod + def _broadcast(message: Message) -> None: + payload = serialize_message(message) + async_to_sync(get_channel_layer().group_send)( + f"chat_{message.chat_id}", + { + "type": "chat.message", + "chat_id": message.chat_id, + "payload": payload, + }, + ) diff --git a/backend/communications/services/chat_read_marker.py b/backend/communications/services/chat_read_marker.py new file mode 100644 index 00000000..82629f38 --- /dev/null +++ b/backend/communications/services/chat_read_marker.py @@ -0,0 +1,83 @@ +import logging + +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer + +from communications.models import ChatReadState +from communications.serialization import serialize_message_reader + + +logger = logging.getLogger(__name__) + + +class ChatReadMarkerService: + """Advance a user's chat read marker and notify every active client.""" + + def mark_read(self, *, chat, user, message) -> bool: + read_state, created = ChatReadState.objects.get_or_create( + chat=chat, + user=user, + defaults={ + "last_read_message": message, + "unread_count": 0, + "unread_mention_count": 0, + }, + ) + + if not created: + if ( + read_state.last_read_message_id + and message.id <= read_state.last_read_message_id + ): + return False + + read_state.last_read_message = message + read_state.unread_count = 0 + read_state.unread_mention_count = 0 + read_state.save( + update_fields=[ + "last_read_message", + "unread_count", + "unread_mention_count", + "updated_at", + ] + ) + + logger.info( + "Chat read marker advanced", + extra={ + "event": "chat.read_marker.advanced", + "user_id": user.id, + "chat_id": chat.id, + "message_id": message.id, + "read_state_created": created, + }, + ) + self._broadcast(user=user, chat_id=chat.id, message_id=message.id) + return True + + @staticmethod + def _broadcast(*, user, chat_id: int, message_id: int) -> None: + channel_layer = get_channel_layer() + reader = serialize_message_reader(user) + event_payload = { + "chat_id": chat_id, + "last_read_message_id": message_id, + "reader_user_id": user.id, + "reader": reader, + } + + async_to_sync(channel_layer.group_send)( + f"user_{user.id}", + { + "type": "chat_marked_read_sync", + **event_payload, + }, + ) + async_to_sync(channel_layer.group_send)( + f"chat_{chat_id}", + { + "type": "chat_marked_read", + **event_payload, + }, + ) diff --git a/backend/notifications/api/urls.py b/backend/notifications/api/urls.py index 60e1c2a6..3008d9ab 100644 --- a/backend/notifications/api/urls.py +++ b/backend/notifications/api/urls.py @@ -33,6 +33,9 @@ urlpatterns = [ path("preferences/", views.channel_preferences, name="preferences"), # Web Push интеграция path("push/vapid-key/", views.get_vapid_public_key, name="vapid_key"), + path("push/action/read/", views.push_mark_chat_read, name="push_mark_chat_read"), + path("push/action/reply/", views.push_reply_to_chat, name="push_reply_to_chat"), + path("push/avatar/", views.push_avatar, name="push_avatar"), path("push/subscribe/", views.subscribe_push, name="push_subscribe"), path("push/unsubscribe/", views.unsubscribe_push, name="push_unsubscribe"), path( diff --git a/backend/notifications/api/views.py b/backend/notifications/api/views.py index 99a0f9d5..f0406145 100644 --- a/backend/notifications/api/views.py +++ b/backend/notifications/api/views.py @@ -8,16 +8,34 @@ API endpoints для системы уведомлений v2.0 - UserChannelPreferences для настроек каналов """ from datetime import datetime +from io import BytesIO import uuid from drf_spectacular.utils import OpenApiParameter, extend_schema +from django.core import signing +from django.core.files.storage import default_storage +from django.db import transaction from django.db.models import Count, Q +from django.http import HttpResponse from django.utils import timezone +from PIL import Image, ImageDraw, ImageOps, UnidentifiedImageError from rest_framework import status -from rest_framework.decorators import api_view, permission_classes -from rest_framework.permissions import IsAuthenticated +from rest_framework.decorators import ( + api_view, + authentication_classes, + permission_classes, +) +from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response +from communications.models import Chat, Message +from communications.services import ( + ChatNotificationReadService, + ChatQuickReplyError, + ChatQuickReplyService, + ChatReadMarkerService, +) + from ..models import ( Notification, NotificationDeliveryRouting, @@ -36,6 +54,11 @@ from ..realtime import ( send_notifications_read_all_event, ) from ..push_lifecycle import dismiss_notifications_push +from ..push_actions import ( + load_push_avatar_token, + load_push_read_token, + load_push_reply_token, +) from .serializers import ( ChannelPreferencesSerializer, CountResponseSerializer, @@ -632,6 +655,238 @@ def get_vapid_public_key(request): }) +@api_view(['POST']) +@authentication_classes([]) +@permission_classes([AllowAny]) +def push_mark_chat_read(request): + """Mark the pushed chat message as read using a short-lived signed token.""" + token = request.data.get('token') + if not isinstance(token, str) or not token: + return Response( + {'status': 'error', 'message': 'token is required'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + payload = load_push_read_token(token) + except signing.SignatureExpired: + return Response( + {'status': 'error', 'message': 'token expired'}, + status=status.HTTP_410_GONE, + ) + except signing.BadSignature: + return Response( + {'status': 'error', 'message': 'invalid token'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + notification = Notification.objects.select_related('recipient').get( + id=int(payload['notification_id']), + recipient_id=int(payload['user_id']), + deleted=False, + ) + chat = Chat.objects.get(id=int(payload['chat_id'])) + message = Message.objects.get( + id=int(payload['message_id']), + chat=chat, + is_deleted=False, + ) + except ( + KeyError, + TypeError, + ValueError, + Notification.DoesNotExist, + Chat.DoesNotExist, + Message.DoesNotExist, + ): + return Response( + {'status': 'error', 'message': 'push target not found'}, + status=status.HTTP_404_NOT_FOUND, + ) + + notification_data = ( + notification.data if isinstance(notification.data, dict) else {} + ) + if ( + str(notification_data.get('chat_id')) != str(chat.id) + or str(notification_data.get('message_id')) != str(message.id) + ): + return Response( + {'status': 'error', 'message': 'push target mismatch'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + ChatReadMarkerService().mark_read( + chat=chat, + user=notification.recipient, + message=message, + ) + read_result = ChatNotificationReadService().mark_chat_notifications_as_read( + user=notification.recipient, + chat_id=chat.id, + ) + return Response({ + 'status': 'success', + 'chat_id': chat.id, + 'message_id': message.id, + 'notifications_read': read_result.count, + }) + + +@api_view(['POST']) +@authentication_classes([]) +@permission_classes([AllowAny]) +def push_reply_to_chat(request): + """Send a one-time reply entered in Chromium's native notification UI.""" + token = request.data.get('token') + content = request.data.get('reply') + if not isinstance(token, str) or not token: + return Response( + {'status': 'error', 'message': 'token is required'}, + status=status.HTTP_400_BAD_REQUEST, + ) + if not isinstance(content, str) or not content.strip(): + return Response( + {'status': 'error', 'message': 'reply is required'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + payload = load_push_reply_token(token) + except signing.SignatureExpired: + return Response( + {'status': 'error', 'message': 'token expired'}, + status=status.HTTP_410_GONE, + ) + except signing.BadSignature: + return Response( + {'status': 'error', 'message': 'invalid token'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + with transaction.atomic(): + notification = ( + Notification.objects.select_for_update() + .select_related('recipient') + .get( + id=int(payload['notification_id']), + recipient_id=int(payload['user_id']), + deleted=False, + ) + ) + chat = Chat.objects.get(id=int(payload['chat_id'])) + source_message = Message.objects.get( + id=int(payload['message_id']), + chat=chat, + is_deleted=False, + ) + notification_data = ( + notification.data if isinstance(notification.data, dict) else {} + ) + if ( + str(notification_data.get('chat_id')) != str(chat.id) + or str(notification_data.get('message_id')) + != str(source_message.id) + ): + return Response( + {'status': 'error', 'message': 'push target mismatch'}, + status=status.HTTP_400_BAD_REQUEST, + ) + if notification_data.get('push_reply_message_id'): + return Response( + {'status': 'error', 'message': 'reply already sent'}, + status=status.HTTP_409_CONFLICT, + ) + + reply_message = ChatQuickReplyService().send( + chat=chat, + user=notification.recipient, + reply_to=source_message, + content=content, + ) + notification.data = { + **notification_data, + 'push_reply_message_id': reply_message.id, + } + notification.save(update_fields=['data']) + except ( + KeyError, + TypeError, + ValueError, + Notification.DoesNotExist, + Chat.DoesNotExist, + Message.DoesNotExist, + ): + return Response( + {'status': 'error', 'message': 'push target not found'}, + status=status.HTTP_404_NOT_FOUND, + ) + except ChatQuickReplyError: + return Response( + {'status': 'error', 'message': 'reply is not allowed'}, + status=status.HTTP_403_FORBIDDEN, + ) + + ChatReadMarkerService().mark_read( + chat=chat, + user=notification.recipient, + message=source_message, + ) + ChatNotificationReadService().mark_chat_notifications_as_read( + user=notification.recipient, + chat_id=chat.id, + ) + return Response( + { + 'status': 'success', + 'chat_id': chat.id, + 'message_id': reply_message.id, + 'reply_to_id': source_message.id, + }, + status=status.HTTP_201_CREATED, + ) + + +@api_view(['GET']) +@authentication_classes([]) +@permission_classes([AllowAny]) +def push_avatar(request): + """Return a signed avatar as a transparent circular PNG for native push UI.""" + token = request.query_params.get('token', '') + try: + payload = load_push_avatar_token(token) + path = payload['path'] + if not isinstance(path, str) or not path: + raise signing.BadSignature('missing path') + + with default_storage.open(path, 'rb') as avatar_file: + with Image.open(avatar_file) as source: + source = ImageOps.exif_transpose(source).convert('RGBA') + image = ImageOps.fit( + source, + (192, 192), + method=Image.Resampling.LANCZOS, + ) + except signing.SignatureExpired: + return Response(status=status.HTTP_410_GONE) + except (signing.BadSignature, KeyError, OSError, UnidentifiedImageError): + return Response(status=status.HTTP_404_NOT_FOUND) + + mask = Image.new('L', image.size, 0) + ImageDraw.Draw(mask).ellipse( + (0, 0, image.width - 1, image.height - 1), + fill=255, + ) + image.putalpha(mask) + output = BytesIO() + image.save(output, format='PNG', optimize=True) + response = HttpResponse(output.getvalue(), content_type='image/png') + response['Cache-Control'] = 'public, max-age=2592000, immutable' + return response + + @extend_schema( tags=["Notifications"], summary="Подписаться на Web Push", diff --git a/backend/notifications/push_actions.py b/backend/notifications/push_actions.py new file mode 100644 index 00000000..13f39ab8 --- /dev/null +++ b/backend/notifications/push_actions.py @@ -0,0 +1,79 @@ +from django.core import signing + + +PUSH_READ_SALT = "notifications.push.read.v1" +PUSH_REPLY_SALT = "notifications.push.reply.v1" +PUSH_AVATAR_SALT = "notifications.push.avatar.v1" +PUSH_READ_MAX_AGE_SECONDS = 7 * 24 * 60 * 60 +PUSH_REPLY_MAX_AGE_SECONDS = 24 * 60 * 60 +PUSH_AVATAR_MAX_AGE_SECONDS = 30 * 24 * 60 * 60 + + +def build_push_read_token( + *, + notification_id: int, + user_id: int, + chat_id: int, + message_id: int, +) -> str: + return signing.dumps( + { + "notification_id": notification_id, + "user_id": user_id, + "chat_id": chat_id, + "message_id": message_id, + }, + salt=PUSH_READ_SALT, + compress=True, + ) + + +def load_push_read_token(token: str) -> dict: + return signing.loads( + token, + salt=PUSH_READ_SALT, + max_age=PUSH_READ_MAX_AGE_SECONDS, + ) + + +def build_push_reply_token( + *, + notification_id: int, + user_id: int, + chat_id: int, + message_id: int, +) -> str: + return signing.dumps( + { + "notification_id": notification_id, + "user_id": user_id, + "chat_id": chat_id, + "message_id": message_id, + }, + salt=PUSH_REPLY_SALT, + compress=True, + ) + + +def load_push_reply_token(token: str) -> dict: + return signing.loads( + token, + salt=PUSH_REPLY_SALT, + max_age=PUSH_REPLY_MAX_AGE_SECONDS, + ) + + +def build_push_avatar_token(path: str) -> str: + return signing.dumps( + {"path": path}, + salt=PUSH_AVATAR_SALT, + compress=True, + ) + + +def load_push_avatar_token(token: str) -> dict: + return signing.loads( + token, + salt=PUSH_AVATAR_SALT, + max_age=PUSH_AVATAR_MAX_AGE_SECONDS, + ) diff --git a/backend/notifications/senders/push.py b/backend/notifications/senders/push.py index 898c943a..73a6926e 100644 --- a/backend/notifications/senders/push.py +++ b/backend/notifications/senders/push.py @@ -4,6 +4,7 @@ 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 @@ -14,6 +15,11 @@ 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 +from notifications.push_actions import ( + build_push_avatar_token, + build_push_read_token, + build_push_reply_token, +) class PushNotificationSender(BaseNotificationSender): @@ -72,20 +78,22 @@ class PushNotificationSender(BaseNotificationSender): avatar = getattr(actor, "avatar", None) if not avatar: return "" - avatar_url = avatar.url + avatar_path = avatar.name except Exception: return "" - if not avatar_url: + if not avatar_path: 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}" + 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) -> str: + 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 @@ -112,12 +120,20 @@ class PushNotificationSender(BaseNotificationSender): preferences is None or preferences.show_notification_preview ) - title = self._build_title(notification) + 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 len(body) > 300: + 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 = { @@ -136,9 +152,60 @@ class PushNotificationSender(BaseNotificationSender): "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"), + "can_reply": source_data.get("can_reply", True), + "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), + ) + reply_token = build_push_reply_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 = "" + reply_token = "" + + if read_token and reply_token: + actions = [] + if show_preview and source_data.get("can_reply", True): + actions.append({ + "action": "reply", + "type": "text", + "title": "Ответить", + "placeholder": "Введите ответ…", + }) + actions.append({ + "action": "mark-read", + "type": "button", + "title": "Прочитано", + }) + message_data["actions"] = actions + message_data["data"].update({ + "mark_read_url": "/api/v1/notifications/push/action/read/", + "mark_read_token": read_token, + }) + if show_preview and source_data.get("can_reply", True): + message_data["data"].update({ + "reply_url": "/api/v1/notifications/push/action/reply/", + "reply_token": reply_token, + }) + actor_icon = ( self._get_actor_avatar_url(notification) if notification.verb in self.CHAT_NOTIFICATION_VERBS and show_preview diff --git a/backend/tests/api/v1/communications/test_communications_api.py b/backend/tests/api/v1/communications/test_communications_api.py index a87d1cb5..c312baa6 100644 --- a/backend/tests/api/v1/communications/test_communications_api.py +++ b/backend/tests/api/v1/communications/test_communications_api.py @@ -381,7 +381,8 @@ class TestChatViewSet: user2, ): """Read receipt содержит данные для обновления списка читателей без API-запроса.""" - from communications.api import viewsets as communications_viewsets + from communications.services import chat_read_marker + from communications.services import ChatReadMarkerService sent_events = [] @@ -390,20 +391,20 @@ class TestChatViewSet: return None monkeypatch.setattr( - communications_viewsets, + chat_read_marker, "get_channel_layer", lambda: FakeChannelLayer(), ) monkeypatch.setattr( - communications_viewsets, + chat_read_marker, "async_to_sync", lambda _callback: lambda group, event: sent_events.append((group, event)), ) - communications_viewsets.ChatViewSet()._send_marked_read_event( - user2, - private_chat.id, - 321, + ChatReadMarkerService._broadcast( + user=user2, + chat_id=private_chat.id, + message_id=321, ) expected_reader = { diff --git a/backend/tests/api/v1/notifications/test_notifications_api.py b/backend/tests/api/v1/notifications/test_notifications_api.py index 655f8a4b..9e862cba 100644 --- a/backend/tests/api/v1/notifications/test_notifications_api.py +++ b/backend/tests/api/v1/notifications/test_notifications_api.py @@ -1,12 +1,18 @@ +from io import BytesIO from unittest.mock import patch import pytest +from django.core.files.base import ContentFile from django.core.cache import cache from django.contrib.contenttypes.models import ContentType from django.db import connection +from django.test import override_settings from django.test.utils import CaptureQueriesContext +from PIL import Image +from rest_framework.test import APIRequestFactory from push_notifications.models import WebPushDevice +from communications.models import Chat, ChatMembership, ChatReadState, Message from notifications.cache import unread_summary_cache_key from notifications.models import ( Notification, @@ -14,11 +20,163 @@ from notifications.models import ( NotificationInstallation, UserChannelPreferences, ) +from notifications.push_actions import ( + build_push_avatar_token, + build_push_read_token, + build_push_reply_token, +) +from notifications.api.views import ( + push_avatar, + push_mark_chat_read, + push_reply_to_chat, +) pytestmark = pytest.mark.django_db +class TestPushNotificationActions: + @patch("communications.services.chat_read_marker.ChatReadMarkerService._broadcast") + def test_signed_push_action_marks_chat_and_notifications_read( + self, + broadcast, + user_factory, + ): + recipient = user_factory(email="push-read-recipient@example.com") + actor = user_factory(email="push-read-actor@example.com") + chat = Chat.objects.create(type="group", name="Рабочая группа", created_by=actor) + message = Message.objects.create(chat=chat, author=actor, content="Готово") + notification = Notification.objects.create( + recipient=recipient, + actor_content_type=ContentType.objects.get_for_model(actor), + actor_object_id=actor.id, + verb="chat_new_message", + description=message.content, + action_url=f"/messages/{chat.id}", + data={"chat_id": chat.id, "message_id": message.id}, + ) + token = build_push_read_token( + notification_id=notification.id, + user_id=recipient.id, + chat_id=chat.id, + message_id=message.id, + ) + + request = APIRequestFactory().post( + "/push/action/read/", {"token": token}, format="json" + ) + response = push_mark_chat_read(request) + + assert response.status_code == 200 + notification.refresh_from_db() + assert notification.unread is False + read_state = ChatReadState.objects.get(chat=chat, user=recipient) + assert read_state.last_read_message_id == message.id + assert read_state.unread_count == 0 + broadcast.assert_called_once() + + def test_push_read_action_rejects_invalid_token(self): + request = APIRequestFactory().post( + "/push/action/read/", {"token": "invalid"}, format="json" + ) + response = push_mark_chat_read(request) + + assert response.status_code == 400 + + @patch("communications.notifications.signals.notify_new_message") + @patch("communications.services.chat_read_marker.ChatReadMarkerService._broadcast") + @patch("communications.services.chat_quick_reply.ChatQuickReplyService._broadcast") + def test_signed_native_reply_sends_message_once( + self, + reply_broadcast, + read_broadcast, + notify_new_message, + user_factory, + ): + recipient = user_factory(email="push-reply-recipient@example.com") + actor = user_factory(email="push-reply-actor@example.com") + chat = Chat.objects.create(type="group", name="Рабочая группа", created_by=actor) + ChatMembership.objects.create(chat=chat, user=recipient, role="member") + source_message = Message.objects.create( + chat=chat, + author=actor, + content="Какой статус?", + ) + notify_new_message.reset_mock() + notification = Notification.objects.create( + recipient=recipient, + actor_content_type=ContentType.objects.get_for_model(actor), + actor_object_id=actor.id, + verb="chat_new_message", + description=source_message.content, + action_url=f"/messages/{chat.id}", + data={"chat_id": chat.id, "message_id": source_message.id}, + ) + token = build_push_reply_token( + notification_id=notification.id, + user_id=recipient.id, + chat_id=chat.id, + message_id=source_message.id, + ) + + request = APIRequestFactory().post( + "/push/action/reply/", + {"token": token, "reply": " Уже готово "}, + format="json", + ) + response = push_reply_to_chat(request) + + assert response.status_code == 201 + reply = Message.objects.get(id=response.data["message_id"]) + assert reply.author == recipient + assert reply.chat == chat + assert reply.reply_to == source_message + assert reply.content == "Уже готово" + notification.refresh_from_db() + assert notification.unread is False + assert notification.data["push_reply_message_id"] == reply.id + reply_broadcast.assert_called_once_with(reply) + read_broadcast.assert_called_once() + assert notify_new_message.call_count == 1 + + replay_request = APIRequestFactory().post( + "/push/action/reply/", + {"token": token, "reply": "Отправить повторно"}, + format="json", + ) + replay_response = push_reply_to_chat(replay_request) + + assert replay_response.status_code == 409 + assert Message.objects.filter(author=recipient, chat=chat).count() == 1 + + def test_push_avatar_has_transparent_round_corners( + self, + tmp_path, + ): + source = Image.new("RGB", (80, 40), "#38bdf8") + source_bytes = BytesIO() + source.save(source_bytes, format="PNG") + avatar_path = "push-tests/avatar.png" + with override_settings(MEDIA_ROOT=tmp_path): + from django.core.files.storage import storages + + storages["default"].save( + avatar_path, ContentFile(source_bytes.getvalue()) + ) + token = build_push_avatar_token(avatar_path) + request = APIRequestFactory().get( + "/push/avatar/", {"token": token} + ) + response = push_avatar(request) + + assert response.status_code == 200 + assert response["Content-Type"] == "image/png" + rendered = Image.open(BytesIO(response.content)).convert("RGBA") + assert rendered.size == (192, 192) + assert rendered.getpixel((0, 0))[3] == 0 + assert rendered.getpixel((96, 96))[3] == 255 + + class TestNotificationList: def test_notification_actors_are_prefetched(self, api_client, user_factory): recipient = user_factory() diff --git a/backend/tests/api/v1/notifications/test_push_sender.py b/backend/tests/api/v1/notifications/test_push_sender.py index 11130a8a..80cc4b16 100644 --- a/backend/tests/api/v1/notifications/test_push_sender.py +++ b/backend/tests/api/v1/notifications/test_push_sender.py @@ -78,6 +78,8 @@ class TestPushNotificationSenderPayload: data={ "title": "Новое сообщение от Рубцова Олеся", "chat_id": 123, + "chat_name": "Личный чат", + "chat_type": "private", "message_id": 456, }, ) @@ -88,12 +90,30 @@ class TestPushNotificationSenderPayload: assert payload["head"] == "Рубцова Олеся" assert payload["body"] == "Полинина" assert payload["url"] == "/messages/123" - assert payload["data"] == { - "url": "/messages/123", - "notification_id": notification.id, - "chat_id": 123, - "message_id": 456, - } + assert payload["data"]["url"] == "/messages/123" + assert payload["data"]["notification_id"] == notification.id + assert payload["data"]["chat_id"] == 123 + assert payload["data"]["message_id"] == 456 + assert payload["data"]["chat_name"] == "Личный чат" + assert payload["data"]["chat_type"] == "private" + assert payload["data"]["actor_name"] == "Рубцова Олеся" + assert payload["data"]["mark_read_url"].endswith("/push/action/read/") + assert payload["data"]["mark_read_token"] + assert payload["data"]["reply_url"].endswith("/push/action/reply/") + assert payload["data"]["reply_token"] + assert payload["actions"] == [ + { + "action": "reply", + "type": "text", + "title": "Ответить", + "placeholder": "Введите ответ…", + }, + { + "action": "mark-read", + "type": "button", + "title": "Прочитано", + }, + ] assert payload["tag"] == "chat-123" assert "icon" not in payload assert "chat_new_message" not in json.dumps(payload) @@ -124,6 +144,7 @@ class TestPushNotificationSenderPayload: data={ "chat_id": chat.id, "chat_name": chat.name, + "chat_type": chat.type, "message_id": 77, }, ) @@ -133,8 +154,45 @@ class TestPushNotificationSenderPayload: assert payload["title"] == "Секретный чат" assert payload["body"] == "Новое сообщение" assert payload["silent"] is True + assert payload["actions"] == [ + { + "action": "mark-read", + "type": "button", + "title": "Прочитано", + }, + ] + assert "reply_token" not in payload["data"] assert "Секретный текст" not in json.dumps(payload, ensure_ascii=False) + def test_group_chat_push_shows_chat_then_sender(self, user_factory): + recipient = user_factory(email="group-recipient@example.com") + actor = user_factory( + email="group-actor@example.com", + first_name="Олеся", + last_name="Рубцова", + ) + _make_push_device(recipient) + notification = _make_notification( + recipient, + actor_content_type=ContentType.objects.get_for_model(actor), + actor_object_id=actor.pk, + verb="chat_new_message", + description="Проверила макет", + action_url="/messages/321", + data={ + "chat_id": 321, + "chat_name": "Команда продукта", + "chat_type": "group", + "message_id": 654, + }, + ) + + payload = _send_and_load_payload(notification) + + assert payload["title"] == "Команда продукта" + assert payload["body"] == "Рубцова Олеся: Проверила макет" + assert payload["tag"] == "chat-321" + @override_settings(SITE_URL="https://corp.example.test") def test_chat_push_uses_actor_avatar_as_icon(self, user_factory): recipient = user_factory(email="avatar-recipient@example.com") @@ -163,9 +221,8 @@ class TestPushNotificationSenderPayload: payload = _send_and_load_payload(notification) - assert ( - payload["icon"] - == "https://corp.example.test/media/users/avatars/actor.jpg" + assert payload["icon"].startswith( + "https://corp.example.test/api/v1/notifications/push/avatar/?token=" ) def test_non_chat_push_uses_notification_data_title(self, user_factory): diff --git a/frontend/public/sw.js b/frontend/public/sw.js index f1630d6b..75c19b09 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -6,7 +6,7 @@ * нативные уведомления даже когда страница закрыта. */ -const SW_VERSION = '2.2.1'; +const SW_VERSION = '2.4.0'; const SW_APP_KIND = self.CORP_SW_APP_KIND || 'main'; const STATIC_CACHE = `eusrr-static-${SW_VERSION}`; const PRECACHE_URLS = [ @@ -30,6 +30,13 @@ const DEFAULT_NOTIFICATION_OPTIONS = { renotify: true, }; +function supportsNativeInlineReply() { + const userAgent = self.navigator?.userAgent || ''; + const isChromium = /(?:Chrome|Chromium|Edg|OPR)\//.test(userAgent); + const isSupportedPlatform = /(?:Windows NT|Android|CrOS)/.test(userAgent); + return isChromium && isSupportedPlatform; +} + function buildNotificationData(payload = {}) { const notificationData = { title: payload.title || payload.head || 'Новое уведомление', @@ -49,7 +56,11 @@ function buildNotificationData(payload = {}) { notificationData.vibrate = payload.vibrate || DEFAULT_NOTIFICATION_OPTIONS.vibrate; } if (payload.image) notificationData.image = payload.image; - if (payload.actions) notificationData.actions = payload.actions; + if (payload.actions) { + notificationData.actions = payload.actions.filter( + (action) => action.type !== 'text' || supportsNativeInlineReply(), + ); + } return notificationData; } @@ -95,6 +106,81 @@ async function applyNotificationControl(payload) { await self.registration.showNotification(title, options); } +async function markPushNotificationRead(notification) { + const data = notification.data || {}; + if (!data.mark_read_url || !data.mark_read_token) { + throw new Error('Push notification has no signed read action'); + } + + const response = await fetch( + new URL(data.mark_read_url, self.location.origin).toString(), + { + method: 'POST', + credentials: 'omit', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: data.mark_read_token }), + }, + ); + if (!response.ok) { + throw new Error(`Push read action failed with HTTP ${response.status}`); + } + + const notifications = await self.registration.getNotifications({ includeTriggered: true }); + notifications + .filter((item) => sameIdentity(item.data?.chat_id, data.chat_id)) + .forEach((item) => item.close()); + + const windowClients = await clients.matchAll({ type: 'window', includeUncontrolled: true }); + windowClients.forEach((client) => client.postMessage({ + type: 'PUSH_CHAT_MARKED_READ', + chatId: data.chat_id, + messageId: data.message_id, + notificationId: data.notification_id, + })); +} + +async function sendPushQuickReply(notification, reply) { + const data = notification.data || {}; + const normalizedReply = typeof reply === 'string' ? reply.trim() : ''; + if (!normalizedReply) { + throw new Error('Native notification did not provide reply text'); + } + if (!data.reply_url || !data.reply_token) { + throw new Error('Push notification has no signed reply action'); + } + + const response = await fetch( + new URL(data.reply_url, self.location.origin).toString(), + { + method: 'POST', + credentials: 'omit', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: data.reply_token, + reply: normalizedReply, + }), + }, + ); + if (!response.ok) { + throw new Error(`Push reply failed with HTTP ${response.status}`); + } + + const result = await response.json(); + const notifications = await self.registration.getNotifications({ includeTriggered: true }); + notifications + .filter((item) => sameIdentity(item.data?.chat_id, data.chat_id)) + .forEach((item) => item.close()); + + const windowClients = await clients.matchAll({ type: 'window', includeUncontrolled: true }); + windowClients.forEach((client) => client.postMessage({ + type: 'PUSH_CHAT_QUICK_REPLIED', + chatId: data.chat_id, + messageId: result.message_id, + replyToId: data.message_id, + notificationId: data.notification_id, + })); +} + /** * Событие установки Service Worker */ @@ -242,7 +328,29 @@ self.addEventListener('message', (event) => { */ self.addEventListener('notificationclick', (event) => { console.log('[SW] Notification clicked:', event.notification.tag); - + + if (event.action === 'reply') { + if (typeof event.reply !== 'string' || !event.reply.trim()) { + console.warn('[SW] Native inline reply is unavailable on this platform'); + return; + } + event.waitUntil( + sendPushQuickReply(event.notification, event.reply).catch((error) => { + console.error('[SW] Failed to send push reply:', error); + }), + ); + return; + } + + if (event.action === 'mark-read') { + event.waitUntil( + markPushNotificationRead(event.notification).catch((error) => { + console.error('[SW] Failed to mark push notification as read:', error); + }), + ); + return; + } + event.notification.close(); // Получаем URL из данных уведомления