344 lines
10 KiB
Python
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 ───
|