"""Message retrieval, sending and editing for the kommunikation plugin.""" from __future__ import annotations import logging import uuid from datetime import UTC, datetime from typing import Any from sqlalchemy import func, select, update from sqlalchemy.ext.asyncio import AsyncSession from app.core.event_bus import get_event_bus from app.plugins.builtins.kommunikation.models import ( CommConversation, CommMessage, CommMessageAttachment, CommMessageBlock, CommMessageEdit, CommMessageReaction, CommParticipant, ) from app.plugins.builtins.kommunikation.participant_registry import get_participant_registry from app.plugins.builtins.kommunikation.serializers import ( conversation_to_response, message_to_response, parse_mentions, participant_to_response, ) logger = logging.getLogger(__name__) MAX_TRIGGER_DEPTH = 3 # ─── 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 from app.core.hooks import do_action await do_action("comm.before_message", conversation_id=conversation_id, tenant_id=tenant_id, sender_id=sender_id) db.add(msg) await db.flush() await do_action("comm.after_message", message_id=msg.id, conversation_id=conversation_id, tenant_id=tenant_id, sender_id=sender_id) # 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(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) from app.core.hooks import do_action await do_action("comm.before_edit", message_id=message_id, tenant_id=tenant_id, user_id=user_id) # Update message msg.content = new_content msg.edited_at = datetime.now(UTC) await db.flush() await do_action("comm.after_edit", message_id=message_id, tenant_id=tenant_id, user_id=user_id) 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 from app.core.hooks import do_action await do_action("comm.before_delete", message_id=message_id) msg.deleted_at = datetime.now(UTC) await db.flush() await do_action("comm.after_delete", message_id=message_id) return True # ─── Reactions ───