395 lines
12 KiB
Python
395 lines
12 KiB
Python
"""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 ───
|