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)
This commit is contained in:
2026-03-25 10:26:34 -06:00
parent c0fa0cefee
commit c72beb916b
7 changed files with 957 additions and 1 deletions

View File

@@ -77,7 +77,7 @@ from orchestrator.tools.registry import get_tools_for_agent
from shared.config import settings
from shared.db import async_session_factory, engine
from shared.models.message import KonstructMessage
from shared.redis_keys import escalation_status_key
from shared.redis_keys import escalation_status_key, webchat_response_key
from shared.rls import configure_rls_hook, current_tenant_id
logger = logging.getLogger(__name__)
@@ -253,6 +253,11 @@ def handle_message(self, message_data: dict) -> dict: # type: ignore[no-untyped
phone_number_id: str = message_data.pop("phone_number_id", "") or ""
bot_token: str = message_data.pop("bot_token", "") or ""
# Extract web channel extras before model validation
# The web WebSocket handler injects these alongside the normalized KonstructMessage fields
conversation_id: str = message_data.pop("conversation_id", "") or ""
portal_user_id: str = message_data.pop("portal_user_id", "") or ""
try:
msg = KonstructMessage.model_validate(message_data)
except Exception as exc:
@@ -272,6 +277,11 @@ def handle_message(self, message_data: dict) -> dict: # type: ignore[no-untyped
"phone_number_id": phone_number_id,
"bot_token": bot_token,
"wa_id": wa_id,
# Web channel extras
"conversation_id": conversation_id,
"portal_user_id": portal_user_id,
# tenant_id for web channel response routing (web lacks a workspace_id in channel_connections)
"tenant_id": msg.tenant_id or "",
}
result = asyncio.run(_process_message(msg, extras=extras))
@@ -646,6 +656,13 @@ def _build_response_extras(
"bot_token": extras.get("bot_token", "") or "",
"wa_id": extras.get("wa_id", "") or "",
}
elif channel_str == "web":
# Web channel: tenant_id comes from extras (set by handle_message from msg.tenant_id),
# not from channel_connections like Slack. conversation_id scopes the Redis pub-sub channel.
return {
"conversation_id": extras.get("conversation_id", "") or "",
"tenant_id": extras.get("tenant_id", "") or "",
}
else:
return dict(extras)
@@ -774,6 +791,31 @@ async def _send_response(
text=text,
)
elif channel_str == "web":
# Publish agent response to Redis pub-sub so the WebSocket handler can deliver it
web_conversation_id: str = extras.get("conversation_id", "") or ""
web_tenant_id: str = extras.get("tenant_id", "") or ""
if not web_conversation_id or not web_tenant_id:
logger.warning(
"_send_response: web channel missing conversation_id or tenant_id in extras"
)
return
response_channel = webchat_response_key(web_tenant_id, web_conversation_id)
publish_redis = aioredis.from_url(settings.redis_url)
try:
await publish_redis.publish(
response_channel,
json.dumps({
"type": "response",
"text": text,
"conversation_id": web_conversation_id,
}),
)
finally:
await publish_redis.aclose()
else:
logger.warning(
"_send_response: unsupported channel=%r — response not delivered", channel