346 lines
9.7 KiB
Python
346 lines
9.7 KiB
Python
"""Plugin room creation and system channels for the kommunikation plugin."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import uuid
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
|
|
from sqlalchemy import and_, select, update
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.event_bus import get_event_bus
|
|
from app.plugins.builtins.kommunikation.conversations import get_conversation
|
|
from app.plugins.builtins.kommunikation.models import (
|
|
CommConversation,
|
|
CommConversationPin,
|
|
CommMessage,
|
|
CommMessageBlock,
|
|
CommParticipant,
|
|
)
|
|
from app.plugins.builtins.kommunikation.serializers import conversation_to_response
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
# ─── plugin_rooms ───
|
|
|
|
|
|
|
|
async def find_locked_room_id(
|
|
db: AsyncSession,
|
|
tenant_id: uuid.UUID,
|
|
plugin_name: str,
|
|
title: str,
|
|
) -> uuid.UUID | None:
|
|
"""Find the conversation ID of a locked plugin room by tenant and title.
|
|
|
|
Matches the same room semantics as ``create_plugin_room``: locked rooms
|
|
are owned by the plugin (``locked_by == plugin_name``) and soft-deleted
|
|
conversations are excluded. Returns ``None`` when no room exists.
|
|
"""
|
|
result = await db.execute(
|
|
select(CommConversation.id).where(
|
|
CommConversation.tenant_id == tenant_id,
|
|
CommConversation.title == title,
|
|
CommConversation.is_locked.is_(True),
|
|
CommConversation.locked_by == plugin_name,
|
|
CommConversation.deleted_at.is_(None),
|
|
)
|
|
)
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
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.is_(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,
|
|
owner_id=user_id,
|
|
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)
|
|
|
|
|
|
|
|
# ─── System Channel ───
|
|
|
|
|
|
async def get_or_create_system_channel(
|
|
db: AsyncSession,
|
|
tenant_id: uuid.UUID,
|
|
) -> CommConversation:
|
|
"""Get or create the tenant-wide system channel.
|
|
|
|
The system channel is a locked, is_system=True conversation that serves as
|
|
the central destination for system notifications, user alerts, and agent messages.
|
|
All users of the tenant are automatically added as participants.
|
|
"""
|
|
result = await db.execute(
|
|
select(CommConversation).where(
|
|
CommConversation.tenant_id == tenant_id,
|
|
CommConversation.is_system.is_(True),
|
|
CommConversation.deleted_at.is_(None),
|
|
)
|
|
)
|
|
conv = result.scalar_one_or_none()
|
|
if conv is not None:
|
|
return conv
|
|
|
|
# Create the system channel
|
|
conv = CommConversation(
|
|
tenant_id=tenant_id,
|
|
title="System Channel",
|
|
is_pinned=False,
|
|
is_locked=True,
|
|
is_direct=False,
|
|
is_archived=False,
|
|
is_system=True,
|
|
created_by=None,
|
|
created_by_type="system",
|
|
metadata_={},
|
|
)
|
|
db.add(conv)
|
|
await db.flush()
|
|
|
|
# Add all tenant users as participants
|
|
from app.models.user import User, UserTenant
|
|
|
|
users_result = await db.execute(
|
|
select(User.id)
|
|
.join(UserTenant, UserTenant.user_id == User.id)
|
|
.where(UserTenant.tenant_id == tenant_id)
|
|
)
|
|
user_ids = [row[0] for row in users_result.all()]
|
|
|
|
for uid in user_ids:
|
|
p = CommParticipant(
|
|
tenant_id=tenant_id,
|
|
conversation_id=conv.id,
|
|
participant_id=uid,
|
|
participant_type="user",
|
|
role="member",
|
|
)
|
|
db.add(p)
|
|
|
|
await db.flush()
|
|
return conv
|
|
|
|
|
|
async def post_system_message(
|
|
db: AsyncSession,
|
|
tenant_id: uuid.UUID,
|
|
user_id: uuid.UUID,
|
|
message_type: str,
|
|
title: str,
|
|
body: str | None = None,
|
|
entity_type: str | None = None,
|
|
entity_id: uuid.UUID | None = None,
|
|
severity: str = "info",
|
|
) -> CommMessage | None:
|
|
"""Post a typed system message to the tenant system channel.
|
|
|
|
Creates a CommMessage in the system channel with:
|
|
- A text block containing title + body
|
|
- An action_card block with deep-link if entity_type/entity_id is set
|
|
- Block/message metadata: notification_type, severity, entity_ref
|
|
|
|
Returns the created CommMessage, or None if the user has muted this type.
|
|
"""
|
|
# Check user preferences — reuse the notification preference system
|
|
from app.models.notification import NotificationPreference, NotificationType
|
|
|
|
pref = await db.execute(
|
|
select(NotificationPreference).where(
|
|
and_(
|
|
NotificationPreference.user_id == user_id,
|
|
NotificationPreference.type_key == message_type,
|
|
NotificationPreference.tenant_id == tenant_id,
|
|
)
|
|
)
|
|
)
|
|
pref_row = pref.scalar_one_or_none()
|
|
|
|
if pref_row and not pref_row.is_enabled:
|
|
return None
|
|
|
|
if not pref_row:
|
|
type_def = await db.execute(
|
|
select(NotificationType).where(NotificationType.type_key == message_type)
|
|
)
|
|
type_row = type_def.scalar_one_or_none()
|
|
if type_row and not type_row.is_enabled_by_default:
|
|
return None
|
|
|
|
# Get or create system channel
|
|
conv = await get_or_create_system_channel(db, tenant_id)
|
|
|
|
# Build message content
|
|
content = title
|
|
if body:
|
|
content = f"{title}\n{body}"
|
|
|
|
# Build metadata
|
|
msg_metadata: dict[str, Any] = {
|
|
"notification_type": message_type,
|
|
"severity": severity,
|
|
"target_user_id": str(user_id),
|
|
}
|
|
if entity_type and entity_id:
|
|
msg_metadata["entity_ref"] = {
|
|
"entity_type": entity_type,
|
|
"entity_id": str(entity_id),
|
|
}
|
|
|
|
# Build blocks
|
|
blocks: list[dict[str, Any]] = [
|
|
{
|
|
"block_type": "text",
|
|
"block_data": {"text": content, "title": title, "body": body or ""},
|
|
}
|
|
]
|
|
if entity_type and entity_id:
|
|
blocks.append(
|
|
{
|
|
"block_type": "action_card",
|
|
"block_data": {
|
|
"label": "Open",
|
|
"entity_type": entity_type,
|
|
"entity_id": str(entity_id),
|
|
},
|
|
}
|
|
)
|
|
|
|
# Create message directly (not via send_message to avoid trigger_depth issues)
|
|
msg = CommMessage(
|
|
tenant_id=tenant_id,
|
|
conversation_id=conv.id,
|
|
sender_id=None,
|
|
sender_type="system",
|
|
content=content,
|
|
content_format="text",
|
|
metadata_=msg_metadata,
|
|
)
|
|
db.add(msg)
|
|
await db.flush()
|
|
|
|
# Create 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)
|
|
|
|
await db.flush()
|
|
|
|
# Update conversation last_msg
|
|
|
|
await db.execute(
|
|
update(CommConversation)
|
|
.where(CommConversation.id == conv.id)
|
|
.values(
|
|
last_msg_at=datetime.now(UTC),
|
|
last_msg_preview=content[:200],
|
|
last_msg_sender_type="system",
|
|
)
|
|
)
|
|
|
|
# Publish event
|
|
event_bus = get_event_bus()
|
|
await event_bus.publish("system.message.posted", {
|
|
"conversation_id": str(conv.id),
|
|
"message_id": str(msg.id),
|
|
"tenant_id": str(tenant_id),
|
|
"user_id": str(user_id),
|
|
"message_type": message_type,
|
|
"severity": severity,
|
|
})
|
|
|
|
return msg
|