- Add ChannelType.WEB = 'web' to shared/models/message.py - Add webchat_response_key() to shared/redis_keys.py - Create WebConversation and WebConversationMessage ORM models (SQLAlchemy 2.0) - Create migration 008_web_chat.py with RLS, indexes, and channel_type CHECK update - Pop conversation_id/portal_user_id extras in handle_message before model_validate - Add web case to _build_response_extras and _send_response (Redis pub-sub publish) - Import webchat_response_key in orchestrator/tasks.py - Write 19 unit tests covering CHAT-01 through CHAT-05 (all pass)
131 lines
4.6 KiB
Python
131 lines
4.6 KiB
Python
"""
|
|
KonstructMessage — the unified internal message format.
|
|
|
|
All channel adapters (Slack, WhatsApp, Mattermost, etc.) normalize inbound
|
|
events into this format before passing them to the Message Router. The core
|
|
business logic never depends on which messaging platform a message came from.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from datetime import datetime
|
|
from enum import StrEnum
|
|
from typing import Any
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
class ChannelType(StrEnum):
|
|
"""Supported messaging channels."""
|
|
|
|
SLACK = "slack"
|
|
WHATSAPP = "whatsapp"
|
|
MATTERMOST = "mattermost"
|
|
ROCKETCHAT = "rocketchat"
|
|
TEAMS = "teams"
|
|
TELEGRAM = "telegram"
|
|
SIGNAL = "signal"
|
|
WEB = "web"
|
|
|
|
|
|
class MediaType(StrEnum):
|
|
"""Supported media attachment types."""
|
|
|
|
IMAGE = "image"
|
|
DOCUMENT = "document"
|
|
AUDIO = "audio"
|
|
VIDEO = "video"
|
|
|
|
|
|
class MediaAttachment(BaseModel):
|
|
"""
|
|
A media file attached to a message (image, document, audio, or video).
|
|
|
|
After normalization, `url` contains a placeholder media ID URL from the
|
|
channel's API. The channel adapter downloads the media and stores it in
|
|
MinIO, then updates `storage_key` and `url` with the final presigned URL.
|
|
"""
|
|
|
|
media_type: MediaType = Field(description="Type of media: image, document, audio, or video")
|
|
url: str | None = Field(
|
|
default=None,
|
|
description="Download URL — placeholder media ID URL after normalization, presigned MinIO URL after storage",
|
|
)
|
|
storage_key: str | None = Field(
|
|
default=None,
|
|
description="MinIO object key: {tenant_id}/{agent_id}/{message_id}/{filename}",
|
|
)
|
|
mime_type: str | None = Field(default=None, description="MIME type (e.g. image/jpeg)")
|
|
filename: str | None = Field(default=None, description="Original filename if available")
|
|
size_bytes: int | None = Field(default=None, description="File size in bytes if available")
|
|
|
|
|
|
class SenderInfo(BaseModel):
|
|
"""Information about the message sender."""
|
|
|
|
user_id: str = Field(description="Channel-native user ID (e.g. Slack user ID U12345)")
|
|
display_name: str = Field(description="Human-readable display name")
|
|
email: str | None = Field(default=None, description="Sender email if available")
|
|
is_bot: bool = Field(default=False, description="True if sender is a bot/automation")
|
|
|
|
|
|
class MessageContent(BaseModel):
|
|
"""The content of a message — text and optional attachments."""
|
|
|
|
text: str = Field(description="Plain text content of the message")
|
|
html: str | None = Field(default=None, description="HTML-formatted content if available")
|
|
attachments: list[dict[str, Any]] = Field(
|
|
default_factory=list,
|
|
description="File attachments, images, or structured payloads",
|
|
)
|
|
mentions: list[str] = Field(
|
|
default_factory=list,
|
|
description="List of user/bot IDs mentioned in the message",
|
|
)
|
|
media: list[MediaAttachment] = Field(
|
|
default_factory=list,
|
|
description="Typed media attachments (images, documents, audio, video)",
|
|
)
|
|
|
|
|
|
class KonstructMessage(BaseModel):
|
|
"""
|
|
Unified internal message format for Konstruct.
|
|
|
|
All channel adapters normalize events into this format. Downstream services
|
|
(Router, Orchestrator) operate exclusively on KonstructMessage — they never
|
|
inspect channel-specific fields directly.
|
|
|
|
`tenant_id` is None immediately after normalization. The Message Router
|
|
populates it via channel_connections lookup before forwarding.
|
|
"""
|
|
|
|
id: str = Field(
|
|
default_factory=lambda: str(uuid.uuid4()),
|
|
description="Unique message ID (UUID)",
|
|
)
|
|
tenant_id: str | None = Field(
|
|
default=None,
|
|
description="Konstruct tenant ID — populated by Message Router after resolution",
|
|
)
|
|
channel: ChannelType = Field(description="Source messaging channel")
|
|
channel_metadata: dict[str, Any] = Field(
|
|
description="Channel-specific identifiers: workspace_id, channel_id, bot_user_id, etc."
|
|
)
|
|
sender: SenderInfo = Field(description="Message sender information")
|
|
content: MessageContent = Field(description="Message content")
|
|
timestamp: datetime = Field(description="Message timestamp (UTC)")
|
|
thread_id: str | None = Field(
|
|
default=None,
|
|
description="Thread identifier for threaded conversations (e.g. Slack thread_ts)",
|
|
)
|
|
reply_to: str | None = Field(
|
|
default=None,
|
|
description="Parent message ID if this is a reply",
|
|
)
|
|
context: dict[str, Any] = Field(
|
|
default_factory=dict,
|
|
description="Extracted intent, entities, sentiment — populated by downstream processors",
|
|
)
|