533 lines
19 KiB
Python
533 lines
19 KiB
Python
|
|
"""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)
|