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:
@@ -30,6 +30,14 @@ class BasePlugin(ABC):
|
|||||||
raise ValueError(f"{self.__class__.__name__} must define a 'manifest' attribute")
|
raise ValueError(f"{self.__class__.__name__} must define a 'manifest' attribute")
|
||||||
self._event_handlers: dict[str, Any] = {}
|
self._event_handlers: dict[str, Any] = {}
|
||||||
self._routers: list[APIRouter] = []
|
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 ───
|
# ─── Lifecycle Hooks ───
|
||||||
|
|
||||||
@@ -52,6 +60,7 @@ class BasePlugin(ABC):
|
|||||||
handler = self._make_event_handler(event_name)
|
handler = self._make_event_handler(event_name)
|
||||||
self._event_handlers[event_name] = handler
|
self._event_handlers[event_name] = handler
|
||||||
event_bus.subscribe(event_name, handler)
|
event_bus.subscribe(event_name, handler)
|
||||||
|
self._container = service_container
|
||||||
|
|
||||||
async def on_deactivate(
|
async def on_deactivate(
|
||||||
self, db: AsyncSession, service_container: ServiceContainer, event_bus: EventBus
|
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,
|
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:
|
async def on_install(self, db, service_container) -> None:
|
||||||
"""Seed default provider and agent."""
|
"""Seed default provider and agent."""
|
||||||
from app.plugins.builtins.ai_assistant.services import seed_defaults
|
from app.plugins.builtins.ai_assistant.services import seed_defaults
|
||||||
|
|
||||||
await seed_defaults(db)
|
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]]:
|
def get_notification_types(self) -> list[dict[str, Any]]:
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -314,3 +314,84 @@ async def deep_analysis(
|
|||||||
entity_type,
|
entity_type,
|
||||||
entity_id,
|
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",
|
||||||
|
}
|
||||||
@@ -45,8 +45,12 @@ class AIProactivePlugin(BasePlugin):
|
|||||||
is_core=False,
|
is_core=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._proactive_handler = None
|
||||||
|
|
||||||
async def on_activate(self, db, service_container, event_bus) -> 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)
|
await super().on_activate(db, service_container, event_bus)
|
||||||
try:
|
try:
|
||||||
from app.plugins.builtins.ai_proactive.context_tools import (
|
from app.plugins.builtins.ai_proactive.context_tools import (
|
||||||
@@ -61,8 +65,36 @@ class AIProactivePlugin(BasePlugin):
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to register AI Proactive context tools")
|
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:
|
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:
|
try:
|
||||||
from app.plugins.builtins.ai_assistant.tool_registry import (
|
from app.plugins.builtins.ai_assistant.tool_registry import (
|
||||||
get_tool_registry,
|
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,
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -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",
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
```
|
||||||
@@ -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.
|
||||||
@@ -3,7 +3,7 @@ import { Outlet, useLocation } from 'react-router-dom';
|
|||||||
import { Sidebar } from './Sidebar';
|
import { Sidebar } from './Sidebar';
|
||||||
import { TopBar } from './TopBar';
|
import { TopBar } from './TopBar';
|
||||||
import { PluginToolbar } from './PluginToolbar';
|
import { PluginToolbar } from './PluginToolbar';
|
||||||
import { AISidebar } from './AISidebar';
|
import { MessageSidebar } from './MessageSidebar';
|
||||||
import { ToastContainer } from '@/components/ui/Toast';
|
import { ToastContainer } from '@/components/ui/Toast';
|
||||||
import { useAIContext } from '@/hooks/useAIContext';
|
import { useAIContext } from '@/hooks/useAIContext';
|
||||||
|
|
||||||
@@ -13,8 +13,8 @@ export function AppShell() {
|
|||||||
// Track context for AI Proactive suggestions
|
// Track context for AI Proactive suggestions
|
||||||
useAIContext();
|
useAIContext();
|
||||||
|
|
||||||
// Hide AI sidebar on the AI Assistant page itself
|
// Hide message sidebar on the AI Assistant page itself
|
||||||
const showAISidebar = !location.pathname.startsWith('/ai-assistant');
|
const showMessageSidebar = !location.pathname.startsWith('/ai-assistant');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen overflow-hidden bg-secondary-50" data-testid="app-shell">
|
<div className="flex h-screen overflow-hidden bg-secondary-50" data-testid="app-shell">
|
||||||
@@ -34,8 +34,8 @@ export function AppShell() {
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* AI Sidebar — full height, right of TopBar and Toolbar */}
|
{/* Message Sidebar — full height, right of TopBar and Toolbar */}
|
||||||
{showAISidebar && <AISidebar />}
|
{showMessageSidebar && <MessageSidebar />}
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
</div>
|
</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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ export function TopBar() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, currentTenant } = useAuthStore();
|
const { user, currentTenant } = useAuthStore();
|
||||||
const tenants = user?.tenants || [];
|
const tenants = user?.tenants || [];
|
||||||
const { toggleSidebar, openAISidebarProactive } = useUIStore();
|
const { toggleSidebar, toggleMessageSidebar } = useUIStore();
|
||||||
const logoutMutation = useLogout();
|
const logoutMutation = useLogout();
|
||||||
|
|
||||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 }),
|
||||||
|
}));
|
||||||
@@ -18,6 +18,7 @@ export interface UIState {
|
|||||||
suggestionSidebarOpen: boolean;
|
suggestionSidebarOpen: boolean;
|
||||||
aiSidebarCollapsed: boolean;
|
aiSidebarCollapsed: boolean;
|
||||||
aiSidebarTab: AISidebarTab;
|
aiSidebarTab: AISidebarTab;
|
||||||
|
messageSidebarCollapsed: boolean;
|
||||||
toasts: Toast[];
|
toasts: Toast[];
|
||||||
notifications: string[];
|
notifications: string[];
|
||||||
setTheme: (theme: Theme) => void;
|
setTheme: (theme: Theme) => void;
|
||||||
@@ -29,6 +30,8 @@ export interface UIState {
|
|||||||
setAISidebarCollapsed: (collapsed: boolean) => void;
|
setAISidebarCollapsed: (collapsed: boolean) => void;
|
||||||
setAISidebarTab: (tab: AISidebarTab) => void;
|
setAISidebarTab: (tab: AISidebarTab) => void;
|
||||||
openAISidebarProactive: () => void;
|
openAISidebarProactive: () => void;
|
||||||
|
toggleMessageSidebar: () => void;
|
||||||
|
setMessageSidebarCollapsed: (collapsed: boolean) => void;
|
||||||
addToast: (toast: Omit<Toast, 'id'>) => void;
|
addToast: (toast: Omit<Toast, 'id'>) => void;
|
||||||
removeToast: (id: string) => void;
|
removeToast: (id: string) => void;
|
||||||
clearToasts: () => void;
|
clearToasts: () => void;
|
||||||
@@ -45,12 +48,9 @@ export const useUIStore = create<UIState>((set) => ({
|
|||||||
suggestionSidebarOpen: false,
|
suggestionSidebarOpen: false,
|
||||||
aiSidebarCollapsed: true,
|
aiSidebarCollapsed: true,
|
||||||
aiSidebarTab: 'chat',
|
aiSidebarTab: 'chat',
|
||||||
|
messageSidebarCollapsed: true,
|
||||||
toasts: [],
|
toasts: [],
|
||||||
notifications: [
|
notifications: [],
|
||||||
'Neuer Kontakt hinzugefügt: Max Mustermann',
|
|
||||||
'Firma aktualisiert: TechCorp GmbH',
|
|
||||||
'Meeting um 14:00 Uhr mit Müller AG',
|
|
||||||
],
|
|
||||||
setTheme: (theme) => {
|
setTheme: (theme) => {
|
||||||
localStorage.setItem('leocrm_theme', theme);
|
localStorage.setItem('leocrm_theme', theme);
|
||||||
set({ theme });
|
set({ theme });
|
||||||
@@ -66,6 +66,8 @@ export const useUIStore = create<UIState>((set) => ({
|
|||||||
setAISidebarCollapsed: (collapsed) => set({ aiSidebarCollapsed: collapsed }),
|
setAISidebarCollapsed: (collapsed) => set({ aiSidebarCollapsed: collapsed }),
|
||||||
setAISidebarTab: (tab) => set({ aiSidebarTab: tab }),
|
setAISidebarTab: (tab) => set({ aiSidebarTab: tab }),
|
||||||
openAISidebarProactive: () => set({ aiSidebarCollapsed: false, aiSidebarTab: 'proactive' }),
|
openAISidebarProactive: () => set({ aiSidebarCollapsed: false, aiSidebarTab: 'proactive' }),
|
||||||
|
toggleMessageSidebar: () => set((s) => ({ messageSidebarCollapsed: !s.messageSidebarCollapsed })),
|
||||||
|
setMessageSidebarCollapsed: (collapsed) => set({ messageSidebarCollapsed: collapsed }),
|
||||||
addToast: (toast) => {
|
addToast: (toast) => {
|
||||||
const id = `toast-${++toastIdCounter}`;
|
const id = `toast-${++toastIdCounter}`;
|
||||||
set((s) => ({ toasts: [...s.toasts, { ...toast, id }] }));
|
set((s) => ({ toasts: [...s.toasts, { ...toast, id }] }));
|
||||||
|
|||||||
+34
-100
@@ -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
|
**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 Files**: 5 passed (5)
|
|
||||||
- **Tests**: 44 passed (44)
|
|
||||||
- **Duration**: ~6s
|
|
||||||
|
|
||||||
### Test Breakdown
|
## Smoke Test Description
|
||||||
|
|
||||||
#### MailPage.test.tsx (14 tests)
|
- **Build:** `npx tsc --noEmit` exits with only pre-existing Dms.tsx errors (0 new errors from this task)
|
||||||
- ✓ renders mail page after loading
|
- **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)
|
||||||
- ✓ renders folder tree pane
|
- **API integration:** comm.ts uses same `apiGet/apiPost/apiPatch/apiDelete` from `client.ts` as `ai.ts` — consistent with existing conventions
|
||||||
- ✓ renders mail list pane
|
- **WebSocket:** Auto-reconnect with exponential backoff, ping interval, handles message.new, conversation.updated, message.streaming, typing events
|
||||||
- ✓ renders mail detail pane
|
- **Store:** commStore provides conversations, messages per conversation, typing users, unread counts with all setter methods
|
||||||
- ✓ renders compose button
|
- **uiStore:** messageSidebarCollapsed defaults to true (same as aiSidebarCollapsed), toggleMessageSidebar works, mock notifications replaced with empty array
|
||||||
- ✓ 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
|
|
||||||
|
|
||||||
#### ComposeModal.test.tsx (10 tests)
|
## What Was NOT Tested
|
||||||
- ✓ 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
|
|
||||||
|
|
||||||
#### MailSettings.test.tsx (9 tests)
|
- Live API calls (requires running backend with kommunikation plugin)
|
||||||
- ✓ renders settings page
|
- WebSocket connection (requires authenticated session)
|
||||||
- ✓ renders accounts tab content
|
- Visual rendering (requires dev server + browser)
|
||||||
- ✓ renders add account button
|
- These require runtime verification in a subsequent phase
|
||||||
- ✓ 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)
|
|
||||||
|
|||||||
Reference in New Issue
Block a user