add websocket

This commit is contained in:
rookit
2026-06-29 13:46:26 +08:00
parent 7cad6783e9
commit 4753f945f1
25 changed files with 802 additions and 20 deletions
+7
View File
@@ -58,5 +58,12 @@ def create_record_comment(
object_id=comment.object_id,
metadata={"source": "comment", "comment_id": comment.id},
)
transaction.on_commit(lambda comment_id=comment.id: _broadcast_comment_created(comment_id))
return comment
def _broadcast_comment_created(comment_id):
from apps.realtime.events import broadcast_comment_created
broadcast_comment_created(comment_id)
+14
View File
@@ -1,4 +1,5 @@
from django.contrib.contenttypes.models import ContentType
from django.db import transaction
from rest_framework import viewsets, permissions
from rest_framework.exceptions import PermissionDenied
from rest_framework.response import Response
@@ -36,4 +37,17 @@ class CommentViewSet(viewsets.ModelViewSet):
def perform_destroy(self, instance):
if instance.author_id != self.request.user.id:
raise PermissionDenied("You can only delete your own comments.")
comment_id = instance.id
content_type = instance.content_type.model
object_id = instance.object_id
actor_id = self.request.user.id
instance.delete()
transaction.on_commit(
lambda: _broadcast_comment_deleted(comment_id, content_type, object_id, actor_id=actor_id)
)
def _broadcast_comment_deleted(comment_id, content_type, object_id, *, actor_id):
from apps.realtime.events import broadcast_comment_deleted
broadcast_comment_deleted(comment_id, content_type, object_id, actor_id=actor_id)
+14 -2
View File
@@ -237,8 +237,20 @@ class InboxReplySerializer(serializers.Serializer):
def mark_message_read(message, user):
InboxMessageRecipient.objects.filter(
read_at = timezone.now()
updated = InboxMessageRecipient.objects.filter(
message=message,
user=user,
read_at__isnull=True,
).update(read_at=timezone.now())
).update(read_at=read_at)
if updated:
from django.db import transaction
from apps.realtime.events import broadcast_inbox_message_read
transaction.on_commit(
lambda message_id=message.id, user_id=user.id, timestamp=read_at: broadcast_inbox_message_read(
message_id,
user_id,
timestamp,
)
)
+7
View File
@@ -188,9 +188,16 @@ def create_inbox_message(
])
if attachment_ids:
message.attachments.set(Attachment.objects.filter(id__in=attachment_ids))
transaction.on_commit(lambda message_id=message.id: _broadcast_inbox_message_created(message_id))
return message
def _broadcast_inbox_message_created(message_id):
from apps.realtime.events import broadcast_inbox_message_created
broadcast_inbox_message_created(message_id)
def send_system_message(
*,
recipients,
+33 -1
View File
@@ -1,3 +1,4 @@
from django.db import transaction
from django.db.models import Prefetch, Q
from django.utils import timezone
from rest_framework import permissions, status, viewsets
@@ -75,7 +76,15 @@ class InboxMessageViewSet(viewsets.ModelViewSet):
raise PermissionDenied("Viewer users cannot delete messages.")
if instance.kind != InboxMessage.KIND_USER or instance.sender_id != self.request.user.id:
raise PermissionDenied("You can only delete your own user messages.")
user_ids = set(instance.recipients.values_list("id", flat=True))
if instance.sender_id:
user_ids.add(instance.sender_id)
message_id = instance.id
actor_id = self.request.user.id
instance.delete()
transaction.on_commit(
lambda: _broadcast_inbox_message_deleted(message_id, list(user_ids), actor_id=actor_id)
)
@action(detail=False, methods=["get"], url_path="unread-count")
def unread_count(self, request):
@@ -96,10 +105,21 @@ class InboxMessageViewSet(viewsets.ModelViewSet):
@action(detail=False, methods=["post"], url_path="mark-all-read")
def mark_all_read(self, request):
message_ids = list(
InboxMessageRecipient.objects.filter(
user=request.user,
read_at__isnull=True,
).values_list("message_id", flat=True)
)
read_at = timezone.now()
updated = InboxMessageRecipient.objects.filter(
user=request.user,
read_at__isnull=True,
).update(read_at=timezone.now())
).update(read_at=read_at)
if updated:
transaction.on_commit(
lambda: _broadcast_inbox_all_read(request.user.id, message_ids, read_at)
)
return Response({"updated": updated})
@action(detail=True, methods=["post"])
@@ -112,3 +132,15 @@ class InboxMessageViewSet(viewsets.ModelViewSet):
message = serializer.save()
output = InboxMessageSerializer(message, context=self.get_serializer_context())
return Response(output.data, status=status.HTTP_201_CREATED)
def _broadcast_inbox_message_deleted(message_id, user_ids, *, actor_id):
from apps.realtime.events import broadcast_inbox_message_deleted
broadcast_inbox_message_deleted(message_id, user_ids, actor_id=actor_id)
def _broadcast_inbox_all_read(user_id, message_ids, read_at):
from apps.realtime.events import broadcast_inbox_all_read
broadcast_inbox_all_read(user_id, message_ids, read_at)
View File
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class RealtimeConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.realtime"
+48
View File
@@ -0,0 +1,48 @@
from urllib.parse import parse_qs
from channels.db import database_sync_to_async
from django.contrib.auth.models import AnonymousUser
from rest_framework_simplejwt.authentication import JWTAuthentication
class JWTAuthMiddleware:
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
scope = dict(scope)
scope["user"] = await self._authenticate(scope)
return await self.app(scope, receive, send)
@database_sync_to_async
def _authenticate(self, scope):
token = self._token_from_scope(scope)
if not token:
return AnonymousUser()
authenticator = JWTAuthentication()
try:
validated_token = authenticator.get_validated_token(token)
return authenticator.get_user(validated_token)
except Exception:
return AnonymousUser()
def _token_from_scope(self, scope):
query_string = scope.get("query_string", b"").decode("utf-8")
token = parse_qs(query_string).get("token", [""])[0]
if token:
return token
for name, value in scope.get("headers", []):
if name != b"sec-websocket-protocol":
continue
protocols = [
part.strip()
for part in value.decode("utf-8").split(",")
if part.strip()
]
for protocol in protocols:
if protocol.startswith("bearer."):
return protocol.removeprefix("bearer.")
return ""
+88
View File
@@ -0,0 +1,88 @@
from channels.db import database_sync_to_async
from channels.generic.websocket import AsyncJsonWebsocketConsumer
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from .groups import comments_group_name, inbox_group_name
class EventsConsumer(AsyncJsonWebsocketConsumer):
async def connect(self):
user = self.scope.get("user")
if not user or not user.is_authenticated:
await self.close(code=4401)
return
self.comment_groups = set()
self.inbox_group = inbox_group_name(user.id)
await self.channel_layer.group_add(self.inbox_group, self.channel_name)
await self.accept()
await self.send_json({"type": "realtime.connected"})
async def disconnect(self, close_code):
if hasattr(self, "inbox_group"):
await self.channel_layer.group_discard(self.inbox_group, self.channel_name)
for group_name in getattr(self, "comment_groups", set()):
await self.channel_layer.group_discard(group_name, self.channel_name)
async def receive_json(self, content, **kwargs):
message_type = content.get("type")
if message_type == "comments.subscribe":
await self._subscribe_comments(content)
return
if message_type == "comments.unsubscribe":
await self._unsubscribe_comments(content)
return
await self.send_json({"type": "realtime.error", "payload": {"detail": "Unknown message type."}})
async def _subscribe_comments(self, content):
content_type = str(content.get("content_type") or "")
object_id = str(content.get("object_id") or "")
if not await self._can_subscribe_to_record(content_type, object_id):
await self.send_json({
"type": "realtime.error",
"payload": {"detail": "Cannot subscribe to record comments."},
})
return
group_name = comments_group_name(content_type, object_id)
await self.channel_layer.group_add(group_name, self.channel_name)
self.comment_groups.add(group_name)
await self.send_json({
"type": "comments.subscribed",
"payload": {"content_type": content_type, "object_id": object_id},
})
async def _unsubscribe_comments(self, content):
content_type = str(content.get("content_type") or "")
object_id = str(content.get("object_id") or "")
group_name = comments_group_name(content_type, object_id)
if group_name in self.comment_groups:
self.comment_groups.remove(group_name)
await self.channel_layer.group_discard(group_name, self.channel_name)
await self.send_json({
"type": "comments.unsubscribed",
"payload": {"content_type": content_type, "object_id": object_id},
})
@database_sync_to_async
def _can_subscribe_to_record(self, content_type, object_id):
if not content_type or not object_id:
return False
try:
content_type_obj = ContentType.objects.get(model=content_type)
except ContentType.DoesNotExist:
return False
model_class = content_type_obj.model_class()
if not model_class:
return False
try:
return model_class._default_manager.filter(pk=object_id).exists()
except (ValidationError, ValueError):
return False
async def realtime_event(self, event):
await self.send_json(event["event"])
+171
View File
@@ -0,0 +1,171 @@
import uuid
import logging
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from django.contrib.auth import get_user_model
from django.utils import timezone
from redis.exceptions import RedisError
from .groups import comments_group_name, inbox_group_name
User = get_user_model()
logger = logging.getLogger(__name__)
def _event(event_type, payload, *, actor_id=None):
return {
"type": event_type,
"event_id": str(uuid.uuid4()),
"occurred_at": timezone.now().isoformat(),
"actor_id": actor_id,
"payload": payload,
}
def _send_group(group_name, event):
channel_layer = get_channel_layer()
if channel_layer is None:
logger.warning("Realtime channel layer is not configured; skipped event type=%s group=%s", event.get("type"), group_name)
return
try:
async_to_sync(channel_layer.group_send)(
group_name,
{
"type": "realtime.event",
"event": event,
},
)
except RedisError:
logger.exception("Failed to publish realtime event type=%s group=%s", event.get("type"), group_name)
def _unread_count(user_id):
from apps.inbox.models import InboxMessageRecipient
return InboxMessageRecipient.objects.filter(user_id=user_id, read_at__isnull=True).count()
def _send_unread_count_changed(user_id):
_send_group(
inbox_group_name(user_id),
_event(
"inbox.unread_count_changed",
{"count": _unread_count(user_id)},
),
)
def _serialize_inbox_message(message, user):
from types import SimpleNamespace
from apps.inbox.serializers import InboxMessageSerializer
return InboxMessageSerializer(message, context={"request": SimpleNamespace(user=user)}).data
def broadcast_inbox_message_created(message_id):
from apps.inbox.models import InboxMessage
message = (
InboxMessage.objects
.select_related("sender", "content_type", "parent", "parent__sender")
.prefetch_related("attachments", "recipients")
.get(pk=message_id)
)
users = list(message.recipients.all())
if message.sender_id and all(user.id != message.sender_id for user in users):
users.append(message.sender)
for user in users:
_send_group(
inbox_group_name(user.id),
_event(
"inbox.message_created",
{"message": _serialize_inbox_message(message, user)},
actor_id=message.sender_id,
),
)
_send_unread_count_changed(user.id)
def broadcast_inbox_message_deleted(message_id, user_ids, *, actor_id=None):
for user_id in user_ids:
_send_group(
inbox_group_name(user_id),
_event(
"inbox.message_deleted",
{"message_id": message_id},
actor_id=actor_id,
),
)
_send_unread_count_changed(user_id)
def broadcast_inbox_message_read(message_id, user_id, read_at):
_send_group(
inbox_group_name(user_id),
_event(
"inbox.message_read",
{"message_id": message_id, "read_at": read_at.isoformat()},
actor_id=user_id,
),
)
_send_unread_count_changed(user_id)
def broadcast_inbox_all_read(user_id, message_ids, read_at):
_send_group(
inbox_group_name(user_id),
_event(
"inbox.all_read",
{"message_ids": message_ids, "read_at": read_at.isoformat()},
actor_id=user_id,
),
)
_send_unread_count_changed(user_id)
def _serialize_comment(comment):
from apps.comments.serializers import CommentSerializer
return CommentSerializer(comment).data
def broadcast_comment_created(comment_id):
from apps.comments.models import Comment
comment = (
Comment.objects
.select_related("author", "content_type", "parent", "parent__author")
.prefetch_related("mentions", "attachments")
.get(pk=comment_id)
)
content_type = comment.content_type.model
_send_group(
comments_group_name(content_type, comment.object_id),
_event(
"comment.created",
{
"content_type": content_type,
"object_id": comment.object_id,
"comment": _serialize_comment(comment),
},
actor_id=comment.author_id,
),
)
def broadcast_comment_deleted(comment_id, content_type, object_id, *, actor_id=None):
_send_group(
comments_group_name(content_type, object_id),
_event(
"comment.deleted",
{
"content_type": content_type,
"object_id": object_id,
"comment_id": comment_id,
},
actor_id=actor_id,
),
)
+12
View File
@@ -0,0 +1,12 @@
import hashlib
def inbox_group_name(user_id):
return f"inbox.user.{user_id}"
def comments_group_name(content_type, object_id):
identity = f"{content_type}:{object_id}"
digest = hashlib.sha256(identity.encode("utf-8")).hexdigest()
return f"comments.record.{digest}"
+7
View File
@@ -0,0 +1,7 @@
from django.urls import path
from .consumers import EventsConsumer
websocket_urlpatterns = [
path("ws/events/", EventsConsumer.as_asgi()),
]
+10 -1
View File
@@ -8,6 +8,7 @@ import os
from django.core.asgi import get_asgi_application
from starlette.applications import Starlette
from starlette.routing import Mount
from channels.routing import ProtocolTypeRouter, URLRouter
from apps.common.logging import configure_process_file_logging
@@ -17,6 +18,8 @@ django_application = get_asgi_application()
configure_process_file_logging("asgi")
from apps.mcp.asgi import mcp_asgi_app, mcp_server # noqa: E402
from apps.realtime.auth import JWTAuthMiddleware # noqa: E402
from apps.realtime.routing import websocket_urlpatterns # noqa: E402
@contextlib.asynccontextmanager
@@ -25,10 +28,16 @@ async def lifespan(app):
yield
application = Starlette(
http_application = Starlette(
routes=[
Mount("/api/mcp", app=mcp_asgi_app),
Mount("/", app=django_application),
],
lifespan=lifespan,
)
application = ProtocolTypeRouter({
"http": http_application,
"lifespan": http_application,
"websocket": JWTAuthMiddleware(URLRouter(websocket_urlpatterns)),
})
+16
View File
@@ -30,6 +30,7 @@ INSTALLED_APPS = [
"corsheaders",
"django_filters",
"storages",
"channels",
# Local apps
"apps.common",
"apps.dashboard",
@@ -45,6 +46,7 @@ INSTALLED_APPS = [
"apps.attachments",
"apps.audit",
"apps.inbox",
"apps.realtime",
"apps.webhook",
"apps.mcp",
"apps.agentic",
@@ -80,6 +82,7 @@ TEMPLATES = [
]
WSGI_APPLICATION = "asp.wsgi.application"
ASGI_APPLICATION = "asp.asgi.application"
DATABASES = {
"default": {
@@ -98,6 +101,7 @@ REDIS_DB = os.environ.get("REDIS_DB", "1")
REDIS_PASSWORD = os.environ.get("REDIS_PASSWORD", "")
REDIS_AUTH = f":{quote(REDIS_PASSWORD, safe='')}@" if REDIS_PASSWORD else ""
REDIS_URL = f"redis://{REDIS_AUTH}{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}"
CHANNEL_REDIS_SOCKET_TIMEOUT = int(os.environ.get("CHANNEL_REDIS_SOCKET_TIMEOUT", "10"))
CACHES = {
"default": {
@@ -109,6 +113,18 @@ CACHES = {
}
}
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels_redis.core.RedisChannelLayer",
"CONFIG": {
"hosts": [{
"address": REDIS_URL,
"socket_timeout": CHANNEL_REDIS_SOCKET_TIMEOUT,
}],
},
},
}
AUTH_USER_MODEL = "accounts.User"
REST_FRAMEWORK = {
+2
View File
@@ -3,6 +3,8 @@ name = "asp"
version = "0.1.0"
requires-python = ">=3.14"
dependencies = [
"channels>=4.3.2",
"channels-redis>=4.3.0",
"django>=6.0.6",
"djangorestframework==3.17.1",
"djangorestframework-simplejwt==5.5.1",
+62
View File
@@ -46,6 +46,8 @@ name = "asp"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "channels" },
{ name = "channels-redis" },
{ name = "django" },
{ name = "django-cors-headers" },
{ name = "django-filter" },
@@ -71,6 +73,8 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "channels", specifier = ">=4.3.2" },
{ name = "channels-redis", specifier = ">=4.3.0" },
{ name = "django", specifier = ">=6.0.6" },
{ name = "django-cors-headers", specifier = "==4.9.0" },
{ name = "django-filter", specifier = "==25.2" },
@@ -173,6 +177,34 @@ wheels = [
{ url = "https://mirrors.aliyun.com/pypi/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9" },
]
[[package]]
name = "channels"
version = "4.3.2"
source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }
dependencies = [
{ name = "asgiref" },
{ name = "django" },
]
sdist = { url = "https://mirrors.aliyun.com/pypi/packages/74/92/b18d4bb54d14986a8b35215a1c9e6a7f9f4d57ca63ac9aee8290ebb4957d/channels-4.3.2.tar.gz", hash = "sha256:f2bb6bfb73ad7fb4705041d07613c7b4e69528f01ef8cb9fb6c21d9295f15667" }
wheels = [
{ url = "https://mirrors.aliyun.com/pypi/packages/16/34/c32915288b7ef482377b6adc401192f98c6a99b3a145423d3b8aed807898/channels-4.3.2-py3-none-any.whl", hash = "sha256:fef47e9055a603900cf16cef85f050d522d9ac4b3daccf24835bd9580705c176" },
]
[[package]]
name = "channels-redis"
version = "4.3.0"
source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }
dependencies = [
{ name = "asgiref" },
{ name = "channels" },
{ name = "msgpack" },
{ name = "redis" },
]
sdist = { url = "https://mirrors.aliyun.com/pypi/packages/ab/69/fd3407ad407a80e72ca53850eb7a4c306273e67d5bbb71a86d0e6d088439/channels_redis-4.3.0.tar.gz", hash = "sha256:740ee7b54f0e28cf2264a940a24453d3f00526a96931f911fcb69228ef245dd2" }
wheels = [
{ url = "https://mirrors.aliyun.com/pypi/packages/df/fe/b7224a401ad227b263e5ba84753ffb5a88df048f3b15efd2797903543ce4/channels_redis-4.3.0-py3-none-any.whl", hash = "sha256:48f3e902ae2d5fef7080215524f3b4a1d3cea4e304150678f867a1a822c0d9f5" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.7"
@@ -767,6 +799,36 @@ wheels = [
{ url = "https://mirrors.aliyun.com/pypi/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8" },
]
[[package]]
name = "msgpack"
version = "1.2.1"
source = { registry = "https://mirrors.aliyun.com/pypi/simple/" }
sdist = { url = "https://mirrors.aliyun.com/pypi/packages/31/f9/c0a1c127f9049db9155afc316952ea571720dd01833ff5e4d7e8e6352dbb/msgpack-1.2.1.tar.gz", hash = "sha256:04c721c2c7448767e9e3f2520a475663d8ee0f09c31890f6d2bd70fd636a9647" }
wheels = [
{ url = "https://mirrors.aliyun.com/pypi/packages/77/58/cce442852c6b9e1639c7c8ac8fd9143121cb32dab0f308df4d1426a8eb9c/msgpack-1.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:05f340e47e7e47d2da8db9b53e1bb1d294369e9ef45a747441309f6650b8351d" },
{ url = "https://mirrors.aliyun.com/pypi/packages/60/5c/15b4c7a0182f75ffa90751958ba36a9c01cafee367d49a3edc10ed140b01/msgpack-1.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:810b916696c86ef0deb3b74588480224df4c1b071136c34183e4a2a4284d7ac7" },
{ url = "https://mirrors.aliyun.com/pypi/packages/b8/a6/99e58722feaffc5f2fbcc0c8c0d1451ab9f84097f7af87291b46af2390f4/msgpack-1.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ca0dacff965c47afdc3749a8469d7302a8f801d6a28758d55120d75e66ce6889" },
{ url = "https://mirrors.aliyun.com/pypi/packages/19/03/8c63e8cf52958534ef688625965ab04c269a6cadd8caef16758b380a821a/msgpack-1.2.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e2bf9280bceb5efca998435904b5d3e9fdbcc11d90dc9df30aec7973252b720" },
{ url = "https://mirrors.aliyun.com/pypi/packages/63/d2/155d9e71b40e41fd934bc0c48b9b2770f22263e1ac20aad8e29fdca7be3f/msgpack-1.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6c4be5d1c02a42b066ca6ddb71adf36432868fdcdb6ee87e634e86e0674190" },
{ url = "https://mirrors.aliyun.com/pypi/packages/98/48/deaf2326262a8d5ea3295ce9649912ecd3f551ba7ec8e33c665d2ba583f3/msgpack-1.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec0e675d59150a6269ddc9139087c722292664a37d071a849c05c473350f1f2d" },
{ url = "https://mirrors.aliyun.com/pypi/packages/10/2a/b4410f906c2ec0008f1608d3ab5143afc3ad3f4e6da0fed3ea2231d0bef4/msgpack-1.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:dd3bfe82d53edfe4b7fc9a7ec9761e23a7a5b1dac22264505af428253c29ed24" },
{ url = "https://mirrors.aliyun.com/pypi/packages/59/86/1edc67270099a528fa2093ea60fe191233cd238e4bd30cfacf7db79fc959/msgpack-1.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5ad5467fc3f68b5468e06c5f788d712e9f8ffc8b0cd1bcb160c105c1ee92dae7" },
{ url = "https://mirrors.aliyun.com/pypi/packages/82/90/8b630fef07d8c5ab457b71ff2c217910c83d333c7a68472c186e87cc504a/msgpack-1.2.1-cp314-cp314-win32.whl", hash = "sha256:98b58bdb89c46190e4609bb36abe17c6d4105ad13f9c5f8f6f64d320f8ced3fb" },
{ url = "https://mirrors.aliyun.com/pypi/packages/16/f1/467b81e98b24dd3885d7b1857728797b4ffc76a7a7483af4fb321a07de3c/msgpack-1.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:74847557e28ce71bd3c438a447ca90e4b507e997ddbdef8a12a7b283b86c156b" },
{ url = "https://mirrors.aliyun.com/pypi/packages/a7/1d/5d8c4c89985feb6acefb82a09e501c60392261856d2408d20bfe4f0360b1/msgpack-1.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:b50b727bd652bdc37d950336c848ef20ec54a4cafc38dce19b1cd86ad625d0f7" },
{ url = "https://mirrors.aliyun.com/pypi/packages/1b/02/ad2afb678b4de94496cd432b581759b756a92c1192d8c767edd6b132efdc/msgpack-1.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8d00f177ca88a77c1cf848d204a38f249751650b601cb6532acc68805d8a8273" },
{ url = "https://mirrors.aliyun.com/pypi/packages/54/74/0b797484013128837f3b1cbb6cea019277c4de4e377dc512b4d9a0f92940/msgpack-1.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5bb9c386f0a329c035ddbab4b72d1028bf9627add8dda41070288563d57ed1b1" },
{ url = "https://mirrors.aliyun.com/pypi/packages/a9/b4/b774d7eb95561739907fec675582f83203cf41c597a418c2589b4bfb8e9d/msgpack-1.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20466cca18c49c7292a8984bc15d65857b171e7264bdcb5f96baf8be238791fc" },
{ url = "https://mirrors.aliyun.com/pypi/packages/b2/f9/3243191dc9937e00756c8bc1b0272fed8f23758e43df2a3b46f533e5090f/msgpack-1.2.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:196300e7e5d6e74d50f1607ab9c06c4a1484c383cd22defd727902591f7e8dde" },
{ url = "https://mirrors.aliyun.com/pypi/packages/23/c7/1693111db9944ba4ad4b67a1e788400d78a0b6af7a6523dc7e4e58f8274b/msgpack-1.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575957e79cd51903a4e8495a242442949641e08f1efd5197b43bebd3ea7682b4" },
{ url = "https://mirrors.aliyun.com/pypi/packages/3e/2b/92f86956a0c13e8662f7e2ad630c4eb4db07497b967589bd5245e018b2c1/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8c2ed1e48cc0f460bf3c7780e7137ff21a4e18433451916f2442c1b21036cd7d" },
{ url = "https://mirrors.aliyun.com/pypi/packages/da/ea/1479f72d200313a76fc2f823a79d1e07ed052ab7b8a0280640aa7b95de42/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5f6277e5f783c36786a145e0247fc189a03f35f84b251646e53592d2bc12b355" },
{ url = "https://mirrors.aliyun.com/pypi/packages/f5/4d/fa006060ffa1011d32bfae826fe766fe73e02982183601633b7121058ab3/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9389552ecf4784886345ead0647e4edc96bee37cbab05b75540f542f766c48c" },
{ url = "https://mirrors.aliyun.com/pypi/packages/2f/e1/aab6c946570496b78e67804721f3d5e2d62a93081b9b37df77764ef56347/msgpack-1.2.1-cp314-cp314t-win32.whl", hash = "sha256:c1c79a604a2969a868a78b6ebd27a887e00c624f14f66b3038e0590cb23332d1" },
{ url = "https://mirrors.aliyun.com/pypi/packages/13/0a/e608956488a2af014cfe6e3d665e090b8ee42aa14b07f8f95b8880d66b09/msgpack-1.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f12038a35fabd52e56a3547bab42401af49a45caa6dd00b34c44de235bc93ee2" },
{ url = "https://mirrors.aliyun.com/pypi/packages/d2/8a/27e2e57055176e366a46b85d02d68e7a5bcfbdd8474c9706375d965f24d3/msgpack-1.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0adcf06ffde0777c0e1a9b771a2b1c4226ba1bbf748c8efcc02fcdeca3299107" },
]
[[package]]
name = "openai"
version = "2.44.0"
+2
View File
@@ -127,6 +127,8 @@ Restart only the Web/API entrypoints:
docker compose restart asp-frontend asp-web asp-asgi
```
The reverse proxy must forward `/ws/` to the ASGI service.
Restart only background workers:
```bash
+2
View File
@@ -127,6 +127,8 @@ docker compose restart
docker compose restart asp-frontend asp-web asp-asgi
```
反向代理需要将 `/ws/` 转发到 ASGI 服务。
只重启后台 Worker
```bash
+12
View File
@@ -22,6 +22,18 @@ server {
proxy_buffering off;
}
location /ws/ {
proxy_pass http://asp-asgi:8001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
}
location /api/ {
proxy_pass http://asp-web:8000;
proxy_set_header Host $host;
+2 -1
View File
@@ -16,6 +16,7 @@ import {useAuthStore} from './stores/auth'
import {hasPermission, type PermissionKey} from './utils/permissions'
import {getMe} from './api/auth'
import {buildLoginRedirectPath} from './utils/authRedirect'
import {RealtimeProvider} from './realtime'
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const location = useLocation()
@@ -31,7 +32,7 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
}, [setAuth, token, user?.role])
if (!token) return <Navigate to={buildLoginRedirectPath(`${location.pathname}${location.search}${location.hash}`)} replace />
return <>{children}</>
return <RealtimeProvider>{children}</RealtimeProvider>
}
function PermissionRoute({ permission, children }: { permission: PermissionKey; children: React.ReactNode }) {
+38 -4
View File
@@ -1,4 +1,4 @@
import {useCallback, useMemo, useState} from 'react'
import {useCallback, useEffect, useMemo, useState} from 'react'
import {Alert, Button, Input, List, message, Popconfirm, Space, Tooltip} from 'antd'
import {MessageOutlined, ReloadOutlined, SearchOutlined} from '@ant-design/icons'
import dayjs from 'dayjs'
@@ -10,6 +10,7 @@ import MessageAttachments from './MessageAttachments'
import MessageComposer from './MessageComposer'
import UserAvatar from './UserAvatar'
import {tabularNumbersStyle, typography} from '../utils/typography'
import {useRealtime} from '../realtimeContext'
dayjs.extend(relativeTime)
@@ -121,6 +122,7 @@ export default function DiscussionThread({ contentType, objectId }: DiscussionTh
const [replyTo, setReplyTo] = useState<RecordComment | null>(null)
const [hoveredCommentId, setHoveredCommentId] = useState<number | null>(null)
const [actionLoading, setActionLoading] = useState(false)
const {reconnectToken, subscribe, subscribeToComments} = useRealtime()
const fetchCommentPage = useCallback((cursor?: string | null) => {
return fetchComments(contentType, objectId, { cursor, pageSize: 20 })
@@ -128,6 +130,7 @@ export default function DiscussionThread({ contentType, objectId }: DiscussionTh
const {
items: comments,
setItems: setComments,
loadingInitial,
loadingMore,
hasMore,
@@ -140,6 +143,37 @@ export default function DiscussionThread({ contentType, objectId }: DiscussionTh
errorMessage: 'Failed to load comments',
})
useEffect(() => {
return subscribeToComments(contentType, objectId)
}, [contentType, objectId, subscribeToComments])
useEffect(() => {
return subscribe((event) => {
if (event.type === 'comment.created') {
if (event.payload.content_type !== contentType || event.payload.object_id !== objectId) return
const nextComment = event.payload.comment
setComments((current) => {
if (current.some((comment) => comment.id === nextComment.id)) {
return current.map((comment) => comment.id === nextComment.id ? nextComment : comment)
}
return [...current, nextComment]
})
return
}
if (event.type === 'comment.deleted') {
if (event.payload.content_type !== contentType || event.payload.object_id !== objectId) return
setComments((current) => current.filter((comment) => comment.id !== event.payload.comment_id))
setReplyTo((current) => current?.id === event.payload.comment_id ? null : current)
}
})
}, [contentType, objectId, setComments, subscribe])
useEffect(() => {
if (reconnectToken === 0) return
// eslint-disable-next-line react-hooks/set-state-in-effect
refreshComments()
}, [reconnectToken, refreshComments])
const filteredComments = useMemo(() => {
if (!search.trim()) return comments
const keyword = search.toLowerCase()
@@ -149,7 +183,7 @@ export default function DiscussionThread({ contentType, objectId }: DiscussionTh
const submit = async (input: { body: string; mentionedIds: number[]; attachments: { id: number }[] }) => {
setActionLoading(true)
try {
await createComment({
const createdComment = await createComment({
content_type: contentType,
object_id: objectId,
body: input.body,
@@ -158,7 +192,7 @@ export default function DiscussionThread({ contentType, objectId }: DiscussionTh
attachment_ids: input.attachments.map((attachment) => attachment.id),
})
setReplyTo(null)
await refreshComments()
setComments((current) => current.some((comment) => comment.id === createdComment.id) ? current : [...current, createdComment])
message.success('Comment added')
} catch {
message.error('Failed to add comment')
@@ -172,7 +206,7 @@ export default function DiscussionThread({ contentType, objectId }: DiscussionTh
try {
await deleteComment(comment.id)
setReplyTo(null)
await refreshComments()
setComments((current) => current.filter((item) => item.id !== comment.id))
message.success('Comment deleted')
} catch {
message.error('Failed to delete comment')
+71 -11
View File
@@ -21,6 +21,7 @@ import MessageAttachments from './MessageAttachments'
import MessageComposer from './MessageComposer'
import UserAvatar from './UserAvatar'
import {tabularNumbersStyle, typography} from '../utils/typography'
import {useRealtime} from '../realtimeContext'
dayjs.extend(relativeTime)
@@ -28,8 +29,6 @@ interface InboxDrawerProps {
onOpenResource?: (resourceKey: string, rowId: string | number) => void
}
const INBOX_UNREAD_POLL_INTERVAL_MS = 60000
function senderLabel(row: InboxMessage) {
if (row.kind === 'system') return 'System'
return row.sender_name || row.sender_username || 'Unknown user'
@@ -138,6 +137,7 @@ export default function InboxDrawer({ onOpenResource }: InboxDrawerProps) {
const [replyTo, setReplyTo] = useState<InboxMessage | null>(null)
const [unreadCount, setUnreadCount] = useState(0)
const [refreshingCount, setRefreshingCount] = useState(false)
const {reconnectToken, subscribe} = useRealtime()
const fetchInboxPage = useCallback((cursor?: string | null) => (
fetchInboxMessages({ unread: filter === 'unread', cursor, pageSize: 20 })
@@ -173,9 +173,58 @@ export default function InboxDrawer({ onOpenResource }: InboxDrawerProps) {
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
loadUnreadCount()
const timer = window.setInterval(loadUnreadCount, INBOX_UNREAD_POLL_INTERVAL_MS)
return () => window.clearInterval(timer)
}, [loadUnreadCount])
}, [loadUnreadCount, reconnectToken])
useEffect(() => {
return subscribe((event) => {
if (event.type === 'inbox.unread_count_changed') {
setUnreadCount(event.payload.count)
return
}
if (event.type === 'inbox.message_created') {
const nextMessage = event.payload.message
if (filter === 'unread' && nextMessage.is_read) return
setRows((current) => {
if (current.some((item) => item.id === nextMessage.id)) {
return current.map((item) => item.id === nextMessage.id ? nextMessage : item)
}
return [nextMessage, ...current]
})
return
}
if (event.type === 'inbox.message_deleted') {
const deletedId = event.payload.message_id
setRows((current) => current.filter((item) => item.id !== deletedId))
setSelected((current) => current?.id === deletedId ? null : current)
setReplyTo((current) => current?.id === deletedId ? null : current)
return
}
if (event.type === 'inbox.message_read') {
const {message_id: messageId, read_at: readAt} = event.payload
setRows((current) => {
const updated = current.map((item) => item.id === messageId ? {...item, is_read: true, read_at: readAt} : item)
return filter === 'unread' ? updated.filter((item) => item.id !== messageId) : updated
})
setSelected((current) => current?.id === messageId ? {...current, is_read: true, read_at: readAt} : current)
return
}
if (event.type === 'inbox.all_read') {
const {read_at: readAt} = event.payload
if (filter === 'unread') {
setRows([])
} else {
setRows((current) => current.map((item) => ({...item, is_read: true, read_at: readAt})))
}
setSelected((current) => current ? {...current, is_read: true, read_at: readAt} : current)
}
})
}, [filter, setRows, subscribe])
useEffect(() => {
if (!open || reconnectToken === 0) return
// eslint-disable-next-line react-hooks/set-state-in-effect
refreshMessages()
}, [open, reconnectToken, refreshMessages])
const canReply = (row: InboxMessage) => {
return Boolean(row.kind === 'user' && row.sender && row.sender !== currentUser?.id)
@@ -200,7 +249,8 @@ export default function InboxDrawer({ onOpenResource }: InboxDrawerProps) {
setSelected(null)
}
if (replyTo?.id === row.id) setReplyTo(null)
await Promise.all([refreshMessages(), loadUnreadCount()])
setRows((current) => current.filter((item) => item.id !== row.id))
await loadUnreadCount()
} catch {
message.error('Failed to delete message')
}
@@ -208,13 +258,15 @@ export default function InboxDrawer({ onOpenResource }: InboxDrawerProps) {
const submitMessage = async (input: { body: string; mentionedIds: number[]; attachments: { id: number }[] }) => {
if (replyTo) {
await replyInboxMessage(replyTo.id, {
const sentMessage = await replyInboxMessage(replyTo.id, {
body: input.body,
attachments: input.attachments.map((attachment) => attachment.id),
})
setReplyTo(null)
if (filter === 'all') {
setRows((current) => current.some((item) => item.id === sentMessage.id) ? current : [sentMessage, ...current])
}
message.success('Reply sent')
await Promise.all([refreshMessages(), loadUnreadCount()])
return
}
@@ -222,13 +274,15 @@ export default function InboxDrawer({ onOpenResource }: InboxDrawerProps) {
message.warning('Mention at least one user to send a message')
return
}
await createInboxMessage({
const sentMessage = await createInboxMessage({
body: input.body,
recipients: input.mentionedIds,
attachments: input.attachments.map((attachment) => attachment.id),
})
if (filter === 'all') {
setRows((current) => current.some((item) => item.id === sentMessage.id) ? current : [sentMessage, ...current])
}
message.success('Message sent')
await Promise.all([refreshMessages(), loadUnreadCount()])
}
return (
@@ -271,7 +325,13 @@ export default function InboxDrawer({ onOpenResource }: InboxDrawerProps) {
icon={<CheckCircleOutlined />}
onClick={async () => {
await markAllInboxMessagesRead()
await Promise.all([refreshMessages(), loadUnreadCount()])
setUnreadCount(0)
if (filter === 'unread') {
setRows([])
} else {
const readAt = new Date().toISOString()
setRows((current) => current.map((item) => ({...item, is_read: true, read_at: readAt})))
}
}}
/>
</Tooltip>
+145
View File
@@ -0,0 +1,145 @@
import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
import {useAuthStore} from './stores/auth'
import {RealtimeContext, type RealtimeEvent, type RealtimeStatus} from './realtimeContext'
function websocketUrl(token: string) {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
return `${protocol}//${window.location.host}/ws/events/?token=${encodeURIComponent(token)}`
}
export function RealtimeProvider({ children }: { children: React.ReactNode }) {
const token = useAuthStore((state) => state.token)
const [status, setStatus] = useState<RealtimeStatus>('disconnected')
const [reconnectToken, setReconnectToken] = useState(0)
const socketRef = useRef<WebSocket | null>(null)
const handlersRef = useRef(new Set<(event: RealtimeEvent) => void>())
const commentSubscriptionsRef = useRef(new Map<string, { contentType: string; objectId: string; count: number }>())
const reconnectTimerRef = useRef<number | null>(null)
const reconnectAttemptRef = useRef(0)
const shouldReconnectRef = useRef(false)
const sendJson = useCallback((payload: unknown) => {
const socket = socketRef.current
if (socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(payload))
}
}, [])
const sendCommentSubscription = useCallback((type: 'comments.subscribe' | 'comments.unsubscribe', contentType: string, objectId: string) => {
sendJson({ type, content_type: contentType, object_id: objectId })
}, [sendJson])
useEffect(() => {
if (!token) {
shouldReconnectRef.current = false
socketRef.current?.close()
socketRef.current = null
return
}
shouldReconnectRef.current = true
const clearReconnectTimer = () => {
if (reconnectTimerRef.current !== null) {
window.clearTimeout(reconnectTimerRef.current)
reconnectTimerRef.current = null
}
}
const connect = () => {
clearReconnectTimer()
setStatus('connecting')
const socket = new WebSocket(websocketUrl(token))
socketRef.current = socket
socket.onopen = () => {
reconnectAttemptRef.current = 0
setStatus('connected')
setReconnectToken((current) => current + 1)
commentSubscriptionsRef.current.forEach(({ contentType, objectId }) => {
sendCommentSubscription('comments.subscribe', contentType, objectId)
})
}
socket.onmessage = (event) => {
let data: unknown
try {
data = JSON.parse(event.data)
} catch {
return
}
if (!data || typeof data !== 'object' || !('type' in data)) return
const realtimeEvent = data as RealtimeEvent
if (realtimeEvent.type.startsWith('realtime.') || realtimeEvent.type.startsWith('comments.subscribed')) return
handlersRef.current.forEach((handler) => handler(realtimeEvent))
}
socket.onclose = () => {
if (socketRef.current === socket) {
socketRef.current = null
}
setStatus('disconnected')
if (!shouldReconnectRef.current) return
const attempt = reconnectAttemptRef.current + 1
reconnectAttemptRef.current = attempt
const delay = Math.min(30000, 1000 * 2 ** Math.min(attempt, 5))
reconnectTimerRef.current = window.setTimeout(connect, delay)
}
socket.onerror = () => {
socket.close()
}
}
connect()
return () => {
shouldReconnectRef.current = false
clearReconnectTimer()
socketRef.current?.close()
socketRef.current = null
}
}, [sendCommentSubscription, token])
const subscribe = useCallback((handler: (event: RealtimeEvent) => void) => {
handlersRef.current.add(handler)
return () => {
handlersRef.current.delete(handler)
}
}, [])
const subscribeToComments = useCallback((contentType: string, objectId: string) => {
const key = `${contentType}:${objectId}`
const existing = commentSubscriptionsRef.current.get(key)
if (existing) {
existing.count += 1
} else {
commentSubscriptionsRef.current.set(key, { contentType, objectId, count: 1 })
sendCommentSubscription('comments.subscribe', contentType, objectId)
}
return () => {
const current = commentSubscriptionsRef.current.get(key)
if (!current) return
if (current.count > 1) {
current.count -= 1
return
}
commentSubscriptionsRef.current.delete(key)
sendCommentSubscription('comments.unsubscribe', contentType, objectId)
}
}, [sendCommentSubscription])
const value = useMemo(() => ({
status,
reconnectToken,
subscribe,
subscribeToComments,
}), [reconnectToken, status, subscribe, subscribeToComments])
return (
<RealtimeContext.Provider value={value}>
{children}
</RealtimeContext.Provider>
)
}
+31
View File
@@ -0,0 +1,31 @@
import {createContext, useContext} from 'react'
import type {InboxMessage} from './api/inbox'
import type {RecordComment} from './api/comments'
export type RealtimeStatus = 'disconnected' | 'connecting' | 'connected'
export type RealtimeEvent =
| { type: 'inbox.message_created'; event_id: string; occurred_at: string; actor_id: number | null; payload: { message: InboxMessage } }
| { type: 'inbox.message_deleted'; event_id: string; occurred_at: string; actor_id: number | null; payload: { message_id: number } }
| { type: 'inbox.message_read'; event_id: string; occurred_at: string; actor_id: number | null; payload: { message_id: number; read_at: string } }
| { type: 'inbox.all_read'; event_id: string; occurred_at: string; actor_id: number | null; payload: { message_ids: number[]; read_at: string } }
| { type: 'inbox.unread_count_changed'; event_id: string; occurred_at: string; actor_id: number | null; payload: { count: number } }
| { type: 'comment.created'; event_id: string; occurred_at: string; actor_id: number | null; payload: { content_type: string; object_id: string; comment: RecordComment } }
| { type: 'comment.deleted'; event_id: string; occurred_at: string; actor_id: number | null; payload: { content_type: string; object_id: string; comment_id: number } }
export interface RealtimeContextValue {
status: RealtimeStatus
reconnectToken: number
subscribe: (handler: (event: RealtimeEvent) => void) => () => void
subscribeToComments: (contentType: string, objectId: string) => () => void
}
export const RealtimeContext = createContext<RealtimeContextValue | null>(null)
export function useRealtime() {
const context = useContext(RealtimeContext)
if (!context) {
throw new Error('useRealtime must be used within RealtimeProvider')
}
return context
}
+1
View File
@@ -6,6 +6,7 @@ export default defineConfig({
server: {
proxy: {
'/api': { target: 'http://localhost:8000', changeOrigin: true },
'/ws': { target: 'ws://localhost:8001', ws: true },
},
},
})