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:
Agent Zero
2026-07-22 01:22:15 +02:00
parent 5980d38c66
commit cc3ac9a43d
45 changed files with 8154 additions and 113 deletions
@@ -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)