2026-10-08 22:35:00 +08:00
|
|
|
"""
|
2026-10-26 00:50:43 +08:00
|
|
|
Copyright 2024-2026 ReplyPier
|
2026-10-08 22:35:00 +08:00
|
|
|
|
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
|
you may not use this file except in compliance with the License.
|
|
|
|
|
You may obtain a copy of the License at
|
|
|
|
|
|
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
|
|
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
|
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
|
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
|
|
See the License for the specific language governing permissions and
|
|
|
|
|
limitations under the License.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from types import SimpleNamespace
|
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
|
from uuid import uuid4
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from app.channels.base import InboundMessage, InteractiveReply, InteractiveReplyType
|
|
|
|
|
from app.channels.interactive_limits import TELEGRAM_LIMITS, WHATSAPP_LIMITS
|
|
|
|
|
from app.models.channels import ChannelConversation
|
|
|
|
|
from app.models.chat_history import ChatHistory
|
|
|
|
|
from app.models.agent import AgentType
|
|
|
|
|
from app.models.session_to_agent import SessionToAgent, SessionStatus
|
|
|
|
|
from app.repositories.channels import (
|
|
|
|
|
ChannelAccountRepository,
|
|
|
|
|
ChannelConversationRepository,
|
|
|
|
|
AgentChannelConfigRepository,
|
|
|
|
|
)
|
|
|
|
|
from app.repositories.agent import AgentRepository
|
|
|
|
|
from app.repositories.chat import ChatRepository
|
|
|
|
|
from app.services import agent_rooms, channel_chat
|
|
|
|
|
from app.services.human_routing import HUMAN_ONLY_ACK
|
|
|
|
|
from app.services.business_message import remember_business_message
|
|
|
|
|
from app.services.channel_chat import process_channel_message
|
|
|
|
|
from app.services.message_delivery import DeliveryResult
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def make_inbound(text="hello"):
|
|
|
|
|
return InboundMessage(
|
|
|
|
|
external_account_id="bot123",
|
|
|
|
|
external_conversation_id="222",
|
|
|
|
|
external_user_id="111",
|
|
|
|
|
external_message_id="42",
|
|
|
|
|
text=text,
|
|
|
|
|
profile={"name": "Ada Lovelace"},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def account(db, test_organization):
|
|
|
|
|
return ChannelAccountRepository(db).create_account(
|
|
|
|
|
organization_id=test_organization.id,
|
|
|
|
|
channel_type="telegram",
|
|
|
|
|
external_account_id="bot123",
|
|
|
|
|
credentials={"bot_token": "123:abc"},
|
|
|
|
|
display_name="@testbot",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def routed_account(db, account, test_agent):
|
|
|
|
|
AgentChannelConfigRepository(db).set_agent(account.id, test_agent.id)
|
|
|
|
|
return account
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def use_test_db(db, monkeypatch):
|
|
|
|
|
"""process_channel_message opens its own SessionLocal; bind it to the test db."""
|
|
|
|
|
class _NonClosing:
|
|
|
|
|
def __init__(self, inner):
|
|
|
|
|
self._inner = inner
|
|
|
|
|
def __getattr__(self, name):
|
|
|
|
|
return getattr(self._inner, name)
|
|
|
|
|
def close(self):
|
|
|
|
|
pass # keep the fixture-owned session usable after processing
|
|
|
|
|
monkeypatch.setattr(channel_chat, "SessionLocal", lambda: _NonClosing(db))
|
|
|
|
|
return db
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def mock_sio(monkeypatch):
|
|
|
|
|
sio = MagicMock()
|
|
|
|
|
sio.emit = AsyncMock()
|
|
|
|
|
monkeypatch.setattr(agent_rooms, "sio", sio)
|
|
|
|
|
return sio
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_drops_message_when_no_agent_routed(db, account, use_test_db, test_ai_config):
|
|
|
|
|
with patch.object(channel_chat, "ChatAgent") as mock_agent:
|
|
|
|
|
await process_channel_message(account.id, make_inbound())
|
|
|
|
|
mock_agent.create_async.assert_not_called()
|
|
|
|
|
# No session created
|
|
|
|
|
assert db.query(ChannelConversation).count() == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_ai_path_creates_session_and_delivers(db, routed_account, use_test_db, test_ai_config, test_agent, mock_sio):
|
|
|
|
|
response = SimpleNamespace(message="hi there", transfer_to_human=False,
|
|
|
|
|
end_chat=False, request_lead_capture=False)
|
|
|
|
|
fake_agent = MagicMock()
|
|
|
|
|
fake_agent.get_response = AsyncMock(return_value=response)
|
|
|
|
|
fake_agent.safe_cleanup_mcp_tools = AsyncMock()
|
|
|
|
|
|
|
|
|
|
with patch.object(channel_chat.ChatAgent, "create_async", AsyncMock(return_value=fake_agent)) as create_async, \
|
|
|
|
|
patch.object(channel_chat, "deliver_to_customer", AsyncMock()) as deliver:
|
|
|
|
|
await process_channel_message(routed_account.id, make_inbound())
|
|
|
|
|
|
|
|
|
|
assert create_async.await_args.kwargs["channel"] == "telegram"
|
|
|
|
|
fake_agent.get_response.assert_awaited_once()
|
|
|
|
|
fake_agent.safe_cleanup_mcp_tools.assert_awaited()
|
|
|
|
|
deliver.assert_awaited_once()
|
|
|
|
|
payload = deliver.await_args.args[2]
|
|
|
|
|
assert payload["message"] == "hi there"
|
|
|
|
|
|
|
|
|
|
# Session + conversation persisted with the right channel
|
|
|
|
|
conversation = db.query(ChannelConversation).one()
|
|
|
|
|
session = db.query(SessionToAgent).filter_by(session_id=conversation.session_id).one()
|
|
|
|
|
assert session.channel == "telegram"
|
|
|
|
|
assert str(session.agent_id) == str(test_agent.id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _agent_persisting_reply(db, text="hi there"):
|
|
|
|
|
"""A ChatAgent stub that persists its reply the way the real one does and
|
|
|
|
|
reports the row id, so delivery results land on that row."""
|
|
|
|
|
response = SimpleNamespace(message=text, transfer_to_human=False,
|
|
|
|
|
end_chat=False, request_lead_capture=False)
|
|
|
|
|
|
|
|
|
|
fake_agent = MagicMock()
|
|
|
|
|
|
|
|
|
|
async def _respond(**kwargs):
|
|
|
|
|
fake_agent.last_bot_message_id = ChatRepository(db).create_message({
|
|
|
|
|
"message": text,
|
|
|
|
|
"message_type": "bot",
|
|
|
|
|
"session_id": kwargs["session_id"],
|
|
|
|
|
"organization_id": kwargs["org_id"],
|
|
|
|
|
"agent_id": kwargs["agent_id"],
|
|
|
|
|
"customer_id": kwargs["customer_id"],
|
|
|
|
|
}).id
|
|
|
|
|
return response
|
|
|
|
|
|
|
|
|
|
fake_agent.get_response = AsyncMock(side_effect=_respond)
|
|
|
|
|
fake_agent.safe_cleanup_mcp_tools = AsyncMock()
|
|
|
|
|
return fake_agent
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_bot_reply_records_delivery_failure(db, routed_account, use_test_db, test_ai_config, mock_sio):
|
|
|
|
|
"""Outside the messaging window the customer receives nothing, so the stored
|
|
|
|
|
reply must record that instead of looking delivered."""
|
|
|
|
|
fake_agent = _agent_persisting_reply(db)
|
|
|
|
|
failed = DeliveryResult(ok=False, reason="window_expired", can_template=True)
|
|
|
|
|
|
|
|
|
|
with patch.object(channel_chat.ChatAgent, "create_async", AsyncMock(return_value=fake_agent)), \
|
|
|
|
|
patch.object(channel_chat, "deliver_to_customer", AsyncMock(return_value=failed)):
|
|
|
|
|
await process_channel_message(routed_account.id, make_inbound())
|
|
|
|
|
|
|
|
|
|
message = db.query(ChatHistory).filter_by(message_type="bot").one()
|
|
|
|
|
assert message.attributes["delivery_status"] == "window_expired"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_bot_reply_marks_nothing_when_delivered(db, routed_account, use_test_db, test_ai_config, mock_sio):
|
|
|
|
|
fake_agent = _agent_persisting_reply(db)
|
|
|
|
|
|
|
|
|
|
with patch.object(channel_chat.ChatAgent, "create_async", AsyncMock(return_value=fake_agent)), \
|
|
|
|
|
patch.object(channel_chat, "deliver_to_customer", AsyncMock(return_value=DeliveryResult(ok=True))):
|
|
|
|
|
await process_channel_message(routed_account.id, make_inbound())
|
|
|
|
|
|
|
|
|
|
message = db.query(ChatHistory).filter_by(message_type="bot").one()
|
|
|
|
|
assert "delivery_status" not in (message.attributes or {})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_reuses_open_session_for_same_conversation(db, routed_account, use_test_db, test_ai_config, mock_sio):
|
|
|
|
|
response = SimpleNamespace(message="ok", transfer_to_human=False,
|
|
|
|
|
end_chat=False, request_lead_capture=False)
|
|
|
|
|
fake_agent = MagicMock()
|
|
|
|
|
fake_agent.get_response = AsyncMock(return_value=response)
|
|
|
|
|
fake_agent.safe_cleanup_mcp_tools = AsyncMock()
|
|
|
|
|
|
|
|
|
|
with patch.object(channel_chat.ChatAgent, "create_async", AsyncMock(return_value=fake_agent)), \
|
|
|
|
|
patch.object(channel_chat, "deliver_to_customer", AsyncMock()):
|
|
|
|
|
await process_channel_message(routed_account.id, make_inbound("first"))
|
|
|
|
|
await process_channel_message(routed_account.id, make_inbound("second"))
|
|
|
|
|
|
|
|
|
|
assert db.query(ChannelConversation).count() == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestAgentChangeStartsANewSession:
|
|
|
|
|
"""A session belongs to one agent: its history, and the AI runtime's memory
|
|
|
|
|
for that session id, are that agent's. Reusing it after the channel is
|
|
|
|
|
pointed at a different agent made the new agent answer out of the previous
|
|
|
|
|
one's retrieved knowledge, and split the inbox in two — the conversations
|
|
|
|
|
list groups by agent *and* session while the detail view keys on session
|
|
|
|
|
alone, so one thread showed twice and neither row opened the other."""
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
async def _send(routed_account, text):
|
|
|
|
|
response = SimpleNamespace(message="ok", transfer_to_human=False,
|
|
|
|
|
end_chat=False, request_lead_capture=False)
|
|
|
|
|
fake_agent = MagicMock()
|
|
|
|
|
fake_agent.get_response = AsyncMock(return_value=response)
|
|
|
|
|
fake_agent.safe_cleanup_mcp_tools = AsyncMock()
|
|
|
|
|
with patch.object(channel_chat.ChatAgent, "create_async", AsyncMock(return_value=fake_agent)), \
|
|
|
|
|
patch.object(channel_chat, "deliver_to_customer", AsyncMock()):
|
|
|
|
|
await process_channel_message(routed_account.id, make_inbound(text))
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_a_new_agent_gets_a_new_session(self, db, routed_account, use_test_db,
|
|
|
|
|
test_ai_config, test_organization, mock_sio):
|
|
|
|
|
await self._send(routed_account, "first")
|
|
|
|
|
first = db.query(ChannelConversation).one()
|
|
|
|
|
|
|
|
|
|
other = AgentRepository(db).create_agent(
|
|
|
|
|
name="Other Agent", agent_type=AgentType.CUSTOMER_SUPPORT,
|
|
|
|
|
org_id=test_organization.id, instructions=["help"])
|
|
|
|
|
AgentChannelConfigRepository(db).set_agent(routed_account.id, other.id)
|
|
|
|
|
|
|
|
|
|
await self._send(routed_account, "second")
|
|
|
|
|
|
|
|
|
|
# Same platform thread, but a second conversation and a second session.
|
|
|
|
|
conversations = db.query(ChannelConversation).order_by(
|
|
|
|
|
ChannelConversation.created_at).all()
|
|
|
|
|
assert len(conversations) == 2
|
|
|
|
|
assert conversations[0].session_id != conversations[1].session_id
|
|
|
|
|
assert {c.external_conversation_id for c in conversations} == {"222"}
|
|
|
|
|
|
|
|
|
|
# The retired session is closed, so it stops being picked up, and each
|
|
|
|
|
# session is bound to exactly one agent.
|
|
|
|
|
old = db.query(SessionToAgent).filter_by(session_id=first.session_id).one()
|
|
|
|
|
new = db.query(SessionToAgent).filter_by(session_id=conversations[1].session_id).one()
|
|
|
|
|
assert old.status == SessionStatus.CLOSED
|
|
|
|
|
assert str(new.agent_id) == str(other.id)
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_the_same_agent_keeps_its_session(self, db, routed_account, use_test_db,
|
|
|
|
|
test_ai_config, mock_sio):
|
|
|
|
|
"""The guard must not fire on every message — only on a real change."""
|
|
|
|
|
await self._send(routed_account, "first")
|
|
|
|
|
await self._send(routed_account, "second")
|
|
|
|
|
|
|
|
|
|
assert db.query(ChannelConversation).count() == 1
|
|
|
|
|
assert db.query(SessionToAgent).filter_by(
|
|
|
|
|
status=SessionStatus.CLOSED).count() == 0
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_a_human_handled_chat_is_not_rotated(self, db, routed_account, use_test_db,
|
|
|
|
|
test_ai_config, test_organization,
|
|
|
|
|
test_user, mock_sio):
|
|
|
|
|
"""Rotating here would drop the human agent out of a live conversation."""
|
|
|
|
|
await self._send(routed_account, "first")
|
|
|
|
|
conversation = db.query(ChannelConversation).one()
|
|
|
|
|
session = db.query(SessionToAgent).filter_by(
|
|
|
|
|
session_id=conversation.session_id).one()
|
|
|
|
|
session.user_id = test_user.id
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
other = AgentRepository(db).create_agent(
|
|
|
|
|
name="Other Agent 2", agent_type=AgentType.CUSTOMER_SUPPORT,
|
|
|
|
|
org_id=test_organization.id, instructions=["help"])
|
|
|
|
|
AgentChannelConfigRepository(db).set_agent(routed_account.id, other.id)
|
|
|
|
|
|
|
|
|
|
await self._send(routed_account, "while a human is on it")
|
|
|
|
|
|
|
|
|
|
assert db.query(ChannelConversation).count() == 1
|
|
|
|
|
assert db.query(SessionToAgent).filter_by(
|
|
|
|
|
session_id=conversation.session_id).one().status != SessionStatus.CLOSED
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_human_handling_relays_and_skips_bot(db, routed_account, use_test_db, test_ai_config, test_user, mock_sio):
|
|
|
|
|
# First message creates the session via the AI path
|
|
|
|
|
response = SimpleNamespace(message="ok", transfer_to_human=False,
|
|
|
|
|
end_chat=False, request_lead_capture=False)
|
|
|
|
|
fake_agent = MagicMock()
|
|
|
|
|
fake_agent.get_response = AsyncMock(return_value=response)
|
|
|
|
|
fake_agent.safe_cleanup_mcp_tools = AsyncMock()
|
|
|
|
|
with patch.object(channel_chat.ChatAgent, "create_async", AsyncMock(return_value=fake_agent)), \
|
|
|
|
|
patch.object(channel_chat, "deliver_to_customer", AsyncMock()):
|
|
|
|
|
await process_channel_message(routed_account.id, make_inbound("first"))
|
|
|
|
|
|
|
|
|
|
# A human takes over the session
|
|
|
|
|
conversation = db.query(ChannelConversation).one()
|
|
|
|
|
session = db.query(SessionToAgent).filter_by(session_id=conversation.session_id).one()
|
|
|
|
|
session.user_id = test_user.id
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
with patch.object(channel_chat.ChatAgent, "create_async", AsyncMock()) as create_async:
|
|
|
|
|
await process_channel_message(routed_account.id, make_inbound("for the human"))
|
|
|
|
|
create_async.assert_not_awaited()
|
|
|
|
|
|
|
|
|
|
# Relayed to the handling agent's rooms
|
|
|
|
|
rooms = [call.kwargs.get("room") for call in mock_sio.emit.await_args_list]
|
|
|
|
|
assert f"user_{test_user.id}" in rooms
|
|
|
|
|
assert str(session.session_id) in rooms
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_human_only_agent_never_runs_the_bot(db, routed_account, use_test_db,
|
|
|
|
|
test_ai_config, test_agent, mock_sio):
|
|
|
|
|
"""AI switched off on the agent: relay to the dashboard, never model.
|
|
|
|
|
|
|
|
|
|
No transfer either — the chat simply waits for someone to pick it up, so
|
|
|
|
|
the customer gets no automated reply.
|
|
|
|
|
"""
|
|
|
|
|
test_agent.ai_replies_enabled = False
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
with patch.object(channel_chat.ChatAgent, "create_async", AsyncMock()) as create_async, \
|
|
|
|
|
patch("app.services.human_routing.deliver_to_customer",
|
|
|
|
|
AsyncMock(return_value=DeliveryResult(ok=True))) as ack:
|
|
|
|
|
await process_channel_message(routed_account.id, make_inbound("hello"))
|
|
|
|
|
create_async.assert_not_awaited()
|
|
|
|
|
# One acknowledgement so the number doesn't look dead, nothing more.
|
|
|
|
|
assert ack.await_count == 1
|
|
|
|
|
assert ack.await_args.args[2]['message'] == HUMAN_ONLY_ACK
|
|
|
|
|
|
|
|
|
|
# A follow-up message is not acknowledged again.
|
|
|
|
|
with patch.object(channel_chat.ChatAgent, "create_async", AsyncMock()), \
|
|
|
|
|
patch("app.services.human_routing.deliver_to_customer",
|
|
|
|
|
AsyncMock(return_value=DeliveryResult(ok=True))) as ack:
|
|
|
|
|
await process_channel_message(routed_account.id, make_inbound("still there?"))
|
|
|
|
|
assert not ack.called
|
|
|
|
|
|
|
|
|
|
conversation = db.query(ChannelConversation).one()
|
|
|
|
|
session = db.query(SessionToAgent).filter_by(
|
|
|
|
|
session_id=conversation.session_id).one()
|
|
|
|
|
# A plain open chat waiting in the inbox, not a transfer.
|
|
|
|
|
assert session.status == SessionStatus.OPEN
|
|
|
|
|
assert session.user_id is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_email_channel_uses_real_customer_email(db, test_organization):
|
|
|
|
|
"""Email sender address is used verbatim as the customer email (unifies
|
|
|
|
|
the person across channels + widget) — not the synthetic @email.channel."""
|
|
|
|
|
from app.services.channel_chat import _get_or_create_customer
|
|
|
|
|
from app.models.channels import ChannelAccount
|
|
|
|
|
from app.models.customer import Customer
|
|
|
|
|
from app.channels.base import InboundMessage
|
|
|
|
|
import uuid as _uuid
|
|
|
|
|
|
|
|
|
|
account = MagicMock(spec=ChannelAccount)
|
|
|
|
|
account.channel_type = "email"
|
|
|
|
|
inbound = InboundMessage(
|
|
|
|
|
external_account_id="", external_conversation_id="jane@acme.com",
|
|
|
|
|
external_user_id="jane@acme.com", external_message_id="m1", text="hi",
|
|
|
|
|
profile={"name": "Jane Roe", "email": "Jane@Acme.com"})
|
|
|
|
|
customer_id = _get_or_create_customer(db, account, inbound, str(test_organization.id))
|
|
|
|
|
customer = db.query(Customer).filter(Customer.id == _uuid.UUID(customer_id)).one()
|
|
|
|
|
assert customer.email == "jane@acme.com" # lowercased real address, no suffix
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_non_email_channel_synthesizes_address(db, test_organization):
|
|
|
|
|
from app.services.channel_chat import _get_or_create_customer
|
|
|
|
|
from app.models.channels import ChannelAccount
|
|
|
|
|
from app.models.customer import Customer
|
|
|
|
|
from app.channels.base import InboundMessage
|
|
|
|
|
import uuid as _uuid
|
|
|
|
|
|
|
|
|
|
account = MagicMock(spec=ChannelAccount)
|
|
|
|
|
account.channel_type = "telegram"
|
|
|
|
|
inbound = InboundMessage(
|
|
|
|
|
external_account_id="", external_conversation_id="555",
|
|
|
|
|
external_user_id="555", external_message_id="m1", text="hi",
|
|
|
|
|
profile={"name": "Bob"})
|
|
|
|
|
customer_id = _get_or_create_customer(db, account, inbound, str(test_organization.id))
|
|
|
|
|
customer = db.query(Customer).filter(Customer.id == _uuid.UUID(customer_id)).one()
|
|
|
|
|
assert customer.email == "555@telegram.channel"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_placeholder_name_upgraded_when_real_name_resolved(db, account, test_organization):
|
|
|
|
|
"""An existing customer created with a '<Channel> user xxxx' placeholder is
|
|
|
|
|
renamed once fetch_profile resolves a real name."""
|
|
|
|
|
from app.services.channel_chat import _get_or_create_customer
|
|
|
|
|
from app.channels.base import InboundMessage
|
|
|
|
|
from app.models.customer import Customer
|
|
|
|
|
import uuid as _uuid
|
|
|
|
|
|
|
|
|
|
account.channel_type = "slack"
|
|
|
|
|
# First message: no real name yet → placeholder
|
|
|
|
|
inbound1 = InboundMessage(external_account_id="T1", external_conversation_id="D7",
|
|
|
|
|
external_user_id="U09UPKP7", external_message_id="e1", text="hi")
|
|
|
|
|
cid = _get_or_create_customer(db, account, inbound1, str(test_organization.id))
|
|
|
|
|
cust = db.query(Customer).filter(Customer.id == _uuid.UUID(cid)).one()
|
|
|
|
|
assert cust.full_name == "Slack user U09UPKP7"
|
|
|
|
|
|
|
|
|
|
# Second message: profile now carries the real name → upgraded in place
|
|
|
|
|
inbound2 = InboundMessage(external_account_id="T1", external_conversation_id="D7",
|
|
|
|
|
external_user_id="U09UPKP7", external_message_id="e2", text="hi again",
|
|
|
|
|
profile={"name": "Ada Lovelace"})
|
|
|
|
|
cid2 = _get_or_create_customer(db, account, inbound2, str(test_organization.id))
|
|
|
|
|
assert cid2 == cid # same customer
|
|
|
|
|
db.refresh(cust)
|
|
|
|
|
assert cust.full_name == "Ada Lovelace"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_enrich_customer_name_only_fetches_while_placeholder(db, account, test_organization):
|
|
|
|
|
"""fetch_profile is called to resolve a placeholder name, but skipped once
|
|
|
|
|
the customer already has a real name (no per-message API call)."""
|
|
|
|
|
from unittest.mock import AsyncMock
|
|
|
|
|
from app.services.channel_chat import _get_or_create_customer, _enrich_customer_name
|
|
|
|
|
from app.channels.base import InboundMessage
|
|
|
|
|
from app.models.customer import Customer
|
|
|
|
|
import uuid as _uuid
|
|
|
|
|
|
|
|
|
|
account.channel_type = "slack"
|
|
|
|
|
inbound = InboundMessage(external_account_id="T1", external_conversation_id="D8",
|
|
|
|
|
external_user_id="U0ENRICH", external_message_id="e1", text="hi")
|
|
|
|
|
cid = _get_or_create_customer(db, account, inbound, str(test_organization.id))
|
|
|
|
|
cust = db.query(Customer).filter(Customer.id == _uuid.UUID(cid)).one()
|
|
|
|
|
assert cust.full_name == "Slack user U0ENRICH"
|
|
|
|
|
|
|
|
|
|
# Placeholder present → adapter is queried and the name is upgraded
|
|
|
|
|
adapter = AsyncMock()
|
|
|
|
|
adapter.fetch_profile = AsyncMock(return_value={"name": "Grace Hopper"})
|
|
|
|
|
await _enrich_customer_name(db, adapter, account, inbound, cid)
|
|
|
|
|
adapter.fetch_profile.assert_awaited_once()
|
|
|
|
|
db.refresh(cust)
|
|
|
|
|
assert cust.full_name == "Grace Hopper"
|
|
|
|
|
|
|
|
|
|
# Real name already set → no further API call on subsequent messages
|
|
|
|
|
adapter.fetch_profile.reset_mock()
|
|
|
|
|
await _enrich_customer_name(db, adapter, account, inbound, cid)
|
|
|
|
|
adapter.fetch_profile.assert_not_awaited()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_whatsapp_inbound_stores_the_phone(db, test_organization):
|
|
|
|
|
"""The WhatsApp adapter declares profile['phone']; identity stores it."""
|
|
|
|
|
from app.services.channel_chat import _get_or_create_customer
|
|
|
|
|
from app.models.channels import ChannelAccount
|
|
|
|
|
from app.models.customer import Customer
|
|
|
|
|
from app.channels.base import InboundMessage
|
|
|
|
|
import uuid as _uuid
|
|
|
|
|
|
|
|
|
|
account = MagicMock(spec=ChannelAccount)
|
|
|
|
|
account.channel_type = "whatsapp"
|
|
|
|
|
inbound = InboundMessage(
|
|
|
|
|
external_account_id="PN1", external_conversation_id="911234567890",
|
|
|
|
|
external_user_id="911234567890", external_message_id="m1", text="hi",
|
|
|
|
|
profile={"name": "Priya", "phone": "+911234567890"})
|
|
|
|
|
cid = _get_or_create_customer(db, account, inbound, str(test_organization.id))
|
|
|
|
|
customer = db.query(Customer).filter(Customer.id == _uuid.UUID(cid)).one()
|
|
|
|
|
assert customer.phone == "+911234567890"
|
|
|
|
|
assert customer.email == "911234567890@whatsapp.channel"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_phone_unifies_one_human_across_channels(db, test_organization):
|
|
|
|
|
"""A widget lead with a captured phone and a WhatsApp inbound from that
|
|
|
|
|
number resolve to ONE customer — the point of the phone identity key."""
|
|
|
|
|
from app.services.channel_chat import _get_or_create_customer
|
|
|
|
|
from app.models.channels import ChannelAccount
|
|
|
|
|
from app.repositories.customer import CustomerRepository
|
|
|
|
|
from app.channels.base import InboundMessage
|
|
|
|
|
|
|
|
|
|
widget_person = CustomerRepository(db).create_customer(
|
|
|
|
|
email="priya@example.com", organization_id=test_organization.id,
|
|
|
|
|
full_name="Priya", phone="+911234567890")
|
|
|
|
|
|
|
|
|
|
account = MagicMock(spec=ChannelAccount)
|
|
|
|
|
account.channel_type = "whatsapp"
|
|
|
|
|
inbound = InboundMessage(
|
|
|
|
|
external_account_id="PN1", external_conversation_id="911234567890",
|
|
|
|
|
external_user_id="911234567890", external_message_id="m2", text="hello",
|
|
|
|
|
profile={"phone": "+911234567890"})
|
|
|
|
|
cid = _get_or_create_customer(db, account, inbound, str(test_organization.id))
|
|
|
|
|
assert cid == str(widget_person.id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_channels_without_phone_keep_a_null_column(db, test_organization):
|
|
|
|
|
from app.services.channel_chat import _get_or_create_customer
|
|
|
|
|
from app.models.channels import ChannelAccount
|
|
|
|
|
from app.models.customer import Customer
|
|
|
|
|
from app.channels.base import InboundMessage
|
|
|
|
|
import uuid as _uuid
|
|
|
|
|
|
|
|
|
|
account = MagicMock(spec=ChannelAccount)
|
|
|
|
|
account.channel_type = "messenger"
|
|
|
|
|
inbound = InboundMessage(
|
|
|
|
|
external_account_id="PG1", external_conversation_id="PSID9",
|
|
|
|
|
external_user_id="PSID9", external_message_id="m1", text="hi")
|
|
|
|
|
cid = _get_or_create_customer(db, account, inbound, str(test_organization.id))
|
|
|
|
|
customer = db.query(Customer).filter(Customer.id == _uuid.UUID(cid)).one()
|
|
|
|
|
assert customer.phone is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_outbound_context_reaches_the_agent(db, routed_account, use_test_db, test_ai_config, mock_sio):
|
|
|
|
|
"""A reply to an outbound template is a fragment ("yes") — the agent gets
|
|
|
|
|
told what was sent, on every turn, via extra_context."""
|
|
|
|
|
response = SimpleNamespace(message="ok", transfer_to_human=False,
|
|
|
|
|
end_chat=False, request_lead_capture=False)
|
|
|
|
|
fake_agent = MagicMock()
|
|
|
|
|
fake_agent.get_response = AsyncMock(return_value=response)
|
|
|
|
|
fake_agent.safe_cleanup_mcp_tools = AsyncMock()
|
|
|
|
|
|
|
|
|
|
with patch.object(channel_chat.ChatAgent, "create_async", AsyncMock(return_value=fake_agent)) as create_async, \
|
|
|
|
|
patch.object(channel_chat, "deliver_to_customer", AsyncMock()):
|
|
|
|
|
# First inbound creates the conversation; stamp the outbound marker on
|
|
|
|
|
# it the way start_outbound_conversation does, then send the reply.
|
|
|
|
|
await process_channel_message(routed_account.id, make_inbound())
|
|
|
|
|
conversation = db.query(ChannelConversation).one()
|
|
|
|
|
remember_business_message(db, conversation, "Hi Priya, your order A-12 shipped.", "wamid.T")
|
|
|
|
|
await process_channel_message(routed_account.id, make_inbound(text="yes"))
|
|
|
|
|
|
|
|
|
|
extra = create_async.await_args.kwargs["extra_context"]
|
|
|
|
|
assert "Hi Priya, your order A-12 shipped." in extra
|
|
|
|
|
assert "Your business sent this customer" in extra
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_ordinary_inbound_conversations_carry_no_outbound_context(db, routed_account, use_test_db, test_ai_config, mock_sio):
|
|
|
|
|
response = SimpleNamespace(message="ok", transfer_to_human=False,
|
|
|
|
|
end_chat=False, request_lead_capture=False)
|
|
|
|
|
fake_agent = MagicMock()
|
|
|
|
|
fake_agent.get_response = AsyncMock(return_value=response)
|
|
|
|
|
fake_agent.safe_cleanup_mcp_tools = AsyncMock()
|
|
|
|
|
|
|
|
|
|
with patch.object(channel_chat.ChatAgent, "create_async", AsyncMock(return_value=fake_agent)) as create_async, \
|
|
|
|
|
patch.object(channel_chat, "deliver_to_customer", AsyncMock()):
|
|
|
|
|
await process_channel_message(routed_account.id, make_inbound())
|
|
|
|
|
|
|
|
|
|
assert create_async.await_args.kwargs["extra_context"] is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def whatsapp_account(db, test_organization, test_agent):
|
|
|
|
|
account = ChannelAccountRepository(db).create_account(
|
|
|
|
|
organization_id=test_organization.id,
|
|
|
|
|
channel_type="whatsapp",
|
|
|
|
|
external_account_id="PN123",
|
|
|
|
|
credentials={"access_token": "tok"},
|
|
|
|
|
display_name="+1 555",
|
|
|
|
|
)
|
|
|
|
|
AgentChannelConfigRepository(db).set_agent(account.id, test_agent.id)
|
|
|
|
|
return account
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _wa_tap():
|
|
|
|
|
return InboundMessage(
|
|
|
|
|
external_account_id="PN123", external_conversation_id="447",
|
|
|
|
|
external_user_id="447", external_message_id="wamid.IN", text="Tomorrow",
|
|
|
|
|
profile={"phone": "447"},
|
|
|
|
|
interactive=InteractiveReply(type=InteractiveReplyType.BUTTON_REPLY,
|
|
|
|
|
id="qr_1", title="Tomorrow"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestChoices:
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_whatsapp_agent_is_told_it_can_offer_choices(
|
|
|
|
|
self, db, whatsapp_account, use_test_db, test_ai_config, mock_sio, test_agent):
|
|
|
|
|
test_agent.channel_quick_replies = True
|
|
|
|
|
db.commit()
|
|
|
|
|
response = SimpleNamespace(message="When?", transfer_to_human=False, end_chat=False,
|
|
|
|
|
request_lead_capture=False, quick_replies=["Today", "Tomorrow"])
|
|
|
|
|
fake_agent = MagicMock()
|
|
|
|
|
fake_agent.get_response = AsyncMock(return_value=response)
|
|
|
|
|
fake_agent.safe_cleanup_mcp_tools = AsyncMock()
|
|
|
|
|
fake_agent.last_bot_message_id = 7
|
|
|
|
|
|
|
|
|
|
with patch.object(channel_chat.ChatAgent, "create_async", AsyncMock(return_value=fake_agent)) as create_async, \
|
|
|
|
|
patch.object(channel_chat, "deliver_to_customer",
|
|
|
|
|
AsyncMock(return_value=DeliveryResult(ok=True))) as deliver:
|
|
|
|
|
await process_channel_message(whatsapp_account.id, _wa_tap())
|
|
|
|
|
|
|
|
|
|
assert create_async.await_args.kwargs["choice_limits"] is WHATSAPP_LIMITS
|
|
|
|
|
assert deliver.await_args.kwargs["message_id"] == 7
|
|
|
|
|
sent = deliver.await_args.kwargs["interactive"]
|
|
|
|
|
assert [b.title for b in sent.buttons] == ["Today", "Tomorrow"]
|
|
|
|
|
user_attributes = fake_agent.get_response.await_args.kwargs["user_attributes"]
|
|
|
|
|
assert user_attributes["interactive_reply"] == {"type": "button_reply", "id": "qr_1"}
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_buttons_are_off_until_the_agent_turns_them_on(
|
|
|
|
|
self, db, whatsapp_account, use_test_db, test_ai_config, mock_sio):
|
|
|
|
|
response = SimpleNamespace(message="When?", transfer_to_human=False, end_chat=False,
|
|
|
|
|
request_lead_capture=False, quick_replies=["Today", "Tomorrow"])
|
|
|
|
|
fake_agent = MagicMock()
|
|
|
|
|
fake_agent.get_response = AsyncMock(return_value=response)
|
|
|
|
|
fake_agent.safe_cleanup_mcp_tools = AsyncMock()
|
|
|
|
|
fake_agent.last_bot_message_id = 7
|
|
|
|
|
|
|
|
|
|
with patch.object(channel_chat.ChatAgent, "create_async", AsyncMock(return_value=fake_agent)) as create_async, \
|
|
|
|
|
patch.object(channel_chat, "deliver_to_customer",
|
|
|
|
|
AsyncMock(return_value=DeliveryResult(ok=True))) as deliver:
|
|
|
|
|
await process_channel_message(whatsapp_account.id, _wa_tap())
|
|
|
|
|
|
|
|
|
|
assert create_async.await_args.kwargs["choice_limits"] is None
|
|
|
|
|
assert deliver.await_args.kwargs["interactive"] is None
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_telegram_agents_get_buttons_when_turned_on(
|
|
|
|
|
self, db, routed_account, use_test_db, test_ai_config, mock_sio, test_agent):
|
|
|
|
|
test_agent.channel_quick_replies = True
|
|
|
|
|
db.commit()
|
|
|
|
|
fake_agent = _agent_persisting_reply(db)
|
|
|
|
|
|
|
|
|
|
with patch.object(channel_chat.ChatAgent, "create_async", AsyncMock(return_value=fake_agent)) as create_async, \
|
|
|
|
|
patch.object(channel_chat, "deliver_to_customer", AsyncMock(return_value=DeliveryResult(ok=True))):
|
|
|
|
|
await process_channel_message(routed_account.id, make_inbound())
|
|
|
|
|
|
|
|
|
|
assert create_async.await_args.kwargs["choice_limits"] is TELEGRAM_LIMITS
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_a_channel_without_buttons_offers_none_even_when_turned_on(
|
|
|
|
|
self, db, routed_account, use_test_db, test_ai_config, mock_sio, test_agent, monkeypatch):
|
|
|
|
|
test_agent.channel_quick_replies = True
|
|
|
|
|
db.commit()
|
|
|
|
|
monkeypatch.setattr(type(channel_chat.get_adapter("telegram")), "interactive_limits", None)
|
|
|
|
|
fake_agent = _agent_persisting_reply(db)
|
|
|
|
|
|
|
|
|
|
with patch.object(channel_chat.ChatAgent, "create_async", AsyncMock(return_value=fake_agent)) as create_async, \
|
|
|
|
|
patch.object(channel_chat, "deliver_to_customer", AsyncMock(return_value=DeliveryResult(ok=True))):
|
|
|
|
|
await process_channel_message(routed_account.id, make_inbound())
|
|
|
|
|
|
|
|
|
|
assert create_async.await_args.kwargs["choice_limits"] is None
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_human_path_stores_and_relays_the_tap(self, db, whatsapp_account, use_test_db, mock_sio):
|
|
|
|
|
with patch.object(channel_chat.AgentRepository, "get_agent",
|
|
|
|
|
return_value=SimpleNamespace(ai_replies_enabled=False, channel_quick_replies=False)), \
|
|
|
|
|
patch.object(channel_chat, "notify_customer", AsyncMock()):
|
|
|
|
|
await process_channel_message(whatsapp_account.id, _wa_tap())
|
|
|
|
|
|
|
|
|
|
stored = db.query(ChatHistory).filter_by(message_type="user").one()
|
|
|
|
|
assert stored.attributes["interactive_reply"] == {"type": "button_reply", "id": "qr_1"}
|
|
|
|
|
relayed = mock_sio.emit.await_args.args[1]
|
|
|
|
|
assert relayed["message_id"] == stored.id
|
|
|
|
|
assert relayed["attributes"]["interactive_reply"]["id"] == "qr_1"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_concurrent_turns_each_deliver_against_their_own_reply(
|
|
|
|
|
db, routed_account, use_test_db, test_ai_config, mock_sio):
|
|
|
|
|
"""Two messages processed at once on one conversation: each reply's
|
|
|
|
|
delivery must point at that reply's row, never the other's (the old
|
|
|
|
|
'latest bot message' read-back could swap them)."""
|
|
|
|
|
import asyncio
|
|
|
|
|
|
|
|
|
|
def _agent():
|
|
|
|
|
agent = MagicMock()
|
|
|
|
|
agent.safe_cleanup_mcp_tools = AsyncMock()
|
|
|
|
|
|
|
|
|
|
async def _respond(**kwargs):
|
|
|
|
|
await asyncio.sleep(0) # let the other turn interleave
|
|
|
|
|
text = f"re: {kwargs['message']}"
|
|
|
|
|
agent.last_bot_message_id = ChatRepository(db).create_message({
|
|
|
|
|
"message": text, "message_type": "bot",
|
|
|
|
|
"session_id": kwargs["session_id"], "organization_id": kwargs["org_id"],
|
|
|
|
|
"agent_id": kwargs["agent_id"], "customer_id": kwargs["customer_id"],
|
|
|
|
|
}).id
|
|
|
|
|
await asyncio.sleep(0)
|
|
|
|
|
return SimpleNamespace(message=text, transfer_to_human=False,
|
|
|
|
|
end_chat=False, request_lead_capture=False)
|
|
|
|
|
agent.get_response = AsyncMock(side_effect=_respond)
|
|
|
|
|
return agent
|
|
|
|
|
|
|
|
|
|
deliveries = []
|
|
|
|
|
|
|
|
|
|
async def _deliver(db_, session, payload, message_id=None, interactive=None, **_):
|
|
|
|
|
deliveries.append((payload["message"], message_id))
|
|
|
|
|
return DeliveryResult(ok=True)
|
|
|
|
|
|
|
|
|
|
first, second = make_inbound("first"), make_inbound("second")
|
|
|
|
|
second.external_message_id = "43"
|
|
|
|
|
with patch.object(channel_chat.ChatAgent, "create_async", AsyncMock(side_effect=lambda **_: _agent())), \
|
|
|
|
|
patch.object(channel_chat, "deliver_to_customer", side_effect=_deliver):
|
|
|
|
|
await asyncio.gather(process_channel_message(routed_account.id, first),
|
|
|
|
|
process_channel_message(routed_account.id, second))
|
|
|
|
|
|
|
|
|
|
assert len(deliveries) == 2
|
|
|
|
|
for text, message_id in deliveries:
|
|
|
|
|
assert db.get(ChatHistory, message_id).message == text
|