diff --git a/packages/gateway/gateway/channels/slack_media.py b/packages/gateway/gateway/channels/slack_media.py new file mode 100644 index 0000000..2ec0c90 --- /dev/null +++ b/packages/gateway/gateway/channels/slack_media.py @@ -0,0 +1,258 @@ +""" +Slack file_share media extraction helpers. + +Handles Slack file_share events by: + 1. Detecting file_share subtype in Slack events + 2. Downloading files from Slack's private download URL using the bot token + 3. Uploading files to MinIO with tenant-isolated keys + 4. Building MediaAttachment objects with correct media_type, filename, mime_type + +Storage key format: {tenant_id}/{agent_id}/{message_id}/{filename} + +This module is intentionally import-side-effect free — no boto3 or httpx +imports at the module level to keep import times fast. Both are imported +inside the async function that requires them. +""" + +from __future__ import annotations + +import logging + +from shared.models.message import MediaAttachment, MediaType + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# MIME type → MediaType mapping +# --------------------------------------------------------------------------- + +_MIME_TO_MEDIA_TYPE: dict[str, MediaType] = { + # Images + "image/jpeg": MediaType.IMAGE, + "image/jpg": MediaType.IMAGE, + "image/png": MediaType.IMAGE, + "image/gif": MediaType.IMAGE, + "image/webp": MediaType.IMAGE, + "image/bmp": MediaType.IMAGE, + "image/tiff": MediaType.IMAGE, + "image/svg+xml": MediaType.IMAGE, + # Audio + "audio/mpeg": MediaType.AUDIO, + "audio/mp3": MediaType.AUDIO, + "audio/ogg": MediaType.AUDIO, + "audio/wav": MediaType.AUDIO, + "audio/webm": MediaType.AUDIO, + "audio/aac": MediaType.AUDIO, + # Video + "video/mp4": MediaType.VIDEO, + "video/webm": MediaType.VIDEO, + "video/ogg": MediaType.VIDEO, + "video/quicktime": MediaType.VIDEO, + "video/x-msvideo": MediaType.VIDEO, +} + + +def media_type_from_mime(mime_type: str) -> MediaType: + """ + Map a MIME type string to a MediaType enum value. + + Checks explicit MIME map first, then falls back to: + - image/* → IMAGE + - audio/* → AUDIO + - video/* → VIDEO + - everything else → DOCUMENT + + Args: + mime_type: MIME type string (e.g. "image/png", "application/pdf"). + + Returns: + The appropriate MediaType enum value. + """ + if not mime_type: + return MediaType.DOCUMENT + + lower = mime_type.lower() + + # Check exact match first + if lower in _MIME_TO_MEDIA_TYPE: + return _MIME_TO_MEDIA_TYPE[lower] + + # Fall back to prefix match + if lower.startswith("image/"): + return MediaType.IMAGE + if lower.startswith("audio/"): + return MediaType.AUDIO + if lower.startswith("video/"): + return MediaType.VIDEO + + # Default: treat as document + return MediaType.DOCUMENT + + +# --------------------------------------------------------------------------- +# Event detection +# --------------------------------------------------------------------------- + + +def is_file_share_event(event: dict) -> bool: + """ + Return True if the Slack event is a file_share subtype event. + + Args: + event: Slack event dict. + + Returns: + True if event["subtype"] == "file_share", else False. + """ + return event.get("subtype") == "file_share" + + +# --------------------------------------------------------------------------- +# Storage key builder +# --------------------------------------------------------------------------- + + +def build_slack_storage_key( + tenant_id: str, + agent_id: str, + message_id: str, + filename: str, +) -> str: + """ + Build the MinIO storage key for a Slack file attachment. + + Format: ``{tenant_id}/{agent_id}/{message_id}/{filename}`` + + This key ensures tenant-isolated, message-scoped storage. The agent_id + is included to allow per-agent media lifecycle management in the future. + + Args: + tenant_id: Konstruct tenant ID. + agent_id: Agent ID that received the message. + message_id: Slack message timestamp (event ts) or generated UUID. + filename: Original filename from Slack. + + Returns: + MinIO object key string. + """ + return f"{tenant_id}/{agent_id}/{message_id}/{filename}" + + +# --------------------------------------------------------------------------- +# Attachment builder +# --------------------------------------------------------------------------- + + +def build_attachment_from_slack_file( + file_info: dict, + storage_key: str, +) -> MediaAttachment: + """ + Build a MediaAttachment from Slack file metadata + storage key. + + Args: + file_info: Slack file metadata dict (from files.info or inline event). + Expected keys: id, name, mimetype, size. + storage_key: MinIO storage key already written by download_and_store_slack_file. + + Returns: + A populated MediaAttachment instance. + """ + mime_type: str = file_info.get("mimetype", "") or "" + filename: str = file_info.get("name", "") or "" + size_bytes: int | None = file_info.get("size") + + return MediaAttachment( + media_type=media_type_from_mime(mime_type), + storage_key=storage_key, + mime_type=mime_type or None, + filename=filename or None, + size_bytes=size_bytes, + ) + + +# --------------------------------------------------------------------------- +# Download + upload +# --------------------------------------------------------------------------- + + +async def download_and_store_slack_file( + tenant_id: str, + agent_id: str, + message_id: str, + file_info: dict, + bot_token: str, + expiry: int = 3600, +) -> tuple[str, str]: + """ + Download a Slack private file and store it in MinIO. + + Slack files are only accessible with a valid bot token via + ``url_private_download``. This function: + 1. Downloads the file using the bot token in the Authorization header + 2. Uploads to MinIO with the key ``{tenant_id}/{agent_id}/{message_id}/{filename}`` + 3. Generates a presigned URL valid for ``expiry`` seconds + + Args: + tenant_id: Konstruct tenant ID. + agent_id: Agent ID (for scoped storage key). + message_id: Message ID (for scoped storage key). + file_info: Slack file metadata dict. Must include: + - ``name`` (filename) + - ``mimetype`` (MIME type) + - ``url_private_download`` (Slack private URL) + bot_token: Slack bot token (xoxb-...) for Authorization header. + expiry: Presigned URL expiry in seconds (default: 3600 = 1 hour). + + Returns: + Tuple of (storage_key, presigned_url). + """ + import boto3 # type: ignore[import-untyped] + import httpx + + from shared.config import settings + + filename: str = file_info.get("name", "file") or "file" + mime_type: str = file_info.get("mimetype", "") or "" + download_url: str = file_info.get("url_private_download", "") or "" + storage_key = build_slack_storage_key(tenant_id, agent_id, message_id, filename) + + # Download the file from Slack using the bot token + async with httpx.AsyncClient(timeout=httpx.Timeout(60.0)) as client: + response = await client.get( + download_url, + headers={"Authorization": f"Bearer {bot_token}"}, + ) + response.raise_for_status() + file_bytes = response.content + + # Upload to MinIO + s3_client = boto3.client( + "s3", + endpoint_url=settings.minio_endpoint, + aws_access_key_id=settings.minio_access_key, + aws_secret_access_key=settings.minio_secret_key, + region_name="us-east-1", # MinIO ignores region but boto3 requires it + ) + + s3_client.put_object( + Bucket=settings.minio_media_bucket, + Key=storage_key, + Body=file_bytes, + ContentType=mime_type or "application/octet-stream", + ) + + # Generate presigned URL + presigned_url: str = s3_client.generate_presigned_url( + "get_object", + Params={"Bucket": settings.minio_media_bucket, "Key": storage_key}, + ExpiresIn=expiry, + ) + + logger.info( + "download_and_store_slack_file: stored tenant=%s key=%s", + tenant_id, + storage_key, + ) + return storage_key, presigned_url diff --git a/packages/gateway/pyproject.toml b/packages/gateway/pyproject.toml index 641ca2c..b002c49 100644 --- a/packages/gateway/pyproject.toml +++ b/packages/gateway/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "python-telegram-bot>=21.0", "httpx>=0.28.0", "redis>=5.0.0", + "boto3>=1.35.0", ] [tool.uv.sources] diff --git a/packages/orchestrator/orchestrator/tasks.py b/packages/orchestrator/orchestrator/tasks.py index 7711e35..84a6529 100644 --- a/packages/orchestrator/orchestrator/tasks.py +++ b/packages/orchestrator/orchestrator/tasks.py @@ -44,6 +44,7 @@ import json import logging import uuid +from gateway.channels.whatsapp import send_whatsapp_message from orchestrator.main import app from shared.models.message import KonstructMessage @@ -524,6 +525,68 @@ def _extract_tool_name_from_confirmation(confirmation_message: str) -> str: return "unknown_tool" +async def _send_response( + channel: str, + text: str, + extras: dict, +) -> None: + """ + Channel-aware outbound routing — dispatch a response to the correct channel. + + Checks ``channel`` and routes to: + - ``slack``: calls ``_update_slack_placeholder`` to replace the "Thinking..." message + - ``whatsapp``: calls ``send_whatsapp_message`` via Meta Cloud API + - other channels: logs a warning and returns (no-op for unsupported channels) + + Args: + channel: Channel name from KonstructMessage.channel (e.g. "slack", "whatsapp"). + text: Response text to send. + extras: Channel-specific metadata dict. + For Slack: ``bot_token``, ``channel_id``, ``placeholder_ts`` + For WhatsApp: ``phone_number_id``, ``bot_token`` (access_token), ``wa_id`` + """ + if channel == "slack": + bot_token: str = extras.get("bot_token", "") or "" + channel_id: str = extras.get("channel_id", "") or "" + placeholder_ts: str = extras.get("placeholder_ts", "") or "" + + if not channel_id or not placeholder_ts: + logger.warning( + "_send_response: Slack channel missing channel_id or placeholder_ts in extras" + ) + return + + await _update_slack_placeholder( + bot_token=bot_token, + channel_id=channel_id, + placeholder_ts=placeholder_ts, + text=text, + ) + + elif channel == "whatsapp": + phone_number_id: str = extras.get("phone_number_id", "") or "" + access_token: str = extras.get("bot_token", "") or "" + wa_id: str = extras.get("wa_id", "") or "" + + if not phone_number_id or not wa_id: + logger.warning( + "_send_response: WhatsApp channel missing phone_number_id or wa_id in extras" + ) + return + + await send_whatsapp_message( + phone_number_id=phone_number_id, + access_token=access_token, + recipient_wa_id=wa_id, + text=text, + ) + + else: + logger.warning( + "_send_response: unsupported channel=%r — response not delivered", channel + ) + + async def _update_slack_placeholder( bot_token: str, channel_id: str, diff --git a/pyproject.toml b/pyproject.toml index f98f6f6..391cd3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,9 @@ name = "konstruct" version = "0.1.0" description = "AI workforce platform — channel-native AI employees for Slack, Teams, and more" requires-python = ">=3.12" -dependencies = [] +dependencies = [ + "boto3>=1.42.74", +] [tool.uv.workspace] members = [ diff --git a/tests/unit/test_slack_media.py b/tests/unit/test_slack_media.py new file mode 100644 index 0000000..5017e74 --- /dev/null +++ b/tests/unit/test_slack_media.py @@ -0,0 +1,428 @@ +""" +Unit tests for Slack file_share media extraction and channel-aware outbound routing. + +Tests: +- file_share event detection (file_share subtype identified) +- MediaAttachment creation from Slack file metadata (correct media_type, filename, mime_type) +- MinIO upload key format: {tenant_id}/{agent_id}/{message_id}/{filename} +- Channel-aware outbound routing helper (Slack vs WhatsApp) +- Mock httpx/boto3 calls to avoid real API hits +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from shared.models.message import MediaType + + +# --------------------------------------------------------------------------- +# Helpers to build Slack event payloads +# --------------------------------------------------------------------------- + +def make_file_share_event( + file_id: str = "F12345", + file_name: str = "screenshot.png", + mime_type: str = "image/png", + size: int = 4096, + workspace_id: str = "T-WORKSPACE-1", + channel: str = "C-CHANNEL-1", + user: str = "U-USER-1", + ts: str = "1711234567.000100", + thread_ts: str | None = None, +) -> dict: + """Build a minimal Slack file_share event dict.""" + event: dict = { + "type": "message", + "subtype": "file_share", + "channel": channel, + "channel_type": "channel", + "user": user, + "ts": ts, + "files": [ + { + "id": file_id, + "name": file_name, + "mimetype": mime_type, + "size": size, + "url_private_download": f"https://files.slack.com/files-pri/T-xxx/{file_name}", + } + ], + "_workspace_id": workspace_id, + "_bot_user_id": "U-BOT-1", + "text": "", + } + if thread_ts: + event["thread_ts"] = thread_ts + return event + + +# --------------------------------------------------------------------------- +# Tests: file_share detection +# --------------------------------------------------------------------------- + +class TestFileShareDetection: + """Tests that file_share subtypes are correctly identified.""" + + def test_is_file_share_event_true(self): + """Event with subtype='file_share' returns True.""" + from gateway.channels.slack_media import is_file_share_event + + event = make_file_share_event() + assert is_file_share_event(event) is True + + def test_is_file_share_event_false_for_regular_message(self): + """Regular message event (no subtype) returns False.""" + from gateway.channels.slack_media import is_file_share_event + + event = {"type": "message", "text": "Hello", "user": "U123"} + assert is_file_share_event(event) is False + + def test_is_file_share_event_false_for_bot_message(self): + """Bot message subtype returns False.""" + from gateway.channels.slack_media import is_file_share_event + + event = {"type": "message", "subtype": "bot_message", "text": "Bot says hi"} + assert is_file_share_event(event) is False + + def test_is_file_share_event_false_for_empty_event(self): + """Empty event dict returns False.""" + from gateway.channels.slack_media import is_file_share_event + + assert is_file_share_event({}) is False + + +# --------------------------------------------------------------------------- +# Tests: MediaAttachment creation from Slack file metadata +# --------------------------------------------------------------------------- + +class TestMediaAttachmentFromSlackFile: + """Tests that Slack file metadata is correctly mapped to MediaAttachment.""" + + def test_image_file_produces_image_media_type(self): + """image/png MIME type maps to MediaType.IMAGE.""" + from gateway.channels.slack_media import media_type_from_mime + + assert media_type_from_mime("image/png") == MediaType.IMAGE + + def test_jpeg_file_produces_image_media_type(self): + """image/jpeg MIME type maps to MediaType.IMAGE.""" + from gateway.channels.slack_media import media_type_from_mime + + assert media_type_from_mime("image/jpeg") == MediaType.IMAGE + + def test_gif_file_produces_image_media_type(self): + """image/gif MIME type maps to MediaType.IMAGE.""" + from gateway.channels.slack_media import media_type_from_mime + + assert media_type_from_mime("image/gif") == MediaType.IMAGE + + def test_pdf_produces_document_media_type(self): + """application/pdf MIME type maps to MediaType.DOCUMENT.""" + from gateway.channels.slack_media import media_type_from_mime + + assert media_type_from_mime("application/pdf") == MediaType.DOCUMENT + + def test_docx_produces_document_media_type(self): + """application/vnd.openxmlformats-officedocument... maps to MediaType.DOCUMENT.""" + from gateway.channels.slack_media import media_type_from_mime + + result = media_type_from_mime( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ) + assert result == MediaType.DOCUMENT + + def test_mp3_produces_audio_media_type(self): + """audio/mpeg MIME type maps to MediaType.AUDIO.""" + from gateway.channels.slack_media import media_type_from_mime + + assert media_type_from_mime("audio/mpeg") == MediaType.AUDIO + + def test_mp4_produces_video_media_type(self): + """video/mp4 MIME type maps to MediaType.VIDEO.""" + from gateway.channels.slack_media import media_type_from_mime + + assert media_type_from_mime("video/mp4") == MediaType.VIDEO + + def test_unknown_mime_defaults_to_document(self): + """Unknown MIME type falls back to MediaType.DOCUMENT.""" + from gateway.channels.slack_media import media_type_from_mime + + assert media_type_from_mime("application/x-custom") == MediaType.DOCUMENT + + def test_build_attachment_from_slack_file(self): + """MediaAttachment is correctly built from Slack file metadata.""" + from gateway.channels.slack_media import build_attachment_from_slack_file + + file_info = { + "id": "F12345", + "name": "screenshot.png", + "mimetype": "image/png", + "size": 4096, + "url_private_download": "https://files.slack.com/files-pri/T-xxx/screenshot.png", + } + storage_key = "tenant-1/agent-1/msg-1/screenshot.png" + + attachment = build_attachment_from_slack_file(file_info, storage_key) + + assert attachment.media_type == MediaType.IMAGE + assert attachment.filename == "screenshot.png" + assert attachment.mime_type == "image/png" + assert attachment.size_bytes == 4096 + assert attachment.storage_key == storage_key + + def test_build_attachment_pdf(self): + """PDF file produces DOCUMENT media type attachment.""" + from gateway.channels.slack_media import build_attachment_from_slack_file + + file_info = { + "id": "F99999", + "name": "report.pdf", + "mimetype": "application/pdf", + "size": 102400, + "url_private_download": "https://files.slack.com/files-pri/T-xxx/report.pdf", + } + storage_key = "tenant-2/agent-2/msg-2/report.pdf" + + attachment = build_attachment_from_slack_file(file_info, storage_key) + + assert attachment.media_type == MediaType.DOCUMENT + assert attachment.filename == "report.pdf" + assert attachment.mime_type == "application/pdf" + assert attachment.size_bytes == 102400 + + +# --------------------------------------------------------------------------- +# Tests: MinIO storage key format +# --------------------------------------------------------------------------- + +class TestMinIOStorageKey: + """Tests that MinIO storage keys follow the correct format.""" + + def test_storage_key_format(self): + """Storage key follows {tenant_id}/{agent_id}/{message_id}/{filename} format.""" + from gateway.channels.slack_media import build_slack_storage_key + + key = build_slack_storage_key( + tenant_id="tenant-abc", + agent_id="agent-xyz", + message_id="msg-123", + filename="photo.jpg", + ) + + assert key == "tenant-abc/agent-xyz/msg-123/photo.jpg" + + def test_storage_key_with_all_parts(self): + """Storage key includes all four parts.""" + from gateway.channels.slack_media import build_slack_storage_key + + key = build_slack_storage_key( + tenant_id="t1", + agent_id="a1", + message_id="m1", + filename="document.pdf", + ) + + parts = key.split("/") + assert len(parts) == 4 + assert parts[0] == "t1" + assert parts[1] == "a1" + assert parts[2] == "m1" + assert parts[3] == "document.pdf" + + +# --------------------------------------------------------------------------- +# Tests: Download and upload to MinIO (mocked) +# --------------------------------------------------------------------------- + +class TestSlackFileDownloadAndUpload: + """Tests for Slack file download and MinIO upload flow.""" + + @pytest.mark.asyncio + async def test_download_and_store_slack_file_calls_minio(self): + """download_and_store_slack_file downloads from Slack and uploads to MinIO.""" + from gateway.channels.slack_media import download_and_store_slack_file + + file_info = { + "id": "F12345", + "name": "image.png", + "mimetype": "image/png", + "size": 2048, + "url_private_download": "https://files.slack.com/files-pri/T-xxx/image.png", + } + + mock_response = MagicMock() + mock_response.content = b"fake-image-bytes" + mock_response.raise_for_status = MagicMock() + + mock_s3 = MagicMock() + mock_s3.put_object = MagicMock() + mock_s3.generate_presigned_url = MagicMock( + return_value="http://minio:9000/konstruct-media/tenant-1/agent-1/msg-1/image.png?X-Amz-Signature=abc" + ) + + with patch("boto3.client") as mock_boto3_client: + mock_boto3_client.return_value = mock_s3 + with patch("httpx.AsyncClient") as mock_httpx: + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_client.get = AsyncMock(return_value=mock_response) + mock_httpx.return_value = mock_client + + storage_key, presigned_url = await download_and_store_slack_file( + tenant_id="tenant-1", + agent_id="agent-1", + message_id="msg-1", + file_info=file_info, + bot_token="xoxb-test-token", + ) + + assert storage_key == "tenant-1/agent-1/msg-1/image.png" + assert "minio:9000" in presigned_url + mock_s3.put_object.assert_called_once() + mock_s3.generate_presigned_url.assert_called_once() + + @pytest.mark.asyncio + async def test_download_uses_bot_token_for_authorization(self): + """Download request includes Slack bot token in Authorization header.""" + from gateway.channels.slack_media import download_and_store_slack_file + + file_info = { + "id": "F99999", + "name": "doc.pdf", + "mimetype": "application/pdf", + "size": 5000, + "url_private_download": "https://files.slack.com/files-pri/T-xxx/doc.pdf", + } + + mock_response = MagicMock() + mock_response.content = b"pdf-content" + mock_response.raise_for_status = MagicMock() + + captured_headers: list[dict] = [] + + async def fake_get(url: str, **kwargs: object) -> MagicMock: + captured_headers.append(dict(kwargs.get("headers", {}))) + return mock_response + + mock_s3 = MagicMock() + mock_s3.put_object = MagicMock() + mock_s3.generate_presigned_url = MagicMock( + return_value="http://minio:9000/key?sig=xyz" + ) + + with patch("boto3.client") as mock_boto3_client: + mock_boto3_client.return_value = mock_s3 + with patch("httpx.AsyncClient") as mock_httpx: + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_client.get = AsyncMock(side_effect=fake_get) + mock_httpx.return_value = mock_client + + await download_and_store_slack_file( + tenant_id="tenant-1", + agent_id="agent-1", + message_id="msg-1", + file_info=file_info, + bot_token="xoxb-my-bot-token", + ) + + # At least one request should include the Authorization header + auth_headers = [h.get("Authorization", "") for h in captured_headers] + assert any("xoxb-my-bot-token" in h for h in auth_headers), ( + f"Expected bot token in headers, got: {auth_headers}" + ) + + +# --------------------------------------------------------------------------- +# Tests: Channel-aware outbound routing helper +# --------------------------------------------------------------------------- + +class TestChannelAwareOutbound: + """Tests for the send_response helper that routes to Slack or WhatsApp.""" + + @pytest.mark.asyncio + async def test_slack_channel_calls_chat_update(self): + """Slack channel routes to _update_slack_placeholder.""" + from orchestrator.tasks import _send_response + + with patch("orchestrator.tasks._update_slack_placeholder") as mock_update: + mock_update.return_value = None + await _send_response( + channel="slack", + text="Hello from agent", + extras={ + "bot_token": "xoxb-test", + "channel_id": "C-123", + "placeholder_ts": "1711234567.000001", + }, + ) + mock_update.assert_called_once_with( + bot_token="xoxb-test", + channel_id="C-123", + placeholder_ts="1711234567.000001", + text="Hello from agent", + ) + + @pytest.mark.asyncio + async def test_whatsapp_channel_calls_send_whatsapp_message(self): + """WhatsApp channel routes to send_whatsapp_message.""" + from orchestrator.tasks import _send_response + + with patch("orchestrator.tasks.send_whatsapp_message") as mock_wa: + mock_wa.return_value = None + await _send_response( + channel="whatsapp", + text="Reply via WhatsApp", + extras={ + "phone_number_id": "12345678901", + "bot_token": "EAAx...", + "wa_id": "5511987654321", + }, + ) + mock_wa.assert_called_once_with( + phone_number_id="12345678901", + access_token="EAAx...", + recipient_wa_id="5511987654321", + text="Reply via WhatsApp", + ) + + @pytest.mark.asyncio + async def test_unknown_channel_logs_warning_no_exception(self): + """Unknown channel type logs a warning but does not raise.""" + from orchestrator.tasks import _send_response + + # Should not raise, just log and return + await _send_response( + channel="mattermost", + text="Reply via Mattermost", + extras={}, + ) + + @pytest.mark.asyncio + async def test_slack_channel_missing_extras_does_not_raise(self): + """Slack routing with empty extras logs warning but does not crash.""" + from orchestrator.tasks import _send_response + + # Should fall back gracefully when extras are missing + await _send_response( + channel="slack", + text="Hello", + extras={}, + ) + + @pytest.mark.asyncio + async def test_whatsapp_channel_missing_extras_does_not_raise(self): + """WhatsApp routing with empty extras logs warning but does not crash.""" + from orchestrator.tasks import _send_response + + # Should fall back gracefully when wa_id is missing + await _send_response( + channel="whatsapp", + text="Hello", + extras={}, + ) diff --git a/uv.lock b/uv.lock index c0b9a6e..b7e0a7c 100644 --- a/uv.lock +++ b/uv.lock @@ -305,6 +305,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/87/8bab77b323f16d67be364031220069f79159117dd5e43eeb4be2fef1ac9b/billiard-4.2.4-py3-none-any.whl", hash = "sha256:525b42bdec68d2b983347ac312f892db930858495db601b5836ac24e6477cde5", size = 87070, upload-time = "2025-11-30T13:28:47.016Z" }, ] +[[package]] +name = "boto3" +version = "1.42.74" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/16/a264b4da2af99f4a12609b93fea941cce5ec41da14b33ed3fef77a910f0c/boto3-1.42.74-py3-none-any.whl", hash = "sha256:4bf89c044d618fe4435af854ab820f09dd43569c0df15d7beb0398f50b9aa970", size = 140557, upload-time = "2026-03-23T19:34:07.084Z" }, +] + +[[package]] +name = "botocore" +version = "1.42.74" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/c7/cab8a14f0b69944bd0dd1fd58559163455b347eeda00bf836e93ce2684e4/botocore-1.42.74.tar.gz", hash = "sha256:9cf5cdffc6c90ed87b0fe184676806182588be0d0df9b363e9fe3e2923ac8e80", size = 15014379, upload-time = "2026-03-23T19:33:57.692Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/65/75852e04de5423c9b0c5b88241d0bdea33e6c6f454c88b71377d230216f2/botocore-1.42.74-py3-none-any.whl", hash = "sha256:3a76a8af08b5de82e51a0ae132394e226e15dbf21c8146ac3f7c1f881517a7a7", size = 14688218, upload-time = "2026-03-23T19:33:52.677Z" }, +] + [[package]] name = "celery" version = "5.6.2" @@ -1153,6 +1180,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/67/8a/a342b2f0251f3dac4ca17618265d93bf244a2a4d089126e81e4c1056ac50/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19", size = 343768, upload-time = "2026-02-02T12:37:55.055Z" }, ] +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + [[package]] name = "joblib" version = "1.5.3" @@ -1213,6 +1249,9 @@ redis = [ name = "konstruct" version = "0.1.0" source = { virtual = "." } +dependencies = [ + { name = "boto3" }, +] [package.dev-dependencies] dev = [ @@ -1226,6 +1265,7 @@ dev = [ ] [package.metadata] +requires-dist = [{ name = "boto3", specifier = ">=1.42.74" }] [package.metadata.requires-dev] dev = [ @@ -1243,6 +1283,7 @@ name = "konstruct-gateway" version = "0.1.0" source = { editable = "packages/gateway" } dependencies = [ + { name = "boto3" }, { name = "fastapi", extra = ["standard"] }, { name = "httpx" }, { name = "konstruct-orchestrator" }, @@ -1255,6 +1296,7 @@ dependencies = [ [package.metadata] requires-dist = [ + { name = "boto3", specifier = ">=1.35.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.115.0" }, { name = "httpx", specifier = ">=0.28.0" }, { name = "konstruct-orchestrator", editable = "packages/orchestrator" }, @@ -1292,6 +1334,7 @@ dependencies = [ { name = "celery", extra = ["redis"] }, { name = "fastapi", extra = ["standard"] }, { name = "httpx" }, + { name = "jsonschema" }, { name = "konstruct-shared" }, { name = "sentence-transformers" }, ] @@ -1301,6 +1344,7 @@ requires-dist = [ { name = "celery", extras = ["redis"], specifier = ">=5.4.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.115.0" }, { name = "httpx", specifier = ">=0.28.0" }, + { name = "jsonschema", specifier = ">=4.26.0" }, { name = "konstruct-shared", editable = "packages/shared" }, { name = "sentence-transformers", specifier = ">=3.0.0" }, ] @@ -2660,6 +2704,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2", size = 11023304, upload-time = "2026-03-19T16:26:51.669Z" }, ] +[[package]] +name = "s3transfer" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/04/74127fc843314818edfa81b5540e26dd537353b123a4edc563109d8f17dd/s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920", size = 153827, upload-time = "2025-12-01T02:30:59.114Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" }, +] + [[package]] name = "safetensors" version = "0.7.0"