From 3ba93c55f439a3dd4e904243002b40f11f0f7bc2 Mon Sep 17 00:00:00 2001 From: Ryan Chen Date: Wed, 3 Jun 2026 19:39:39 -0400 Subject: [PATCH] Add channel-scoped conversations for iMessage, WhatsApp, and email Revert get_conversation_for_user to use Conversation.get() with MultipleObjectsReturned fallback. Add channel field to Conversation model and get_conversation_for_channel helper so each messaging channel gets its own isolated conversation per user. Co-Authored-By: Claude Opus 4.6 --- blueprints/conversation/logic.py | 22 ++++++++++++++++--- blueprints/conversation/models.py | 1 + blueprints/email/__init__.py | 4 ++-- blueprints/imessage/__init__.py | 4 ++-- blueprints/whatsapp/__init__.py | 35 ++++++++++++++++++------------- 5 files changed, 45 insertions(+), 21 deletions(-) diff --git a/blueprints/conversation/logic.py b/blueprints/conversation/logic.py index 426dd59..a565c30 100644 --- a/blueprints/conversation/logic.py +++ b/blueprints/conversation/logic.py @@ -47,11 +47,27 @@ async def get_the_only_conversation() -> Conversation: async def get_conversation_for_user(user: blueprints.users.models.User) -> Conversation: try: - return await Conversation.get(user=user) + conversation = await Conversation.get(user=user) + except tortoise.exceptions.MultipleObjectsReturned: + conversation = ( + await Conversation.filter(user=user).order_by("created_at").first() + ) except tortoise.exceptions.DoesNotExist: - await Conversation.get_or_create(name=f"{user.username}'s chat", user=user) + conversation = await Conversation.create( + name=f"{user.username}'s chat", user=user + ) + return conversation - return await Conversation.get(user=user) + +async def get_conversation_for_channel( + user: blueprints.users.models.User, channel: str +) -> Conversation: + conversation = await Conversation.filter(user=user, channel=channel).first() + if conversation is None: + conversation = await Conversation.create( + name=f"{user.username}'s {channel} chat", user=user, channel=channel + ) + return conversation async def get_conversation_by_id(id: str) -> Conversation: diff --git a/blueprints/conversation/models.py b/blueprints/conversation/models.py index 339fe3d..60b8cee 100644 --- a/blueprints/conversation/models.py +++ b/blueprints/conversation/models.py @@ -21,6 +21,7 @@ class Conversation(Model): user: fields.ForeignKeyRelation = fields.ForeignKeyField( "models.User", related_name="conversations", null=True ) + channel = fields.CharField(max_length=20, default="web", null=True) class Meta: table = "conversations" diff --git a/blueprints/email/__init__.py b/blueprints/email/__init__.py index 738e273..121a740 100644 --- a/blueprints/email/__init__.py +++ b/blueprints/email/__init__.py @@ -11,7 +11,7 @@ from quart import Blueprint, request from blueprints.users.models import User from blueprints.conversation.logic import ( - get_conversation_for_user, + get_conversation_for_channel, add_message_to_conversation, ) from blueprints.conversation.agents import main_agent @@ -176,7 +176,7 @@ async def webhook(): # Get or create conversation try: - conversation = await get_conversation_for_user(user=user) + conversation = await get_conversation_for_channel(user=user, channel="email") await conversation.fetch_related("messages") except Exception as e: logger.error(f"Failed to get conversation for user {user.username}: {e}") diff --git a/blueprints/imessage/__init__.py b/blueprints/imessage/__init__.py index 56b9c23..f62706c 100644 --- a/blueprints/imessage/__init__.py +++ b/blueprints/imessage/__init__.py @@ -10,7 +10,7 @@ from quart import Blueprint, request, jsonify from blueprints.users.models import User from blueprints.conversation.logic import ( - get_conversation_for_user, + get_conversation_for_channel, add_message_to_conversation, ) from blueprints.conversation.agents import main_agent @@ -178,7 +178,7 @@ async def webhook(): # Get or create conversation try: - conversation = await get_conversation_for_user(user=user) + conversation = await get_conversation_for_channel(user=user, channel="imessage") await conversation.fetch_related("messages") except Exception as e: logger.error(f"Failed to get conversation for user {user.username}: {e}") diff --git a/blueprints/whatsapp/__init__.py b/blueprints/whatsapp/__init__.py index 6b94efd..6824d2d 100644 --- a/blueprints/whatsapp/__init__.py +++ b/blueprints/whatsapp/__init__.py @@ -1,18 +1,16 @@ import os import logging -import asyncio import functools import time from collections import defaultdict -from quart import Blueprint, request, jsonify, abort +from quart import Blueprint, request, abort from twilio.request_validator import RequestValidator from twilio.twiml.messaging_response import MessagingResponse from blueprints.users.models import User from blueprints.conversation.logic import ( - get_conversation_for_user, + get_conversation_for_channel, add_message_to_conversation, - get_conversation_transcript, ) from blueprints.conversation.agents import main_agent from blueprints.conversation.prompts import SIMBA_SYSTEM_PROMPT @@ -69,6 +67,7 @@ def validate_twilio_request(f): so the validated URL matches what Twilio signed against. Set TWILIO_SIGNATURE_VALIDATION=false to disable in development. """ + @functools.wraps(f) async def decorated_function(*args, **kwargs): if os.getenv("TWILIO_SIGNATURE_VALIDATION", "true").lower() == "false": @@ -94,6 +93,7 @@ def validate_twilio_request(f): abort(403) return await f(*args, **kwargs) + return decorated_function @@ -104,11 +104,15 @@ async def webhook(): Handle incoming WhatsApp messages from Twilio. """ form_data = await request.form - from_number = form_data.get("From") # e.g., "whatsapp:+1234567890" + from_number = form_data.get("From") # e.g., "whatsapp:+1234567890" body = form_data.get("Body") if not from_number or not body: - return _twiml_response("Invalid message received.") if from_number else ("Missing From or Body", 400) + return ( + _twiml_response("Invalid message received.") + if from_number + else ("Missing From or Body", 400) + ) # Strip whitespace and check for empty body body = body.strip() @@ -118,12 +122,16 @@ async def webhook(): # Rate limiting if not _check_rate_limit(from_number): logger.warning(f"Rate limit exceeded for {from_number}") - return _twiml_response("You're sending messages too quickly. Please wait a moment and try again.") + return _twiml_response( + "You're sending messages too quickly. Please wait a moment and try again." + ) # Truncate overly long messages if len(body) > MAX_MESSAGE_LENGTH: body = body[:MAX_MESSAGE_LENGTH] - logger.info(f"Truncated long message from {from_number} to {MAX_MESSAGE_LENGTH} chars") + logger.info( + f"Truncated long message from {from_number} to {MAX_MESSAGE_LENGTH} chars" + ) logger.info(f"Received WhatsApp message from {from_number}: {body[:100]}") @@ -143,16 +151,18 @@ async def webhook(): username=username, email=f"{username}@whatsapp.simbarag.local", whatsapp_number=from_number, - auth_provider="whatsapp" + auth_provider="whatsapp", ) logger.info(f"Created new user for WhatsApp: {username}") except Exception as e: logger.error(f"Failed to create user for {from_number}: {e}") - return _twiml_response("Sorry, something went wrong setting up your account. Please try again later.") + return _twiml_response( + "Sorry, something went wrong setting up your account. Please try again later." + ) # Get or create a conversation for this user try: - conversation = await get_conversation_for_user(user=user) + conversation = await get_conversation_for_channel(user=user, channel="whatsapp") await conversation.fetch_related("messages") except Exception as e: logger.error(f"Failed to get conversation for user {user.username}: {e}") @@ -166,9 +176,6 @@ async def webhook(): user=user, ) - # Get transcript for context - transcript = await get_conversation_transcript(user=user, conversation=conversation) - # Build messages payload for LangChain agent with system prompt and conversation history try: # Get last 10 messages for conversation history