Add interactive push notification chat actions
This commit is contained in:
parent
f7f7d3316d
commit
dc4b7ae0a8
@ -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"])
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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',
|
||||
]
|
||||
|
||||
53
backend/communications/services/chat_quick_reply.py
Normal file
53
backend/communications/services/chat_quick_reply.py
Normal file
@ -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,
|
||||
},
|
||||
)
|
||||
83
backend/communications/services/chat_read_marker.py
Normal file
83
backend/communications/services/chat_read_marker.py
Normal file
@ -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,
|
||||
},
|
||||
)
|
||||
@ -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(
|
||||
|
||||
@ -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",
|
||||
|
||||
79
backend/notifications/push_actions.py
Normal file
79
backend/notifications/push_actions.py
Normal file
@ -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,
|
||||
)
|
||||
@ -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
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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 из данных уведомления
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user