Files
konstruct/packages/shared/shared/models/message.py
Adolfo Delorenzo c72beb916b feat(06-01): add web channel type, Redis key, ORM models, migration, and tests
- 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)
2026-03-25 10:26:34 -06:00

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",
)