diff --git a/backend/communications/api/serializers.py b/backend/communications/api/serializers.py
index 317aca97..1e47ec9f 100644
--- a/backend/communications/api/serializers.py
+++ b/backend/communications/api/serializers.py
@@ -23,6 +23,7 @@ from ..models import (
ChatMembership,
ChatUserSettings,
)
+from ..mentions import build_message_mentions
from ..serialization import serialize_message, serialize_poll
from ..utils import user_can_access_chat
@@ -160,6 +161,7 @@ class ChatListSerializer(serializers.ModelSerializer):
last_message = serializers.SerializerMethodField()
unread_count = serializers.SerializerMethodField()
+ has_unread_mention = serializers.SerializerMethodField()
participant_details = serializers.SerializerMethodField()
participant_names = serializers.SerializerMethodField()
member_ids = serializers.SerializerMethodField()
@@ -192,6 +194,7 @@ class ChatListSerializer(serializers.ModelSerializer):
# Messages & status
"last_message",
"unread_count",
+ "has_unread_mention",
"participant_details",
"participant_names",
"member_ids",
@@ -212,6 +215,12 @@ class ChatListSerializer(serializers.ModelSerializer):
return obj.my_read_state[0].unread_count
return 0
+ @extend_schema_field(serializers.BooleanField())
+ def get_has_unread_mention(self, obj):
+ if hasattr(obj, "my_read_state") and obj.my_read_state:
+ return obj.my_read_state[0].unread_mention_count > 0
+ return False
+
@extend_schema_field(ChatLastMessageSerializer)
def get_last_message(self, obj):
"""Последнее сообщение в чате (использует prefetch если доступен)"""
@@ -283,6 +292,7 @@ class ChatDetailSerializer(serializers.ModelSerializer):
last_read_message_id = serializers.SerializerMethodField()
last_message = serializers.SerializerMethodField()
unread_count = serializers.SerializerMethodField()
+ has_unread_mention = serializers.SerializerMethodField()
context_content_type = serializers.PrimaryKeyRelatedField(
queryset=ContentType.objects.all(),
write_only=True,
@@ -332,6 +342,7 @@ class ChatDetailSerializer(serializers.ModelSerializer):
"last_read_message_id",
"last_message",
"unread_count",
+ "has_unread_mention",
]
read_only_fields = [
"created_at",
@@ -412,6 +423,12 @@ class ChatDetailSerializer(serializers.ModelSerializer):
return obj.my_read_state[0].unread_count
return 0
+ @extend_schema_field(serializers.BooleanField())
+ def get_has_unread_mention(self, obj):
+ if hasattr(obj, "my_read_state") and obj.my_read_state:
+ return obj.my_read_state[0].unread_mention_count > 0
+ return False
+
class MessageAttachmentSerializer(serializers.ModelSerializer):
"""Вложение сообщения"""
@@ -697,6 +714,7 @@ class MessageListSerializer(serializers.ModelSerializer):
fields = [
"id",
"content",
+ "mentions",
"author",
"author_name",
"author_avatar",
@@ -739,9 +757,23 @@ class MessageDetailSerializer(serializers.ModelSerializer):
class MessageCreateSerializer(serializers.ModelSerializer):
"""Сериализатор для создания/редактирования сообщений"""
+ mentioned_user_ids = serializers.ListField(
+ child=serializers.IntegerField(min_value=1),
+ write_only=True,
+ required=False,
+ allow_empty=True,
+ max_length=50,
+ )
+
class Meta:
model = Message
- fields = ["content", "chat", "reply_to", "is_forwarded"]
+ fields = [
+ "content",
+ "chat",
+ "reply_to",
+ "is_forwarded",
+ "mentioned_user_ids",
+ ]
def validate(self, attrs):
"""Валидация: сообщение должно иметь либо текст, либо вложения"""
@@ -750,7 +782,15 @@ class MessageCreateSerializer(serializers.ModelSerializer):
def create(self, validated_data):
"""Создание сообщения с автором"""
- validated_data["author"] = self.context["request"].user
+ author = self.context["request"].user
+ mentioned_user_ids = validated_data.pop("mentioned_user_ids", [])
+ validated_data["author"] = author
+ validated_data["mentions"] = build_message_mentions(
+ validated_data["chat"],
+ author,
+ validated_data.get("content", ""),
+ mentioned_user_ids,
+ )
return super().create(validated_data)
@@ -761,6 +801,12 @@ class MessageEditSerializer(serializers.Serializer):
existing_attachment_ids = serializers.ListField(
child=serializers.IntegerField(), required=False, allow_empty=True
)
+ mentioned_user_ids = serializers.ListField(
+ child=serializers.IntegerField(min_value=1),
+ required=False,
+ allow_empty=True,
+ max_length=50,
+ )
class ReactionSerializer(serializers.Serializer):
diff --git a/backend/communications/api/viewsets.py b/backend/communications/api/viewsets.py
index a2da4d29..5cb8d647 100644
--- a/backend/communications/api/viewsets.py
+++ b/backend/communications/api/viewsets.py
@@ -53,6 +53,15 @@ from ..models import (
MessageReaction,
Poll,
)
+from ..mentions import (
+ build_message_mentions,
+ filter_mentionable_users,
+ get_mentionable_users,
+ mentioned_user_ids as get_mentioned_user_ids,
+ mention_display_name,
+ parse_mentioned_user_ids,
+ update_unread_mention_counters,
+)
from ..polls import PollActionError, cast_poll_vote
from ..services import soft_delete_message
from ..utils import _coerce_ts, user_can_access_chat
@@ -350,6 +359,29 @@ class ChatViewSet(viewsets.ModelViewSet):
{"ok": True, "notifications_enabled": settings.notifications_enabled}
)
+ @action(detail=True, methods=["get"], url_path="mention-candidates")
+ def mention_candidates(self, request, pk=None):
+ """Active chat participants available for an @mention."""
+ chat = self.get_object()
+ query = str(request.query_params.get("q", "")).strip()[:100]
+ users = get_mentionable_users(chat, author=request.user).order_by(
+ "last_name", "first_name", "patronymic", "id"
+ )
+ if query:
+ users = filter_mentionable_users(users, query)
+
+ candidates = []
+ for user in users:
+ candidates.append(
+ {
+ "id": user.id,
+ "name": mention_display_name(user),
+ "email": user.email,
+ "avatar": user.avatar.url if getattr(user, "avatar", None) else None,
+ }
+ )
+ return Response({"results": candidates})
+
@action(detail=True, methods=["post"])
def leave(self, request, pk=None):
"""Покинуть чат (выход пользователя из чата)"""
@@ -638,6 +670,7 @@ class ChatViewSet(viewsets.ModelViewSet):
defaults={
"last_read_message": last_message,
"unread_count": 0, # Прочитали → обнуляем
+ "unread_mention_count": 0,
},
)
@@ -674,8 +707,14 @@ class ChatViewSet(viewsets.ModelViewSet):
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", "updated_at"]
+ update_fields=[
+ "last_read_message",
+ "unread_count",
+ "unread_mention_count",
+ "updated_at",
+ ]
)
logger.info(
@@ -1344,6 +1383,9 @@ class MessageViewSet(viewsets.ModelViewSet):
chat_id = request.data.get("chat_id")
content = request.data.get("content", "").strip()
reply_to_id = request.data.get("reply_to") or request.data.get("reply_to_id")
+ mentioned_user_ids = parse_mentioned_user_ids(
+ request.data.get("mentioned_user_ids")
+ )
# Валидация чата
try:
@@ -1389,6 +1431,12 @@ class MessageViewSet(viewsets.ModelViewSet):
chat=chat,
author=request.user,
content=content,
+ mentions=build_message_mentions(
+ chat,
+ request.user,
+ content,
+ mentioned_user_ids,
+ ),
reply_to=reply_to_message,
has_attachments=len(files) > 0,
)
@@ -1499,6 +1547,19 @@ class MessageViewSet(viewsets.ModelViewSet):
serializer.is_valid(raise_exception=True)
new_content = serializer.validated_data.get("content", "").strip()
+ previous_mention_ids = get_mentioned_user_ids(instance.mentions)
+ requested_mention_ids = serializer.validated_data.get(
+ "mentioned_user_ids",
+ list(previous_mention_ids),
+ )
+ next_mentions = build_message_mentions(
+ instance.chat,
+ request.user,
+ new_content,
+ requested_mention_ids,
+ )
+ added_mention_ids = get_mentioned_user_ids(next_mentions) - previous_mention_ids
+ removed_mention_ids = previous_mention_ids - get_mentioned_user_ids(next_mentions)
existing_attachment_ids = serializer.validated_data.get(
"existing_attachment_ids"
)
@@ -1523,6 +1584,7 @@ class MessageViewSet(viewsets.ModelViewSet):
)
instance.content = new_content
+ instance.mentions = next_mentions
instance.is_edited = True
instance.edited_at = timezone.now()
@@ -1570,6 +1632,17 @@ class MessageViewSet(viewsets.ModelViewSet):
instance.save()
+ update_unread_mention_counters(
+ instance,
+ added_user_ids=added_mention_ids,
+ removed_user_ids=removed_mention_ids,
+ )
+
+ if added_mention_ids:
+ from communications.notifications.handlers import notify_message_mentions
+
+ notify_message_mentions(instance, recipient_ids=added_mention_ids)
+
# ВАЖНО: Очищаем кэш attachments перед подсчетом
instance.refresh_from_db()
diff --git a/backend/communications/consumers.py b/backend/communications/consumers.py
index 8028c9fc..dc97df5f 100644
--- a/backend/communications/consumers.py
+++ b/backend/communications/consumers.py
@@ -27,6 +27,12 @@ from .models import (
MessageReaction,
Poll,
)
+from .mentions import (
+ build_message_mentions,
+ mentioned_user_ids,
+ parse_mentioned_user_ids,
+ update_unread_mention_counters,
+)
from .polls import PollActionError, cast_poll_vote
from .serialization import serialize_message, serialize_poll
from .services import soft_delete_message
@@ -380,6 +386,11 @@ class ChatConsumerMixin:
field="content",
max_length=20_000,
)
+ mention_user_ids = (
+ parse_mentioned_user_ids(content.get("mentioned_user_ids"))
+ if "mentioned_user_ids" in content
+ else None
+ )
chat = await self._get_chat(self.active_chat_id)
if not chat or not await self._user_can_access(chat, self.user):
@@ -401,7 +412,7 @@ class ChatConsumerMixin:
return
# Создаем сообщение
- msg = await self._create_message(chat, self.user, text)
+ msg = await self._create_message(chat, self.user, text, mention_user_ids)
if msg:
# Сериализуем и отправляем всем в группе
@@ -427,6 +438,11 @@ class ChatConsumerMixin:
field="content",
max_length=20_000,
)
+ mention_user_ids = (
+ parse_mentioned_user_ids(content.get("mentioned_user_ids"))
+ if "mentioned_user_ids" in content
+ else None
+ )
msg = await self._get_message(message_id)
if (
@@ -439,7 +455,7 @@ class ChatConsumerMixin:
return
# Обновляем сообщение
- await self._update_message(msg, new_content)
+ await self._update_message(msg, new_content, mention_user_ids)
# Сериализуем и отправляем
msg_data = await database_sync_to_async(serialize_message)(msg)
@@ -769,10 +785,20 @@ class ChatConsumerMixin:
)
@database_sync_to_async
- def _create_message(self, chat, user, content):
+ def _create_message(self, chat, user, content, mention_user_ids=None):
"""Создать новое сообщение"""
try:
- msg = Message.objects.create(chat=chat, author=user, content=content)
+ msg = Message.objects.create(
+ chat=chat,
+ author=user,
+ content=content,
+ mentions=build_message_mentions(
+ chat,
+ user,
+ content,
+ mention_user_ids or [],
+ ),
+ )
return msg
except Exception as e:
logger.exception(
@@ -787,12 +813,36 @@ class ChatConsumerMixin:
return None
@database_sync_to_async
- def _update_message(self, msg, new_content):
+ def _update_message(self, msg, new_content, mention_user_ids=None):
"""Обновить содержимое сообщения"""
+ previous_mention_ids = mentioned_user_ids(msg.mentions)
+ requested_mention_ids = (
+ mention_user_ids
+ if mention_user_ids is not None
+ else list(previous_mention_ids)
+ )
+ next_mentions = build_message_mentions(
+ msg.chat,
+ msg.author,
+ new_content,
+ requested_mention_ids,
+ )
+ added_mention_ids = mentioned_user_ids(next_mentions) - previous_mention_ids
+ removed_mention_ids = previous_mention_ids - mentioned_user_ids(next_mentions)
msg.content = new_content
+ msg.mentions = next_mentions
msg.is_edited = True
msg.edited_at = timezone.now()
- msg.save(update_fields=["content", "is_edited", "edited_at"])
+ msg.save(update_fields=["content", "mentions", "is_edited", "edited_at"])
+ update_unread_mention_counters(
+ msg,
+ added_user_ids=added_mention_ids,
+ removed_user_ids=removed_mention_ids,
+ )
+ if added_mention_ids:
+ from communications.notifications.handlers import notify_message_mentions
+
+ notify_message_mentions(msg, recipient_ids=added_mention_ids)
@database_sync_to_async
def _soft_delete_message(self, msg):
diff --git a/backend/communications/mentions.py b/backend/communications/mentions.py
new file mode 100644
index 00000000..27d2fa2e
--- /dev/null
+++ b/backend/communications/mentions.py
@@ -0,0 +1,194 @@
+import json
+import re
+import unicodedata
+from collections.abc import Iterable
+
+from django.contrib.auth import get_user_model
+from django.db.models import F, Q, QuerySet
+
+User = get_user_model()
+
+MAX_MENTIONS_PER_MESSAGE = 50
+
+
+def mention_display_name(user) -> str:
+ full_name = " ".join(
+ part.strip()
+ for part in (user.last_name, user.first_name, user.patronymic)
+ if part and part.strip()
+ )
+ return (full_name or user.username or user.email or str(user.pk)).strip()
+
+
+def _normalize_search_value(value) -> str:
+ return unicodedata.normalize("NFKC", str(value or "")).casefold()
+
+
+def filter_mentionable_users(users: Iterable, query: str) -> list:
+ """Match every query token against any employee identity field."""
+ tokens = [
+ _normalize_search_value(token)
+ for token in query.split()
+ if token.strip()
+ ]
+ if not tokens:
+ return list(users)
+
+ matches = []
+ for user in users:
+ fields = (
+ _normalize_search_value(user.last_name),
+ _normalize_search_value(user.first_name),
+ _normalize_search_value(user.patronymic),
+ _normalize_search_value(user.email),
+ _normalize_search_value(user.username),
+ )
+ if all(any(token in field for field in fields) for token in tokens):
+ matches.append(user)
+ return matches
+
+
+def get_mentionable_users(chat, *, author=None) -> QuerySet:
+ """Return active users who can see the chat, including legacy memberships."""
+ if chat.type == "global" or chat.include_all_users:
+ users = User.objects.filter(is_active=True)
+ else:
+ users = User.objects.filter(
+ Q(chat_memberships__chat=chat, chat_memberships__is_active=True)
+ | Q(chats=chat)
+ ).filter(is_active=True).distinct()
+
+ if author is not None:
+ users = users.exclude(pk=author.pk)
+ return users
+
+
+def parse_mentioned_user_ids(raw_value) -> list[int]:
+ if raw_value in (None, ""):
+ return []
+
+ values = raw_value
+ if isinstance(raw_value, str):
+ try:
+ values = json.loads(raw_value)
+ except json.JSONDecodeError:
+ values = raw_value.split(",")
+ if not isinstance(values, Iterable) or isinstance(values, (str, bytes, dict)):
+ values = [values]
+
+ result = []
+ seen = set()
+ for value in values:
+ try:
+ user_id = int(value)
+ except (TypeError, ValueError):
+ continue
+ if user_id <= 0 or user_id in seen:
+ continue
+ seen.add(user_id)
+ result.append(user_id)
+ if len(result) >= MAX_MENTIONS_PER_MESSAGE:
+ break
+ return result
+
+
+def build_message_mentions(chat, author, content: str, user_ids) -> list[dict]:
+ """Validate selected users and resolve their visible mention ranges."""
+ normalized_ids = parse_mentioned_user_ids(user_ids)
+ if not content or not normalized_ids:
+ return []
+
+ users_by_id = {
+ user.id: user
+ for user in get_mentionable_users(chat, author=author).filter(
+ id__in=normalized_ids
+ )
+ }
+ mentions = []
+ occupied_ranges: list[tuple[int, int]] = []
+
+ for user_id in normalized_ids:
+ user = users_by_id.get(user_id)
+ if user is None:
+ continue
+ label = mention_display_name(user)
+ if not label:
+ continue
+ pattern = re.compile(
+ rf"(? used_start for used_start, used_end in occupied_ranges):
+ continue
+ occupied_ranges.append((start, end))
+ mentions.append(
+ {
+ "user_id": user.id,
+ "name": label,
+ "start": start,
+ "end": end,
+ }
+ )
+
+ return sorted(mentions, key=lambda mention: mention["start"])
+
+
+def mentioned_user_ids(mentions) -> set[int]:
+ result = set()
+ for mention in mentions or []:
+ try:
+ result.add(int(mention.get("user_id")))
+ except (AttributeError, TypeError, ValueError):
+ continue
+ return result
+
+
+def update_unread_mention_counters(
+ message,
+ *,
+ added_user_ids=(),
+ removed_user_ids=(),
+) -> None:
+ """Keep per-chat mention badges correct when a message is edited."""
+ from .models import ChatReadState
+
+ added_ids = set(parse_mentioned_user_ids(added_user_ids))
+ removed_ids = set(parse_mentioned_user_ids(removed_user_ids)) - added_ids
+
+ if removed_ids:
+ ChatReadState.objects.filter(
+ chat_id=message.chat_id,
+ user_id__in=removed_ids,
+ unread_mention_count__gt=0,
+ ).update(unread_mention_count=F("unread_mention_count") - 1)
+
+ if not added_ids:
+ return
+
+ existing_ids = set(
+ ChatReadState.objects.filter(
+ chat_id=message.chat_id,
+ user_id__in=added_ids,
+ ).values_list("user_id", flat=True)
+ )
+ if existing_ids:
+ ChatReadState.objects.filter(
+ chat_id=message.chat_id,
+ user_id__in=existing_ids,
+ ).update(unread_mention_count=F("unread_mention_count") + 1)
+
+ missing_ids = added_ids - existing_ids
+ if missing_ids:
+ ChatReadState.objects.bulk_create(
+ [
+ ChatReadState(
+ chat_id=message.chat_id,
+ user_id=user_id,
+ unread_mention_count=1,
+ )
+ for user_id in missing_ids
+ ],
+ ignore_conflicts=True,
+ )
diff --git a/backend/communications/migrations/0040_message_mentions.py b/backend/communications/migrations/0040_message_mentions.py
new file mode 100644
index 00000000..49835655
--- /dev/null
+++ b/backend/communications/migrations/0040_message_mentions.py
@@ -0,0 +1,20 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("communications", "0039_alter_chat_context_object_id_bigint"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="message",
+ name="mentions",
+ field=models.JSONField(
+ blank=True,
+ default=list,
+ help_text="Проверенные ссылки на пользователей и диапазоны в тексте сообщения",
+ verbose_name="Упоминания",
+ ),
+ ),
+ ]
diff --git a/backend/communications/migrations/0041_chatreadstate_unread_mention_count.py b/backend/communications/migrations/0041_chatreadstate_unread_mention_count.py
new file mode 100644
index 00000000..51784167
--- /dev/null
+++ b/backend/communications/migrations/0041_chatreadstate_unread_mention_count.py
@@ -0,0 +1,62 @@
+from django.db import migrations, models
+
+
+def backfill_unread_mentions(apps, schema_editor):
+ ChatReadState = apps.get_model("communications", "ChatReadState")
+ Message = apps.get_model("communications", "Message")
+
+ states = list(ChatReadState.objects.all())
+ states_by_chat_and_user = {
+ (state.chat_id, state.user_id): state for state in states
+ }
+
+ for message in Message.objects.filter(is_deleted=False).exclude(mentions=[]).iterator():
+ seen_user_ids = set()
+ for mention in message.mentions or []:
+ try:
+ user_id = int(mention.get("user_id"))
+ except (AttributeError, TypeError, ValueError):
+ continue
+ if user_id in seen_user_ids or user_id == message.author_id:
+ continue
+ seen_user_ids.add(user_id)
+ state = states_by_chat_and_user.get((message.chat_id, user_id))
+ if state is None:
+ continue
+ if state.last_read_message_id and state.last_read_message_id >= message.id:
+ continue
+ state.unread_mention_count += 1
+
+ if states:
+ ChatReadState.objects.bulk_update(states, ["unread_mention_count"])
+
+
+def clear_unread_mentions(apps, schema_editor):
+ ChatReadState = apps.get_model("communications", "ChatReadState")
+ ChatReadState.objects.update(unread_mention_count=0)
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("communications", "0040_message_mentions"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="chatreadstate",
+ name="unread_mention_count",
+ field=models.PositiveIntegerField(
+ default=0,
+ help_text="Кешированное количество непрочитанных сообщений с @-упоминанием пользователя.",
+ verbose_name="Непрочитанных упоминаний",
+ ),
+ ),
+ migrations.AddIndex(
+ model_name="chatreadstate",
+ index=models.Index(
+ fields=["user", "unread_mention_count"],
+ name="communicati_user_id_0c9ce2_idx",
+ ),
+ ),
+ migrations.RunPython(backfill_unread_mentions, clear_unread_mentions),
+ ]
diff --git a/backend/communications/models.py b/backend/communications/models.py
index d346383f..21097ddd 100644
--- a/backend/communications/models.py
+++ b/backend/communications/models.py
@@ -49,6 +49,11 @@ class ChatReadState(models.Model):
"Обновляется при новых сообщениях и прочтении."
),
)
+ unread_mention_count = models.PositiveIntegerField(
+ default=0,
+ verbose_name="Непрочитанных упоминаний",
+ help_text="Кешированное количество непрочитанных сообщений с @-упоминанием пользователя.",
+ )
# Статус набора текста
is_typing = models.BooleanField(default=False, verbose_name="Набирает сообщение")
@@ -72,6 +77,7 @@ class ChatReadState(models.Model):
models.Index(fields=["last_read_message"]),
# Для быстрых запросов непрочитанных
models.Index(fields=["user", "unread_count"]),
+ models.Index(fields=["user", "unread_mention_count"]),
]
def __str__(self):
@@ -257,7 +263,11 @@ class Chat(models.Model):
read_state, created = ChatReadState.objects.get_or_create(
chat=self,
user=user,
- defaults={"last_read_message": last_message, "unread_count": 0},
+ defaults={
+ "last_read_message": last_message,
+ "unread_count": 0,
+ "unread_mention_count": 0,
+ },
)
if not created:
@@ -267,10 +277,12 @@ class Chat(models.Model):
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",
]
)
@@ -440,6 +452,12 @@ class Message(models.Model):
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, verbose_name="Автор"
)
content = models.TextField("Текст сообщения")
+ mentions = models.JSONField(
+ default=list,
+ blank=True,
+ verbose_name="Упоминания",
+ help_text="Проверенные ссылки на пользователей и диапазоны в тексте сообщения",
+ )
created_at = models.DateTimeField(
auto_now_add=True, verbose_name="Время отправки", db_index=True
)
diff --git a/backend/communications/notifications/handlers.py b/backend/communications/notifications/handlers.py
index fd5d69c6..8384dd59 100644
--- a/backend/communications/notifications/handlers.py
+++ b/backend/communications/notifications/handlers.py
@@ -23,11 +23,11 @@ except ImportError:
notify = None
from ..models import ChatUserSettings
+from ..mentions import get_mentionable_users, mentioned_user_ids
from .config import (
NotificationVerbs,
MessageTemplates,
ActionURLs,
- extract_mentions,
truncate_message,
get_chat_name,
)
@@ -74,6 +74,53 @@ def get_users_with_notifications_enabled(chat, users) -> dict[int, bool]:
return {uid: settings_dict.get(uid, True) for uid in user_ids}
+def notify_message_mentions(
+ message,
+ *,
+ recipient_ids=None,
+ participants_list=None,
+) -> set[int]:
+ """Send one high-priority notification to each validated mentioned user."""
+ chat = message.chat
+ author = message.author
+ selected_ids = mentioned_user_ids(message.mentions)
+ if recipient_ids is not None:
+ selected_ids.intersection_update({int(user_id) for user_id in recipient_ids})
+ selected_ids.discard(author.id)
+ if not selected_ids:
+ return set()
+
+ if participants_list is None:
+ participants_list = list(get_mentionable_users(chat, author=author))
+ participant_by_id = {user.id: user for user in participants_list}
+ recipients = [
+ participant_by_id[user_id]
+ for user_id in selected_ids
+ if user_id in participant_by_id
+ ]
+ author_name = author.get_full_name() or author.username
+ notified_user_ids = set()
+ for recipient in recipients:
+ _send_notification(
+ sender=author,
+ recipient=recipient,
+ verb=NotificationVerbs.MENTION,
+ action_object=message,
+ description=truncate_message(message.content, 100),
+ action_url=ActionURLs.message_detail(chat.id, message.id),
+ data={
+ "title": MessageTemplates.mention(author_name),
+ "chat_id": chat.id,
+ "chat_name": get_chat_name(chat),
+ "message_id": message.id,
+ "author_id": author.id,
+ "mentioned": True,
+ },
+ )
+ notified_user_ids.add(recipient.id)
+ return notified_user_ids
+
+
def notify_new_message(message):
"""
Отправляет уведомления о новом сообщении в чате.
@@ -96,14 +143,12 @@ def notify_new_message(message):
# Специальная обработка для чатов комментариев
if chat.type == "comments":
- _notify_comment(message)
+ mentioned_recipient_ids = notify_message_mentions(message)
+ _notify_comment(message, excluded_recipient_ids=mentioned_recipient_ids)
return
# Получаем всех участников чата кроме автора
- if chat.type in ["announcement", "channel", "global"]:
- participants = chat.get_participants().exclude(id=author.id)
- else:
- participants = chat.participants.exclude(id=author.id)
+ participants = get_mentionable_users(chat, author=author)
# ОПТИМИЗАЦИЯ: Загружаем настройки уведомлений ОДНИМ запросом
participants_list = list(participants)
@@ -117,34 +162,13 @@ def notify_new_message(message):
# Отслеживаем, кому уже отправили уведомления
notified_user_ids = set()
- # 1. УПОМИНАНИЯ (@username)
- mentioned_users = extract_mentions(content)
author_name = author.get_full_name() or author.username
-
- if mentioned_users:
- for email in mentioned_users:
- try:
- user = User.objects.get(email=email)
- if user in participants_list and user.id != author.id:
- if notification_settings.get(user.id, True):
- _send_notification(
- sender=author,
- recipient=user,
- verb=NotificationVerbs.MENTION,
- action_object=message,
- description=truncate_message(content, 100),
- action_url=ActionURLs.chat_detail(chat.id),
- data={
- "title": MessageTemplates.mention(author_name),
- "chat_id": chat.id,
- "chat_name": get_chat_name(chat),
- "message_id": message.id,
- "author_id": author.id,
- },
- )
- notified_user_ids.add(user.id)
- except User.DoesNotExist:
- continue
+ notified_user_ids.update(
+ notify_message_mentions(
+ message,
+ participants_list=participants_list,
+ )
+ )
# 2. ОТВЕТЫ НА СООБЩЕНИЯ
if message.reply_to and message.reply_to.author_id != author.id:
@@ -214,7 +238,7 @@ def notify_new_message(message):
)
-def _notify_comment(message):
+def _notify_comment(message, excluded_recipient_ids=None):
"""
Отправляет уведомления о новом комментарии к объекту.
@@ -319,6 +343,7 @@ def _notify_comment(message):
recipient_ids.add(message.reply_to.author_id)
recipient_ids.discard(author.id)
+ recipient_ids.difference_update(excluded_recipient_ids or set())
if not recipient_ids:
return
diff --git a/backend/communications/serialization.py b/backend/communications/serialization.py
index 0ad014b7..3927fb43 100644
--- a/backend/communications/serialization.py
+++ b/backend/communications/serialization.py
@@ -258,6 +258,7 @@ def serialize_message(m, user=None, include_linked_tasks=False) -> dict:
data = {
"id": m.id,
"content": m.content,
+ "mentions": m.mentions or [],
"author_id": author.id if author else None,
"author_name": author_name,
"author_url": _get_author_url(author),
diff --git a/backend/communications/services/message_deletion.py b/backend/communications/services/message_deletion.py
index 6aa2d04e..1eee82ee 100644
--- a/backend/communications/services/message_deletion.py
+++ b/backend/communications/services/message_deletion.py
@@ -5,6 +5,7 @@ from django.db.models import F, Q
from django.utils import timezone
from communications.models import ChatReadState, Message
+from communications.mentions import mentioned_user_ids
def soft_delete_message(
@@ -29,6 +30,13 @@ def soft_delete_message(
)
unread_states.update(unread_count=F("unread_count") - 1)
+ mention_recipient_ids = mentioned_user_ids(locked_message.mentions)
+ if mention_recipient_ids:
+ unread_states.filter(
+ user_id__in=mention_recipient_ids,
+ unread_mention_count__gt=0,
+ ).update(unread_mention_count=F("unread_mention_count") - 1)
+
locked_message.is_deleted = True
locked_message.deleted_at = timezone.now()
locked_message.deleted_by = deleted_by
diff --git a/backend/communications/signals.py b/backend/communications/signals.py
index 46959b0e..aaba1c5f 100644
--- a/backend/communications/signals.py
+++ b/backend/communications/signals.py
@@ -164,11 +164,13 @@ def increment_unread_count_on_new_message(sender, instance, created, **kwargs):
return
from communications.models import ChatReadState
+ from communications.mentions import mentioned_user_ids
try:
# Получаем всех участников чата
chat = instance.chat
participants = chat.get_participants().exclude(id=instance.author_id)
+ mentioned_ids = mentioned_user_ids(instance.mentions)
# Инкрементируем счетчик для существующих ChatReadState
# Только для тех, кто еще не прочитал это сообщение
@@ -181,14 +183,26 @@ def increment_unread_count_on_new_message(sender, instance, created, **kwargs):
.update(unread_count=F("unread_count") + 1)
)
+ updated_mention_count = 0
+ if mentioned_ids:
+ updated_mention_count = (
+ ChatReadState.objects.filter(
+ chat=chat,
+ user__in=participants,
+ user_id__in=mentioned_ids,
+ )
+ .exclude(last_read_message_id__gte=instance.id)
+ .update(unread_mention_count=F("unread_mention_count") + 1)
+ )
+
# Создаем ChatReadState для участников, у которых его еще нет
existing_users = ChatReadState.objects.filter(
chat=chat, user__in=participants
).values_list("user_id", flat=True)
- new_users = participants.exclude(id__in=existing_users)
+ new_users = list(participants.exclude(id__in=existing_users))
- if new_users.exists():
+ if new_users:
# Создаем ChatReadState с unread_count=1 для новых участников
ChatReadState.objects.bulk_create(
[
@@ -197,6 +211,7 @@ def increment_unread_count_on_new_message(sender, instance, created, **kwargs):
user=user,
last_read_message=None,
unread_count=1,
+ unread_mention_count=1 if user.id in mentioned_ids else 0,
)
for user in new_users
],
@@ -209,7 +224,7 @@ def increment_unread_count_on_new_message(sender, instance, created, **kwargs):
"event": "chat.read_states.created",
"chat_id": chat.id,
"message_id": instance.id,
- "state_count": new_users.count(),
+ "state_count": len(new_users),
},
)
@@ -220,6 +235,7 @@ def increment_unread_count_on_new_message(sender, instance, created, **kwargs):
"chat_id": chat.id,
"message_id": instance.id,
"state_count": updated_count,
+ "mention_state_count": updated_mention_count,
},
)
diff --git a/backend/docs/architecture/websocket-asyncapi.yaml b/backend/docs/architecture/websocket-asyncapi.yaml
index df28ca7f..0092a088 100644
--- a/backend/docs/architecture/websocket-asyncapi.yaml
+++ b/backend/docs/architecture/websocket-asyncapi.yaml
@@ -123,6 +123,10 @@ components:
content:
type: string
nullable: true
+ mentions:
+ type: array
+ items:
+ $ref: '#/components/schemas/MessageMention'
chat:
type: integer
author:
@@ -133,6 +137,20 @@ components:
required:
- id
additionalProperties: true
+ MessageMention:
+ type: object
+ required: [user_id, name, start, end]
+ properties:
+ user_id:
+ $ref: '#/components/schemas/UserId'
+ name:
+ type: string
+ start:
+ type: integer
+ minimum: 0
+ end:
+ type: integer
+ minimum: 1
NotificationPayload:
type: object
description: Сериализованное уведомление пользователя.
@@ -236,6 +254,12 @@ components:
type: string
text:
type: string
+ mentioned_user_ids:
+ type: array
+ maxItems: 50
+ uniqueItems: true
+ items:
+ $ref: '#/components/schemas/UserId'
EditMessageAction:
name: EditMessageAction
title: Редактировать сообщение
@@ -250,6 +274,12 @@ components:
$ref: '#/components/schemas/MessageId'
content:
type: string
+ mentioned_user_ids:
+ type: array
+ maxItems: 50
+ uniqueItems: true
+ items:
+ $ref: '#/components/schemas/UserId'
DeleteMessageAction:
name: DeleteMessageAction
title: Удалить сообщение
diff --git a/backend/tests/api/v1/communications/test_mentions.py b/backend/tests/api/v1/communications/test_mentions.py
new file mode 100644
index 00000000..6f79db2a
--- /dev/null
+++ b/backend/tests/api/v1/communications/test_mentions.py
@@ -0,0 +1,295 @@
+from unittest.mock import patch
+
+import pytest
+from communications.mentions import build_message_mentions, mention_display_name
+from communications.models import Chat, ChatReadState, ChatUserSettings, Message
+from django.contrib.auth import get_user_model
+from rest_framework.test import APIClient
+
+
+User = get_user_model()
+pytestmark = pytest.mark.django_db
+
+
+def create_user(index: int):
+ return User.objects.create_user(
+ email=f"mention-user-{index}@test.com",
+ password="testpass123",
+ first_name="Mention",
+ last_name=f"User {index}",
+ username=f"mention-user-{index}",
+ phone_number=f"+799900001{index:02d}",
+ is_active=True,
+ send_activation_email=False,
+ )
+
+
+@pytest.fixture
+def mention_users():
+ return create_user(1), create_user(2), create_user(3)
+
+
+@pytest.fixture
+def private_chat(mention_users):
+ author, recipient, _outsider = mention_users
+ chat = Chat.objects.create(type="private", created_by=author)
+ chat.participants.add(author, recipient)
+ return chat
+
+
+@pytest.fixture
+def auth_client(mention_users):
+ client = APIClient()
+ client.force_authenticate(user=mention_users[0])
+ return client
+
+
+def test_build_mentions_accepts_participant_and_rejects_outsider(
+ private_chat,
+ mention_users,
+):
+ author, recipient, outsider = mention_users
+ recipient_name = mention_display_name(recipient)
+ outsider_name = mention_display_name(outsider)
+ content = f"Привет @{recipient_name} и @{outsider_name}"
+
+ mentions = build_message_mentions(
+ private_chat,
+ author,
+ content,
+ [recipient.id, outsider.id],
+ )
+
+ assert mentions == [
+ {
+ "user_id": recipient.id,
+ "name": recipient_name,
+ "start": 7,
+ "end": 7 + len(f"@{recipient_name}"),
+ }
+ ]
+
+
+def test_private_chat_candidates_only_include_counterpart(
+ auth_client,
+ private_chat,
+ mention_users,
+):
+ _author, recipient, outsider = mention_users
+
+ response = auth_client.get(
+ f"/api/v1/communications/chats/{private_chat.id}/mention-candidates/"
+ )
+
+ assert response.status_code == 200
+ assert [candidate["id"] for candidate in response.data["results"]] == [
+ recipient.id
+ ]
+ assert outsider.id not in {
+ candidate["id"] for candidate in response.data["results"]
+ }
+
+
+def test_global_chat_candidates_are_not_truncated(auth_client, mention_users):
+ author, _recipient, _outsider = mention_users
+ extra_users = [create_user(index) for index in range(10, 35)]
+ chat = Chat.objects.create(
+ type="global",
+ name="Global mentions",
+ created_by=author,
+ include_all_users=True,
+ )
+
+ response = auth_client.get(
+ f"/api/v1/communications/chats/{chat.id}/mention-candidates/"
+ )
+
+ assert response.status_code == 200
+ result_ids = {candidate["id"] for candidate in response.data["results"]}
+ assert len(result_ids) == len(mention_users) + len(extra_users) - 1
+ assert {user.id for user in extra_users}.issubset(result_ids)
+
+
+@pytest.mark.parametrize(
+ "query",
+ [
+ "Иванов",
+ "Петр",
+ "Сергеевич",
+ "Петр Иванов",
+ "Сергеевич Иванов Петр",
+ "иванов петр",
+ "сергеевич",
+ "ivanov.petrov",
+ "petrov-ldap",
+ ],
+)
+def test_candidates_search_all_identity_fields(
+ auth_client,
+ mention_users,
+ query,
+):
+ author, _recipient, _outsider = mention_users
+ employee = User.objects.create_user(
+ email="ivanov.petrov@company.test",
+ password="testpass123",
+ first_name="Петр",
+ last_name="Иванов",
+ patronymic="Сергеевич",
+ username="petrov-ldap",
+ phone_number="+79990000900",
+ is_active=True,
+ send_activation_email=False,
+ )
+ chat = Chat.objects.create(
+ type="global",
+ name="Global search",
+ created_by=author,
+ include_all_users=True,
+ )
+
+ response = auth_client.get(
+ f"/api/v1/communications/chats/{chat.id}/mention-candidates/",
+ {"q": query},
+ )
+
+ assert response.status_code == 200
+ candidate = next(
+ item for item in response.data["results"] if item["id"] == employee.id
+ )
+ assert candidate["name"] == "Иванов Петр Сергеевич"
+ assert candidate["email"] == employee.email
+
+
+def test_upload_persists_structured_mention_and_sends_single_notification(
+ auth_client,
+ private_chat,
+ mention_users,
+):
+ author, recipient, _outsider = mention_users
+ recipient_name = mention_display_name(recipient)
+ content = f"Привет @{recipient_name}"
+
+ with patch(
+ "communications.notifications.handlers._send_notification"
+ ) as send_notification:
+ response = auth_client.post(
+ "/api/v1/communications/messages/upload/",
+ {
+ "chat_id": private_chat.id,
+ "content": content,
+ "mentioned_user_ids": f"[{recipient.id}]",
+ },
+ format="multipart",
+ )
+
+ assert response.status_code == 201
+ message = Message.objects.get(pk=response.data["message"]["id"])
+ assert message.mentions == [
+ {
+ "user_id": recipient.id,
+ "name": recipient_name,
+ "start": 7,
+ "end": len(content),
+ }
+ ]
+ assert response.data["message"]["mentions"] == message.mentions
+ send_notification.assert_called_once()
+ notification = send_notification.call_args.kwargs
+ assert notification["sender"] == author
+ assert notification["recipient"] == recipient
+ assert notification["verb"] == "chat_mention"
+
+
+def test_mention_bypasses_chat_mute_and_badge_clears_when_read(
+ auth_client,
+ private_chat,
+ mention_users,
+):
+ _author, recipient, _outsider = mention_users
+ recipient_name = mention_display_name(recipient)
+ ChatUserSettings.objects.create(
+ chat=private_chat,
+ user=recipient,
+ notifications_enabled=False,
+ )
+
+ with patch(
+ "communications.notifications.handlers._send_notification"
+ ) as send_notification:
+ response = auth_client.post(
+ "/api/v1/communications/messages/upload/",
+ {
+ "chat_id": private_chat.id,
+ "content": f"Важно для @{recipient_name}",
+ "mentioned_user_ids": f"[{recipient.id}]",
+ },
+ format="multipart",
+ )
+
+ assert response.status_code == 201
+ message_id = response.data["message"]["id"]
+ send_notification.assert_called_once()
+ assert send_notification.call_args.kwargs["recipient"] == recipient
+ assert send_notification.call_args.kwargs["verb"] == "chat_mention"
+
+ read_state = ChatReadState.objects.get(chat=private_chat, user=recipient)
+ assert read_state.unread_mention_count == 1
+
+ recipient_client = APIClient()
+ recipient_client.force_authenticate(user=recipient)
+ chat_response = recipient_client.get(
+ f"/api/v1/communications/chats/{private_chat.id}/"
+ )
+ assert chat_response.status_code == 200
+ assert chat_response.data["notifications_enabled"] is False
+ assert chat_response.data["has_unread_mention"] is True
+
+ read_response = recipient_client.post(
+ f"/api/v1/communications/chats/{private_chat.id}/mark-read/",
+ {"message_id": message_id},
+ format="json",
+ )
+ assert read_response.status_code == 200
+ read_state.refresh_from_db()
+ assert read_state.unread_mention_count == 0
+
+ refreshed_chat = recipient_client.get(
+ f"/api/v1/communications/chats/{private_chat.id}/"
+ )
+ assert refreshed_chat.data["has_unread_mention"] is False
+
+
+def test_edit_recalculates_and_removes_deleted_mention(
+ auth_client,
+ private_chat,
+ mention_users,
+):
+ author, recipient, _outsider = mention_users
+ message = Message.objects.create(
+ chat=private_chat,
+ author=author,
+ content=f"Привет @{mention_display_name(recipient)}",
+ mentions=build_message_mentions(
+ private_chat,
+ author,
+ f"Привет @{mention_display_name(recipient)}",
+ [recipient.id],
+ ),
+ )
+
+ response = auth_client.patch(
+ f"/api/v1/communications/messages/{message.id}/",
+ {
+ "content": "Привет без обращения",
+ "mentioned_user_ids": [recipient.id],
+ },
+ format="json",
+ )
+
+ assert response.status_code == 200
+ message.refresh_from_db()
+ assert message.mentions == []
+ assert response.data["mentions"] == []
+ read_state = ChatReadState.objects.get(chat=private_chat, user=recipient)
+ assert read_state.unread_mention_count == 0
diff --git a/docs/guides/API.md b/docs/guides/API.md
index ad4ecc0d..102801b4 100644
--- a/docs/guides/API.md
+++ b/docs/guides/API.md
@@ -145,6 +145,20 @@ Router публикует:
Кроме CRUD доступны actions для участников, ролей, поиска, истории, отметки прочитанного, загрузок, реакций, пересылки, bulk delete и голосований. Их точные URL и payload смотрите в Swagger: они формируются из `backend/communications/api/viewsets.py`.
+### Упоминания в сообщениях
+
+Доступные для `@` участники запрашиваются с учётом object-level доступа к чату:
+
+```text
+GET /api/v1/communications/chats/{chat_id}/mention-candidates/?q=Ива
+```
+
+Ответ содержит `results` с полями `id`, `name`, `email`, `avatar`. Автор запроса исключён; в личном чате возвращается только собеседник, в групповом — активные участники, в глобальном — активные пользователи. Параметр `q` ищет каждый введённый фрагмент по фамилии, имени, отчеству, email и LDAP-логину, поэтому составные ФИО можно вводить в любом порядке.
+
+При создании multipart-сообщения или `PATCH` передавайте `mentioned_user_ids` — JSON-массив выбранных ID. Сервер повторно проверяет доступ участников и наличие видимого `@Полное имя` в `content`; подставить посторонний ID нельзя. В ответе сообщения поле `mentions` содержит проверенные `{user_id, name, start, end}` для безопасного выделения текста. Максимум — 50 выбранных пользователей на сообщение.
+
+Список и detail чата возвращают `has_unread_mention`. Значение остаётся `true` после перезагрузки и сбрасывается вместе с read marker; frontend показывает его отдельным значком `@` на аватаре чата. Упоминание создаёт `chat_mention` даже при выключенных уведомлениях конкретного чата, не создавая второе обычное уведомление.
+
Для отметки прочитанного используйте HTTP action:
```text
diff --git a/docs/guides/NOTIFICATIONS.md b/docs/guides/NOTIFICATIONS.md
index d2a1fb75..dfc2b799 100644
--- a/docs/guides/NOTIFICATIONS.md
+++ b/docs/guides/NOTIFICATIONS.md
@@ -39,6 +39,8 @@ notify.send(
Не создавайте уведомления пользователю о его собственном действии, если продуктовый сценарий явно этого не требует. `action_url` должен быть внутренним разрешённым маршрутом frontend, а `data` — JSON-serializable.
+Для сообщений verb `chat_mention` создаётся только по проверенному структурированному полю `Message.mentions`, а не по произвольному тексту после `@`. Получатель упоминания исключается из обычного `chat_new_message` или `chat_reply` для этого же сообщения, поэтому одно действие не создаёт два уведомления. Персональное упоминание обходит mute конкретного чата (`ChatUserSettings.notifications_enabled=false`), но общие настройки канала, DND и системное разрешение Web Push продолжают применяться.
+
## Поток доставки
1. `notify.send` создаёт `Notification`.
@@ -177,4 +179,3 @@ pytest tests/api/v1/notifications tests/realtime -q
- Уведомление дублируется: найдите повторный `notify.send`; UI deduplication не должна маскировать backend double-create.
- Unread count устарел: операции read должны идти через API/model methods, которые инвалидируют cache и отправляют realtime state event.
- Email не приходит: проверьте frequency, DND, SMTP и поле `emailed`.
-
diff --git a/docs/guides/WEBSOCKET.md b/docs/guides/WEBSOCKET.md
index 726ea53f..0098152d 100644
--- a/docs/guides/WEBSOCKET.md
+++ b/docs/guides/WEBSOCKET.md
@@ -68,8 +68,8 @@ ASGI проверяет `Origin` через `AllowedHostsOriginValidator`. Ес
| --- | --- | --- |
| `open_chat` | `chat_id` | Активировать чат; optional `load_history` |
| `close_chat` | `chat_id` | Закрыть активный чат |
-| `send_message` | `content` или `text` | Отправить текст в активный чат |
-| `edit_message` | `message_id`, `content` | Изменить своё сообщение |
+| `send_message` | `content` или `text`; optional `mentioned_user_ids` | Отправить текст в активный чат |
+| `edit_message` | `message_id`, `content`; optional `mentioned_user_ids` | Изменить своё сообщение |
| `delete_message` | `message_id` | Soft-delete своего сообщения |
| `add_reaction` | `message_id`, `emoji` | Добавить реакцию |
| `remove_reaction` | `message_id`, `emoji` | Удалить реакцию |
@@ -87,6 +87,8 @@ ASGI проверяет `Origin` через `AllowedHostsOriginValidator`. Ес
{"action":"open_chat","chat_id":42,"load_history":false}
```
+`mentioned_user_ids` — массив максимум из 50 ID, выбранных autocomplete-ом. Consumer проверяет, что каждый пользователь имеет доступ к активному чату, и связывает ID только с реально присутствующим в тексте `@Полное имя`. События с сериализованным сообщением возвращают поле `mentions` в виде массива `{user_id, name, start, end}`. Если поле не передано при редактировании старым клиентом, существующие упоминания сохраняются и их диапазоны пересчитываются по новому тексту.
+
## События сервера
Основные публичные `type`:
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
index fcfd455a..9dd72626 100644
--- a/frontend/src/app/globals.css
+++ b/frontend/src/app/globals.css
@@ -303,6 +303,13 @@ html[data-bs-theme="dark"] .app-input::-webkit-calendar-picker-indicator {
box-shadow: var(--shadow-elevated);
}
+.app-menu,
+[role="menu"],
+[role="listbox"] {
+ max-width: calc(100vw - 1rem);
+ max-width: calc(100dvw - 1rem);
+}
+
.app-link-accent {
color: var(--accent-primary-strong);
}
diff --git a/frontend/src/app/messages/[chatId]/page.tsx b/frontend/src/app/messages/[chatId]/page.tsx
index 0944a812..ceba6859 100644
--- a/frontend/src/app/messages/[chatId]/page.tsx
+++ b/frontend/src/app/messages/[chatId]/page.tsx
@@ -693,20 +693,27 @@ function MessageDialogPageContent() {
requestAnimationFrame(() => focusMessageInput());
};
- const handleSend = async () => {
+ const handleSend = async (mentionedUserIds: number[] = []): Promise
Сообщение удалено
) : message.content && !message.poll ? ( -- {message.content} -
+Отреагировали