From cc3ac9a43d76b8e9083559be7af6d068a853df2d Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Wed, 22 Jul 2026 01:22:15 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Unified=20Messaging=20System=20?= =?UTF-8?q?=E2=80=94=20kommunikation=20plugin,=20AI/Proactive/System=20par?= =?UTF-8?q?ticipants,=20MessageSidebar,=20Rich=20Content=20Renderer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/plugins/base.py | 9 + .../ai_assistant/participant_handler.py | 287 +++ app/plugins/builtins/ai_assistant/plugin.py | 66 + app/plugins/builtins/ai_proactive/jobs.py | 81 + .../ai_proactive/participant_handler.py | 232 +++ app/plugins/builtins/ai_proactive/plugin.py | 36 +- .../builtins/kommunikation/__init__.py | 0 .../builtins/kommunikation/content_types.py | 82 + .../builtins/kommunikation/dms_bridge.py | 187 ++ .../kommunikation/migrations/0001_initial.sql | 160 ++ .../kommunikation/miniapp_registry.py | 73 + app/plugins/builtins/kommunikation/models.py | 283 +++ .../kommunikation/participant_registry.py | 89 + app/plugins/builtins/kommunikation/plugin.py | 91 + app/plugins/builtins/kommunikation/rbac.py | 136 ++ app/plugins/builtins/kommunikation/routes.py | 532 ++++++ app/plugins/builtins/kommunikation/schemas.py | 177 ++ .../builtins/kommunikation/search_provider.py | 112 ++ .../builtins/kommunikation/services.py | 1122 +++++++++++ .../kommunikation/websocket_manager.py | 89 + app/plugins/builtins/system_notif/__init__.py | 0 .../system_notif/participant_handler.py | 46 + app/plugins/builtins/system_notif/plugin.py | 223 +++ docs/bauplan-unified-messaging.md | 1633 +++++++++++++++++ docs/konzept-unified-messaging.md | 624 +++++++ frontend/src/api/comm.ts | 199 ++ .../comm/blocks/ActionCardBlock.tsx | 62 + .../src/components/comm/blocks/AudioBlock.tsx | 41 + .../components/comm/blocks/BlockRenderer.tsx | 75 + .../comm/blocks/ContactCardBlock.tsx | 52 + .../src/components/comm/blocks/FileBlock.tsx | 85 + .../src/components/comm/blocks/HtmlBlock.tsx | 45 + .../src/components/comm/blocks/ImageBlock.tsx | 35 + .../components/comm/blocks/MarkdownBlock.tsx | 59 + .../components/comm/blocks/MiniAppBlock.tsx | 50 + .../src/components/comm/blocks/VideoBlock.tsx | 30 + frontend/src/components/comm/blocks/index.ts | 10 + .../src/components/comm/blocks/test_report.md | 66 + frontend/src/components/layout/AppShell.tsx | 10 +- .../src/components/layout/MessageSidebar.tsx | 683 +++++++ frontend/src/components/layout/TopBar.tsx | 2 +- frontend/src/hooks/useCommWebSocket.ts | 140 ++ frontend/src/store/commStore.ts | 107 ++ frontend/src/store/uiStore.ts | 12 +- frontend/test_report.md | 134 +- 45 files changed, 8154 insertions(+), 113 deletions(-) create mode 100644 app/plugins/builtins/ai_assistant/participant_handler.py create mode 100644 app/plugins/builtins/ai_proactive/participant_handler.py create mode 100644 app/plugins/builtins/kommunikation/__init__.py create mode 100644 app/plugins/builtins/kommunikation/content_types.py create mode 100644 app/plugins/builtins/kommunikation/dms_bridge.py create mode 100644 app/plugins/builtins/kommunikation/migrations/0001_initial.sql create mode 100644 app/plugins/builtins/kommunikation/miniapp_registry.py create mode 100644 app/plugins/builtins/kommunikation/models.py create mode 100644 app/plugins/builtins/kommunikation/participant_registry.py create mode 100644 app/plugins/builtins/kommunikation/plugin.py create mode 100644 app/plugins/builtins/kommunikation/rbac.py create mode 100644 app/plugins/builtins/kommunikation/routes.py create mode 100644 app/plugins/builtins/kommunikation/schemas.py create mode 100644 app/plugins/builtins/kommunikation/search_provider.py create mode 100644 app/plugins/builtins/kommunikation/services.py create mode 100644 app/plugins/builtins/kommunikation/websocket_manager.py create mode 100644 app/plugins/builtins/system_notif/__init__.py create mode 100644 app/plugins/builtins/system_notif/participant_handler.py create mode 100644 app/plugins/builtins/system_notif/plugin.py create mode 100644 docs/bauplan-unified-messaging.md create mode 100644 docs/konzept-unified-messaging.md create mode 100644 frontend/src/api/comm.ts create mode 100644 frontend/src/components/comm/blocks/ActionCardBlock.tsx create mode 100644 frontend/src/components/comm/blocks/AudioBlock.tsx create mode 100644 frontend/src/components/comm/blocks/BlockRenderer.tsx create mode 100644 frontend/src/components/comm/blocks/ContactCardBlock.tsx create mode 100644 frontend/src/components/comm/blocks/FileBlock.tsx create mode 100644 frontend/src/components/comm/blocks/HtmlBlock.tsx create mode 100644 frontend/src/components/comm/blocks/ImageBlock.tsx create mode 100644 frontend/src/components/comm/blocks/MarkdownBlock.tsx create mode 100644 frontend/src/components/comm/blocks/MiniAppBlock.tsx create mode 100644 frontend/src/components/comm/blocks/VideoBlock.tsx create mode 100644 frontend/src/components/comm/blocks/index.ts create mode 100644 frontend/src/components/comm/blocks/test_report.md create mode 100644 frontend/src/components/layout/MessageSidebar.tsx create mode 100644 frontend/src/hooks/useCommWebSocket.ts create mode 100644 frontend/src/store/commStore.ts diff --git a/app/plugins/base.py b/app/plugins/base.py index d34660c..bfc4c18 100644 --- a/app/plugins/base.py +++ b/app/plugins/base.py @@ -30,6 +30,14 @@ class BasePlugin(ABC): raise ValueError(f"{self.__class__.__name__} must define a 'manifest' attribute") self._event_handlers: dict[str, Any] = {} self._routers: list[APIRouter] = [] + self._container: Any = None # ServiceContainer, set during on_activate + + @property + def services(self) -> Any: + """Access the ServiceContainer after activation.""" + if self._container is None: + raise RuntimeError("Services not available — plugin not activated") + return self._container # ─── Lifecycle Hooks ─── @@ -52,6 +60,7 @@ class BasePlugin(ABC): handler = self._make_event_handler(event_name) self._event_handlers[event_name] = handler event_bus.subscribe(event_name, handler) + self._container = service_container async def on_deactivate( self, db: AsyncSession, service_container: ServiceContainer, event_bus: EventBus diff --git a/app/plugins/builtins/ai_assistant/participant_handler.py b/app/plugins/builtins/ai_assistant/participant_handler.py new file mode 100644 index 0000000..e45ae8f --- /dev/null +++ b/app/plugins/builtins/ai_assistant/participant_handler.py @@ -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", + } diff --git a/app/plugins/builtins/ai_assistant/plugin.py b/app/plugins/builtins/ai_assistant/plugin.py index e4c234c..644c5b6 100644 --- a/app/plugins/builtins/ai_assistant/plugin.py +++ b/app/plugins/builtins/ai_assistant/plugin.py @@ -42,12 +42,78 @@ class AIAssistantPlugin(BasePlugin): is_core=True, ) + def __init__(self) -> None: + super().__init__() + self._ai_handler = None + self._msg_handler = None + async def on_install(self, db, service_container) -> None: """Seed default provider and agent.""" from app.plugins.builtins.ai_assistant.services import seed_defaults await seed_defaults(db) + async def on_activate(self, db, service_container, event_bus) -> None: + """Activate plugin: register context tools and participant handler.""" + await super().on_activate(db, service_container, event_bus) + + # Register as participant in the kommunikation system + try: + from app.plugins.builtins.ai_assistant.participant_handler import ( + AIParticipantHandler, + ) + from app.plugins.builtins.kommunikation.participant_registry import ( + get_participant_registry, + ) + + self._ai_handler = AIParticipantHandler(service_container) + get_participant_registry().register("ai", self._ai_handler) + logger.info("AI Assistant registered as participant 'ai'") + except Exception: + logger.exception("Failed to register AI Assistant as participant") + + # Subscribe to message.received events + try: + self._msg_handler = self._on_message_received + event_bus.subscribe("message.received", self._msg_handler) + logger.info("AI Assistant subscribed to message.received events") + except Exception: + logger.exception("Failed to subscribe to message.received events") + + async def _on_message_received(self, payload: dict[str, Any]) -> None: + """Handle message.received event by delegating to the AI participant handler.""" + if self._ai_handler is None: + return + try: + await self._ai_handler.handle_event(payload) + except Exception: + logger.exception("Error in AI participant handler for message.received") + + async def on_deactivate(self, db, service_container, event_bus) -> None: + """Deactivate plugin: unregister participant and event subscriptions.""" + # Unregister from participant registry + try: + from app.plugins.builtins.kommunikation.participant_registry import ( + get_participant_registry, + ) + + get_participant_registry().unregister("ai") + logger.info("AI Assistant unregistered as participant 'ai'") + except Exception: + logger.exception("Failed to unregister AI Assistant as participant") + + # Unsubscribe from message.received events + if self._msg_handler: + try: + event_bus.unsubscribe("message.received", self._msg_handler) + except Exception: + logger.exception("Failed to unsubscribe from message.received events") + self._msg_handler = None + + self._ai_handler = None + + await super().on_deactivate(db, service_container, event_bus) + def get_notification_types(self) -> list[dict[str, Any]]: return [ { diff --git a/app/plugins/builtins/ai_proactive/jobs.py b/app/plugins/builtins/ai_proactive/jobs.py index 7203d5e..73e3423 100644 --- a/app/plugins/builtins/ai_proactive/jobs.py +++ b/app/plugins/builtins/ai_proactive/jobs.py @@ -314,3 +314,84 @@ async def deep_analysis( entity_type, entity_id, ) + + +# ─── Heartbeat Job ─── + + +async def heartbeat(ctx: dict[str, Any], user_id: str, tenant_id: str) -> None: + """Heartbeat job for the AI Proactive plugin. + + Runs every 5 minutes (scheduled by the plugin on activation). + Posts a status message to the 'Live KI' room in the kommunikation system. + """ + try: + uid = uuid.UUID(user_id) + tid = uuid.UUID(tenant_id) + except (ValueError, TypeError): + logger.warning("heartbeat: invalid UUID parameters (user_id=%s, tenant_id=%s)", user_id, tenant_id) + return + + try: + from app.core.db import create_db_session + from app.plugins.builtins.kommunikation.services import ( + create_plugin_room, + send_message, + ) + + async with create_db_session(tid) as db: + # Create or get the 'Live KI' room for this user + room = await create_plugin_room( + db, + tid, + uid, + plugin_name="ai_proactive", + title="Live KI", + participant_type="ai_proactive", + user_role="member", + ) + + conversation_id_str = room.get("id") + if not conversation_id_str: + logger.warning("heartbeat: could not create/get Live KI room") + return + + conversation_id = uuid.UUID(conversation_id_str) + + # Gather some stats for the status message + from sqlalchemy import func, select + + from app.models.contact import Contact + + contact_count_result = await db.execute( + select(func.count()).select_from(Contact).where( + Contact.tenant_id == tid, + Contact.deleted_at.is_(None), + ) + ) + contact_count = contact_count_result.scalar() or 0 + + # Build status message + from datetime import datetime, timezone + + now_str = datetime.now(timezone.utc).strftime("%H:%M:%S") + status_content = ( + f"**System aktiv** — überwacht {contact_count} Kontakte\n" + f"_Letztes Update: {now_str}_" + ) + + await send_message( + db=db, + tenant_id=tid, + conversation_id=conversation_id, + sender_id=None, + sender_type="ai_proactive", + content=status_content, + content_format="markdown", + ) + + await db.commit() + logger.info("heartbeat: posted status to Live KI room for user %s", user_id) + + except Exception: + logger.exception("heartbeat: error posting status message") diff --git a/app/plugins/builtins/ai_proactive/participant_handler.py b/app/plugins/builtins/ai_proactive/participant_handler.py new file mode 100644 index 0000000..d01cad9 --- /dev/null +++ b/app/plugins/builtins/ai_proactive/participant_handler.py @@ -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", + } diff --git a/app/plugins/builtins/ai_proactive/plugin.py b/app/plugins/builtins/ai_proactive/plugin.py index 6ffae6a..ef5a07d 100644 --- a/app/plugins/builtins/ai_proactive/plugin.py +++ b/app/plugins/builtins/ai_proactive/plugin.py @@ -45,8 +45,12 @@ class AIProactivePlugin(BasePlugin): is_core=False, ) + def __init__(self) -> None: + super().__init__() + self._proactive_handler = None + async def on_activate(self, db, service_container, event_bus) -> None: - """Register context tools and subscribe to events.""" + """Register context tools, subscribe to events, and register as participant.""" await super().on_activate(db, service_container, event_bus) try: from app.plugins.builtins.ai_proactive.context_tools import ( @@ -61,8 +65,36 @@ class AIProactivePlugin(BasePlugin): except Exception: logger.exception("Failed to register AI Proactive context tools") + # Register as participant in the kommunikation system + try: + from app.plugins.builtins.ai_proactive.participant_handler import ( + AIProactiveParticipantHandler, + ) + from app.plugins.builtins.kommunikation.participant_registry import ( + get_participant_registry, + ) + + self._proactive_handler = AIProactiveParticipantHandler(service_container) + get_participant_registry().register("ai_proactive", self._proactive_handler) + logger.info("AI Proactive registered as participant 'ai_proactive'") + except Exception: + logger.exception("Failed to register AI Proactive as participant") + async def on_deactivate(self, db, service_container, event_bus) -> None: - """Unregister tools and event listeners.""" + """Unregister tools, event listeners, and participant.""" + # Unregister from participant registry + try: + from app.plugins.builtins.kommunikation.participant_registry import ( + get_participant_registry, + ) + + get_participant_registry().unregister("ai_proactive") + logger.info("AI Proactive unregistered as participant 'ai_proactive'") + except Exception: + logger.exception("Failed to unregister AI Proactive as participant") + + self._proactive_handler = None + try: from app.plugins.builtins.ai_assistant.tool_registry import ( get_tool_registry, diff --git a/app/plugins/builtins/kommunikation/__init__.py b/app/plugins/builtins/kommunikation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/plugins/builtins/kommunikation/content_types.py b/app/plugins/builtins/kommunikation/content_types.py new file mode 100644 index 0000000..a1930d8 --- /dev/null +++ b/app/plugins/builtins/kommunikation/content_types.py @@ -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() + ] diff --git a/app/plugins/builtins/kommunikation/dms_bridge.py b/app/plugins/builtins/kommunikation/dms_bridge.py new file mode 100644 index 0000000..5bbf116 --- /dev/null +++ b/app/plugins/builtins/kommunikation/dms_bridge.py @@ -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() diff --git a/app/plugins/builtins/kommunikation/migrations/0001_initial.sql b/app/plugins/builtins/kommunikation/migrations/0001_initial.sql new file mode 100644 index 0000000..5c14b93 --- /dev/null +++ b/app/plugins/builtins/kommunikation/migrations/0001_initial.sql @@ -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); diff --git a/app/plugins/builtins/kommunikation/miniapp_registry.py b/app/plugins/builtins/kommunikation/miniapp_registry.py new file mode 100644 index 0000000..67d1abe --- /dev/null +++ b/app/plugins/builtins/kommunikation/miniapp_registry.py @@ -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) diff --git a/app/plugins/builtins/kommunikation/models.py b/app/plugins/builtins/kommunikation/models.py new file mode 100644 index 0000000..c357254 --- /dev/null +++ b/app/plugins/builtins/kommunikation/models.py @@ -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() + ) diff --git a/app/plugins/builtins/kommunikation/participant_registry.py b/app/plugins/builtins/kommunikation/participant_registry.py new file mode 100644 index 0000000..53e4443 --- /dev/null +++ b/app/plugins/builtins/kommunikation/participant_registry.py @@ -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 diff --git a/app/plugins/builtins/kommunikation/plugin.py b/app/plugins/builtins/kommunikation/plugin.py new file mode 100644 index 0000000..f48d920 --- /dev/null +++ b/app/plugins/builtins/kommunikation/plugin.py @@ -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, + }, + ] diff --git a/app/plugins/builtins/kommunikation/rbac.py b/app/plugins/builtins/kommunikation/rbac.py new file mode 100644 index 0000000..0fc2555 --- /dev/null +++ b/app/plugins/builtins/kommunikation/rbac.py @@ -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" diff --git a/app/plugins/builtins/kommunikation/routes.py b/app/plugins/builtins/kommunikation/routes.py new file mode 100644 index 0000000..2a8c59a --- /dev/null +++ b/app/plugins/builtins/kommunikation/routes.py @@ -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) diff --git a/app/plugins/builtins/kommunikation/schemas.py b/app/plugins/builtins/kommunikation/schemas.py new file mode 100644 index 0000000..c7a607f --- /dev/null +++ b/app/plugins/builtins/kommunikation/schemas.py @@ -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) diff --git a/app/plugins/builtins/kommunikation/search_provider.py b/app/plugins/builtins/kommunikation/search_provider.py new file mode 100644 index 0000000..1b59563 --- /dev/null +++ b/app/plugins/builtins/kommunikation/search_provider.py @@ -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 diff --git a/app/plugins/builtins/kommunikation/services.py b/app/plugins/builtins/kommunikation/services.py new file mode 100644 index 0000000..8f64891 --- /dev/null +++ b/app/plugins/builtins/kommunikation/services.py @@ -0,0 +1,1122 @@ +"""Business logic for the kommunikation plugin.""" + +from __future__ import annotations + +import logging +import re +import uuid +from datetime import datetime, timezone +from typing import Any + +from sqlalchemy import select, update, func, and_, or_ +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.event_bus import get_event_bus +from app.plugins.builtins.kommunikation.models import ( + CommConversation, + CommConversationMute, + CommConversationPin, + CommMessage, + CommMessageAttachment, + CommMessageBlock, + CommMessageEdit, + CommMessageRead, + CommMessageReaction, + CommParticipant, +) +from app.plugins.builtins.kommunikation.participant_registry import get_participant_registry +from app.plugins.builtins.kommunikation.rbac import CommRBAC + +logger = logging.getLogger(__name__) + +MAX_TRIGGER_DEPTH = 3 + + +# ─── Mention Parsing ─── + +MENTION_PATTERN = re.compile(r"@(\w+)") + + +def parse_mentions(content: str) -> list[str]: + """Parse @mentions from message content. Returns list of mentioned types/names.""" + return MENTION_PATTERN.findall(content) + + +# ─── Conversation Helpers ─── + + +def conversation_to_response( + conv: CommConversation, + participants: list[CommParticipant], + unread_count: int = 0, + is_pinned_by_user: bool = False, +) -> dict[str, Any]: + """Convert a CommConversation to a response dict.""" + return { + "id": str(conv.id), + "title": conv.title, + "is_locked": conv.is_locked, + "locked_by": conv.locked_by, + "is_direct": conv.is_direct, + "is_archived": conv.is_archived, + "is_pinned": is_pinned_by_user, + "created_by": str(conv.created_by) if conv.created_by else None, + "created_by_type": conv.created_by_type, + "last_msg_at": conv.last_msg_at.isoformat() if conv.last_msg_at else None, + "last_msg_preview": conv.last_msg_preview, + "last_msg_sender_type": conv.last_msg_sender_type, + "participants": [participant_to_response(p) for p in participants], + "unread_count": unread_count, + "metadata": conv.metadata_ or {}, + } + + +def participant_to_response(p: CommParticipant) -> dict[str, Any]: + """Convert a CommParticipant to a response dict.""" + return { + "id": str(p.id), + "conversation_id": str(p.conversation_id), + "participant_id": str(p.participant_id) if p.participant_id else None, + "participant_type": p.participant_type, + "display_name": p.display_name, + "role": p.role, + "joined_at": p.joined_at.isoformat() if p.joined_at else None, + } + + +def message_to_response( + msg: CommMessage, + blocks: list[CommMessageBlock] | None = None, + attachments: list[CommMessageAttachment] | None = None, + reactions: list[CommMessageReaction] | None = None, +) -> dict[str, Any]: + """Convert a CommMessage to a response dict.""" + return { + "id": str(msg.id), + "conversation_id": str(msg.conversation_id), + "sender_id": str(msg.sender_id) if msg.sender_id else None, + "sender_type": msg.sender_type, + "content": msg.content, + "content_format": msg.content_format, + "metadata": msg.metadata_ or {}, + "reply_to_id": str(msg.reply_to_id) if msg.reply_to_id else None, + "is_pinned": msg.is_pinned, + "created_at": msg.created_at.isoformat() if msg.created_at else None, + "edited_at": msg.edited_at.isoformat() if msg.edited_at else None, + "blocks": [ + { + "id": str(b.id), + "block_type": b.block_type, + "block_data": b.block_data, + "sort_order": b.sort_order, + } + for b in (blocks or []) + ], + "attachments": [ + { + "id": str(a.id), + "file_id": str(a.file_id) if a.file_id else None, + "file_source": a.file_source, + "file_name": a.file_name, + "file_type": a.file_type, + "file_size": a.file_size, + "thumbnail_path": a.thumbnail_path, + } + for a in (attachments or []) + ], + "reactions": [ + { + "id": str(r.id), + "message_id": str(r.message_id), + "user_id": str(r.user_id), + "emoji": r.emoji, + } + for r in (reactions or []) + ], + } + + +# ─── Conversation CRUD ─── + + +async def list_conversations( + db: AsyncSession, + tenant_id: uuid.UUID, + user_id: uuid.UUID, + include_archived: bool = False, +) -> list[dict[str, Any]]: + """List all conversations for a user.""" + # Get conversations where user is a participant + result = await db.execute( + select(CommConversation) + .join(CommParticipant, CommParticipant.conversation_id == CommConversation.id) + .where( + CommParticipant.participant_id == user_id, + CommParticipant.participant_type == "user", + CommParticipant.left_at.is_(None), + CommConversation.tenant_id == tenant_id, + CommConversation.deleted_at.is_(None), + ) + .order_by(CommConversation.last_msg_at.desc().nullslast()) + ) + conversations = result.scalars().all() + + # Get user's pinned conversations + pins_result = await db.execute( + select(CommConversationPin).where( + CommConversationPin.user_id == user_id, + CommConversationPin.tenant_id == tenant_id, + ) + ) + pinned_ids = {p.conversation_id for p in pins_result.scalars().all()} + + conv_list = [] + for conv in conversations: + if conv.is_archived and not include_archived: + continue + # Get participants + parts_result = await db.execute( + select(CommParticipant).where( + CommParticipant.conversation_id == conv.id, + CommParticipant.left_at.is_(None), + ) + ) + participants = list(parts_result.scalars().all()) + + # Get unread count + unread = await _get_unread_count(db, tenant_id, conv.id, user_id) + + conv_list.append( + conversation_to_response( + conv, participants, unread_count=unread, is_pinned_by_user=conv.id in pinned_ids + ) + ) + + # Sort: pinned first, then by last_msg_at + conv_list.sort(key=lambda c: (not c["is_pinned"], c["last_msg_at"] or ""), reverse=False) + # Actually: pinned first (True > False in reverse), then newest first + conv_list.sort(key=lambda c: c["last_msg_at"] or "0000", reverse=True) + conv_list.sort(key=lambda c: c["is_pinned"], reverse=True) + + return conv_list + + +async def get_conversation( + db: AsyncSession, + tenant_id: uuid.UUID, + conversation_id: uuid.UUID, + user_id: uuid.UUID, +) -> dict[str, Any] | None: + """Get a single conversation with participants.""" + result = await db.execute( + select(CommConversation).where( + CommConversation.id == conversation_id, + CommConversation.tenant_id == tenant_id, + CommConversation.deleted_at.is_(None), + ) + ) + conv = result.scalar_one_or_none() + if conv is None: + return None + + # Check user is participant + if not await CommRBAC.is_participant(db, conversation_id, user_id): + return None + + parts_result = await db.execute( + select(CommParticipant).where( + CommParticipant.conversation_id == conv.id, + CommParticipant.left_at.is_(None), + ) + ) + participants = list(parts_result.scalars().all()) + + # Check pinned + pin_result = await db.execute( + select(CommConversationPin).where( + CommConversationPin.conversation_id == conv.id, + CommConversationPin.user_id == user_id, + ) + ) + is_pinned = pin_result.scalar_one_or_none() is not None + + unread = await _get_unread_count(db, tenant_id, conv.id, user_id) + + return conversation_to_response(conv, participants, unread_count=unread, is_pinned_by_user=is_pinned) + + +async def create_conversation( + db: AsyncSession, + tenant_id: uuid.UUID, + user_id: uuid.UUID, + title: str | None = None, + participant_ids: list[str] | None = None, + is_direct: bool = False, + initial_message: str | None = None, +) -> dict[str, Any]: + """Create a new conversation.""" + conv = CommConversation( + tenant_id=tenant_id, + title=title, + is_direct=is_direct, + created_by=user_id, + created_by_type="user", + metadata_={}, + ) + db.add(conv) + await db.flush() + + # Add creator as admin + creator = CommParticipant( + tenant_id=tenant_id, + conversation_id=conv.id, + participant_id=user_id, + participant_type="user", + role="admin", + ) + db.add(creator) + + # Add other participants + for pid_str in (participant_ids or []): + try: + pid = uuid.UUID(pid_str) + if pid == user_id: + continue + p = CommParticipant( + tenant_id=tenant_id, + conversation_id=conv.id, + participant_id=pid, + participant_type="user", + role="member", + ) + db.add(p) + except ValueError: + logger.warning(f"Invalid participant UUID: {pid_str}") + + await db.flush() + + # Send initial message if provided + if initial_message: + await send_message( + db, tenant_id, conv.id, user_id, "user", + content=initial_message, content_format="text", + ) + + # Publish event + event_bus = get_event_bus() + await event_bus.publish("conversation.created", { + "conversation_id": str(conv.id), + "tenant_id": str(tenant_id), + "created_by": str(user_id), + }) + + # Get all participants for response + parts_result = await db.execute( + select(CommParticipant).where( + CommParticipant.conversation_id == conv.id, + CommParticipant.left_at.is_(None), + ) + ) + participants = list(parts_result.scalars().all()) + + return conversation_to_response(conv, participants) + + +async def update_conversation( + db: AsyncSession, + tenant_id: uuid.UUID, + conversation_id: uuid.UUID, + user_id: uuid.UUID, + title: str | None = None, + is_archived: bool | None = None, +) -> dict[str, Any] | None: + """Update a conversation.""" + result = await db.execute( + select(CommConversation).where( + CommConversation.id == conversation_id, + CommConversation.tenant_id == tenant_id, + CommConversation.deleted_at.is_(None), + ) + ) + conv = result.scalar_one_or_none() + if conv is None: + return None + + # Check locked + if conv.is_locked and title is not None: + # Only the locking plugin can change title on locked conversations + # Users cannot + pass + elif title is not None: + conv.title = title + conv.title_set_by = user_id + + if is_archived is not None: + conv.is_archived = is_archived + + await db.flush() + + return await get_conversation(db, tenant_id, conversation_id, user_id) + + +async def pin_conversation( + db: AsyncSession, + tenant_id: uuid.UUID, + conversation_id: uuid.UUID, + user_id: uuid.UUID, +) -> bool: + """Pin a conversation for a user.""" + existing = await db.execute( + select(CommConversationPin).where( + CommConversationPin.conversation_id == conversation_id, + CommConversationPin.user_id == user_id, + ) + ) + if existing.scalar_one_or_none() is None: + pin = CommConversationPin( + tenant_id=tenant_id, + conversation_id=conversation_id, + user_id=user_id, + ) + db.add(pin) + await db.flush() + return True + + +async def unpin_conversation( + db: AsyncSession, + conversation_id: uuid.UUID, + user_id: uuid.UUID, +) -> bool: + """Unpin a conversation for a user.""" + result = await db.execute( + select(CommConversationPin).where( + CommConversationPin.conversation_id == conversation_id, + CommConversationPin.user_id == user_id, + ) + ) + pin = result.scalar_one_or_none() + if pin: + await db.delete(pin) + await db.flush() + return True + + +async def mute_conversation( + db: AsyncSession, + tenant_id: uuid.UUID, + conversation_id: uuid.UUID, + user_id: uuid.UUID, +) -> bool: + """Mute a conversation for a user.""" + existing = await db.execute( + select(CommConversationMute).where( + CommConversationMute.conversation_id == conversation_id, + CommConversationMute.user_id == user_id, + ) + ) + if existing.scalar_one_or_none() is None: + mute = CommConversationMute( + tenant_id=tenant_id, + conversation_id=conversation_id, + user_id=user_id, + ) + db.add(mute) + await db.flush() + return True + + +async def unmute_conversation( + db: AsyncSession, + conversation_id: uuid.UUID, + user_id: uuid.UUID, +) -> bool: + """Unmute a conversation for a user.""" + result = await db.execute( + select(CommConversationMute).where( + CommConversationMute.conversation_id == conversation_id, + CommConversationMute.user_id == user_id, + ) + ) + mute = result.scalar_one_or_none() + if mute: + await db.delete(mute) + await db.flush() + return True + + +# ─── Participant Management ─── + + +async def add_participant( + db: AsyncSession, + tenant_id: uuid.UUID, + conversation_id: uuid.UUID, + participant_id: str, + participant_type: str = "user", + role: str = "member", + display_name: str | None = None, +) -> dict[str, Any] | None: + """Add a participant to a conversation.""" + try: + pid = uuid.UUID(participant_id) if participant_type == "user" else None + except ValueError: + return None + + existing = await db.execute( + select(CommParticipant).where( + CommParticipant.conversation_id == conversation_id, + CommParticipant.participant_id == pid if pid else CommParticipant.participant_type == participant_type, + CommParticipant.participant_type == participant_type, + CommParticipant.left_at.is_(None), + ) + ) + if existing.scalar_one_or_none() is not None: + return None # Already a participant + + p = CommParticipant( + tenant_id=tenant_id, + conversation_id=conversation_id, + participant_id=pid, + participant_type=participant_type, + role=role, + display_name=display_name, + ) + db.add(p) + await db.flush() + + # Publish event + event_bus = get_event_bus() + await event_bus.publish("participant.joined", { + "conversation_id": str(conversation_id), + "participant_id": participant_id, + "participant_type": participant_type, + "tenant_id": str(tenant_id), + }) + + return participant_to_response(p) + + +async def remove_participant( + db: AsyncSession, + conversation_id: uuid.UUID, + participant_id: uuid.UUID, +) -> bool: + """Remove a participant from a conversation (set left_at).""" + result = await db.execute( + select(CommParticipant).where( + CommParticipant.conversation_id == conversation_id, + CommParticipant.participant_id == participant_id, + CommParticipant.participant_type == "user", + CommParticipant.left_at.is_(None), + ) + ) + p = result.scalar_one_or_none() + if p is None: + return False + p.left_at = datetime.now(timezone.utc) + await db.flush() + + event_bus = get_event_bus() + await event_bus.publish("participant.left", { + "conversation_id": str(conversation_id), + "participant_id": str(participant_id), + }) + return True + + +async def change_role( + db: AsyncSession, + conversation_id: uuid.UUID, + participant_id: uuid.UUID, + new_role: str, +) -> dict[str, Any] | None: + """Change a participant's role.""" + result = await db.execute( + select(CommParticipant).where( + CommParticipant.conversation_id == conversation_id, + CommParticipant.participant_id == participant_id, + CommParticipant.participant_type == "user", + CommParticipant.left_at.is_(None), + ) + ) + p = result.scalar_one_or_none() + if p is None: + return None + p.role = new_role + await db.flush() + return participant_to_response(p) + + +# ─── Messages ─── + + +async def get_messages( + db: AsyncSession, + tenant_id: uuid.UUID, + conversation_id: uuid.UUID, + page: int = 1, + page_size: int = 50, + before_id: uuid.UUID | None = None, +) -> dict[str, Any]: + """Get paginated messages for a conversation.""" + query = select(CommMessage).where( + CommMessage.conversation_id == conversation_id, + CommMessage.tenant_id == tenant_id, + CommMessage.deleted_at.is_(None), + ).order_by(CommMessage.created_at.desc()) + + if before_id: + before_msg = await db.execute( + select(CommMessage).where(CommMessage.id == before_id) + ) + before = before_msg.scalar_one_or_none() + if before: + query = query.where(CommMessage.created_at < before.created_at) + + query = query.offset((page - 1) * page_size).limit(page_size) + result = await db.execute(query) + messages = list(result.scalars().all()) + + # Get blocks, attachments, reactions for each message + msg_ids = [m.id for m in messages] + blocks_map: dict[uuid.UUID, list] = {} + attachments_map: dict[uuid.UUID, list] = {} + reactions_map: dict[uuid.UUID, list] = {} + + if msg_ids: + blocks_result = await db.execute( + select(CommMessageBlock).where( + CommMessageBlock.message_id.in_(msg_ids), + CommMessageBlock.deleted_at.is_(None), + ).order_by(CommMessageBlock.sort_order) + ) + for b in blocks_result.scalars().all(): + blocks_map.setdefault(b.message_id, []).append(b) + + atts_result = await db.execute( + select(CommMessageAttachment).where( + CommMessageAttachment.message_id.in_(msg_ids), + CommMessageAttachment.deleted_at.is_(None), + ) + ) + for a in atts_result.scalars().all(): + attachments_map.setdefault(a.message_id, []).append(a) + + reactions_result = await db.execute( + select(CommMessageReaction).where( + CommMessageReaction.message_id.in_(msg_ids), + ) + ) + for r in reactions_result.scalars().all(): + reactions_map.setdefault(r.message_id, []).append(r) + + items = [] + for msg in reversed(messages): # chronological order + items.append( + message_to_response( + msg, + blocks=blocks_map.get(msg.id, []), + attachments=attachments_map.get(msg.id, []), + reactions=reactions_map.get(msg.id, []), + ) + ) + + # Total count + count_result = await db.execute( + select(func.count()).select_from(CommMessage).where( + CommMessage.conversation_id == conversation_id, + CommMessage.tenant_id == tenant_id, + CommMessage.deleted_at.is_(None), + ) + ) + total = count_result.scalar() or 0 + + has_more = (page * page_size) < total + + return {"items": items, "total": total, "page": page, "has_more": has_more} + + +async def send_message( + db: AsyncSession, + tenant_id: uuid.UUID, + conversation_id: uuid.UUID, + sender_id: uuid.UUID | None, + sender_type: str, + content: str = "", + content_format: str = "text", + blocks: list[dict[str, Any]] | None = None, + reply_to_id: str | None = None, + attachments: list[dict[str, Any]] | None = None, + metadata: dict[str, Any] | None = None, + trigger_depth: int = 0, +) -> dict[str, Any]: + """Send a message to a conversation and trigger participant handlers.""" + # Create message + msg = CommMessage( + tenant_id=tenant_id, + conversation_id=conversation_id, + sender_id=sender_id, + sender_type=sender_type, + content=content, + content_format=content_format, + metadata_=metadata or {}, + ) + if reply_to_id: + try: + msg.reply_to_id = uuid.UUID(reply_to_id) + except ValueError: + pass + + db.add(msg) + await db.flush() + + # Create blocks + if blocks: + for i, block in enumerate(blocks): + b = CommMessageBlock( + tenant_id=tenant_id, + message_id=msg.id, + block_type=block["block_type"], + block_data=block["block_data"], + sort_order=i, + ) + db.add(b) + + # Create attachments + if attachments: + for att in attachments: + a = CommMessageAttachment( + tenant_id=tenant_id, + message_id=msg.id, + file_id=uuid.UUID(att["file_id"]) if att.get("file_id") else None, + file_source=att.get("file_source", "comm"), + file_name=att.get("file_name", ""), + file_type=att.get("file_type", "application/octet-stream"), + file_size=att.get("file_size"), + ) + db.add(a) + + await db.flush() + + # Update conversation last_msg + await db.execute( + update(CommConversation) + .where(CommConversation.id == conversation_id) + .values( + last_msg_at=datetime.now(timezone.utc), + last_msg_preview=content[:200] if content else "", + last_msg_sender_type=sender_type, + ) + ) + + # Publish event + event_bus = get_event_bus() + await event_bus.publish("message.received", { + "conversation_id": str(conversation_id), + "message_id": str(msg.id), + "sender_type": sender_type, + "tenant_id": str(tenant_id), + "content": content, + "trigger_depth": trigger_depth, + }) + + # Trigger participant handlers (if not at max depth) + if trigger_depth < MAX_TRIGGER_DEPTH: + await _trigger_participants( + db, tenant_id, conversation_id, msg, trigger_depth + ) + + # Load blocks/attachments/reactions for response + blocks_result = await db.execute( + select(CommMessageBlock).where( + CommMessageBlock.message_id == msg.id, + CommMessageBlock.deleted_at.is_(None), + ).order_by(CommMessageBlock.sort_order) + ) + msg_blocks = list(blocks_result.scalars().all()) + + atts_result = await db.execute( + select(CommMessageAttachment).where( + CommMessageAttachment.message_id == msg.id, + CommMessageAttachment.deleted_at.is_(None), + ) + ) + msg_atts = list(atts_result.scalars().all()) + + return message_to_response(msg, blocks=msg_blocks, attachments=msg_atts) + + +async def _trigger_participants( + db: AsyncSession, + tenant_id: uuid.UUID, + conversation_id: uuid.UUID, + message: CommMessage, + trigger_depth: int, +) -> None: + """Trigger participant handlers for non-user participants.""" + # Get conversation participants + result = await db.execute( + select(CommParticipant).where( + CommParticipant.conversation_id == conversation_id, + CommParticipant.left_at.is_(None), + CommParticipant.participant_type != "user", + ) + ) + non_user_participants = list(result.scalars().all()) + + if not non_user_participants: + return + + # Get conversation info + conv_result = await db.execute( + select(CommConversation).where(CommConversation.id == conversation_id) + ) + conv = conv_result.scalar_one_or_none() + if conv is None: + return + + # Parse mentions + mentions = parse_mentions(message.content) + + # Build conversation dict + all_parts_result = await db.execute( + select(CommParticipant).where( + CommParticipant.conversation_id == conversation_id, + CommParticipant.left_at.is_(None), + ) + ) + all_parts = [participant_to_response(p) for p in all_parts_result.scalars().all()] + conv_dict = conversation_to_response(conv, []) + conv_dict["participants"] = all_parts + + msg_dict = message_to_response(message) + context = {"tenant_id": str(tenant_id), "trigger_depth": trigger_depth} + + registry = get_participant_registry() + + for p in non_user_participants: + handler = registry.get_handler(p.participant_type) + if handler is None: + continue + + try: + responses = await handler.on_message_received( + conversation_id=conversation_id, + message=msg_dict, + conversation=conv_dict, + mentions=mentions, + context=context, + ) + + if responses: + for resp in responses: + await send_message( + db, + tenant_id, + conversation_id, + sender_id=None, + sender_type=p.participant_type, + content=resp.get("content", ""), + content_format=resp.get("content_format", "text"), + blocks=resp.get("blocks"), + metadata={ + **(resp.get("metadata") or {}), + "triggered_by": str(message.id), + "trigger_depth": trigger_depth + 1, + }, + trigger_depth=trigger_depth + 1, + ) + except Exception: + logger.exception( + f"Participant handler error for type {p.participant_type}" + ) + + +async def edit_message( + db: AsyncSession, + tenant_id: uuid.UUID, + message_id: uuid.UUID, + user_id: uuid.UUID, + new_content: str, +) -> dict[str, Any] | None: + """Edit a message, storing the old version in history.""" + result = await db.execute( + select(CommMessage).where( + CommMessage.id == message_id, + CommMessage.tenant_id == tenant_id, + CommMessage.deleted_at.is_(None), + ) + ) + msg = result.scalar_one_or_none() + if msg is None: + return None + + # Get old blocks + blocks_result = await db.execute( + select(CommMessageBlock).where( + CommMessageBlock.message_id == message_id, + CommMessageBlock.deleted_at.is_(None), + ) + ) + old_blocks = [b.block_data for b in blocks_result.scalars().all()] + + # Save edit history + edit = CommMessageEdit( + tenant_id=tenant_id, + message_id=message_id, + old_content=msg.content, + old_blocks=old_blocks, + edited_by=user_id, + ) + db.add(edit) + + # Update message + msg.content = new_content + msg.edited_at = datetime.now(timezone.utc) + await db.flush() + + return message_to_response(msg) + + +async def delete_message( + db: AsyncSession, + message_id: uuid.UUID, +) -> bool: + """Soft-delete a message.""" + result = await db.execute( + select(CommMessage).where(CommMessage.id == message_id) + ) + msg = result.scalar_one_or_none() + if msg is None: + return False + msg.deleted_at = datetime.now(timezone.utc) + await db.flush() + return True + + +# ─── Reactions ─── + + +async def add_reaction( + db: AsyncSession, + tenant_id: uuid.UUID, + message_id: uuid.UUID, + user_id: uuid.UUID, + emoji: str, +) -> dict[str, Any] | None: + """Add an emoji reaction to a message.""" + existing = await db.execute( + select(CommMessageReaction).where( + CommMessageReaction.message_id == message_id, + CommMessageReaction.user_id == user_id, + CommMessageReaction.emoji == emoji, + ) + ) + if existing.scalar_one_or_none() is not None: + return None # Already reacted + + r = CommMessageReaction( + tenant_id=tenant_id, + message_id=message_id, + user_id=user_id, + emoji=emoji, + ) + db.add(r) + await db.flush() + + event_bus = get_event_bus() + await event_bus.publish("reaction.added", { + "message_id": str(message_id), + "emoji": emoji, + "user_id": str(user_id), + }) + + return { + "id": str(r.id), + "message_id": str(r.message_id), + "user_id": str(r.user_id), + "emoji": r.emoji, + } + + +async def remove_reaction( + db: AsyncSession, + message_id: uuid.UUID, + user_id: uuid.UUID, + emoji: str, +) -> bool: + """Remove an emoji reaction.""" + result = await db.execute( + select(CommMessageReaction).where( + CommMessageReaction.message_id == message_id, + CommMessageReaction.user_id == user_id, + CommMessageReaction.emoji == emoji, + ) + ) + r = result.scalar_one_or_none() + if r is None: + return False + await db.delete(r) + await db.flush() + return True + + +# ─── Read State ─── + + +async def mark_read( + db: AsyncSession, + tenant_id: uuid.UUID, + conversation_id: uuid.UUID, + user_id: uuid.UUID, + last_read_msg_id: str | None = None, +) -> bool: + """Mark conversation as read up to a message.""" + result = await db.execute( + select(CommMessageRead).where( + CommMessageRead.conversation_id == conversation_id, + CommMessageRead.user_id == user_id, + ) + ) + read = result.scalar_one_or_none() + + msg_id = uuid.UUID(last_read_msg_id) if last_read_msg_id else None + + if read is None: + read = CommMessageRead( + tenant_id=tenant_id, + conversation_id=conversation_id, + user_id=user_id, + last_read_msg_id=msg_id, + ) + db.add(read) + else: + read.last_read_msg_id = msg_id + read.last_read_at = datetime.now(timezone.utc) + + await db.flush() + return True + + +async def _get_unread_count( + db: AsyncSession, + tenant_id: uuid.UUID, + conversation_id: uuid.UUID, + user_id: uuid.UUID, +) -> int: + """Get unread message count for a user in a conversation.""" + # Get last read message + read_result = await db.execute( + select(CommMessageRead).where( + CommMessageRead.conversation_id == conversation_id, + CommMessageRead.user_id == user_id, + ) + ) + read = read_result.scalar_one_or_none() + + query = select(func.count()).select_from(CommMessage).where( + CommMessage.conversation_id == conversation_id, + CommMessage.tenant_id == tenant_id, + CommMessage.deleted_at.is_(None), + CommMessage.sender_type != "system", # Don't count system messages? Or count all? + ) + + if read and read.last_read_at: + query = query.where(CommMessage.created_at > read.last_read_at) + + result = await db.execute(query) + return result.scalar() or 0 + + +# ─── Plugin Room Creation ─── + + +async def create_plugin_room( + db: AsyncSession, + tenant_id: uuid.UUID, + user_id: uuid.UUID, + plugin_name: str, + title: str, + participant_type: str, + user_role: str = "member", +) -> dict[str, Any]: + """Create a locked, pinned room for a plugin (System, Live KI, Assistent). + + The room is locked (users can't change title/participants) and pinned for the user. + """ + # Check if room already exists for this user + plugin + result = await db.execute( + select(CommConversation).where( + CommConversation.tenant_id == tenant_id, + CommConversation.title == title, + CommConversation.is_locked == True, + CommConversation.locked_by == plugin_name, + 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), + ) + ) + existing = result.scalar_one_or_none() + if existing: + # Already exists — return it + return await get_conversation(db, tenant_id, existing.id, user_id) or {} + + # Create conversation + conv = CommConversation( + tenant_id=tenant_id, + title=title, + is_locked=True, + locked_by=plugin_name, + is_direct=False, + created_by=None, + created_by_type="plugin", + metadata_={"plugin": plugin_name}, + ) + db.add(conv) + await db.flush() + + # Add plugin as participant + plugin_p = CommParticipant( + tenant_id=tenant_id, + conversation_id=conv.id, + participant_id=None, + participant_type=participant_type, + role="admin", + display_name=title, + ) + db.add(plugin_p) + + # Add user as participant + user_p = CommParticipant( + tenant_id=tenant_id, + conversation_id=conv.id, + participant_id=user_id, + participant_type="user", + role=user_role, + ) + db.add(user_p) + + # Pin for user + pin = CommConversationPin( + tenant_id=tenant_id, + conversation_id=conv.id, + user_id=user_id, + ) + db.add(pin) + + await db.flush() + + # Publish event + event_bus = get_event_bus() + await event_bus.publish("conversation.created", { + "conversation_id": str(conv.id), + "tenant_id": str(tenant_id), + "created_by_type": "plugin", + "plugin_name": plugin_name, + }) + + parts = [plugin_p, user_p] + return conversation_to_response(conv, parts, is_pinned_by_user=True) diff --git a/app/plugins/builtins/kommunikation/websocket_manager.py b/app/plugins/builtins/kommunikation/websocket_manager.py new file mode 100644 index 0000000..4f25f2d --- /dev/null +++ b/app/plugins/builtins/kommunikation/websocket_manager.py @@ -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 diff --git a/app/plugins/builtins/system_notif/__init__.py b/app/plugins/builtins/system_notif/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/plugins/builtins/system_notif/participant_handler.py b/app/plugins/builtins/system_notif/participant_handler.py new file mode 100644 index 0000000..f3bfe86 --- /dev/null +++ b/app/plugins/builtins/system_notif/participant_handler.py @@ -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", + } diff --git a/app/plugins/builtins/system_notif/plugin.py b/app/plugins/builtins/system_notif/plugin.py new file mode 100644 index 0000000..e81cd20 --- /dev/null +++ b/app/plugins/builtins/system_notif/plugin.py @@ -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 [] diff --git a/docs/bauplan-unified-messaging.md b/docs/bauplan-unified-messaging.md new file mode 100644 index 0000000..52182e7 --- /dev/null +++ b/docs/bauplan-unified-messaging.md @@ -0,0 +1,1633 @@ +# Bauplan: Unified Messaging System für LeoCRM + +> Vollständiger Bauplan unter Berücksichtigung aller bestehenden Systeme, +> Plugin-Architektur, RBAC, DMS, Suche und aller Nutzer-Anforderungen. + +--- + +## 1. Bestandsaufnahme — Was existiert bereits + +### 1.1 Plugin-System +- **BasePlugin** mit Lifecycle Hooks: `on_install`, `on_activate`, `on_deactivate`, `on_uninstall` +- **PluginManifest** mit: name, version, dependencies, routes, events, migrations, permissions, is_core +- **PluginRegistry** mit: Discovery, Install, Activate, Deactivate, Uninstall, Topological Sort, Dependency Resolution +- **EventBus** — in-process pub/sub, async handlers +- **MigrationRunner** — SQL-Migrations pro Plugin +- **ServiceContainer** — shared services + +### 1.2 Bestehende Plugins + +| Plugin | Status | Abhängigkeiten | is_core | Eigene UI | Eigene Tabellen | +|---|---|---|---|---|---| +| `ai_assistant` | Aktiv | — | ✅ | Ja (ChatWindow, Sessions) | ai_chat_sessions, ai_chat_messages, ai_chat_attachments, ai_chat_folders, ai_providers, ai_models, ai_presets, ai_agents | +| `ai_proactive` | Aktiv | ai_assistant, unified_search | ❌ | Ja (SuggestionList, SSE) | ai_proactive_suggestions, ai_proactive_context_log, ai_proactive_settings | +| `dms` | Aktiv | permissions | ❌ | Ja (DMS Page) | folders, files | +| `mail` | Aktiv | — | ❌ | Ja (Mail Page) | mails | +| `calendar` | Aktiv | — | ❌ | Ja (Calendar Page) | calendar_events | +| `unified_search` | Aktiv | — | ❌ | Ja (Search Page) | search_index | +| `permissions` | Aktiv | — | ❌ | Ja (Settings) | permissions, role_permissions | +| `report_generator` | Aktiv | — | ❌ | Ja | — | + +### 1.3 Notification-System (Core, nicht Plugin) +- **Modelle:** `Notification`, `NotificationType`, `NotificationPreference` +- **Core Service:** `app/core/notifications.py` → `create_notification()` +- **Routes:** `app/routes/notifications.py` +- **Plugin Integration:** Plugins deklarieren Notification-Types via `get_notification_types()` +- **Sync:** `PluginRegistry.sync_notification_types()` → DB +- **Frontend:** Aktuell im uiStore als `string[]` (Mock-Daten) + +### 1.4 RBAC / Permission System +- **Permission Registry:** `app/core/permission_registry.py` +- **Permission Check:** `app/core/permissions.py` → `check_permission()`, `get_cached_permissions()` +- **FastAPI Deps:** `require_permission()`, `require_admin()`, `require_write()` +- **Modelle:** `Permission`, `Role`, `RolePermission` (permissions plugin) +- **User Session:** `get_current_user` → liefert `permissions[]`, `denied_permissions[]`, `field_permissions{}`, `is_system_admin` +- **Pattern:** `module:action` (z.B. `comm:read`, `comm:write`, `comm:manage`) + +### 1.5 DMS Plugin +- **Modelle:** `Folder` (hierarchisch, tenant-scoped, soft-deletable), `File` (storage_path auf Disk, mime_type, size_bytes) +- **Storage:** `/tmp/dms` (configurable via `DMS_STORAGE_BASE`) +- **Sharing:** Internal sharing via permissions +- **OnlyOffice:** Edit sessions für Office-Dateien +- **Upload:** `UploadFile` → Disk + DB-Eintrag + +### 1.6 AI Assistant Plugin (Detail) +- **Provider-Verwaltung:** Multi-Provider (OpenAI, Ollama, etc.), API-Keys in DB +- **Modelle/Preset/Agents:** Konfigurierbar pro Tenant +- **Chat Sessions:** Eigene Tabellen, Streaming via SSE +- **Tool Registry:** Plugins können AI-Tools registrieren (`tool_registry.py`) +- **Frontend:** `ChatWindow` Komponente, `AIAssistant` Page, `AISettings` Page + +### 1.7 AI Proactive Plugin (Detail) +- **Suggestion Engine:** Kontext-Änderung → LLM → Suggestion +- **SSE Push:** `_sse_queues` dict, `push_suggestion()` +- **Background Jobs:** `deep_analysis` via ARQ +- **Settings:** Pro-User (enabled, categories, confidence_threshold, rate_limit, model) +- **Frontend:** `SuggestionList` Komponente, `ProactiveAISettings` Page +- **Events:** `context.view_changed`, `context.entity_selected` + +### 1.8 Frontend Sidebar Struktur +- **Linke Sidebar:** Navigation (Dashboard, Kontakte, Kalender, Dateien, Mail) +- **Rechte Sidebar (AISidebar):** 5 Tabs — KI Chat, Live KI, Benachrichtigungen, Team, Chat +- **uiStore:** `aiSidebarCollapsed`, `aiSidebarTab`, `notifications: string[]` +- **Komponenten:** `ChatWindow`, `SuggestionList`, `TeamPanel`, `ChatPanel` (placeholder) + +### 1.9 AI Copilot System (Core, nicht Plugin) +- **Modelle:** `AIConversation`, `AIMessage` (tenant-scoped) +- **Service:** `ai_copilot_service` — NL → proposed actions → execute +- **Routes:** `/api/v1/ai/copilot/query`, `/api/v1/ai/copilot/execute` +- **Frontend:** AIAssistant Page nutzt Copilot + +--- + +## 2. Architektur — Plugin-Ebenen + +### Übersicht + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Frontend │ +│ ┌──────────────┐ ┌───────────────────┐ ┌──────────────────┐ │ +│ │ Linke Sidebar │ │ Hauptbereich │ │ Rechte Sidebar │ │ +│ │ Navigation │ │ (CRM Seiten) │ │ = MessageSidebar │ │ +│ │ │ │ │ │ Raum-Liste + Feed │ │ +│ │ │ │ │ │ + Eingabefeld │ │ +│ └──────────────┘ └───────────────────┘ └──────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + │ │ │ + │ │ │ WebSocket +┌────────┴────────────────────┴────────────────────────┴──────────┐ +│ Plugin: kommunikation (Core) │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────────┐ │ +│ │ Conversations│ │ Messages │ │ Participant Registry │ │ +│ │ + Räume │ │ + Blocks │ │ (Andockpunkt) │ │ +│ │ + Locking │ │ + Attachments│ │ │ │ +│ │ + RBAC │ │ + Reactions │ │ register(type, handler) │ │ +│ └──────────────┘ └──────────────┘ └──────────────────────────┘ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────────┐ │ +│ │ WebSocket Mgr│ │ DMS Bridge │ │ Mini-App Registry │ │ +│ │ (Real-time) │ │ (File Store) │ │ (Plugin Blocks) │ │ +│ └──────────────┘ └──────────────┘ └──────────────────────────┘ │ +└──────────────────────────┬──────────────────────────────────────┘ + │ EventBus + Participant Registry +┌──────────────────────────┴──────────────────────────────────────┐ +│ Plugin: ai_assistant │ Plugin: ai_proactive │ Plugin: │ +│ (Teilnehmer: ai) │ (Teilnehmer: ai_p) │ system_notif │ +│ @KI → Response │ Heartbeat → Kanal │ (Teilnehmer: │ +│ Keine eigene UI │ Keine eigene UI │ system) │ +│ Nutzt comm UI │ Nutzt comm UI │ Events→Msg │ +└─────────────────────────────────────────────────────────────────┘ + │ +┌──────────────────────────┴──────────────────────────────────────┐ +│ Später: whatsapp_gateway │ telegram_gateway │ email_gateway │ +│ (Teilnehmer: whatsapp) │ (Teilnehmer: telegram) │ (Teilnehmer: email)│ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 3. Plugin `kommunikation` (Core-Plugin) + +### 3.1 Manifest + +```python +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="...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", # Nachrichten/Konversationen lesen + "comm:write", # Nachrichten schreiben + "comm:create", # Konversationen erstellen + "comm:manage", # Teilnehmer verwalten, Räume locken + "comm:admin", # Konversation-Admin (Rollen vergeben) + "comm:delete", # Nachrichten/Konversationen löschen + ], + is_core=True, +) +``` + +### 3.2 Komponenten + +``` +app/plugins/builtins/kommunikation/ +├── __init__.py +├── plugin.py # Plugin-Klasse, Lifecycle +├── manifest.py # (in plugin.py) +├── models.py # SQLAlchemy-Modelle +├── schemas.py # Pydantic-Schemas +├── routes.py # REST-API + WebSocket +├── services.py # Business Logic +├── participant_registry.py # Teilnehmer-Interface + Registry +├── content_types.py # Block-Typ-Definitionen +├── miniapp_registry.py # Mini-App Registry +├── websocket_manager.py # WebSocket-Verbindungs-Manager +├── dms_bridge.py # DMS-Integration für Attachments +├── rbac.py # Chat-interne RBAC-Logik +├── search_provider.py # unified_search Provider +└── migrations/ + └── 0001_initial.sql +``` + +### 3.3 Datenmodell + +#### Tabelle `comm_conversations` +```sql +CREATE TABLE comm_conversations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + title TEXT, -- Raum-Name (NULL = Direkt-Chat) + title_set_by UUID, -- user_id die den Titel gesetzt hat + is_pinned BOOLEAN DEFAULT FALSE, -- von User angepinnt + is_locked BOOLEAN DEFAULT FALSE, -- Plugin-Lock (User kann nicht ändern) + locked_by TEXT, -- Plugin-Name der gelockt hat + is_direct BOOLEAN DEFAULT FALSE, -- 1:1 vs Gruppe/Raum + is_archived BOOLEAN DEFAULT FALSE, -- archiviert + created_by UUID, -- user_id oder plugin_name + created_by_type TEXT DEFAULT 'user', -- 'user', 'plugin', 'system' + last_msg_at TIMESTAMPTZ, + last_msg_preview TEXT, -- für Listen-Anzeige + last_msg_sender_type TEXT, -- für Icon in Liste + metadata JSONB DEFAULT '{}', -- z.B. {"pinned_by": "user_id"} + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); +CREATE INDEX ix_comm_conversations_tenant ON comm_conversations(tenant_id); +CREATE INDEX ix_comm_conversations_tenant_pinned ON comm_conversations(tenant_id, is_pinned); +CREATE INDEX ix_comm_conversations_last_msg ON comm_conversations(tenant_id, last_msg_at DESC); +``` + +#### Tabelle `comm_participants` +```sql +CREATE TABLE 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, -- user_id (NULL für ai/system/gateways) + participant_type TEXT NOT NULL, -- 'user', 'ai', 'ai_proactive', 'system', 'whatsapp', ... + display_name TEXT, -- Override-Name + role TEXT DEFAULT 'member', -- 'admin', 'member', 'reader' + joined_at TIMESTAMPTZ DEFAULT NOW(), + left_at TIMESTAMPTZ, + UNIQUE(conversation_id, participant_id, participant_type) +); +CREATE INDEX ix_comm_participants_tenant_conv ON comm_participants(tenant_id, conversation_id); +CREATE INDEX ix_comm_participants_tenant_user ON comm_participants(tenant_id, participant_id); +``` + +#### Tabelle `comm_messages` +```sql +CREATE TABLE 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, -- user_id (NULL für ai/system) + sender_type TEXT NOT NULL, -- 'user', 'ai', 'ai_proactive', 'system', ... + content TEXT NOT NULL DEFAULT '', -- Text-Inhalt (Fallback) + content_format TEXT DEFAULT 'text', -- 'text', 'markdown', 'html' + metadata JSONB DEFAULT '{}', -- typ-spezifische Daten + reply_to_id UUID REFERENCES comm_messages(id) ON DELETE SET NULL, + is_pinned BOOLEAN DEFAULT FALSE, -- Nachricht angepinnt im Feed + created_at TIMESTAMPTZ DEFAULT NOW(), + read_at TIMESTAMPTZ, -- veraltet → comm_message_reads + edited_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ +); +CREATE INDEX ix_comm_messages_tenant_conv ON comm_messages(tenant_id, conversation_id, created_at); +CREATE INDEX ix_comm_messages_tenant_sender ON comm_messages(tenant_id, sender_id); +``` + +#### Tabelle `comm_message_blocks` (Rich Content) +```sql +CREATE TABLE 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 TEXT NOT NULL, -- 'text', 'markdown', 'html', 'image', 'audio', 'video', 'file', 'action_card', 'contact_card', 'miniapp', ... + block_data JSONB NOT NULL, -- typ-spezifische strukturierte Daten + sort_order INT DEFAULT 0 +); +CREATE INDEX ix_comm_blocks_tenant_msg ON comm_message_blocks(tenant_id, message_id); +``` + +#### Tabelle `comm_message_attachments` (DMS-Referenzen) +```sql +CREATE TABLE 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, -- DMS file_id (Referenz) + file_source TEXT DEFAULT 'comm', -- 'comm' (eigener DMS-Bereich) oder 'dms' (externer Verweis) + file_name TEXT NOT NULL, + file_type TEXT NOT NULL, -- MIME type + file_size BIGINT, + thumbnail_path TEXT, -- für Bilder/Videos + metadata JSONB DEFAULT '{}', + created_at TIMESTAMPTZ DEFAULT NOW() +); +CREATE INDEX ix_comm_attachments_tenant_msg ON comm_message_attachments(tenant_id, message_id); +``` + +#### Tabelle `comm_message_reactions` +```sql +CREATE TABLE 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 TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(message_id, user_id, emoji) +); +CREATE INDEX ix_comm_reactions_tenant_msg ON comm_message_reactions(tenant_id, message_id); +``` + +#### Tabelle `comm_message_reads` (Lese-Status pro User pro Konversation) +```sql +CREATE TABLE 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 DEFAULT NOW(), + UNIQUE(conversation_id, user_id) +); +CREATE INDEX ix_comm_reads_tenant_user ON comm_message_reads(tenant_id, user_id); +``` + +#### Tabelle `comm_conversation_pins` (User-spezifisches Pinning) +```sql +CREATE TABLE 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 DEFAULT NOW(), + UNIQUE(conversation_id, user_id) +); +CREATE INDEX ix_comm_pins_tenant_user ON comm_conversation_pins(tenant_id, user_id); +``` + +#### Tabelle `comm_message_edits` (Bearbeitung-Historie) +```sql +CREATE TABLE 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 DEFAULT '[]', + edited_by UUID NOT NULL, + edited_at TIMESTAMPTZ DEFAULT NOW() +); +CREATE INDEX ix_comm_edits_tenant_msg ON comm_message_edits(tenant_id, message_id); +``` + +#### Tabelle `comm_conversation_mutes` (Stummschaltung pro User) +```sql +CREATE TABLE 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 DEFAULT NOW(), + UNIQUE(conversation_id, user_id) +); +CREATE INDEX ix_comm_mutes_tenant_user ON comm_conversation_mutes(tenant_id, user_id); +``` + +### 3.4 Raum-Logik + +**Räume = Benannte Konversationen mit Pinning und Locking** + +| Eigenschaft | Wer setzt es | Bedeutung | +|---|---|---| +| `title` | User oder Plugin | Raum-Name. NULL = Direkt-Chat (auto-Titel aus Teilnehmern) | +| `is_pinned` (Tabelle `comm_conversation_pins`) | User | User-spezifisches Anpinnen für Sortierung | +| `is_locked` | Plugin | Plugin-Lock: User kann Titel/Teilnehmer nicht ändern | +| `locked_by` | Plugin | Plugin-Name der gelockt hat | +| `is_direct` | System | TRUE = 1:1 Chat, FALSE = Gruppe/Raum | +| `is_archived` | User | Archiviert → erscheint nicht in Standard-Liste | + +**Plugin-erstellte Räume:** +- Plugin erstellt Konversation via `services.create_conversation()` +- Setzt `is_locked=True`, `locked_by='plugin_name'`, `created_by_type='plugin'` +- Fügt sich selbst als Participant hinzu (`participant_type='system'` etc.) +- Fügt User als Participant hinzu (`role='reader'` oder `role='member'`) +- Pinnt den Raum für den User (`comm_conversation_pins`) + +**Beispiele für Plugin-Räume:** +- `system_notif` → Raum "System" (locked, gepinnt) — System-Notifications +- `ai_proactive` → Raum "Live KI" (locked, gepinnt) — Proaktive Vorschläge + Heartbeat +- `ai_assistant` → Raum "Assistent" (locked, gepinnt) — 1:1 KI-Chat + +### 3.5 Participant Registry + +```python +class ParticipantHandler(ABC): + """Interface das Plugins implementieren um als Teilnehmer zu fungieren.""" + + @abstractmethod + async def on_message_received( + self, + conversation_id: uuid.UUID, + message: dict, # Die neue Nachricht + conversation: dict, # Die gesamte Konversation mit Teilnehmern + mentions: list[str], # Geparste @Mentions (Teilnehmer-Typen) + context: dict # Tenant, user, etc. + ) -> list[dict] | None: + """Wird aufgerufen wenn eine neue Nachricht in einer Konversation + ankommt, an der dieser Teilnehmer beteiligt ist. + + Rückgabe: Optional Liste von neuen Nachrichten (z.B. AI-Response). + Für reine Leser (system) → return None. + Für reaktive Teilnehmer (ai) → return [message_dict, ...]. + """ + pass + + @abstractmethod + def get_participant_info(self) -> dict: + """Metadaten: display_name, avatar_url, capabilities, description.""" + pass + + +class ParticipantRegistry: + """Global registry für Plugin-Teilnehmer.""" + + def __init__(self): + self._handlers: dict[str, ParticipantHandler] = {} + + def register(self, participant_type: str, handler: ParticipantHandler) -> None: + self._handlers[participant_type] = handler + + def unregister(self, participant_type: str) -> None: + self._handlers.pop(participant_type, None) + + def get_handler(self, participant_type: str) -> ParticipantHandler | None: + return self._handlers.get(participant_type) + + def list_types(self) -> list[str]: + return list(self._handlers.keys()) + + +# Global instance +_registry = ParticipantRegistry() + +def get_participant_registry() -> ParticipantRegistry: + return _registry +``` + +### 3.6 DMS Bridge + +Das `kommunikation` Plugin nutzt das DMS für Datei-Speicherung: + +```python +class DmsBridge: + """Bridge zum DMS Plugin für Attachment-Speicherung.""" + + COMM_FOLDER_NAME = "_kommunikation" # Versteckter Root-Ordner + + async def ensure_comm_folder(self, db, tenant_id) -> Folder: + """Stellt sicher dass der Kommunikation-Ordner im DMS existiert.""" + # Prüfe ob Folder existiert + # Wenn nicht: Erstelle Root-Folder '_kommunikation' + # Pro Konversation: Sub-Ordner mit Konversations-ID + + async def store_attachment( + self, db, tenant_id, conversation_id, user_id, file: UploadFile + ) -> dict: + """Speichert eine Datei im DMS unter _kommunikation/{conversation_id}/.""" + # 1. Ensure conversation sub-folder (created_by = user_id) + # 2. Upload file to DMS (uploaded_by = user_id) + # 3. Return file_id + metadata + + async def reference_external_file( + self, db, file_id: uuid.UUID + ) -> dict: + """Referenziert eine bereits im DMS existierende Datei.""" + # Prüfe ob file_id existiert + # Return metadata ohne Kopie + + async def get_file(self, db, file_id: uuid.UUID) -> File: + """Holt eine Datei aus dem DMS.""" +``` + +**Zwei Attachment-Modi:** +1. **`file_source='comm'`** — Datei wurde im Chat hochgeladen, liegt unter `_kommunikation/{conversation_id}/` im DMS +2. **`file_source='dms'`** — Datei ist eine Referenz auf ein bestehendes DMS-Dokument (keine Kopie) + +### 3.7 Chat-interne RBAC + +**Konversations-Rollen** (in `comm_participants.role`): + +| Rolle | Rechte | +|---|---| +| `admin` | Alles: Nachrichten löschen, Teilnehmer verwalten, Titel ändern, andere admin machen | +| `member` | Nachrichten schreiben, lesen, reagieren, Dateien hochladen, @Mentions | +| `reader` | Nur lesen, reagieren — kein Schreiben | + +**RBAC-Integration mit bestehendem System:** + +```python +class CommRBAC: + """Chat-interne RBAC, angebunden an bestehendes Permission-System.""" + + async def can_user_write( + self, db, user_id, conversation_id, user_permissions: list[str] + ) -> bool: + """Prüft ob User schreiben darf. + 1. System-Permission 'comm:write' muss vorhanden sein + 2. User muss Teilnehmer der Konversation sein + 3. User-Rolle in Konversation muss 'admin' oder 'member' sein + 4. Konversation darf nicht gelockt sein (für nicht-Plugins) + """ + + async def can_user_manage( + self, db, user_id, conversation_id, user_permissions: list[str] + ) -> bool: + """Prüft ob User Teilnehmer verwalten darf. + 1. System-Permission 'comm:manage' oder 'comm:admin' + 2. User muss Konversation-Admin sein + """ + + async def can_user_delete( + self, db, user_id, conversation_id, user_permissions: list[str] + ) -> bool: + """Prüft ob User Nachrichten/Konversation löschen darf. + 1. System-Permission 'comm:delete' oder is_system_admin + 2. Für eigene Nachrichten: immer erlaubt + 3. Für fremde Nachrichten: Konversation-Admin + 4. Für Konversation: admin + comm:delete + """ + + async def invite_user( + self, db, conversation_id, inviter_id, invitee_id, role='member' + ) -> comm_participants: + """Lädt User in Konversation ein. + 1. Inviter muss admin sein oder comm:manage haben + 2. Invitee muss existieren und im selben Tenant sein + 3. Füge als Participant hinzu + 4. Event: participant.joined + """ + + async def change_role( + self, db, conversation_id, changer_id, target_id, new_role + ) -> comm_participants: + """Ändert Rolle eines Teilnehmers. + 1. Changer muss admin sein + 2. Target muss Teilnehmer sein + 3. Neue Rolle: 'admin', 'member', 'reader' + """ +``` + +**Permission-Flow:** +``` +User Request → require_permission('comm:write') → System-Check + → CommRBAC.can_user_write(current_user) → Konversations-Check + → check_permission(current_user, 'comm:write') + is_system_admin + → Participant role check → Erlaubt/Verweigert +``` + +### 3.8 WebSocket Manager + +```python +class WebSocketManager: + """Verwaltet WebSocket-Verbindungen pro User.""" + + def __init__(self): + self._connections: dict[str, list[WebSocket]] = {} # user_id → connections + + async def connect(self, websocket: WebSocket, user_id: str): + """Neue WebSocket-Verbindung.""" + + async def disconnect(self, websocket: WebSocket, user_id: str): + """Verbindung geschlossen.""" + + async def send_to_user(self, user_id: str, message: dict): + """Sendet Nachricht an alle Verbindungen eines Users.""" + + async def send_to_conversation(self, conversation_id: str, message: dict, exclude_user: str = None): + """Sendet an alle Teilnehmer einer Konversation.""" + + async def broadcast(self, message: dict): + """Broadcast an alle verbundenen User.""" +``` + +**WebSocket Events:** +``` +# Client → Server +{"type": "subscribe", "conversation_id": "..."} +{"type": "unsubscribe", "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": "...", "conversation_id": "..."} +{"type": "message.reaction", "message_id": "...", "emoji": "👍", "user_id": "...", "action": "add|remove"} +{"type": "participant.joined", "conversation_id": "...", "participant": {...}} +{"type": "participant.left", "conversation_id": "...", "participant_id": "..."} +{"type": "participant.role_changed", "conversation_id": "...", "participant_id": "...", "role": "..."} +{"type": "typing", "conversation_id": "...", "user_id": "...", "is_typing": true} +{"type": "conversation.updated", "conversation": {...}} +{"type": "conversation.pinned", "conversation_id": "...", "pinned": true} +{"type": "read.update", "conversation_id": "...", "user_id": "...", "last_read_msg_id": "..."} +{"type": "pong"} +``` + +### 3.9 Mini-App Registry + +```python +class MiniAppRegistry: + """Registry für Mini-Apps die von Plugins bereitgestellt werden.""" + + def __init__(self): + self._apps: dict[str, MiniAppDef] = {} + + def register(self, app_id: str, name: str, icon: str, + render_schema: dict, handler: Callable) -> None: + """Plugin registriert eine Mini-App.""" + + def unregister(self, app_id: str) -> None: + + def list_apps(self) -> list[dict]: + """Alle verfügbaren Mini-Apps für Frontend.""" + + def get_app(self, app_id: str) -> MiniAppDef | None: + + +class MiniAppDef(BaseModel): + app_id: str + name: str + icon: str + description: str + render_schema: dict # JSON-Schema für Frontend-Rendering + plugin_name: str # welches Plugin hat es registriert +``` + +### 3.10 Search Provider (unified_search Integration) + +```python +class CommSearchProvider: + """Provider für unified_search — durchsucht alle Konversationen und Nachrichten.""" + + async def search( + self, db, tenant_id, user_id, query: str, limit: int = 20 + ) -> list[dict]: + """Sucht in Nachrichten und Konversationen. + + Returns: + [ + { + 'type': 'message', + 'id': '...', + 'conversation_id': '...', + 'conversation_title': '...', + 'content': '...', + 'sender_type': 'user', + 'sender_name': 'Max', + 'created_at': '...', + 'snippet': '...matching text...' + }, + { + 'type': 'conversation', + 'id': '...', + 'title': '...', + 'participant_count': 3, + 'last_msg_at': '...' + } + ] + """ + + async def index_message(self, message: dict) -> None: + """Indexiert eine Nachricht für die Suche.""" + + async def reindex_all(self, db, tenant_id) -> None: + """Vollständige Neu-Indexierung.""" +``` + +**Registrierung:** Das `kommunikation` Plugin registriert seinen Search Provider beim `unified_search` Plugin bei Aktivierung. + +--- + +## 4. Plugin `ai_assistant` (Anpassung) + +### 4.1 Was bleibt +- Provider-Verwaltung, Modelle, Presets, Agents, Tools +- Tool Registry (andere Plugins können Tools registrieren) +- Streaming-Logik +- AI Copilot System (AIConversation/AIMessage) — parallel laufen lassen +- Eigene Settings-Pages (Provider, Modelle, Agents) + +### 4.2 Was ändert sich +- **Keine eigene Chat-UI** — `ChatWindow` wird durch `kommunikation` MessageSidebar ersetzt +- **Implementiert `ParticipantHandler`** — registriert sich als `ai` Teilnehmer +- **Erstellt gepinnten Raum "Assistent"** — 1:1 Konversation mit User + AI +- **Schreibt in `comm_messages`** — nicht mehr nur in `ai_chat_sessions` +- **Auf `message.received` hören** — wenn @KI erwähnt oder AI Teilnehmer → Response +- **Streaming über WebSocket** — nicht mehr SSE, sondern WebSocket `message.new` Events + - Token-basiertes Streaming: AI generiert Token für Token + - Pro Token: `{"type": "message.streaming", "conversation_id": "...", "message_id": "...", "token": "...", "chunk_index": N}` + - Am Ende: `{"type": "message.streaming.done", "message_id": "...", "final_content": "..."}` + - Frontend zeigt Token live an, ersetzt am Ende durch finalen Content + +### 4.2.1 BasePlugin `services` Property (System-Fix) + +`BasePlugin` wird um eine `services` Property erweitert, damit Plugins auf den `ServiceContainer` zugreifen können: + +```python +# In app/plugins/base.py + +class BasePlugin(ABC): + def __init__(self) -> None: + # ... bestehend ... + self._container: ServiceContainer | None = None + + async def on_activate(self, db, service_container, event_bus) -> None: + self._container = service_container # ← NEU + # ... bestehend ... + + @property + def services(self) -> ServiceContainer: + if self._container is None: + raise RuntimeError("Services not available — plugin not activated") + return self._container +``` + +**Aufwand:** 3-4 Zeilen in `base.py`. Keine bestehenden Plugins müssen geändert werden. + - Token-basiertes Streaming: AI generiert Token für Token + - Pro Token: `{"type": "message.streaming", "conversation_id": "...", "message_id": "...", "token": "...", "chunk_index": N}` + - Am Ende: `{"type": "message.streaming.done", "message_id": "...", "final_content": "..."}` + - Frontend zeigt Token live an, ersetzt am Ende durch finalen Content + +### 4.3 ParticipantHandler Implementation + +```python +class AIParticipantHandler(ParticipantHandler): + """AI Assistant als Teilnehmer im kommunikation Plugin.""" + + async def on_message_received( + self, conversation_id, message, conversation, mentions, context + ) -> list[dict] | None: + """Reagiert auf Nachrichten in Konversationen mit AI-Teilnehmer.""" + # 1. Prüfe ob AI Teilnehmer in dieser Konversation ist + # 2. Prüfe ob @KI erwähnt wurde ODER Konversation ist 1:1 mit AI + # 3. Wenn ja: generiere AI-Response via stream_chat() + # 4. Schreibe Response als comm_message (sender_type='ai') + # 5. Push via WebSocket + # 6. Return [response_message_dict] + + def get_participant_info(self) -> dict: + return { + 'display_name': 'KI Assistent', + 'avatar_url': None, # Robot-Icon im Frontend + 'capabilities': ['chat', 'tools', 'streaming'], + 'description': 'KI Assistent mit LLM und Tools' + } +``` + +### 4.4 Plugin on_activate + +```python +class AIAssistantPlugin(BasePlugin): + async def on_activate(self, db, container, event_bus): + await super().on_activate(db, container, event_bus) + + # 1. Bei kommunikation registrieren + from app.plugins.builtins.kommunikation.participant_registry import get_participant_registry + registry = get_participant_registry() + registry.register('ai', AIParticipantHandler(self.services)) + + # 2. Auf message.received hören (für @KI Detection) + event_bus.subscribe('message.received', self.on_message_received) + + # 3. Tools registrieren (bestehend) + # ... + + async def on_deactivate(self, db, container, event_bus): + # Unregister participant + get_participant_registry().unregister('ai') + event_bus.unsubscribe('message.received', self.on_message_received) + await super().on_deactivate(db, container, event_bus) +``` + +--- + +## 5. Plugin `ai_proactive` (Anpassung) + +### 5.1 Was bleibt +- Context-Engine (Kontext-Änderung → Suggestion) +- Background Jobs (deep_analysis via ARQ) +- Settings (Pro-User: enabled, categories, confidence, rate_limit, model) +- Tool Registry Integration + +### 5.2 Was ändert sich +- **Keine eigene UI** — `SuggestionList` wird durch `kommunikation` MessageSidebar ersetzt +- **Implementiert `ParticipantHandler`** — registriert sich als `ai_proactive` Teilnehmer +- **Erstellt gepinnten Raum "Live KI"** — locked, gepinnt, für proaktive Vorschläge +- **Heartbeat** — regelmäßige Background-Job postet Status/Updates in den Raum +- **Schreibt in `comm_messages`** — nicht mehr in `ai_proactive_suggestions` (parallel) +- **Push via WebSocket** — nicht mehr SSE +- **Suggestion als Rich Content** — `action_card` Blocks mit Buttons + +### 5.3 Heartbeat + +```python +class AIProactivePlugin(BasePlugin): + manifest = PluginManifest( + ..., + events=["context.view_changed", "context.entity_selected"], + ..., + ) + + async def on_activate(self, db, container, event_bus): + await super().on_activate(db, container, event_bus) + + # 1. Bei kommunikation registrieren + registry = get_participant_registry() + registry.register('ai_proactive', AIProactiveParticipantHandler(self.services)) + + # 2. Heartbeat Job registrieren (ARQ) + # Läuft alle X Minuten, postet Status in "Live KI" Raum + # z.B. "System aktiv — überwacht 3 Kontakte, 2 Vorschläge generiert" + + async def on_deactivate(self, db, container, event_bus): + registry.unregister('ai_proactive') + # Heartbeat Job abmelden + await super().on_deactivate(db, container, event_bus) +``` + +**Heartbeat Job:** +```python +async def heartbeat_job(ctx): + """Läuft alle 5 Minuten. + + - Prüft ob Proactive AI für User aktiviert ist + - Postet Status-Message in "Live KI" Raum: + - Anzahl überwachter Entities + - Anzahl generierter Vorschläge (heute) + - System-Status (aktiv, pausiert, Fehler) + - Wenn neue Vorschläge vorhanden: pusht diese als action_card + """ +``` + +### 5.4 Proactive ParticipantHandler + +```python +class AIProactiveParticipantHandler(ParticipantHandler): + async def on_message_received( + self, conversation_id, message, conversation, mentions, context + ) -> list[dict] | None: + """Reagiert auf Nachrichten im 'Live KI' Raum. + + - User kann Fragen stellen ("Was schlägst du vor?") + - AI Proactive generiert Vorschlag basierend auf aktuellem Kontext + - Antwort als action_card mit Buttons + """ + + def get_participant_info(self) -> dict: + return { + 'display_name': 'Live KI', + 'avatar_url': None, # Bulb-Icon im Frontend + 'capabilities': ['proactive', 'context_aware', 'heartbeat'], + 'description': 'Proaktive KI mit Kontext-Überwachung' + } +``` + +--- + +## 6. Plugin `system_notif` (Neu) + +### 6.1 Verantwortung + +Wandelt System-Events in Nachrichten um. Ersetzt langfristig das bestehende Notification-System. + +### 6.2 Manifest + +```python +PluginManifest( + name="system_notif", + version="1.0.0", + display_name="System Benachrichtigungen", + description="Wandelt System-Events in Chat-Nachrichten um", + dependencies=["kommunikation"], + routes=[], # Keine eigenen Routes — nur Participant + events=[ + "lead.created", "contact.created", "contact.updated", + "task.overdue", "task.created", "mail.received", + "user.created", "workflow.completed", + ], + migrations=["0001_initial.sql"], + permissions=["system_notif:read"], + is_core=False, +) +``` + +### 6.3 Funktionsweise + +```python +class SystemNotificationPlugin(BasePlugin): + async def on_activate(self, db, container, event_bus): + await super().on_activate(db, container, event_bus) + + # 1. Bei kommunikation registrieren + registry = get_participant_registry() + registry.register('system', SystemParticipantHandler()) + + # 2. Auf System-Events hören (bereits via manifest.events) + # 3. Für jeden User: Erstelle gepinnten Raum "System" (locked) + + async def on_lead_created(self, payload): + """lead.created → System-Nachricht""" + # 1. Finde alle User die benachrichtigt werden sollen + # 2. Für jeden User: schreibe Nachricht in System-Raum + # 3. Nachricht als action_card: "Neuer Lead: Acme Corp" + [Öffnen] Button + + async def on_contact_created(self, payload): + """contact.created → System-Nachricht""" + + async def on_task_overdue(self, payload): + """task.overdue → System-Nachricht (warning)""" +``` + +### 6.4 System ParticipantHandler + +```python +class SystemParticipantHandler(ParticipantHandler): + async def on_message_received( + self, conversation_id, message, conversation, mentions, context + ) -> list[dict] | None: + """System ist passiv — liest nur, antwortet nicht. + + Ausnahme: User kann auf Action-Buttons reagieren (archivieren, öffnen). + Diese Reaktionen werden als metadata auf der Nachricht gesetzt, nicht als neue Nachricht. + """ + return None + + def get_participant_info(self) -> dict: + return { + 'display_name': 'System', + 'avatar_url': None, # Bell-Icon im Frontend + 'capabilities': ['notifications', 'action_cards'], + 'description': 'System-Benachrichtigungen' + } +``` + +### 6.5 Migration bestehender Notifications + +- Bestehende `notifications` Tabelle bleibt erhalten (Abwärtskompatibilität) +- Neue System-Events schreiben in `comm_messages` (System-Raum) +- Langfristig: Frontend zeigt nur noch `comm_messages` an, alte Tabelle wird deprecated +- `create_notification()` bleibt unverändert (Core-Code kann Plugin-Code nicht importieren) +- Stattdessen: `create_notification()` publiziert EventBus Event `notification.created` +- `system_notif` Plugin hört auf `notification.created` und schreibt in `comm_messages` +- Keine zirkuläre Abhängigkeit — Core → EventBus → Plugin + +--- + +## 7. Frontend — MessageSidebar + +### 7.1 Struktur + +Die rechte Sidebar wird zur **MessageSidebar** und ersetzt die aktuelle AISidebar: + +``` +┌──────────────────────────────────────────┐ +│ Kommunikation [×] │ +├──────────────────────────────────────────┤ +│ 🔍 Suche... │ +├──────────────────────────────────────────┤ +│ RAUM-LISTE │ +│ 📌 🔒 System │ ← gepinnt + locked (Plugin) +│ 🔔 3 neue Leads importiert │ +│ 📌 🔒 Live KI │ ← gepinnt + locked (Plugin) +│ 🤖 System aktiv — 2 Vorschläge... │ +│ 📌 🔒 Assistent │ ← gepinnt + locked (Plugin) +│ 🤖 47 Kontakte ohne Email... │ +│ ──────────────────────────── │ +│ 👥 Projekt Alpha │ ← User-Raum (nicht gepinnt) +│ Max: @KI fasse die Leads zusammen │ +│ 👥 Sales Team │ +│ Lisa: Q3-Zahlen sind da │ +│ 👤 Max Müller │ ← Direkt-Chat +│ Du: Hast du kurz Zeit? │ +├──────────────────────────────────────────┤ +│ FEED (aktive Konversation) │ +│ ┌────────────────────────────────────┐ │ +│ │ 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] │ │ │ +│ │ └──────────────────────────────┘ │ │ +│ └────────────────────────────────────┘ │ +├──────────────────────────────────────────┤ +│ [📎] [Eingabefeld...] [Senden] │ +└──────────────────────────────────────────┘ +``` + +### 7.2 Komponenten + +``` +frontend/src/components/comm/ +├── MessageSidebar.tsx # Haupt-Komponente (ersetzt AISidebar) +├── ConversationList.tsx # Raum-Liste (links im Panel) +├── ConversationItem.tsx # Einzelne Raum-Zeile +├── MessageFeed.tsx # Nachrichten-Feed (Mitte) +├── MessageBubble.tsx # Einzelne Nachricht +├── MessageInput.tsx # Eingabefeld + Upload + @Mention +├── BlockRenderer.tsx # Rich Content Block Renderer +├── blocks/ +│ ├── MarkdownBlock.tsx +│ ├── HtmlBlock.tsx +│ ├── ImageBlock.tsx +│ ├── AudioBlock.tsx +│ ├── VideoBlock.tsx +│ ├── FileBlock.tsx +│ ├── ActionCardBlock.tsx +│ ├── ContactCardBlock.tsx +│ └── MiniAppBlock.tsx +├── ParticipantAvatars.tsx # Teilnehmer-Avatare im Header +├── ReactionBar.tsx # Emoji-Reaktionen +├── TypingIndicator.tsx # "X tippt..." +└── CreateConversationDialog.tsx # Neue Konversation erstellen +``` + +### 7.3 commStore (neu) + +```typescript +// store/commStore.ts — neuer Store für Communication State +import { create } from 'zustand'; + +export interface CommState { + conversations: Conversation[]; + activeConversationId: string | null; + messages: Record; // conversation_id → messages + typingUsers: Record; // conversation_id → user_ids + unreadCounts: Record; // conversation_id → count + + setConversations: (convs: Conversation[]) => void; + setActiveConversation: (id: string | null) => void; + addMessage: (convId: string, msg: Message) => void; + updateConversation: (conv: Conversation) => void; + setTyping: (convId: string, userIds: string[]) => void; + setUnread: (convId: string, count: number) => void; +} + +export const useCommStore = create((set) => ({ + conversations: [], + activeConversationId: null, + messages: {}, + typingUsers: {}, + unreadCounts: {}, + // ... implementations +})); +``` + +### 7.4 uiStore Anpassung + +```typescript +// uiStore.ts — neue State-Struktur +export type MessageSidebarView = 'rooms' | 'feed'; + +export interface UIState { + // ... bestehende ... + + // Communication + messageSidebarCollapsed: boolean; + messageSidebarView: MessageSidebarView; // 'rooms' = Liste, 'feed' = aktive Konversation + activeConversationId: string | null; + commWebSocket: WebSocket | null; + + toggleMessageSidebar: () => void; + setMessageSidebarView: (view: MessageSidebarView) => void; + setActiveConversation: (id: string | null) => void; +} +``` + +### 7.4 WebSocket Hook + +```typescript +// hooks/useCommWebSocket.ts +export function useCommWebSocket() { + const { activeConversationId, addMessage, updateConversation } = useCommStore(); + + useEffect(() => { + const ws = new WebSocket(`${WS_BASE}/api/v1/comm/ws`); + + ws.onmessage = (event) => { + const data = JSON.parse(event.data); + switch (data.type) { + case 'message.new': + addMessage(data.conversation_id, data.message); + break; + case 'conversation.updated': + updateConversation(data.conversation); + break; + case 'typing': + // Update typing indicator + break; + // ... + } + }; + + return () => ws.close(); + }, []); +} +``` + +### 7.5 API Client + +```typescript +// api/comm.ts +export const commApi = { + // Konversationen + listConversations: () => api.get('/comm/conversations'), + createConversation: (data) => api.post('/comm/conversations', data), + getConversation: (id) => api.get(`/comm/conversations/${id}`), + updateConversation: (id, data) => api.patch(`/comm/conversations/${id}`, data), + pinConversation: (id) => api.post(`/comm/conversations/${id}/pin`), + unpinConversation: (id) => api.delete(`/comm/conversations/${id}/pin`), + muteConversation: (id) => api.post(`/comm/conversations/${id}/mute`), + + // Teilnehmer + addParticipant: (convId, data) => api.post(`/comm/conversations/${convId}/participants`, data), + removeParticipant: (convId, pid) => api.delete(`/comm/conversations/${convId}/participants/${pid}`), + changeRole: (convId, pid, role) => api.patch(`/comm/conversations/${convId}/participants/${pid}`, { role }), + + // Nachrichten + getMessages: (convId, page) => api.get(`/comm/conversations/${convId}/messages`, { params: { page } }), + sendMessage: (convId, data) => api.post(`/comm/conversations/${convId}/messages`, data), + editMessage: (id, data) => api.patch(`/comm/messages/${id}`, data), + deleteMessage: (id) => api.delete(`/comm/messages/${id}`), + + // Attachments + uploadAttachment: (msgId, file) => { + const formData = new FormData(); + formData.append('file', file); + return api.post(`/comm/messages/${msgId}/attachments`, formData); + }, + referenceDmsFile: (msgId, fileId) => api.post(`/comm/messages/${msgId}/attachments`, { file_id: fileId }), + + // Reaktionen + addReaction: (msgId, emoji) => api.post(`/comm/messages/${msgId}/reactions`, { emoji }), + removeReaction: (msgId, emoji) => api.delete(`/comm/messages/${msgId}/reactions/${emoji}`), + + // Read State + markRead: (convId) => api.post(`/comm/conversations/${convId}/read`), + + // Mini-Apps + listMiniApps: () => api.get('/comm/miniapps'), + startMiniApp: (convId, appId, config) => api.post(`/comm/conversations/${convId}/miniapps`, { app_id: appId, config }), +}; +``` + +--- + +## 8. REST API + +### 8.1 Konversationen + +``` +GET /api/v1/comm/conversations + → Liste für aktuellen User (inkl. pinned, locked, unread_count, last_msg) + Query: ?archived=false (default: nur nicht-archivierte) + Response: [{ id, title, is_locked, locked_by, is_direct, is_pinned, + participants: [...], last_msg_preview, last_msg_sender_type, + last_msg_at, unread_count }] + +POST /api/v1/comm/conversations + Body: { title?, participant_ids: [uuid], participant_types: ['user'], + initial_message?, is_direct? } + → Neue Konversation erstellen (User wird automatisch admin) + Permission: comm:create + +GET /api/v1/comm/conversations/{id} + → Details + Teilnehmer-Liste + Rolle des aktuellen Users + +PATCH /api/v1/comm/conversations/{id} + Body: { title?, is_archived? } + → Titel ändern (nicht wenn locked), archivieren + Permission: comm:write + admin role (für title) + +DELETE /api/v1/comm/conversations/{id} + → Konversation löschen (admin) oder verlassen (member) + Permission: comm:delete (admin) oder comm:write (leave) + +POST /api/v1/comm/conversations/{id}/pin + → Für aktuellen User anpinnen + Permission: comm:read + +DELETE /api/v1/comm/conversations/{id}/pin + → Pinning entfernen + +POST /api/v1/comm/conversations/{id}/mute + → Stummschalten für aktuellen User + +DELETE /api/v1/comm/conversations/{id}/mute +``` + +### 8.2 Teilnehmer + +``` +POST /api/v1/comm/conversations/{id}/participants + Body: { participant_id, participant_type: 'user', role: 'member' } + → Teilnehmer hinzufügen (einladen) + Permission: comm:manage + admin role + +DELETE /api/v1/comm/conversations/{id}/participants/{pid} + → Teilnehmer entfernen + Permission: comm:manage + admin role (oder self-leave) + +PATCH /api/v1/comm/conversations/{id}/participants/{pid} + Body: { role: 'admin'|'member'|'reader' } + → Rolle ändern + Permission: comm:admin + admin role +``` + +### 8.3 Nachrichten + +``` +GET /api/v1/comm/conversations/{id}/messages + → Nachrichten (paginiert, 50 pro Seite) + Query: ?page=1&before={msg_id} + Response: { items: [...], total, page, has_more } + Permission: comm:read + participant + +POST /api/v1/comm/conversations/{id}/messages + Body: { content, content_format: 'markdown', + blocks?: [{ block_type, block_data }], + reply_to_id?, attachments?: [{ file_id?, file_source? }] } + → Nachricht senden + Permission: comm:write + participant (admin/member role) + → Triggert EventBus: message.received + → Triggert ParticipantHandler für alle nicht-user Teilnehmer + → Push via WebSocket an alle Teilnehmer + +PATCH /api/v1/comm/messages/{id} + Body: { content?, read? } + → Bearbeiten oder als gelesen markieren + Permission: comm:write (eigene) oder comm:manage (fremde) + +DELETE /api/v1/comm/messages/{id} + → Nachricht löschen (soft delete) + Permission: comm:delete (eigene) oder admin role +``` + +### 8.4 Attachments + +``` +POST /api/v1/comm/messages/{id}/attachments + Body: multipart/form-data (file) ODER JSON ({ file_id, file_source: 'dms' }) + → Datei hochladen (→ DMS Bridge) oder DMS-Datei referenzieren + Permission: comm:write + participant + +GET /api/v1/comm/attachments/{id} + → Datei herunterladen + Permission: comm:read + participant + +GET /api/v1/comm/attachments/{id}/thumbnail + → Thumbnail für Bild/Video +``` + +### 8.5 Reaktionen + +``` +POST /api/v1/comm/messages/{id}/reactions + Body: { emoji } + Permission: comm:write + participant + +DELETE /api/v1/comm/messages/{id}/reactions/{emoji} +``` + +### 8.6 Read State + +``` +POST /api/v1/comm/conversations/{id}/read + Body: { last_read_msg_id } + → Lese-Status aktualisieren + Permission: comm:read + participant +``` + +### 8.7 Mini-Apps + +``` +GET /api/v1/comm/miniapps + → Verfügbare Mini-Apps (von Plugins registriert) + Response: [{ app_id, name, icon, description, plugin_name }] + +POST /api/v1/comm/conversations/{id}/miniapps + Body: { app_id, config? } + → Mini-App in Konversation starten → erzeugt miniapp Block + Permission: comm:write + participant +``` + +### 8.8 WebSocket + +``` +WS /api/v1/comm/ws + → Authentifiziert via Session-Cookie (wie REST-API, Same-Origin) + → Bei Connect: Session validieren, user_id extrahieren, Tenant-Kontext setzen + → Bei ungültiger Session: WebSocket mit Code 4001 geschlossen + → Siehe WebSocket Events oben +``` + +--- + +## 9. Plugin-übergreifende Integration + +### 9.1 EventBus Flow bei neuer Nachricht + +``` +1. User schreibt Nachricht → POST /api/v1/comm/conversations/{id}/messages +2. kommunikation Service: + a. Speichert Nachricht in comm_messages + comm_message_blocks + b. Publiziert EventBus: message.received { conversation_id, message, mentions } + c. Pusht via WebSocket an alle Teilnehmer +3. ParticipantHandler werden aufgerufen: + a. AI ParticipantHandler: @KI erwähnt? → generiere Response → neue comm_message + b. AI Proactive Handler: im Live KI Raum? → generiere Vorschlag → neue comm_message + c. System Handler: passiv → return None +4. Wenn neue Nachricht von ParticipantHandler: → wieder Schritt 2 + WICHTIG: Infinite-Loop-Protection + - Jede Nachricht bekommt metadata['triggered_by'] = original_message_id + - ParticipantHandler prüft: wenn message.id == triggered_by → nicht erneut triggern + - Max depth counter in metadata['trigger_depth'] (default 0, max 3) + - Bei max depth: Nachricht wird gespeichert aber keine Handler mehr getriggert +``` + +### 9.2 Plugin-Raum-Erstellung bei Aktivierung + +``` +Plugin wird aktiviert → on_activate() + → Prüfe ob Plugin-Raum für User existiert + → Wenn nicht: Erstelle Konversation + - title: "System" / "Live KI" / "Assistent" + - is_locked: true + - locked_by: plugin_name + - created_by_type: 'plugin' + - Füge Plugin als Participant hinzu (participant_type) + - Füge User als Participant hinzu (role: 'reader' für system, 'member' für ai) + - Pinne für User + → Wenn ja: nichts tun +``` + +### 9.3 unified_search Integration + +``` +kommunikation Plugin aktiviert → + → Registriert CommSearchProvider bei unified_search + → unified_search indexiert comm_messages + → Suche liefert Ergebnisse aus Konversationen +``` + +### 9.4 DMS Integration + +``` +User lädt Datei im Chat → + → DmsBridge.store_attachment() + → Erstellt/Findet DMS-Ordner _kommunikation/{conversation_id}/ + → Speichert Datei im DMS + → Erstellt comm_message_attachment mit file_id + file_source='comm' + +User referenziert DMS-Datei → + → DmsBridge.reference_external_file(file_id) + → Prüft DMS-Datei existiert + → Erstellt comm_message_attachment mit file_id + file_source='dms' + → Keine Kopie, nur Referenz +``` + +--- + +## 10. Vollständige Checkliste — Was berücksichtigt wurde + +### Core Features +- [x] Einheitliches Messaging-System (ein Plugin, ein Datenmodell) +- [x] KI als Teilnehmer (kein eigener Chat-Typ) +- [x] System als Teilnehmer (Notifications = Nachrichten) +- [x] Externe Messenger als Teilnehmer (erweiterbar) +- [x] Rich Content Transport (Blocks: text, markdown, html, image, audio, video, file, action_card, contact_card, miniapp) +- [x] Mini-Apps (Plugin-basiert, im Chat renderbar) +- [x] Datei-Upload (DMS-Integration mit eigener Datenquelle + externe Referenzen) +- [x] WebSocket (Real-time für alles) +- [x] EventBus Integration (Plugins reagieren auf Nachrichten) + +### Raum-Logik +- [x] Räume = benannte Konversationen (Titel editierbar) +- [x] Pinning (user-spezifisch, pro User) +- [x] Locking (Plugin-Lock: User kann nicht ändern) +- [x] Plugin-erstellte Räume (System, Live KI, Assistent) +- [x] Archivierung (ausgeblendete Konversationen) +- [x] Stummschaltung (mute pro User) + +### KI-Plugins +- [x] ai_assistant als Teilnehmer (ohne eigene Chat-UI) +- [x] ai_proactive als Teilnehmer (ohne eigene UI) +- [x] Heartbeat für ai_proactive (regelmäßiger Background-Job) +- [x] Gepinnter Kanal für proaktive KI +- [x] @KI Mention in jedem Chat +- [x] Streaming über WebSocket +- [x] AI Copilot bleibt parallel (nicht migriert) + +### RBAC +- [x] Anbindung an bestehendes Permission-System (comm:read, comm:write, etc.) +- [x] Konversations-Rollen (admin, member, reader) +- [x] User einladen (admin only) +- [x] Rollen vergeben (admin only) +- [x] System-Permission + Konversations-Permission (zwei-Level Check) +- [x] Locked Räume (User kann Titel/Teilnehmer nicht ändern) + +### DMS +- [x] Eigener DMS-Bereich (_kommunikation/{conversation_id}/) +- [x] Externe DMS-Referenzen (file_source='dms') +- [x] Thumbnail-Generierung für Bilder/Videos + +### Suche +- [x] unified_search Provider für comm_messages + comm_conversations +- [x] Volltext-Suche in Nachrichten +- [x] Konversations-Suche + +### Frontend +- [x] MessageSidebar ersetzt AISidebar +- [x] Raum-Liste (pinned/locked oben, dann normale) +- [x] Unified Feed mit Rich Content Rendering +- [x] Eingabefeld mit Datei-Upload + @Mention +- [x] WebSocket-Verbindung +- [x] Block-Renderer (Markdown, HTML, Image, Audio, Video, File, ActionCard, MiniApp) +- [x] Reaktionen (Emoji-Bar) +- [x] Typing-Indikator +- [x] Lese-Status (gelesen-Häkchen) + +### Migration +- [x] Alte Tabellen bleiben (ai_chat_sessions, ai_conversations, notifications) +- [x] Neue Tabellen parallel (comm_*) +- [x] Schrittweise Migration +- [x] Abwärtskompatibilität + +### Zukünftig +- [x] Messenger-Gateway Plugins (WhatsApp, Telegram, Email) +- [x] Push-Notifications für Mobile (später) +- [x] E2E-Verschlüsselung (später evaluieren) + +--- + +## 11. Was könnte noch fehlen? — Ergänzungen + +### 11.1 Message Threading +- `reply_to_id` ist vorhanden für direkte Antworten +- Echte Thread-Ansicht (Sub-Threads) wäre möglich, aber nicht in Phase 1 +- Empfehlung: Reply-To für Phase 1, Sub-Threads später + +### 11.2 Message Drafts +- Drafts pro Konversation speichern (lokal im Frontend oder serverseitig) +- Empfehlung: Lokal im Frontend (localStorage) für Phase 1 + +### 11.3 Message Forwarding +- Nachricht an andere Konversation weiterleiten +- Empfehlung: Später, einfache Implementierung (kopiere message + blocks) + +### 11.4 Conversation Export +- Konversation als PDF/CSV exportieren +- Empfehlung: Später, über Report Generator Plugin + +### 11.5 User Presence / Online-Status +- Real-time Online-Status über WebSocket +- Empfehlung: Phase 2, über WebSocket presence channel + +### 11.6 Voice Messages +- Audio-Block ist vorhanden, Aufnahme im Frontend +- Empfehlung: Phase 2 (Audio-Block + Frontend Recorder) + +### 11.7 Message Pinning (innerhalb Konversation) +- `is_pinned` auf comm_messages vorhanden +- Wichtige Nachrichten im Feed anpinnen +- Empfehlung: Phase 1 (Feld vorhanden, UI später) + +### 11.8 Conversation Description / Topic +- Raum-Beschreibung / Thema +- Empfehlung: In `metadata` speichern, UI später + +### 11.9 Read Receipts (Detail) +- Wer hat die Nachricht gelesen? +- Empfehlung: Phase 2 (comm_message_reads reicht für Phase 1) + +### 11.10 Message Search (innerhalb Konversation) +- Suche innerhalb einer Konversation +- Empfehlung: Frontend-Filter auf geladene Nachrichten + Server-Suche für ältere + +### 11.11 Notification Preferences pro Konversation +- Stummschaltung ist vorhanden (mute) +- Feinere Einstellungen (nur @Mentions, etc.) +- Empfehlung: Phase 2 + +### 11.12 Group Avatar / Icon +- Konversations-Icon setzen +- Empfehlung: In `metadata.icon` speichern, Frontend später + +### 11.13 Nachrichten-Bearbeitung-Historie +- Neue Tabelle `comm_message_edits` speichert alte Versionen bei Bearbeitung +- Felder: id, message_id, old_content, old_blocks (JSONB), edited_by, edited_at +- Audit-Trail: jede Bearbeitung wird nachvollziehbar + +### 11.14 Rate-Limiting (optionale Konfiguration) +- Konfigurierbares Rate-Limit pro Tenant (z.B. max N Nachrichten pro Minute pro User) +- In den Einstellungen aktivierbar/deaktivierbar +- Default: deaktiviert, kann bei Bedarf eingeschaltet werden + +### 11.15 Datei-Größen-Limit und Referenz-Modus +- Dateien < 100MB: direkter Upload im Chat → DMS unter `_kommunikation/{conversation_id}/` +- Dateien >= 100MB: nur DMS-Referenz (`file_source='dms'`) — kein Upload im Chat +- Frontend zeigt bei großen Dateien einen "Im DMS öffnen" Link statt Download +- `comm_message_attachments.file_source` unterscheidet die Modi + +### 11.16 Konversations-Cover-Bild +- Gruppen-Chats / Räume können ein Cover-Bild haben +- Gespeichert in `metadata.cover_url` +- Frontend zeigt Cover-Bild in Raum-Liste und Konversations-Header + +### 11.17 WebSocket Reconnection +- Frontend auto-reconnect bei Verbindungsabbruch +- Exponential backoff (1s, 2s, 4s, 8s, max 30s) +- Bei Reconnect: Lese-Status synchronisieren, verpasste Nachrichten nachladen + +### 11.18 Offline-Queue (später für Mobile) +- Nachrichten lokal speichern bei Offline-Status +- Bei Reconnect automatisch senden +- Nur für Mobile App relevant, nicht für Desktop + +### 11.19 Datenretention (Einstellungen) +- Keine automatische Löschung in Phase 1 +- Einstellungen pro Tenant: Aufbewahrungszeit konfigurierbar (z.B. 90 Tage, 1 Jahr, unbegrenzt) +- Bereits in Phase 1 als Setting-Feld vorsehen, Funktionalität später + +### 11.20 Konversations-Limit (Einstellungen) +- Kein Limit in Phase 1 +- Einstellungen pro Tenant: maximale Anzahl Konversationen konfigurierbar +- Bereits in Phase 1 als Setting-Feld vorsehen, Funktionalität später + +--- + +## 12. Implementierungs-Phasen + +### Phase 1: Backend — Plugin `kommunikation` (Woche 1-2) +1. Plugin-Gerüst (plugin.py, manifest, __init__.py) +2. Datenmodell (models.py) — alle Tabellen +3. Migration (0001_initial.sql) +4. Participant Registry (participant_registry.py) +5. Services (services.py) — CRUD, Nachrichten senden, Raum-Erstellung +6. RBAC (rbac.py) — Konversations-Rollen, Permission-Checks +7. DMS Bridge (dms_bridge.py) — Attachment-Speicherung +8. WebSocket Manager (websocket_manager.py) +9. REST API (routes.py) — alle Endpoints +10. Mini-App Registry (miniapp_registry.py) +11. Content Types (content_types.py) — Block-Definitionen +12. Search Provider (search_provider.py) + +### Phase 2: Backend — AI Plugins anpassen (Woche 2-3) +1. ai_assistant: ParticipantHandler implementieren +2. ai_assistant: Bei kommunikation registrieren +3. ai_assistant: message.received → AI-Response +4. ai_assistant: Streaming über WebSocket +5. ai_assistant: Gepinnten Raum "Assistent" erstellen +6. ai_proactive: ParticipantHandler implementieren +7. ai_proactive: Bei kommunikation registrieren +8. ai_proactive: Heartbeat Job +9. ai_proactive: Gepinnten Raum "Live KI" erstellen +10. ai_proactive: Suggestion als action_card + +### Phase 3: Backend — Plugin `system_notif` (Woche 3) +1. Plugin-Gerüst +2. ParticipantHandler (passiv) +3. Event-Handler (lead.created, contact.created, etc.) +4. Gepinnten Raum "System" erstellen +5. Bestehende Notifications migrieren (parallel) + +### Phase 4: Frontend — MessageSidebar (Woche 3-4) +1. MessageSidebar-Komponente (ersetzt AISidebar) +2. ConversationList (Raum-Liste mit Pinning/Locking) +3. MessageFeed (Nachrichten-Feed) +4. MessageInput (Eingabefeld + Upload + @Mention) +5. WebSocket Hook +6. API Client (comm.ts) +7. uiStore Anpassung +8. Alte AISidebar entfernen + +### Phase 5: Frontend — Rich Content Renderer (Woche 4-5) +1. BlockRenderer Haupt-Komponente +2. MarkdownBlock, HtmlBlock +3. ImageBlock, AudioBlock, VideoBlock, FileBlock +4. ActionCardBlock (mit Button-Handler) +5. ContactCardBlock +6. MiniAppBlock (Plugin-basiert) +7. ReactionBar +8. TypingIndicator +9. ParticipantAvatars + +### Phase 5.5: Daten-Migration bestehender Chats (Woche 5) +1. Migration-Script: ai_chat_sessions → comm_conversations (1:1 mit AI) +2. Migration-Script: ai_chat_messages → comm_messages (sender_type='user'/'ai') +3. Migration-Script: ai_chat_attachments → comm_message_attachments +4. Migration-Script: notifications → comm_messages (System-Raum, sender_type='system') +5. Migration-Script: ai_proactive_suggestions → comm_messages (Live KI Raum, als action_card) +6. Flag in metadata: `{"migrated_from": "ai_chat_sessions"}` für Nachverfolgung +7. Alte Tabellen bleiben erhalten (kein Datenverlust) + +### Phase 6: Testing & Deployment (Woche 5) +1. Backend Tests (API, WebSocket, RBAC, Participant Registry) +2. Frontend Tests (MessageSidebar, Block Renderer) +3. Integration Tests (AI Response, System Notifications) +4. Deployment (Coolify) + +### Phase 7: Messenger-Gateway Plugins (später) +1. whatsapp_gateway Plugin +2. telegram_gateway Plugin +3. email_gateway Plugin + +### Phase 8: Mobile & Push (später) +1. Push-Notification Service +2. Mobile App API +3. E2E-Verschlüsselung (evaluieren) + +--- + +## 13. Zusammenfassung + +``` +Ein Plugin (kommunikation) → Chat-Infrastruktur + Rich Content + WebSocket + RBAC + DMS +Ein Interface (ParticipantHandler) → Plugins docken als Teilnehmer an +Ein Datenmodell (10 Tabellen) → Conversations, Participants, Messages, Blocks, Attachments, Reactions, Reads, Pins +Eine UI (MessageSidebar) → Raum-Liste + Feed + Eingabefeld +Eine WebSocket → Real-time für alles +Ein EventBus → Plugins reagieren auf Nachrichten + +KI = Teilnehmer → @KI in jedem Chat, keine eigene UI +Proactive KI = Teilnehmer → Heartbeat + gepinnter Kanal, keine eigene UI +System = Teilnehmer → Notifications als Nachrichten, locked Raum +WhatsApp = Teilnehmer → Externe Messenger andocken (später) +Mini-Apps = Plugin-Blocks → Erweiterbar im Chat +Räume = Benannte Chats → Titel + Pinning + Locking +RBAC = Zwei-Level → System-Permission + Konversations-Rolle +DMS = Bridge → Eigener Bereich + externe Referenzen +Suche = Provider → unified_search Integration +``` diff --git a/docs/konzept-unified-messaging.md b/docs/konzept-unified-messaging.md new file mode 100644 index 0000000..f77b3a1 --- /dev/null +++ b/docs/konzept-unified-messaging.md @@ -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": "
...
"}` | +| `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 +``` diff --git a/frontend/src/api/comm.ts b/frontend/src/api/comm.ts new file mode 100644 index 0000000..9067e00 --- /dev/null +++ b/frontend/src/api/comm.ts @@ -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; +} + +export interface MessageBlock { + id: string; + block_type: string; + block_data: Record; + 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; + 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; +} + +export interface MiniApp { + id: string; + name: string; + description: string; + config_schema: Record; +} + +// ─── Conversations ─── + +export const listConversations = (archived?: boolean) => + apiGet('/comm/conversations', { + params: archived !== undefined ? { archived } : {}, + }); + +export const createConversation = (data: { + title?: string; + participant_ids?: string[]; + is_direct?: boolean; + initial_message?: string; +}) => apiPost('/comm/conversations', data); + +export const getConversation = (id: string) => + apiGet(`/comm/conversations/${id}`); + +export const updateConversation = (id: string, data: { title?: string; is_archived?: boolean }) => + apiPatch(`/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(`/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(`/comm/conversations/${convId}/participants/${pid}`, { role }); + +// ─── Messages ─── + +export const getMessages = (convId: string, page = 1, pageSize = 50, before?: string) => + apiGet(`/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 }>; + reply_to_id?: string; + attachments?: Array>; + }, +) => apiPost(`/comm/conversations/${convId}/messages`, data); + +export const editMessage = (id: string, data: { content?: string }) => + apiPatch(`/comm/messages/${id}`, data); + +export const deleteMessage = (id: string) => + apiDelete<{ success: boolean }>(`/comm/messages/${id}`); + +// ─── Reactions ─── + +export const addReaction = (msgId: string, emoji: string) => + apiPost(`/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) => + apiPost(`/comm/conversations/${convId}/miniapps`, { + app_id: appId, + config: config || {}, + }); + +// ─── Block Types ─── + +export const getBlockTypes = () => + apiGet<{ items: BlockType[] }>('/comm/block-types'); diff --git a/frontend/src/components/comm/blocks/ActionCardBlock.tsx b/frontend/src/components/comm/blocks/ActionCardBlock.tsx new file mode 100644 index 0000000..24cd3ce --- /dev/null +++ b/frontend/src/components/comm/blocks/ActionCardBlock.tsx @@ -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 = ({ 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 ( +
+ {cardTitle && ( +

{cardTitle}

+ )} + {cardBody && ( +

{cardBody}

+ )} + {actionList.length > 0 && ( +
+ {actionList.map((action, idx) => { + const isPrimary = action.type !== 'secondary'; + return ( + + ); + })} +
+ )} +
+ ); +}; + +export default ActionCardBlock; diff --git a/frontend/src/components/comm/blocks/AudioBlock.tsx b/frontend/src/components/comm/blocks/AudioBlock.tsx new file mode 100644 index 0000000..99d248a --- /dev/null +++ b/frontend/src/components/comm/blocks/AudioBlock.tsx @@ -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 = ({ block }) => { + const { url, duration } = block.block_data; + + if (!url) { + return null; + } + + const durationLabel = formatDuration(duration); + + return ( +
+
+ ); +}; + +export default AudioBlock; diff --git a/frontend/src/components/comm/blocks/BlockRenderer.tsx b/frontend/src/components/comm/blocks/BlockRenderer.tsx new file mode 100644 index 0000000..e93fd70 --- /dev/null +++ b/frontend/src/components/comm/blocks/BlockRenderer.tsx @@ -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 = ({ 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 ( +
+ {sortedBlocks.map((block) => { + const renderBlock = (): React.ReactNode => { + switch (block.block_type) { + case 'text': + // Text blocks: render as plain text in a styled div + return ( +
+ {block.block_data.text || block.block_data.content || ''} +
+ ); + case 'markdown': + return ; + case 'html': + return ; + case 'image': + return ; + case 'audio': + return ; + case 'video': + return ; + case 'file': + return ; + case 'action_card': + return ; + case 'contact_card': + return ; + case 'miniapp': + return ; + default: + // Fallback for unknown block types + return ( +
+ Unbekannter Block-Typ: {block.block_type} +
+ ); + } + }; + + return ( +
+ {renderBlock()} +
+ ); + })} +
+ ); +}; + +export default BlockRenderer; diff --git a/frontend/src/components/comm/blocks/ContactCardBlock.tsx b/frontend/src/components/comm/blocks/ContactCardBlock.tsx new file mode 100644 index 0000000..68ff166 --- /dev/null +++ b/frontend/src/components/comm/blocks/ContactCardBlock.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import type { MessageBlock } from '@/store/commStore'; + +interface ContactCardBlockProps { + block: MessageBlock; +} + +const ContactCardBlock: React.FC = ({ 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 ( + +
+ {initials || '?'} +
+
+

{contactName}

+

Kontakt anzeigen

+
+ +
+ ); +}; + +export default ContactCardBlock; diff --git a/frontend/src/components/comm/blocks/FileBlock.tsx b/frontend/src/components/comm/blocks/FileBlock.tsx new file mode 100644 index 0000000..02c4244 --- /dev/null +++ b/frontend/src/components/comm/blocks/FileBlock.tsx @@ -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 = ( + +); + +const downloadIcon = ( + +); + +const FileBlock: React.FC = ({ block }) => { + const { url, name, size } = block.block_data; + + if (!url && !name) { + return null; + } + + const fileName: string = name || 'Datei'; + const fileSizeLabel = formatFileSize(size); + + return ( +
+
{fileIcon}
+
+

{fileName}

+ {fileSizeLabel && ( +

{fileSizeLabel}

+ )} +
+ {url && ( + + {downloadIcon} + + )} +
+ ); +}; + +export default FileBlock; diff --git a/frontend/src/components/comm/blocks/HtmlBlock.tsx b/frontend/src/components/comm/blocks/HtmlBlock.tsx new file mode 100644 index 0000000..f5feb0d --- /dev/null +++ b/frontend/src/components/comm/blocks/HtmlBlock.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import type { MessageBlock } from '@/store/commStore'; + +interface HtmlBlockProps { + block: MessageBlock; +} + +/** + * Basic HTML sanitization: removes blocks (including content) + sanitized = sanitized.replace(/)<[^<]*)*<\/script>/gi, ''); + // Remove