Add iMessage threading, typing indicators, and fix tool-use response extraction
- Native reply threading: inbound replies continue their conversation via SendBlue message_handle lookup; standalone messages create a new conversation - Our responses are sent with reply_to so they thread visually in iMessage - Typing indicator fires before agent invocation and stops in a finally block - Add extract_agent_text() to handle list-type AIMessage content and strip <think> tokens from Qwen3 — applied to iMessage, WhatsApp, and web chat - ConversationMessage gains a nullable message_handle field (migration needed) - /threads command lists recent iMessage conversations Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@ from quart_jwt_extended import (
|
||||
import blueprints.users.models
|
||||
from metrics import count_result, observe_response, time_response
|
||||
from utils.image_process import image_content_block
|
||||
from utils.strip_markdown import extract_agent_text
|
||||
from utils.image_upload import ImageValidationError, process_image
|
||||
from utils.s3_client import generate_presigned_url as s3_presigned_url
|
||||
from utils.s3_client import get_image as s3_get_image
|
||||
@@ -171,7 +172,7 @@ async def query():
|
||||
}
|
||||
|
||||
response = await main_agent.ainvoke(payload, config=agent_config)
|
||||
message = response.get("messages", [])[-1].content
|
||||
message = extract_agent_text(response.get("messages", [])[-1].content)
|
||||
await persist_agent_tool_messages(
|
||||
conversation, user, response.get("messages", [])
|
||||
)
|
||||
|
||||
@@ -54,6 +54,7 @@ async def add_message_to_conversation(
|
||||
speaker: str,
|
||||
user: blueprints.users.models.User,
|
||||
image_key: str | None = None,
|
||||
message_handle: str | None = None,
|
||||
) -> ConversationMessage:
|
||||
# Bump updated_at on every message so conversations sort by last activity.
|
||||
# `update_fields` bypasses the auto_now recompute, so set it explicitly.
|
||||
@@ -72,6 +73,7 @@ async def add_message_to_conversation(
|
||||
speaker=speaker,
|
||||
conversation=conversation,
|
||||
image_key=image_key,
|
||||
message_handle=message_handle,
|
||||
)
|
||||
|
||||
return message
|
||||
@@ -105,7 +107,9 @@ async def get_conversation_for_user(user: blueprints.users.models.User) -> Conve
|
||||
async def get_conversation_for_channel(
|
||||
user: blueprints.users.models.User, channel: str
|
||||
) -> Conversation:
|
||||
conversation = await Conversation.filter(user=user, channel=channel).first()
|
||||
conversation = await (
|
||||
Conversation.filter(user=user, channel=channel).order_by("-updated_at").first()
|
||||
)
|
||||
if conversation is None:
|
||||
conversation = await Conversation.create(
|
||||
name=f"{user.username}'s {channel} chat", user=user, channel=channel
|
||||
|
||||
@@ -37,6 +37,7 @@ class ConversationMessage(Model):
|
||||
created_at = fields.DatetimeField(auto_now_add=True)
|
||||
speaker = fields.CharEnumField(enum_type=Speaker, max_length=10)
|
||||
image_key = fields.CharField(max_length=512, null=True, default=None)
|
||||
message_handle = fields.CharField(max_length=255, null=True, default=None)
|
||||
|
||||
class Meta:
|
||||
table = "conversation_messages"
|
||||
|
||||
+118
-18
@@ -14,9 +14,10 @@ from blueprints.conversation.logic import (
|
||||
add_message_to_conversation,
|
||||
persist_agent_tool_messages,
|
||||
)
|
||||
from blueprints.conversation.models import Speaker
|
||||
from blueprints.conversation.models import Conversation, ConversationMessage, Speaker
|
||||
from blueprints.conversation.agents import main_agent
|
||||
from blueprints.conversation.memory import build_system_prompt_with_memories
|
||||
from utils.strip_markdown import extract_agent_text, strip_markdown
|
||||
|
||||
imessage_blueprint = Blueprint("imessage_api", __name__, url_prefix="/api/imessage")
|
||||
|
||||
@@ -59,25 +60,60 @@ def _check_rate_limit(phone_number: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
async def send_imessage(to_number: str, content: str) -> dict:
|
||||
async def send_imessage(
|
||||
to_number: str,
|
||||
content: str,
|
||||
reply_to_handle: str | None = None,
|
||||
) -> dict:
|
||||
"""Send an iMessage via SendBlue API."""
|
||||
from_number = os.getenv("SENDBLUE_FROM_NUMBER", "")
|
||||
|
||||
payload: dict = {
|
||||
"number": to_number,
|
||||
"from_number": from_number,
|
||||
"content": content,
|
||||
}
|
||||
if reply_to_handle:
|
||||
payload["reply_to"] = {"message_handle": reply_to_handle}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
f"{SENDBLUE_API_BASE}/api/send-message",
|
||||
headers=_get_sendblue_headers(),
|
||||
json={
|
||||
"number": to_number,
|
||||
"from_number": from_number,
|
||||
"content": content,
|
||||
},
|
||||
json=payload,
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def send_typing_indicator(
|
||||
to_number: str,
|
||||
state: str = "start",
|
||||
max_duration_ms: int | None = None,
|
||||
) -> None:
|
||||
"""Send or stop a typing indicator. Errors are logged and swallowed."""
|
||||
from_number = os.getenv("SENDBLUE_FROM_NUMBER", "")
|
||||
payload: dict = {
|
||||
"number": to_number,
|
||||
"from_number": from_number,
|
||||
"state": state,
|
||||
}
|
||||
if max_duration_ms is not None:
|
||||
payload["max_duration_ms"] = max_duration_ms
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
await client.post(
|
||||
f"{SENDBLUE_API_BASE}/api/send-typing-indicator",
|
||||
headers=_get_sendblue_headers(),
|
||||
json=payload,
|
||||
timeout=10,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to send typing indicator ({state}): {e}")
|
||||
|
||||
|
||||
def validate_sendblue_signature(f):
|
||||
"""Decorator to validate the SendBlue webhook signing secret."""
|
||||
|
||||
@@ -114,6 +150,11 @@ async def webhook():
|
||||
media_url = data.get("media_url")
|
||||
is_outbound = data.get("is_outbound", False)
|
||||
|
||||
# Threading fields from SendBlue
|
||||
inbound_handle = data.get("message_handle")
|
||||
reply_to_handle = (data.get("reply_to") or {}).get("message_handle")
|
||||
originator_handle = (data.get("thread_originator") or {}).get("message_handle")
|
||||
|
||||
# Ignore outbound messages (our own replies echoed back)
|
||||
if is_outbound:
|
||||
return jsonify({"status": "ignored"}), 200
|
||||
@@ -183,23 +224,73 @@ async def webhook():
|
||||
await send_imessage(from_number, "Sorry, this feature is restricted to admins.")
|
||||
return jsonify({"status": "forbidden"}), 200
|
||||
|
||||
# Get or create conversation
|
||||
# /threads command — list recent iMessage conversations
|
||||
if content.strip().lower() == "/threads":
|
||||
threads = (
|
||||
await Conversation.filter(user=user, channel="imessage")
|
||||
.order_by("-updated_at")
|
||||
.limit(5)
|
||||
)
|
||||
if not threads:
|
||||
await send_imessage(from_number, "No threads found.")
|
||||
else:
|
||||
lines = ["Your recent threads (most recent first):"]
|
||||
for i, t in enumerate(threads, 1):
|
||||
updated = t.updated_at.strftime("%b %-d")
|
||||
lines.append(f"{i}. {t.name} ({updated})")
|
||||
lines.append("\nReply to any message to continue its thread.")
|
||||
await send_imessage(from_number, "\n".join(lines))
|
||||
return jsonify({"status": "ok"}), 200
|
||||
|
||||
# Determine conversation based on iMessage reply threading:
|
||||
# - Reply to an existing message → continue that conversation
|
||||
# - New standalone message → start a fresh conversation
|
||||
try:
|
||||
conversation = await get_conversation_for_channel(user=user, channel="imessage")
|
||||
if reply_to_handle:
|
||||
lookup_handle = originator_handle or reply_to_handle
|
||||
parent_msg = await ConversationMessage.get_or_none(
|
||||
message_handle=lookup_handle,
|
||||
conversation__user=user,
|
||||
conversation__channel="imessage",
|
||||
)
|
||||
if parent_msg:
|
||||
conversation = await Conversation.get(id=parent_msg.conversation_id)
|
||||
logger.info(
|
||||
f"Continuing conversation {conversation.id} via reply to {lookup_handle!r}"
|
||||
)
|
||||
else:
|
||||
# Handle not found (pre-migration messages) — fall back to most recent
|
||||
logger.warning(
|
||||
f"Reply handle {lookup_handle!r} not in DB; falling back to most recent conversation"
|
||||
)
|
||||
conversation = await get_conversation_for_channel(
|
||||
user=user, channel="imessage"
|
||||
)
|
||||
else:
|
||||
conversation = await Conversation.create(
|
||||
name="New iMessage",
|
||||
user=user,
|
||||
channel="imessage",
|
||||
)
|
||||
logger.info(
|
||||
f"New iMessage conversation {conversation.id} for {user.username}"
|
||||
)
|
||||
|
||||
await conversation.fetch_related("messages")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get conversation for user {user.username}: {e}")
|
||||
logger.error(f"Failed to get/create conversation for {user.username}: {e}")
|
||||
await send_imessage(
|
||||
from_number, "Sorry, something went wrong. Please try again later."
|
||||
)
|
||||
return jsonify({"status": "error"}), 200
|
||||
|
||||
# Add user message to conversation
|
||||
# Add user message (store the inbound handle for future reply lookups)
|
||||
await add_message_to_conversation(
|
||||
conversation=conversation,
|
||||
message=content,
|
||||
speaker="user",
|
||||
user=user,
|
||||
message_handle=inbound_handle,
|
||||
)
|
||||
|
||||
# Attach image directly to the agent request if present
|
||||
@@ -216,7 +307,11 @@ async def webhook():
|
||||
logger.error(f"Failed to load iMessage image: {e}")
|
||||
content = f"{content or ''}\n\n[An image was sent but could not be loaded.]".strip()
|
||||
|
||||
# Show typing indicator for up to 2 minutes while the agent processes
|
||||
await send_typing_indicator(from_number, state="start", max_duration_ms=120_000)
|
||||
|
||||
# Build messages payload for LangChain agent
|
||||
response_text = "Sorry, I'm having trouble thinking right now."
|
||||
try:
|
||||
messages = await conversation.messages.all()
|
||||
recent_messages = list(messages)[-20:]
|
||||
@@ -244,25 +339,30 @@ async def webhook():
|
||||
response = await main_agent.ainvoke(
|
||||
{"messages": messages_payload}, config=agent_config
|
||||
)
|
||||
response_text = response.get("messages", [])[-1].content
|
||||
response_text = extract_agent_text(response.get("messages", [])[-1].content)
|
||||
await persist_agent_tool_messages(
|
||||
conversation, user, response.get("messages", [])
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error invoking agent: {e}")
|
||||
response_text = "Sorry, I'm having trouble thinking right now."
|
||||
finally:
|
||||
await send_typing_indicator(from_number, state="stop")
|
||||
|
||||
# Send reply threaded under the user's inbound message
|
||||
send_resp = await send_imessage(
|
||||
from_number,
|
||||
strip_markdown(response_text),
|
||||
reply_to_handle=inbound_handle,
|
||||
)
|
||||
outbound_handle = (send_resp or {}).get("message_handle")
|
||||
|
||||
# Save and send response
|
||||
await add_message_to_conversation(
|
||||
conversation=conversation,
|
||||
message=response_text,
|
||||
speaker="simba",
|
||||
user=user,
|
||||
message_handle=outbound_handle,
|
||||
)
|
||||
|
||||
from utils.strip_markdown import strip_markdown
|
||||
|
||||
await send_imessage(from_number, strip_markdown(response_text))
|
||||
|
||||
return jsonify({"status": "ok"}), 200
|
||||
|
||||
@@ -16,6 +16,7 @@ from blueprints.conversation.logic import (
|
||||
from blueprints.conversation.models import Speaker
|
||||
from blueprints.conversation.agents import main_agent
|
||||
from blueprints.conversation.prompts import SIMBA_SYSTEM_PROMPT
|
||||
from utils.strip_markdown import extract_agent_text
|
||||
|
||||
whatsapp_blueprint = Blueprint("whatsapp_api", __name__, url_prefix="/api/whatsapp")
|
||||
|
||||
@@ -198,7 +199,7 @@ async def webhook():
|
||||
# Invoke LangChain agent
|
||||
logger.info(f"Invoking LangChain agent with {len(messages_payload)} messages")
|
||||
response = await main_agent.ainvoke({"messages": messages_payload})
|
||||
response_text = response.get("messages", [])[-1].content
|
||||
response_text = extract_agent_text(response.get("messages", [])[-1].content)
|
||||
await persist_agent_tool_messages(
|
||||
conversation, user, response.get("messages", [])
|
||||
)
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
import re
|
||||
|
||||
_THINK_RE = re.compile(r"<think>.*?</think>", re.DOTALL)
|
||||
|
||||
|
||||
def extract_agent_text(content) -> str:
|
||||
"""Extract plain text from a LangChain AIMessage content.
|
||||
|
||||
Handles both str and list-of-blocks content (the latter occurs with some
|
||||
tool-using or vision models), and strips <think>...</think> tokens emitted
|
||||
by reasoning models like Qwen3.
|
||||
"""
|
||||
if isinstance(content, list):
|
||||
text = "".join(
|
||||
block.get("text", "")
|
||||
if isinstance(block, dict) and block.get("type") != "thinking"
|
||||
else (str(block) if not isinstance(block, dict) else "")
|
||||
for block in content
|
||||
)
|
||||
else:
|
||||
text = str(content) if content is not None else ""
|
||||
return _THINK_RE.sub("", text).strip()
|
||||
|
||||
|
||||
def strip_markdown(text: str) -> str:
|
||||
"""Strip markdown formatting from text for plain-text channels like iMessage."""
|
||||
|
||||
Reference in New Issue
Block a user