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

1123 lines
33 KiB
Python
Raw Normal View History

"""Business logic for the kommunikation plugin."""
from __future__ import annotations
import logging
import re
import uuid
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import select, update, func, and_, or_
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.event_bus import get_event_bus
from app.plugins.builtins.kommunikation.models import (
CommConversation,
CommConversationMute,
CommConversationPin,
CommMessage,
CommMessageAttachment,
CommMessageBlock,
CommMessageEdit,
CommMessageRead,
CommMessageReaction,
CommParticipant,
)
from app.plugins.builtins.kommunikation.participant_registry import get_participant_registry
from app.plugins.builtins.kommunikation.rbac import CommRBAC
logger = logging.getLogger(__name__)
MAX_TRIGGER_DEPTH = 3
# ─── Mention Parsing ───
MENTION_PATTERN = re.compile(r"@(\w+)")
def parse_mentions(content: str) -> list[str]:
"""Parse @mentions from message content. Returns list of mentioned types/names."""
return MENTION_PATTERN.findall(content)
# ─── Conversation Helpers ───
def conversation_to_response(
conv: CommConversation,
participants: list[CommParticipant],
unread_count: int = 0,
is_pinned_by_user: bool = False,
) -> dict[str, Any]:
"""Convert a CommConversation to a response dict."""
return {
"id": str(conv.id),
"title": conv.title,
"is_locked": conv.is_locked,
"locked_by": conv.locked_by,
"is_direct": conv.is_direct,
"is_archived": conv.is_archived,
"is_pinned": is_pinned_by_user,
"created_by": str(conv.created_by) if conv.created_by else None,
"created_by_type": conv.created_by_type,
"last_msg_at": conv.last_msg_at.isoformat() if conv.last_msg_at else None,
"last_msg_preview": conv.last_msg_preview,
"last_msg_sender_type": conv.last_msg_sender_type,
"participants": [participant_to_response(p) for p in participants],
"unread_count": unread_count,
"metadata": conv.metadata_ or {},
}
def participant_to_response(p: CommParticipant) -> dict[str, Any]:
"""Convert a CommParticipant to a response dict."""
return {
"id": str(p.id),
"conversation_id": str(p.conversation_id),
"participant_id": str(p.participant_id) if p.participant_id else None,
"participant_type": p.participant_type,
"display_name": p.display_name,
"role": p.role,
"joined_at": p.joined_at.isoformat() if p.joined_at else None,
}
def message_to_response(
msg: CommMessage,
blocks: list[CommMessageBlock] | None = None,
attachments: list[CommMessageAttachment] | None = None,
reactions: list[CommMessageReaction] | None = None,
) -> dict[str, Any]:
"""Convert a CommMessage to a response dict."""
return {
"id": str(msg.id),
"conversation_id": str(msg.conversation_id),
"sender_id": str(msg.sender_id) if msg.sender_id else None,
"sender_type": msg.sender_type,
"content": msg.content,
"content_format": msg.content_format,
"metadata": msg.metadata_ or {},
"reply_to_id": str(msg.reply_to_id) if msg.reply_to_id else None,
"is_pinned": msg.is_pinned,
"created_at": msg.created_at.isoformat() if msg.created_at else None,
"edited_at": msg.edited_at.isoformat() if msg.edited_at else None,
"blocks": [
{
"id": str(b.id),
"block_type": b.block_type,
"block_data": b.block_data,
"sort_order": b.sort_order,
}
for b in (blocks or [])
],
"attachments": [
{
"id": str(a.id),
"file_id": str(a.file_id) if a.file_id else None,
"file_source": a.file_source,
"file_name": a.file_name,
"file_type": a.file_type,
"file_size": a.file_size,
"thumbnail_path": a.thumbnail_path,
}
for a in (attachments or [])
],
"reactions": [
{
"id": str(r.id),
"message_id": str(r.message_id),
"user_id": str(r.user_id),
"emoji": r.emoji,
}
for r in (reactions or [])
],
}
# ─── Conversation CRUD ───
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,
) -> dict[str, Any]:
"""Create a new conversation."""
conv = CommConversation(
tenant_id=tenant_id,
title=title,
is_direct=is_direct,
created_by=user_id,
created_by_type="user",
metadata_={},
)
db.add(conv)
await db.flush()
# 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 ───
async def add_participant(
db: AsyncSession,
tenant_id: uuid.UUID,
conversation_id: uuid.UUID,
participant_id: str,
participant_type: str = "user",
role: str = "member",
display_name: str | None = None,
) -> dict[str, Any] | None:
"""Add a participant to a conversation."""
try:
pid = uuid.UUID(participant_id) if participant_type == "user" else None
except ValueError:
return None
existing = await db.execute(
select(CommParticipant).where(
CommParticipant.conversation_id == conversation_id,
CommParticipant.participant_id == pid if pid else CommParticipant.participant_type == participant_type,
CommParticipant.participant_type == participant_type,
CommParticipant.left_at.is_(None),
)
)
if existing.scalar_one_or_none() is not None:
return None # Already a participant
p = CommParticipant(
tenant_id=tenant_id,
conversation_id=conversation_id,
participant_id=pid,
participant_type=participant_type,
role=role,
display_name=display_name,
)
db.add(p)
await db.flush()
# Publish event
event_bus = get_event_bus()
await event_bus.publish("participant.joined", {
"conversation_id": str(conversation_id),
"participant_id": participant_id,
"participant_type": participant_type,
"tenant_id": str(tenant_id),
})
return participant_to_response(p)
async def remove_participant(
db: AsyncSession,
conversation_id: uuid.UUID,
participant_id: uuid.UUID,
) -> bool:
"""Remove a participant from a conversation (set left_at)."""
result = await db.execute(
select(CommParticipant).where(
CommParticipant.conversation_id == conversation_id,
CommParticipant.participant_id == participant_id,
CommParticipant.participant_type == "user",
CommParticipant.left_at.is_(None),
)
)
p = result.scalar_one_or_none()
if p is None:
return False
p.left_at = datetime.now(timezone.utc)
await db.flush()
event_bus = get_event_bus()
await event_bus.publish("participant.left", {
"conversation_id": str(conversation_id),
"participant_id": str(participant_id),
})
return True
async def change_role(
db: AsyncSession,
conversation_id: uuid.UUID,
participant_id: uuid.UUID,
new_role: str,
) -> dict[str, Any] | None:
"""Change a participant's role."""
result = await db.execute(
select(CommParticipant).where(
CommParticipant.conversation_id == conversation_id,
CommParticipant.participant_id == participant_id,
CommParticipant.participant_type == "user",
CommParticipant.left_at.is_(None),
)
)
p = result.scalar_one_or_none()
if p is None:
return None
p.role = new_role
await db.flush()
return participant_to_response(p)
# ─── Messages ───
async def get_messages(
db: AsyncSession,
tenant_id: uuid.UUID,
conversation_id: uuid.UUID,
page: int = 1,
page_size: int = 50,
before_id: uuid.UUID | None = None,
) -> dict[str, Any]:
"""Get paginated messages for a conversation."""
query = select(CommMessage).where(
CommMessage.conversation_id == conversation_id,
CommMessage.tenant_id == tenant_id,
CommMessage.deleted_at.is_(None),
).order_by(CommMessage.created_at.desc())
if before_id:
before_msg = await db.execute(
select(CommMessage).where(CommMessage.id == before_id)
)
before = before_msg.scalar_one_or_none()
if before:
query = query.where(CommMessage.created_at < before.created_at)
query = query.offset((page - 1) * page_size).limit(page_size)
result = await db.execute(query)
messages = list(result.scalars().all())
# Get blocks, attachments, reactions for each message
msg_ids = [m.id for m in messages]
blocks_map: dict[uuid.UUID, list] = {}
attachments_map: dict[uuid.UUID, list] = {}
reactions_map: dict[uuid.UUID, list] = {}
if msg_ids:
blocks_result = await db.execute(
select(CommMessageBlock).where(
CommMessageBlock.message_id.in_(msg_ids),
CommMessageBlock.deleted_at.is_(None),
).order_by(CommMessageBlock.sort_order)
)
for b in blocks_result.scalars().all():
blocks_map.setdefault(b.message_id, []).append(b)
atts_result = await db.execute(
select(CommMessageAttachment).where(
CommMessageAttachment.message_id.in_(msg_ids),
CommMessageAttachment.deleted_at.is_(None),
)
)
for a in atts_result.scalars().all():
attachments_map.setdefault(a.message_id, []).append(a)
reactions_result = await db.execute(
select(CommMessageReaction).where(
CommMessageReaction.message_id.in_(msg_ids),
)
)
for r in reactions_result.scalars().all():
reactions_map.setdefault(r.message_id, []).append(r)
items = []
for msg in reversed(messages): # chronological order
items.append(
message_to_response(
msg,
blocks=blocks_map.get(msg.id, []),
attachments=attachments_map.get(msg.id, []),
reactions=reactions_map.get(msg.id, []),
)
)
# Total count
count_result = await db.execute(
select(func.count()).select_from(CommMessage).where(
CommMessage.conversation_id == conversation_id,
CommMessage.tenant_id == tenant_id,
CommMessage.deleted_at.is_(None),
)
)
total = count_result.scalar() or 0
has_more = (page * page_size) < total
return {"items": items, "total": total, "page": page, "has_more": has_more}
async def send_message(
db: AsyncSession,
tenant_id: uuid.UUID,
conversation_id: uuid.UUID,
sender_id: uuid.UUID | None,
sender_type: str,
content: str = "",
content_format: str = "text",
blocks: list[dict[str, Any]] | None = None,
reply_to_id: str | None = None,
attachments: list[dict[str, Any]] | None = None,
metadata: dict[str, Any] | None = None,
trigger_depth: int = 0,
) -> dict[str, Any]:
"""Send a message to a conversation and trigger participant handlers."""
# Create message
msg = CommMessage(
tenant_id=tenant_id,
conversation_id=conversation_id,
sender_id=sender_id,
sender_type=sender_type,
content=content,
content_format=content_format,
metadata_=metadata or {},
)
if reply_to_id:
try:
msg.reply_to_id = uuid.UUID(reply_to_id)
except ValueError:
pass
db.add(msg)
await db.flush()
# Create blocks
if 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)
# Create attachments
if attachments:
for att in attachments:
a = CommMessageAttachment(
tenant_id=tenant_id,
message_id=msg.id,
file_id=uuid.UUID(att["file_id"]) if att.get("file_id") else None,
file_source=att.get("file_source", "comm"),
file_name=att.get("file_name", ""),
file_type=att.get("file_type", "application/octet-stream"),
file_size=att.get("file_size"),
)
db.add(a)
await db.flush()
# Update conversation last_msg
await db.execute(
update(CommConversation)
.where(CommConversation.id == conversation_id)
.values(
last_msg_at=datetime.now(timezone.utc),
last_msg_preview=content[:200] if content else "",
last_msg_sender_type=sender_type,
)
)
# Publish event
event_bus = get_event_bus()
await event_bus.publish("message.received", {
"conversation_id": str(conversation_id),
"message_id": str(msg.id),
"sender_type": sender_type,
"tenant_id": str(tenant_id),
"content": content,
"trigger_depth": trigger_depth,
})
# Trigger participant handlers (if not at max depth)
if trigger_depth < MAX_TRIGGER_DEPTH:
await _trigger_participants(
db, tenant_id, conversation_id, msg, trigger_depth
)
# Load blocks/attachments/reactions for response
blocks_result = await db.execute(
select(CommMessageBlock).where(
CommMessageBlock.message_id == msg.id,
CommMessageBlock.deleted_at.is_(None),
).order_by(CommMessageBlock.sort_order)
)
msg_blocks = list(blocks_result.scalars().all())
atts_result = await db.execute(
select(CommMessageAttachment).where(
CommMessageAttachment.message_id == msg.id,
CommMessageAttachment.deleted_at.is_(None),
)
)
msg_atts = list(atts_result.scalars().all())
return message_to_response(msg, blocks=msg_blocks, attachments=msg_atts)
async def _trigger_participants(
db: AsyncSession,
tenant_id: uuid.UUID,
conversation_id: uuid.UUID,
message: CommMessage,
trigger_depth: int,
) -> None:
"""Trigger participant handlers for non-user participants."""
# Get conversation participants
result = await db.execute(
select(CommParticipant).where(
CommParticipant.conversation_id == conversation_id,
CommParticipant.left_at.is_(None),
CommParticipant.participant_type != "user",
)
)
non_user_participants = list(result.scalars().all())
if not non_user_participants:
return
# Get conversation info
conv_result = await db.execute(
select(CommConversation).where(CommConversation.id == conversation_id)
)
conv = conv_result.scalar_one_or_none()
if conv is None:
return
# Parse mentions
mentions = parse_mentions(message.content)
# Build conversation dict
all_parts_result = await db.execute(
select(CommParticipant).where(
CommParticipant.conversation_id == conversation_id,
CommParticipant.left_at.is_(None),
)
)
all_parts = [participant_to_response(p) for p in all_parts_result.scalars().all()]
conv_dict = conversation_to_response(conv, [])
conv_dict["participants"] = all_parts
msg_dict = message_to_response(message)
context = {"tenant_id": str(tenant_id), "trigger_depth": trigger_depth}
registry = get_participant_registry()
for p in non_user_participants:
handler = registry.get_handler(p.participant_type)
if handler is None:
continue
try:
responses = await handler.on_message_received(
conversation_id=conversation_id,
message=msg_dict,
conversation=conv_dict,
mentions=mentions,
context=context,
)
if responses:
for resp in responses:
await send_message(
db,
tenant_id,
conversation_id,
sender_id=None,
sender_type=p.participant_type,
content=resp.get("content", ""),
content_format=resp.get("content_format", "text"),
blocks=resp.get("blocks"),
metadata={
**(resp.get("metadata") or {}),
"triggered_by": str(message.id),
"trigger_depth": trigger_depth + 1,
},
trigger_depth=trigger_depth + 1,
)
except Exception:
logger.exception(
f"Participant handler error for type {p.participant_type}"
)
async def edit_message(
db: AsyncSession,
tenant_id: uuid.UUID,
message_id: uuid.UUID,
user_id: uuid.UUID,
new_content: str,
) -> dict[str, Any] | None:
"""Edit a message, storing the old version in history."""
result = await db.execute(
select(CommMessage).where(
CommMessage.id == message_id,
CommMessage.tenant_id == tenant_id,
CommMessage.deleted_at.is_(None),
)
)
msg = result.scalar_one_or_none()
if msg is None:
return None
# Get old blocks
blocks_result = await db.execute(
select(CommMessageBlock).where(
CommMessageBlock.message_id == message_id,
CommMessageBlock.deleted_at.is_(None),
)
)
old_blocks = [b.block_data for b in blocks_result.scalars().all()]
# Save edit history
edit = CommMessageEdit(
tenant_id=tenant_id,
message_id=message_id,
old_content=msg.content,
old_blocks=old_blocks,
edited_by=user_id,
)
db.add(edit)
# Update message
msg.content = new_content
msg.edited_at = datetime.now(timezone.utc)
await db.flush()
return message_to_response(msg)
async def delete_message(
db: AsyncSession,
message_id: uuid.UUID,
) -> bool:
"""Soft-delete a message."""
result = await db.execute(
select(CommMessage).where(CommMessage.id == message_id)
)
msg = result.scalar_one_or_none()
if msg is None:
return False
msg.deleted_at = datetime.now(timezone.utc)
await db.flush()
return True
# ─── Reactions ───
async def add_reaction(
db: AsyncSession,
tenant_id: uuid.UUID,
message_id: uuid.UUID,
user_id: uuid.UUID,
emoji: str,
) -> dict[str, Any] | None:
"""Add an emoji reaction to a message."""
existing = await db.execute(
select(CommMessageReaction).where(
CommMessageReaction.message_id == message_id,
CommMessageReaction.user_id == user_id,
CommMessageReaction.emoji == emoji,
)
)
if existing.scalar_one_or_none() is not None:
return None # Already reacted
r = CommMessageReaction(
tenant_id=tenant_id,
message_id=message_id,
user_id=user_id,
emoji=emoji,
)
db.add(r)
await db.flush()
event_bus = get_event_bus()
await event_bus.publish("reaction.added", {
"message_id": str(message_id),
"emoji": emoji,
"user_id": str(user_id),
})
return {
"id": str(r.id),
"message_id": str(r.message_id),
"user_id": str(r.user_id),
"emoji": r.emoji,
}
async def remove_reaction(
db: AsyncSession,
message_id: uuid.UUID,
user_id: uuid.UUID,
emoji: str,
) -> bool:
"""Remove an emoji reaction."""
result = await db.execute(
select(CommMessageReaction).where(
CommMessageReaction.message_id == message_id,
CommMessageReaction.user_id == user_id,
CommMessageReaction.emoji == emoji,
)
)
r = result.scalar_one_or_none()
if r is None:
return False
await db.delete(r)
await db.flush()
return True
# ─── Read State ───
async def mark_read(
db: AsyncSession,
tenant_id: uuid.UUID,
conversation_id: uuid.UUID,
user_id: uuid.UUID,
last_read_msg_id: str | None = None,
) -> bool:
"""Mark conversation as read up to a message."""
result = await db.execute(
select(CommMessageRead).where(
CommMessageRead.conversation_id == conversation_id,
CommMessageRead.user_id == user_id,
)
)
read = result.scalar_one_or_none()
msg_id = uuid.UUID(last_read_msg_id) if last_read_msg_id else None
if read is None:
read = CommMessageRead(
tenant_id=tenant_id,
conversation_id=conversation_id,
user_id=user_id,
last_read_msg_id=msg_id,
)
db.add(read)
else:
read.last_read_msg_id = msg_id
read.last_read_at = datetime.now(timezone.utc)
await db.flush()
return True
async def _get_unread_count(
db: AsyncSession,
tenant_id: uuid.UUID,
conversation_id: uuid.UUID,
user_id: uuid.UUID,
) -> int:
"""Get unread message count for a user in a conversation."""
# Get last read message
read_result = await db.execute(
select(CommMessageRead).where(
CommMessageRead.conversation_id == conversation_id,
CommMessageRead.user_id == user_id,
)
)
read = read_result.scalar_one_or_none()
query = select(func.count()).select_from(CommMessage).where(
CommMessage.conversation_id == conversation_id,
CommMessage.tenant_id == tenant_id,
CommMessage.deleted_at.is_(None),
CommMessage.sender_type != "system", # Don't count system messages? Or count all?
)
if read and read.last_read_at:
query = query.where(CommMessage.created_at > read.last_read_at)
result = await db.execute(query)
return result.scalar() or 0
# ─── Plugin Room Creation ───
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 == 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,
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)