feature/refactor_uuid #1
112 changed files with 1029 additions and 16 deletions
BIN
apps/chat/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
apps/chat/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
apps/chat/__pycache__/admin.cpython-313.pyc
Normal file
BIN
apps/chat/__pycache__/admin.cpython-313.pyc
Normal file
Binary file not shown.
BIN
apps/chat/__pycache__/apps.cpython-313.pyc
Normal file
BIN
apps/chat/__pycache__/apps.cpython-313.pyc
Normal file
Binary file not shown.
BIN
apps/chat/__pycache__/urls.cpython-313.pyc
Normal file
BIN
apps/chat/__pycache__/urls.cpython-313.pyc
Normal file
Binary file not shown.
0
apps/chat/api/__init__.py
Normal file
0
apps/chat/api/__init__.py
Normal file
BIN
apps/chat/api/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
apps/chat/api/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
apps/chat/api/__pycache__/urls.cpython-313.pyc
Normal file
BIN
apps/chat/api/__pycache__/urls.cpython-313.pyc
Normal file
Binary file not shown.
16
apps/chat/api/urls.py
Normal file
16
apps/chat/api/urls.py
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from apps.chat.api.views.conversations import (
|
||||||
|
ConversationCreateView,
|
||||||
|
UserConversationListView,
|
||||||
|
)
|
||||||
|
from apps.chat.api.views.messages import MessageView
|
||||||
|
from apps.chat.api.views.read_state import ChatEventsView, ReadStateView
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("chats/", ConversationCreateView.as_view(), name="chat-create"),
|
||||||
|
path("users/<uuid:user_id>/chats/", UserConversationListView.as_view(), name="user-chat-list"),
|
||||||
|
path("chats/<uuid:chat_id>/messages/", MessageView.as_view(), name="chat-messages"),
|
||||||
|
path("chats/<uuid:chat_id>/read/", ReadStateView.as_view(), name="chat-read"),
|
||||||
|
path("chats/<uuid:chat_id>/events/", ChatEventsView.as_view(), name="chat-events"),
|
||||||
|
]
|
||||||
0
apps/chat/api/views/__init__.py
Normal file
0
apps/chat/api/views/__init__.py
Normal file
BIN
apps/chat/api/views/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
apps/chat/api/views/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
apps/chat/api/views/__pycache__/conversations.cpython-313.pyc
Normal file
BIN
apps/chat/api/views/__pycache__/conversations.cpython-313.pyc
Normal file
Binary file not shown.
BIN
apps/chat/api/views/__pycache__/messages.cpython-313.pyc
Normal file
BIN
apps/chat/api/views/__pycache__/messages.cpython-313.pyc
Normal file
Binary file not shown.
BIN
apps/chat/api/views/__pycache__/read_state.cpython-313.pyc
Normal file
BIN
apps/chat/api/views/__pycache__/read_state.cpython-313.pyc
Normal file
Binary file not shown.
72
apps/chat/api/views/conversations.py
Normal file
72
apps/chat/api/views/conversations.py
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
from apps.chat.models import ConversationParticipant
|
||||||
|
from apps.chat.serializers.conversations import (
|
||||||
|
ConversationListSerializer,
|
||||||
|
ConversationSerializer,
|
||||||
|
)
|
||||||
|
from apps.chat.services.conversation import ConversationService
|
||||||
|
from apps.chat.services.message import MessageService
|
||||||
|
from apps.chat.services.read_state import ReadStateService
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationCreateView(APIView):
|
||||||
|
def post(self, request):
|
||||||
|
user_1_id = UUID(request.data["user_1_id"])
|
||||||
|
user_2_id = UUID(request.data["user_2_id"])
|
||||||
|
|
||||||
|
conversation = ConversationService().create(user_1_id, user_2_id)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
ConversationSerializer(conversation).data,
|
||||||
|
status=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UserConversationListView(APIView):
|
||||||
|
def get(self, request, user_id):
|
||||||
|
uid = UUID(str(user_id))
|
||||||
|
page = int(request.query_params.get("page", 0))
|
||||||
|
page_size = int(request.query_params.get("page_size", 20))
|
||||||
|
|
||||||
|
conv_svc = ConversationService()
|
||||||
|
read_svc = ReadStateService()
|
||||||
|
msg_svc = MessageService()
|
||||||
|
|
||||||
|
conversations = conv_svc.list_for_user(uid, page, page_size)
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for conv in conversations:
|
||||||
|
participants = list(
|
||||||
|
ConversationParticipant.objects.filter(conversation_id=conv.id)
|
||||||
|
.order_by("joined_at")
|
||||||
|
.values_list("user_id", flat=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
has_unread = read_svc.has_unread(conv.id, uid)
|
||||||
|
except Exception:
|
||||||
|
has_unread = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
last_msgs = msg_svc.list_messages(conv.id, page=0, per_page=1)
|
||||||
|
last_message = last_msgs[0] if last_msgs else None
|
||||||
|
except Exception:
|
||||||
|
last_message = None
|
||||||
|
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"id": conv.id,
|
||||||
|
"type": conv.type,
|
||||||
|
"created_at": conv.created_at,
|
||||||
|
"participants": participants,
|
||||||
|
"has_unread": has_unread,
|
||||||
|
"last_message": last_message,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(ConversationListSerializer(result, many=True).data)
|
||||||
41
apps/chat/api/views/messages.py
Normal file
41
apps/chat/api/views/messages.py
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
from apps.chat.serializers.messages import MessageSerializer
|
||||||
|
from apps.chat.services.message import MessageService
|
||||||
|
|
||||||
|
|
||||||
|
class MessageView(APIView):
|
||||||
|
def post(self, request, chat_id):
|
||||||
|
sender_id = UUID(request.data["sender_id"])
|
||||||
|
message_type = request.data.get("message_type", "text")
|
||||||
|
text = request.data.get("text")
|
||||||
|
file = request.FILES.get("file")
|
||||||
|
|
||||||
|
result = MessageService().send(
|
||||||
|
conversation_id=UUID(str(chat_id)),
|
||||||
|
sender_id=sender_id,
|
||||||
|
message_type=message_type,
|
||||||
|
text=text,
|
||||||
|
file=file,
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(MessageSerializer(result).data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
def get(self, request, chat_id):
|
||||||
|
page = int(request.query_params.get("page", 0))
|
||||||
|
per_page = int(request.query_params.get("per_page", 20))
|
||||||
|
since_raw = request.query_params.get("since")
|
||||||
|
since = int(since_raw) if since_raw else None
|
||||||
|
|
||||||
|
messages = MessageService().list_messages(
|
||||||
|
conversation_id=UUID(str(chat_id)),
|
||||||
|
page=page,
|
||||||
|
per_page=per_page,
|
||||||
|
since=since,
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(MessageSerializer(messages, many=True).data)
|
||||||
46
apps/chat/api/views/read_state.py
Normal file
46
apps/chat/api/views/read_state.py
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import json
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django_redis import get_redis_connection
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
from apps.chat.serializers.read_state import ReadStateSerializer
|
||||||
|
from apps.chat.services.read_state import ReadStateService
|
||||||
|
|
||||||
|
|
||||||
|
class ReadStateView(APIView):
|
||||||
|
def post(self, request, chat_id):
|
||||||
|
user_id = UUID(request.data["user_id"])
|
||||||
|
post_id = request.data["post_id"]
|
||||||
|
|
||||||
|
read_state = ReadStateService().mark_read(
|
||||||
|
conversation_id=UUID(str(chat_id)),
|
||||||
|
user_id=user_id,
|
||||||
|
post_id=post_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"conversation_id": read_state.conversation_id,
|
||||||
|
"user_id": read_state.user_id,
|
||||||
|
"has_unread": False,
|
||||||
|
}
|
||||||
|
return Response(ReadStateSerializer(data).data)
|
||||||
|
|
||||||
|
|
||||||
|
class ChatEventsView(APIView):
|
||||||
|
def get(self, request, chat_id):
|
||||||
|
timeout = getattr(settings, "CHAT_LONG_POLL_TIMEOUT_SECONDS", 25)
|
||||||
|
|
||||||
|
redis = get_redis_connection("default")
|
||||||
|
key = f"chat_events:{chat_id}"
|
||||||
|
|
||||||
|
result = redis.blpop(key, timeout=timeout)
|
||||||
|
|
||||||
|
if result is None:
|
||||||
|
return Response([])
|
||||||
|
|
||||||
|
_, raw = result
|
||||||
|
event = json.loads(raw)
|
||||||
|
return Response([event])
|
||||||
|
|
@ -3,3 +3,4 @@ from django.apps import AppConfig
|
||||||
|
|
||||||
class ChatConfig(AppConfig):
|
class ChatConfig(AppConfig):
|
||||||
name = 'apps.chat'
|
name = 'apps.chat'
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
|
|
||||||
0
apps/chat/events/__init__.py
Normal file
0
apps/chat/events/__init__.py
Normal file
BIN
apps/chat/events/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
apps/chat/events/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
apps/chat/events/__pycache__/event.cpython-313.pyc
Normal file
BIN
apps/chat/events/__pycache__/event.cpython-313.pyc
Normal file
Binary file not shown.
0
apps/chat/events/base.py
Normal file
0
apps/chat/events/base.py
Normal file
11
apps/chat/events/event.py
Normal file
11
apps/chat/events/event.py
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MessageSentEvent:
|
||||||
|
chat_id: UUID
|
||||||
|
post_id: str
|
||||||
|
sender_id: UUID
|
||||||
|
message_type: str
|
||||||
|
payload: dict
|
||||||
0
apps/chat/events/publishers/__init__.py
Normal file
0
apps/chat/events/publishers/__init__.py
Normal file
BIN
apps/chat/events/publishers/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
apps/chat/events/publishers/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
apps/chat/events/publishers/__pycache__/longpoll.cpython-313.pyc
Normal file
BIN
apps/chat/events/publishers/__pycache__/longpoll.cpython-313.pyc
Normal file
Binary file not shown.
BIN
apps/chat/events/publishers/__pycache__/push.cpython-313.pyc
Normal file
BIN
apps/chat/events/publishers/__pycache__/push.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
23
apps/chat/events/publishers/longpoll.py
Normal file
23
apps/chat/events/publishers/longpoll.py
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import json
|
||||||
|
|
||||||
|
from django_redis import get_redis_connection
|
||||||
|
|
||||||
|
from apps.chat.events.event import MessageSentEvent
|
||||||
|
|
||||||
|
_KEY_TTL = 300
|
||||||
|
|
||||||
|
|
||||||
|
class LongPollPublisher:
|
||||||
|
def publish(self, event: MessageSentEvent) -> None:
|
||||||
|
redis = get_redis_connection("default")
|
||||||
|
key = f"chat_events:{event.chat_id}"
|
||||||
|
payload = json.dumps(
|
||||||
|
{
|
||||||
|
"post_id": event.post_id,
|
||||||
|
"sender_id": str(event.sender_id),
|
||||||
|
"message_type": event.message_type,
|
||||||
|
**{k: v for k, v in event.payload.items() if v is not None},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
redis.rpush(key, payload)
|
||||||
|
redis.expire(key, _KEY_TTL)
|
||||||
7
apps/chat/events/publishers/push.py
Normal file
7
apps/chat/events/publishers/push.py
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
from apps.chat.events.event import MessageSentEvent
|
||||||
|
|
||||||
|
|
||||||
|
class PushPublisher:
|
||||||
|
# Future: FCM / APNS integration
|
||||||
|
def publish(self, event: MessageSentEvent) -> None:
|
||||||
|
pass
|
||||||
20
apps/chat/events/publishers/websocket.py
Normal file
20
apps/chat/events/publishers/websocket.py
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
from asgiref.sync import async_to_sync
|
||||||
|
from channels.layers import get_channel_layer
|
||||||
|
|
||||||
|
from apps.chat.events.event import MessageSentEvent
|
||||||
|
|
||||||
|
|
||||||
|
class WebSocketPublisher:
|
||||||
|
def publish(self, event: MessageSentEvent) -> None:
|
||||||
|
channel_layer = get_channel_layer()
|
||||||
|
group_name = f"chat_{event.chat_id}"
|
||||||
|
async_to_sync(channel_layer.group_send)(
|
||||||
|
group_name,
|
||||||
|
{
|
||||||
|
"type": "chat.message",
|
||||||
|
"post_id": event.post_id,
|
||||||
|
"sender_id": str(event.sender_id),
|
||||||
|
"message_type": event.message_type,
|
||||||
|
**event.payload,
|
||||||
|
},
|
||||||
|
)
|
||||||
0
apps/chat/integrations/__init__.py
Normal file
0
apps/chat/integrations/__init__.py
Normal file
BIN
apps/chat/integrations/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
apps/chat/integrations/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
0
apps/chat/integrations/mattermost/__init__.py
Normal file
0
apps/chat/integrations/mattermost/__init__.py
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
201
apps/chat/integrations/mattermost/client.py
Normal file
201
apps/chat/integrations/mattermost/client.py
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from mattermostdriver import Driver
|
||||||
|
from mattermostdriver.exceptions import ResourceNotFound
|
||||||
|
|
||||||
|
from apps.chat.integrations.mattermost.exceptions import MattermostError
|
||||||
|
|
||||||
|
|
||||||
|
class MattermostClient:
|
||||||
|
"""
|
||||||
|
Thin adapter around the Mattermost API.
|
||||||
|
|
||||||
|
All calls use a single service-level admin token — no per-user tokens.
|
||||||
|
Mattermost internal IDs (user IDs, channel IDs, post IDs) are opaque
|
||||||
|
strings from the perspective of callers; they must never appear in public
|
||||||
|
API responses or service-layer interfaces.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
url = getattr(settings, "MATTERMOST_URL", None)
|
||||||
|
token = getattr(settings, "MATTERMOST_TOKEN", None)
|
||||||
|
|
||||||
|
if not url:
|
||||||
|
raise MattermostError("MATTERMOST_URL is not configured")
|
||||||
|
if not token:
|
||||||
|
raise MattermostError("MATTERMOST_TOKEN is not configured")
|
||||||
|
|
||||||
|
parsed = urlparse(url)
|
||||||
|
scheme = parsed.scheme or "https"
|
||||||
|
hostname = parsed.hostname or url
|
||||||
|
port = parsed.port or (443 if scheme == "https" else 80)
|
||||||
|
|
||||||
|
self._team_id: str | None = getattr(settings, "MATTERMOST_TEAM_ID", None) or None
|
||||||
|
|
||||||
|
self._driver = Driver(
|
||||||
|
{
|
||||||
|
"url": hostname,
|
||||||
|
"token": token,
|
||||||
|
"scheme": scheme,
|
||||||
|
"port": port,
|
||||||
|
"verify": True,
|
||||||
|
"debug": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Internal helpers — username and email are deterministic from UUID
|
||||||
|
# so the same user always maps to the same Mattermost account.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _username_for(self, user_id: UUID) -> str:
|
||||||
|
return f"chat-{UUID(str(user_id)).hex}"
|
||||||
|
|
||||||
|
def _email_for(self, user_id: UUID) -> str:
|
||||||
|
return f"chat-{UUID(str(user_id)).hex}@mm.internal"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _random_password() -> str:
|
||||||
|
alphabet = string.ascii_letters + string.digits + "!@#$%"
|
||||||
|
return "".join(secrets.choice(alphabet) for _ in range(32))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public API — return only opaque strings, never raw Mattermost dicts
|
||||||
|
# with identifying fields surfaced beyond this layer.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_or_create_user(self, user_id: UUID) -> str:
|
||||||
|
"""
|
||||||
|
Ensure a Mattermost account exists for the given external UUID.
|
||||||
|
Returns the opaque mattermost_user_id string.
|
||||||
|
"""
|
||||||
|
username = self._username_for(user_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
user = self._driver.users.get_user_by_username(username)
|
||||||
|
return user["id"]
|
||||||
|
except ResourceNotFound:
|
||||||
|
pass
|
||||||
|
except Exception as exc:
|
||||||
|
raise MattermostError(f"User lookup failed: {exc}") from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
user = self._driver.users.create_user(
|
||||||
|
options={
|
||||||
|
"username": username,
|
||||||
|
"email": self._email_for(user_id),
|
||||||
|
"password": self._random_password(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return user["id"]
|
||||||
|
except Exception as exc:
|
||||||
|
# Guard against a concurrent request having created the account
|
||||||
|
# between our lookup and our create attempt.
|
||||||
|
try:
|
||||||
|
user = self._driver.users.get_user_by_username(username)
|
||||||
|
return user["id"]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise MattermostError(f"User creation failed: {exc}") from exc
|
||||||
|
|
||||||
|
def create_private_channel(self, mm_user_ids: list[str]) -> str:
|
||||||
|
"""
|
||||||
|
Create a new private Mattermost channel and add all members.
|
||||||
|
Each call always creates a brand-new channel.
|
||||||
|
Returns the opaque mattermost_channel_id string.
|
||||||
|
"""
|
||||||
|
if not self._team_id:
|
||||||
|
raise MattermostError("MATTERMOST_TEAM_ID is not configured")
|
||||||
|
|
||||||
|
channel_name = f"conv-{secrets.token_hex(8)}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
channel = self._driver.channels.create_channel(
|
||||||
|
options={
|
||||||
|
"team_id": self._team_id,
|
||||||
|
"name": channel_name,
|
||||||
|
"display_name": "Conversation",
|
||||||
|
"type": "P", # P = private channel (not a DM)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
raise MattermostError(f"Channel creation failed: {exc}") from exc
|
||||||
|
|
||||||
|
channel_id: str = channel["id"]
|
||||||
|
|
||||||
|
for mm_user_id in mm_user_ids:
|
||||||
|
try:
|
||||||
|
self._driver.channels.add_user(
|
||||||
|
channel_id, options={"user_id": mm_user_id}
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
raise MattermostError(
|
||||||
|
f"Adding user {mm_user_id} to channel failed: {exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
return channel_id
|
||||||
|
|
||||||
|
def post_message(self, channel_id: str, message: str) -> str:
|
||||||
|
"""
|
||||||
|
Post a message to the given channel using the admin token.
|
||||||
|
Returns the opaque mattermost_post_id string.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
post = self._driver.posts.create_post(
|
||||||
|
options={
|
||||||
|
"channel_id": channel_id,
|
||||||
|
"message": message,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return post["id"]
|
||||||
|
except Exception as exc:
|
||||||
|
raise MattermostError(f"Post creation failed: {exc}") from exc
|
||||||
|
|
||||||
|
def get_posts(
|
||||||
|
self,
|
||||||
|
channel_id: str,
|
||||||
|
page: int = 0,
|
||||||
|
per_page: int = 20,
|
||||||
|
since: int | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Fetch posts from a channel and return them in chronological order.
|
||||||
|
|
||||||
|
`since` is a Unix timestamp in **milliseconds**. When provided,
|
||||||
|
`page` is ignored and all posts after that timestamp are returned.
|
||||||
|
"""
|
||||||
|
params: dict = {"per_page": per_page}
|
||||||
|
if since is not None:
|
||||||
|
params["since"] = since
|
||||||
|
else:
|
||||||
|
params["page"] = page
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = self._driver.posts.get_posts_for_channel(channel_id, params=params)
|
||||||
|
except Exception as exc:
|
||||||
|
raise MattermostError(f"Fetching posts failed: {exc}") from exc
|
||||||
|
|
||||||
|
order: list[str] = data.get("order", [])
|
||||||
|
posts_map: dict = data.get("posts", {})
|
||||||
|
|
||||||
|
# Mattermost returns newest-first; reverse to get chronological order.
|
||||||
|
return [posts_map[pid] for pid in reversed(order) if pid in posts_map]
|
||||||
|
|
||||||
|
def get_latest_post_id(self, channel_id: str) -> str | None:
|
||||||
|
"""
|
||||||
|
Return the ID of the most recent post in the channel, or None if empty.
|
||||||
|
Used to derive `has_unread` against a stored last_read_mattermost_post_id.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = self._driver.posts.get_posts_for_channel(
|
||||||
|
channel_id, params={"page": 0, "per_page": 1}
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
raise MattermostError(f"Fetching latest post failed: {exc}") from exc
|
||||||
|
|
||||||
|
order: list[str] = data.get("order", [])
|
||||||
|
return order[0] if order else None
|
||||||
2
apps/chat/integrations/mattermost/exceptions.py
Normal file
2
apps/chat/integrations/mattermost/exceptions.py
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
class MattermostError(Exception):
|
||||||
|
"""Raised when any Mattermost API call fails."""
|
||||||
68
apps/chat/migrations/0001_initial.py
Normal file
68
apps/chat/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
# Generated by Django 5.2.13 on 2026-06-10 12:09
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Conversation',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('mattermost_channel_id', models.CharField(db_index=True, max_length=255, unique=True)),
|
||||||
|
('type', models.CharField(choices=[('direct', 'Direct'), ('group', 'Group')], default='direct', max_length=20)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'indexes': [models.Index(fields=['type'], name='chat_conver_type_857bd7_idx'), models.Index(fields=['created_at'], name='chat_conver_created_656b50_idx')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='MattermostAccountMapping',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('user_id', models.UUIDField(db_index=True, unique=True)),
|
||||||
|
('mattermost_user_id', models.CharField(db_index=True, max_length=255, unique=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'indexes': [models.Index(fields=['mattermost_user_id'], name='chat_matter_matterm_e23c84_idx')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ConversationParticipant',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('user_id', models.UUIDField()),
|
||||||
|
('joined_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('conversation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participants', to='chat.conversation')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'indexes': [models.Index(fields=['user_id'], name='chat_conver_user_id_697713_idx'), models.Index(fields=['conversation', 'user_id'], name='chat_conver_convers_59b4dd_idx')],
|
||||||
|
'constraints': [models.UniqueConstraint(fields=('conversation', 'user_id'), name='unique_participant_per_conversation')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ConversationReadState',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('user_id', models.UUIDField()),
|
||||||
|
('last_read_mattermost_post_id', models.CharField(blank=True, max_length=255, null=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('conversation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='read_states', to='chat.conversation')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'indexes': [models.Index(fields=['user_id'], name='chat_conver_user_id_e7609f_idx'), models.Index(fields=['conversation', 'user_id'], name='chat_conver_convers_9555aa_idx')],
|
||||||
|
'constraints': [models.UniqueConstraint(fields=('conversation', 'user_id'), name='unique_read_state_per_user')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
BIN
apps/chat/migrations/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
apps/chat/migrations/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
|
|
@ -1,3 +0,0 @@
|
||||||
from django.db import models
|
|
||||||
|
|
||||||
# Create your models here.
|
|
||||||
12
apps/chat/models/__init__.py
Normal file
12
apps/chat/models/__init__.py
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
from apps.chat.models.conversation import Conversation, ConversationType
|
||||||
|
from apps.chat.models.mapping import MattermostAccountMapping
|
||||||
|
from apps.chat.models.participant import ConversationParticipant
|
||||||
|
from apps.chat.models.read_state import ConversationReadState
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Conversation",
|
||||||
|
"ConversationType",
|
||||||
|
"ConversationParticipant",
|
||||||
|
"MattermostAccountMapping",
|
||||||
|
"ConversationReadState",
|
||||||
|
]
|
||||||
BIN
apps/chat/models/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
apps/chat/models/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
apps/chat/models/__pycache__/conversation.cpython-313.pyc
Normal file
BIN
apps/chat/models/__pycache__/conversation.cpython-313.pyc
Normal file
Binary file not shown.
BIN
apps/chat/models/__pycache__/mapping.cpython-313.pyc
Normal file
BIN
apps/chat/models/__pycache__/mapping.cpython-313.pyc
Normal file
Binary file not shown.
BIN
apps/chat/models/__pycache__/participant.cpython-313.pyc
Normal file
BIN
apps/chat/models/__pycache__/participant.cpython-313.pyc
Normal file
Binary file not shown.
BIN
apps/chat/models/__pycache__/read_state.cpython-313.pyc
Normal file
BIN
apps/chat/models/__pycache__/read_state.cpython-313.pyc
Normal file
Binary file not shown.
30
apps/chat/models/conversation.py
Normal file
30
apps/chat/models/conversation.py
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationType(models.TextChoices):
|
||||||
|
DIRECT = "direct", "Direct"
|
||||||
|
GROUP = "group", "Group"
|
||||||
|
|
||||||
|
|
||||||
|
class Conversation(models.Model):
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
# Internal only — never exposed through any public API or serializer
|
||||||
|
mattermost_channel_id = models.CharField(max_length=255, unique=True, db_index=True)
|
||||||
|
type = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=ConversationType.choices,
|
||||||
|
default=ConversationType.DIRECT,
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["type"]),
|
||||||
|
models.Index(fields=["created_at"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return str(self.id)
|
||||||
18
apps/chat/models/mapping.py
Normal file
18
apps/chat/models/mapping.py
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class MattermostAccountMapping(models.Model):
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
user_id = models.UUIDField(unique=True, db_index=True)
|
||||||
|
mattermost_user_id = models.CharField(max_length=255, unique=True, db_index=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["mattermost_user_id"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user_id} -> {self.mattermost_user_id}"
|
||||||
31
apps/chat/models/participant.py
Normal file
31
apps/chat/models/participant.py
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
from apps.chat.models.conversation import Conversation
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationParticipant(models.Model):
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
conversation = models.ForeignKey(
|
||||||
|
Conversation,
|
||||||
|
related_name="participants",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
)
|
||||||
|
user_id = models.UUIDField()
|
||||||
|
joined_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["conversation", "user_id"],
|
||||||
|
name="unique_participant_per_conversation",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["user_id"]),
|
||||||
|
models.Index(fields=["conversation", "user_id"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user_id} in {self.conversation_id}"
|
||||||
32
apps/chat/models/read_state.py
Normal file
32
apps/chat/models/read_state.py
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
from apps.chat.models.conversation import Conversation
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationReadState(models.Model):
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
conversation = models.ForeignKey(
|
||||||
|
Conversation,
|
||||||
|
related_name="read_states",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
)
|
||||||
|
user_id = models.UUIDField()
|
||||||
|
last_read_mattermost_post_id = models.CharField(max_length=255, null=True, blank=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["conversation", "user_id"],
|
||||||
|
name="unique_read_state_per_user",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["user_id"]),
|
||||||
|
models.Index(fields=["conversation", "user_id"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user_id} read state for {self.conversation_id}"
|
||||||
0
apps/chat/serializers/__init__.py
Normal file
0
apps/chat/serializers/__init__.py
Normal file
BIN
apps/chat/serializers/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
apps/chat/serializers/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
apps/chat/serializers/__pycache__/conversations.cpython-313.pyc
Normal file
BIN
apps/chat/serializers/__pycache__/conversations.cpython-313.pyc
Normal file
Binary file not shown.
BIN
apps/chat/serializers/__pycache__/messages.cpython-313.pyc
Normal file
BIN
apps/chat/serializers/__pycache__/messages.cpython-313.pyc
Normal file
Binary file not shown.
BIN
apps/chat/serializers/__pycache__/read_state.cpython-313.pyc
Normal file
BIN
apps/chat/serializers/__pycache__/read_state.cpython-313.pyc
Normal file
Binary file not shown.
27
apps/chat/serializers/conversations.py
Normal file
27
apps/chat/serializers/conversations.py
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from apps.chat.models import Conversation, ConversationParticipant
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationSerializer(serializers.ModelSerializer):
|
||||||
|
participants = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Conversation
|
||||||
|
fields = ["id", "type", "created_at", "participants"]
|
||||||
|
|
||||||
|
def get_participants(self, obj):
|
||||||
|
return list(
|
||||||
|
ConversationParticipant.objects.filter(conversation_id=obj.id)
|
||||||
|
.order_by("joined_at")
|
||||||
|
.values_list("user_id", flat=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationListSerializer(serializers.Serializer):
|
||||||
|
id = serializers.UUIDField()
|
||||||
|
type = serializers.CharField()
|
||||||
|
created_at = serializers.DateTimeField()
|
||||||
|
participants = serializers.ListField(child=serializers.UUIDField())
|
||||||
|
has_unread = serializers.BooleanField()
|
||||||
|
last_message = serializers.DictField(allow_null=True, required=False)
|
||||||
10
apps/chat/serializers/messages.py
Normal file
10
apps/chat/serializers/messages.py
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
|
||||||
|
class MessageSerializer(serializers.Serializer):
|
||||||
|
post_id = serializers.CharField()
|
||||||
|
sender_id = serializers.UUIDField(allow_null=True)
|
||||||
|
message_type = serializers.CharField(default="text")
|
||||||
|
text = serializers.CharField(allow_null=True, required=False)
|
||||||
|
url = serializers.CharField(allow_null=True, required=False)
|
||||||
|
created_at = serializers.DateTimeField(allow_null=True, required=False)
|
||||||
7
apps/chat/serializers/read_state.py
Normal file
7
apps/chat/serializers/read_state.py
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
|
||||||
|
class ReadStateSerializer(serializers.Serializer):
|
||||||
|
conversation_id = serializers.UUIDField()
|
||||||
|
user_id = serializers.UUIDField()
|
||||||
|
has_unread = serializers.BooleanField()
|
||||||
0
apps/chat/services/__init__.py
Normal file
0
apps/chat/services/__init__.py
Normal file
BIN
apps/chat/services/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
apps/chat/services/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
apps/chat/services/__pycache__/account.cpython-313.pyc
Normal file
BIN
apps/chat/services/__pycache__/account.cpython-313.pyc
Normal file
Binary file not shown.
BIN
apps/chat/services/__pycache__/conversation.cpython-313.pyc
Normal file
BIN
apps/chat/services/__pycache__/conversation.cpython-313.pyc
Normal file
Binary file not shown.
BIN
apps/chat/services/__pycache__/message.cpython-313.pyc
Normal file
BIN
apps/chat/services/__pycache__/message.cpython-313.pyc
Normal file
Binary file not shown.
BIN
apps/chat/services/__pycache__/read_state.cpython-313.pyc
Normal file
BIN
apps/chat/services/__pycache__/read_state.cpython-313.pyc
Normal file
Binary file not shown.
BIN
apps/chat/services/__pycache__/storage.cpython-313.pyc
Normal file
BIN
apps/chat/services/__pycache__/storage.cpython-313.pyc
Normal file
Binary file not shown.
7
apps/chat/services/account.py
Normal file
7
apps/chat/services/account.py
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
|
||||||
|
class AccountService:
|
||||||
|
def validate_user(self, user_id: UUID) -> bool:
|
||||||
|
# Future: call external Account Service HTTP API
|
||||||
|
return True
|
||||||
65
apps/chat/services/conversation.py
Normal file
65
apps/chat/services/conversation.py
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from apps.chat.integrations.mattermost.client import MattermostClient
|
||||||
|
from apps.chat.models import (
|
||||||
|
Conversation,
|
||||||
|
ConversationParticipant,
|
||||||
|
MattermostAccountMapping,
|
||||||
|
)
|
||||||
|
from apps.chat.services.account import AccountService
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
account_service: AccountService | None = None,
|
||||||
|
mattermost_client: MattermostClient | None = None,
|
||||||
|
):
|
||||||
|
self._account = account_service or AccountService()
|
||||||
|
self._mm = mattermost_client or MattermostClient()
|
||||||
|
|
||||||
|
def create(self, user_1_id: UUID, user_2_id: UUID) -> Conversation:
|
||||||
|
if not self._account.validate_user(user_1_id):
|
||||||
|
raise ValueError(f"User {user_1_id} is not valid")
|
||||||
|
if not self._account.validate_user(user_2_id):
|
||||||
|
raise ValueError(f"User {user_2_id} is not valid")
|
||||||
|
|
||||||
|
mm_user_1 = self._mm.get_or_create_user(user_1_id)
|
||||||
|
mm_user_2 = self._mm.get_or_create_user(user_2_id)
|
||||||
|
|
||||||
|
MattermostAccountMapping.objects.get_or_create(
|
||||||
|
user_id=user_1_id,
|
||||||
|
defaults={"mattermost_user_id": mm_user_1},
|
||||||
|
)
|
||||||
|
MattermostAccountMapping.objects.get_or_create(
|
||||||
|
user_id=user_2_id,
|
||||||
|
defaults={"mattermost_user_id": mm_user_2},
|
||||||
|
)
|
||||||
|
|
||||||
|
channel_id = self._mm.create_private_channel([mm_user_1, mm_user_2])
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
conversation = Conversation.objects.create(mattermost_channel_id=channel_id)
|
||||||
|
ConversationParticipant.objects.create(
|
||||||
|
conversation=conversation, user_id=user_1_id
|
||||||
|
)
|
||||||
|
ConversationParticipant.objects.create(
|
||||||
|
conversation=conversation, user_id=user_2_id
|
||||||
|
)
|
||||||
|
|
||||||
|
return conversation
|
||||||
|
|
||||||
|
def list_for_user(
|
||||||
|
self, user_id: UUID, page: int, page_size: int
|
||||||
|
) -> list[Conversation]:
|
||||||
|
offset = page * page_size
|
||||||
|
conversation_ids = ConversationParticipant.objects.filter(
|
||||||
|
user_id=user_id
|
||||||
|
).values_list("conversation_id", flat=True)
|
||||||
|
return list(
|
||||||
|
Conversation.objects.filter(id__in=conversation_ids).order_by(
|
||||||
|
"-created_at"
|
||||||
|
)[offset : offset + page_size]
|
||||||
|
)
|
||||||
118
apps/chat/services/message.py
Normal file
118
apps/chat/services/message.py
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from apps.chat.events.event import MessageSentEvent
|
||||||
|
from apps.chat.events.publishers.longpoll import LongPollPublisher
|
||||||
|
from apps.chat.events.publishers.push import PushPublisher
|
||||||
|
from apps.chat.events.publishers.websocket import WebSocketPublisher
|
||||||
|
from apps.chat.integrations.mattermost.client import MattermostClient
|
||||||
|
from apps.chat.models import Conversation, MattermostAccountMapping
|
||||||
|
from apps.chat.services.account import AccountService
|
||||||
|
from apps.chat.services.storage import StorageService
|
||||||
|
|
||||||
|
|
||||||
|
class MessageService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
account_service: AccountService | None = None,
|
||||||
|
storage_service: StorageService | None = None,
|
||||||
|
mattermost_client: MattermostClient | None = None,
|
||||||
|
publishers: list | None = None,
|
||||||
|
):
|
||||||
|
self._account = account_service or AccountService()
|
||||||
|
self._storage = storage_service or StorageService()
|
||||||
|
self._mm = mattermost_client or MattermostClient()
|
||||||
|
self._publishers = (
|
||||||
|
publishers
|
||||||
|
if publishers is not None
|
||||||
|
else [WebSocketPublisher(), PushPublisher(), LongPollPublisher()]
|
||||||
|
)
|
||||||
|
|
||||||
|
def send(
|
||||||
|
self,
|
||||||
|
conversation_id,
|
||||||
|
sender_id: UUID,
|
||||||
|
message_type: str,
|
||||||
|
text: str | None = None,
|
||||||
|
file=None,
|
||||||
|
) -> dict:
|
||||||
|
if not self._account.validate_user(sender_id):
|
||||||
|
raise ValueError(f"Sender {sender_id} is not valid")
|
||||||
|
|
||||||
|
file_url: str | None = None
|
||||||
|
if file is not None:
|
||||||
|
filename = getattr(file, "name", f"{sender_id}")
|
||||||
|
file_url = self._storage.upload_file(file, filename)
|
||||||
|
|
||||||
|
if message_type == "text":
|
||||||
|
mm_message = text or ""
|
||||||
|
else:
|
||||||
|
mm_message = file_url or text or ""
|
||||||
|
|
||||||
|
conversation = Conversation.objects.get(id=conversation_id)
|
||||||
|
post_id = self._mm.post_message(conversation.mattermost_channel_id, mm_message)
|
||||||
|
|
||||||
|
event = MessageSentEvent(
|
||||||
|
chat_id=conversation_id,
|
||||||
|
post_id=post_id,
|
||||||
|
sender_id=sender_id,
|
||||||
|
message_type=message_type,
|
||||||
|
payload={"text": text, "file_url": file_url},
|
||||||
|
)
|
||||||
|
for publisher in self._publishers:
|
||||||
|
publisher.publish(event)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"post_id": post_id,
|
||||||
|
"sender_id": sender_id,
|
||||||
|
"message_type": message_type,
|
||||||
|
"text": text,
|
||||||
|
"url": file_url,
|
||||||
|
"created_at": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def list_messages(
|
||||||
|
self,
|
||||||
|
conversation_id,
|
||||||
|
page: int,
|
||||||
|
per_page: int,
|
||||||
|
since: int | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
conversation = Conversation.objects.get(id=conversation_id)
|
||||||
|
raw_posts = self._mm.get_posts(
|
||||||
|
conversation.mattermost_channel_id,
|
||||||
|
page=page,
|
||||||
|
per_page=per_page,
|
||||||
|
since=since,
|
||||||
|
)
|
||||||
|
|
||||||
|
mm_user_ids = {p["user_id"] for p in raw_posts if p.get("user_id")}
|
||||||
|
mappings = {
|
||||||
|
m.mattermost_user_id: m.user_id
|
||||||
|
for m in MattermostAccountMapping.objects.filter(
|
||||||
|
mattermost_user_id__in=mm_user_ids
|
||||||
|
)
|
||||||
|
} if mm_user_ids else {}
|
||||||
|
|
||||||
|
return [self._normalize_post(p, mappings) for p in raw_posts]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_post(post: dict, mappings: dict) -> dict:
|
||||||
|
mm_uid = post.get("user_id")
|
||||||
|
sender_id = mappings.get(mm_uid)
|
||||||
|
msg = post.get("message", "")
|
||||||
|
is_url = msg.startswith("http")
|
||||||
|
created_ms = post.get("create_at")
|
||||||
|
created_at = (
|
||||||
|
datetime.fromtimestamp(created_ms / 1000, tz=timezone.utc)
|
||||||
|
if created_ms
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"post_id": post.get("id"),
|
||||||
|
"sender_id": sender_id,
|
||||||
|
"message_type": "file" if is_url else "text",
|
||||||
|
"text": None if is_url else msg,
|
||||||
|
"url": msg if is_url else None,
|
||||||
|
"created_at": created_at,
|
||||||
|
}
|
||||||
48
apps/chat/services/read_state.py
Normal file
48
apps/chat/services/read_state.py
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from django.core.cache import cache
|
||||||
|
|
||||||
|
from apps.chat.integrations.mattermost.client import MattermostClient
|
||||||
|
from apps.chat.models import Conversation, ConversationReadState
|
||||||
|
|
||||||
|
_LATEST_POST_CACHE_TTL = 60
|
||||||
|
|
||||||
|
|
||||||
|
class ReadStateService:
|
||||||
|
def __init__(self, mattermost_client: MattermostClient | None = None):
|
||||||
|
self._mm = mattermost_client or MattermostClient()
|
||||||
|
|
||||||
|
def mark_read(
|
||||||
|
self, conversation_id, user_id: UUID, post_id: str
|
||||||
|
) -> ConversationReadState:
|
||||||
|
read_state, _ = ConversationReadState.objects.update_or_create(
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
user_id=user_id,
|
||||||
|
defaults={"last_read_mattermost_post_id": post_id},
|
||||||
|
)
|
||||||
|
return read_state
|
||||||
|
|
||||||
|
def has_unread(self, conversation_id, user_id: UUID) -> bool:
|
||||||
|
try:
|
||||||
|
read_state = ConversationReadState.objects.get(
|
||||||
|
conversation_id=conversation_id, user_id=user_id
|
||||||
|
)
|
||||||
|
except ConversationReadState.DoesNotExist:
|
||||||
|
return True
|
||||||
|
|
||||||
|
last_read = read_state.last_read_mattermost_post_id
|
||||||
|
|
||||||
|
cache_key = f"latest_post:{conversation_id}"
|
||||||
|
latest_post_id = cache.get(cache_key)
|
||||||
|
|
||||||
|
if latest_post_id is None:
|
||||||
|
conversation = Conversation.objects.get(id=conversation_id)
|
||||||
|
latest_post_id = self._mm.get_latest_post_id(
|
||||||
|
conversation.mattermost_channel_id
|
||||||
|
)
|
||||||
|
cache.set(cache_key, latest_post_id or "", _LATEST_POST_CACHE_TTL)
|
||||||
|
|
||||||
|
if not latest_post_id:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return latest_post_id != last_read
|
||||||
12
apps/chat/services/realtime.py
Normal file
12
apps/chat/services/realtime.py
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
from asgiref.sync import async_to_sync
|
||||||
|
from channels.layers import get_channel_layer
|
||||||
|
|
||||||
|
|
||||||
|
class RealtimeService:
|
||||||
|
def publish_message(self, chat_id, payload: dict) -> None:
|
||||||
|
channel_layer = get_channel_layer()
|
||||||
|
group_name = f"chat_{chat_id}"
|
||||||
|
async_to_sync(channel_layer.group_send)(
|
||||||
|
group_name,
|
||||||
|
{"type": "chat.message", **payload},
|
||||||
|
)
|
||||||
42
apps/chat/services/storage.py
Normal file
42
apps/chat/services/storage.py
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import io
|
||||||
|
import mimetypes
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from minio import Minio
|
||||||
|
|
||||||
|
|
||||||
|
class StorageService:
|
||||||
|
def __init__(self):
|
||||||
|
endpoint = getattr(settings, "MINIO_ENDPOINT", None)
|
||||||
|
if not endpoint:
|
||||||
|
raise ValueError("MINIO_ENDPOINT is not configured")
|
||||||
|
|
||||||
|
secure = getattr(settings, "MINIO_SECURE", False)
|
||||||
|
self._client = Minio(
|
||||||
|
endpoint,
|
||||||
|
access_key=getattr(settings, "MINIO_ACCESS_KEY", None),
|
||||||
|
secret_key=getattr(settings, "MINIO_SECRET_KEY", None),
|
||||||
|
secure=secure,
|
||||||
|
)
|
||||||
|
self._bucket = getattr(settings, "MINIO_BUCKET_CHAT", "chat")
|
||||||
|
scheme = "https" if secure else "http"
|
||||||
|
self._base_url = f"{scheme}://{endpoint}"
|
||||||
|
|
||||||
|
def upload_file(self, file, filename: str) -> str:
|
||||||
|
data = file.read() if hasattr(file, "read") else file
|
||||||
|
size = len(data)
|
||||||
|
|
||||||
|
content_type, _ = mimetypes.guess_type(filename)
|
||||||
|
if not content_type:
|
||||||
|
content_type = "application/octet-stream"
|
||||||
|
|
||||||
|
object_name = f"{uuid4().hex}/{filename}"
|
||||||
|
self._client.put_object(
|
||||||
|
self._bucket,
|
||||||
|
object_name,
|
||||||
|
io.BytesIO(data),
|
||||||
|
size,
|
||||||
|
content_type=content_type,
|
||||||
|
)
|
||||||
|
return f"{self._base_url}/{self._bucket}/{object_name}"
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
# Create your tests here.
|
|
||||||
0
apps/chat/tests/__init__.py
Normal file
0
apps/chat/tests/__init__.py
Normal file
0
apps/chat/tests/test_conversations.py
Normal file
0
apps/chat/tests/test_conversations.py
Normal file
0
apps/chat/tests/test_messages.py
Normal file
0
apps/chat/tests/test_messages.py
Normal file
0
apps/chat/tests/test_read_state.py
Normal file
0
apps/chat/tests/test_read_state.py
Normal file
5
apps/chat/urls.py
Normal file
5
apps/chat/urls.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
from django.urls import path, include
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("api/", include("apps.chat.api.urls")),
|
||||||
|
]
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
from django.shortcuts import render
|
|
||||||
|
|
||||||
# Create your views here.
|
|
||||||
0
apps/chat/websocket/__init__.py
Normal file
0
apps/chat/websocket/__init__.py
Normal file
0
apps/chat/websocket/consumers.py
Normal file
0
apps/chat/websocket/consumers.py
Normal file
BIN
apps/core/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
apps/core/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
apps/core/__pycache__/admin.cpython-313.pyc
Normal file
BIN
apps/core/__pycache__/admin.cpython-313.pyc
Normal file
Binary file not shown.
BIN
apps/core/__pycache__/apps.cpython-313.pyc
Normal file
BIN
apps/core/__pycache__/apps.cpython-313.pyc
Normal file
Binary file not shown.
BIN
apps/core/__pycache__/models.cpython-313.pyc
Normal file
BIN
apps/core/__pycache__/models.cpython-313.pyc
Normal file
Binary file not shown.
BIN
apps/core/migrations/__pycache__/0001_initial.cpython-313.pyc
Normal file
BIN
apps/core/migrations/__pycache__/0001_initial.cpython-313.pyc
Normal file
Binary file not shown.
BIN
apps/core/migrations/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
apps/core/migrations/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
apps/gooyal_oauth2/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
apps/gooyal_oauth2/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
apps/gooyal_oauth2/__pycache__/admin.cpython-313.pyc
Normal file
BIN
apps/gooyal_oauth2/__pycache__/admin.cpython-313.pyc
Normal file
Binary file not shown.
BIN
apps/gooyal_oauth2/__pycache__/apps.cpython-313.pyc
Normal file
BIN
apps/gooyal_oauth2/__pycache__/apps.cpython-313.pyc
Normal file
Binary file not shown.
BIN
apps/gooyal_oauth2/__pycache__/models.cpython-313.pyc
Normal file
BIN
apps/gooyal_oauth2/__pycache__/models.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
apps/users/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
apps/users/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
apps/users/__pycache__/admin.cpython-313.pyc
Normal file
BIN
apps/users/__pycache__/admin.cpython-313.pyc
Normal file
Binary file not shown.
BIN
apps/users/__pycache__/apps.cpython-313.pyc
Normal file
BIN
apps/users/__pycache__/apps.cpython-313.pyc
Normal file
Binary file not shown.
BIN
apps/users/__pycache__/models.cpython-313.pyc
Normal file
BIN
apps/users/__pycache__/models.cpython-313.pyc
Normal file
Binary file not shown.
BIN
apps/users/migrations/__pycache__/0001_initial.cpython-313.pyc
Normal file
BIN
apps/users/migrations/__pycache__/0001_initial.cpython-313.pyc
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue