Files
leocrm/app/plugins/builtins/kommunikation/conversations.py
T

344 lines
10 KiB
Python

"""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 ───