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 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 19:39:39 -04:00
parent a693874662
commit 3ba93c55f4
5 changed files with 45 additions and 21 deletions
+19 -3
View File
@@ -47,11 +47,27 @@ async def get_the_only_conversation() -> Conversation:
async def get_conversation_for_user(user: blueprints.users.models.User) -> Conversation: async def get_conversation_for_user(user: blueprints.users.models.User) -> Conversation:
try: 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: 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: async def get_conversation_by_id(id: str) -> Conversation:
+1
View File
@@ -21,6 +21,7 @@ class Conversation(Model):
user: fields.ForeignKeyRelation = fields.ForeignKeyField( user: fields.ForeignKeyRelation = fields.ForeignKeyField(
"models.User", related_name="conversations", null=True "models.User", related_name="conversations", null=True
) )
channel = fields.CharField(max_length=20, default="web", null=True)
class Meta: class Meta:
table = "conversations" table = "conversations"
+2 -2
View File
@@ -11,7 +11,7 @@ from quart import Blueprint, request
from blueprints.users.models import User from blueprints.users.models import User
from blueprints.conversation.logic import ( from blueprints.conversation.logic import (
get_conversation_for_user, get_conversation_for_channel,
add_message_to_conversation, add_message_to_conversation,
) )
from blueprints.conversation.agents import main_agent from blueprints.conversation.agents import main_agent
@@ -176,7 +176,7 @@ async def webhook():
# Get or create conversation # Get or create conversation
try: try:
conversation = await get_conversation_for_user(user=user) conversation = await get_conversation_for_channel(user=user, channel="email")
await conversation.fetch_related("messages") await conversation.fetch_related("messages")
except Exception as e: except Exception as e:
logger.error(f"Failed to get conversation for user {user.username}: {e}") logger.error(f"Failed to get conversation for user {user.username}: {e}")
+2 -2
View File
@@ -10,7 +10,7 @@ from quart import Blueprint, request, jsonify
from blueprints.users.models import User from blueprints.users.models import User
from blueprints.conversation.logic import ( from blueprints.conversation.logic import (
get_conversation_for_user, get_conversation_for_channel,
add_message_to_conversation, add_message_to_conversation,
) )
from blueprints.conversation.agents import main_agent from blueprints.conversation.agents import main_agent
@@ -178,7 +178,7 @@ async def webhook():
# Get or create conversation # Get or create conversation
try: try:
conversation = await get_conversation_for_user(user=user) conversation = await get_conversation_for_channel(user=user, channel="imessage")
await conversation.fetch_related("messages") await conversation.fetch_related("messages")
except Exception as e: except Exception as e:
logger.error(f"Failed to get conversation for user {user.username}: {e}") logger.error(f"Failed to get conversation for user {user.username}: {e}")
+21 -14
View File
@@ -1,18 +1,16 @@
import os import os
import logging import logging
import asyncio
import functools import functools
import time import time
from collections import defaultdict 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.request_validator import RequestValidator
from twilio.twiml.messaging_response import MessagingResponse from twilio.twiml.messaging_response import MessagingResponse
from blueprints.users.models import User from blueprints.users.models import User
from blueprints.conversation.logic import ( from blueprints.conversation.logic import (
get_conversation_for_user, get_conversation_for_channel,
add_message_to_conversation, add_message_to_conversation,
get_conversation_transcript,
) )
from blueprints.conversation.agents import main_agent from blueprints.conversation.agents import main_agent
from blueprints.conversation.prompts import SIMBA_SYSTEM_PROMPT 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. so the validated URL matches what Twilio signed against.
Set TWILIO_SIGNATURE_VALIDATION=false to disable in development. Set TWILIO_SIGNATURE_VALIDATION=false to disable in development.
""" """
@functools.wraps(f) @functools.wraps(f)
async def decorated_function(*args, **kwargs): async def decorated_function(*args, **kwargs):
if os.getenv("TWILIO_SIGNATURE_VALIDATION", "true").lower() == "false": if os.getenv("TWILIO_SIGNATURE_VALIDATION", "true").lower() == "false":
@@ -94,6 +93,7 @@ def validate_twilio_request(f):
abort(403) abort(403)
return await f(*args, **kwargs) return await f(*args, **kwargs)
return decorated_function return decorated_function
@@ -104,11 +104,15 @@ async def webhook():
Handle incoming WhatsApp messages from Twilio. Handle incoming WhatsApp messages from Twilio.
""" """
form_data = await request.form 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") body = form_data.get("Body")
if not from_number or not 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 # Strip whitespace and check for empty body
body = body.strip() body = body.strip()
@@ -118,12 +122,16 @@ async def webhook():
# Rate limiting # Rate limiting
if not _check_rate_limit(from_number): if not _check_rate_limit(from_number):
logger.warning(f"Rate limit exceeded for {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 # Truncate overly long messages
if len(body) > MAX_MESSAGE_LENGTH: if len(body) > MAX_MESSAGE_LENGTH:
body = 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]}") logger.info(f"Received WhatsApp message from {from_number}: {body[:100]}")
@@ -143,16 +151,18 @@ async def webhook():
username=username, username=username,
email=f"{username}@whatsapp.simbarag.local", email=f"{username}@whatsapp.simbarag.local",
whatsapp_number=from_number, whatsapp_number=from_number,
auth_provider="whatsapp" auth_provider="whatsapp",
) )
logger.info(f"Created new user for WhatsApp: {username}") logger.info(f"Created new user for WhatsApp: {username}")
except Exception as e: except Exception as e:
logger.error(f"Failed to create user for {from_number}: {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 # Get or create a conversation for this user
try: try:
conversation = await get_conversation_for_user(user=user) conversation = await get_conversation_for_channel(user=user, channel="whatsapp")
await conversation.fetch_related("messages") await conversation.fetch_related("messages")
except Exception as e: except Exception as e:
logger.error(f"Failed to get conversation for user {user.username}: {e}") logger.error(f"Failed to get conversation for user {user.username}: {e}")
@@ -166,9 +176,6 @@ async def webhook():
user=user, 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 # Build messages payload for LangChain agent with system prompt and conversation history
try: try:
# Get last 10 messages for conversation history # Get last 10 messages for conversation history