"""Plugin room creation and system channels for the kommunikation plugin.""" from __future__ import annotations import logging import uuid from datetime import UTC, datetime from typing import Any from sqlalchemy import and_, select, update from sqlalchemy.ext.asyncio import AsyncSession from app.core.event_bus import get_event_bus from app.plugins.builtins.kommunikation.conversations import get_conversation from app.plugins.builtins.kommunikation.models import ( CommConversation, CommConversationPin, CommMessage, CommMessageBlock, CommParticipant, ) from app.plugins.builtins.kommunikation.serializers import conversation_to_response logger = logging.getLogger(__name__) # ─── plugin_rooms ─── async def find_locked_room_id( db: AsyncSession, tenant_id: uuid.UUID, plugin_name: str, title: str, ) -> uuid.UUID | None: """Find the conversation ID of a locked plugin room by tenant and title. Matches the same room semantics as ``create_plugin_room``: locked rooms are owned by the plugin (``locked_by == plugin_name``) and soft-deleted conversations are excluded. Returns ``None`` when no room exists. """ result = await db.execute( select(CommConversation.id).where( CommConversation.tenant_id == tenant_id, CommConversation.title == title, CommConversation.is_locked.is_(True), CommConversation.locked_by == plugin_name, CommConversation.deleted_at.is_(None), ) ) return result.scalar_one_or_none() async def create_plugin_room( db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, plugin_name: str, title: str, participant_type: str, user_role: str = "member", ) -> dict[str, Any]: """Create a locked, pinned room for a plugin (System, Live KI, Assistent). The room is locked (users can't change title/participants) and pinned for the user. """ # Check if room already exists for this user + plugin result = await db.execute( select(CommConversation).where( CommConversation.tenant_id == tenant_id, CommConversation.title == title, CommConversation.is_locked.is_(True), CommConversation.locked_by == plugin_name, CommConversation.deleted_at.is_(None), ).join(CommParticipant, CommParticipant.conversation_id == CommConversation.id).where( CommParticipant.participant_id == user_id, CommParticipant.participant_type == "user", CommParticipant.left_at.is_(None), ) ) existing = result.scalar_one_or_none() if existing: # Already exists — return it return await get_conversation(db, tenant_id, existing.id, user_id) or {} # Create conversation conv = CommConversation( tenant_id=tenant_id, title=title, owner_id=user_id, is_locked=True, locked_by=plugin_name, is_direct=False, created_by=None, created_by_type="plugin", metadata_={"plugin": plugin_name}, ) db.add(conv) await db.flush() # Add plugin as participant plugin_p = CommParticipant( tenant_id=tenant_id, conversation_id=conv.id, participant_id=None, participant_type=participant_type, role="admin", display_name=title, ) db.add(plugin_p) # Add user as participant user_p = CommParticipant( tenant_id=tenant_id, conversation_id=conv.id, participant_id=user_id, participant_type="user", role=user_role, ) db.add(user_p) # Pin for user pin = CommConversationPin( tenant_id=tenant_id, conversation_id=conv.id, user_id=user_id, ) db.add(pin) await db.flush() # Publish event event_bus = get_event_bus() await event_bus.publish("conversation.created", { "conversation_id": str(conv.id), "tenant_id": str(tenant_id), "created_by_type": "plugin", "plugin_name": plugin_name, }) parts = [plugin_p, user_p] return conversation_to_response(conv, parts, is_pinned_by_user=True) # ─── System Channel ─── async def get_or_create_system_channel( db: AsyncSession, tenant_id: uuid.UUID, ) -> CommConversation: """Get or create the tenant-wide system channel. The system channel is a locked, is_system=True conversation that serves as the central destination for system notifications, user alerts, and agent messages. All users of the tenant are automatically added as participants. """ result = await db.execute( select(CommConversation).where( CommConversation.tenant_id == tenant_id, CommConversation.is_system.is_(True), CommConversation.deleted_at.is_(None), ) ) conv = result.scalar_one_or_none() if conv is not None: return conv # Create the system channel conv = CommConversation( tenant_id=tenant_id, title="System Channel", is_pinned=False, is_locked=True, is_direct=False, is_archived=False, is_system=True, created_by=None, created_by_type="system", metadata_={}, ) db.add(conv) await db.flush() # Add all tenant users as participants from app.models.user import User, UserTenant users_result = await db.execute( select(User.id) .join(UserTenant, UserTenant.user_id == User.id) .where(UserTenant.tenant_id == tenant_id) ) user_ids = [row[0] for row in users_result.all()] for uid in user_ids: p = CommParticipant( tenant_id=tenant_id, conversation_id=conv.id, participant_id=uid, participant_type="user", role="member", ) db.add(p) await db.flush() return conv async def post_system_message( db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, message_type: str, title: str, body: str | None = None, entity_type: str | None = None, entity_id: uuid.UUID | None = None, severity: str = "info", ) -> CommMessage | None: """Post a typed system message to the tenant system channel. Creates a CommMessage in the system channel with: - A text block containing title + body - An action_card block with deep-link if entity_type/entity_id is set - Block/message metadata: notification_type, severity, entity_ref Returns the created CommMessage, or None if the user has muted this type. """ # Check user preferences — reuse the notification preference system from app.models.notification import NotificationPreference, NotificationType pref = await db.execute( select(NotificationPreference).where( and_( NotificationPreference.user_id == user_id, NotificationPreference.type_key == message_type, NotificationPreference.tenant_id == tenant_id, ) ) ) pref_row = pref.scalar_one_or_none() if pref_row and not pref_row.is_enabled: return None if not pref_row: type_def = await db.execute( select(NotificationType).where(NotificationType.type_key == message_type) ) type_row = type_def.scalar_one_or_none() if type_row and not type_row.is_enabled_by_default: return None # Get or create system channel conv = await get_or_create_system_channel(db, tenant_id) # Build message content content = title if body: content = f"{title}\n{body}" # Build metadata msg_metadata: dict[str, Any] = { "notification_type": message_type, "severity": severity, "target_user_id": str(user_id), } if entity_type and entity_id: msg_metadata["entity_ref"] = { "entity_type": entity_type, "entity_id": str(entity_id), } # Build blocks blocks: list[dict[str, Any]] = [ { "block_type": "text", "block_data": {"text": content, "title": title, "body": body or ""}, } ] if entity_type and entity_id: blocks.append( { "block_type": "action_card", "block_data": { "label": "Open", "entity_type": entity_type, "entity_id": str(entity_id), }, } ) # Create message directly (not via send_message to avoid trigger_depth issues) msg = CommMessage( tenant_id=tenant_id, conversation_id=conv.id, sender_id=None, sender_type="system", content=content, content_format="text", metadata_=msg_metadata, ) db.add(msg) await db.flush() # Create blocks for i, block in enumerate(blocks): b = CommMessageBlock( tenant_id=tenant_id, message_id=msg.id, block_type=block["block_type"], block_data=block["block_data"], sort_order=i, ) db.add(b) await db.flush() # Update conversation last_msg await db.execute( update(CommConversation) .where(CommConversation.id == conv.id) .values( last_msg_at=datetime.now(UTC), last_msg_preview=content[:200], last_msg_sender_type="system", ) ) # Publish event event_bus = get_event_bus() await event_bus.publish("system.message.posted", { "conversation_id": str(conv.id), "message_id": str(msg.id), "tenant_id": str(tenant_id), "user_id": str(user_id), "message_type": message_type, "severity": severity, }) return msg