complete mattermost integration

This commit is contained in:
Ali Asadi 2026-06-13 13:43:29 +03:30
parent b7239defcf
commit dcb1873608
24 changed files with 509 additions and 40 deletions

41
.env.example Normal file
View file

@ -0,0 +1,41 @@
# Database
DB_NAME=gooyal_chat
DB_USER=postgres
DB_HOST=127.0.0.1
DB_PASSWORD=
DB_PORT=5432
DEBUG=true
# OAuth2 / SSO provider
OAUTH2_PROVIDER_BASE_PUBLIC_URL=https://accounts.example.com/oauth2
OAUTH2_PROVIDER_BASE_PRIVATE_URL=https://accounts.example.com/oauth2
OAUTH2_PROVIDER_CLIENT_ID=
OAUTH2_PROVIDER_CLIENT_SECRET=
OAUTH2_PROVIDER_SCOPES=
# Redis (used for cache, channel layer, and long-poll)
REDIS_BASE_URL=redis://127.0.0.1:6379/1
# Mattermost
# MATTERMOST_TOKEN must be a Personal Access Token (not a session token — session tokens expire)
# Enable at: System Console → Integrations → Integration Management → Enable Personal Access Tokens
MATTERMOST_URL=https://chat.example.com
MATTERMOST_TOKEN=
MATTERMOST_TEAM_ID=
MATTERMOST_SERVICE_USERNAME=chat-service
MATTERMOST_SERVICE_EMAIL=chat-service@local.invalid
# MinIO object storage
MINIO_ENDPOINT=localhost:9000
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET_CHAT=chat
# Chat long-poll tuning
CHAT_LONG_POLL_TIMEOUT_SECONDS=25
CHAT_LONG_POLL_INTERVAL_SECONDS=1
# Native library paths (macOS Homebrew — leave empty on Linux)
GDAL_LIBRARY_PATH=
GEOS_LIBRARY_PATH=

2
.gitignore vendored
View file

@ -5,3 +5,5 @@ media
/delme.py
/log/accounts.log
/log/errors.log
__pycache__
.claude

View file

