mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-26 11:12:03 +00:00
fix(whatsapp_cloud): resolve reply-to text so the agent sees reply context (#52957)
Replies on WhatsApp Cloud arrived at the agent with reply_to_id set but reply_to_text=None, so run.py never injected the "[Replying to: ...]" disambiguation prefix (it gates on reply_to_text). Meta's webhook context object carries only the quoted message's id, never its text. Index (chat_id, wamid) -> text in rich_sent_store on every inbound message and every outbound text send -- the same store that solved the identical Telegram rich-send problem -- then look up the quoted text in _build_message_event_from_cloud and populate reply_to_text plus reply_to_is_own_message, derived from context.from versus the business number.
This commit is contained in:
parent
19b2624404
commit
619dc4a561
2 changed files with 139 additions and 1 deletions
|
|
@ -79,6 +79,7 @@ from gateway.platforms.base import (
|
|||
SUPPORTED_DOCUMENT_TYPES,
|
||||
)
|
||||
from gateway.platforms.whatsapp_common import WhatsAppBehaviorMixin
|
||||
from gateway import rich_sent_store
|
||||
from hermes_constants import get_hermes_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -489,6 +490,15 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
# Remember (chat_id, wamid) -> text so that when the user replies to
|
||||
# one of our messages, _build_message_event_from_cloud can resolve the
|
||||
# quoted text. Meta's inbound webhook ``context`` object carries only
|
||||
# the quoted message's id, never its text, so without this index the
|
||||
# agent would never learn what the user was replying to. Best-effort;
|
||||
# rich_sent_store swallows all errors.
|
||||
if last_message_id:
|
||||
rich_sent_store.record(chat_id, last_message_id, formatted)
|
||||
|
||||
return SendResult(success=True, message_id=last_message_id)
|
||||
|
||||
# ------------------------------------------------------------------ typing indicator + read receipts
|
||||
|
|
@ -1923,9 +1933,26 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
|
|||
doc_path,
|
||||
)
|
||||
|
||||
# context.id is set when the user replied to one of our messages.
|
||||
# context.id is set when the user replied to a prior message. Meta's
|
||||
# webhook only gives us the quoted message's id (and its author in
|
||||
# context.from) — never the quoted text. We resolve the text from
|
||||
# rich_sent_store, which we populate on every inbound message (below)
|
||||
# and every outbound send. Without this the agent receives a bare
|
||||
# reply_to_message_id and run.py can't inject the "[Replying to: ...]"
|
||||
# disambiguation prefix (it gates on reply_to_text being present).
|
||||
context = raw_message.get("context") or {}
|
||||
reply_to_id = str(context.get("id") or "").strip() or None
|
||||
reply_to_text: Optional[str] = None
|
||||
reply_to_is_own = False
|
||||
if reply_to_id:
|
||||
reply_to_text = rich_sent_store.lookup(chat_id, reply_to_id)
|
||||
# context.from is the wa_id of the quoted message's author. When it
|
||||
# matches our business number the user replied to the bot's own
|
||||
# message; otherwise they replied to one of their own messages.
|
||||
quoted_from = str(context.get("from") or "").strip()
|
||||
our_number = str(metadata.get("display_phone_number") or "").strip()
|
||||
if quoted_from and our_number:
|
||||
reply_to_is_own = quoted_from == our_number
|
||||
|
||||
source = self.build_source(
|
||||
chat_id=chat_id,
|
||||
|
|
@ -1945,6 +1972,11 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
|
|||
# gating) so filtered messages don't leak typing on
|
||||
# unwanted inbound traffic.
|
||||
self._bounded_put(self._last_inbound_wamid_by_chat, chat_id, wamid)
|
||||
# Index this message's text by wamid so a later reply to it can
|
||||
# resolve the quoted text (Meta's webhook context carries only
|
||||
# the id). Mirrors the outbound record in send(). Best-effort.
|
||||
if body:
|
||||
rich_sent_store.record(chat_id, wamid, body)
|
||||
|
||||
return MessageEvent(
|
||||
text=body,
|
||||
|
|
@ -1953,6 +1985,8 @@ class WhatsAppCloudAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
|
|||
raw_message=raw_message,
|
||||
message_id=wamid,
|
||||
reply_to_message_id=reply_to_id,
|
||||
reply_to_text=reply_to_text,
|
||||
reply_to_is_own_message=reply_to_is_own,
|
||||
media_urls=media_urls,
|
||||
media_types=media_types,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2319,3 +2319,107 @@ class TestMediaIdValidation:
|
|||
path, mime = await adapter._download_media_to_cache("../../etc/passwd")
|
||||
assert path is None and mime is None
|
||||
adapter._http_client.get.assert_not_called()
|
||||
|
||||
|
||||
class TestReplyContextResolution:
|
||||
"""The Cloud webhook ``context`` object only carries the quoted message's
|
||||
id (and author), never its text. We resolve the text from rich_sent_store,
|
||||
which is populated on every inbound message and every outbound send. Without
|
||||
a resolved ``reply_to_text`` run.py can't inject the disambiguation prefix,
|
||||
so the agent never learns the message was a reply (the user-reported bug).
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reply_to_own_earlier_message_resolves_text(self):
|
||||
"""User replies to their own earlier message — its text was indexed
|
||||
on the earlier inbound, so the reply resolves it."""
|
||||
adapter = _make_adapter()
|
||||
# First inbound message gets recorded by wamid.
|
||||
await adapter._build_message_event_from_cloud(
|
||||
{"from": "15551234567", "id": "wamid.PRIOR", "type": "text",
|
||||
"text": {"body": "remind me to buy milk"}},
|
||||
{"15551234567": "Alice"}, {},
|
||||
)
|
||||
# Now the user replies to that earlier message.
|
||||
event = await adapter._build_message_event_from_cloud(
|
||||
{"from": "15551234567", "id": "wamid.REPLY", "type": "text",
|
||||
"text": {"body": "did you?"},
|
||||
"context": {"id": "wamid.PRIOR", "from": "15551234567"}},
|
||||
{"15551234567": "Alice"}, {},
|
||||
)
|
||||
assert event is not None
|
||||
assert event.reply_to_message_id == "wamid.PRIOR"
|
||||
assert event.reply_to_text == "remind me to buy milk"
|
||||
assert event.reply_to_is_own_message is False # quoted author == the user
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reply_to_bot_message_marks_own(self):
|
||||
"""User replies to one of the bot's messages — context.from matches the
|
||||
business number, so reply_to_is_own_message is True and text resolves
|
||||
from the outbound record made in send()."""
|
||||
from gateway import rich_sent_store
|
||||
|
||||
adapter = _make_adapter()
|
||||
# Simulate the outbound record send() would have made.
|
||||
rich_sent_store.record("15551234567", "wamid.BOT", "Sure, milk added.")
|
||||
event = await adapter._build_message_event_from_cloud(
|
||||
{"from": "15551234567", "id": "wamid.REPLY", "type": "text",
|
||||
"text": {"body": "thanks"},
|
||||
"context": {"id": "wamid.BOT", "from": "15550009999"}},
|
||||
{"15551234567": "Alice"},
|
||||
{"display_phone_number": "15550009999"},
|
||||
)
|
||||
assert event is not None
|
||||
assert event.reply_to_message_id == "wamid.BOT"
|
||||
assert event.reply_to_text == "Sure, milk added."
|
||||
assert event.reply_to_is_own_message is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reply_to_unknown_message_id_no_text(self):
|
||||
"""Quoted message we never indexed (e.g. before gateway start) — id is
|
||||
still surfaced, text is None, and we don't crash."""
|
||||
adapter = _make_adapter()
|
||||
event = await adapter._build_message_event_from_cloud(
|
||||
{"from": "15551234567", "id": "wamid.REPLY", "type": "text",
|
||||
"text": {"body": "what about this"},
|
||||
"context": {"id": "wamid.GONE", "from": "15551234567"}},
|
||||
{"15551234567": "Alice"}, {},
|
||||
)
|
||||
assert event is not None
|
||||
assert event.reply_to_message_id == "wamid.GONE"
|
||||
assert event.reply_to_text is None
|
||||
assert event.reply_to_is_own_message is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_reply_message_has_no_reply_context(self):
|
||||
adapter = _make_adapter()
|
||||
event = await adapter._build_message_event_from_cloud(
|
||||
{"from": "15551234567", "id": "wamid.PLAIN", "type": "text",
|
||||
"text": {"body": "hello"}},
|
||||
{"15551234567": "Alice"}, {},
|
||||
)
|
||||
assert event is not None
|
||||
assert event.reply_to_message_id is None
|
||||
assert event.reply_to_text is None
|
||||
assert event.reply_to_is_own_message is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_records_outbound_text_by_wamid(self):
|
||||
"""send() must index its own wamid -> text so replies to the bot
|
||||
resolve. Verify the round-trip through rich_sent_store."""
|
||||
from gateway import rich_sent_store
|
||||
|
||||
adapter = _make_adapter()
|
||||
adapter._http_client = MagicMock()
|
||||
adapter._http_client.post = AsyncMock(
|
||||
return_value=_mock_httpx_response(
|
||||
200, {"messages": [{"id": "wamid.OUT"}]}
|
||||
)
|
||||
)
|
||||
result = await adapter.send("15551234567", "here is your answer")
|
||||
assert result.success and result.message_id == "wamid.OUT"
|
||||
assert (
|
||||
rich_sent_store.lookup("15551234567", "wamid.OUT")
|
||||
== "here is your answer"
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue