feat: Unified Messaging System — kommunikation plugin, AI/Proactive/System participants, MessageSidebar, Rich Content Renderer
Phase 1: Backend plugin kommunikation (13 files, 10 tables, REST API, WebSocket, RBAC, DMS Bridge, Participant Registry, Mini-App Registry, Search Provider) Phase 2: AI plugins as participants (ai_assistant + ai_proactive dock as participants, heartbeat job) Phase 3: system_notif plugin (system events → chat messages, pinned System room) Phase 4: Frontend MessageSidebar (replaces AISidebar, same design, comm API client, WebSocket hook, commStore) Phase 5: Rich Content Block Renderer (11 components: Markdown, HTML, Image, Audio, Video, File, ActionCard, ContactCard, MiniApp, BlockRenderer) BasePlugin: added services property + _container in on_activate
This commit is contained in:
@@ -0,0 +1,532 @@
|
||||
"""REST API routes for the kommunikation plugin."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, WebSocket, WebSocketDisconnect, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.deps import get_current_user
|
||||
from app.plugins.builtins.kommunikation.rbac import CommRBAC
|
||||
from app.plugins.builtins.kommunikation.schemas import (
|
||||
ConversationCreate,
|
||||
ConversationUpdate,
|
||||
MessageCreate,
|
||||
MessageUpdate,
|
||||
ParticipantAdd,
|
||||
ParticipantRoleUpdate,
|
||||
ReactionCreate,
|
||||
ReadStateUpdate,
|
||||
MiniAppStartRequest,
|
||||
)
|
||||
from app.plugins.builtins.kommunikation.services import (
|
||||
add_participant,
|
||||
add_reaction,
|
||||
change_role,
|
||||
create_conversation,
|
||||
delete_message,
|
||||
edit_message,
|
||||
get_conversation,
|
||||
get_messages,
|
||||
list_conversations,
|
||||
mark_read,
|
||||
mute_conversation,
|
||||
pin_conversation,
|
||||
remove_participant,
|
||||
remove_reaction,
|
||||
send_message,
|
||||
unmute_conversation,
|
||||
unpin_conversation,
|
||||
update_conversation,
|
||||
)
|
||||
from app.plugins.builtins.kommunikation.content_types import list_block_types
|
||||
from app.plugins.builtins.kommunikation.dms_bridge import DmsBridge
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/comm", tags=["kommunikation"])
|
||||
|
||||
|
||||
def _parse_uuid(val: str, field: str = "id") -> uuid.UUID:
|
||||
try:
|
||||
return uuid.UUID(val)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(400, detail={"detail": f"Invalid {field}", "code": "invalid_id"})
|
||||
|
||||
|
||||
# ─── Conversations ───
|
||||
|
||||
@router.get("/conversations")
|
||||
async def list_user_conversations(
|
||||
archived: bool = Query(False, description="Include archived conversations"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List all conversations for the current user."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
convs = await list_conversations(db, tenant_id, user_id, include_archived=archived)
|
||||
return {"items": convs, "total": len(convs)}
|
||||
|
||||
|
||||
@router.post("/conversations")
|
||||
async def create_new_conversation(
|
||||
body: ConversationCreate,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create a new conversation."""
|
||||
if not await CommRBAC.can_user_create(current_user):
|
||||
raise HTTPException(403, detail={"detail": "Permission denied", "code": "forbidden"})
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
return await create_conversation(
|
||||
db, tenant_id, user_id,
|
||||
title=body.title,
|
||||
participant_ids=body.participant_ids,
|
||||
is_direct=body.is_direct,
|
||||
initial_message=body.initial_message,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/conversations/{conversation_id}")
|
||||
async def get_single_conversation(
|
||||
conversation_id: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get a single conversation with participants."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
conv_id = _parse_uuid(conversation_id, "conversation_id")
|
||||
conv = await get_conversation(db, tenant_id, conv_id, user_id)
|
||||
if conv is None:
|
||||
raise HTTPException(404, detail={"detail": "Conversation not found", "code": "not_found"})
|
||||
return conv
|
||||
|
||||
|
||||
@router.patch("/conversations/{conversation_id}")
|
||||
async def update_single_conversation(
|
||||
conversation_id: str,
|
||||
body: ConversationUpdate,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update a conversation (title, archive)."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
conv_id = _parse_uuid(conversation_id, "conversation_id")
|
||||
conv = await update_conversation(db, tenant_id, conv_id, user_id, title=body.title, is_archived=body.is_archived)
|
||||
if conv is None:
|
||||
raise HTTPException(404, detail={"detail": "Conversation not found", "code": "not_found"})
|
||||
return conv
|
||||
|
||||
|
||||
@router.delete("/conversations/{conversation_id}")
|
||||
async def leave_or_delete_conversation(
|
||||
conversation_id: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Leave (member) or delete (admin) a conversation."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
conv_id = _parse_uuid(conversation_id, "conversation_id")
|
||||
# For now: just leave (set left_at)
|
||||
success = await remove_participant(db, conv_id, user_id)
|
||||
if not success:
|
||||
raise HTTPException(404, detail={"detail": "Not a participant", "code": "not_found"})
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.post("/conversations/{conversation_id}/pin")
|
||||
async def pin_conv(
|
||||
conversation_id: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Pin a conversation for the current user."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
conv_id = _parse_uuid(conversation_id, "conversation_id")
|
||||
await pin_conversation(db, tenant_id, conv_id, user_id)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.delete("/conversations/{conversation_id}/pin")
|
||||
async def unpin_conv(
|
||||
conversation_id: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Unpin a conversation."""
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
conv_id = _parse_uuid(conversation_id, "conversation_id")
|
||||
await unpin_conversation(db, conv_id, user_id)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.post("/conversations/{conversation_id}/mute")
|
||||
async def mute_conv(
|
||||
conversation_id: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Mute a conversation."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
conv_id = _parse_uuid(conversation_id, "conversation_id")
|
||||
await mute_conversation(db, tenant_id, conv_id, user_id)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.delete("/conversations/{conversation_id}/mute")
|
||||
async def unmute_conv(
|
||||
conversation_id: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Unmute a conversation."""
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
conv_id = _parse_uuid(conversation_id, "conversation_id")
|
||||
await unmute_conversation(db, conv_id, user_id)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
# ─── Participants ───
|
||||
|
||||
@router.post("/conversations/{conversation_id}/participants")
|
||||
async def add_participant_endpoint(
|
||||
conversation_id: str,
|
||||
body: ParticipantAdd,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Add a participant to a conversation."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
conv_id = _parse_uuid(conversation_id, "conversation_id")
|
||||
if not await CommRBAC.can_user_manage(db, conv_id, current_user):
|
||||
raise HTTPException(403, detail={"detail": "Cannot manage participants", "code": "forbidden"})
|
||||
result = await add_participant(db, tenant_id, conv_id, body.participant_id, body.participant_type, body.role)
|
||||
if result is None:
|
||||
raise HTTPException(400, detail={"detail": "Already a participant or invalid ID", "code": "bad_request"})
|
||||
return result
|
||||
|
||||
|
||||
@router.delete("/conversations/{conversation_id}/participants/{participant_id}")
|
||||
async def remove_participant_endpoint(
|
||||
conversation_id: str,
|
||||
participant_id: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Remove a participant from a conversation."""
|
||||
conv_id = _parse_uuid(conversation_id, "conversation_id")
|
||||
pid = _parse_uuid(participant_id, "participant_id")
|
||||
# Self-leave is always allowed
|
||||
if pid != uuid.UUID(current_user["user_id"]):
|
||||
if not await CommRBAC.can_user_manage(db, conv_id, current_user):
|
||||
raise HTTPException(403, detail={"detail": "Cannot manage participants", "code": "forbidden"})
|
||||
success = await remove_participant(db, conv_id, pid)
|
||||
if not success:
|
||||
raise HTTPException(404, detail={"detail": "Participant not found", "code": "not_found"})
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.patch("/conversations/{conversation_id}/participants/{participant_id}")
|
||||
async def change_participant_role(
|
||||
conversation_id: str,
|
||||
participant_id: str,
|
||||
body: ParticipantRoleUpdate,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Change a participant's role."""
|
||||
conv_id = _parse_uuid(conversation_id, "conversation_id")
|
||||
pid = _parse_uuid(participant_id, "participant_id")
|
||||
if not await CommRBAC.can_user_change_role(db, conv_id, current_user):
|
||||
raise HTTPException(403, detail={"detail": "Cannot change roles", "code": "forbidden"})
|
||||
result = await change_role(db, conv_id, pid, body.role)
|
||||
if result is None:
|
||||
raise HTTPException(404, detail={"detail": "Participant not found", "code": "not_found"})
|
||||
return result
|
||||
|
||||
|
||||
# ─── Messages ───
|
||||
|
||||
@router.get("/conversations/{conversation_id}/messages")
|
||||
async def get_conv_messages(
|
||||
conversation_id: str,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=100),
|
||||
before: str | None = Query(None),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get paginated messages for a conversation."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
conv_id = _parse_uuid(conversation_id, "conversation_id")
|
||||
if not await CommRBAC.is_participant(db, conv_id, user_id):
|
||||
raise HTTPException(403, detail={"detail": "Not a participant", "code": "forbidden"})
|
||||
before_id = _parse_uuid(before, "before") if before else None
|
||||
return await get_messages(db, tenant_id, conv_id, page, page_size, before_id)
|
||||
|
||||
|
||||
@router.post("/conversations/{conversation_id}/messages")
|
||||
async def send_conv_message(
|
||||
conversation_id: str,
|
||||
body: MessageCreate,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Send a message to a conversation."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
conv_id = _parse_uuid(conversation_id, "conversation_id")
|
||||
if not await CommRBAC.can_user_write(db, conv_id, current_user):
|
||||
raise HTTPException(403, detail={"detail": "Cannot write to this conversation", "code": "forbidden"})
|
||||
blocks_data = [b.model_dump() for b in body.blocks] if body.blocks else None
|
||||
attachments_data = [a.model_dump() for a in body.attachments] if body.attachments else None
|
||||
return await send_message(
|
||||
db, tenant_id, conv_id, user_id, "user",
|
||||
content=body.content,
|
||||
content_format=body.content_format,
|
||||
blocks=blocks_data,
|
||||
reply_to_id=body.reply_to_id,
|
||||
attachments=attachments_data,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/messages/{message_id}")
|
||||
async def update_msg(
|
||||
message_id: str,
|
||||
body: MessageUpdate,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Edit a message or mark as read."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
msg_id = _parse_uuid(message_id, "message_id")
|
||||
if body.content is not None:
|
||||
result = await edit_message(db, tenant_id, msg_id, user_id, body.content)
|
||||
if result is None:
|
||||
raise HTTPException(404, detail={"detail": "Message not found", "code": "not_found"})
|
||||
return result
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.delete("/messages/{message_id}")
|
||||
async def delete_msg(
|
||||
message_id: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete a message."""
|
||||
msg_id = _parse_uuid(message_id, "message_id")
|
||||
success = await delete_message(db, msg_id)
|
||||
if not success:
|
||||
raise HTTPException(404, detail={"detail": "Message not found", "code": "not_found"})
|
||||
return {"success": True}
|
||||
|
||||
|
||||
# ─── Attachments ───
|
||||
|
||||
@router.post("/messages/{message_id}/attachments")
|
||||
async def upload_attachment(
|
||||
message_id: str,
|
||||
file: UploadFile = File(...),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Upload a file attachment to a message."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
msg_id = _parse_uuid(message_id, "message_id")
|
||||
# Get conversation_id from message
|
||||
from sqlalchemy import select
|
||||
from app.plugins.builtins.kommunikation.models import CommMessage
|
||||
result = await db.execute(select(CommMessage).where(CommMessage.id == msg_id))
|
||||
msg = result.scalar_one_or_none()
|
||||
if msg is None:
|
||||
raise HTTPException(404, detail={"detail": "Message not found", "code": "not_found"})
|
||||
try:
|
||||
return await DmsBridge.store_attachment(db, tenant_id, msg.conversation_id, user_id, file)
|
||||
except ValueError as e:
|
||||
raise HTTPException(413, detail={"detail": str(e), "code": "file_too_large"})
|
||||
|
||||
|
||||
# ─── Reactions ───
|
||||
|
||||
@router.post("/messages/{message_id}/reactions")
|
||||
async def add_msg_reaction(
|
||||
message_id: str,
|
||||
body: ReactionCreate,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Add an emoji reaction to a message."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
msg_id = _parse_uuid(message_id, "message_id")
|
||||
result = await add_reaction(db, tenant_id, msg_id, user_id, body.emoji)
|
||||
if result is None:
|
||||
raise HTTPException(409, detail={"detail": "Already reacted", "code": "conflict"})
|
||||
return result
|
||||
|
||||
|
||||
@router.delete("/messages/{message_id}/reactions/{emoji}")
|
||||
async def remove_msg_reaction(
|
||||
message_id: str,
|
||||
emoji: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Remove an emoji reaction."""
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
msg_id = _parse_uuid(message_id, "message_id")
|
||||
success = await remove_reaction(db, msg_id, user_id, emoji)
|
||||
if not success:
|
||||
raise HTTPException(404, detail={"detail": "Reaction not found", "code": "not_found"})
|
||||
return {"success": True}
|
||||
|
||||
|
||||
# ─── Read State ───
|
||||
|
||||
@router.post("/conversations/{conversation_id}/read")
|
||||
async def mark_conv_read(
|
||||
conversation_id: str,
|
||||
body: ReadStateUpdate,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Mark a conversation as read."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
conv_id = _parse_uuid(conversation_id, "conversation_id")
|
||||
await mark_read(db, tenant_id, conv_id, user_id, body.last_read_msg_id)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
# ─── Mini-Apps ───
|
||||
|
||||
@router.get("/miniapps")
|
||||
async def list_miniapps(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""List available mini-apps."""
|
||||
from app.core.service_container import get_container
|
||||
container = get_container()
|
||||
if not container.has("comm_miniapps"):
|
||||
return {"items": []}
|
||||
registry = container.get("comm_miniapps")
|
||||
return {"items": registry.list_apps()}
|
||||
|
||||
|
||||
@router.post("/conversations/{conversation_id}/miniapps")
|
||||
async def start_miniapp(
|
||||
conversation_id: str,
|
||||
body: MiniAppStartRequest,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Start a mini-app in a conversation."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
conv_id = _parse_uuid(conversation_id, "conversation_id")
|
||||
if not await CommRBAC.can_user_write(db, conv_id, current_user):
|
||||
raise HTTPException(403, detail={"detail": "Cannot write", "code": "forbidden"})
|
||||
# Create a miniapp block as a message
|
||||
return await send_message(
|
||||
db, tenant_id, conv_id, user_id, "user",
|
||||
content=f"[Mini-App: {body.app_id}]",
|
||||
blocks=[{
|
||||
"block_type": "miniapp",
|
||||
"block_data": {"app_id": body.app_id, "config": body.config},
|
||||
}],
|
||||
)
|
||||
|
||||
|
||||
# ─── Content Types ───
|
||||
|
||||
@router.get("/block-types")
|
||||
async def get_block_types():
|
||||
"""List all known content block types."""
|
||||
return {"items": list_block_types()}
|
||||
|
||||
|
||||
# ─── WebSocket ───
|
||||
|
||||
@router.websocket("/ws")
|
||||
async def websocket_endpoint(
|
||||
websocket: WebSocket,
|
||||
):
|
||||
"""WebSocket endpoint for real-time messaging.
|
||||
|
||||
Authenticates via session cookie. On connect, subscribes user to all their conversations.
|
||||
"""
|
||||
# Authenticate via session cookie
|
||||
from app.config import get_settings
|
||||
from app.core.auth import get_session_data, get_redis
|
||||
|
||||
settings = get_settings()
|
||||
session_id = websocket.cookies.get(settings.session_cookie_name)
|
||||
if not session_id:
|
||||
await websocket.close(code=4001, reason="Not authenticated")
|
||||
return
|
||||
|
||||
redis = get_redis()
|
||||
session_data = await get_session_data(redis, session_id)
|
||||
if session_data is None:
|
||||
await websocket.close(code=4001, reason="Session expired")
|
||||
return
|
||||
|
||||
user_id = session_data["user_id"]
|
||||
tenant_id = session_data["tenant_id"]
|
||||
|
||||
# Get WebSocket manager from service container
|
||||
from app.core.service_container import get_container
|
||||
container = get_container()
|
||||
if not container.has("comm_websocket"):
|
||||
await websocket.close(code=4003, reason="Messaging not available")
|
||||
return
|
||||
|
||||
ws_manager = container.get("comm_websocket")
|
||||
await ws_manager.connect(websocket, user_id)
|
||||
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_text()
|
||||
msg = json.loads(data)
|
||||
msg_type = msg.get("type")
|
||||
|
||||
if msg_type == "ping":
|
||||
await ws_manager.send_to_user(user_id, {"type": "pong"})
|
||||
elif msg_type == "subscribe":
|
||||
conv_id = msg.get("conversation_id")
|
||||
if conv_id:
|
||||
ws_manager.subscribe(conv_id, user_id)
|
||||
elif msg_type == "unsubscribe":
|
||||
conv_id = msg.get("conversation_id")
|
||||
if conv_id:
|
||||
ws_manager.unsubscribe(conv_id, user_id)
|
||||
elif msg_type == "typing":
|
||||
conv_id = msg.get("conversation_id")
|
||||
is_typing = msg.get("is_typing", False)
|
||||
if conv_id:
|
||||
await ws_manager.send_to_conversation(
|
||||
conv_id,
|
||||
{"type": "typing", "conversation_id": conv_id, "user_id": user_id, "is_typing": is_typing},
|
||||
exclude_user=user_id,
|
||||
)
|
||||
except WebSocketDisconnect:
|
||||
await ws_manager.disconnect(websocket, user_id)
|
||||
except Exception:
|
||||
logger.exception("WebSocket error")
|
||||
await ws_manager.disconnect(websocket, user_id)
|
||||
Reference in New Issue
Block a user