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
+9
View File
@@ -30,6 +30,14 @@ class BasePlugin(ABC):
raise ValueError(f"{self.__class__.__name__} must define a 'manifest' attribute")
self._event_handlers: dict[str, Any] = {}
self._routers: list[APIRouter] = []
self._container: Any = None # ServiceContainer, set during on_activate
@property
def services(self) -> Any:
"""Access the ServiceContainer after activation."""
if self._container is None:
raise RuntimeError("Services not available — plugin not activated")
return self._container
# ─── Lifecycle Hooks ───
@@ -52,6 +60,7 @@ class BasePlugin(ABC):
handler = self._make_event_handler(event_name)
self._event_handlers[event_name] = handler
event_bus.subscribe(event_name, handler)
self._container = service_container
async def on_deactivate(
self, db: AsyncSession, service_container: ServiceContainer, event_bus: EventBus
@@ -0,0 +1,287 @@
"""AI Participant Handler — bridges the kommunikation plugin with the AI Assistant.
When a message is received in a conversation that includes the 'ai' participant,
this handler generates an LLM response using litellm.acompletion (non-streaming)
and returns it as a new message in the conversation.
"""
from __future__ import annotations
import logging
import uuid
from typing import Any
import litellm
from app.core.db import create_db_session
from app.plugins.builtins.kommunikation.participant_registry import ParticipantHandler
logger = logging.getLogger(__name__)
class AIParticipantHandler(ParticipantHandler):
"""Handles AI responses as a participant in kommunikation conversations."""
def __init__(self, container: Any) -> None:
self._container = container
async def on_message_received(
self,
conversation_id: Any,
message: dict[str, Any],
conversation: dict[str, Any],
mentions: list[str],
context: dict[str, Any],
) -> list[dict[str, Any]] | None:
"""Generate an AI response when the AI is mentioned or in a direct chat.
Checks:
1. 'ai' is in the conversation participants as a participant_type
2. '@KI' is in mentions OR the conversation is_direct with only user+ai
Returns a list with one message dict containing the AI response.
"""
# Check if 'ai' is a participant in this conversation
participants = conversation.get("participants", [])
ai_is_participant = any(
p.get("participant_type") == "ai" for p in participants
)
if not ai_is_participant:
return None
# Check if AI is mentioned or it's a direct chat with only user + ai
ai_mentioned = "KI" in mentions or "ai" in mentions
is_direct = conversation.get("is_direct", False)
if is_direct:
# For direct chats, check that only user and ai are participants
non_system_participants = [
p for p in participants
if p.get("participant_type") in ("user", "ai")
]
if len(non_system_participants) <= 2:
ai_mentioned = True
if not ai_mentioned:
return None
# Don't respond to our own messages
if message.get("sender_type") == "ai":
return None
# Get tenant_id and user_id from context
tenant_id_str = context.get("tenant_id") or message.get("tenant_id")
if not tenant_id_str:
logger.warning("AIParticipantHandler: missing tenant_id in context")
return None
try:
tenant_id = uuid.UUID(str(tenant_id_str))
except (ValueError, TypeError):
logger.warning("AIParticipantHandler: invalid tenant_id: %s", tenant_id_str)
return None
# Build messages from conversation history and generate response
try:
from app.plugins.builtins.ai_assistant.services import (
build_litellm_params,
get_default_agent,
)
async with create_db_session(tenant_id) as db:
# Get default agent for system prompt and preset configuration
agent = await get_default_agent(db, tenant_id)
# Build message history from conversation messages
messages = await self._build_message_history(
db, conversation_id, tenant_id, message
)
if agent:
params, model_id = await build_litellm_params(
db, agent, messages, tenant_id
)
else:
# Fallback: use default provider without agent
from app.plugins.builtins.ai_assistant.services import (
get_default_provider,
)
provider = await get_default_provider(db, tenant_id)
if not provider:
return [
{
"content": "Kein AI-Provider konfiguriert. Bitte konfigurieren Sie einen Provider in den KI-Einstellungen.",
"content_format": "text",
}
]
model_id = "gpt-4o-mini"
litellm_model = f"{provider.provider_type}/{model_id}"
params: dict[str, Any] = {
"model": litellm_model,
"messages": messages,
"temperature": 0.7,
"max_tokens": 2048,
"stream": False,
}
if provider.api_key:
params["api_key"] = provider.api_key
if provider.base_url:
params["api_base"] = provider.base_url
# Ensure non-streaming for acompletion
params["stream"] = False
response = await litellm.acompletion(**params)
response_text = response.choices[0].message.content or ""
if not response_text.strip():
response_text = "*(keine Antwort generiert)*"
return [
{
"content": response_text,
"content_format": "markdown",
}
]
except Exception as exc:
logger.exception("AIParticipantHandler: error generating AI response")
return [
{
"content": f"Fehler bei der KI-Antwort: {exc}",
"content_format": "text",
}
]
async def _build_message_history(
self,
db: Any,
conversation_id: Any,
tenant_id: uuid.UUID,
current_message: dict[str, Any],
) -> list[dict[str, str]]:
"""Build a messages array from the conversation history for the LLM."""
from app.plugins.builtins.kommunikation.services import get_messages
messages: list[dict[str, str]] = []
# Get conversation history (last 50 messages)
try:
result = await get_messages(
db, tenant_id, uuid.UUID(str(conversation_id)),
page=1, page_size=50,
)
items = result.get("items", [])
for item in items:
role = "assistant" if item.get("sender_type") == "ai" else "user"
content = item.get("content", "")
if content:
messages.append({"role": role, "content": content})
except Exception:
logger.debug("Could not load conversation history, using current message only")
# Ensure the current message is included
current_content = current_message.get("content", "")
if current_content and (
not messages
or messages[-1].get("content") != current_content
):
messages.append({"role": "user", "content": current_content})
return messages
async def handle_event(self, payload: dict[str, Any]) -> None:
"""Handle a message.received event from the event bus.
Extracts conversation_id from the payload, loads the conversation,
and calls on_message_received. If a response is generated, sends it
back to the conversation.
"""
conversation_id_str = payload.get("conversation_id")
tenant_id_str = payload.get("tenant_id")
message_content = payload.get("content", "")
message_id = payload.get("message_id")
sender_type = payload.get("sender_type", "user")
if not conversation_id_str or not tenant_id_str:
logger.warning("AIParticipantHandler.handle_event: missing conversation_id or tenant_id")
return
try:
tenant_id = uuid.UUID(str(tenant_id_str))
conversation_id = uuid.UUID(str(conversation_id_str))
except (ValueError, TypeError):
logger.warning("AIParticipantHandler.handle_event: invalid UUID in payload")
return
# Build the message dict
message: dict[str, Any] = {
"id": message_id,
"content": message_content,
"sender_type": sender_type,
"tenant_id": tenant_id_str,
}
# Load conversation
try:
from app.plugins.builtins.kommunikation.services import get_conversation
async with create_db_session(tenant_id) as db:
# We need a user_id to load the conversation — use the sender_id from payload
user_id_str = payload.get("sender_id")
if not user_id_str:
logger.warning("AIParticipantHandler.handle_event: missing sender_id")
return
user_id = uuid.UUID(str(user_id_str))
conversation = await get_conversation(db, tenant_id, conversation_id, user_id)
if not conversation:
logger.warning("AIParticipantHandler.handle_event: conversation not found")
return
# Parse mentions from message content
from app.plugins.builtins.kommunikation.services import parse_mentions
mentions = parse_mentions(message_content)
context: dict[str, Any] = {
"tenant_id": tenant_id_str,
"user_id": user_id_str,
}
# Call on_message_received
response_messages = await self.on_message_received(
conversation_id, message, conversation, mentions, context
)
# If we got a response, send it to the conversation
if response_messages:
from app.plugins.builtins.kommunikation.services import send_message
for resp_msg in response_messages:
await send_message(
db=db,
tenant_id=tenant_id,
conversation_id=conversation_id,
sender_id=None,
sender_type="ai",
content=resp_msg.get("content", ""),
content_format=resp_msg.get("content_format", "text"),
blocks=resp_msg.get("blocks"),
)
await db.commit()
except Exception:
logger.exception("AIParticipantHandler.handle_event: error processing event")
def get_participant_info(self) -> dict[str, Any]:
"""Return metadata about this participant."""
return {
"display_name": "KI Assistent",
"capabilities": ["chat", "tools", "streaming"],
"description": "KI Assistent für Chat und Tool-Nutzung",
}
@@ -42,12 +42,78 @@ class AIAssistantPlugin(BasePlugin):
is_core=True,
)
def __init__(self) -> None:
super().__init__()
self._ai_handler = None
self._msg_handler = None
async def on_install(self, db, service_container) -> None:
"""Seed default provider and agent."""
from app.plugins.builtins.ai_assistant.services import seed_defaults
await seed_defaults(db)
async def on_activate(self, db, service_container, event_bus) -> None:
"""Activate plugin: register context tools and participant handler."""
await super().on_activate(db, service_container, event_bus)
# Register as participant in the kommunikation system
try:
from app.plugins.builtins.ai_assistant.participant_handler import (
AIParticipantHandler,
)
from app.plugins.builtins.kommunikation.participant_registry import (
get_participant_registry,
)
self._ai_handler = AIParticipantHandler(service_container)
get_participant_registry().register("ai", self._ai_handler)
logger.info("AI Assistant registered as participant 'ai'")
except Exception:
logger.exception("Failed to register AI Assistant as participant")
# Subscribe to message.received events
try:
self._msg_handler = self._on_message_received
event_bus.subscribe("message.received", self._msg_handler)
logger.info("AI Assistant subscribed to message.received events")
except Exception:
logger.exception("Failed to subscribe to message.received events")
async def _on_message_received(self, payload: dict[str, Any]) -> None:
"""Handle message.received event by delegating to the AI participant handler."""
if self._ai_handler is None:
return
try:
await self._ai_handler.handle_event(payload)
except Exception:
logger.exception("Error in AI participant handler for message.received")
async def on_deactivate(self, db, service_container, event_bus) -> None:
"""Deactivate plugin: unregister participant and event subscriptions."""
# Unregister from participant registry
try:
from app.plugins.builtins.kommunikation.participant_registry import (
get_participant_registry,
)
get_participant_registry().unregister("ai")
logger.info("AI Assistant unregistered as participant 'ai'")
except Exception:
logger.exception("Failed to unregister AI Assistant as participant")
# Unsubscribe from message.received events
if self._msg_handler:
try:
event_bus.unsubscribe("message.received", self._msg_handler)
except Exception:
logger.exception("Failed to unsubscribe from message.received events")
self._msg_handler = None
self._ai_handler = None
await super().on_deactivate(db, service_container, event_bus)
def get_notification_types(self) -> list[dict[str, Any]]:
return [
{
+81
View File
@@ -314,3 +314,84 @@ async def deep_analysis(
entity_type,
entity_id,
)
# ─── Heartbeat Job ───
async def heartbeat(ctx: dict[str, Any], user_id: str, tenant_id: str) -> None:
"""Heartbeat job for the AI Proactive plugin.
Runs every 5 minutes (scheduled by the plugin on activation).
Posts a status message to the 'Live KI' room in the kommunikation system.
"""
try:
uid = uuid.UUID(user_id)
tid = uuid.UUID(tenant_id)
except (ValueError, TypeError):
logger.warning("heartbeat: invalid UUID parameters (user_id=%s, tenant_id=%s)", user_id, tenant_id)
return
try:
from app.core.db import create_db_session
from app.plugins.builtins.kommunikation.services import (
create_plugin_room,
send_message,
)
async with create_db_session(tid) as db:
# Create or get the 'Live KI' room for this user
room = await create_plugin_room(
db,
tid,
uid,
plugin_name="ai_proactive",
title="Live KI",
participant_type="ai_proactive",
user_role="member",
)
conversation_id_str = room.get("id")
if not conversation_id_str:
logger.warning("heartbeat: could not create/get Live KI room")
return
conversation_id = uuid.UUID(conversation_id_str)
# Gather some stats for the status message
from sqlalchemy import func, select
from app.models.contact import Contact
contact_count_result = await db.execute(
select(func.count()).select_from(Contact).where(
Contact.tenant_id == tid,
Contact.deleted_at.is_(None),
)
)
contact_count = contact_count_result.scalar() or 0
# Build status message
from datetime import datetime, timezone
now_str = datetime.now(timezone.utc).strftime("%H:%M:%S")
status_content = (
f"**System aktiv** — überwacht {contact_count} Kontakte\n"
f"_Letztes Update: {now_str}_"
)
await send_message(
db=db,
tenant_id=tid,
conversation_id=conversation_id,
sender_id=None,
sender_type="ai_proactive",
content=status_content,
content_format="markdown",
)
await db.commit()
logger.info("heartbeat: posted status to Live KI room for user %s", user_id)
except Exception:
logger.exception("heartbeat: error posting status message")
@@ -0,0 +1,232 @@
"""AI Proactive Participant Handler — bridges the kommunikation plugin with the proactive AI.
When a message is received in a conversation that includes the 'ai_proactive' participant,
this handler generates a proactive suggestion based on the message context.
"""
from __future__ import annotations
import logging
import uuid
from typing import Any
from app.core.db import create_db_session
from app.plugins.builtins.kommunikation.participant_registry import ParticipantHandler
logger = logging.getLogger(__name__)
class AIProactiveParticipantHandler(ParticipantHandler):
"""Handles proactive AI suggestions as a participant in kommunikation conversations."""
def __init__(self, container: Any) -> None:
self._container = container
async def on_message_received(
self,
conversation_id: Any,
message: dict[str, Any],
conversation: dict[str, Any],
mentions: list[str],
context: dict[str, Any],
) -> list[dict[str, Any]] | None:
"""Generate a proactive suggestion when ai_proactive is a participant.
Checks if 'ai_proactive' is in the conversation participants as a
participant_type. If yes, generates a suggestion based on the message.
Returns a list with one message dict containing the suggestion as
an action card, or None on error.
"""
# Check if 'ai_proactive' is a participant in this conversation
participants = conversation.get("participants", [])
ai_proactive_is_participant = any(
p.get("participant_type") == "ai_proactive" for p in participants
)
if not ai_proactive_is_participant:
return None
# Don't respond to our own messages
if message.get("sender_type") == "ai_proactive":
return None
# Get tenant_id and user_id from context
tenant_id_str = context.get("tenant_id") or message.get("tenant_id")
user_id_str = context.get("user_id")
if not tenant_id_str:
logger.warning("AIProactiveParticipantHandler: missing tenant_id in context")
return None
try:
tenant_id = uuid.UUID(str(tenant_id_str))
user_id = uuid.UUID(str(user_id_str)) if user_id_str else None
except (ValueError, TypeError):
logger.warning("AIProactiveParticipantHandler: invalid UUID in context")
return None
# Generate suggestion using the existing generate_suggestion function
try:
from app.plugins.builtins.ai_proactive.services import (
generate_suggestion,
get_user_settings,
)
async with create_db_session(tenant_id) as db:
# Get user settings for the proactive AI
if user_id:
settings = await get_user_settings(db, tenant_id, user_id)
else:
# Create minimal default settings if no user_id
from app.plugins.builtins.ai_proactive.models import ProactiveSettings
settings = ProactiveSettings(
tenant_id=tenant_id,
user_id=user_id or uuid.uuid4(),
enabled=True,
suggestion_categories=["mail", "tasks", "contacts", "companies", "insights"],
confidence_threshold=0.5,
rate_limit_seconds=10,
model="ollama/deepseek-v4-flash",
)
if not settings.enabled:
return None
# Build context data from the message
context_data: dict[str, Any] = {
"entity_type": "message",
"message": message.get("content", ""),
"conversation_id": str(conversation_id),
"sender_type": message.get("sender_type", "user"),
}
# Generate suggestion via LLM
suggestion = await generate_suggestion(
context_data, settings, db=db, tenant_id=tenant_id
)
if not suggestion:
return None
# Build the response message with an action card block
suggestion_text = suggestion.get("content", "")
title = suggestion.get("title", "KI Vorschlag")
suggestion_type = suggestion.get("suggestion_type", "info")
confidence = suggestion.get("confidence", 0.5)
actions = suggestion.get("actions", [])
return [
{
"content": suggestion_text,
"content_format": "markdown",
"blocks": [
{
"block_type": "action_card",
"block_data": {
"title": title,
"suggestion_type": suggestion_type,
"confidence": confidence,
"actions": actions,
},
}
],
}
]
except Exception:
logger.exception("AIProactiveParticipantHandler: error generating suggestion")
return None
async def handle_event(self, payload: dict[str, Any]) -> None:
"""Handle a message.received event from the event bus.
Extracts conversation_id from the payload, loads the conversation,
and calls on_message_received. If a response is generated, sends it
back to the conversation.
"""
conversation_id_str = payload.get("conversation_id")
tenant_id_str = payload.get("tenant_id")
message_content = payload.get("content", "")
message_id = payload.get("message_id")
sender_type = payload.get("sender_type", "user")
sender_id_str = payload.get("sender_id")
if not conversation_id_str or not tenant_id_str:
logger.warning("AIProactiveParticipantHandler.handle_event: missing conversation_id or tenant_id")
return
try:
tenant_id = uuid.UUID(str(tenant_id_str))
conversation_id = uuid.UUID(str(conversation_id_str))
except (ValueError, TypeError):
logger.warning("AIProactiveParticipantHandler.handle_event: invalid UUID in payload")
return
# Build the message dict
message: dict[str, Any] = {
"id": message_id,
"content": message_content,
"sender_type": sender_type,
"tenant_id": tenant_id_str,
}
# Load conversation
try:
from app.plugins.builtins.kommunikation.services import get_conversation
async with create_db_session(tenant_id) as db:
if not sender_id_str:
logger.warning("AIProactiveParticipantHandler.handle_event: missing sender_id")
return
user_id = uuid.UUID(str(sender_id_str))
conversation = await get_conversation(db, tenant_id, conversation_id, user_id)
if not conversation:
logger.warning("AIProactiveParticipantHandler.handle_event: conversation not found")
return
# Parse mentions from message content
from app.plugins.builtins.kommunikation.services import parse_mentions
mentions = parse_mentions(message_content)
context: dict[str, Any] = {
"tenant_id": tenant_id_str,
"user_id": sender_id_str,
}
# Call on_message_received
response_messages = await self.on_message_received(
conversation_id, message, conversation, mentions, context
)
# If we got a response, send it to the conversation
if response_messages:
from app.plugins.builtins.kommunikation.services import send_message
for resp_msg in response_messages:
await send_message(
db=db,
tenant_id=tenant_id,
conversation_id=conversation_id,
sender_id=None,
sender_type="ai_proactive",
content=resp_msg.get("content", ""),
content_format=resp_msg.get("content_format", "text"),
blocks=resp_msg.get("blocks"),
)
await db.commit()
except Exception:
logger.exception("AIProactiveParticipantHandler.handle_event: error processing event")
def get_participant_info(self) -> dict[str, Any]:
"""Return metadata about this participant."""
return {
"display_name": "Live KI",
"capabilities": ["proactive", "context_aware", "heartbeat"],
"description": "Proaktive KI mit Kontextbewusstsein und Heartbeat",
}
+34 -2
View File
@@ -45,8 +45,12 @@ class AIProactivePlugin(BasePlugin):
is_core=False,
)
def __init__(self) -> None:
super().__init__()
self._proactive_handler = None
async def on_activate(self, db, service_container, event_bus) -> None:
"""Register context tools and subscribe to events."""
"""Register context tools, subscribe to events, and register as participant."""
await super().on_activate(db, service_container, event_bus)
try:
from app.plugins.builtins.ai_proactive.context_tools import (
@@ -61,8 +65,36 @@ class AIProactivePlugin(BasePlugin):
except Exception:
logger.exception("Failed to register AI Proactive context tools")
# Register as participant in the kommunikation system
try:
from app.plugins.builtins.ai_proactive.participant_handler import (
AIProactiveParticipantHandler,
)
from app.plugins.builtins.kommunikation.participant_registry import (
get_participant_registry,
)
self._proactive_handler = AIProactiveParticipantHandler(service_container)
get_participant_registry().register("ai_proactive", self._proactive_handler)
logger.info("AI Proactive registered as participant 'ai_proactive'")
except Exception:
logger.exception("Failed to register AI Proactive as participant")
async def on_deactivate(self, db, service_container, event_bus) -> None:
"""Unregister tools and event listeners."""
"""Unregister tools, event listeners, and participant."""
# Unregister from participant registry
try:
from app.plugins.builtins.kommunikation.participant_registry import (
get_participant_registry,
)
get_participant_registry().unregister("ai_proactive")
logger.info("AI Proactive unregistered as participant 'ai_proactive'")
except Exception:
logger.exception("Failed to unregister AI Proactive as participant")
self._proactive_handler = None
try:
from app.plugins.builtins.ai_assistant.tool_registry import (
get_tool_registry,
@@ -0,0 +1,82 @@
"""Content block type definitions for rich content in messages."""
from __future__ import annotations
from typing import Any
# Known block types and their expected schema
BLOCK_TYPES: dict[str, dict[str, Any]] = {
"text": {
"description": "Plain text fallback",
"fields": {"text": "str"},
},
"markdown": {
"description": "Markdown formatted text",
"fields": {"markdown": "str"},
},
"html": {
"description": "Sanitized HTML content",
"fields": {"html": "str"},
},
"image": {
"description": "Image attachment",
"fields": {"url": "str", "alt": "str", "width": "int?"},
},
"audio": {
"description": "Audio file",
"fields": {"url": "str", "duration": "int?", "waveform": "list?"},
},
"video": {
"description": "Video file",
"fields": {"url": "str", "duration": "int?", "thumbnail": "str?"},
},
"file": {
"description": "Generic file attachment",
"fields": {"url": "str", "name": "str", "size": "int?"},
},
"action_card": {
"description": "Interactive card with buttons",
"fields": {
"title": "str",
"body": "str",
"actions": "list[dict]", # [{label, action, type}]
},
},
"contact_card": {
"description": "Contact reference card",
"fields": {"contact_id": "str", "name": "str"},
},
"miniapp": {
"description": "Embedded mini-app",
"fields": {"app_id": "str", "config": "dict?"},
},
}
def validate_block(block_type: str, block_data: dict[str, Any]) -> bool:
"""Validate that a block has the required fields for its type.
Returns True if valid, False otherwise.
Unknown block types are allowed (forward-compatible) but logged.
"""
if block_type not in BLOCK_TYPES:
# Unknown types are allowed — forward compatible
return True
required_fields = BLOCK_TYPES[block_type].get("fields", {})
for field_name, field_type in required_fields.items():
if field_type.endswith("?"):
continue # Optional field
if field_name not in block_data:
return False
return True
def list_block_types() -> list[dict[str, Any]]:
"""List all known block types for frontend reference."""
return [
{"block_type": bt, "description": info["description"], "fields": info["fields"]}
for bt, info in BLOCK_TYPES.items()
]
@@ -0,0 +1,187 @@
"""DMS Bridge — integrates with the DMS plugin for file storage."""
from __future__ import annotations
import logging
import os
import uuid
from typing import Any
from fastapi import UploadFile
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.plugins.builtins.dms.models import File as DmsFile, Folder
logger = logging.getLogger(__name__)
DMS_STORAGE_BASE = os.environ.get("DMS_STORAGE_BASE", "/tmp/dms")
COMM_FOLDER_NAME = "_kommunikation"
MAX_DIRECT_UPLOAD = 100 * 1024 * 1024 # 100 MB — larger files must be DMS references
class DmsBridge:
"""Bridge to the DMS plugin for attachment storage."""
@staticmethod
async def ensure_comm_folder(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
) -> Folder:
"""Ensure the _kommunikation root folder exists in DMS."""
result = await db.execute(
select(Folder).where(
Folder.name == COMM_FOLDER_NAME,
Folder.parent_id.is_(None),
Folder.tenant_id == tenant_id,
Folder.deleted_at.is_(None),
)
)
folder = result.scalar_one_or_none()
if folder is None:
folder = Folder(
tenant_id=tenant_id,
name=COMM_FOLDER_NAME,
parent_id=None,
created_by=user_id,
)
db.add(folder)
await db.flush()
return folder
@staticmethod
async def ensure_conversation_folder(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
conversation_id: uuid.UUID,
) -> Folder:
"""Ensure a sub-folder for a specific conversation exists."""
root_folder = await DmsBridge.ensure_comm_folder(db, tenant_id, user_id)
conv_name = str(conversation_id)
result = await db.execute(
select(Folder).where(
Folder.name == conv_name,
Folder.parent_id == root_folder.id,
Folder.tenant_id == tenant_id,
Folder.deleted_at.is_(None),
)
)
folder = result.scalar_one_or_none()
if folder is None:
folder = Folder(
tenant_id=tenant_id,
name=conv_name,
parent_id=root_folder.id,
created_by=user_id,
)
db.add(folder)
await db.flush()
return folder
@staticmethod
async def store_attachment(
db: AsyncSession,
tenant_id: uuid.UUID,
conversation_id: uuid.UUID,
user_id: uuid.UUID,
file: UploadFile,
) -> dict[str, Any]:
"""Store an uploaded file in the DMS under _kommunikation/{conversation_id}/.
Returns dict with file_id, file_name, file_type, file_size.
"""
# Check size limit
content = await file.read()
file_size = len(content)
if file_size > MAX_DIRECT_UPLOAD:
raise ValueError(
f"File size {file_size} exceeds direct upload limit ({MAX_DIRECT_UPLOAD} bytes). "
f"Use DMS reference instead."
)
# Ensure conversation folder
folder = await DmsBridge.ensure_conversation_folder(
db, tenant_id, user_id, conversation_id
)
# Save file to disk
file_id = uuid.uuid4()
file_ext = os.path.splitext(file.filename or "")[1] or ""
storage_path = os.path.join(
DMS_STORAGE_BASE,
str(tenant_id),
str(folder.id),
f"{file_id}{file_ext}",
)
os.makedirs(os.path.dirname(storage_path), exist_ok=True)
with open(storage_path, "wb") as f:
f.write(content)
# Create DMS file record
dms_file = DmsFile(
tenant_id=tenant_id,
name=file.filename or f"{file_id}",
folder_id=folder.id,
uploaded_by=user_id,
mime_type=file.content_type or "application/octet-stream",
size_bytes=file_size,
storage_path=storage_path,
)
db.add(dms_file)
await db.flush()
return {
"file_id": str(dms_file.id),
"file_name": dms_file.name,
"file_type": dms_file.mime_type,
"file_size": dms_file.size_bytes,
"file_source": "comm",
}
@staticmethod
async def reference_external_file(
db: AsyncSession,
tenant_id: uuid.UUID,
file_id: uuid.UUID,
) -> dict[str, Any] | None:
"""Reference an existing DMS file without copying it.
Returns dict with file metadata or None if file not found.
"""
result = await db.execute(
select(DmsFile).where(
DmsFile.id == file_id,
DmsFile.tenant_id == tenant_id,
DmsFile.deleted_at.is_(None),
)
)
dms_file = result.scalar_one_or_none()
if dms_file is None:
return None
return {
"file_id": str(dms_file.id),
"file_name": dms_file.name,
"file_type": dms_file.mime_type,
"file_size": dms_file.size_bytes,
"file_source": "dms",
}
@staticmethod
async def get_file(
db: AsyncSession,
tenant_id: uuid.UUID,
file_id: uuid.UUID,
) -> DmsFile | None:
"""Get a DMS file by ID."""
result = await db.execute(
select(DmsFile).where(
DmsFile.id == file_id,
DmsFile.tenant_id == tenant_id,
DmsFile.deleted_at.is_(None),
)
)
return result.scalar_one_or_none()
@@ -0,0 +1,160 @@
-- kommunikation plugin initial migration: creates all 10 tables
CREATE TABLE IF NOT EXISTS comm_conversations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
title VARCHAR(255),
title_set_by UUID,
is_pinned BOOLEAN NOT NULL DEFAULT FALSE,
is_locked BOOLEAN NOT NULL DEFAULT FALSE,
locked_by VARCHAR(100),
is_direct BOOLEAN NOT NULL DEFAULT FALSE,
is_archived BOOLEAN NOT NULL DEFAULT FALSE,
created_by UUID,
created_by_type VARCHAR(20) NOT NULL DEFAULT 'user',
last_msg_at TIMESTAMPTZ,
last_msg_preview TEXT,
last_msg_sender_type VARCHAR(50),
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS ix_comm_conversations_tenant ON comm_conversations(tenant_id);
CREATE INDEX IF NOT EXISTS ix_comm_conversations_tenant_pinned ON comm_conversations(tenant_id, is_pinned);
CREATE INDEX IF NOT EXISTS ix_comm_conversations_last_msg ON comm_conversations(tenant_id, last_msg_at DESC);
CREATE TABLE IF NOT EXISTS comm_participants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
conversation_id UUID NOT NULL REFERENCES comm_conversations(id) ON DELETE CASCADE,
participant_id UUID,
participant_type VARCHAR(50) NOT NULL,
display_name VARCHAR(255),
role VARCHAR(20) NOT NULL DEFAULT 'member',
joined_at TIMESTAMPTZ NOT NULL DEFAULT now(),
left_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ,
UNIQUE(conversation_id, participant_id, participant_type)
);
CREATE INDEX IF NOT EXISTS ix_comm_participants_tenant_conv ON comm_participants(tenant_id, conversation_id);
CREATE INDEX IF NOT EXISTS ix_comm_participants_tenant_user ON comm_participants(tenant_id, participant_id);
CREATE TABLE IF NOT EXISTS comm_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
conversation_id UUID NOT NULL REFERENCES comm_conversations(id) ON DELETE CASCADE,
sender_id UUID,
sender_type VARCHAR(50) NOT NULL,
content TEXT NOT NULL DEFAULT '',
content_format VARCHAR(20) NOT NULL DEFAULT 'text',
metadata JSONB NOT NULL DEFAULT '{}',
reply_to_id UUID REFERENCES comm_messages(id) ON DELETE SET NULL,
is_pinned BOOLEAN NOT NULL DEFAULT FALSE,
read_at TIMESTAMPTZ,
edited_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS ix_comm_messages_tenant_conv ON comm_messages(tenant_id, conversation_id, created_at);
CREATE INDEX IF NOT EXISTS ix_comm_messages_tenant_sender ON comm_messages(tenant_id, sender_id);
CREATE TABLE IF NOT EXISTS comm_message_blocks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
message_id UUID NOT NULL REFERENCES comm_messages(id) ON DELETE CASCADE,
block_type VARCHAR(50) NOT NULL,
block_data JSONB NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS ix_comm_blocks_tenant_msg ON comm_message_blocks(tenant_id, message_id);
CREATE TABLE IF NOT EXISTS comm_message_attachments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
message_id UUID NOT NULL REFERENCES comm_messages(id) ON DELETE CASCADE,
file_id UUID,
file_source VARCHAR(10) NOT NULL DEFAULT 'comm',
file_name VARCHAR(255) NOT NULL,
file_type VARCHAR(255) NOT NULL,
file_size INTEGER,
thumbnail_path VARCHAR(1024),
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS ix_comm_attachments_tenant_msg ON comm_message_attachments(tenant_id, message_id);
CREATE TABLE IF NOT EXISTS comm_message_reactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
message_id UUID NOT NULL REFERENCES comm_messages(id) ON DELETE CASCADE,
user_id UUID NOT NULL,
emoji VARCHAR(50) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ,
UNIQUE(message_id, user_id, emoji)
);
CREATE INDEX IF NOT EXISTS ix_comm_reactions_tenant_msg ON comm_message_reactions(tenant_id, message_id);
CREATE TABLE IF NOT EXISTS comm_message_reads (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
conversation_id UUID NOT NULL REFERENCES comm_conversations(id) ON DELETE CASCADE,
user_id UUID NOT NULL,
last_read_msg_id UUID REFERENCES comm_messages(id) ON DELETE SET NULL,
last_read_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ,
UNIQUE(conversation_id, user_id)
);
CREATE INDEX IF NOT EXISTS ix_comm_reads_tenant_user ON comm_message_reads(tenant_id, user_id);
CREATE TABLE IF NOT EXISTS comm_conversation_pins (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
conversation_id UUID NOT NULL REFERENCES comm_conversations(id) ON DELETE CASCADE,
user_id UUID NOT NULL,
pinned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ,
UNIQUE(conversation_id, user_id)
);
CREATE INDEX IF NOT EXISTS ix_comm_pins_tenant_user ON comm_conversation_pins(tenant_id, user_id);
CREATE TABLE IF NOT EXISTS comm_conversation_mutes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
conversation_id UUID NOT NULL REFERENCES comm_conversations(id) ON DELETE CASCADE,
user_id UUID NOT NULL,
muted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ,
UNIQUE(conversation_id, user_id)
);
CREATE INDEX IF NOT EXISTS ix_comm_mutes_tenant_user ON comm_conversation_mutes(tenant_id, user_id);
CREATE TABLE IF NOT EXISTS comm_message_edits (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
message_id UUID NOT NULL REFERENCES comm_messages(id) ON DELETE CASCADE,
old_content TEXT NOT NULL,
old_blocks JSONB NOT NULL DEFAULT '[]',
edited_by UUID NOT NULL,
edited_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS ix_comm_edits_tenant_msg ON comm_message_edits(tenant_id, message_id);
@@ -0,0 +1,73 @@
"""Mini-App registry for plugin-provided interactive chat components."""
from __future__ import annotations
import logging
from typing import Any, Callable, Awaitable
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
class MiniAppDef(BaseModel):
"""Definition of a mini-app that plugins can register."""
app_id: str = Field(..., description="Unique app identifier")
name: str = Field(..., description="Display name")
icon: str = Field(default="app", description="Icon name")
description: str = Field(default="", description="App description")
plugin_name: str = Field(..., description="Plugin that registered this app")
render_schema: dict[str, Any] = Field(
default_factory=dict, description="JSON schema for frontend rendering"
)
class MiniAppRegistry:
"""Registry for mini-apps that plugins provide for chat embedding."""
def __init__(self) -> None:
self._apps: dict[str, MiniAppDef] = {}
def register(
self,
app_id: str,
name: str,
icon: str,
description: str,
plugin_name: str,
render_schema: dict[str, Any] | None = None,
) -> None:
"""Register a mini-app."""
app = MiniAppDef(
app_id=app_id,
name=name,
icon=icon,
description=description,
plugin_name=plugin_name,
render_schema=render_schema or {},
)
self._apps[app_id] = app
logger.info(f"Mini-app registered: {app_id} by {plugin_name}")
def unregister(self, app_id: str) -> None:
"""Unregister a mini-app."""
app = self._apps.pop(app_id, None)
if app:
logger.info(f"Mini-app unregistered: {app_id}")
def unregister_plugin(self, plugin_name: str) -> None:
"""Unregister all mini-apps from a specific plugin."""
to_remove = [app_id for app_id, app in self._apps.items() if app.plugin_name == plugin_name]
for app_id in to_remove:
self._apps.pop(app_id, None)
if to_remove:
logger.info(f"Unregistered {len(to_remove)} mini-apps from plugin {plugin_name}")
def list_apps(self) -> list[dict[str, Any]]:
"""List all available mini-apps for frontend."""
return [app.model_dump() for app in self._apps.values()]
def get_app(self, app_id: str) -> MiniAppDef | None:
"""Get a specific mini-app definition."""
return self._apps.get(app_id)
@@ -0,0 +1,283 @@
"""SQLAlchemy models for the kommunikation plugin."""
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import (
Boolean,
DateTime,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin
class CommConversation(Base, TenantMixin):
"""Conversation / Room — tenant-scoped, supports pinning, locking, archiving."""
__tablename__ = "comm_conversations"
__table_args__ = (
Index("ix_comm_conversations_tenant", "tenant_id"),
Index("ix_comm_conversations_tenant_pinned", "tenant_id", "is_pinned"),
Index("ix_comm_conversations_last_msg", "tenant_id", "last_msg_at"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
title: Mapped[str | None] = mapped_column(String(255), nullable=True)
title_set_by: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
is_pinned: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_locked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
locked_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
is_direct: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
created_by: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
created_by_type: Mapped[str] = mapped_column(String(20), nullable=False, default="user")
last_msg_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_msg_preview: Mapped[str | None] = mapped_column(Text, nullable=True)
last_msg_sender_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
metadata_: Mapped[dict[str, Any]] = mapped_column("metadata", JSONB, default=dict, nullable=False)
class CommParticipant(Base, TenantMixin):
"""Participant in a conversation — user, ai, system, gateway, etc."""
__tablename__ = "comm_participants"
__table_args__ = (
UniqueConstraint(
"conversation_id", "participant_id", "participant_type",
name="uq_comm_participants_conv_part_type",
),
Index("ix_comm_participants_tenant_conv", "tenant_id", "conversation_id"),
Index("ix_comm_participants_tenant_user", "tenant_id", "participant_id"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
conversation_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("comm_conversations.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
participant_id: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
participant_type: Mapped[str] = mapped_column(String(50), nullable=False)
display_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
role: Mapped[str] = mapped_column(String(20), nullable=False, default="member")
joined_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
left_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class CommMessage(Base, TenantMixin):
"""Message in a conversation — text content plus rich content blocks."""
__tablename__ = "comm_messages"
__table_args__ = (
Index("ix_comm_messages_tenant_conv", "tenant_id", "conversation_id", "created_at"),
Index("ix_comm_messages_tenant_sender", "tenant_id", "sender_id"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
conversation_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("comm_conversations.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
sender_id: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
sender_type: Mapped[str] = mapped_column(String(50), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False, default="")
content_format: Mapped[str] = mapped_column(String(20), nullable=False, default="text")
metadata_: Mapped[dict[str, Any]] = mapped_column("metadata", JSONB, default=dict, nullable=False)
reply_to_id: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("comm_messages.id", ondelete="SET NULL"),
nullable=True,
)
is_pinned: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
edited_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class CommMessageBlock(Base, TenantMixin):
"""Rich content block attached to a message."""
__tablename__ = "comm_message_blocks"
__table_args__ = (
Index("ix_comm_blocks_tenant_msg", "tenant_id", "message_id"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
message_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("comm_messages.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
block_type: Mapped[str] = mapped_column(String(50), nullable=False)
block_data: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
class CommMessageAttachment(Base, TenantMixin):
"""File attachment on a message — DMS reference or comm-internal upload."""
__tablename__ = "comm_message_attachments"
__table_args__ = (
Index("ix_comm_attachments_tenant_msg", "tenant_id", "message_id"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
message_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("comm_messages.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
file_id: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
file_source: Mapped[str] = mapped_column(String(10), nullable=False, default="comm")
file_name: Mapped[str] = mapped_column(String(255), nullable=False)
file_type: Mapped[str] = mapped_column(String(255), nullable=False)
file_size: Mapped[int | None] = mapped_column(Integer, nullable=True)
thumbnail_path: Mapped[str | None] = mapped_column(String(1024), nullable=True)
metadata_: Mapped[dict[str, Any]] = mapped_column("metadata", JSONB, default=dict, nullable=False)
class CommMessageReaction(Base, TenantMixin):
"""Emoji reaction on a message."""
__tablename__ = "comm_message_reactions"
__table_args__ = (
UniqueConstraint("message_id", "user_id", "emoji", name="uq_comm_reactions_msg_user_emoji"),
Index("ix_comm_reactions_tenant_msg", "tenant_id", "message_id"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
message_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("comm_messages.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
user_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
emoji: Mapped[str] = mapped_column(String(50), nullable=False)
class CommMessageRead(Base, TenantMixin):
"""Read state per user per conversation."""
__tablename__ = "comm_message_reads"
__table_args__ = (
UniqueConstraint("conversation_id", "user_id", name="uq_comm_reads_conv_user"),
Index("ix_comm_reads_tenant_user", "tenant_id", "user_id"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
conversation_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("comm_conversations.id", ondelete="CASCADE"),
nullable=False,
)
user_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
last_read_msg_id: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("comm_messages.id", ondelete="SET NULL"),
nullable=True,
)
last_read_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
class CommConversationPin(Base, TenantMixin):
"""User-specific conversation pinning."""
__tablename__ = "comm_conversation_pins"
__table_args__ = (
UniqueConstraint("conversation_id", "user_id", name="uq_comm_pins_conv_user"),
Index("ix_comm_pins_tenant_user", "tenant_id", "user_id"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
conversation_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("comm_conversations.id", ondelete="CASCADE"),
nullable=False,
)
user_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
pinned_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
class CommConversationMute(Base, TenantMixin):
"""User-specific conversation muting."""
__tablename__ = "comm_conversation_mutes"
__table_args__ = (
UniqueConstraint("conversation_id", "user_id", name="uq_comm_mutes_conv_user"),
Index("ix_comm_mutes_tenant_user", "tenant_id", "user_id"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
conversation_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("comm_conversations.id", ondelete="CASCADE"),
nullable=False,
)
user_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
muted_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
class CommMessageEdit(Base, TenantMixin):
"""Edit history for messages — stores old content before each edit."""
__tablename__ = "comm_message_edits"
__table_args__ = (
Index("ix_comm_edits_tenant_msg", "tenant_id", "message_id"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
message_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("comm_messages.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
old_content: Mapped[str] = mapped_column(Text, nullable=False)
old_blocks: Mapped[list[Any]] = mapped_column(JSONB, default=list, nullable=False)
edited_by: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
edited_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
@@ -0,0 +1,89 @@
"""Participant Registry — Andockpunkt für Plugins als Teilnehmer."""
from __future__ import annotations
import logging
from abc import ABC, abstractmethod
from typing import Any
logger = logging.getLogger(__name__)
class ParticipantHandler(ABC):
"""Interface that plugins implement to act as a conversation participant."""
@abstractmethod
async def on_message_received(
self,
conversation_id: Any,
message: dict[str, Any],
conversation: dict[str, Any],
mentions: list[str],
context: dict[str, Any],
) -> list[dict[str, Any]] | None:
"""Called when a new message arrives in a conversation this participant is part of.
Args:
conversation_id: UUID of the conversation.
message: The new message dict.
conversation: Full conversation dict with participants.
mentions: Parsed @mention participant types.
context: Tenant, user, and other context.
Returns:
Optional list of new message dicts (e.g. AI response).
For passive readers (system) → return None.
For reactive participants (ai) → return [message_dict, ...].
"""
pass
@abstractmethod
def get_participant_info(self) -> dict[str, Any]:
"""Return metadata: display_name, avatar_url, capabilities, description."""
pass
class ParticipantRegistry:
"""Global registry for plugin participants."""
def __init__(self) -> None:
self._handlers: dict[str, ParticipantHandler] = {}
def register(self, participant_type: str, handler: ParticipantHandler) -> None:
"""Register a plugin as a participant type."""
self._handlers[participant_type] = handler
logger.info(f"Participant registered: {participant_type}")
def unregister(self, participant_type: str) -> None:
"""Unregister a participant type."""
handler = self._handlers.pop(participant_type, None)
if handler:
logger.info(f"Participant unregistered: {participant_type}")
def get_handler(self, participant_type: str) -> ParticipantHandler | None:
"""Get the handler for a participant type."""
return self._handlers.get(participant_type)
def list_types(self) -> list[str]:
"""List all registered participant types."""
return list(self._handlers.keys())
def get_all_info(self) -> dict[str, dict[str, Any]]:
"""Get info for all registered participants."""
return {ptype: handler.get_participant_info() for ptype, handler in self._handlers.items()}
# Global instance
_registry = ParticipantRegistry()
def get_participant_registry() -> ParticipantRegistry:
"""Get the global participant registry."""
return _registry
def reset_participant_registry_for_testing() -> ParticipantRegistry:
"""Create a fresh registry for testing."""
global _registry
_registry = ParticipantRegistry()
return _registry
@@ -0,0 +1,91 @@
"""Kommunikation plugin — unified messaging: chat, AI, system, messenger."""
from __future__ import annotations
import logging
from typing import Any
from app.plugins.base import BasePlugin
from app.plugins.manifest import PluginManifest, PluginRouteDef
logger = logging.getLogger(__name__)
class KommunikationPlugin(BasePlugin):
"""Unified messaging plugin: conversations, messages, participants, WebSocket, rich content."""
manifest = PluginManifest(
name="kommunikation",
version="1.0.0",
display_name="Kommunikation",
description=(
"Unified Messaging: Chat, KI, System, Messenger — "
"alles ist ein Teilnehmer."
),
dependencies=["permissions", "dms"],
routes=[
PluginRouteDef(
path="/api/v1/comm",
module="app.plugins.builtins.kommunikation.routes",
router_attr="router",
),
],
events=[
"message.received",
"message.sent",
"conversation.created",
"conversation.updated",
"participant.joined",
"participant.left",
"reaction.added",
],
migrations=["0001_initial.sql"],
permissions=[
"comm:read",
"comm:write",
"comm:create",
"comm:manage",
"comm:admin",
"comm:delete",
],
is_core=True,
)
async def on_activate(self, db, service_container, event_bus) -> None:
"""Register participant registry and WebSocket manager."""
await super().on_activate(db, service_container, event_bus)
# Register WebSocket manager as a shared service
from app.plugins.builtins.kommunikation.websocket_manager import WebSocketManager
ws_manager = WebSocketManager()
service_container.register("comm_websocket", ws_manager)
# Register Mini-App registry as a shared service
from app.plugins.builtins.kommunikation.miniapp_registry import MiniAppRegistry
miniapp_registry = MiniAppRegistry()
service_container.register("comm_miniapps", miniapp_registry)
logger.info("Kommunikation plugin activated — WebSocket + MiniApp registries ready")
async def on_deactivate(self, db, service_container, event_bus) -> None:
"""Clean up registries."""
await super().on_deactivate(db, service_container, event_bus)
logger.info("Kommunikation plugin deactivated")
def get_notification_types(self) -> list[dict[str, Any]]:
return [
{
"type_key": "comm_message",
"category": "communication",
"label": "Neue Nachricht",
"description": "Neue Nachricht in einer Konversation",
"is_enabled_by_default": True,
},
{
"type_key": "comm_mention",
"category": "communication",
"label": "Erwähnung",
"description": "Du wurdest in einer Nachricht erwähnt",
"is_enabled_by_default": True,
},
]
+136
View File
@@ -0,0 +1,136 @@
"""Chat-internal RBAC logic — two-level permission checks."""
from __future__ import annotations
import logging
import uuid
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.permissions import check_permission
from app.plugins.builtins.kommunikation.models import CommParticipant
logger = logging.getLogger(__name__)
class CommRBAC:
"""Chat-internal RBAC, integrated with the existing permission system."""
@staticmethod
async def get_user_role(
db: AsyncSession,
conversation_id: uuid.UUID,
user_id: uuid.UUID,
) -> str | None:
"""Get the user's role in a conversation, or None if not a participant."""
result = await db.execute(
select(CommParticipant).where(
CommParticipant.conversation_id == conversation_id,
CommParticipant.participant_id == user_id,
CommParticipant.participant_type == "user",
CommParticipant.left_at.is_(None),
)
)
participant = result.scalar_one_or_none()
return participant.role if participant else None
@staticmethod
async def is_participant(
db: AsyncSession,
conversation_id: uuid.UUID,
user_id: uuid.UUID,
) -> bool:
"""Check if a user is an active participant in a conversation."""
role = await CommRBAC.get_user_role(db, conversation_id, user_id)
return role is not None
@staticmethod
async def can_user_write(
db: AsyncSession,
conversation_id: uuid.UUID,
current_user: dict[str, Any],
) -> bool:
"""Check if user can write messages.
1. System permission 'comm:write' via check_permission
2. is_system_admin → always allowed
3. User must be participant with role 'admin' or 'member'
"""
if current_user.get("is_system_admin"):
return True
if not check_permission(current_user, "comm:write"):
return False
user_id = uuid.UUID(current_user["user_id"])
role = await CommRBAC.get_user_role(db, conversation_id, user_id)
return role in ("admin", "member")
@staticmethod
async def can_user_manage(
db: AsyncSession,
conversation_id: uuid.UUID,
current_user: dict[str, Any],
) -> bool:
"""Check if user can manage participants.
1. System permission 'comm:manage' via check_permission
2. is_system_admin → always allowed
3. User must be conversation admin
"""
if current_user.get("is_system_admin"):
return True
if not check_permission(current_user, "comm:manage"):
return False
user_id = uuid.UUID(current_user["user_id"])
role = await CommRBAC.get_user_role(db, conversation_id, user_id)
return role == "admin"
@staticmethod
async def can_user_delete(
db: AsyncSession,
conversation_id: uuid.UUID,
current_user: dict[str, Any],
is_own_message: bool = False,
) -> bool:
"""Check if user can delete messages or conversation.
1. is_system_admin → always allowed
2. For own messages: comm:write suffices
3. For others' messages: comm:delete + conversation admin
4. For conversation: comm:delete + admin role
"""
if current_user.get("is_system_admin"):
return True
if is_own_message:
return check_permission(current_user, "comm:write")
if not check_permission(current_user, "comm:delete"):
return False
user_id = uuid.UUID(current_user["user_id"])
role = await CommRBAC.get_user_role(db, conversation_id, user_id)
return role == "admin"
@staticmethod
async def can_user_create(current_user: dict[str, Any]) -> bool:
"""Check if user can create conversations."""
if current_user.get("is_system_admin"):
return True
return check_permission(current_user, "comm:create")
@staticmethod
async def can_user_change_role(
db: AsyncSession,
conversation_id: uuid.UUID,
current_user: dict[str, Any],
) -> bool:
"""Check if user can change participant roles.
Requires comm:admin permission + conversation admin role.
"""
if current_user.get("is_system_admin"):
return True
if not check_permission(current_user, "comm:admin"):
return False
user_id = uuid.UUID(current_user["user_id"])
role = await CommRBAC.get_user_role(db, conversation_id, user_id)
return role == "admin"
@@ -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)
@@ -0,0 +1,177 @@
"""Pydantic schemas for the kommunikation plugin API."""
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field
# ─── Conversation Schemas ───
class ParticipantResponse(BaseModel):
id: str
conversation_id: str
participant_id: str | None = None
participant_type: str
display_name: str | None = None
role: str
joined_at: str | None = None
class ConversationCreate(BaseModel):
title: str | None = None
participant_ids: list[str] = Field(default_factory=list)
participant_types: list[str] = Field(default_factory=lambda: ["user"])
initial_message: str | None = None
is_direct: bool = False
class ConversationUpdate(BaseModel):
title: str | None = None
is_archived: bool | None = None
class ConversationResponse(BaseModel):
id: str
title: str | None = None
is_locked: bool = False
locked_by: str | None = None
is_direct: bool = False
is_archived: bool = False
is_pinned: bool = False
created_by: str | None = None
created_by_type: str = "user"
last_msg_at: str | None = None
last_msg_preview: str | None = None
last_msg_sender_type: str | None = None
participants: list[ParticipantResponse] = Field(default_factory=list)
unread_count: int = 0
metadata: dict[str, Any] = Field(default_factory=dict)
class ConversationListResponse(BaseModel):
items: list[ConversationResponse]
total: int
# ─── Participant Management ───
class ParticipantAdd(BaseModel):
participant_id: str
participant_type: str = "user"
role: str = "member"
class ParticipantRoleUpdate(BaseModel):
role: str = Field(..., pattern="^(admin|member|reader)$")
# ─── Message Schemas ───
class MessageBlockCreate(BaseModel):
block_type: str
block_data: dict[str, Any]
class MessageBlockResponse(BaseModel):
id: str
block_type: str
block_data: dict[str, Any]
sort_order: int = 0
class AttachmentCreate(BaseModel):
file_id: str | None = None
file_source: str = "comm"
class AttachmentResponse(BaseModel):
id: str
file_id: str | None = None
file_source: str = "comm"
file_name: str
file_type: str
file_size: int | None = None
thumbnail_path: str | None = None
class MessageCreate(BaseModel):
content: str = ""
content_format: str = "text"
blocks: list[MessageBlockCreate] = Field(default_factory=list)
reply_to_id: str | None = None
attachments: list[AttachmentCreate] = Field(default_factory=list)
class MessageUpdate(BaseModel):
content: str | None = None
read: bool | None = None
class MessageResponse(BaseModel):
id: str
conversation_id: str
sender_id: str | None = None
sender_type: str
content: str = ""
content_format: str = "text"
metadata: dict[str, Any] = Field(default_factory=dict)
reply_to_id: str | None = None
is_pinned: bool = False
created_at: str | None = None
edited_at: str | None = None
blocks: list[MessageBlockResponse] = Field(default_factory=list)
attachments: list[AttachmentResponse] = Field(default_factory=list)
reactions: list[dict[str, Any]] = Field(default_factory=list)
class MessageListResponse(BaseModel):
items: list[MessageResponse]
total: int
page: int = 1
has_more: bool = False
# ─── Reaction Schemas ───
class ReactionCreate(BaseModel):
emoji: str
class ReactionResponse(BaseModel):
id: str
message_id: str
user_id: str
emoji: str
# ─── Read State ───
class ReadStateUpdate(BaseModel):
last_read_msg_id: str | None = None
# ─── Mini-App Schemas ───
class MiniAppResponse(BaseModel):
app_id: str
name: str
icon: str
description: str
plugin_name: str
render_schema: dict[str, Any] = Field(default_factory=dict)
class MiniAppStartRequest(BaseModel):
app_id: str
config: dict[str, Any] = Field(default_factory=dict)
# ─── WebSocket Message Schemas ───
class WSMessage(BaseModel):
type: str
data: dict[str, Any] = Field(default_factory=dict)
@@ -0,0 +1,112 @@
"""Search provider for unified_search integration."""
from __future__ import annotations
import logging
import uuid
from typing import Any
from sqlalchemy import select, func, or_
from sqlalchemy.ext.asyncio import AsyncSession
from app.plugins.builtins.kommunikation.models import (
CommConversation,
CommMessage,
CommParticipant,
)
logger = logging.getLogger(__name__)
class CommSearchProvider:
"""Provider for unified_search — searches conversations and messages."""
async def search(
self,
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
query: str,
limit: int = 20,
) -> list[dict[str, Any]]:
"""Search in messages and conversations the user has access to."""
results: list[dict[str, Any]] = []
# Get user's conversation IDs
conv_result = await db.execute(
select(CommParticipant.conversation_id).where(
CommParticipant.participant_id == user_id,
CommParticipant.participant_type == "user",
CommParticipant.left_at.is_(None),
CommParticipant.tenant_id == tenant_id,
)
)
conv_ids = [row[0] for row in conv_result.fetchall()]
if not conv_ids:
return results
# Search in messages
msg_result = await db.execute(
select(CommMessage, CommConversation.title)
.join(CommConversation, CommConversation.id == CommMessage.conversation_id)
.where(
CommMessage.conversation_id.in_(conv_ids),
CommMessage.tenant_id == tenant_id,
CommMessage.deleted_at.is_(None),
CommMessage.content.ilike(f"%{query}%"),
)
.order_by(CommMessage.created_at.desc())
.limit(limit)
)
for msg, conv_title in msg_result.fetchall():
# Build snippet around match
content = msg.content or ""
idx = content.lower().find(query.lower())
if idx >= 0:
start = max(0, idx - 30)
end = min(len(content), idx + len(query) + 30)
snippet = ("..." if start > 0 else "") + content[start:end] + ("..." if end < len(content) else "")
else:
snippet = content[:100]
results.append({
"type": "message",
"id": str(msg.id),
"conversation_id": str(msg.conversation_id),
"conversation_title": conv_title,
"content": msg.content,
"sender_type": msg.sender_type,
"created_at": msg.created_at.isoformat() if msg.created_at else None,
"snippet": snippet,
})
# Search in conversation titles
conv_title_result = await db.execute(
select(CommConversation).where(
CommConversation.id.in_(conv_ids),
CommConversation.tenant_id == tenant_id,
CommConversation.deleted_at.is_(None),
CommConversation.title.ilike(f"%{query}%"),
)
.limit(limit)
)
for conv in conv_title_result.scalars().all():
results.append({
"type": "conversation",
"id": str(conv.id),
"title": conv.title,
"last_msg_at": conv.last_msg_at.isoformat() if conv.last_msg_at else None,
})
return results
async def index_message(self, message: dict[str, Any]) -> None:
"""Index a message for search (placeholder for future full-text indexing)."""
pass
async def reindex_all(self, db: AsyncSession, tenant_id: uuid.UUID) -> None:
"""Full reindex (placeholder for future full-text indexing)."""
pass
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,89 @@
"""WebSocket connection manager for the kommunikation plugin."""
from __future__ import annotations
import json
import logging
from typing import Any
from fastapi import WebSocket
logger = logging.getLogger(__name__)
class WebSocketManager:
"""Manages WebSocket connections per user for real-time messaging."""
def __init__(self) -> None:
# user_id (str) → list of WebSocket connections
self._connections: dict[str, list[WebSocket]] = {}
# conversation_id (str) → set of user_ids subscribed
self._subscriptions: dict[str, set[str]] = {}
async def connect(self, websocket: WebSocket, user_id: str) -> None:
"""Accept and register a new WebSocket connection."""
await websocket.accept()
if user_id not in self._connections:
self._connections[user_id] = []
self._connections[user_id].append(websocket)
logger.debug(f"WebSocket connected: user={user_id}, total={len(self._connections[user_id])}")
async def disconnect(self, websocket: WebSocket, user_id: str) -> None:
"""Remove a WebSocket connection."""
conns = self._connections.get(user_id, [])
if websocket in conns:
conns.remove(websocket)
if not conns:
self._connections.pop(user_id, None)
# Remove from all subscriptions
for conv_id, users in self._subscriptions.items():
users.discard(user_id)
logger.debug(f"WebSocket disconnected: user={user_id}, remaining={len(conns)}")
def subscribe(self, conversation_id: str, user_id: str) -> None:
"""Subscribe a user to a conversation's updates."""
if conversation_id not in self._subscriptions:
self._subscriptions[conversation_id] = set()
self._subscriptions[conversation_id].add(user_id)
def unsubscribe(self, conversation_id: str, user_id: str) -> None:
"""Unsubscribe a user from a conversation."""
if conversation_id in self._subscriptions:
self._subscriptions[conversation_id].discard(user_id)
async def send_to_user(self, user_id: str, message: dict[str, Any]) -> None:
"""Send a message to all connections of a specific user."""
conns = self._connections.get(user_id, [])
text = json.dumps(message, default=str)
for ws in conns:
try:
await ws.send_text(text)
except Exception:
logger.warning(f"Failed to send to user {user_id}, removing connection")
await self.disconnect(ws, user_id)
async def send_to_conversation(
self,
conversation_id: str,
message: dict[str, Any],
exclude_user: str | None = None,
) -> None:
"""Send a message to all users subscribed to a conversation."""
user_ids = self._subscriptions.get(conversation_id, set())
for user_id in list(user_ids):
if exclude_user and user_id == exclude_user:
continue
await self.send_to_user(user_id, message)
async def broadcast(self, message: dict[str, Any]) -> None:
"""Broadcast a message to all connected users."""
for user_id in list(self._connections.keys()):
await self.send_to_user(user_id, message)
def get_online_users(self) -> list[str]:
"""Get list of currently connected user IDs."""
return list(self._connections.keys())
def is_user_online(self, user_id: str) -> bool:
"""Check if a user has any active connections."""
return user_id in self._connections and len(self._connections[user_id]) > 0
@@ -0,0 +1,46 @@
"""System participant handler — passive participant that only reads."""
from __future__ import annotations
import logging
from typing import Any
from app.plugins.builtins.kommunikation.participant_registry import ParticipantHandler
logger = logging.getLogger(__name__)
class SystemParticipantHandler(ParticipantHandler):
"""System participant — passive, does not respond to messages.
The system participant only sends messages triggered by system events
(handled in the plugin's event handlers). It does not respond to
user messages in the chat.
"""
def __init__(self, container: Any) -> None:
self._container = container
async def on_message_received(
self,
conversation_id: Any,
message: dict[str, Any],
conversation: dict[str, Any],
mentions: list[str],
context: dict[str, Any],
) -> list[dict[str, Any]] | None:
"""System is passive — does not respond to messages.
Users can interact with action_card buttons (open, archive) which
are handled via the frontend, not via new messages.
"""
return None
def get_participant_info(self) -> dict[str, Any]:
"""Return participant metadata."""
return {
"display_name": "System",
"avatar_url": None,
"capabilities": ["notifications", "action_cards"],
"description": "System-Benachrichtigungen",
}
+223
View File
@@ -0,0 +1,223 @@
"""System Notification plugin — converts system events into chat messages."""
from __future__ import annotations
import logging
from typing import Any
from app.plugins.base import BasePlugin
from app.plugins.manifest import PluginManifest
logger = logging.getLogger(__name__)
class SystemNotifPlugin(BasePlugin):
"""System notifications as a participant in the kommunikation plugin."""
manifest = PluginManifest(
name="system_notif",
version="1.0.0",
display_name="System Benachrichtigungen",
description=(
"Wandelt System-Events in Chat-Nachrichten um. "
"Registriert sich als 'system' Teilnehmer am kommunikation Plugin."
),
dependencies=["kommunikation"],
routes=[],
events=[
"lead.created",
"contact.created",
"contact.updated",
"task.overdue",
"task.created",
"mail.received",
"user.created",
"workflow.completed",
"notification.created",
],
migrations=[],
permissions=["system_notif:read"],
is_core=False,
)
def __init__(self) -> None:
super().__init__()
self._system_handler = None
async def on_activate(self, db, service_container, event_bus) -> None:
"""Register as system participant and subscribe to events."""
await super().on_activate(db, service_container, event_bus)
from app.plugins.builtins.system_notif.participant_handler import SystemParticipantHandler
from app.plugins.builtins.kommunikation.participant_registry import get_participant_registry
self._system_handler = SystemParticipantHandler(service_container)
registry = get_participant_registry()
registry.register("system", self._system_handler)
logger.info("System notification plugin activated — registered as 'system' participant")
async def on_deactivate(self, db, service_container, event_bus) -> None:
"""Unregister participant."""
from app.plugins.builtins.kommunikation.participant_registry import get_participant_registry
get_participant_registry().unregister("system")
self._system_handler = None
await super().on_deactivate(db, service_container, event_bus)
logger.info("System notification plugin deactivated")
# ─── Event Handlers ───
async def on_lead_created(self, payload: dict[str, Any]) -> None:
"""Handle lead.created event → system message."""
await self._create_system_notification(payload, event_type="lead.created")
async def on_contact_created(self, payload: dict[str, Any]) -> None:
"""Handle contact.created event → system message."""
await self._create_system_notification(payload, event_type="contact.created")
async def on_contact_updated(self, payload: dict[str, Any]) -> None:
"""Handle contact.updated event → system message."""
await self._create_system_notification(payload, event_type="contact.updated")
async def on_task_overdue(self, payload: dict[str, Any]) -> None:
"""Handle task.overdue event → system message (warning)."""
await self._create_system_notification(payload, event_type="task.overdue", severity="warning")
async def on_task_created(self, payload: dict[str, Any]) -> None:
"""Handle task.created event → system message."""
await self._create_system_notification(payload, event_type="task.created")
async def on_mail_received(self, payload: dict[str, Any]) -> None:
"""Handle mail.received event → system message."""
await self._create_system_notification(payload, event_type="mail.received")
async def on_user_created(self, payload: dict[str, Any]) -> None:
"""Handle user.created event → system message."""
await self._create_system_notification(payload, event_type="user.created")
async def on_workflow_completed(self, payload: dict[str, Any]) -> None:
"""Handle workflow.completed event → system message."""
await self._create_system_notification(payload, event_type="workflow.completed")
async def on_notification_created(self, payload: dict[str, Any]) -> None:
"""Handle notification.created event → system message.
This is the bridge from the core notification system to the kommunikation plugin.
Core code publishes 'notification.created' on the EventBus, this plugin
converts it into a comm_message in the user's System room.
"""
await self._create_system_notification(payload, event_type="notification.created")
async def _create_system_notification(
self,
payload: dict[str, Any],
event_type: str,
severity: str = "info",
) -> None:
"""Create a system notification message in the user's System room."""
import uuid
from app.core.db import create_db_session
from app.plugins.builtins.kommunikation.services import create_plugin_room, send_message
tenant_id_str = payload.get("tenant_id")
user_id_str = payload.get("user_id")
if not tenant_id_str or not user_id_str:
logger.warning("System notification missing tenant_id or user_id in payload: %s", payload)
return
try:
tenant_id = uuid.UUID(tenant_id_str)
user_id = uuid.UUID(user_id_str)
except (ValueError, TypeError):
logger.warning("System notification invalid UUIDs: tenant=%s user=%s", tenant_id_str, user_id_str)
return
# Build notification content from payload
title = payload.get("title", "")
body = payload.get("body", "")
action_url = payload.get("action_url", "")
if not title:
# Generate title from event type
event_titles = {
"lead.created": "Neuer Lead",
"contact.created": "Neuer Kontakt",
"contact.updated": "Kontakt aktualisiert",
"task.overdue": "Aufgabe überfällig",
"task.created": "Neue Aufgabe",
"mail.received": "Neue E-Mail",
"user.created": "Neuer Benutzer",
"workflow.completed": "Workflow abgeschlossen",
"notification.created": "Benachrichtigung",
}
title = event_titles.get(event_type, event_type)
content = f"**{title}**"
if body:
content += f"\n{body}"
# Build action_card block if action_url is present
blocks = []
if action_url:
blocks.append({
"block_type": "action_card",
"block_data": {
"title": title,
"body": body or "",
"actions": [
{"label": "Öffnen", "action": action_url, "type": "primary"},
{"label": "Archivieren", "action": "dismiss", "type": "secondary"},
],
},
})
async with create_db_session(tenant_id) as db:
# Ensure System room exists for this user
await create_plugin_room(
db, tenant_id, user_id,
plugin_name="system_notif",
title="System",
participant_type="system",
user_role="reader",
)
# Find the System room conversation
from sqlalchemy import select
from app.plugins.builtins.kommunikation.models import CommConversation, CommParticipant
result = await db.execute(
select(CommConversation).where(
CommConversation.tenant_id == tenant_id,
CommConversation.title == "System",
CommConversation.is_locked == True,
CommConversation.locked_by == "system_notif",
CommConversation.deleted_at.is_(None),
).join(CommParticipant, CommParticipant.conversation_id == CommConversation.id).where(
CommParticipant.participant_id == user_id,
CommParticipant.participant_type == "user",
CommParticipant.left_at.is_(None),
)
)
conv = result.scalar_one_or_none()
if conv is None:
logger.warning("Could not find System room for user %s", user_id)
return
# Send the system message
await send_message(
db, tenant_id, conv.id,
sender_id=None,
sender_type="system",
content=content,
content_format="markdown",
blocks=blocks if blocks else None,
metadata={"event_type": event_type, "severity": severity},
)
logger.info("System notification created: %s for user %s", event_type, user_id)
def get_notification_types(self) -> list[dict[str, Any]]:
return []
File diff suppressed because it is too large Load Diff
+624
View File
@@ -0,0 +1,624 @@
# Konzept: Unified Messaging System für LeoCRM
## Vision
Alle Kommunikation in LeoCRM — KI-Chat, Mitarbeiter-Chat, System-Benachrichtigungen, externe Messenger — läuft über **ein einheitliches Messaging-System**, das als Plugin-Architektur realisiert wird.
**Kernprinzip:** Alles ist ein Teilnehmer. Die KI ist ein Teilnehmer. Das System ist ein Teilnehmer. Ein WhatsApp-Gateway ist ein Teilnehmer. Es gibt keine Sonderbehandlung.
---
## Plugin-Architektur
### Übersicht: Drei Plugin-Ebenen
```
┌─────────────────────────────────────────────────────────────┐
│ Frontend (MessageSidebar) │
│ Ein Feed, eine Konversations-Liste, ein Eingabefeld │
└──────────────────────────┬──────────────────────────────────┘
│ │ WebSocket │
┌──────────────────────────┴──────────────────────────────────┐
│ Plugin: kommunikation (Core) │
│ ┌─────────────┐ ┌──────────────┐ ┌────────────────────┐ │
│ │ conversations│ │ messages │ │ participants │ │
│ │ + Räume │ │ + Rich Cont.│ │ + Registrierung │ │
│ └─────────────┘ └──────────────┘ └────────────────────┘ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Participant Registry (Hook) │ │
│ │ Andockpunkt für: ai_assistant, system_notif, │ │
│ │ whatsapp_gateway, telegram_gateway, ... │ │
│ └──────────────────────────────────────────────────────┘ │
└──────────────────────────┬──────────────────────────────────┘
│ │ EventBus │
┌──────────────────────────┴──────────────────────────────────┐
│ Plugin: ai_assistant │ Plugin: system_notif │ Plugin: │
│ dockt als Teilnehmer an │ dockt als Teilnehmer │ whatsapp │
│ @KI → AI-Response │ Events → Messages │ Gateway │
└─────────────────────────────────────────────────────────────┘
```
### 1. Plugin `kommunikation` (Core-Plugin)
**Verantwortung:** Chat-Infrastruktur — Konversationen, Nachrichten, Teilnehmer-Verwaltung, WebSocket, Rich Content Transport.
**Basiert auf:** AI Assistant Plugin (Sessions/Messages/Streaming) als Grundlage, erweitert um Multi-Teilnehmer und Rich Content.
**Manifest:**
```python
PluginManifest(
name="kommunikation",
version="1.0.0",
display_name="Kommunikation",
description="Unified Messaging: Chat, KI, System, Messenger",
dependencies=[],
routes=[
PluginRouteDef(path="/api/v1/comm", module="...routes", router_attr="router"),
],
events=["message.received", "conversation.created", "participant.joined"],
migrations=["0001_initial.sql"],
permissions=["comm:read", "comm:write", "comm:manage"],
is_core=True,
)
```
**Komponenten:**
- `models.py` — Conversation, Message, Participant, MessageAttachment, MessageReaction
- `schemas.py` — Pydantic-Schemas für API
- `routes.py` — REST-API + WebSocket
- `services.py` — Business Logic (Nachrichten senden, Konversationen verwalten)
- `participant_registry.py` — Registrierungs-Interface für andere Plugins
- `content_types.py` — Rich Content Type-Definitionen
- `websocket_manager.py` — WebSocket-Verbindungs-Manager
### 2. Participant Registry (Andockpunkt)
Das `kommunikation` Plugin stellt eine **Participant Registry** bereit — ein Interface, über das sich andere Plugins als Teilnehmer registrieren.
```python
# In kommunikation/participant_registry.py
class ParticipantType(Enum):
USER = "user"
AI = "ai"
SYSTEM = "system"
WHATSAPP = "whatsapp"
TELEGRAM = "telegram"
EMAIL = "email"
SLACK = "slack"
# Erweiterbar...
class ParticipantHandler(ABC):
"""Interface das Plugins implementieren um als Teilnehmer zu fungieren."""
@abstractmethod
async def on_message_received(self, conversation_id, message, context) -> Message | None:
"""Wird aufgerufen wenn eine neue Nachricht in einer Konversation
ankommt, an der dieser Teilnehmer beteiligt ist.
Rückgabe: Optional eine neue Message (z.B. AI-Response).
Für reine Leser (system) → return None.
Für reaktive Teilnehmer (ai) → return Message(...).
"""
pass
@abstractmethod
def get_participant_info(self) -> dict:
"""Metadaten: name, avatar_url, display_name, capabilities."""
pass
class ParticipantRegistry:
"""Global registry für Plugin-Teilnehmer."""
def register(self, participant_type: str, handler: ParticipantHandler) -> None:
"""Plugin registriert sich als Teilnehmer-Typ."""
def get_handler(self, participant_type: str) -> ParticipantHandler | None:
"""Handler für einen Teilnehmer-Typ abrufen."""
```
**Wie Plugins andocken:**
```python
# In ai_assistant/plugin.py on_activate():
from app.plugins.builtins.kommunikation.participant_registry import get_registry
class AIAssistantPlugin(BasePlugin):
async def on_activate(self, db, container, event_bus):
await super().on_activate(db, container, event_bus)
# Als AI-Teilnehmer registrieren
registry = get_registry()
registry.register("ai", AIParticipantHandler(self.services))
```
```python
# In system_notif/plugin.py on_activate():
class SystemNotificationPlugin(BasePlugin):
async def on_activate(self, db, container, event_bus):
await super().on_activate(db, container, event_bus)
# Als System-Teilnehmer registrieren
registry = get_registry()
registry.register("system", SystemParticipantHandler(...))
# Auf Events hören und Nachrichten erzeugen
event_bus.subscribe("lead.created", self.on_lead_created)
```
### 3. Plugin `ai_assistant` (Anpassung)
Das bestehende AI Assistant Plugin wird angepasst:
- Behält: Provider-Verwaltung, Modelle, Agents, Tools, Streaming
- Neu: Implementiert `ParticipantHandler` und registriert sich bei `kommunikation`
- Neu: Lauscht auf `message.received` Events → wenn `@KI` erwähnt wird oder Konversation AI als Teilnehmer hat → generiert Response
- Alt: Eigene `AIChatSession` / `AIChatMessage` Tabellen bleiben für Abwärtskompatibilität, werden langfristig migriert
- Neu: Schreibt Nachrichten in `kommunikation.messages` statt nur in eigene Tabellen
### 4. Plugin `system_notif` (Neu)
**Verantwortung:** System-Events in Nachrichten umwandeln.
- Registriert sich als `system` Teilnehmer
- Hört auf EventBus-Events (`lead.created`, `contact.created`, `task.overdue`, ...)
- Erzeugt Nachrichten in der System-Konversation des Users
- Notifications haben `metadata.action_url` und `metadata.severity`
- Bestehende Notification-Tabelle wird migriert
### 5. Messenger-Gateway Plugins (Später)
Jeder Messenger ist ein eigenes Plugin:
- `whatsapp_gateway` — registriert sich als `whatsapp` Teilnehmer
- `telegram_gateway` — registriert sich als `telegram` Teilnehmer
- `email_gateway` — registriert sich als `email` Teilnehmer
Jedes implementiert `ParticipantHandler` und ggf. Webhook-Routes.
---
## Datenmodell
### Tabelle `comm_conversations`
```
id UUID PK
tenant_id UUID NOT NULL
title TEXT NULL -- benannte Räume
title_set_by UUID NULL -- user_id der den Titel gesetzt hat
is_pinned BOOLEAN DEFAULT FALSE
is_direct BOOLEAN DEFAULT FALSE -- 1:1 vs Gruppe
created_by UUID NULL
last_msg_at TIMESTAMP
last_msg_preview TEXT NULL -- für Konversations-Liste
last_msg_sender_type TEXT NULL -- für Icon in Liste
metadata JSONB DEFAULT '{}' -- z.B. {"pinned_by": "user_id"}
created_at TIMESTAMP DEFAULT NOW()
updated_at TIMESTAMP DEFAULT NOW()
```
### Tabelle `comm_participants`
```
id UUID PK
conversation_id UUID FK → comm_conversations
participant_id UUID NULL -- user_id (NULL für ai/system/gateways)
participant_type TEXT NOT NULL -- 'user', 'ai', 'system', 'whatsapp', ...
display_name TEXT NULL -- override (z.B. WhatsApp-Kontakt-Name)
joined_at TIMESTAMP DEFAULT NOW()
left_at TIMESTAMP NULL
```
### Tabelle `comm_messages`
```
id UUID PK
tenant_id UUID NOT NULL
conversation_id UUID FK → comm_conversations
sender_id UUID NULL -- user_id (NULL für ai/system/gateways)
sender_type TEXT NOT NULL -- 'user', 'ai', 'system', 'whatsapp', ...
content TEXT NOT NULL -- Text-Inhalt (Markdown)
content_format TEXT DEFAULT 'text' -- 'text', 'markdown', 'html'
metadata JSONB DEFAULT '{}' -- typ-spezifische Daten
reply_to_id UUID NULL FK → comm_messages -- Thread-Antwort
created_at TIMESTAMP DEFAULT NOW()
read_at TIMESTAMP NULL
edited_at TIMESTAMP NULL
deleted_at TIMESTAMP NULL
```
### Tabelle `comm_message_attachments`
```
id UUID PK
message_id UUID FK → comm_messages
file_name TEXT NOT NULL
file_path TEXT NOT NULL -- Pfad im DMS oder S3
file_type TEXT NOT NULL -- MIME type
file_size BIGINT
thumbnail_path TEXT NULL -- für Bilder/Videos
metadata JSONB DEFAULT '{}' -- z.B. {"width": 1920, "height": 1080}
created_at TIMESTAMP DEFAULT NOW()
```
### Tabelle `comm_message_reactions`
```
id UUID PK
message_id UUID FK → comm_messages
user_id UUID NOT NULL
emoji TEXT NOT NULL
created_at TIMESTAMP DEFAULT NOW()
UNIQUE(message_id, user_id, emoji)
```
### Tabelle `comm_message_reads`
```
id UUID PK
conversation_id UUID FK
user_id UUID NOT NULL
last_read_msg_id UUID FK → comm_messages
last_read_at TIMESTAMP DEFAULT NOW()
```
### Tabelle `comm_message_blocks` (Rich Content / Mini-Apps)
```
id UUID PK
message_id UUID FK → comm_messages
block_type TEXT NOT NULL -- 'file', 'image', 'audio', 'video',
-- 'markdown', 'html', 'miniapp',
-- 'action_card', 'contact_card', ...
block_data JSONB NOT NULL -- typ-spezifische strukturierte Daten
sort_order INT DEFAULT 0
```
**Das ist der Schlüssel für Rich Content:**
Eine Nachricht hat einen `content` (Text) plus beliebig viele `blocks` (strukturierte Elemente).
---
## Rich Content Transport
### Block-Typen (erweiterbar durch Plugins)
| block_type | Beschreibung | block_data Beispiel |
|---|---|---|
| `text` | Reiner Text (Fallback) | `{"text": "..."}` |
| `markdown` | Markdown-Content | `{"markdown": "# Titel\n..."}` |
| `html` | HTML-Content (sanitized) | `{"html": "<div>...</div>"}` |
| `image` | Bild | `{"url": "...", "alt": "...", "width": 800}` |
| `audio` | Audio-Datei | `{"url": "...", "duration": 120, "waveform": [...]}` |
| `video` | Video-Datei | `{"url": "...", "duration": 60, "thumbnail": "..."}` |
| `file` | Allgemeine Datei | `{"url": "...", "name": "...", "size": 1024}` |
| `action_card` | Interaktive Karte mit Buttons | `{"title": "...", "body": "...", "actions": [{"label": "Öffnen", "url": "..."}]}` |
| `contact_card` | Kontakt-Referenz | `{"contact_id": "...", "name": "..."}` |
| `miniapp` | Eingebettete Mini-App | `{"app_id": "...", "config": {...}}` |
### Mini-App System
Mini-Apps sind kleine interaktive Komponenten, die **von Plugins registriert** und im Chat gerendert werden.
```python
# Plugin registriert eine Mini-App:
class MiniAppRegistry:
def register(self, app_id: str, component: dict) -> None:
"""Registriert eine Mini-App.
component = {
'name': 'Lead Qualifier',
'icon': 'clipboard',
'render_schema': {...}, # JSON-Schema für Frontend
'handler': async function # Backend-Handler
}
"""
```
**Beispiel:** Ein Plugin `lead_qualifier` registriert eine Mini-App. Ein User schickt `/miniapp lead_qualifier` im Chat → eine Mini-App-Block wird erzeugt → Frontend rendert das interaktive Formular → Ergebnis wird als Nachricht zurückgeschrieben.
### Nachricht mit Rich Content — Beispiel
```json
{
"id": "...",
"conversation_id": "...",
"sender_type": "ai",
"content": "Hier ist die Zusammenfassung der neuen Leads:",
"blocks": [
{
"block_type": "markdown",
"block_data": {
"markdown": "## 3 neue Leads\n- **Acme Corp** — €50k potential\n- **Globex** — €20k potential\n- **Initech** — €10k potential"
}
},
{
"block_type": "action_card",
"block_data": {
"title": "Nächste Schritte",
"body": "3 Leads warten auf Qualifizierung.",
"actions": [
{"label": "Alle öffnen", "action": "open_leads", "type": "primary"},
{"label": "Ignorieren", "action": "dismiss", "type": "secondary"}
]
}
}
]
}
```
---
## API
### REST Endpoints
```
# Konversationen
GET /api/v1/comm/conversations -- Liste (für aktuellen User)
POST /api/v1/comm/conversations -- Neue Konversation
GET /api/v1/comm/conversations/{id} -- Details + Teilnehmer
PATCH /api/v1/comm/conversations/{id} -- Titel ändern, pinnen
DELETE /api/v1/comm/conversations/{id} -- Löschen/Verlassen
# Teilnehmer
POST /api/v1/comm/conversations/{id}/participants -- Teilnehmer hinzufügen
DELETE /api/v1/comm/conversations/{id}/participants/{pid} -- Entfernen
# Nachrichten
GET /api/v1/comm/conversations/{id}/messages -- Nachrichten (paginiert)
POST /api/v1/comm/conversations/{id}/messages -- Nachricht senden
PATCH /api/v1/comm/messages/{id} -- Bearbeiten/Lesen
DELETE /api/v1/comm/messages/{id} -- Löschen
# Attachments
POST /api/v1/comm/messages/{id}/attachments -- Datei hochladen
GET /api/v1/comm/attachments/{id} -- Datei herunterladen
# Reaktionen
POST /api/v1/comm/messages/{id}/reactions -- Reaktion hinzufügen
DELETE /api/v1/comm/messages/{id}/reactions/{emoji} -- Reaktion entfernen
# Read State
POST /api/v1/comm/conversations/{id}/read -- Als gelesen markieren
# Mini-Apps
GET /api/v1/comm/miniapps -- Verfügbare Mini-Apps
POST /api/v1/comm/conversations/{id}/miniapps -- Mini-App starten
```
### WebSocket
```
WS /api/v1/comm/ws
# Client → Server
{"type": "subscribe", "conversation_id": "..."}
{"type": "typing", "conversation_id": "...", "is_typing": true}
{"type": "ping"}
# Server → Client
{"type": "message.new", "conversation_id": "...", "message": {...}}
{"type": "message.updated", "message": {...}}
{"type": "message.deleted", "id": "..."}
{"type": "participant.joined", "conversation_id": "...", "participant": {...}}
{"type": "participant.left", "conversation_id": "...", "participant_id": "..."}
{"type": "typing", "conversation_id": "...", "user_id": "...", "is_typing": true}
{"type": "reaction.added", "message_id": "...", "emoji": "👍", "user_id": "..."}
{"type": "conversation.updated", "conversation": {...}}
{"type": "pong"}
```
---
## UI-Konzept
### MessageSidebar (ersetzt AISidebar)
```
┌──────────────────────────────────────────┐
│ Kommunikation [×] │
├──────────────────────────────────────────┤
│ 🔍 Suche... │
├──────────────────────────────────────────┤
│ 📌 Projekt Alpha │ ←angepinnt
│ 🤖 KI: 3 Leads zusammengefasst... │
│ ┌────────────────────────────────────┐ │
│ │ Max: @KI fasse die Leads zusammen │ │
│ │ 🤖 KI: 3 neue Leads, 2 aus... │ │
│ │ Lisa: Super, danke! │ │
│ │ ┌──────────────────────────────┐ │ │
│ │ │ 📎 lead_report.pdf │ │ │
│ │ │ ──────────────────────────── │ │ │
│ │ │ ## 3 neue Leads │ │ │
│ │ │ - **Acme Corp** — €50k │ │ │
│ │ │ ──────────────────────────── │ │ │
│ │ │ [Öffnen] [Archivieren] │ │ │
│ │ └──────────────────────────────┘ │ │
│ └────────────────────────────────────┘ │
│ │
│ 📌 Assistent (1:1 mit KI) │
│ 🤖 47 Kontakte ohne Email... │
│ │
│ 👥 Sales Team │
│ Max: Hat jemand die Q3-Zahlen? │
│ │
│ 🔔 System │
│ 3 neue Leads importiert │
│ │
├──────────────────────────────────────────┤
│ [📎] [Eingabefeld...] [Senden] │
└──────────────────────────────────────────┘
```
### Konversations-Liste (links im Panel)
- Angespinnte Konversationen oben (📌)
- Ungelesene-Badge pro Konversation
- Letzte Nachricht mit Sender-Icon (🤖/👤/🔔)
- Klick → öffnet Konversation im Feed
### Feed (Mitte)
- Chronologische Nachrichten
- Sender-Icon + Name pro Nachricht
- Rich Content Blocks inline gerendert
- Action-Cards mit Buttons
- Datei-Anhänge mit Vorschau
- Reaktionen (Emoji-Bar beim Hover)
- Lesebestätigung (gelesen-Häkchen)
### Eingabefeld (unten)
- Text-Eingabe mit Markdown-Support
- Datei-Anhang Button (📎)
- Mini-App Picker (/command)
- @Mention Support (@KI, @Max)
- Senden-Button
- Kontextsensitiv: in System-Konversation → kein Eingabefeld
### Teilnehmer-Info
- In Konversations-Header: Avatare aller Teilnehmer
- Klick auf Avatar → Info-Popover
- KI-Teilnehmer: zeigt Modell/Agent
- System-Teilnehmer: zeigt Quelle
### Räume
- Konversationen können benannt werden (Titel editierbar)
- Anpinnen möglich (📌)
- Gruppierung durch Titel, nicht durch spezielle Raum-Logik
- Ein "Raum" ist einfach eine benannte Konversation
---
## EventBus Integration
### Events vom kommunikation Plugin
```
message.received → {conversation_id, message, sender_type}
message.sent → {conversation_id, message}
conversation.created → {conversation_id, participants, created_by}
participant.joined → {conversation_id, participant_type, participant_id}
participant.left → {conversation_id, participant_id}
```
### Events die andere Plugins hören
```
# ai_assistant hört auf:
message.received → prüft ob @KI erwähnt oder AI Teilnehmer → generiert Response
# system_notif hört auf (vom Core-System):
lead.created → erzeugt System-Nachricht
contact.created → erzeugt System-Nachricht
task.overdue → erzeugt System-Nachricht
# whatsapp_gateway hört auf:
message.received → wenn Konversation WhatsApp-Teilnehmer hat → sende extern
```
---
## Migration
### Phase 1: Backend — Plugin `kommunikation`
1. Neue Tabellen: `comm_conversations`, `comm_participants`, `comm_messages`, `comm_message_attachments`, `comm_message_blocks`, `comm_message_reactions`, `comm_message_reads`
2. Participant Registry Interface
3. REST-API + WebSocket
4. Rich Content Block System
5. Mini-App Registry Interface
### Phase 2: Backend — Plugin `ai_assistant` anpassen
1. `ParticipantHandler` implementieren
2. Bei `kommunikation` registrieren
3. Auf `message.received` hören → AI-Response generieren
4. Streaming-Responses über WebSocket pushen
5. Alte `AIChatSession`/`AIChatMessage` behalten für Abwärtskompatibilität
### Phase 3: Backend — Plugin `system_notif` (neu)
1. `ParticipantHandler` implementieren
2. System-Events → Nachrichten in System-Konversation
3. Bestehende Notifications migrieren
4. Action-URLs als `action_card` Blocks
### Phase 4: Frontend — MessageSidebar
1. AISidebar → MessageSidebar umbauen
2. Konversations-Liste mit Pinning
3. Unified Feed mit Rich Content Rendering
4. Eingabefeld mit Datei-Upload + @Mention
5. WebSocket-Verbindung
6. Mini-App Rendering Framework
### Phase 5: Frontend — Rich Content Renderer
1. Block-Renderer: Markdown, HTML, Image, Audio, Video, File
2. Action-Card Renderer mit Button-Handler
3. Mini-App Renderer (Plugin-basiert)
4. Contact-Card, Lead-Card, etc.
### Phase 6: Messenger-Gateway Plugins (später)
1. `whatsapp_gateway` Plugin
2. `telegram_gateway` Plugin
3. `email_gateway` Plugin
4. Jeweils: ParticipantHandler + Webhook-Routes + Gateway-Adapter
---
## Technische Entscheidungen
### WebSocket vs Polling
**WebSocket** — eine Verbindung pro User, pusht alle Konversationen.
Grund: Real-time ist essenziell für Chat, und eine Verbindung für alles ist effizienter als Multiple Polling.
### Rich Content: Blocks vs Inline
**Blocks** — separate Tabelle `comm_message_blocks` mit `block_type` + `block_data`.
Grund: Erweiterbar durch Plugins, strukturiert, frontend kann unbekannte Typen graceful ignorieren.
### Mini-Apps: Plugin-basiert
**Registry Pattern** — Plugins registrieren Mini-Apps bei `kommunikation`.
Grund: Plugins können eigene Mini-Apps mitbringen, Frontend rendert sie dynamisch.
### Räume: Keine separate Tabelle
**Titel + Pinning** — eine Konversation mit Titel ist ein Raum.
Grund: Minimalistisch, keine zusätzliche Komplexität, flexibel.
### @Mention Detection
**Im Backend** — `message.received` Event enthält geparste mentions.
Grund: Zentrale Logik, alle Teilnehmer-Plugins bekommen saubere Daten.
### Abwärtskompatibilität
**Alte Tabellen behalten** — `ai_chat_sessions`, `ai_chat_messages`, `ai_conversations`, `ai_messages` bleiben erhalten.
Grund: Bestehende Daten gehen nicht verloren, Migration schrittweise.
---
## Offene Fragen
1. **Soll `kommunikation` das bestehende AI Copilot System (AIConversation/AIMessage) ersetzen oder parallel laufen?**
- Vorschlag: Parallel, langfristig migrieren
2. **Datei-Speicherung:** DMS-Plugin nutzen oder eigener Speicher für Attachments?
- Vorschlag: DMS-Integration, `file_path` verweist auf DMS-Dokument
3. **Berechtigungen:** Wer darf Konversationen erstellen? Wer darf Teilnehmer hinzufügen?
- Vorschlag: `comm:write` für erstellen, `comm:manage` für Teilnehmer verwalten
4. **Gruppen-Chat-Limit:** Maximale Anzahl Teilnehmer?
- Vorschlag: Kein Limit, Performance-Test später
5. **Nachrichten-Historie:** Endlos oder Paginierung mit Lazy-Loading?
- Vorschlag: Paginierung (50 pro Seite), Lazy-Load beim Scrollen
6. **Suche:** Über alle Konversationen? Global mit unified_search Plugin?
- Vorschlag: Ja, `unified_search` Provider für `kommunikation`
7. **Push-Notifications:** Browser-Notifications bei neuen Nachrichten?
- Vorschlag: Ja, über Notification API + Service Worker
8. **Verschlüsselung:** E2E für bestimmte Konversationen?
- Vorschlag: Nein in Phase 1, später evaluieren
---
## Zusammenfassung
```
Ein Plugin (kommunikation) → Chat-Infrastruktur + Rich Content + WebSocket
Ein Interface (ParticipantHandler) → Plugins docken als Teilnehmer an
Ein Datenmodell (3+Tabellen) → Konversationen, Teilnehmer, Nachrichten + Blocks
Eine UI (MessageSidebar) → Ein Feed, eine Liste, ein Eingabefeld
Eine WebSocket → Real-time für alles
Ein EventBus → Plugins reagieren auf Nachrichten
KI = Teilnehmer → @KI in jedem Chat
System = Teilnehmer → Notifications als Nachrichten
WhatsApp = Teilnehmer → Externe Messenger andocken
Mini-Apps = Plugin-Blocks → Erweiterbar im Chat
Räume = Benannte Chats → Titel + Pinning
```
+199
View File
@@ -0,0 +1,199 @@
/**
* Communication plugin API client.
*
* All requests use the shared `apiClient` (`baseURL: '/api/v1'`) and target the
* kommunikation plugin routes under `/comm/...`.
*/
import { apiDelete, apiGet, apiPatch, apiPost } from './client';
// ─── Types ───
export interface Participant {
id: string;
conversation_id: string;
participant_id: string | null;
participant_type: string;
display_name: string | null;
role: string;
}
export interface Conversation {
id: string;
title: string | null;
is_locked: boolean;
locked_by: string | null;
is_direct: boolean;
is_archived: boolean;
is_pinned: boolean;
created_by: string | null;
created_by_type: string;
last_msg_at: string | null;
last_msg_preview: string | null;
last_msg_sender_type: string | null;
participants: Participant[];
unread_count: number;
metadata: Record<string, any>;
}
export interface MessageBlock {
id: string;
block_type: string;
block_data: Record<string, any>;
sort_order: number;
}
export interface MessageAttachment {
id: string;
file_id: string | null;
file_source: string;
file_name: string;
file_type: string;
file_size: number | null;
thumbnail_path: string | null;
}
export interface Message {
id: string;
conversation_id: string;
sender_id: string | null;
sender_type: string;
content: string;
content_format: string;
metadata: Record<string, any>;
reply_to_id: string | null;
is_pinned: boolean;
created_at: string | null;
edited_at: string | null;
blocks: MessageBlock[];
attachments: MessageAttachment[];
reactions: any[];
}
export interface ConversationListResponse {
items: Conversation[];
total: number;
}
export interface MessageListResponse {
items: Message[];
total: number;
page: number;
page_size: number;
}
export interface BlockType {
type: string;
label: string;
schema: Record<string, any>;
}
export interface MiniApp {
id: string;
name: string;
description: string;
config_schema: Record<string, any>;
}
// ─── Conversations ───
export const listConversations = (archived?: boolean) =>
apiGet<ConversationListResponse>('/comm/conversations', {
params: archived !== undefined ? { archived } : {},
});
export const createConversation = (data: {
title?: string;
participant_ids?: string[];
is_direct?: boolean;
initial_message?: string;
}) => apiPost<Conversation>('/comm/conversations', data);
export const getConversation = (id: string) =>
apiGet<Conversation>(`/comm/conversations/${id}`);
export const updateConversation = (id: string, data: { title?: string; is_archived?: boolean }) =>
apiPatch<Conversation>(`/comm/conversations/${id}`, data);
export const leaveConversation = (id: string) =>
apiDelete<{ success: boolean }>(`/comm/conversations/${id}`);
export const pinConversation = (id: string) =>
apiPost<{ success: boolean }>(`/comm/conversations/${id}/pin`);
export const unpinConversation = (id: string) =>
apiDelete<{ success: boolean }>(`/comm/conversations/${id}/pin`);
export const muteConversation = (id: string) =>
apiPost<{ success: boolean }>(`/comm/conversations/${id}/mute`);
export const unmuteConversation = (id: string) =>
apiDelete<{ success: boolean }>(`/comm/conversations/${id}/mute`);
// ─── Participants ───
export const addParticipant = (
convId: string,
data: { participant_id: string; participant_type: string; role?: string },
) => apiPost<Participant>(`/comm/conversations/${convId}/participants`, data);
export const removeParticipant = (convId: string, pid: string) =>
apiDelete<{ success: boolean }>(`/comm/conversations/${convId}/participants/${pid}`);
export const changeParticipantRole = (convId: string, pid: string, role: string) =>
apiPatch<Participant>(`/comm/conversations/${convId}/participants/${pid}`, { role });
// ─── Messages ───
export const getMessages = (convId: string, page = 1, pageSize = 50, before?: string) =>
apiGet<MessageListResponse>(`/comm/conversations/${convId}/messages`, {
params: { page, page_size: pageSize, ...(before ? { before } : {}) },
});
export const sendMessage = (
convId: string,
data: {
content: string;
content_format?: string;
blocks?: Array<{ block_type: string; block_data: Record<string, any> }>;
reply_to_id?: string;
attachments?: Array<Record<string, any>>;
},
) => apiPost<Message>(`/comm/conversations/${convId}/messages`, data);
export const editMessage = (id: string, data: { content?: string }) =>
apiPatch<Message>(`/comm/messages/${id}`, data);
export const deleteMessage = (id: string) =>
apiDelete<{ success: boolean }>(`/comm/messages/${id}`);
// ─── Reactions ───
export const addReaction = (msgId: string, emoji: string) =>
apiPost<any>(`/comm/messages/${msgId}/reactions`, { emoji });
export const removeReaction = (msgId: string, emoji: string) =>
apiDelete<{ success: boolean }>(`/comm/messages/${msgId}/reactions/${encodeURIComponent(emoji)}`);
// ─── Read State ───
export const markRead = (convId: string, lastReadMsgId?: string) =>
apiPost<{ success: boolean }>(`/comm/conversations/${convId}/read`, {
last_read_msg_id: lastReadMsgId,
});
// ─── Mini-Apps ───
export const listMiniApps = () =>
apiGet<{ items: MiniApp[] }>('/comm/miniapps');
export const startMiniApp = (convId: string, appId: string, config?: Record<string, any>) =>
apiPost<Message>(`/comm/conversations/${convId}/miniapps`, {
app_id: appId,
config: config || {},
});
// ─── Block Types ───
export const getBlockTypes = () =>
apiGet<{ items: BlockType[] }>('/comm/block-types');
@@ -0,0 +1,62 @@
import React from 'react';
import type { MessageBlock } from '@/store/commStore';
interface ActionCardBlockProps {
block: MessageBlock;
}
interface ActionItem {
label: string;
action: string;
type?: 'primary' | 'secondary';
}
const ActionCardBlock: React.FC<ActionCardBlockProps> = ({ block }) => {
const { title, body, actions } = block.block_data;
const cardTitle: string = title || '';
const cardBody: string = body || '';
const actionList: ActionItem[] = Array.isArray(actions) ? actions : [];
const handleActionClick = (action: ActionItem) => {
if (action.action === 'dismiss') {
// Frontend handles dismiss — no-op here, parent component can wire up
return;
}
// Treat as URL
window.open(action.action, '_blank', 'noopener,noreferrer');
};
return (
<div className="border border-secondary-200 rounded-lg p-4 shadow-sm bg-white space-y-2">
{cardTitle && (
<h4 className="text-sm font-semibold text-secondary-800">{cardTitle}</h4>
)}
{cardBody && (
<p className="text-sm text-secondary-600">{cardBody}</p>
)}
{actionList.length > 0 && (
<div className="flex flex-wrap gap-2 pt-1">
{actionList.map((action, idx) => {
const isPrimary = action.type !== 'secondary';
return (
<button
key={idx}
onClick={() => handleActionClick(action)}
className={
isPrimary
? 'px-3 py-1.5 text-sm rounded-lg bg-primary-600 text-white hover:bg-primary-700 transition-colors'
: 'px-3 py-1.5 text-sm rounded-lg bg-secondary-100 text-secondary-700 hover:bg-secondary-200 transition-colors'
}
>
{action.label || 'Aktion'}
</button>
);
})}
</div>
)}
</div>
);
};
export default ActionCardBlock;
@@ -0,0 +1,41 @@
import React from 'react';
import type { MessageBlock } from '@/store/commStore';
interface AudioBlockProps {
block: MessageBlock;
}
function formatDuration(seconds: number | undefined): string | null {
if (typeof seconds !== 'number' || seconds <= 0) return null;
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
const AudioBlock: React.FC<AudioBlockProps> = ({ block }) => {
const { url, duration } = block.block_data;
if (!url) {
return null;
}
const durationLabel = formatDuration(duration);
return (
<div className="space-y-1">
<audio
src={url}
controls
className="w-full rounded-lg"
preload="metadata"
/>
{durationLabel && (
<p className="text-xs text-secondary-500">
Dauer: {durationLabel}
</p>
)}
</div>
);
};
export default AudioBlock;
@@ -0,0 +1,75 @@
import React from 'react';
import type { MessageBlock } from '@/store/commStore';
import MarkdownBlock from './MarkdownBlock';
import HtmlBlock from './HtmlBlock';
import ImageBlock from './ImageBlock';
import AudioBlock from './AudioBlock';
import VideoBlock from './VideoBlock';
import FileBlock from './FileBlock';
import ActionCardBlock from './ActionCardBlock';
import ContactCardBlock from './ContactCardBlock';
import MiniAppBlock from './MiniAppBlock';
interface BlockRendererProps {
blocks: MessageBlock[];
}
const BlockRenderer: React.FC<BlockRendererProps> = ({ blocks }) => {
if (!blocks || blocks.length === 0) {
return null;
}
// Sort blocks by sort_order to ensure correct rendering sequence
const sortedBlocks = [...blocks].sort((a, b) => a.sort_order - b.sort_order);
return (
<div className="space-y-2">
{sortedBlocks.map((block) => {
const renderBlock = (): React.ReactNode => {
switch (block.block_type) {
case 'text':
// Text blocks: render as plain text in a styled div
return (
<div className="text-sm whitespace-pre-wrap break-words">
{block.block_data.text || block.block_data.content || ''}
</div>
);
case 'markdown':
return <MarkdownBlock block={block} />;
case 'html':
return <HtmlBlock block={block} />;
case 'image':
return <ImageBlock block={block} />;
case 'audio':
return <AudioBlock block={block} />;
case 'video':
return <VideoBlock block={block} />;
case 'file':
return <FileBlock block={block} />;
case 'action_card':
return <ActionCardBlock block={block} />;
case 'contact_card':
return <ContactCardBlock block={block} />;
case 'miniapp':
return <MiniAppBlock block={block} />;
default:
// Fallback for unknown block types
return (
<div className="text-xs text-secondary-400 italic p-2 rounded bg-secondary-50">
Unbekannter Block-Typ: {block.block_type}
</div>
);
}
};
return (
<div key={block.id} className="block-wrapper">
{renderBlock()}
</div>
);
})}
</div>
);
};
export default BlockRenderer;
@@ -0,0 +1,52 @@
import React from 'react';
import type { MessageBlock } from '@/store/commStore';
interface ContactCardBlockProps {
block: MessageBlock;
}
const ContactCardBlock: React.FC<ContactCardBlockProps> = ({ block }) => {
const { contact_id, name } = block.block_data;
const contactName: string = name || 'Unbekannter Kontakt';
const contactId: string = contact_id || '';
const linkHref = contactId ? `/contacts/${contactId}` : '#';
// Generate initials for avatar placeholder
const initials = contactName
.split(' ')
.map((part) => part.charAt(0).toUpperCase())
.slice(0, 2)
.join('');
return (
<a
href={linkHref}
className="flex items-center gap-3 p-3 rounded-lg border border-secondary-200 bg-white shadow-sm hover:shadow-md hover:border-primary-300 transition-all"
>
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-primary-100 text-primary-700 flex items-center justify-center text-sm font-semibold">
{initials || '?'}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-secondary-700 truncate">{contactName}</p>
<p className="text-xs text-secondary-400">Kontakt anzeigen</p>
</div>
<svg
className="w-4 h-4 text-secondary-400 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</a>
);
};
export default ContactCardBlock;
@@ -0,0 +1,85 @@
import React from 'react';
import type { MessageBlock } from '@/store/commStore';
interface FileBlockProps {
block: MessageBlock;
}
function formatFileSize(bytes: number | undefined): string {
if (typeof bytes !== 'number' || bytes <= 0) return '';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
const fileIcon = (
<svg
className="w-8 h-8 text-secondary-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z M14 2v6h6 M8 13h8 M8 17h5"
/>
</svg>
);
const downloadIcon = (
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
);
const FileBlock: React.FC<FileBlockProps> = ({ block }) => {
const { url, name, size } = block.block_data;
if (!url && !name) {
return null;
}
const fileName: string = name || 'Datei';
const fileSizeLabel = formatFileSize(size);
return (
<div className="flex items-center gap-3 p-3 rounded-lg border border-secondary-200 bg-white shadow-sm">
<div className="flex-shrink-0">{fileIcon}</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-secondary-700 truncate">{fileName}</p>
{fileSizeLabel && (
<p className="text-xs text-secondary-400">{fileSizeLabel}</p>
)}
</div>
{url && (
<a
href={url}
download={fileName}
target="_blank"
rel="noopener noreferrer"
className="flex-shrink-0 p-2 rounded-lg bg-primary-500 text-white hover:bg-primary-600 transition-colors"
aria-label={`Datei herunterladen: ${fileName}`}
>
{downloadIcon}
</a>
)}
</div>
);
};
export default FileBlock;
@@ -0,0 +1,45 @@
import React from 'react';
import type { MessageBlock } from '@/store/commStore';
interface HtmlBlockProps {
block: MessageBlock;
}
/**
* Basic HTML sanitization: removes <script> tags and event handler attributes.
* This is a minimal sanitizer — for production use DOMPurify or similar.
*/
function sanitizeHtml(html: string): string {
let sanitized = html;
// Remove <script>...</script> blocks (including content)
sanitized = sanitized.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
// Remove <script ...> self-referencing or incomplete tags
sanitized = sanitized.replace(/<script\b[^>]*>/gi, '');
// Remove on* event handler attributes (onclick, onload, onerror, etc.)
sanitized = sanitized.replace(/\son\w+\s*=\s*"[^"]*"/gi, '');
sanitized = sanitized.replace(/\son\w+\s*=\s*'[^']*'/gi, '');
sanitized = sanitized.replace(/\son\w+\s*=\s*[^\s>]+/gi, '');
// Remove javascript: URLs in href/src
sanitized = sanitized.replace(/(href|src)\s*=\s*"javascript:[^"]*"/gi, '$1="#"');
sanitized = sanitized.replace(/(href|src)\s*=\s*'javascript:[^']*'/gi, '$1="#"');
return sanitized;
}
const HtmlBlock: React.FC<HtmlBlockProps> = ({ block }) => {
const rawHtml: string = block.block_data.html || '';
if (!rawHtml) {
return null;
}
const sanitized = sanitizeHtml(rawHtml);
return (
<div
className="prose prose-sm max-w-none text-inherit"
dangerouslySetInnerHTML={{ __html: sanitized }}
/>
);
};
export default HtmlBlock;
@@ -0,0 +1,35 @@
import React from 'react';
import type { MessageBlock } from '@/store/commStore';
interface ImageBlockProps {
block: MessageBlock;
}
const ImageBlock: React.FC<ImageBlockProps> = ({ block }) => {
const { url, alt, width } = block.block_data;
if (!url) {
return null;
}
const imageAlt: string = alt || '';
const imageWidth: number | undefined = typeof width === 'number' ? width : undefined;
return (
<div className="space-y-1">
<a href={url} target="_blank" rel="noopener noreferrer" className="block">
<img
src={url}
alt={imageAlt}
width={imageWidth}
className="rounded-lg max-w-full shadow-sm cursor-pointer hover:shadow-md transition-shadow"
/>
</a>
{imageAlt && (
<p className="text-xs text-secondary-500 italic">{imageAlt}</p>
)}
</div>
);
};
export default ImageBlock;
@@ -0,0 +1,59 @@
import React from 'react';
import ReactMarkdown from 'react-markdown';
import type { MessageBlock } from '@/store/commStore';
interface MarkdownBlockProps {
block: MessageBlock;
}
const MarkdownBlock: React.FC<MarkdownBlockProps> = ({ block }) => {
const markdown: string = block.block_data.markdown || block.block_data.content || '';
if (!markdown) {
return null;
}
return (
<div className="prose prose-sm max-w-none text-inherit">
<ReactMarkdown
components={{
p: ({ children }) => <p className="mb-1 last:mb-0">{children}</p>,
ul: ({ children }) => <ul className="list-disc pl-4 mb-1">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal pl-4 mb-1">{children}</ol>,
code: ({ children, className }) => {
const isInline = !className;
if (isInline) {
return (
<code className="px-1 py-0.5 rounded bg-black/10 text-xs font-mono">
{children}
</code>
);
}
return (
<pre className="p-2 rounded bg-black/10 overflow-x-auto">
<code className="text-xs font-mono">{children}</code>
</pre>
);
},
h1: ({ children }) => <h1 className="text-base font-bold mb-1">{children}</h1>,
h2: ({ children }) => <h2 className="text-sm font-bold mb-1">{children}</h2>,
h3: ({ children }) => <h3 className="text-sm font-semibold mb-1">{children}</h3>,
a: ({ children, href }) => (
<a href={href} target="_blank" rel="noopener noreferrer" className="underline">
{children}
</a>
),
blockquote: ({ children }) => (
<blockquote className="border-l-2 border-current/30 pl-2 italic opacity-80">
{children}
</blockquote>
),
}}
>
{markdown}
</ReactMarkdown>
</div>
);
};
export default MarkdownBlock;
@@ -0,0 +1,50 @@
import React from 'react';
import type { MessageBlock } from '@/store/commStore';
interface MiniAppBlockProps {
block: MessageBlock;
}
const MiniAppBlock: React.FC<MiniAppBlockProps> = ({ block }) => {
const { app_id, config } = block.block_data;
const appId: string = app_id || 'Unbekannt';
const hasConfig = config && typeof config === 'object' && Object.keys(config).length > 0;
return (
<div className="border-2 border-dashed border-secondary-300 rounded-lg p-4 text-center bg-secondary-50">
<div className="flex flex-col items-center gap-2">
<svg
className="w-8 h-8 text-secondary-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M4 6a2 2 0 012-2h12a2 2 0 012 2v12a2 2 0 01-2 2H6a2 2 0 01-2-2V6z M9 9h6v6H9z"
/>
</svg>
<p className="text-sm font-medium text-secondary-600">
Mini-App: {appId}
</p>
<p className="text-xs text-secondary-400">
Mini-Apps werden in Zukunft vollständig gerendert.
</p>
{hasConfig && (
<details className="text-xs text-secondary-400 mt-1">
<summary className="cursor-pointer hover:text-secondary-600">Konfiguration</summary>
<pre className="mt-1 p-2 rounded bg-white text-left overflow-x-auto">
{JSON.stringify(config, null, 2)}
</pre>
</details>
)}
</div>
</div>
);
};
export default MiniAppBlock;
@@ -0,0 +1,30 @@
import React from 'react';
import type { MessageBlock } from '@/store/commStore';
interface VideoBlockProps {
block: MessageBlock;
}
const VideoBlock: React.FC<VideoBlockProps> = ({ block }) => {
const { url, thumbnail } = block.block_data;
if (!url) {
return null;
}
const poster: string | undefined = typeof thumbnail === 'string' ? thumbnail : undefined;
return (
<div className="space-y-1">
<video
src={url}
controls
poster={poster}
className="w-full rounded-lg"
preload="metadata"
/>
</div>
);
};
export default VideoBlock;
@@ -0,0 +1,10 @@
export { default as BlockRenderer } from './BlockRenderer';
export { default as MarkdownBlock } from './MarkdownBlock';
export { default as HtmlBlock } from './HtmlBlock';
export { default as ImageBlock } from './ImageBlock';
export { default as AudioBlock } from './AudioBlock';
export { default as VideoBlock } from './VideoBlock';
export { default as FileBlock } from './FileBlock';
export { default as ActionCardBlock } from './ActionCardBlock';
export { default as ContactCardBlock } from './ContactCardBlock';
export { default as MiniAppBlock } from './MiniAppBlock';
@@ -0,0 +1,66 @@
# Test Report: Phase 5 — Rich Content Block-Renderer
## Task
Implement Frontend Rich Content Block-Renderer Components for Unified Messaging System.
## Files Created
| File | Lines | Purpose |
|------|-------|---------|
| `BlockRenderer.tsx` | 75 | Main dispatcher — switches on `block_type`, renders appropriate sub-renderer, fallback for unknown types |
| `MarkdownBlock.tsx` | 59 | Renders Markdown via `react-markdown` (already installed), custom component styling |
| `HtmlBlock.tsx` | 45 | Renders sanitized HTML via `dangerouslySetInnerHTML` — strips `<script>`, `on*` handlers, `javascript:` URLs |
| `ImageBlock.tsx` | 35 | Renders image with alt caption, optional width, opens in new tab on click |
| `AudioBlock.tsx` | 41 | Renders `<audio>` player with controls, shows formatted duration |
| `VideoBlock.tsx` | 30 | Renders `<video>` player with controls, optional thumbnail poster |
| `FileBlock.tsx` | 85 | Renders file card with icon, name, size, download link |
| `ActionCardBlock.tsx` | 62 | Renders interactive card with title, body, action buttons (primary/secondary styling) |
| `ContactCardBlock.tsx` | 52 | Renders contact reference card with avatar initials, links to `/contacts/{id}` |
| `MiniAppBlock.tsx` | 50 | Renders placeholder for mini-apps with app_id and config details |
| `index.ts` | 10 | Barrel export for all components |
**Total: 544 lines across 11 files**
## Files Modified
| File | Change |
|------|--------|
| `MessageSidebar.tsx` | Added `BlockRenderer` import; replaced inline block rendering (40 lines) with `<BlockRenderer blocks={msg.blocks} />` (6 lines) |
## TypeScript Compilation Check
```
cd /a0/usr/workdir/leocrm-fix/frontend && npx tsc --noEmit 2>&1 | grep -E 'comm/blocks|MessageSidebar'
→ No errors in block components or MessageSidebar
```
### Pre-existing errors (NOT introduced by this task):
- `src/pages/Dms.tsx(553,13)`: `onRangeSelect` prop mismatch in `FileExplorerProps` — pre-existing, unrelated.
## Backend Compatibility
All block types match `content_types.py` definitions:
- `text``block_data.text`
- `markdown``block_data.markdown` (also falls back to `block_data.content`)
- `html``block_data.html`
- `image``block_data.url`, `block_data.alt`, `block_data.width`
- `audio``block_data.url`, `block_data.duration`
- `video``block_data.url`, `block_data.thumbnail`
- `file``block_data.url`, `block_data.name`, `block_data.size`
- `action_card``block_data.title`, `block_data.body`, `block_data.actions`
- `contact_card``block_data.contact_id`, `block_data.name`
- `miniapp``block_data.app_id`, `block_data.config`
## Smoke Test
- **TypeScript**: `npx tsc --noEmit` passes with zero new errors from block components.
- **Import chain**: `BlockRenderer` imports all 9 sub-renderers + types from `@/store/commStore` — all resolve correctly.
- **MessageSidebar integration**: Inline block rendering replaced with `<BlockRenderer>` component — compiles cleanly.
- **Barrel export**: `index.ts` exports all 10 components for clean imports.
## Notes
- `react-markdown` was already in `package.json` (`^10.1.0`) — used directly.
- HTML sanitization is minimal (regex-based). For production, consider `DOMPurify`.
- Unknown block types render a fallback message ("Unbekannter Block-Typ: {type}") per spec.
- Blocks are sorted by `sort_order` before rendering.
+5 -5
View File
@@ -3,7 +3,7 @@ import { Outlet, useLocation } from 'react-router-dom';
import { Sidebar } from './Sidebar';
import { TopBar } from './TopBar';
import { PluginToolbar } from './PluginToolbar';
import { AISidebar } from './AISidebar';
import { MessageSidebar } from './MessageSidebar';
import { ToastContainer } from '@/components/ui/Toast';
import { useAIContext } from '@/hooks/useAIContext';
@@ -13,8 +13,8 @@ export function AppShell() {
// Track context for AI Proactive suggestions
useAIContext();
// Hide AI sidebar on the AI Assistant page itself
const showAISidebar = !location.pathname.startsWith('/ai-assistant');
// Hide message sidebar on the AI Assistant page itself
const showMessageSidebar = !location.pathname.startsWith('/ai-assistant');
return (
<div className="flex h-screen overflow-hidden bg-secondary-50" data-testid="app-shell">
@@ -34,8 +34,8 @@ export function AppShell() {
</main>
</div>
</div>
{/* AI Sidebar — full height, right of TopBar and Toolbar */}
{showAISidebar && <AISidebar />}
{/* Message Sidebar — full height, right of TopBar and Toolbar */}
{showMessageSidebar && <MessageSidebar />}
<ToastContainer />
</div>
);
@@ -0,0 +1,683 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { ResizablePanel } from '@/components/ui/ResizablePanel';
import { useUIStore } from '@/store/uiStore';
import { useCommStore } from '@/store/commStore';
import { useTranslation } from 'react-i18next';
import { useUsers, useGroups } from '@/api/hooks';
import { Avatar } from '@/components/ui/Avatar';
import {
listConversations,
getMessages,
sendMessage,
markRead,
createConversation,
} from '@/api/comm';
import { useCommWebSocket } from '@/hooks/useCommWebSocket';
import type { Conversation, Message } from '@/store/commStore';
import { BlockRenderer } from '@/components/comm/blocks';
// ─── Icons (identical to AISidebar) ───
const robotIcon = (className: string) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 2a4 4 0 014 4v1h1a3 3 0 013 3v6a3 3 0 01-3 3h-1v1a4 4 0 01-4 4H8a4 4 0 01-4-4v-1H3a3 3 0 01-3-3V10a3 3 0 013-3h1V6a4 4 0 014-4z M9 10h.01M15 10h.01M9 15h6" />
</svg>
);
const bellIcon = (className: string) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
);
const bulbIcon = (className: string) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
);
const teamIcon = (className: string) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6-3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
);
const chatBubbleIcon = (className: string) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
);
const chevronRightIcon = (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
);
const sendIcon = (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
);
const pinIcon = (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
</svg>
);
// ─── Types ───
type SidebarView = 'conversations' | 'team';
interface QuickAccessDef {
key: string;
label: string;
icon: (cls: string) => React.ReactNode;
testId: string;
view: SidebarView;
filterPinned?: string;
}
// ─── Team Panel (reused from AISidebar) ───
function TeamPanel({ onStartDirectChat }: { onStartDirectChat: (userId: string, userName: string) => void }) {
const { data: usersData, isLoading: usersLoading } = useUsers();
const { data: groupsData, isLoading: groupsLoading } = useGroups();
const users: any[] = usersData?.items || [];
const groups: any[] = groupsData?.items || [];
return (
<div className="flex flex-col h-full overflow-y-auto p-3 gap-3" data-testid="team-panel">
<div>
<h3 className="text-xs font-semibold text-secondary-500 uppercase tracking-wide mb-2">Mitarbeiter</h3>
{usersLoading ? (
<p className="text-sm text-secondary-400">Laden...</p>
) : users.length === 0 ? (
<p className="text-sm text-secondary-400">Keine Mitarbeiter</p>
) : (
<div className="space-y-1">
{users.map((u) => (
<button
key={u.id}
onClick={() => onStartDirectChat(u.id, u.name)}
className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-secondary-50 transition-colors w-full text-left"
>
<div className="relative flex-shrink-0">
<Avatar name={u.name} size="sm" />
<span className="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full border-2 border-white bg-secondary-300" aria-label="offline" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-secondary-700 truncate">{u.name}</p>
<p className="text-xs text-secondary-400 truncate">{u.email}</p>
</div>
<span className="text-xs text-secondary-400">{u.role}</span>
</button>
))}
</div>
)}
</div>
<div>
<h3 className="text-xs font-semibold text-secondary-500 uppercase tracking-wide mb-2">Gruppen</h3>
{groupsLoading ? (
<p className="text-sm text-secondary-400">Laden...</p>
) : groups.length === 0 ? (
<p className="text-sm text-secondary-400">Keine Gruppen</p>
) : (
<div className="space-y-1">
{groups.map((g) => (
<div key={g.id} className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-secondary-50 transition-colors">
<div className="w-8 h-8 rounded-full bg-secondary-200 flex items-center justify-center flex-shrink-0">
<svg className="w-4 h-4 text-secondary-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-secondary-700 truncate">{g.name}</p>
{g.description && <p className="text-xs text-secondary-400 truncate">{g.description}</p>}
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
// ─── Conversation List Item ───
function ConversationListItem({
conv,
isActive,
onClick,
}: {
conv: Conversation;
isActive: boolean;
onClick: () => void;
}) {
const senderIcon =
conv.last_msg_sender_type === 'ai' ? '🤖' : conv.last_msg_sender_type === 'system' ? '🔔' : '👤';
return (
<button
onClick={onClick}
className={`flex items-start gap-2 px-3 py-2 rounded-lg transition-colors w-full text-left ${
isActive
? 'bg-primary-100 text-primary-700'
: 'hover:bg-secondary-50 text-secondary-700'
}`}
data-testid={`conv-item-${conv.id}`}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1">
{conv.is_pinned && <span className="text-secondary-400 flex-shrink-0">📌</span>}
<p className="text-sm font-medium truncate flex-1">
{conv.title || 'Ohne Titel'}
</p>
{conv.unread_count > 0 && (
<span className="flex-shrink-0 w-5 h-5 bg-danger-500 text-white text-[10px] font-bold rounded-full flex items-center justify-center">
{conv.unread_count}
</span>
)}
</div>
{conv.last_msg_preview && (
<p className="text-xs text-secondary-400 truncate mt-0.5">
<span className="mr-1">{senderIcon}</span>
{conv.last_msg_preview}
</p>
)}
</div>
</button>
);
}
// ─── Message Feed ───
function MessageFeed({
messages,
loading,
}: {
messages: Message[];
loading: boolean;
}) {
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages]);
if (loading) {
return (
<div className="flex items-center justify-center h-full text-sm text-secondary-400">
Laden...
</div>
);
}
if (messages.length === 0) {
return (
<div className="flex items-center justify-center h-full text-sm text-secondary-400">
Keine Nachrichten
</div>
);
}
return (
<div ref={scrollRef} className="flex-1 overflow-y-auto p-3 space-y-2">
{messages.map((msg) => {
const isAI = msg.sender_type === 'ai';
const isSystem = msg.sender_type === 'system';
const senderIcon = isAI ? '🤖' : isSystem ? '🔔' : '👤';
const senderName = isAI ? 'KI' : isSystem ? 'System' : 'Benutzer';
return (
<div
key={msg.id}
className={`flex flex-col ${msg.sender_type === 'user' ? 'items-end' : 'items-start'}`}
>
<div
className={`max-w-[85%] rounded-lg px-3 py-2 ${
msg.sender_type === 'user'
? 'bg-primary-500 text-white'
: 'bg-secondary-100 text-secondary-800'
}`}
>
<div className="flex items-center gap-1 mb-0.5">
<span className="text-xs">{senderIcon}</span>
<span className="text-xs font-medium opacity-70">{senderName}</span>
</div>
<div className="text-sm whitespace-pre-wrap break-words">{msg.content}</div>
{/* Render rich content blocks via BlockRenderer */}
{msg.blocks && msg.blocks.length > 0 && (
<div className="mt-2">
<BlockRenderer blocks={msg.blocks} />
</div>
)}
{msg.created_at && (
<span className="text-[10px] opacity-50 mt-0.5 block">
{new Date(msg.created_at).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
</span>
)}
</div>
</div>
);
})}
</div>
);
}
// ─── Message Input ───
function MessageInput({
onSend,
disabled,
}: {
onSend: (text: string) => void;
disabled: boolean;
}) {
const [text, setText] = useState('');
const handleSend = () => {
if (!text.trim() || disabled) return;
onSend(text.trim());
setText('');
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
return (
<div className="flex items-center gap-2 p-3 border-t border-secondary-200">
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
disabled={disabled}
placeholder={disabled ? 'Schreibgeschützt' : 'Nachricht eingeben...'}
className="flex-1 px-3 py-2 text-sm rounded-lg border border-secondary-200 bg-white text-secondary-800 placeholder-secondary-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-secondary-50 disabled:text-secondary-400"
data-testid="message-input"
/>
<button
onClick={handleSend}
disabled={disabled || !text.trim()}
className="p-2 rounded-lg bg-primary-500 text-white hover:bg-primary-600 disabled:bg-secondary-200 disabled:text-secondary-400 transition-colors min-h-touch min-w-touch flex items-center justify-center"
aria-label="Senden"
data-testid="message-send-btn"
>
{sendIcon}
</button>
</div>
);
}
// ─── Main MessageSidebar Component ───
export function MessageSidebar() {
const { t } = useTranslation();
const {
messageSidebarCollapsed,
toggleMessageSidebar,
} = useUIStore();
const {
conversations,
activeConversationId,
messages,
loading,
setConversations,
setActiveConversation,
setMessages,
setLoading,
setUnread,
} = useCommStore();
const [currentView, setCurrentView] = useState<SidebarView>('conversations');
const [messagesLoading, setMessagesLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Connect WebSocket
useCommWebSocket();
// Load conversations on mount
useEffect(() => {
loadConversations();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const loadConversations = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await listConversations();
const convs = res.items || [];
setConversations(convs);
// Auto-select first pinned conversation or first conversation
if (convs.length > 0 && !activeConversationId) {
const pinned = convs.find((c) => c.is_pinned);
const first = pinned || convs[0];
setActiveConversation(first.id);
loadMessages(first.id);
}
} catch (e) {
console.error('Failed to load conversations:', e);
setError('Konversationen konnten nicht geladen werden');
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const loadMessages = useCallback(async (convId: string) => {
setMessagesLoading(true);
try {
const res = await getMessages(convId);
setMessages(convId, res.items || []);
// Mark as read
await markRead(convId);
setUnread(convId, 0);
} catch (e) {
console.error('Failed to load messages:', e);
setMessages(convId, []);
} finally {
setMessagesLoading(false);
}
}, []);
const handleSelectConversation = useCallback(
(convId: string) => {
setActiveConversation(convId);
loadMessages(convId);
},
[loadMessages],
);
const handleSendMessage = useCallback(
async (text: string) => {
if (!activeConversationId) return;
try {
await sendMessage(activeConversationId, {
content: text,
content_format: 'text',
});
// The message will arrive via WebSocket, but also reload to be safe
loadMessages(activeConversationId);
} catch (e) {
console.error('Failed to send message:', e);
setError('Nachricht konnte nicht gesendet werden');
}
},
[activeConversationId, loadMessages],
);
const handleStartDirectChat = useCallback(
async (userId: string, userName: string) => {
try {
const conv = await createConversation({
title: userName,
participant_ids: [userId],
is_direct: true,
});
// Reload conversations to include the new one
await loadConversations();
if (conv && conv.id) {
handleSelectConversation(conv.id);
}
} catch (e) {
console.error('Failed to start direct chat:', e);
setError('Direkt-Chat konnte nicht gestartet werden');
}
},
[loadConversations, handleSelectConversation],
);
// Quick access buttons (same icons as AISidebar tabs)
const quickAccess: QuickAccessDef[] = [
{ key: 'assistant', label: 'Assistent', icon: robotIcon, testId: 'msg-sidebar-qa-assistant', view: 'conversations', filterPinned: 'assistant' },
{ key: 'liveai', label: 'Live KI', icon: bulbIcon, testId: 'msg-sidebar-qa-liveai', view: 'conversations', filterPinned: 'liveai' },
{ key: 'system', label: t('topbar.notifications'), icon: bellIcon, testId: 'msg-sidebar-qa-system', view: 'conversations', filterPinned: 'system' },
{ key: 'team', label: 'Team', icon: teamIcon, testId: 'msg-sidebar-qa-team', view: 'team' },
{ key: 'all', label: 'Chat', icon: chatBubbleIcon, testId: 'msg-sidebar-qa-all', view: 'conversations' },
];
// Pinned conversations (system rooms)
const pinnedConversations = conversations.filter((c) => c.is_pinned);
const normalConversations = conversations.filter((c) => !c.is_pinned);
// Active conversation object
const activeConv = conversations.find((c) => c.id === activeConversationId) || null;
const activeMessages = activeConversationId ? messages[activeConversationId] || [] : [];
const isSystemLocked =
activeConv?.is_locked && activeConv?.locked_by === 'system_notif';
// Determine which pinned conv to select when a quick-access button is clicked
const handleQuickAccess = (qa: QuickAccessDef) => {
setCurrentView(qa.view);
if (qa.view === 'conversations' && qa.filterPinned) {
// Find pinned conversation matching the filter
const filter = qa.filterPinned;
const match = pinnedConversations.find((c) =>
c.metadata?.room_type === filter ||
(filter && c.title?.toLowerCase().includes(filter)),
);
if (match) {
handleSelectConversation(match.id);
return;
}
}
if (qa.view === 'conversations' && !qa.filterPinned) {
// 'all' — just show the list, select first if none active
if (!activeConversationId && conversations.length > 0) {
handleSelectConversation(conversations[0].id);
}
}
};
// ─── Collapsed: narrow icon strip (desktop only) ───
if (messageSidebarCollapsed) {
return (
<div
className="hidden md:flex flex-shrink-0 w-12 flex-col items-center border-l border-secondary-200 bg-white py-3 gap-2"
data-testid="message-sidebar-collapsed"
>
{quickAccess.map((qa) => (
<button
key={qa.key}
onClick={() => {
handleQuickAccess(qa);
toggleMessageSidebar();
}}
className="p-2 rounded-md hover:bg-secondary-100 min-h-touch min-w-touch flex items-center justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 relative"
aria-label={qa.label}
title={qa.label}
data-testid={qa.testId}
>
{qa.icon('w-5 h-5 text-secondary-600')}
{qa.key === 'system' &&
pinnedConversations.some((c) => c.unread_count > 0) && (
<span className="absolute -top-0.5 -right-0.5 w-4 h-4 bg-danger-500 text-white text-[10px] font-bold rounded-full flex items-center justify-center">
{pinnedConversations.reduce((sum, c) => sum + c.unread_count, 0)}
</span>
)}
</button>
))}
</div>
);
}
// ─── Icon Strip (expanded) ───
const iconStrip = (
<div className="flex items-center gap-1 px-2 h-[58px] border-b border-secondary-200" role="tablist">
{quickAccess.map((qa) => {
const isActive =
(qa.view === 'conversations' && currentView === 'conversations') ||
(qa.view === 'team' && currentView === 'team');
return (
<button
key={qa.key}
onClick={() => handleQuickAccess(qa)}
className={`p-2 rounded-md min-h-touch min-w-touch flex items-center justify-center transition-colors relative ${
isActive
? 'bg-primary-100 text-primary-600'
: 'text-secondary-500 hover:bg-secondary-100 hover:text-secondary-700'
}`}
role="tab"
aria-selected={isActive}
aria-label={qa.label}
data-testid={qa.testId}
>
{qa.icon('w-5 h-5')}
{qa.key === 'system' &&
pinnedConversations.some((c) => c.unread_count > 0) && (
<span className="absolute -top-0.5 -right-0.5 w-4 h-4 bg-danger-500 text-white text-[10px] font-bold rounded-full flex items-center justify-center">
{pinnedConversations.reduce((sum, c) => sum + c.unread_count, 0)}
</span>
)}
</button>
);
})}
<div className="flex-1" />
<button
onClick={toggleMessageSidebar}
className="p-1.5 rounded-md text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100 min-h-touch min-w-touch flex items-center justify-center"
title="Einklappen"
aria-label="Messaging einklappen"
>
{chevronRightIcon}
</button>
</div>
);
// ─── Conversation List Panel ───
const renderConversationList = () => (
<div className="flex flex-col h-full overflow-y-auto p-2 gap-1" data-testid="conversation-list">
{loading && (
<p className="text-sm text-secondary-400 text-center py-4">Laden...</p>
)}
{error && (
<p className="text-sm text-red-500 text-center py-2">{error}</p>
)}
{!loading && conversations.length === 0 && !error && (
<p className="text-sm text-secondary-400 text-center py-4">Keine Konversationen</p>
)}
{/* Pinned conversations */}
{pinnedConversations.length > 0 && (
<>
<h3 className="text-xs font-semibold text-secondary-500 uppercase tracking-wide px-1 py-1 flex items-center gap-1">
{pinIcon} Angespinnt
</h3>
{pinnedConversations.map((conv) => (
<ConversationListItem
key={conv.id}
conv={conv}
isActive={conv.id === activeConversationId}
onClick={() => handleSelectConversation(conv.id)}
/>
))}
</>
)}
{/* Normal conversations */}
{normalConversations.length > 0 && (
<>
<h3 className="text-xs font-semibold text-secondary-500 uppercase tracking-wide px-1 py-1">
Konversationen
</h3>
{normalConversations.map((conv) => (
<ConversationListItem
key={conv.id}
conv={conv}
isActive={conv.id === activeConversationId}
onClick={() => handleSelectConversation(conv.id)}
/>
))}
</>
)}
</div>
);
// ─── Main Content Area ───
const renderContent = () => {
if (currentView === 'team') {
return <TeamPanel onStartDirectChat={handleStartDirectChat} />;
}
// conversations view: show conversation list + message feed + input
return (
<div className="flex flex-col h-full">
{/* Conversation list (narrow, top section within panel) */}
<div className="border-b border-secondary-200 max-h-[40%] overflow-y-auto">
{renderConversationList()}
</div>
{/* Message feed */}
<div className="flex-1 overflow-hidden flex flex-col">
{activeConv && (
<div className="px-3 py-2 border-b border-secondary-200 flex items-center gap-2">
{activeConv.is_pinned && <span className="text-secondary-400">📌</span>}
<span className="text-sm font-medium text-secondary-700 truncate">
{activeConv.title || 'Ohne Titel'}
</span>
</div>
)}
<MessageFeed messages={activeMessages} loading={messagesLoading} />
{/* Message input (hidden for system-locked conversations) */}
{activeConv && !isSystemLocked && (
<MessageInput onSend={handleSendMessage} disabled={false} />
)}
{isSystemLocked && (
<div className="p-3 border-t border-secondary-200 text-center">
<span className="text-xs text-secondary-400">System-Konversation (schreibgeschützt)</span>
</div>
)}
</div>
</div>
);
};
// ─── Expanded: Mobile = full width overlay, Desktop = resizable panel ───
return (
<>
{/* Mobile: full width overlay with back button */}
<div
className="md:hidden fixed inset-0 z-50 bg-white flex flex-col"
data-testid="message-sidebar-mobile"
>
<div className="h-14 flex items-center gap-2 px-3 border-b border-secondary-200">
<button
onClick={toggleMessageSidebar}
className="inline-flex items-center gap-1 px-2 py-1 rounded text-sm text-secondary-700 hover:bg-secondary-100 min-h-touch"
aria-label="Zurück"
data-testid="message-sidebar-back"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
<span className="text-sm font-medium">Zurück</span>
</button>
</div>
{iconStrip}
<div className="flex-1 overflow-hidden">{renderContent()}</div>
</div>
{/* Desktop: resizable panel */}
<ResizablePanel
initialWidth={320}
minWidth={240}
maxWidth={600}
handleSide="left"
className="hidden md:flex border-l border-secondary-200 bg-white"
data-testid="message-sidebar-pane"
>
<div className="h-full flex flex-col">
{iconStrip}
<div className="flex-1 overflow-hidden">{renderContent()}</div>
</div>
</ResizablePanel>
</>
);
}
+1 -1
View File
@@ -14,7 +14,7 @@ export function TopBar() {
const navigate = useNavigate();
const { user, currentTenant } = useAuthStore();
const tenants = user?.tenants || [];
const { toggleSidebar, openAISidebarProactive } = useUIStore();
const { toggleSidebar, toggleMessageSidebar } = useUIStore();
const logoutMutation = useLogout();
const [userMenuOpen, setUserMenuOpen] = useState(false);
+140
View File
@@ -0,0 +1,140 @@
import { useEffect, useRef } from 'react';
import { useCommStore } from '@/store/commStore';
import type { Conversation, Message } from '@/store/commStore';
/**
* useCommWebSocket — manages a WebSocket connection to /api/v1/comm/ws.
*
* Handles real-time events: new messages, conversation updates, typing
* indicators, and streaming tokens. Auto-reconnects with exponential backoff.
*/
export function useCommWebSocket() {
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeout = useRef<number | undefined>(undefined);
const addMessage = useCommStore((s) => s.addMessage);
const updateConversation = useCommStore((s) => s.updateConversation);
const setTyping = useCommStore((s) => s.setTyping);
const activeConversationId = useCommStore((s) => s.activeConversationId);
const setMessages = useCommStore((s) => s.setMessages);
useEffect(() => {
let reconnectAttempts = 0;
const maxReconnectDelay = 30000;
function connect() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${protocol}//${window.location.host}/api/v1/comm/ws`);
wsRef.current = ws;
ws.onopen = () => {
reconnectAttempts = 0;
console.log('Comm WebSocket connected');
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
switch (data.type) {
case 'message.new': {
if (data.message && data.conversation_id) {
addMessage(data.conversation_id, data.message as Message);
}
break;
}
case 'conversation.updated': {
if (data.conversation) {
updateConversation(data.conversation as Conversation);
}
break;
}
case 'message.streaming': {
// Update last message content with streaming token
if (data.conversation_id && data.token) {
const msgs = useCommStore.getState().messages[data.conversation_id] || [];
if (msgs.length > 0) {
const lastMsg = msgs[msgs.length - 1];
const updatedMsg = {
...lastMsg,
content: lastMsg.content + data.token,
};
setMessages(data.conversation_id, [...msgs.slice(0, -1), updatedMsg]);
}
}
break;
}
case 'message.streaming.done': {
// Replace streaming content with final message
if (data.conversation_id && data.message) {
const msgs = useCommStore.getState().messages[data.conversation_id] || [];
if (msgs.length > 0) {
setMessages(data.conversation_id, [...msgs.slice(0, -1), data.message as Message]);
}
}
break;
}
case 'typing': {
if (data.conversation_id) {
const currentTyping = useCommStore.getState().typingUsers[data.conversation_id] || [];
if (data.is_typing) {
if (!currentTyping.includes(data.user_id)) {
setTyping(data.conversation_id, [...currentTyping, data.user_id]);
}
} else {
setTyping(
data.conversation_id,
currentTyping.filter((uid) => uid !== data.user_id),
);
}
}
break;
}
case 'pong':
break;
default:
break;
}
} catch (err) {
console.error('Comm WebSocket message parse error:', err);
}
};
ws.onclose = () => {
console.log('Comm WebSocket disconnected');
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), maxReconnectDelay);
reconnectAttempts++;
reconnectTimeout.current = window.setTimeout(connect, delay);
};
ws.onerror = (error) => {
console.error('Comm WebSocket error:', error);
};
}
connect();
// Ping interval to keep connection alive
const pingInterval = setInterval(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'ping' }));
}
}, 30000);
return () => {
clearInterval(pingInterval);
if (reconnectTimeout.current) clearTimeout(reconnectTimeout.current);
wsRef.current?.close();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Subscribe to active conversation when it changes
useEffect(() => {
if (wsRef.current?.readyState === WebSocket.OPEN && activeConversationId) {
wsRef.current.send(
JSON.stringify({ type: 'subscribe', conversation_id: activeConversationId }),
);
}
}, [activeConversationId]);
return wsRef;
}
+107
View File
@@ -0,0 +1,107 @@
import { create } from 'zustand';
export interface Participant {
id: string;
conversation_id: string;
participant_id: string | null;
participant_type: string;
display_name: string | null;
role: string;
}
export interface Conversation {
id: string;
title: string | null;
is_locked: boolean;
locked_by: string | null;
is_direct: boolean;
is_archived: boolean;
is_pinned: boolean;
created_by: string | null;
created_by_type: string;
last_msg_at: string | null;
last_msg_preview: string | null;
last_msg_sender_type: string | null;
participants: Participant[];
unread_count: number;
metadata: Record<string, any>;
}
export interface MessageBlock {
id: string;
block_type: string;
block_data: Record<string, any>;
sort_order: number;
}
export interface MessageAttachment {
id: string;
file_id: string | null;
file_source: string;
file_name: string;
file_type: string;
file_size: number | null;
thumbnail_path: string | null;
}
export interface Message {
id: string;
conversation_id: string;
sender_id: string | null;
sender_type: string;
content: string;
content_format: string;
metadata: Record<string, any>;
reply_to_id: string | null;
is_pinned: boolean;
created_at: string | null;
edited_at: string | null;
blocks: MessageBlock[];
attachments: MessageAttachment[];
reactions: any[];
}
export interface CommState {
conversations: Conversation[];
activeConversationId: string | null;
messages: Record<string, Message[]>;
typingUsers: Record<string, string[]>;
unreadCounts: Record<string, number>;
loading: boolean;
setConversations: (convs: Conversation[]) => void;
setActiveConversation: (id: string | null) => void;
addMessage: (convId: string, msg: Message) => void;
setMessages: (convId: string, msgs: Message[]) => void;
updateConversation: (conv: Conversation) => void;
setTyping: (convId: string, userIds: string[]) => void;
setUnread: (convId: string, count: number) => void;
setLoading: (loading: boolean) => void;
}
export const useCommStore = create<CommState>((set) => ({
conversations: [],
activeConversationId: null,
messages: {},
typingUsers: {},
unreadCounts: {},
loading: false,
setConversations: (conversations) => set({ conversations }),
setActiveConversation: (activeConversationId) => set({ activeConversationId }),
addMessage: (convId, msg) => set((s) => ({
messages: { ...s.messages, [convId]: [...(s.messages[convId] || []), msg] },
})),
setMessages: (convId, msgs) => set((s) => ({
messages: { ...s.messages, [convId]: msgs },
})),
updateConversation: (conv) => set((s) => ({
conversations: s.conversations.map((c) => (c.id === conv.id ? conv : c)),
})),
setTyping: (convId, userIds) => set((s) => ({
typingUsers: { ...s.typingUsers, [convId]: userIds },
})),
setUnread: (convId, count) => set((s) => ({
unreadCounts: { ...s.unreadCounts, [convId]: count },
})),
setLoading: (loading) => set({ loading }),
}));
+7 -5
View File
@@ -18,6 +18,7 @@ export interface UIState {
suggestionSidebarOpen: boolean;
aiSidebarCollapsed: boolean;
aiSidebarTab: AISidebarTab;
messageSidebarCollapsed: boolean;
toasts: Toast[];
notifications: string[];
setTheme: (theme: Theme) => void;
@@ -29,6 +30,8 @@ export interface UIState {
setAISidebarCollapsed: (collapsed: boolean) => void;
setAISidebarTab: (tab: AISidebarTab) => void;
openAISidebarProactive: () => void;
toggleMessageSidebar: () => void;
setMessageSidebarCollapsed: (collapsed: boolean) => void;
addToast: (toast: Omit<Toast, 'id'>) => void;
removeToast: (id: string) => void;
clearToasts: () => void;
@@ -45,12 +48,9 @@ export const useUIStore = create<UIState>((set) => ({
suggestionSidebarOpen: false,
aiSidebarCollapsed: true,
aiSidebarTab: 'chat',
messageSidebarCollapsed: true,
toasts: [],
notifications: [
'Neuer Kontakt hinzugefügt: Max Mustermann',
'Firma aktualisiert: TechCorp GmbH',
'Meeting um 14:00 Uhr mit Müller AG',
],
notifications: [],
setTheme: (theme) => {
localStorage.setItem('leocrm_theme', theme);
set({ theme });
@@ -66,6 +66,8 @@ export const useUIStore = create<UIState>((set) => ({
setAISidebarCollapsed: (collapsed) => set({ aiSidebarCollapsed: collapsed }),
setAISidebarTab: (tab) => set({ aiSidebarTab: tab }),
openAISidebarProactive: () => set({ aiSidebarCollapsed: false, aiSidebarTab: 'proactive' }),
toggleMessageSidebar: () => set((s) => ({ messageSidebarCollapsed: !s.messageSidebarCollapsed })),
setMessageSidebarCollapsed: (collapsed) => set({ messageSidebarCollapsed: collapsed }),
addToast: (toast) => {
const id = `toast-${++toastIdCounter}`;
set((s) => ({ toasts: [...s.toasts, { ...toast, id }] }));
+34 -100
View File
@@ -1,110 +1,44 @@
# Test Report — T08c: Frontend Mail UI + Global Search UI
# Test Report — Phase 4: Unified Messaging Frontend
## Date: 2026-07-01
## Task
Implement MessageSidebar replacing AISidebar with comm API backend integration.
## Test Execution
## Files Created/Modified
### New Files
1. `src/store/commStore.ts` — Zustand store for conversations, messages, typing, unread counts
2. `src/api/comm.ts` — API client covering all `/api/v1/comm/*` endpoints (conversations, messages, reactions, read state, mini-apps, block types)
3. `src/hooks/useCommWebSocket.ts` — WebSocket hook with auto-reconnect, ping, streaming, typing indicators
4. `src/components/layout/MessageSidebar.tsx` — Main sidebar component (716 lines) replicating AISidebar design exactly
### Modified Files
5. `src/store/uiStore.ts` — Added `messageSidebarCollapsed` + `toggleMessageSidebar`; replaced mock notifications with empty array; kept `aiSidebarCollapsed` for backward compat
6. `src/components/layout/AppShell.tsx` — Replaced `AISidebar` import with `MessageSidebar`
7. `src/components/layout/TopBar.tsx` — Replaced `openAISidebarProactive` with `toggleMessageSidebar`
## TypeScript Compilation
Command: `npx tsc --noEmit`
### Command
```
cd /a0/usr/workdir/dev-projects/leocrm/frontend && npx vitest run src/__tests__/mail/ src/__tests__/search/ --reporter=verbose
src/pages/Dms.tsx(553,13): error TS2322: ... onRangeSelect does not exist on type FileExplorerProps
src/pages/Dms.tsx(647,15): error TS2322: ... onRangeSelect does not exist on type FileExplorerProps
```
### Results
- **Test Files**: 5 passed (5)
- **Tests**: 44 passed (44)
- **Duration**: ~6s
**Result:** All new/modified files compile cleanly. Only 2 pre-existing errors in `Dms.tsx` (unrelated to this task — `onRangeSelect` prop missing on `FileExplorerProps` type).
### Test Breakdown
## Smoke Test Description
#### MailPage.test.tsx (14 tests)
- ✓ renders mail page after loading
- ✓ renders folder tree pane
- ✓ renders mail list pane
- ✓ renders mail detail pane
- ✓ renders compose button
- ✓ renders shared mailbox selector
- ✓ renders mail search bar
- ✓ renders folders after loading (INBOX, Sent)
- ✓ renders mails after loading (Test Subject, Another Subject)
- ✓ shows compose modal when compose button is clicked
- ✓ shows compose toolbar with bold and italic buttons
- ✓ renders mail detail empty state initially
- ✓ renders mail detail when mail is clicked
- ✓ shows reply and forward buttons in mail detail
- **Build:** `npx tsc --noEmit` exits with only pre-existing Dms.tsx errors (0 new errors from this task)
- **Design fidelity:** MessageSidebar uses identical icons (robotIcon, bellIcon, bulbIcon, teamIcon, chatBubbleIcon), identical ResizablePanel (320px initial, 240-600 range, handleSide=left), identical mobile/desktop structure (collapsed icon strip → expanded resizable panel + mobile overlay)
- **API integration:** comm.ts uses same `apiGet/apiPost/apiPatch/apiDelete` from `client.ts` as `ai.ts` — consistent with existing conventions
- **WebSocket:** Auto-reconnect with exponential backoff, ping interval, handles message.new, conversation.updated, message.streaming, typing events
- **Store:** commStore provides conversations, messages per conversation, typing users, unread counts with all setter methods
- **uiStore:** messageSidebarCollapsed defaults to true (same as aiSidebarCollapsed), toggleMessageSidebar works, mock notifications replaced with empty array
#### ComposeModal.test.tsx (10 tests)
- ✓ renders compose modal when open
- ✓ renders compose toolbar with bold button
- ✓ renders recipient input field
- ✓ renders subject input field
- ✓ renders editor
- ✓ renders send button
- ✓ renders template picker toggle button
- ✓ shows template picker when template button is clicked
- ✓ pre-fills reply fields when mode is reply
- ✓ pre-fills forward fields when mode is forward
## What Was NOT Tested
#### MailSettings.test.tsx (9 tests)
- ✓ renders settings page
- renders accounts tab content
- ✓ renders add account button
- ✓ shows add account form when button is clicked
- ✓ renders signature manager in signatures tab
- ✓ renders rule editor in rules tab
- ✓ renders label manager in labels tab
- ✓ renders vacation responder in vacation tab
- ✓ renders PGP settings in PGP tab
- ✓ renders account list with test connection button
#### GlobalSearchTabs.test.tsx (8 tests)
- ✓ renders search page
- ✓ renders search input field
- ✓ renders search tabs after results load
- ✓ renders all results in the all tab
- ✓ renders company results
- ✓ renders all 5 result types in the all tab
- ✓ renders search submit button
- ✓ shows query display
#### GlobalSearch.test.tsx (2 tests — pre-existing, still passing)
- ✓ renders search page
- ✓ renders search input field
## Type Check
```
npx tsc --noEmit
```
Result: **PASS** — zero errors
## Build
```
npx vite build
```
Result: **PASS** — built in ~6s, 267 modules transformed
## Smoke Test
- Mail page renders with three-pane layout (folder tree + mail list + reading pane)
- Compose modal opens with toolbar (bold, italic, link, template insert)
- Reply pre-fills recipient and subject with Re: prefix
- Forward pre-fills subject with Fwd: prefix
- Mail settings page shows tabs: Accounts, Signatures, Rules, Labels, Vacation, PGP
- Global search results page shows tabs: All, Companies, Contacts, Mails, Files, Events
- Search dropdown in TopBar already existed (SearchDropdown component)
## Acceptance Criteria Coverage
- AC2: Mail route /mail renders folder tree + mail list + reading pane ✓
- AC3: Click folder → mail list updates ✓
- AC4: Click mail → detail with sanitized HTML body + attachments ✓
- AC5: Compose button → editor with toolbar (bold, italic, link, template insert) ✓
- AC6: Reply/forward buttons → compose pre-filled ✓
- AC7: Template picker dropdown in compose → inserts template body ✓
- AC8: Signature manager in settings → create/edit/delete signatures ✓
- AC9: Rule editor → condition builder + action selector ✓
- AC10: Label manager → create labels with colors ✓
- AC11: PGP settings → import private key, view contact public keys ✓
- AC12: Vacation responder toggle → date range + auto-reply text ✓
- AC13: Shared mailbox selector → switch between personal+shared accounts ✓
- AC14: Attachment download → file stream downloaded ✓
- AC15: Create event from mail → calendar event modal pre-filled ✓
- AC16: Global search results page → tabs for companies/contacts/mails/files/events ✓
- AC17: Global search autocomplete in TopBar → dropdown with suggestions ✓ (existing SearchDropdown component)
- Live API calls (requires running backend with kommunikation plugin)
- WebSocket connection (requires authenticated session)
- Visual rendering (requires dev server + browser)
- These require runtime verification in a subsequent phase