feat: Unified Messaging System — kommunikation plugin, AI/Proactive/System participants, MessageSidebar, Rich Content Renderer
Phase 1: Backend plugin kommunikation (13 files, 10 tables, REST API, WebSocket, RBAC, DMS Bridge, Participant Registry, Mini-App Registry, Search Provider) Phase 2: AI plugins as participants (ai_assistant + ai_proactive dock as participants, heartbeat job) Phase 3: system_notif plugin (system events → chat messages, pinned System room) Phase 4: Frontend MessageSidebar (replaces AISidebar, same design, comm API client, WebSocket hook, commStore) Phase 5: Rich Content Block Renderer (11 components: Markdown, HTML, Image, Audio, Video, File, ActionCard, ContactCard, MiniApp, BlockRenderer) BasePlugin: added services property + _container in on_activate
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
"""AI Participant Handler — bridges the kommunikation plugin with the AI Assistant.
|
||||
|
||||
When a message is received in a conversation that includes the 'ai' participant,
|
||||
this handler generates an LLM response using litellm.acompletion (non-streaming)
|
||||
and returns it as a new message in the conversation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import litellm
|
||||
|
||||
from app.core.db import create_db_session
|
||||
from app.plugins.builtins.kommunikation.participant_registry import ParticipantHandler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AIParticipantHandler(ParticipantHandler):
|
||||
"""Handles AI responses as a participant in kommunikation conversations."""
|
||||
|
||||
def __init__(self, container: Any) -> None:
|
||||
self._container = container
|
||||
|
||||
async def on_message_received(
|
||||
self,
|
||||
conversation_id: Any,
|
||||
message: dict[str, Any],
|
||||
conversation: dict[str, Any],
|
||||
mentions: list[str],
|
||||
context: dict[str, Any],
|
||||
) -> list[dict[str, Any]] | None:
|
||||
"""Generate an AI response when the AI is mentioned or in a direct chat.
|
||||
|
||||
Checks:
|
||||
1. 'ai' is in the conversation participants as a participant_type
|
||||
2. '@KI' is in mentions OR the conversation is_direct with only user+ai
|
||||
|
||||
Returns a list with one message dict containing the AI response.
|
||||
"""
|
||||
# Check if 'ai' is a participant in this conversation
|
||||
participants = conversation.get("participants", [])
|
||||
ai_is_participant = any(
|
||||
p.get("participant_type") == "ai" for p in participants
|
||||
)
|
||||
if not ai_is_participant:
|
||||
return None
|
||||
|
||||
# Check if AI is mentioned or it's a direct chat with only user + ai
|
||||
ai_mentioned = "KI" in mentions or "ai" in mentions
|
||||
is_direct = conversation.get("is_direct", False)
|
||||
|
||||
if is_direct:
|
||||
# For direct chats, check that only user and ai are participants
|
||||
non_system_participants = [
|
||||
p for p in participants
|
||||
if p.get("participant_type") in ("user", "ai")
|
||||
]
|
||||
if len(non_system_participants) <= 2:
|
||||
ai_mentioned = True
|
||||
|
||||
if not ai_mentioned:
|
||||
return None
|
||||
|
||||
# Don't respond to our own messages
|
||||
if message.get("sender_type") == "ai":
|
||||
return None
|
||||
|
||||
# Get tenant_id and user_id from context
|
||||
tenant_id_str = context.get("tenant_id") or message.get("tenant_id")
|
||||
if not tenant_id_str:
|
||||
logger.warning("AIParticipantHandler: missing tenant_id in context")
|
||||
return None
|
||||
|
||||
try:
|
||||
tenant_id = uuid.UUID(str(tenant_id_str))
|
||||
except (ValueError, TypeError):
|
||||
logger.warning("AIParticipantHandler: invalid tenant_id: %s", tenant_id_str)
|
||||
return None
|
||||
|
||||
# Build messages from conversation history and generate response
|
||||
try:
|
||||
from app.plugins.builtins.ai_assistant.services import (
|
||||
build_litellm_params,
|
||||
get_default_agent,
|
||||
)
|
||||
|
||||
async with create_db_session(tenant_id) as db:
|
||||
# Get default agent for system prompt and preset configuration
|
||||
agent = await get_default_agent(db, tenant_id)
|
||||
|
||||
# Build message history from conversation messages
|
||||
messages = await self._build_message_history(
|
||||
db, conversation_id, tenant_id, message
|
||||
)
|
||||
|
||||
if agent:
|
||||
params, model_id = await build_litellm_params(
|
||||
db, agent, messages, tenant_id
|
||||
)
|
||||
else:
|
||||
# Fallback: use default provider without agent
|
||||
from app.plugins.builtins.ai_assistant.services import (
|
||||
get_default_provider,
|
||||
)
|
||||
|
||||
provider = await get_default_provider(db, tenant_id)
|
||||
if not provider:
|
||||
return [
|
||||
{
|
||||
"content": "Kein AI-Provider konfiguriert. Bitte konfigurieren Sie einen Provider in den KI-Einstellungen.",
|
||||
"content_format": "text",
|
||||
}
|
||||
]
|
||||
|
||||
model_id = "gpt-4o-mini"
|
||||
litellm_model = f"{provider.provider_type}/{model_id}"
|
||||
params: dict[str, Any] = {
|
||||
"model": litellm_model,
|
||||
"messages": messages,
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 2048,
|
||||
"stream": False,
|
||||
}
|
||||
if provider.api_key:
|
||||
params["api_key"] = provider.api_key
|
||||
if provider.base_url:
|
||||
params["api_base"] = provider.base_url
|
||||
|
||||
# Ensure non-streaming for acompletion
|
||||
params["stream"] = False
|
||||
|
||||
response = await litellm.acompletion(**params)
|
||||
response_text = response.choices[0].message.content or ""
|
||||
|
||||
if not response_text.strip():
|
||||
response_text = "*(keine Antwort generiert)*"
|
||||
|
||||
return [
|
||||
{
|
||||
"content": response_text,
|
||||
"content_format": "markdown",
|
||||
}
|
||||
]
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception("AIParticipantHandler: error generating AI response")
|
||||
return [
|
||||
{
|
||||
"content": f"Fehler bei der KI-Antwort: {exc}",
|
||||
"content_format": "text",
|
||||
}
|
||||
]
|
||||
|
||||
async def _build_message_history(
|
||||
self,
|
||||
db: Any,
|
||||
conversation_id: Any,
|
||||
tenant_id: uuid.UUID,
|
||||
current_message: dict[str, Any],
|
||||
) -> list[dict[str, str]]:
|
||||
"""Build a messages array from the conversation history for the LLM."""
|
||||
from app.plugins.builtins.kommunikation.services import get_messages
|
||||
|
||||
messages: list[dict[str, str]] = []
|
||||
|
||||
# Get conversation history (last 50 messages)
|
||||
try:
|
||||
result = await get_messages(
|
||||
db, tenant_id, uuid.UUID(str(conversation_id)),
|
||||
page=1, page_size=50,
|
||||
)
|
||||
items = result.get("items", [])
|
||||
for item in items:
|
||||
role = "assistant" if item.get("sender_type") == "ai" else "user"
|
||||
content = item.get("content", "")
|
||||
if content:
|
||||
messages.append({"role": role, "content": content})
|
||||
except Exception:
|
||||
logger.debug("Could not load conversation history, using current message only")
|
||||
|
||||
# Ensure the current message is included
|
||||
current_content = current_message.get("content", "")
|
||||
if current_content and (
|
||||
not messages
|
||||
or messages[-1].get("content") != current_content
|
||||
):
|
||||
messages.append({"role": "user", "content": current_content})
|
||||
|
||||
return messages
|
||||
|
||||
async def handle_event(self, payload: dict[str, Any]) -> None:
|
||||
"""Handle a message.received event from the event bus.
|
||||
|
||||
Extracts conversation_id from the payload, loads the conversation,
|
||||
and calls on_message_received. If a response is generated, sends it
|
||||
back to the conversation.
|
||||
"""
|
||||
conversation_id_str = payload.get("conversation_id")
|
||||
tenant_id_str = payload.get("tenant_id")
|
||||
message_content = payload.get("content", "")
|
||||
message_id = payload.get("message_id")
|
||||
sender_type = payload.get("sender_type", "user")
|
||||
|
||||
if not conversation_id_str or not tenant_id_str:
|
||||
logger.warning("AIParticipantHandler.handle_event: missing conversation_id or tenant_id")
|
||||
return
|
||||
|
||||
try:
|
||||
tenant_id = uuid.UUID(str(tenant_id_str))
|
||||
conversation_id = uuid.UUID(str(conversation_id_str))
|
||||
except (ValueError, TypeError):
|
||||
logger.warning("AIParticipantHandler.handle_event: invalid UUID in payload")
|
||||
return
|
||||
|
||||
# Build the message dict
|
||||
message: dict[str, Any] = {
|
||||
"id": message_id,
|
||||
"content": message_content,
|
||||
"sender_type": sender_type,
|
||||
"tenant_id": tenant_id_str,
|
||||
}
|
||||
|
||||
# Load conversation
|
||||
try:
|
||||
from app.plugins.builtins.kommunikation.services import get_conversation
|
||||
|
||||
async with create_db_session(tenant_id) as db:
|
||||
# We need a user_id to load the conversation — use the sender_id from payload
|
||||
user_id_str = payload.get("sender_id")
|
||||
if not user_id_str:
|
||||
logger.warning("AIParticipantHandler.handle_event: missing sender_id")
|
||||
return
|
||||
|
||||
user_id = uuid.UUID(str(user_id_str))
|
||||
conversation = await get_conversation(db, tenant_id, conversation_id, user_id)
|
||||
|
||||
if not conversation:
|
||||
logger.warning("AIParticipantHandler.handle_event: conversation not found")
|
||||
return
|
||||
|
||||
# Parse mentions from message content
|
||||
from app.plugins.builtins.kommunikation.services import parse_mentions
|
||||
|
||||
mentions = parse_mentions(message_content)
|
||||
|
||||
context: dict[str, Any] = {
|
||||
"tenant_id": tenant_id_str,
|
||||
"user_id": user_id_str,
|
||||
}
|
||||
|
||||
# Call on_message_received
|
||||
response_messages = await self.on_message_received(
|
||||
conversation_id, message, conversation, mentions, context
|
||||
)
|
||||
|
||||
# If we got a response, send it to the conversation
|
||||
if response_messages:
|
||||
from app.plugins.builtins.kommunikation.services import send_message
|
||||
|
||||
for resp_msg in response_messages:
|
||||
await send_message(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
conversation_id=conversation_id,
|
||||
sender_id=None,
|
||||
sender_type="ai",
|
||||
content=resp_msg.get("content", ""),
|
||||
content_format=resp_msg.get("content_format", "text"),
|
||||
blocks=resp_msg.get("blocks"),
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
except Exception:
|
||||
logger.exception("AIParticipantHandler.handle_event: error processing event")
|
||||
|
||||
def get_participant_info(self) -> dict[str, Any]:
|
||||
"""Return metadata about this participant."""
|
||||
return {
|
||||
"display_name": "KI Assistent",
|
||||
"capabilities": ["chat", "tools", "streaming"],
|
||||
"description": "KI Assistent für Chat und Tool-Nutzung",
|
||||
}
|
||||
@@ -42,12 +42,78 @@ class AIAssistantPlugin(BasePlugin):
|
||||
is_core=True,
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._ai_handler = None
|
||||
self._msg_handler = None
|
||||
|
||||
async def on_install(self, db, service_container) -> None:
|
||||
"""Seed default provider and agent."""
|
||||
from app.plugins.builtins.ai_assistant.services import seed_defaults
|
||||
|
||||
await seed_defaults(db)
|
||||
|
||||
async def on_activate(self, db, service_container, event_bus) -> None:
|
||||
"""Activate plugin: register context tools and participant handler."""
|
||||
await super().on_activate(db, service_container, event_bus)
|
||||
|
||||
# Register as participant in the kommunikation system
|
||||
try:
|
||||
from app.plugins.builtins.ai_assistant.participant_handler import (
|
||||
AIParticipantHandler,
|
||||
)
|
||||
from app.plugins.builtins.kommunikation.participant_registry import (
|
||||
get_participant_registry,
|
||||
)
|
||||
|
||||
self._ai_handler = AIParticipantHandler(service_container)
|
||||
get_participant_registry().register("ai", self._ai_handler)
|
||||
logger.info("AI Assistant registered as participant 'ai'")
|
||||
except Exception:
|
||||
logger.exception("Failed to register AI Assistant as participant")
|
||||
|
||||
# Subscribe to message.received events
|
||||
try:
|
||||
self._msg_handler = self._on_message_received
|
||||
event_bus.subscribe("message.received", self._msg_handler)
|
||||
logger.info("AI Assistant subscribed to message.received events")
|
||||
except Exception:
|
||||
logger.exception("Failed to subscribe to message.received events")
|
||||
|
||||
async def _on_message_received(self, payload: dict[str, Any]) -> None:
|
||||
"""Handle message.received event by delegating to the AI participant handler."""
|
||||
if self._ai_handler is None:
|
||||
return
|
||||
try:
|
||||
await self._ai_handler.handle_event(payload)
|
||||
except Exception:
|
||||
logger.exception("Error in AI participant handler for message.received")
|
||||
|
||||
async def on_deactivate(self, db, service_container, event_bus) -> None:
|
||||
"""Deactivate plugin: unregister participant and event subscriptions."""
|
||||
# Unregister from participant registry
|
||||
try:
|
||||
from app.plugins.builtins.kommunikation.participant_registry import (
|
||||
get_participant_registry,
|
||||
)
|
||||
|
||||
get_participant_registry().unregister("ai")
|
||||
logger.info("AI Assistant unregistered as participant 'ai'")
|
||||
except Exception:
|
||||
logger.exception("Failed to unregister AI Assistant as participant")
|
||||
|
||||
# Unsubscribe from message.received events
|
||||
if self._msg_handler:
|
||||
try:
|
||||
event_bus.unsubscribe("message.received", self._msg_handler)
|
||||
except Exception:
|
||||
logger.exception("Failed to unsubscribe from message.received events")
|
||||
self._msg_handler = None
|
||||
|
||||
self._ai_handler = None
|
||||
|
||||
await super().on_deactivate(db, service_container, event_bus)
|
||||
|
||||
def get_notification_types(self) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -314,3 +314,84 @@ async def deep_analysis(
|
||||
entity_type,
|
||||
entity_id,
|
||||
)
|
||||
|
||||
|
||||
# ─── Heartbeat Job ───
|
||||
|
||||
|
||||
async def heartbeat(ctx: dict[str, Any], user_id: str, tenant_id: str) -> None:
|
||||
"""Heartbeat job for the AI Proactive plugin.
|
||||
|
||||
Runs every 5 minutes (scheduled by the plugin on activation).
|
||||
Posts a status message to the 'Live KI' room in the kommunikation system.
|
||||
"""
|
||||
try:
|
||||
uid = uuid.UUID(user_id)
|
||||
tid = uuid.UUID(tenant_id)
|
||||
except (ValueError, TypeError):
|
||||
logger.warning("heartbeat: invalid UUID parameters (user_id=%s, tenant_id=%s)", user_id, tenant_id)
|
||||
return
|
||||
|
||||
try:
|
||||
from app.core.db import create_db_session
|
||||
from app.plugins.builtins.kommunikation.services import (
|
||||
create_plugin_room,
|
||||
send_message,
|
||||
)
|
||||
|
||||
async with create_db_session(tid) as db:
|
||||
# Create or get the 'Live KI' room for this user
|
||||
room = await create_plugin_room(
|
||||
db,
|
||||
tid,
|
||||
uid,
|
||||
plugin_name="ai_proactive",
|
||||
title="Live KI",
|
||||
participant_type="ai_proactive",
|
||||
user_role="member",
|
||||
)
|
||||
|
||||
conversation_id_str = room.get("id")
|
||||
if not conversation_id_str:
|
||||
logger.warning("heartbeat: could not create/get Live KI room")
|
||||
return
|
||||
|
||||
conversation_id = uuid.UUID(conversation_id_str)
|
||||
|
||||
# Gather some stats for the status message
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.models.contact import Contact
|
||||
|
||||
contact_count_result = await db.execute(
|
||||
select(func.count()).select_from(Contact).where(
|
||||
Contact.tenant_id == tid,
|
||||
Contact.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
contact_count = contact_count_result.scalar() or 0
|
||||
|
||||
# Build status message
|
||||
from datetime import datetime, timezone
|
||||
|
||||
now_str = datetime.now(timezone.utc).strftime("%H:%M:%S")
|
||||
status_content = (
|
||||
f"**System aktiv** — überwacht {contact_count} Kontakte\n"
|
||||
f"_Letztes Update: {now_str}_"
|
||||
)
|
||||
|
||||
await send_message(
|
||||
db=db,
|
||||
tenant_id=tid,
|
||||
conversation_id=conversation_id,
|
||||
sender_id=None,
|
||||
sender_type="ai_proactive",
|
||||
content=status_content,
|
||||
content_format="markdown",
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
logger.info("heartbeat: posted status to Live KI room for user %s", user_id)
|
||||
|
||||
except Exception:
|
||||
logger.exception("heartbeat: error posting status message")
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
"""AI Proactive Participant Handler — bridges the kommunikation plugin with the proactive AI.
|
||||
|
||||
When a message is received in a conversation that includes the 'ai_proactive' participant,
|
||||
this handler generates a proactive suggestion based on the message context.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from app.core.db import create_db_session
|
||||
from app.plugins.builtins.kommunikation.participant_registry import ParticipantHandler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AIProactiveParticipantHandler(ParticipantHandler):
|
||||
"""Handles proactive AI suggestions as a participant in kommunikation conversations."""
|
||||
|
||||
def __init__(self, container: Any) -> None:
|
||||
self._container = container
|
||||
|
||||
async def on_message_received(
|
||||
self,
|
||||
conversation_id: Any,
|
||||
message: dict[str, Any],
|
||||
conversation: dict[str, Any],
|
||||
mentions: list[str],
|
||||
context: dict[str, Any],
|
||||
) -> list[dict[str, Any]] | None:
|
||||
"""Generate a proactive suggestion when ai_proactive is a participant.
|
||||
|
||||
Checks if 'ai_proactive' is in the conversation participants as a
|
||||
participant_type. If yes, generates a suggestion based on the message.
|
||||
|
||||
Returns a list with one message dict containing the suggestion as
|
||||
an action card, or None on error.
|
||||
"""
|
||||
# Check if 'ai_proactive' is a participant in this conversation
|
||||
participants = conversation.get("participants", [])
|
||||
ai_proactive_is_participant = any(
|
||||
p.get("participant_type") == "ai_proactive" for p in participants
|
||||
)
|
||||
if not ai_proactive_is_participant:
|
||||
return None
|
||||
|
||||
# Don't respond to our own messages
|
||||
if message.get("sender_type") == "ai_proactive":
|
||||
return None
|
||||
|
||||
# Get tenant_id and user_id from context
|
||||
tenant_id_str = context.get("tenant_id") or message.get("tenant_id")
|
||||
user_id_str = context.get("user_id")
|
||||
|
||||
if not tenant_id_str:
|
||||
logger.warning("AIProactiveParticipantHandler: missing tenant_id in context")
|
||||
return None
|
||||
|
||||
try:
|
||||
tenant_id = uuid.UUID(str(tenant_id_str))
|
||||
user_id = uuid.UUID(str(user_id_str)) if user_id_str else None
|
||||
except (ValueError, TypeError):
|
||||
logger.warning("AIProactiveParticipantHandler: invalid UUID in context")
|
||||
return None
|
||||
|
||||
# Generate suggestion using the existing generate_suggestion function
|
||||
try:
|
||||
from app.plugins.builtins.ai_proactive.services import (
|
||||
generate_suggestion,
|
||||
get_user_settings,
|
||||
)
|
||||
|
||||
async with create_db_session(tenant_id) as db:
|
||||
# Get user settings for the proactive AI
|
||||
if user_id:
|
||||
settings = await get_user_settings(db, tenant_id, user_id)
|
||||
else:
|
||||
# Create minimal default settings if no user_id
|
||||
from app.plugins.builtins.ai_proactive.models import ProactiveSettings
|
||||
|
||||
settings = ProactiveSettings(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id or uuid.uuid4(),
|
||||
enabled=True,
|
||||
suggestion_categories=["mail", "tasks", "contacts", "companies", "insights"],
|
||||
confidence_threshold=0.5,
|
||||
rate_limit_seconds=10,
|
||||
model="ollama/deepseek-v4-flash",
|
||||
)
|
||||
|
||||
if not settings.enabled:
|
||||
return None
|
||||
|
||||
# Build context data from the message
|
||||
context_data: dict[str, Any] = {
|
||||
"entity_type": "message",
|
||||
"message": message.get("content", ""),
|
||||
"conversation_id": str(conversation_id),
|
||||
"sender_type": message.get("sender_type", "user"),
|
||||
}
|
||||
|
||||
# Generate suggestion via LLM
|
||||
suggestion = await generate_suggestion(
|
||||
context_data, settings, db=db, tenant_id=tenant_id
|
||||
)
|
||||
|
||||
if not suggestion:
|
||||
return None
|
||||
|
||||
# Build the response message with an action card block
|
||||
suggestion_text = suggestion.get("content", "")
|
||||
title = suggestion.get("title", "KI Vorschlag")
|
||||
suggestion_type = suggestion.get("suggestion_type", "info")
|
||||
confidence = suggestion.get("confidence", 0.5)
|
||||
actions = suggestion.get("actions", [])
|
||||
|
||||
return [
|
||||
{
|
||||
"content": suggestion_text,
|
||||
"content_format": "markdown",
|
||||
"blocks": [
|
||||
{
|
||||
"block_type": "action_card",
|
||||
"block_data": {
|
||||
"title": title,
|
||||
"suggestion_type": suggestion_type,
|
||||
"confidence": confidence,
|
||||
"actions": actions,
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
except Exception:
|
||||
logger.exception("AIProactiveParticipantHandler: error generating suggestion")
|
||||
return None
|
||||
|
||||
async def handle_event(self, payload: dict[str, Any]) -> None:
|
||||
"""Handle a message.received event from the event bus.
|
||||
|
||||
Extracts conversation_id from the payload, loads the conversation,
|
||||
and calls on_message_received. If a response is generated, sends it
|
||||
back to the conversation.
|
||||
"""
|
||||
conversation_id_str = payload.get("conversation_id")
|
||||
tenant_id_str = payload.get("tenant_id")
|
||||
message_content = payload.get("content", "")
|
||||
message_id = payload.get("message_id")
|
||||
sender_type = payload.get("sender_type", "user")
|
||||
sender_id_str = payload.get("sender_id")
|
||||
|
||||
if not conversation_id_str or not tenant_id_str:
|
||||
logger.warning("AIProactiveParticipantHandler.handle_event: missing conversation_id or tenant_id")
|
||||
return
|
||||
|
||||
try:
|
||||
tenant_id = uuid.UUID(str(tenant_id_str))
|
||||
conversation_id = uuid.UUID(str(conversation_id_str))
|
||||
except (ValueError, TypeError):
|
||||
logger.warning("AIProactiveParticipantHandler.handle_event: invalid UUID in payload")
|
||||
return
|
||||
|
||||
# Build the message dict
|
||||
message: dict[str, Any] = {
|
||||
"id": message_id,
|
||||
"content": message_content,
|
||||
"sender_type": sender_type,
|
||||
"tenant_id": tenant_id_str,
|
||||
}
|
||||
|
||||
# Load conversation
|
||||
try:
|
||||
from app.plugins.builtins.kommunikation.services import get_conversation
|
||||
|
||||
async with create_db_session(tenant_id) as db:
|
||||
if not sender_id_str:
|
||||
logger.warning("AIProactiveParticipantHandler.handle_event: missing sender_id")
|
||||
return
|
||||
|
||||
user_id = uuid.UUID(str(sender_id_str))
|
||||
conversation = await get_conversation(db, tenant_id, conversation_id, user_id)
|
||||
|
||||
if not conversation:
|
||||
logger.warning("AIProactiveParticipantHandler.handle_event: conversation not found")
|
||||
return
|
||||
|
||||
# Parse mentions from message content
|
||||
from app.plugins.builtins.kommunikation.services import parse_mentions
|
||||
|
||||
mentions = parse_mentions(message_content)
|
||||
|
||||
context: dict[str, Any] = {
|
||||
"tenant_id": tenant_id_str,
|
||||
"user_id": sender_id_str,
|
||||
}
|
||||
|
||||
# Call on_message_received
|
||||
response_messages = await self.on_message_received(
|
||||
conversation_id, message, conversation, mentions, context
|
||||
)
|
||||
|
||||
# If we got a response, send it to the conversation
|
||||
if response_messages:
|
||||
from app.plugins.builtins.kommunikation.services import send_message
|
||||
|
||||
for resp_msg in response_messages:
|
||||
await send_message(
|
||||
db=db,
|
||||
tenant_id=tenant_id,
|
||||
conversation_id=conversation_id,
|
||||
sender_id=None,
|
||||
sender_type="ai_proactive",
|
||||
content=resp_msg.get("content", ""),
|
||||
content_format=resp_msg.get("content_format", "text"),
|
||||
blocks=resp_msg.get("blocks"),
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
except Exception:
|
||||
logger.exception("AIProactiveParticipantHandler.handle_event: error processing event")
|
||||
|
||||
def get_participant_info(self) -> dict[str, Any]:
|
||||
"""Return metadata about this participant."""
|
||||
return {
|
||||
"display_name": "Live KI",
|
||||
"capabilities": ["proactive", "context_aware", "heartbeat"],
|
||||
"description": "Proaktive KI mit Kontextbewusstsein und Heartbeat",
|
||||
}
|
||||
@@ -45,8 +45,12 @@ class AIProactivePlugin(BasePlugin):
|
||||
is_core=False,
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._proactive_handler = None
|
||||
|
||||
async def on_activate(self, db, service_container, event_bus) -> None:
|
||||
"""Register context tools and subscribe to events."""
|
||||
"""Register context tools, subscribe to events, and register as participant."""
|
||||
await super().on_activate(db, service_container, event_bus)
|
||||
try:
|
||||
from app.plugins.builtins.ai_proactive.context_tools import (
|
||||
@@ -61,8 +65,36 @@ class AIProactivePlugin(BasePlugin):
|
||||
except Exception:
|
||||
logger.exception("Failed to register AI Proactive context tools")
|
||||
|
||||
# Register as participant in the kommunikation system
|
||||
try:
|
||||
from app.plugins.builtins.ai_proactive.participant_handler import (
|
||||
AIProactiveParticipantHandler,
|
||||
)
|
||||
from app.plugins.builtins.kommunikation.participant_registry import (
|
||||
get_participant_registry,
|
||||
)
|
||||
|
||||
self._proactive_handler = AIProactiveParticipantHandler(service_container)
|
||||
get_participant_registry().register("ai_proactive", self._proactive_handler)
|
||||
logger.info("AI Proactive registered as participant 'ai_proactive'")
|
||||
except Exception:
|
||||
logger.exception("Failed to register AI Proactive as participant")
|
||||
|
||||
async def on_deactivate(self, db, service_container, event_bus) -> None:
|
||||
"""Unregister tools and event listeners."""
|
||||
"""Unregister tools, event listeners, and participant."""
|
||||
# Unregister from participant registry
|
||||
try:
|
||||
from app.plugins.builtins.kommunikation.participant_registry import (
|
||||
get_participant_registry,
|
||||
)
|
||||
|
||||
get_participant_registry().unregister("ai_proactive")
|
||||
logger.info("AI Proactive unregistered as participant 'ai_proactive'")
|
||||
except Exception:
|
||||
logger.exception("Failed to unregister AI Proactive as participant")
|
||||
|
||||
self._proactive_handler = None
|
||||
|
||||
try:
|
||||
from app.plugins.builtins.ai_assistant.tool_registry import (
|
||||
get_tool_registry,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Content block type definitions for rich content in messages."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
# Known block types and their expected schema
|
||||
BLOCK_TYPES: dict[str, dict[str, Any]] = {
|
||||
"text": {
|
||||
"description": "Plain text fallback",
|
||||
"fields": {"text": "str"},
|
||||
},
|
||||
"markdown": {
|
||||
"description": "Markdown formatted text",
|
||||
"fields": {"markdown": "str"},
|
||||
},
|
||||
"html": {
|
||||
"description": "Sanitized HTML content",
|
||||
"fields": {"html": "str"},
|
||||
},
|
||||
"image": {
|
||||
"description": "Image attachment",
|
||||
"fields": {"url": "str", "alt": "str", "width": "int?"},
|
||||
},
|
||||
"audio": {
|
||||
"description": "Audio file",
|
||||
"fields": {"url": "str", "duration": "int?", "waveform": "list?"},
|
||||
},
|
||||
"video": {
|
||||
"description": "Video file",
|
||||
"fields": {"url": "str", "duration": "int?", "thumbnail": "str?"},
|
||||
},
|
||||
"file": {
|
||||
"description": "Generic file attachment",
|
||||
"fields": {"url": "str", "name": "str", "size": "int?"},
|
||||
},
|
||||
"action_card": {
|
||||
"description": "Interactive card with buttons",
|
||||
"fields": {
|
||||
"title": "str",
|
||||
"body": "str",
|
||||
"actions": "list[dict]", # [{label, action, type}]
|
||||
},
|
||||
},
|
||||
"contact_card": {
|
||||
"description": "Contact reference card",
|
||||
"fields": {"contact_id": "str", "name": "str"},
|
||||
},
|
||||
"miniapp": {
|
||||
"description": "Embedded mini-app",
|
||||
"fields": {"app_id": "str", "config": "dict?"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def validate_block(block_type: str, block_data: dict[str, Any]) -> bool:
|
||||
"""Validate that a block has the required fields for its type.
|
||||
|
||||
Returns True if valid, False otherwise.
|
||||
Unknown block types are allowed (forward-compatible) but logged.
|
||||
"""
|
||||
if block_type not in BLOCK_TYPES:
|
||||
# Unknown types are allowed — forward compatible
|
||||
return True
|
||||
|
||||
required_fields = BLOCK_TYPES[block_type].get("fields", {})
|
||||
for field_name, field_type in required_fields.items():
|
||||
if field_type.endswith("?"):
|
||||
continue # Optional field
|
||||
if field_name not in block_data:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def list_block_types() -> list[dict[str, Any]]:
|
||||
"""List all known block types for frontend reference."""
|
||||
return [
|
||||
{"block_type": bt, "description": info["description"], "fields": info["fields"]}
|
||||
for bt, info in BLOCK_TYPES.items()
|
||||
]
|
||||
@@ -0,0 +1,187 @@
|
||||
"""DMS Bridge — integrates with the DMS plugin for file storage."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import UploadFile
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.plugins.builtins.dms.models import File as DmsFile, Folder
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DMS_STORAGE_BASE = os.environ.get("DMS_STORAGE_BASE", "/tmp/dms")
|
||||
COMM_FOLDER_NAME = "_kommunikation"
|
||||
MAX_DIRECT_UPLOAD = 100 * 1024 * 1024 # 100 MB — larger files must be DMS references
|
||||
|
||||
|
||||
class DmsBridge:
|
||||
"""Bridge to the DMS plugin for attachment storage."""
|
||||
|
||||
@staticmethod
|
||||
async def ensure_comm_folder(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
) -> Folder:
|
||||
"""Ensure the _kommunikation root folder exists in DMS."""
|
||||
result = await db.execute(
|
||||
select(Folder).where(
|
||||
Folder.name == COMM_FOLDER_NAME,
|
||||
Folder.parent_id.is_(None),
|
||||
Folder.tenant_id == tenant_id,
|
||||
Folder.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
folder = result.scalar_one_or_none()
|
||||
if folder is None:
|
||||
folder = Folder(
|
||||
tenant_id=tenant_id,
|
||||
name=COMM_FOLDER_NAME,
|
||||
parent_id=None,
|
||||
created_by=user_id,
|
||||
)
|
||||
db.add(folder)
|
||||
await db.flush()
|
||||
return folder
|
||||
|
||||
@staticmethod
|
||||
async def ensure_conversation_folder(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
conversation_id: uuid.UUID,
|
||||
) -> Folder:
|
||||
"""Ensure a sub-folder for a specific conversation exists."""
|
||||
root_folder = await DmsBridge.ensure_comm_folder(db, tenant_id, user_id)
|
||||
conv_name = str(conversation_id)
|
||||
result = await db.execute(
|
||||
select(Folder).where(
|
||||
Folder.name == conv_name,
|
||||
Folder.parent_id == root_folder.id,
|
||||
Folder.tenant_id == tenant_id,
|
||||
Folder.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
folder = result.scalar_one_or_none()
|
||||
if folder is None:
|
||||
folder = Folder(
|
||||
tenant_id=tenant_id,
|
||||
name=conv_name,
|
||||
parent_id=root_folder.id,
|
||||
created_by=user_id,
|
||||
)
|
||||
db.add(folder)
|
||||
await db.flush()
|
||||
return folder
|
||||
|
||||
@staticmethod
|
||||
async def store_attachment(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
conversation_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
file: UploadFile,
|
||||
) -> dict[str, Any]:
|
||||
"""Store an uploaded file in the DMS under _kommunikation/{conversation_id}/.
|
||||
|
||||
Returns dict with file_id, file_name, file_type, file_size.
|
||||
"""
|
||||
# Check size limit
|
||||
content = await file.read()
|
||||
file_size = len(content)
|
||||
|
||||
if file_size > MAX_DIRECT_UPLOAD:
|
||||
raise ValueError(
|
||||
f"File size {file_size} exceeds direct upload limit ({MAX_DIRECT_UPLOAD} bytes). "
|
||||
f"Use DMS reference instead."
|
||||
)
|
||||
|
||||
# Ensure conversation folder
|
||||
folder = await DmsBridge.ensure_conversation_folder(
|
||||
db, tenant_id, user_id, conversation_id
|
||||
)
|
||||
|
||||
# Save file to disk
|
||||
file_id = uuid.uuid4()
|
||||
file_ext = os.path.splitext(file.filename or "")[1] or ""
|
||||
storage_path = os.path.join(
|
||||
DMS_STORAGE_BASE,
|
||||
str(tenant_id),
|
||||
str(folder.id),
|
||||
f"{file_id}{file_ext}",
|
||||
)
|
||||
os.makedirs(os.path.dirname(storage_path), exist_ok=True)
|
||||
with open(storage_path, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
# Create DMS file record
|
||||
dms_file = DmsFile(
|
||||
tenant_id=tenant_id,
|
||||
name=file.filename or f"{file_id}",
|
||||
folder_id=folder.id,
|
||||
uploaded_by=user_id,
|
||||
mime_type=file.content_type or "application/octet-stream",
|
||||
size_bytes=file_size,
|
||||
storage_path=storage_path,
|
||||
)
|
||||
db.add(dms_file)
|
||||
await db.flush()
|
||||
|
||||
return {
|
||||
"file_id": str(dms_file.id),
|
||||
"file_name": dms_file.name,
|
||||
"file_type": dms_file.mime_type,
|
||||
"file_size": dms_file.size_bytes,
|
||||
"file_source": "comm",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def reference_external_file(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
file_id: uuid.UUID,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Reference an existing DMS file without copying it.
|
||||
|
||||
Returns dict with file metadata or None if file not found.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(DmsFile).where(
|
||||
DmsFile.id == file_id,
|
||||
DmsFile.tenant_id == tenant_id,
|
||||
DmsFile.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
dms_file = result.scalar_one_or_none()
|
||||
if dms_file is None:
|
||||
return None
|
||||
|
||||
return {
|
||||
"file_id": str(dms_file.id),
|
||||
"file_name": dms_file.name,
|
||||
"file_type": dms_file.mime_type,
|
||||
"file_size": dms_file.size_bytes,
|
||||
"file_source": "dms",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def get_file(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
file_id: uuid.UUID,
|
||||
) -> DmsFile | None:
|
||||
"""Get a DMS file by ID."""
|
||||
result = await db.execute(
|
||||
select(DmsFile).where(
|
||||
DmsFile.id == file_id,
|
||||
DmsFile.tenant_id == tenant_id,
|
||||
DmsFile.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
@@ -0,0 +1,160 @@
|
||||
-- kommunikation plugin initial migration: creates all 10 tables
|
||||
|
||||
CREATE TABLE IF NOT EXISTS comm_conversations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
title VARCHAR(255),
|
||||
title_set_by UUID,
|
||||
is_pinned BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_locked BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
locked_by VARCHAR(100),
|
||||
is_direct BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_archived BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_by UUID,
|
||||
created_by_type VARCHAR(20) NOT NULL DEFAULT 'user',
|
||||
last_msg_at TIMESTAMPTZ,
|
||||
last_msg_preview TEXT,
|
||||
last_msg_sender_type VARCHAR(50),
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_comm_conversations_tenant ON comm_conversations(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_comm_conversations_tenant_pinned ON comm_conversations(tenant_id, is_pinned);
|
||||
CREATE INDEX IF NOT EXISTS ix_comm_conversations_last_msg ON comm_conversations(tenant_id, last_msg_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS comm_participants (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
conversation_id UUID NOT NULL REFERENCES comm_conversations(id) ON DELETE CASCADE,
|
||||
participant_id UUID,
|
||||
participant_type VARCHAR(50) NOT NULL,
|
||||
display_name VARCHAR(255),
|
||||
role VARCHAR(20) NOT NULL DEFAULT 'member',
|
||||
joined_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
left_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
UNIQUE(conversation_id, participant_id, participant_type)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_comm_participants_tenant_conv ON comm_participants(tenant_id, conversation_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_comm_participants_tenant_user ON comm_participants(tenant_id, participant_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS comm_messages (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
conversation_id UUID NOT NULL REFERENCES comm_conversations(id) ON DELETE CASCADE,
|
||||
sender_id UUID,
|
||||
sender_type VARCHAR(50) NOT NULL,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
content_format VARCHAR(20) NOT NULL DEFAULT 'text',
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
reply_to_id UUID REFERENCES comm_messages(id) ON DELETE SET NULL,
|
||||
is_pinned BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
read_at TIMESTAMPTZ,
|
||||
edited_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_comm_messages_tenant_conv ON comm_messages(tenant_id, conversation_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS ix_comm_messages_tenant_sender ON comm_messages(tenant_id, sender_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS comm_message_blocks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
message_id UUID NOT NULL REFERENCES comm_messages(id) ON DELETE CASCADE,
|
||||
block_type VARCHAR(50) NOT NULL,
|
||||
block_data JSONB NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_comm_blocks_tenant_msg ON comm_message_blocks(tenant_id, message_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS comm_message_attachments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
message_id UUID NOT NULL REFERENCES comm_messages(id) ON DELETE CASCADE,
|
||||
file_id UUID,
|
||||
file_source VARCHAR(10) NOT NULL DEFAULT 'comm',
|
||||
file_name VARCHAR(255) NOT NULL,
|
||||
file_type VARCHAR(255) NOT NULL,
|
||||
file_size INTEGER,
|
||||
thumbnail_path VARCHAR(1024),
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_comm_attachments_tenant_msg ON comm_message_attachments(tenant_id, message_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS comm_message_reactions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
message_id UUID NOT NULL REFERENCES comm_messages(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL,
|
||||
emoji VARCHAR(50) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
UNIQUE(message_id, user_id, emoji)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_comm_reactions_tenant_msg ON comm_message_reactions(tenant_id, message_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS comm_message_reads (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
conversation_id UUID NOT NULL REFERENCES comm_conversations(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL,
|
||||
last_read_msg_id UUID REFERENCES comm_messages(id) ON DELETE SET NULL,
|
||||
last_read_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
UNIQUE(conversation_id, user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_comm_reads_tenant_user ON comm_message_reads(tenant_id, user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS comm_conversation_pins (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
conversation_id UUID NOT NULL REFERENCES comm_conversations(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL,
|
||||
pinned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
UNIQUE(conversation_id, user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_comm_pins_tenant_user ON comm_conversation_pins(tenant_id, user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS comm_conversation_mutes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
conversation_id UUID NOT NULL REFERENCES comm_conversations(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL,
|
||||
muted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
UNIQUE(conversation_id, user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_comm_mutes_tenant_user ON comm_conversation_mutes(tenant_id, user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS comm_message_edits (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
message_id UUID NOT NULL REFERENCES comm_messages(id) ON DELETE CASCADE,
|
||||
old_content TEXT NOT NULL,
|
||||
old_blocks JSONB NOT NULL DEFAULT '[]',
|
||||
edited_by UUID NOT NULL,
|
||||
edited_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_comm_edits_tenant_msg ON comm_message_edits(tenant_id, message_id);
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Mini-App registry for plugin-provided interactive chat components."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Callable, Awaitable
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MiniAppDef(BaseModel):
|
||||
"""Definition of a mini-app that plugins can register."""
|
||||
|
||||
app_id: str = Field(..., description="Unique app identifier")
|
||||
name: str = Field(..., description="Display name")
|
||||
icon: str = Field(default="app", description="Icon name")
|
||||
description: str = Field(default="", description="App description")
|
||||
plugin_name: str = Field(..., description="Plugin that registered this app")
|
||||
render_schema: dict[str, Any] = Field(
|
||||
default_factory=dict, description="JSON schema for frontend rendering"
|
||||
)
|
||||
|
||||
|
||||
class MiniAppRegistry:
|
||||
"""Registry for mini-apps that plugins provide for chat embedding."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._apps: dict[str, MiniAppDef] = {}
|
||||
|
||||
def register(
|
||||
self,
|
||||
app_id: str,
|
||||
name: str,
|
||||
icon: str,
|
||||
description: str,
|
||||
plugin_name: str,
|
||||
render_schema: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Register a mini-app."""
|
||||
app = MiniAppDef(
|
||||
app_id=app_id,
|
||||
name=name,
|
||||
icon=icon,
|
||||
description=description,
|
||||
plugin_name=plugin_name,
|
||||
render_schema=render_schema or {},
|
||||
)
|
||||
self._apps[app_id] = app
|
||||
logger.info(f"Mini-app registered: {app_id} by {plugin_name}")
|
||||
|
||||
def unregister(self, app_id: str) -> None:
|
||||
"""Unregister a mini-app."""
|
||||
app = self._apps.pop(app_id, None)
|
||||
if app:
|
||||
logger.info(f"Mini-app unregistered: {app_id}")
|
||||
|
||||
def unregister_plugin(self, plugin_name: str) -> None:
|
||||
"""Unregister all mini-apps from a specific plugin."""
|
||||
to_remove = [app_id for app_id, app in self._apps.items() if app.plugin_name == plugin_name]
|
||||
for app_id in to_remove:
|
||||
self._apps.pop(app_id, None)
|
||||
if to_remove:
|
||||
logger.info(f"Unregistered {len(to_remove)} mini-apps from plugin {plugin_name}")
|
||||
|
||||
def list_apps(self) -> list[dict[str, Any]]:
|
||||
"""List all available mini-apps for frontend."""
|
||||
return [app.model_dump() for app in self._apps.values()]
|
||||
|
||||
def get_app(self, app_id: str) -> MiniAppDef | None:
|
||||
"""Get a specific mini-app definition."""
|
||||
return self._apps.get(app_id)
|
||||
@@ -0,0 +1,283 @@
|
||||
"""SQLAlchemy models for the kommunikation plugin."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
|
||||
|
||||
class CommConversation(Base, TenantMixin):
|
||||
"""Conversation / Room — tenant-scoped, supports pinning, locking, archiving."""
|
||||
|
||||
__tablename__ = "comm_conversations"
|
||||
__table_args__ = (
|
||||
Index("ix_comm_conversations_tenant", "tenant_id"),
|
||||
Index("ix_comm_conversations_tenant_pinned", "tenant_id", "is_pinned"),
|
||||
Index("ix_comm_conversations_last_msg", "tenant_id", "last_msg_at"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
title: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
title_set_by: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
|
||||
is_pinned: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
is_locked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
locked_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
is_direct: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
created_by: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
|
||||
created_by_type: Mapped[str] = mapped_column(String(20), nullable=False, default="user")
|
||||
last_msg_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_msg_preview: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
last_msg_sender_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
metadata_: Mapped[dict[str, Any]] = mapped_column("metadata", JSONB, default=dict, nullable=False)
|
||||
|
||||
|
||||
class CommParticipant(Base, TenantMixin):
|
||||
"""Participant in a conversation — user, ai, system, gateway, etc."""
|
||||
|
||||
__tablename__ = "comm_participants"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"conversation_id", "participant_id", "participant_type",
|
||||
name="uq_comm_participants_conv_part_type",
|
||||
),
|
||||
Index("ix_comm_participants_tenant_conv", "tenant_id", "conversation_id"),
|
||||
Index("ix_comm_participants_tenant_user", "tenant_id", "participant_id"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
conversation_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
ForeignKey("comm_conversations.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
participant_id: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
|
||||
participant_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
display_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
role: Mapped[str] = mapped_column(String(20), nullable=False, default="member")
|
||||
joined_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
left_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class CommMessage(Base, TenantMixin):
|
||||
"""Message in a conversation — text content plus rich content blocks."""
|
||||
|
||||
__tablename__ = "comm_messages"
|
||||
__table_args__ = (
|
||||
Index("ix_comm_messages_tenant_conv", "tenant_id", "conversation_id", "created_at"),
|
||||
Index("ix_comm_messages_tenant_sender", "tenant_id", "sender_id"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
conversation_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
ForeignKey("comm_conversations.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
sender_id: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
|
||||
sender_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
content_format: Mapped[str] = mapped_column(String(20), nullable=False, default="text")
|
||||
metadata_: Mapped[dict[str, Any]] = mapped_column("metadata", JSONB, default=dict, nullable=False)
|
||||
reply_to_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
ForeignKey("comm_messages.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
is_pinned: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
edited_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class CommMessageBlock(Base, TenantMixin):
|
||||
"""Rich content block attached to a message."""
|
||||
|
||||
__tablename__ = "comm_message_blocks"
|
||||
__table_args__ = (
|
||||
Index("ix_comm_blocks_tenant_msg", "tenant_id", "message_id"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
message_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
ForeignKey("comm_messages.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
block_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
block_data: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
|
||||
class CommMessageAttachment(Base, TenantMixin):
|
||||
"""File attachment on a message — DMS reference or comm-internal upload."""
|
||||
|
||||
__tablename__ = "comm_message_attachments"
|
||||
__table_args__ = (
|
||||
Index("ix_comm_attachments_tenant_msg", "tenant_id", "message_id"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
message_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
ForeignKey("comm_messages.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
file_id: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
|
||||
file_source: Mapped[str] = mapped_column(String(10), nullable=False, default="comm")
|
||||
file_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
file_type: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
file_size: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
thumbnail_path: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||
metadata_: Mapped[dict[str, Any]] = mapped_column("metadata", JSONB, default=dict, nullable=False)
|
||||
|
||||
|
||||
class CommMessageReaction(Base, TenantMixin):
|
||||
"""Emoji reaction on a message."""
|
||||
|
||||
__tablename__ = "comm_message_reactions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("message_id", "user_id", "emoji", name="uq_comm_reactions_msg_user_emoji"),
|
||||
Index("ix_comm_reactions_tenant_msg", "tenant_id", "message_id"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
message_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
ForeignKey("comm_messages.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
|
||||
emoji: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
|
||||
|
||||
class CommMessageRead(Base, TenantMixin):
|
||||
"""Read state per user per conversation."""
|
||||
|
||||
__tablename__ = "comm_message_reads"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("conversation_id", "user_id", name="uq_comm_reads_conv_user"),
|
||||
Index("ix_comm_reads_tenant_user", "tenant_id", "user_id"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
conversation_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
ForeignKey("comm_conversations.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
|
||||
last_read_msg_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
ForeignKey("comm_messages.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
last_read_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
|
||||
class CommConversationPin(Base, TenantMixin):
|
||||
"""User-specific conversation pinning."""
|
||||
|
||||
__tablename__ = "comm_conversation_pins"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("conversation_id", "user_id", name="uq_comm_pins_conv_user"),
|
||||
Index("ix_comm_pins_tenant_user", "tenant_id", "user_id"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
conversation_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
ForeignKey("comm_conversations.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
|
||||
pinned_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
|
||||
class CommConversationMute(Base, TenantMixin):
|
||||
"""User-specific conversation muting."""
|
||||
|
||||
__tablename__ = "comm_conversation_mutes"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("conversation_id", "user_id", name="uq_comm_mutes_conv_user"),
|
||||
Index("ix_comm_mutes_tenant_user", "tenant_id", "user_id"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
conversation_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
ForeignKey("comm_conversations.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
|
||||
muted_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
|
||||
class CommMessageEdit(Base, TenantMixin):
|
||||
"""Edit history for messages — stores old content before each edit."""
|
||||
|
||||
__tablename__ = "comm_message_edits"
|
||||
__table_args__ = (
|
||||
Index("ix_comm_edits_tenant_msg", "tenant_id", "message_id"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
message_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True),
|
||||
ForeignKey("comm_messages.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
old_content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
old_blocks: Mapped[list[Any]] = mapped_column(JSONB, default=list, nullable=False)
|
||||
edited_by: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
|
||||
edited_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Participant Registry — Andockpunkt für Plugins als Teilnehmer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ParticipantHandler(ABC):
|
||||
"""Interface that plugins implement to act as a conversation participant."""
|
||||
|
||||
@abstractmethod
|
||||
async def on_message_received(
|
||||
self,
|
||||
conversation_id: Any,
|
||||
message: dict[str, Any],
|
||||
conversation: dict[str, Any],
|
||||
mentions: list[str],
|
||||
context: dict[str, Any],
|
||||
) -> list[dict[str, Any]] | None:
|
||||
"""Called when a new message arrives in a conversation this participant is part of.
|
||||
|
||||
Args:
|
||||
conversation_id: UUID of the conversation.
|
||||
message: The new message dict.
|
||||
conversation: Full conversation dict with participants.
|
||||
mentions: Parsed @mention participant types.
|
||||
context: Tenant, user, and other context.
|
||||
|
||||
Returns:
|
||||
Optional list of new message dicts (e.g. AI response).
|
||||
For passive readers (system) → return None.
|
||||
For reactive participants (ai) → return [message_dict, ...].
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_participant_info(self) -> dict[str, Any]:
|
||||
"""Return metadata: display_name, avatar_url, capabilities, description."""
|
||||
pass
|
||||
|
||||
|
||||
class ParticipantRegistry:
|
||||
"""Global registry for plugin participants."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._handlers: dict[str, ParticipantHandler] = {}
|
||||
|
||||
def register(self, participant_type: str, handler: ParticipantHandler) -> None:
|
||||
"""Register a plugin as a participant type."""
|
||||
self._handlers[participant_type] = handler
|
||||
logger.info(f"Participant registered: {participant_type}")
|
||||
|
||||
def unregister(self, participant_type: str) -> None:
|
||||
"""Unregister a participant type."""
|
||||
handler = self._handlers.pop(participant_type, None)
|
||||
if handler:
|
||||
logger.info(f"Participant unregistered: {participant_type}")
|
||||
|
||||
def get_handler(self, participant_type: str) -> ParticipantHandler | None:
|
||||
"""Get the handler for a participant type."""
|
||||
return self._handlers.get(participant_type)
|
||||
|
||||
def list_types(self) -> list[str]:
|
||||
"""List all registered participant types."""
|
||||
return list(self._handlers.keys())
|
||||
|
||||
def get_all_info(self) -> dict[str, dict[str, Any]]:
|
||||
"""Get info for all registered participants."""
|
||||
return {ptype: handler.get_participant_info() for ptype, handler in self._handlers.items()}
|
||||
|
||||
|
||||
# Global instance
|
||||
_registry = ParticipantRegistry()
|
||||
|
||||
|
||||
def get_participant_registry() -> ParticipantRegistry:
|
||||
"""Get the global participant registry."""
|
||||
return _registry
|
||||
|
||||
|
||||
def reset_participant_registry_for_testing() -> ParticipantRegistry:
|
||||
"""Create a fresh registry for testing."""
|
||||
global _registry
|
||||
_registry = ParticipantRegistry()
|
||||
return _registry
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Kommunikation plugin — unified messaging: chat, AI, system, messenger."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.plugins.base import BasePlugin
|
||||
from app.plugins.manifest import PluginManifest, PluginRouteDef
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class KommunikationPlugin(BasePlugin):
|
||||
"""Unified messaging plugin: conversations, messages, participants, WebSocket, rich content."""
|
||||
|
||||
manifest = PluginManifest(
|
||||
name="kommunikation",
|
||||
version="1.0.0",
|
||||
display_name="Kommunikation",
|
||||
description=(
|
||||
"Unified Messaging: Chat, KI, System, Messenger — "
|
||||
"alles ist ein Teilnehmer."
|
||||
),
|
||||
dependencies=["permissions", "dms"],
|
||||
routes=[
|
||||
PluginRouteDef(
|
||||
path="/api/v1/comm",
|
||||
module="app.plugins.builtins.kommunikation.routes",
|
||||
router_attr="router",
|
||||
),
|
||||
],
|
||||
events=[
|
||||
"message.received",
|
||||
"message.sent",
|
||||
"conversation.created",
|
||||
"conversation.updated",
|
||||
"participant.joined",
|
||||
"participant.left",
|
||||
"reaction.added",
|
||||
],
|
||||
migrations=["0001_initial.sql"],
|
||||
permissions=[
|
||||
"comm:read",
|
||||
"comm:write",
|
||||
"comm:create",
|
||||
"comm:manage",
|
||||
"comm:admin",
|
||||
"comm:delete",
|
||||
],
|
||||
is_core=True,
|
||||
)
|
||||
|
||||
async def on_activate(self, db, service_container, event_bus) -> None:
|
||||
"""Register participant registry and WebSocket manager."""
|
||||
await super().on_activate(db, service_container, event_bus)
|
||||
|
||||
# Register WebSocket manager as a shared service
|
||||
from app.plugins.builtins.kommunikation.websocket_manager import WebSocketManager
|
||||
ws_manager = WebSocketManager()
|
||||
service_container.register("comm_websocket", ws_manager)
|
||||
|
||||
# Register Mini-App registry as a shared service
|
||||
from app.plugins.builtins.kommunikation.miniapp_registry import MiniAppRegistry
|
||||
miniapp_registry = MiniAppRegistry()
|
||||
service_container.register("comm_miniapps", miniapp_registry)
|
||||
|
||||
logger.info("Kommunikation plugin activated — WebSocket + MiniApp registries ready")
|
||||
|
||||
async def on_deactivate(self, db, service_container, event_bus) -> None:
|
||||
"""Clean up registries."""
|
||||
await super().on_deactivate(db, service_container, event_bus)
|
||||
logger.info("Kommunikation plugin deactivated")
|
||||
|
||||
def get_notification_types(self) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"type_key": "comm_message",
|
||||
"category": "communication",
|
||||
"label": "Neue Nachricht",
|
||||
"description": "Neue Nachricht in einer Konversation",
|
||||
"is_enabled_by_default": True,
|
||||
},
|
||||
{
|
||||
"type_key": "comm_mention",
|
||||
"category": "communication",
|
||||
"label": "Erwähnung",
|
||||
"description": "Du wurdest in einer Nachricht erwähnt",
|
||||
"is_enabled_by_default": True,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Chat-internal RBAC logic — two-level permission checks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.permissions import check_permission
|
||||
from app.plugins.builtins.kommunikation.models import CommParticipant
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CommRBAC:
|
||||
"""Chat-internal RBAC, integrated with the existing permission system."""
|
||||
|
||||
@staticmethod
|
||||
async def get_user_role(
|
||||
db: AsyncSession,
|
||||
conversation_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
) -> str | None:
|
||||
"""Get the user's role in a conversation, or None if not a participant."""
|
||||
result = await db.execute(
|
||||
select(CommParticipant).where(
|
||||
CommParticipant.conversation_id == conversation_id,
|
||||
CommParticipant.participant_id == user_id,
|
||||
CommParticipant.participant_type == "user",
|
||||
CommParticipant.left_at.is_(None),
|
||||
)
|
||||
)
|
||||
participant = result.scalar_one_or_none()
|
||||
return participant.role if participant else None
|
||||
|
||||
@staticmethod
|
||||
async def is_participant(
|
||||
db: AsyncSession,
|
||||
conversation_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
) -> bool:
|
||||
"""Check if a user is an active participant in a conversation."""
|
||||
role = await CommRBAC.get_user_role(db, conversation_id, user_id)
|
||||
return role is not None
|
||||
|
||||
@staticmethod
|
||||
async def can_user_write(
|
||||
db: AsyncSession,
|
||||
conversation_id: uuid.UUID,
|
||||
current_user: dict[str, Any],
|
||||
) -> bool:
|
||||
"""Check if user can write messages.
|
||||
|
||||
1. System permission 'comm:write' via check_permission
|
||||
2. is_system_admin → always allowed
|
||||
3. User must be participant with role 'admin' or 'member'
|
||||
"""
|
||||
if current_user.get("is_system_admin"):
|
||||
return True
|
||||
if not check_permission(current_user, "comm:write"):
|
||||
return False
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
role = await CommRBAC.get_user_role(db, conversation_id, user_id)
|
||||
return role in ("admin", "member")
|
||||
|
||||
@staticmethod
|
||||
async def can_user_manage(
|
||||
db: AsyncSession,
|
||||
conversation_id: uuid.UUID,
|
||||
current_user: dict[str, Any],
|
||||
) -> bool:
|
||||
"""Check if user can manage participants.
|
||||
|
||||
1. System permission 'comm:manage' via check_permission
|
||||
2. is_system_admin → always allowed
|
||||
3. User must be conversation admin
|
||||
"""
|
||||
if current_user.get("is_system_admin"):
|
||||
return True
|
||||
if not check_permission(current_user, "comm:manage"):
|
||||
return False
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
role = await CommRBAC.get_user_role(db, conversation_id, user_id)
|
||||
return role == "admin"
|
||||
|
||||
@staticmethod
|
||||
async def can_user_delete(
|
||||
db: AsyncSession,
|
||||
conversation_id: uuid.UUID,
|
||||
current_user: dict[str, Any],
|
||||
is_own_message: bool = False,
|
||||
) -> bool:
|
||||
"""Check if user can delete messages or conversation.
|
||||
|
||||
1. is_system_admin → always allowed
|
||||
2. For own messages: comm:write suffices
|
||||
3. For others' messages: comm:delete + conversation admin
|
||||
4. For conversation: comm:delete + admin role
|
||||
"""
|
||||
if current_user.get("is_system_admin"):
|
||||
return True
|
||||
if is_own_message:
|
||||
return check_permission(current_user, "comm:write")
|
||||
if not check_permission(current_user, "comm:delete"):
|
||||
return False
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
role = await CommRBAC.get_user_role(db, conversation_id, user_id)
|
||||
return role == "admin"
|
||||
|
||||
@staticmethod
|
||||
async def can_user_create(current_user: dict[str, Any]) -> bool:
|
||||
"""Check if user can create conversations."""
|
||||
if current_user.get("is_system_admin"):
|
||||
return True
|
||||
return check_permission(current_user, "comm:create")
|
||||
|
||||
@staticmethod
|
||||
async def can_user_change_role(
|
||||
db: AsyncSession,
|
||||
conversation_id: uuid.UUID,
|
||||
current_user: dict[str, Any],
|
||||
) -> bool:
|
||||
"""Check if user can change participant roles.
|
||||
|
||||
Requires comm:admin permission + conversation admin role.
|
||||
"""
|
||||
if current_user.get("is_system_admin"):
|
||||
return True
|
||||
if not check_permission(current_user, "comm:admin"):
|
||||
return False
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
role = await CommRBAC.get_user_role(db, conversation_id, user_id)
|
||||
return role == "admin"
|
||||
@@ -0,0 +1,532 @@
|
||||
"""REST API routes for the kommunikation plugin."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, WebSocket, WebSocketDisconnect, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.deps import get_current_user
|
||||
from app.plugins.builtins.kommunikation.rbac import CommRBAC
|
||||
from app.plugins.builtins.kommunikation.schemas import (
|
||||
ConversationCreate,
|
||||
ConversationUpdate,
|
||||
MessageCreate,
|
||||
MessageUpdate,
|
||||
ParticipantAdd,
|
||||
ParticipantRoleUpdate,
|
||||
ReactionCreate,
|
||||
ReadStateUpdate,
|
||||
MiniAppStartRequest,
|
||||
)
|
||||
from app.plugins.builtins.kommunikation.services import (
|
||||
add_participant,
|
||||
add_reaction,
|
||||
change_role,
|
||||
create_conversation,
|
||||
delete_message,
|
||||
edit_message,
|
||||
get_conversation,
|
||||
get_messages,
|
||||
list_conversations,
|
||||
mark_read,
|
||||
mute_conversation,
|
||||
pin_conversation,
|
||||
remove_participant,
|
||||
remove_reaction,
|
||||
send_message,
|
||||
unmute_conversation,
|
||||
unpin_conversation,
|
||||
update_conversation,
|
||||
)
|
||||
from app.plugins.builtins.kommunikation.content_types import list_block_types
|
||||
from app.plugins.builtins.kommunikation.dms_bridge import DmsBridge
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/comm", tags=["kommunikation"])
|
||||
|
||||
|
||||
def _parse_uuid(val: str, field: str = "id") -> uuid.UUID:
|
||||
try:
|
||||
return uuid.UUID(val)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(400, detail={"detail": f"Invalid {field}", "code": "invalid_id"})
|
||||
|
||||
|
||||
# ─── Conversations ───
|
||||
|
||||
@router.get("/conversations")
|
||||
async def list_user_conversations(
|
||||
archived: bool = Query(False, description="Include archived conversations"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""List all conversations for the current user."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
convs = await list_conversations(db, tenant_id, user_id, include_archived=archived)
|
||||
return {"items": convs, "total": len(convs)}
|
||||
|
||||
|
||||
@router.post("/conversations")
|
||||
async def create_new_conversation(
|
||||
body: ConversationCreate,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Create a new conversation."""
|
||||
if not await CommRBAC.can_user_create(current_user):
|
||||
raise HTTPException(403, detail={"detail": "Permission denied", "code": "forbidden"})
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
return await create_conversation(
|
||||
db, tenant_id, user_id,
|
||||
title=body.title,
|
||||
participant_ids=body.participant_ids,
|
||||
is_direct=body.is_direct,
|
||||
initial_message=body.initial_message,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/conversations/{conversation_id}")
|
||||
async def get_single_conversation(
|
||||
conversation_id: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get a single conversation with participants."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
conv_id = _parse_uuid(conversation_id, "conversation_id")
|
||||
conv = await get_conversation(db, tenant_id, conv_id, user_id)
|
||||
if conv is None:
|
||||
raise HTTPException(404, detail={"detail": "Conversation not found", "code": "not_found"})
|
||||
return conv
|
||||
|
||||
|
||||
@router.patch("/conversations/{conversation_id}")
|
||||
async def update_single_conversation(
|
||||
conversation_id: str,
|
||||
body: ConversationUpdate,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Update a conversation (title, archive)."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
conv_id = _parse_uuid(conversation_id, "conversation_id")
|
||||
conv = await update_conversation(db, tenant_id, conv_id, user_id, title=body.title, is_archived=body.is_archived)
|
||||
if conv is None:
|
||||
raise HTTPException(404, detail={"detail": "Conversation not found", "code": "not_found"})
|
||||
return conv
|
||||
|
||||
|
||||
@router.delete("/conversations/{conversation_id}")
|
||||
async def leave_or_delete_conversation(
|
||||
conversation_id: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Leave (member) or delete (admin) a conversation."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
conv_id = _parse_uuid(conversation_id, "conversation_id")
|
||||
# For now: just leave (set left_at)
|
||||
success = await remove_participant(db, conv_id, user_id)
|
||||
if not success:
|
||||
raise HTTPException(404, detail={"detail": "Not a participant", "code": "not_found"})
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.post("/conversations/{conversation_id}/pin")
|
||||
async def pin_conv(
|
||||
conversation_id: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Pin a conversation for the current user."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
conv_id = _parse_uuid(conversation_id, "conversation_id")
|
||||
await pin_conversation(db, tenant_id, conv_id, user_id)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.delete("/conversations/{conversation_id}/pin")
|
||||
async def unpin_conv(
|
||||
conversation_id: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Unpin a conversation."""
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
conv_id = _parse_uuid(conversation_id, "conversation_id")
|
||||
await unpin_conversation(db, conv_id, user_id)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.post("/conversations/{conversation_id}/mute")
|
||||
async def mute_conv(
|
||||
conversation_id: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Mute a conversation."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
conv_id = _parse_uuid(conversation_id, "conversation_id")
|
||||
await mute_conversation(db, tenant_id, conv_id, user_id)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.delete("/conversations/{conversation_id}/mute")
|
||||
async def unmute_conv(
|
||||
conversation_id: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Unmute a conversation."""
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
conv_id = _parse_uuid(conversation_id, "conversation_id")
|
||||
await unmute_conversation(db, conv_id, user_id)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
# ─── Participants ───
|
||||
|
||||
@router.post("/conversations/{conversation_id}/participants")
|
||||
async def add_participant_endpoint(
|
||||
conversation_id: str,
|
||||
body: ParticipantAdd,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Add a participant to a conversation."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
conv_id = _parse_uuid(conversation_id, "conversation_id")
|
||||
if not await CommRBAC.can_user_manage(db, conv_id, current_user):
|
||||
raise HTTPException(403, detail={"detail": "Cannot manage participants", "code": "forbidden"})
|
||||
result = await add_participant(db, tenant_id, conv_id, body.participant_id, body.participant_type, body.role)
|
||||
if result is None:
|
||||
raise HTTPException(400, detail={"detail": "Already a participant or invalid ID", "code": "bad_request"})
|
||||
return result
|
||||
|
||||
|
||||
@router.delete("/conversations/{conversation_id}/participants/{participant_id}")
|
||||
async def remove_participant_endpoint(
|
||||
conversation_id: str,
|
||||
participant_id: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Remove a participant from a conversation."""
|
||||
conv_id = _parse_uuid(conversation_id, "conversation_id")
|
||||
pid = _parse_uuid(participant_id, "participant_id")
|
||||
# Self-leave is always allowed
|
||||
if pid != uuid.UUID(current_user["user_id"]):
|
||||
if not await CommRBAC.can_user_manage(db, conv_id, current_user):
|
||||
raise HTTPException(403, detail={"detail": "Cannot manage participants", "code": "forbidden"})
|
||||
success = await remove_participant(db, conv_id, pid)
|
||||
if not success:
|
||||
raise HTTPException(404, detail={"detail": "Participant not found", "code": "not_found"})
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.patch("/conversations/{conversation_id}/participants/{participant_id}")
|
||||
async def change_participant_role(
|
||||
conversation_id: str,
|
||||
participant_id: str,
|
||||
body: ParticipantRoleUpdate,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Change a participant's role."""
|
||||
conv_id = _parse_uuid(conversation_id, "conversation_id")
|
||||
pid = _parse_uuid(participant_id, "participant_id")
|
||||
if not await CommRBAC.can_user_change_role(db, conv_id, current_user):
|
||||
raise HTTPException(403, detail={"detail": "Cannot change roles", "code": "forbidden"})
|
||||
result = await change_role(db, conv_id, pid, body.role)
|
||||
if result is None:
|
||||
raise HTTPException(404, detail={"detail": "Participant not found", "code": "not_found"})
|
||||
return result
|
||||
|
||||
|
||||
# ─── Messages ───
|
||||
|
||||
@router.get("/conversations/{conversation_id}/messages")
|
||||
async def get_conv_messages(
|
||||
conversation_id: str,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=100),
|
||||
before: str | None = Query(None),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get paginated messages for a conversation."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
conv_id = _parse_uuid(conversation_id, "conversation_id")
|
||||
if not await CommRBAC.is_participant(db, conv_id, user_id):
|
||||
raise HTTPException(403, detail={"detail": "Not a participant", "code": "forbidden"})
|
||||
before_id = _parse_uuid(before, "before") if before else None
|
||||
return await get_messages(db, tenant_id, conv_id, page, page_size, before_id)
|
||||
|
||||
|
||||
@router.post("/conversations/{conversation_id}/messages")
|
||||
async def send_conv_message(
|
||||
conversation_id: str,
|
||||
body: MessageCreate,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Send a message to a conversation."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
conv_id = _parse_uuid(conversation_id, "conversation_id")
|
||||
if not await CommRBAC.can_user_write(db, conv_id, current_user):
|
||||
raise HTTPException(403, detail={"detail": "Cannot write to this conversation", "code": "forbidden"})
|
||||
blocks_data = [b.model_dump() for b in body.blocks] if body.blocks else None
|
||||
attachments_data = [a.model_dump() for a in body.attachments] if body.attachments else None
|
||||
return await send_message(
|
||||
db, tenant_id, conv_id, user_id, "user",
|
||||
content=body.content,
|
||||
content_format=body.content_format,
|
||||
blocks=blocks_data,
|
||||
reply_to_id=body.reply_to_id,
|
||||
attachments=attachments_data,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/messages/{message_id}")
|
||||
async def update_msg(
|
||||
message_id: str,
|
||||
body: MessageUpdate,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Edit a message or mark as read."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
msg_id = _parse_uuid(message_id, "message_id")
|
||||
if body.content is not None:
|
||||
result = await edit_message(db, tenant_id, msg_id, user_id, body.content)
|
||||
if result is None:
|
||||
raise HTTPException(404, detail={"detail": "Message not found", "code": "not_found"})
|
||||
return result
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.delete("/messages/{message_id}")
|
||||
async def delete_msg(
|
||||
message_id: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Delete a message."""
|
||||
msg_id = _parse_uuid(message_id, "message_id")
|
||||
success = await delete_message(db, msg_id)
|
||||
if not success:
|
||||
raise HTTPException(404, detail={"detail": "Message not found", "code": "not_found"})
|
||||
return {"success": True}
|
||||
|
||||
|
||||
# ─── Attachments ───
|
||||
|
||||
@router.post("/messages/{message_id}/attachments")
|
||||
async def upload_attachment(
|
||||
message_id: str,
|
||||
file: UploadFile = File(...),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Upload a file attachment to a message."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
msg_id = _parse_uuid(message_id, "message_id")
|
||||
# Get conversation_id from message
|
||||
from sqlalchemy import select
|
||||
from app.plugins.builtins.kommunikation.models import CommMessage
|
||||
result = await db.execute(select(CommMessage).where(CommMessage.id == msg_id))
|
||||
msg = result.scalar_one_or_none()
|
||||
if msg is None:
|
||||
raise HTTPException(404, detail={"detail": "Message not found", "code": "not_found"})
|
||||
try:
|
||||
return await DmsBridge.store_attachment(db, tenant_id, msg.conversation_id, user_id, file)
|
||||
except ValueError as e:
|
||||
raise HTTPException(413, detail={"detail": str(e), "code": "file_too_large"})
|
||||
|
||||
|
||||
# ─── Reactions ───
|
||||
|
||||
@router.post("/messages/{message_id}/reactions")
|
||||
async def add_msg_reaction(
|
||||
message_id: str,
|
||||
body: ReactionCreate,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Add an emoji reaction to a message."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
msg_id = _parse_uuid(message_id, "message_id")
|
||||
result = await add_reaction(db, tenant_id, msg_id, user_id, body.emoji)
|
||||
if result is None:
|
||||
raise HTTPException(409, detail={"detail": "Already reacted", "code": "conflict"})
|
||||
return result
|
||||
|
||||
|
||||
@router.delete("/messages/{message_id}/reactions/{emoji}")
|
||||
async def remove_msg_reaction(
|
||||
message_id: str,
|
||||
emoji: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Remove an emoji reaction."""
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
msg_id = _parse_uuid(message_id, "message_id")
|
||||
success = await remove_reaction(db, msg_id, user_id, emoji)
|
||||
if not success:
|
||||
raise HTTPException(404, detail={"detail": "Reaction not found", "code": "not_found"})
|
||||
return {"success": True}
|
||||
|
||||
|
||||
# ─── Read State ───
|
||||
|
||||
@router.post("/conversations/{conversation_id}/read")
|
||||
async def mark_conv_read(
|
||||
conversation_id: str,
|
||||
body: ReadStateUpdate,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Mark a conversation as read."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
conv_id = _parse_uuid(conversation_id, "conversation_id")
|
||||
await mark_read(db, tenant_id, conv_id, user_id, body.last_read_msg_id)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
# ─── Mini-Apps ───
|
||||
|
||||
@router.get("/miniapps")
|
||||
async def list_miniapps(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""List available mini-apps."""
|
||||
from app.core.service_container import get_container
|
||||
container = get_container()
|
||||
if not container.has("comm_miniapps"):
|
||||
return {"items": []}
|
||||
registry = container.get("comm_miniapps")
|
||||
return {"items": registry.list_apps()}
|
||||
|
||||
|
||||
@router.post("/conversations/{conversation_id}/miniapps")
|
||||
async def start_miniapp(
|
||||
conversation_id: str,
|
||||
body: MiniAppStartRequest,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Start a mini-app in a conversation."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
conv_id = _parse_uuid(conversation_id, "conversation_id")
|
||||
if not await CommRBAC.can_user_write(db, conv_id, current_user):
|
||||
raise HTTPException(403, detail={"detail": "Cannot write", "code": "forbidden"})
|
||||
# Create a miniapp block as a message
|
||||
return await send_message(
|
||||
db, tenant_id, conv_id, user_id, "user",
|
||||
content=f"[Mini-App: {body.app_id}]",
|
||||
blocks=[{
|
||||
"block_type": "miniapp",
|
||||
"block_data": {"app_id": body.app_id, "config": body.config},
|
||||
}],
|
||||
)
|
||||
|
||||
|
||||
# ─── Content Types ───
|
||||
|
||||
@router.get("/block-types")
|
||||
async def get_block_types():
|
||||
"""List all known content block types."""
|
||||
return {"items": list_block_types()}
|
||||
|
||||
|
||||
# ─── WebSocket ───
|
||||
|
||||
@router.websocket("/ws")
|
||||
async def websocket_endpoint(
|
||||
websocket: WebSocket,
|
||||
):
|
||||
"""WebSocket endpoint for real-time messaging.
|
||||
|
||||
Authenticates via session cookie. On connect, subscribes user to all their conversations.
|
||||
"""
|
||||
# Authenticate via session cookie
|
||||
from app.config import get_settings
|
||||
from app.core.auth import get_session_data, get_redis
|
||||
|
||||
settings = get_settings()
|
||||
session_id = websocket.cookies.get(settings.session_cookie_name)
|
||||
if not session_id:
|
||||
await websocket.close(code=4001, reason="Not authenticated")
|
||||
return
|
||||
|
||||
redis = get_redis()
|
||||
session_data = await get_session_data(redis, session_id)
|
||||
if session_data is None:
|
||||
await websocket.close(code=4001, reason="Session expired")
|
||||
return
|
||||
|
||||
user_id = session_data["user_id"]
|
||||
tenant_id = session_data["tenant_id"]
|
||||
|
||||
# Get WebSocket manager from service container
|
||||
from app.core.service_container import get_container
|
||||
container = get_container()
|
||||
if not container.has("comm_websocket"):
|
||||
await websocket.close(code=4003, reason="Messaging not available")
|
||||
return
|
||||
|
||||
ws_manager = container.get("comm_websocket")
|
||||
await ws_manager.connect(websocket, user_id)
|
||||
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_text()
|
||||
msg = json.loads(data)
|
||||
msg_type = msg.get("type")
|
||||
|
||||
if msg_type == "ping":
|
||||
await ws_manager.send_to_user(user_id, {"type": "pong"})
|
||||
elif msg_type == "subscribe":
|
||||
conv_id = msg.get("conversation_id")
|
||||
if conv_id:
|
||||
ws_manager.subscribe(conv_id, user_id)
|
||||
elif msg_type == "unsubscribe":
|
||||
conv_id = msg.get("conversation_id")
|
||||
if conv_id:
|
||||
ws_manager.unsubscribe(conv_id, user_id)
|
||||
elif msg_type == "typing":
|
||||
conv_id = msg.get("conversation_id")
|
||||
is_typing = msg.get("is_typing", False)
|
||||
if conv_id:
|
||||
await ws_manager.send_to_conversation(
|
||||
conv_id,
|
||||
{"type": "typing", "conversation_id": conv_id, "user_id": user_id, "is_typing": is_typing},
|
||||
exclude_user=user_id,
|
||||
)
|
||||
except WebSocketDisconnect:
|
||||
await ws_manager.disconnect(websocket, user_id)
|
||||
except Exception:
|
||||
logger.exception("WebSocket error")
|
||||
await ws_manager.disconnect(websocket, user_id)
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Pydantic schemas for the kommunikation plugin API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ─── Conversation Schemas ───
|
||||
|
||||
class ParticipantResponse(BaseModel):
|
||||
id: str
|
||||
conversation_id: str
|
||||
participant_id: str | None = None
|
||||
participant_type: str
|
||||
display_name: str | None = None
|
||||
role: str
|
||||
joined_at: str | None = None
|
||||
|
||||
|
||||
class ConversationCreate(BaseModel):
|
||||
title: str | None = None
|
||||
participant_ids: list[str] = Field(default_factory=list)
|
||||
participant_types: list[str] = Field(default_factory=lambda: ["user"])
|
||||
initial_message: str | None = None
|
||||
is_direct: bool = False
|
||||
|
||||
|
||||
class ConversationUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
is_archived: bool | None = None
|
||||
|
||||
|
||||
class ConversationResponse(BaseModel):
|
||||
id: str
|
||||
title: str | None = None
|
||||
is_locked: bool = False
|
||||
locked_by: str | None = None
|
||||
is_direct: bool = False
|
||||
is_archived: bool = False
|
||||
is_pinned: bool = False
|
||||
created_by: str | None = None
|
||||
created_by_type: str = "user"
|
||||
last_msg_at: str | None = None
|
||||
last_msg_preview: str | None = None
|
||||
last_msg_sender_type: str | None = None
|
||||
participants: list[ParticipantResponse] = Field(default_factory=list)
|
||||
unread_count: int = 0
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ConversationListResponse(BaseModel):
|
||||
items: list[ConversationResponse]
|
||||
total: int
|
||||
|
||||
|
||||
# ─── Participant Management ───
|
||||
|
||||
class ParticipantAdd(BaseModel):
|
||||
participant_id: str
|
||||
participant_type: str = "user"
|
||||
role: str = "member"
|
||||
|
||||
|
||||
class ParticipantRoleUpdate(BaseModel):
|
||||
role: str = Field(..., pattern="^(admin|member|reader)$")
|
||||
|
||||
|
||||
# ─── Message Schemas ───
|
||||
|
||||
class MessageBlockCreate(BaseModel):
|
||||
block_type: str
|
||||
block_data: dict[str, Any]
|
||||
|
||||
|
||||
class MessageBlockResponse(BaseModel):
|
||||
id: str
|
||||
block_type: str
|
||||
block_data: dict[str, Any]
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class AttachmentCreate(BaseModel):
|
||||
file_id: str | None = None
|
||||
file_source: str = "comm"
|
||||
|
||||
|
||||
class AttachmentResponse(BaseModel):
|
||||
id: str
|
||||
file_id: str | None = None
|
||||
file_source: str = "comm"
|
||||
file_name: str
|
||||
file_type: str
|
||||
file_size: int | None = None
|
||||
thumbnail_path: str | None = None
|
||||
|
||||
|
||||
class MessageCreate(BaseModel):
|
||||
content: str = ""
|
||||
content_format: str = "text"
|
||||
blocks: list[MessageBlockCreate] = Field(default_factory=list)
|
||||
reply_to_id: str | None = None
|
||||
attachments: list[AttachmentCreate] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MessageUpdate(BaseModel):
|
||||
content: str | None = None
|
||||
read: bool | None = None
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
id: str
|
||||
conversation_id: str
|
||||
sender_id: str | None = None
|
||||
sender_type: str
|
||||
content: str = ""
|
||||
content_format: str = "text"
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
reply_to_id: str | None = None
|
||||
is_pinned: bool = False
|
||||
created_at: str | None = None
|
||||
edited_at: str | None = None
|
||||
blocks: list[MessageBlockResponse] = Field(default_factory=list)
|
||||
attachments: list[AttachmentResponse] = Field(default_factory=list)
|
||||
reactions: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MessageListResponse(BaseModel):
|
||||
items: list[MessageResponse]
|
||||
total: int
|
||||
page: int = 1
|
||||
has_more: bool = False
|
||||
|
||||
|
||||
# ─── Reaction Schemas ───
|
||||
|
||||
class ReactionCreate(BaseModel):
|
||||
emoji: str
|
||||
|
||||
|
||||
class ReactionResponse(BaseModel):
|
||||
id: str
|
||||
message_id: str
|
||||
user_id: str
|
||||
emoji: str
|
||||
|
||||
|
||||
# ─── Read State ───
|
||||
|
||||
class ReadStateUpdate(BaseModel):
|
||||
last_read_msg_id: str | None = None
|
||||
|
||||
|
||||
# ─── Mini-App Schemas ───
|
||||
|
||||
class MiniAppResponse(BaseModel):
|
||||
app_id: str
|
||||
name: str
|
||||
icon: str
|
||||
description: str
|
||||
plugin_name: str
|
||||
render_schema: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class MiniAppStartRequest(BaseModel):
|
||||
app_id: str
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
# ─── WebSocket Message Schemas ───
|
||||
|
||||
class WSMessage(BaseModel):
|
||||
type: str
|
||||
data: dict[str, Any] = Field(default_factory=dict)
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Search provider for unified_search integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select, func, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.plugins.builtins.kommunikation.models import (
|
||||
CommConversation,
|
||||
CommMessage,
|
||||
CommParticipant,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CommSearchProvider:
|
||||
"""Provider for unified_search — searches conversations and messages."""
|
||||
|
||||
async def search(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
query: str,
|
||||
limit: int = 20,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Search in messages and conversations the user has access to."""
|
||||
results: list[dict[str, Any]] = []
|
||||
|
||||
# Get user's conversation IDs
|
||||
conv_result = await db.execute(
|
||||
select(CommParticipant.conversation_id).where(
|
||||
CommParticipant.participant_id == user_id,
|
||||
CommParticipant.participant_type == "user",
|
||||
CommParticipant.left_at.is_(None),
|
||||
CommParticipant.tenant_id == tenant_id,
|
||||
)
|
||||
)
|
||||
conv_ids = [row[0] for row in conv_result.fetchall()]
|
||||
|
||||
if not conv_ids:
|
||||
return results
|
||||
|
||||
# Search in messages
|
||||
msg_result = await db.execute(
|
||||
select(CommMessage, CommConversation.title)
|
||||
.join(CommConversation, CommConversation.id == CommMessage.conversation_id)
|
||||
.where(
|
||||
CommMessage.conversation_id.in_(conv_ids),
|
||||
CommMessage.tenant_id == tenant_id,
|
||||
CommMessage.deleted_at.is_(None),
|
||||
CommMessage.content.ilike(f"%{query}%"),
|
||||
)
|
||||
.order_by(CommMessage.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
for msg, conv_title in msg_result.fetchall():
|
||||
# Build snippet around match
|
||||
content = msg.content or ""
|
||||
idx = content.lower().find(query.lower())
|
||||
if idx >= 0:
|
||||
start = max(0, idx - 30)
|
||||
end = min(len(content), idx + len(query) + 30)
|
||||
snippet = ("..." if start > 0 else "") + content[start:end] + ("..." if end < len(content) else "")
|
||||
else:
|
||||
snippet = content[:100]
|
||||
|
||||
results.append({
|
||||
"type": "message",
|
||||
"id": str(msg.id),
|
||||
"conversation_id": str(msg.conversation_id),
|
||||
"conversation_title": conv_title,
|
||||
"content": msg.content,
|
||||
"sender_type": msg.sender_type,
|
||||
"created_at": msg.created_at.isoformat() if msg.created_at else None,
|
||||
"snippet": snippet,
|
||||
})
|
||||
|
||||
# Search in conversation titles
|
||||
conv_title_result = await db.execute(
|
||||
select(CommConversation).where(
|
||||
CommConversation.id.in_(conv_ids),
|
||||
CommConversation.tenant_id == tenant_id,
|
||||
CommConversation.deleted_at.is_(None),
|
||||
CommConversation.title.ilike(f"%{query}%"),
|
||||
)
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
for conv in conv_title_result.scalars().all():
|
||||
results.append({
|
||||
"type": "conversation",
|
||||
"id": str(conv.id),
|
||||
"title": conv.title,
|
||||
"last_msg_at": conv.last_msg_at.isoformat() if conv.last_msg_at else None,
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
async def index_message(self, message: dict[str, Any]) -> None:
|
||||
"""Index a message for search (placeholder for future full-text indexing)."""
|
||||
pass
|
||||
|
||||
async def reindex_all(self, db: AsyncSession, tenant_id: uuid.UUID) -> None:
|
||||
"""Full reindex (placeholder for future full-text indexing)."""
|
||||
pass
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,89 @@
|
||||
"""WebSocket connection manager for the kommunikation plugin."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebSocketManager:
|
||||
"""Manages WebSocket connections per user for real-time messaging."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# user_id (str) → list of WebSocket connections
|
||||
self._connections: dict[str, list[WebSocket]] = {}
|
||||
# conversation_id (str) → set of user_ids subscribed
|
||||
self._subscriptions: dict[str, set[str]] = {}
|
||||
|
||||
async def connect(self, websocket: WebSocket, user_id: str) -> None:
|
||||
"""Accept and register a new WebSocket connection."""
|
||||
await websocket.accept()
|
||||
if user_id not in self._connections:
|
||||
self._connections[user_id] = []
|
||||
self._connections[user_id].append(websocket)
|
||||
logger.debug(f"WebSocket connected: user={user_id}, total={len(self._connections[user_id])}")
|
||||
|
||||
async def disconnect(self, websocket: WebSocket, user_id: str) -> None:
|
||||
"""Remove a WebSocket connection."""
|
||||
conns = self._connections.get(user_id, [])
|
||||
if websocket in conns:
|
||||
conns.remove(websocket)
|
||||
if not conns:
|
||||
self._connections.pop(user_id, None)
|
||||
# Remove from all subscriptions
|
||||
for conv_id, users in self._subscriptions.items():
|
||||
users.discard(user_id)
|
||||
logger.debug(f"WebSocket disconnected: user={user_id}, remaining={len(conns)}")
|
||||
|
||||
def subscribe(self, conversation_id: str, user_id: str) -> None:
|
||||
"""Subscribe a user to a conversation's updates."""
|
||||
if conversation_id not in self._subscriptions:
|
||||
self._subscriptions[conversation_id] = set()
|
||||
self._subscriptions[conversation_id].add(user_id)
|
||||
|
||||
def unsubscribe(self, conversation_id: str, user_id: str) -> None:
|
||||
"""Unsubscribe a user from a conversation."""
|
||||
if conversation_id in self._subscriptions:
|
||||
self._subscriptions[conversation_id].discard(user_id)
|
||||
|
||||
async def send_to_user(self, user_id: str, message: dict[str, Any]) -> None:
|
||||
"""Send a message to all connections of a specific user."""
|
||||
conns = self._connections.get(user_id, [])
|
||||
text = json.dumps(message, default=str)
|
||||
for ws in conns:
|
||||
try:
|
||||
await ws.send_text(text)
|
||||
except Exception:
|
||||
logger.warning(f"Failed to send to user {user_id}, removing connection")
|
||||
await self.disconnect(ws, user_id)
|
||||
|
||||
async def send_to_conversation(
|
||||
self,
|
||||
conversation_id: str,
|
||||
message: dict[str, Any],
|
||||
exclude_user: str | None = None,
|
||||
) -> None:
|
||||
"""Send a message to all users subscribed to a conversation."""
|
||||
user_ids = self._subscriptions.get(conversation_id, set())
|
||||
for user_id in list(user_ids):
|
||||
if exclude_user and user_id == exclude_user:
|
||||
continue
|
||||
await self.send_to_user(user_id, message)
|
||||
|
||||
async def broadcast(self, message: dict[str, Any]) -> None:
|
||||
"""Broadcast a message to all connected users."""
|
||||
for user_id in list(self._connections.keys()):
|
||||
await self.send_to_user(user_id, message)
|
||||
|
||||
def get_online_users(self) -> list[str]:
|
||||
"""Get list of currently connected user IDs."""
|
||||
return list(self._connections.keys())
|
||||
|
||||
def is_user_online(self, user_id: str) -> bool:
|
||||
"""Check if a user has any active connections."""
|
||||
return user_id in self._connections and len(self._connections[user_id]) > 0
|
||||
@@ -0,0 +1,46 @@
|
||||
"""System participant handler — passive participant that only reads."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.plugins.builtins.kommunikation.participant_registry import ParticipantHandler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SystemParticipantHandler(ParticipantHandler):
|
||||
"""System participant — passive, does not respond to messages.
|
||||
|
||||
The system participant only sends messages triggered by system events
|
||||
(handled in the plugin's event handlers). It does not respond to
|
||||
user messages in the chat.
|
||||
"""
|
||||
|
||||
def __init__(self, container: Any) -> None:
|
||||
self._container = container
|
||||
|
||||
async def on_message_received(
|
||||
self,
|
||||
conversation_id: Any,
|
||||
message: dict[str, Any],
|
||||
conversation: dict[str, Any],
|
||||
mentions: list[str],
|
||||
context: dict[str, Any],
|
||||
) -> list[dict[str, Any]] | None:
|
||||
"""System is passive — does not respond to messages.
|
||||
|
||||
Users can interact with action_card buttons (open, archive) which
|
||||
are handled via the frontend, not via new messages.
|
||||
"""
|
||||
return None
|
||||
|
||||
def get_participant_info(self) -> dict[str, Any]:
|
||||
"""Return participant metadata."""
|
||||
return {
|
||||
"display_name": "System",
|
||||
"avatar_url": None,
|
||||
"capabilities": ["notifications", "action_cards"],
|
||||
"description": "System-Benachrichtigungen",
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
"""System Notification plugin — converts system events into chat messages."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.plugins.base import BasePlugin
|
||||
from app.plugins.manifest import PluginManifest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SystemNotifPlugin(BasePlugin):
|
||||
"""System notifications as a participant in the kommunikation plugin."""
|
||||
|
||||
manifest = PluginManifest(
|
||||
name="system_notif",
|
||||
version="1.0.0",
|
||||
display_name="System Benachrichtigungen",
|
||||
description=(
|
||||
"Wandelt System-Events in Chat-Nachrichten um. "
|
||||
"Registriert sich als 'system' Teilnehmer am kommunikation Plugin."
|
||||
),
|
||||
dependencies=["kommunikation"],
|
||||
routes=[],
|
||||
events=[
|
||||
"lead.created",
|
||||
"contact.created",
|
||||
"contact.updated",
|
||||
"task.overdue",
|
||||
"task.created",
|
||||
"mail.received",
|
||||
"user.created",
|
||||
"workflow.completed",
|
||||
"notification.created",
|
||||
],
|
||||
migrations=[],
|
||||
permissions=["system_notif:read"],
|
||||
is_core=False,
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._system_handler = None
|
||||
|
||||
async def on_activate(self, db, service_container, event_bus) -> None:
|
||||
"""Register as system participant and subscribe to events."""
|
||||
await super().on_activate(db, service_container, event_bus)
|
||||
|
||||
from app.plugins.builtins.system_notif.participant_handler import SystemParticipantHandler
|
||||
from app.plugins.builtins.kommunikation.participant_registry import get_participant_registry
|
||||
|
||||
self._system_handler = SystemParticipantHandler(service_container)
|
||||
registry = get_participant_registry()
|
||||
registry.register("system", self._system_handler)
|
||||
|
||||
logger.info("System notification plugin activated — registered as 'system' participant")
|
||||
|
||||
async def on_deactivate(self, db, service_container, event_bus) -> None:
|
||||
"""Unregister participant."""
|
||||
from app.plugins.builtins.kommunikation.participant_registry import get_participant_registry
|
||||
|
||||
get_participant_registry().unregister("system")
|
||||
self._system_handler = None
|
||||
await super().on_deactivate(db, service_container, event_bus)
|
||||
logger.info("System notification plugin deactivated")
|
||||
|
||||
# ─── Event Handlers ───
|
||||
|
||||
async def on_lead_created(self, payload: dict[str, Any]) -> None:
|
||||
"""Handle lead.created event → system message."""
|
||||
await self._create_system_notification(payload, event_type="lead.created")
|
||||
|
||||
async def on_contact_created(self, payload: dict[str, Any]) -> None:
|
||||
"""Handle contact.created event → system message."""
|
||||
await self._create_system_notification(payload, event_type="contact.created")
|
||||
|
||||
async def on_contact_updated(self, payload: dict[str, Any]) -> None:
|
||||
"""Handle contact.updated event → system message."""
|
||||
await self._create_system_notification(payload, event_type="contact.updated")
|
||||
|
||||
async def on_task_overdue(self, payload: dict[str, Any]) -> None:
|
||||
"""Handle task.overdue event → system message (warning)."""
|
||||
await self._create_system_notification(payload, event_type="task.overdue", severity="warning")
|
||||
|
||||
async def on_task_created(self, payload: dict[str, Any]) -> None:
|
||||
"""Handle task.created event → system message."""
|
||||
await self._create_system_notification(payload, event_type="task.created")
|
||||
|
||||
async def on_mail_received(self, payload: dict[str, Any]) -> None:
|
||||
"""Handle mail.received event → system message."""
|
||||
await self._create_system_notification(payload, event_type="mail.received")
|
||||
|
||||
async def on_user_created(self, payload: dict[str, Any]) -> None:
|
||||
"""Handle user.created event → system message."""
|
||||
await self._create_system_notification(payload, event_type="user.created")
|
||||
|
||||
async def on_workflow_completed(self, payload: dict[str, Any]) -> None:
|
||||
"""Handle workflow.completed event → system message."""
|
||||
await self._create_system_notification(payload, event_type="workflow.completed")
|
||||
|
||||
async def on_notification_created(self, payload: dict[str, Any]) -> None:
|
||||
"""Handle notification.created event → system message.
|
||||
|
||||
This is the bridge from the core notification system to the kommunikation plugin.
|
||||
Core code publishes 'notification.created' on the EventBus, this plugin
|
||||
converts it into a comm_message in the user's System room.
|
||||
"""
|
||||
await self._create_system_notification(payload, event_type="notification.created")
|
||||
|
||||
async def _create_system_notification(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
event_type: str,
|
||||
severity: str = "info",
|
||||
) -> None:
|
||||
"""Create a system notification message in the user's System room."""
|
||||
import uuid
|
||||
|
||||
from app.core.db import create_db_session
|
||||
from app.plugins.builtins.kommunikation.services import create_plugin_room, send_message
|
||||
|
||||
tenant_id_str = payload.get("tenant_id")
|
||||
user_id_str = payload.get("user_id")
|
||||
|
||||
if not tenant_id_str or not user_id_str:
|
||||
logger.warning("System notification missing tenant_id or user_id in payload: %s", payload)
|
||||
return
|
||||
|
||||
try:
|
||||
tenant_id = uuid.UUID(tenant_id_str)
|
||||
user_id = uuid.UUID(user_id_str)
|
||||
except (ValueError, TypeError):
|
||||
logger.warning("System notification invalid UUIDs: tenant=%s user=%s", tenant_id_str, user_id_str)
|
||||
return
|
||||
|
||||
# Build notification content from payload
|
||||
title = payload.get("title", "")
|
||||
body = payload.get("body", "")
|
||||
action_url = payload.get("action_url", "")
|
||||
|
||||
if not title:
|
||||
# Generate title from event type
|
||||
event_titles = {
|
||||
"lead.created": "Neuer Lead",
|
||||
"contact.created": "Neuer Kontakt",
|
||||
"contact.updated": "Kontakt aktualisiert",
|
||||
"task.overdue": "Aufgabe überfällig",
|
||||
"task.created": "Neue Aufgabe",
|
||||
"mail.received": "Neue E-Mail",
|
||||
"user.created": "Neuer Benutzer",
|
||||
"workflow.completed": "Workflow abgeschlossen",
|
||||
"notification.created": "Benachrichtigung",
|
||||
}
|
||||
title = event_titles.get(event_type, event_type)
|
||||
|
||||
content = f"**{title}**"
|
||||
if body:
|
||||
content += f"\n{body}"
|
||||
|
||||
# Build action_card block if action_url is present
|
||||
blocks = []
|
||||
if action_url:
|
||||
blocks.append({
|
||||
"block_type": "action_card",
|
||||
"block_data": {
|
||||
"title": title,
|
||||
"body": body or "",
|
||||
"actions": [
|
||||
{"label": "Öffnen", "action": action_url, "type": "primary"},
|
||||
{"label": "Archivieren", "action": "dismiss", "type": "secondary"},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
async with create_db_session(tenant_id) as db:
|
||||
# Ensure System room exists for this user
|
||||
await create_plugin_room(
|
||||
db, tenant_id, user_id,
|
||||
plugin_name="system_notif",
|
||||
title="System",
|
||||
participant_type="system",
|
||||
user_role="reader",
|
||||
)
|
||||
|
||||
# Find the System room conversation
|
||||
from sqlalchemy import select
|
||||
from app.plugins.builtins.kommunikation.models import CommConversation, CommParticipant
|
||||
|
||||
result = await db.execute(
|
||||
select(CommConversation).where(
|
||||
CommConversation.tenant_id == tenant_id,
|
||||
CommConversation.title == "System",
|
||||
CommConversation.is_locked == True,
|
||||
CommConversation.locked_by == "system_notif",
|
||||
CommConversation.deleted_at.is_(None),
|
||||
).join(CommParticipant, CommParticipant.conversation_id == CommConversation.id).where(
|
||||
CommParticipant.participant_id == user_id,
|
||||
CommParticipant.participant_type == "user",
|
||||
CommParticipant.left_at.is_(None),
|
||||
)
|
||||
)
|
||||
conv = result.scalar_one_or_none()
|
||||
if conv is None:
|
||||
logger.warning("Could not find System room for user %s", user_id)
|
||||
return
|
||||
|
||||
# Send the system message
|
||||
await send_message(
|
||||
db, tenant_id, conv.id,
|
||||
sender_id=None,
|
||||
sender_type="system",
|
||||
content=content,
|
||||
content_format="markdown",
|
||||
blocks=blocks if blocks else None,
|
||||
metadata={"event_type": event_type, "severity": severity},
|
||||
)
|
||||
|
||||
logger.info("System notification created: %s for user %s", event_type, user_id)
|
||||
|
||||
def get_notification_types(self) -> list[dict[str, Any]]:
|
||||
return []
|
||||
Reference in New Issue
Block a user