"""Conversation CRUD and pin/mute actions for the kommunikation plugin.""" 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.event_bus import get_event_bus from app.plugins.builtins.kommunikation.interactions import _get_unread_count from app.plugins.builtins.kommunikation.messages import send_message from app.plugins.builtins.kommunikation.models import ( CommConversation, CommConversationMute, CommConversationPin, CommParticipant, ) from app.plugins.builtins.kommunikation.rbac import CommRBAC from app.plugins.builtins.kommunikation.serializers import conversation_to_response logger = logging.getLogger(__name__) # ─── conversations ─── async def list_conversations( db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, include_archived: bool = False, ) -> list[dict[str, Any]]: """List all conversations for a user.""" # Get conversations where user is a participant result = await db.execute( select(CommConversation) .join(CommParticipant, CommParticipant.conversation_id == CommConversation.id) .where( CommParticipant.participant_id == user_id, CommParticipant.participant_type == "user", CommParticipant.left_at.is_(None), CommConversation.tenant_id == tenant_id, CommConversation.deleted_at.is_(None), ) .order_by(CommConversation.last_msg_at.desc().nullslast()) ) conversations = result.scalars().all() # Get user's pinned conversations pins_result = await db.execute( select(CommConversationPin).where( CommConversationPin.user_id == user_id, CommConversationPin.tenant_id == tenant_id, ) ) pinned_ids = {p.conversation_id for p in pins_result.scalars().all()} conv_list = [] for conv in conversations: if conv.is_archived and not include_archived: continue # Get participants parts_result = await db.execute( select(CommParticipant).where( CommParticipant.conversation_id == conv.id, CommParticipant.left_at.is_(None), ) ) participants = list(parts_result.scalars().all()) # Get unread count unread = await _get_unread_count(db, tenant_id, conv.id, user_id) conv_list.append( conversation_to_response( conv, participants, unread_count=unread, is_pinned_by_user=conv.id in pinned_ids ) ) # Sort: pinned first, then by last_msg_at conv_list.sort(key=lambda c: (not c["is_pinned"], c["last_msg_at"] or ""), reverse=False) # Actually: pinned first (True > False in reverse), then newest first conv_list.sort(key=lambda c: c["last_msg_at"] or "0000", reverse=True) conv_list.sort(key=lambda c: c["is_pinned"], reverse=True) return conv_list async def get_conversation( db: AsyncSession, tenant_id: uuid.UUID, conversation_id: uuid.UUID, user_id: uuid.UUID, ) -> dict[str, Any] | None: """Get a single conversation with participants.""" result = await db.execute( select(CommConversation).where( CommConversation.id == conversation_id, CommConversation.tenant_id == tenant_id, CommConversation.deleted_at.is_(None), ) ) conv = result.scalar_one_or_none() if conv is None: return None # Check user is participant if not await CommRBAC.is_participant(db, conversation_id, user_id): return None parts_result = await db.execute( select(CommParticipant).where( CommParticipant.conversation_id == conv.id, CommParticipant.left_at.is_(None), ) ) participants = list(parts_result.scalars().all()) # Check pinned pin_result = await db.execute( select(CommConversationPin).where( CommConversationPin.conversation_id == conv.id, CommConversationPin.user_id == user_id, ) ) is_pinned = pin_result.scalar_one_or_none() is not None unread = await _get_unread_count(db, tenant_id, conv.id, user_id) return conversation_to_response(conv, participants, unread_count=unread, is_pinned_by_user=is_pinned) async def create_conversation( db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, title: str | None = None, participant_ids: list[str] | None = None, is_direct: bool = False, initial_message: str | None = None, metadata: dict[str, Any] | None = None, ) -> dict[str, Any]: """Create a new conversation.""" conv = CommConversation( tenant_id=tenant_id, title=title, owner_id=user_id, is_direct=is_direct, created_by=user_id, created_by_type="user", metadata_=metadata or {}, ) from app.core.hooks import do_action await do_action("comm.conversation.before_create", tenant_id=tenant_id, user_id=user_id) db.add(conv) await db.flush() await do_action("comm.conversation.after_create", conversation_id=conv.id, tenant_id=tenant_id, user_id=user_id) # Add creator as admin creator = CommParticipant( tenant_id=tenant_id, conversation_id=conv.id, participant_id=user_id, participant_type="user", role="admin", ) db.add(creator) # Add other participants for pid_str in (participant_ids or []): try: pid = uuid.UUID(pid_str) if pid == user_id: continue p = CommParticipant( tenant_id=tenant_id, conversation_id=conv.id, participant_id=pid, participant_type="user", role="member", ) db.add(p) except ValueError: logger.warning(f"Invalid participant UUID: {pid_str}") await db.flush() # Send initial message if provided if initial_message: await send_message( db, tenant_id, conv.id, user_id, "user", content=initial_message, content_format="text", ) # Publish event event_bus = get_event_bus() await event_bus.publish("conversation.created", { "conversation_id": str(conv.id), "tenant_id": str(tenant_id), "created_by": str(user_id), }) # Get all participants for response parts_result = await db.execute( select(CommParticipant).where( CommParticipant.conversation_id == conv.id, CommParticipant.left_at.is_(None), ) ) participants = list(parts_result.scalars().all()) return conversation_to_response(conv, participants) async def update_conversation( db: AsyncSession, tenant_id: uuid.UUID, conversation_id: uuid.UUID, user_id: uuid.UUID, title: str | None = None, is_archived: bool | None = None, ) -> dict[str, Any] | None: """Update a conversation.""" result = await db.execute( select(CommConversation).where( CommConversation.id == conversation_id, CommConversation.tenant_id == tenant_id, CommConversation.deleted_at.is_(None), ) ) conv = result.scalar_one_or_none() if conv is None: return None # Check locked if conv.is_locked and title is not None: # Only the locking plugin can change title on locked conversations # Users cannot pass elif title is not None: conv.title = title conv.title_set_by = user_id if is_archived is not None: conv.is_archived = is_archived await db.flush() return await get_conversation(db, tenant_id, conversation_id, user_id) async def pin_conversation( db: AsyncSession, tenant_id: uuid.UUID, conversation_id: uuid.UUID, user_id: uuid.UUID, ) -> bool: """Pin a conversation for a user.""" existing = await db.execute( select(CommConversationPin).where( CommConversationPin.conversation_id == conversation_id, CommConversationPin.user_id == user_id, ) ) if existing.scalar_one_or_none() is None: pin = CommConversationPin( tenant_id=tenant_id, conversation_id=conversation_id, user_id=user_id, ) db.add(pin) await db.flush() return True async def unpin_conversation( db: AsyncSession, conversation_id: uuid.UUID, user_id: uuid.UUID, ) -> bool: """Unpin a conversation for a user.""" result = await db.execute( select(CommConversationPin).where( CommConversationPin.conversation_id == conversation_id, CommConversationPin.user_id == user_id, ) ) pin = result.scalar_one_or_none() if pin: await db.delete(pin) await db.flush() return True async def mute_conversation( db: AsyncSession, tenant_id: uuid.UUID, conversation_id: uuid.UUID, user_id: uuid.UUID, ) -> bool: """Mute a conversation for a user.""" existing = await db.execute( select(CommConversationMute).where( CommConversationMute.conversation_id == conversation_id, CommConversationMute.user_id == user_id, ) ) if existing.scalar_one_or_none() is None: mute = CommConversationMute( tenant_id=tenant_id, conversation_id=conversation_id, user_id=user_id, ) db.add(mute) await db.flush() return True async def unmute_conversation( db: AsyncSession, conversation_id: uuid.UUID, user_id: uuid.UUID, ) -> bool: """Unmute a conversation for a user.""" result = await db.execute( select(CommConversationMute).where( CommConversationMute.conversation_id == conversation_id, CommConversationMute.user_id == user_id, ) ) mute = result.scalar_one_or_none() if mute: await db.delete(mute) await db.flush() return True # ─── Participant Management ───