@ -1,5 +1,6 @@
from uuid import UUID
from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView
@ -8,6 +9,7 @@ from apps.chat.models import ConversationParticipant
from apps.chat.serializers.conversations import (
ConversationListSerializer,
ConversationSerializer,
CreateConversationSerializer,
)
from apps.chat.services.conversation import ConversationService
from apps.chat.services.message import MessageService
@ -15,11 +17,21 @@ 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"])
authentication_classes = []
permission_classes = []
conversation = ConversationService().create(user_1_id, user_2_id)
@extend_schema(
request=CreateConversationSerializer,
responses={201: ConversationSerializer},
)
def post(self, request):
serializer = CreateConversationSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
conversation = ConversationService().create(
serializer.validated_data["user_1_id"],
serializer.validated_data["user_2_id"],
)
return Response(
ConversationSerializer(conversation).data,
@ -28,6 +40,16 @@ class ConversationCreateView(APIView):
class UserConversationListView(APIView):
authentication_classes = []
permission_classes = []
@extend_schema(
parameters=[
OpenApiParameter("page", int, description="0-based page number", default=0),
OpenApiParameter("page_size", int, description="Results per page", default=20),
],
responses={200: ConversationListSerializer(many=True)},
)
def get(self, request, user_id):
uid = UUID(str(user_id))
page = int(request.query_params.get("page", 0))

View file

@ -1,30 +1,50 @@
from uuid import UUID
from drf_spectacular.utils import OpenApiParameter, extend_schema
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.serializers.messages import MessageSerializer, SendMessageSerializer
from apps.chat.services.message import MessageService
class MessageView(APIView):
authentication_classes = []
permission_classes = []
@extend_schema(
request=SendMessageSerializer,
responses={201: MessageSerializer},
)
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")
serializer = SendMessageSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
result = MessageService().send(
conversation_id=UUID(str(chat_id)),
sender_id=sender_id,
message_type=message_type,
text=text,
file=file,
sender_id=data["sender_id"],
message_type=data["message_type"],
text=data.get("text"),
file=data.get("file"),
)
return Response(MessageSerializer(result).data, status=status.HTTP_201_CREATED)
@extend_schema(
parameters=[
OpenApiParameter("page", int, description="0-based page number", default=0),
OpenApiParameter("per_page", int, description="Results per page", default=20),
OpenApiParameter(
"since",
int,
required=False,
description="Return only posts after this Unix timestamp (milliseconds)",
),
],
responses={200: MessageSerializer(many=True)},
)
def get(self, request, chat_id):
page = int(request.query_params.get("page", 0))
per_page = int(request.query_params.get("per_page", 20))

View file

@ -3,33 +3,57 @@ from uuid import UUID
from django.conf import settings
from django_redis import get_redis_connection
from drf_spectacular.utils import extend_schema
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.chat.serializers.read_state import ReadStateSerializer
from apps.chat.serializers.messages import MessageSerializer
from apps.chat.serializers.read_state import MarkReadSerializer, ReadStateSerializer
from apps.chat.services.read_state import ReadStateService
class ReadStateView(APIView):
authentication_classes = []
permission_classes = []
@extend_schema(
request=MarkReadSerializer,
responses={200: ReadStateSerializer},
)
def post(self, request, chat_id):
user_id = UUID(request.data["user_id"])
post_id = request.data["post_id"]
serializer = MarkReadSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
read_state = ReadStateService().mark_read(
conversation_id=UUID(str(chat_id)),
user_id=user_id,
post_id=post_id,
user_id=data["user_id"],
post_id=data["post_id"],
)
data = {
return Response(
ReadStateSerializer(
{
"conversation_id": read_state.conversation_id,
"user_id": read_state.user_id,
"has_unread": False,
}
return Response(ReadStateSerializer(data).data)
).data
)
class ChatEventsView(APIView):
authentication_classes = []
permission_classes = []
@extend_schema(
responses={200: MessageSerializer(many=True)},
description=(
"Long-poll endpoint. Blocks up to CHAT_LONG_POLL_TIMEOUT_SECONDS "
"waiting for a new message event. Returns immediately when an event "
"arrives; returns an empty list on timeout."
),
)
def get(self, request, chat_id):
timeout = getattr(settings, "CHAT_LONG_POLL_TIMEOUT_SECONDS", 25)

View file

@ -1,8 +1,14 @@
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from apps.chat.models import Conversation, ConversationParticipant
class CreateConversationSerializer(serializers.Serializer):
user_1_id = serializers.UUIDField()
user_2_id = serializers.UUIDField()
class ConversationSerializer(serializers.ModelSerializer):
participants = serializers.SerializerMethodField()
@ -10,6 +16,7 @@ class ConversationSerializer(serializers.ModelSerializer):
model = Conversation
fields = ["id", "type", "created_at", "participants"]
@extend_schema_field(serializers.ListField(child=serializers.UUIDField()))
def get_participants(self, obj):
return list(
ConversationParticipant.objects.filter(conversation_id=obj.id)

View file

@ -1,6 +1,13 @@
from rest_framework import serializers
class SendMessageSerializer(serializers.Serializer):
sender_id = serializers.UUIDField()
message_type = serializers.ChoiceField(choices=["text", "image", "file"], default="text")
text = serializers.CharField(required=False, allow_null=True, allow_blank=True)
file = serializers.FileField(required=False, allow_null=True)
class MessageSerializer(serializers.Serializer):
post_id = serializers.CharField()
sender_id = serializers.UUIDField(allow_null=True)

View file

@ -1,6 +1,11 @@
from rest_framework import serializers
class MarkReadSerializer(serializers.Serializer):
user_id = serializers.UUIDField()
post_id = serializers.CharField()
class ReadStateSerializer(serializers.Serializer):
conversation_id = serializers.UUIDField()
user_id = serializers.UUIDField()

View file

@ -0,0 +1,73 @@
import uuid
from unittest.mock import Mock
import pytest
from apps.chat.models import (
Conversation,
ConversationParticipant,
MattermostAccountMapping,
)
from apps.chat.services.conversation import ConversationService
def _make_mm_client(mm_user_ids=("mm-u1", "mm-u2"), channel_id="mm-ch-1"):
client = Mock()
client.get_or_create_user.side_effect = list(mm_user_ids)
client.create_private_channel.return_value = channel_id
return client
@pytest.mark.django_db
def test_create_conversation_success():
user_1 = uuid.uuid4()
user_2 = uuid.uuid4()
svc = ConversationService(mattermost_client=_make_mm_client())
conv = svc.create(user_1, user_2)
assert Conversation.objects.filter(id=conv.id).exists()
participants = ConversationParticipant.objects.filter(conversation=conv)
assert participants.count() == 2
participant_ids = set(participants.values_list("user_id", flat=True))
assert user_1 in participant_ids
assert user_2 in participant_ids
@pytest.mark.django_db
def test_create_conversation_creates_mattermost_mapping():
user_1 = uuid.uuid4()
user_2 = uuid.uuid4()
mm_client = _make_mm_client(mm_user_ids=("mm-user-aaa", "mm-user-bbb"))
svc = ConversationService(mattermost_client=mm_client)
svc.create(user_1, user_2)
mapping_1 = MattermostAccountMapping.objects.get(user_id=user_1)
mapping_2 = MattermostAccountMapping.objects.get(user_id=user_2)
assert mapping_1.mattermost_user_id == "mm-user-aaa"
assert mapping_2.mattermost_user_id == "mm-user-bbb"
@pytest.mark.django_db
def test_list_for_user_returns_only_user_conversations():
target_user = uuid.uuid4()
other_user = uuid.uuid4()
conv_with_user = Conversation.objects.create(mattermost_channel_id="ch-target")
ConversationParticipant.objects.create(
conversation=conv_with_user, user_id=target_user
)
conv_without_user = Conversation.objects.create(mattermost_channel_id="ch-other")
ConversationParticipant.objects.create(
conversation=conv_without_user, user_id=other_user
)
svc = ConversationService(mattermost_client=Mock())
result = svc.list_for_user(target_user, page=0, page_size=20)
assert len(result) == 1
assert result[0].id == conv_with_user.id

View file

@ -0,0 +1,116 @@
import uuid
from unittest.mock import Mock
import pytest
from apps.chat.models import Conversation, MattermostAccountMapping
from apps.chat.services.message import MessageService
def _make_service(*, mm_post_id="post-1", storage_url=None, publishers=None):
"""Return a MessageService with all external deps mocked."""
mm_client = Mock()
mm_client.post_message.return_value = mm_post_id
storage = Mock()
if storage_url:
storage.upload_file.return_value = storage_url
return (
MessageService(
mattermost_client=mm_client,
storage_service=storage,
publishers=publishers if publishers is not None else [],
),
mm_client,
storage,
)
@pytest.mark.django_db
def test_send_text_message():
conv = Conversation.objects.create(mattermost_channel_id="ch-send-text")
sender_id = uuid.uuid4()
ws_publisher = Mock()
svc, mm_client, _ = _make_service(mm_post_id="post-abc", publishers=[ws_publisher])
result = svc.send(conv.id, sender_id, "text", text="Hello world")
mm_client.post_message.assert_called_once_with("ch-send-text", "Hello world")
ws_publisher.publish.assert_called_once()
event = ws_publisher.publish.call_args[0][0]
assert event.post_id == "post-abc"
assert event.sender_id == sender_id
assert event.message_type == "text"
assert result["post_id"] == "post-abc"
assert result["text"] == "Hello world"
@pytest.mark.django_db
def test_send_image_message():
conv = Conversation.objects.create(mattermost_channel_id="ch-send-image")
sender_id = uuid.uuid4()
file_url = "http://minio.local/chat/abc123/photo.jpg"
svc, mm_client, storage = _make_service(storage_url=file_url, publishers=[])
fake_file = Mock()
fake_file.name = "photo.jpg"
result = svc.send(conv.id, sender_id, "image", file=fake_file)
storage.upload_file.assert_called_once_with(fake_file, "photo.jpg")
mm_client.post_message.assert_called_once_with("ch-send-image", file_url)
assert result["url"] == file_url
@pytest.mark.django_db
def test_list_messages_normalized():
conv = Conversation.objects.create(mattermost_channel_id="ch-list")
sender_uuid = uuid.uuid4()
mm_user_id = "mm-user-xyz"
MattermostAccountMapping.objects.create(
user_id=sender_uuid, mattermost_user_id=mm_user_id
)
raw_posts = [
{
"id": "p1",
"user_id": mm_user_id,
"message": "First message",
"create_at": 1700000000000,
},
{
"id": "p2",
"user_id": mm_user_id,
"message": "http://minio.local/chat/file.pdf",
"create_at": 1700000001000,
},
]
mm_client = Mock()
mm_client.get_posts.return_value = raw_posts
svc, _, _ = _make_service(publishers=[])
svc._mm = mm_client # inject after construction to keep _make_service simple
messages = svc.list_messages(conv.id, page=0, per_page=20)
assert len(messages) == 2
text_msg = messages[0]
assert text_msg["post_id"] == "p1"
assert text_msg["sender_id"] == sender_uuid
assert text_msg["message_type"] == "text"
assert text_msg["text"] == "First message"
assert text_msg["url"] is None
file_msg = messages[1]
assert file_msg["post_id"] == "p2"
assert file_msg["sender_id"] == sender_uuid
assert file_msg["message_type"] == "file"
assert file_msg["url"] == "http://minio.local/chat/file.pdf"
assert file_msg["text"] is None

View file

@ -0,0 +1,86 @@
import uuid
from unittest.mock import Mock, patch
import pytest
from apps.chat.models import Conversation, ConversationReadState
from apps.chat.services.read_state import ReadStateService
def _make_conv(channel_id="ch-read"):
return Conversation.objects.create(mattermost_channel_id=channel_id)
@pytest.mark.django_db
def test_mark_read_creates_read_state():
conv = _make_conv()
user_id = uuid.uuid4()
svc = ReadStateService(mattermost_client=Mock())
read_state = svc.mark_read(conv.id, user_id, "post-111")
assert read_state.conversation_id == conv.id
assert read_state.user_id == user_id
assert read_state.last_read_mattermost_post_id == "post-111"
assert ConversationReadState.objects.filter(
conversation=conv, user_id=user_id
).count() == 1
@pytest.mark.django_db
def test_mark_read_updates_existing():
conv = _make_conv()
user_id = uuid.uuid4()
svc = ReadStateService(mattermost_client=Mock())
svc.mark_read(conv.id, user_id, "post-old")
svc.mark_read(conv.id, user_id, "post-new")
rows = ConversationReadState.objects.filter(conversation=conv, user_id=user_id)
assert rows.count() == 1
assert rows.first().last_read_mattermost_post_id == "post-new"
@pytest.mark.django_db
@patch("apps.chat.services.read_state.cache")
def test_has_unread_true(mock_cache):
mock_cache.get.return_value = None # force cache miss → MM lookup
conv = _make_conv()
user_id = uuid.uuid4()
ConversationReadState.objects.create(
conversation=conv,
user_id=user_id,
last_read_mattermost_post_id="post-old",
)
mm_client = Mock()
mm_client.get_latest_post_id.return_value = "post-new"
svc = ReadStateService(mattermost_client=mm_client)
result = svc.has_unread(conv.id, user_id)
assert result is True
mm_client.get_latest_post_id.assert_called_once_with(conv.mattermost_channel_id)
@pytest.mark.django_db
@patch("apps.chat.services.read_state.cache")
def test_has_unread_false(mock_cache):
mock_cache.get.return_value = None # force cache miss → MM lookup
conv = _make_conv()
user_id = uuid.uuid4()
ConversationReadState.objects.create(
conversation=conv,
user_id=user_id,
last_read_mattermost_post_id="post-current",
)
mm_client = Mock()
mm_client.get_latest_post_id.return_value = "post-current"
svc = ReadStateService(mattermost_client=mm_client)
result = svc.has_unread(conv.id, user_id)
assert result is False

View file

@ -0,0 +1,23 @@
import json
from channels.generic.websocket import AsyncWebsocketConsumer
class ChatConsumer(AsyncWebsocketConsumer):
async def connect(self):
self.chat_id = self.scope["url_route"]["kwargs"]["chat_id"]
self.group_name = f"chat_{self.chat_id}"
await self.channel_layer.group_add(self.group_name, self.channel_name)
await self.accept()
async def disconnect(self, close_code):
await self.channel_layer.group_discard(self.group_name, self.channel_name)
async def receive(self, text_data=None, bytes_data=None):
# Frontend receives only; messages are sent via HTTP API
pass
async def chat_message(self, event):
"""Handle chat.message events forwarded from the channel layer."""
payload = {k: v for k, v in event.items() if k != "type"}
await self.send(text_data=json.dumps(payload))

View file

@ -0,0 +1,7 @@
from django.urls import path
from apps.chat.websocket.consumers import ChatConsumer
websocket_urlpatterns = [
path("ws/chat/<uuid:chat_id>/", ChatConsumer.as_asgi()),
]

22
conftest.py Normal file
View file

@ -0,0 +1,22 @@
import pytest
@pytest.fixture(scope="session")
def django_db_setup(django_test_environment, django_db_blocker):
"""
Override pytest-django's default DB setup to:
- Use the local unix-socket superuser instead of the .env credentials
- Remove psycopg3 connection pooling (pool=True conflicts with test
transaction wrapping and causes PoolTimeout during test-DB creation)
"""
from django.conf import settings
from django.test.utils import setup_databases
db = settings.DATABASES["default"]
db["USER"] = "hashdal"
db["PASSWORD"] = ""
db["HOST"] = ""
db["OPTIONS"] = {} # clear pool=True; psycopg3 pool conflicts with pytest-django
with django_db_blocker.unblock():
setup_databases(verbosity=0, interactive=False)

View file

@ -6,22 +6,22 @@ It exposes the ASGI callable as a module-level variable named ``application``.
import os
from django.core.asgi import get_asgi_application
from apps.chat.websocket import websocket_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'main.settings')
django_asgi_application = get_asgi_application()
from django.core.asgi import get_asgi_application
django_asgi_app = get_asgi_application()
async def application(scope, receive, send):
if scope["type"] == "http":
await django_asgi_application(scope, receive, send)
return
from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
if scope["type"] == "websocket":
await websocket_application(scope, receive, send)
return
from apps.chat.websocket.routing import websocket_urlpatterns
raise ValueError(f"Unsupported ASGI scope type: {scope['type']}")
application = ProtocolTypeRouter(
{
"http": django_asgi_app,
"websocket": AuthMiddlewareStack(
URLRouter(websocket_urlpatterns)
),
}
)

View file

@ -223,9 +223,12 @@ SPECTACULAR_SETTINGS = {
'DESCRIPTION': 'service api reference',
'VERSION': '1.0.0',
'SERVE_INCLUDE_SCHEMA': False,
# OTHER SETTINGS
"PARSER_WHITELIST": ["rest_framework.parsers.JSONParser"],
'SERVE_PERMISSIONS': ['rest_framework.permissions.AllowAny'],
'SECURITY': [],
"PARSER_WHITELIST": [
"rest_framework.parsers.JSONParser",
"rest_framework.parsers.MultiPartParser",
],
}
AUTH_USER_MODEL = 'users.User'
@ -254,6 +257,15 @@ CACHES = {
}
}
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels_redis.core.RedisChannelLayer",
"CONFIG": {
"hosts": [config('REDIS_BASE_URL')],
},
}
}
from main.other_settings.logging import get_logging_setting
LOGGING = get_logging_setting()

2
pytest.ini Normal file
View file

@ -0,0 +1,2 @@
[pytest]
DJANGO_SETTINGS_MODULE = main.settings