cc3ac9a43d
Phase 1: Backend plugin kommunikation (13 files, 10 tables, REST API, WebSocket, RBAC, DMS Bridge, Participant Registry, Mini-App Registry, Search Provider) Phase 2: AI plugins as participants (ai_assistant + ai_proactive dock as participants, heartbeat job) Phase 3: system_notif plugin (system events → chat messages, pinned System room) Phase 4: Frontend MessageSidebar (replaces AISidebar, same design, comm API client, WebSocket hook, commStore) Phase 5: Rich Content Block Renderer (11 components: Markdown, HTML, Image, Audio, Video, File, ActionCard, ContactCard, MiniApp, BlockRenderer) BasePlugin: added services property + _container in on_activate
1123 lines
33 KiB
Python
1123 lines
33 KiB
Python
"""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)
|