feat: Unified Messaging System — kommunikation plugin, AI/Proactive/System participants, MessageSidebar, Rich Content Renderer
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
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
"""DMS Bridge — integrates with the DMS plugin for file storage."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import UploadFile
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.plugins.builtins.dms.models import File as DmsFile, Folder
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DMS_STORAGE_BASE = os.environ.get("DMS_STORAGE_BASE", "/tmp/dms")
|
||||
COMM_FOLDER_NAME = "_kommunikation"
|
||||
MAX_DIRECT_UPLOAD = 100 * 1024 * 1024 # 100 MB — larger files must be DMS references
|
||||
|
||||
|
||||
class DmsBridge:
|
||||
"""Bridge to the DMS plugin for attachment storage."""
|
||||
|
||||
@staticmethod
|
||||
async def ensure_comm_folder(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
) -> Folder:
|
||||
"""Ensure the _kommunikation root folder exists in DMS."""
|
||||
result = await db.execute(
|
||||
select(Folder).where(
|
||||
Folder.name == COMM_FOLDER_NAME,
|
||||
Folder.parent_id.is_(None),
|
||||
Folder.tenant_id == tenant_id,
|
||||
Folder.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
folder = result.scalar_one_or_none()
|
||||
if folder is None:
|
||||
folder = Folder(
|
||||
tenant_id=tenant_id,
|
||||
name=COMM_FOLDER_NAME,
|
||||
parent_id=None,
|
||||
created_by=user_id,
|
||||
)
|
||||
db.add(folder)
|
||||
await db.flush()
|
||||
return folder
|
||||
|
||||
@staticmethod
|
||||
async def ensure_conversation_folder(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
conversation_id: uuid.UUID,
|
||||
) -> Folder:
|
||||
"""Ensure a sub-folder for a specific conversation exists."""
|
||||
root_folder = await DmsBridge.ensure_comm_folder(db, tenant_id, user_id)
|
||||
conv_name = str(conversation_id)
|
||||
result = await db.execute(
|
||||
select(Folder).where(
|
||||
Folder.name == conv_name,
|
||||
Folder.parent_id == root_folder.id,
|
||||
Folder.tenant_id == tenant_id,
|
||||
Folder.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
folder = result.scalar_one_or_none()
|
||||
if folder is None:
|
||||
folder = Folder(
|
||||
tenant_id=tenant_id,
|
||||
name=conv_name,
|
||||
parent_id=root_folder.id,
|
||||
created_by=user_id,
|
||||
)
|
||||
db.add(folder)
|
||||
await db.flush()
|
||||
return folder
|
||||
|
||||
@staticmethod
|
||||
async def store_attachment(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
conversation_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
file: UploadFile,
|
||||
) -> dict[str, Any]:
|
||||
"""Store an uploaded file in the DMS under _kommunikation/{conversation_id}/.
|
||||
|
||||
Returns dict with file_id, file_name, file_type, file_size.
|
||||
"""
|
||||
# Check size limit
|
||||
content = await file.read()
|
||||
file_size = len(content)
|
||||
|
||||
if file_size > MAX_DIRECT_UPLOAD:
|
||||
raise ValueError(
|
||||
f"File size {file_size} exceeds direct upload limit ({MAX_DIRECT_UPLOAD} bytes). "
|
||||
f"Use DMS reference instead."
|
||||
)
|
||||
|
||||
# Ensure conversation folder
|
||||
folder = await DmsBridge.ensure_conversation_folder(
|
||||
db, tenant_id, user_id, conversation_id
|
||||
)
|
||||
|
||||
# Save file to disk
|
||||
file_id = uuid.uuid4()
|
||||
file_ext = os.path.splitext(file.filename or "")[1] or ""
|
||||
storage_path = os.path.join(
|
||||
DMS_STORAGE_BASE,
|
||||
str(tenant_id),
|
||||
str(folder.id),
|
||||
f"{file_id}{file_ext}",
|
||||
)
|
||||
os.makedirs(os.path.dirname(storage_path), exist_ok=True)
|
||||
with open(storage_path, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
# Create DMS file record
|
||||
dms_file = DmsFile(
|
||||
tenant_id=tenant_id,
|
||||
name=file.filename or f"{file_id}",
|
||||
folder_id=folder.id,
|
||||
uploaded_by=user_id,
|
||||
mime_type=file.content_type or "application/octet-stream",
|
||||
size_bytes=file_size,
|
||||
storage_path=storage_path,
|
||||
)
|
||||
db.add(dms_file)
|
||||
await db.flush()
|
||||
|
||||
return {
|
||||
"file_id": str(dms_file.id),
|
||||
"file_name": dms_file.name,
|
||||
"file_type": dms_file.mime_type,
|
||||
"file_size": dms_file.size_bytes,
|
||||
"file_source": "comm",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def reference_external_file(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
file_id: uuid.UUID,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Reference an existing DMS file without copying it.
|
||||
|
||||
Returns dict with file metadata or None if file not found.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(DmsFile).where(
|
||||
DmsFile.id == file_id,
|
||||
DmsFile.tenant_id == tenant_id,
|
||||
DmsFile.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
dms_file = result.scalar_one_or_none()
|
||||
if dms_file is None:
|
||||
return None
|
||||
|
||||
return {
|
||||
"file_id": str(dms_file.id),
|
||||
"file_name": dms_file.name,
|
||||
"file_type": dms_file.mime_type,
|
||||
"file_size": dms_file.size_bytes,
|
||||
"file_source": "dms",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def get_file(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
file_id: uuid.UUID,
|
||||
) -> DmsFile | None:
|
||||
"""Get a DMS file by ID."""
|
||||
result = await db.execute(
|
||||
select(DmsFile).where(
|
||||
DmsFile.id == file_id,
|
||||
DmsFile.tenant_id == tenant_id,
|
||||
DmsFile.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
Reference in New Issue
Block a user