feat(chat): Socket.IO 双端通道、消息持久化与幂等
访客与客服分别鉴权接入,消息带客户端 ID 去重,重发只产生一条业务记录。
This commit is contained in:
1 parent
0cc6299d1c
commit
ef9fee1585
23 files changed
+7057
No files matched your search
@@ -0,0 +1,476 @@
|
||||
"""
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
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 uuid import uuid4
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.channels.app_hooks import EventKind, register_event_listener
|
||||
from app.channels.constants import MessageSource
|
||||
from app.channels.base import SendResult, WindowStatus
|
||||
from app.channels.interactive import from_options
|
||||
from app.channels.interactive_limits import INSTAGRAM_LIMITS, WHATSAPP_LIMITS
|
||||
from app.channels.sent_choices import SENT_CHOICES_ATTRIBUTE, decode_sent_choices
|
||||
from app.repositories.channels import ChannelAccountRepository, ChannelConversationRepository
|
||||
from app.repositories.channels.accounts import needs_reconnect
|
||||
from app.repositories.chat import ChatRepository
|
||||
from app.repositories.session_to_agent import SessionToAgentRepository
|
||||
from app.services import message_delivery
|
||||
from app.services.business_message import LAST_BUSINESS_MESSAGE, remember_business_message
|
||||
from app.services.message_delivery import deliver_to_customer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_sio(monkeypatch):
|
||||
sio = MagicMock()
|
||||
sio.emit = AsyncMock()
|
||||
monkeypatch.setattr(message_delivery, "sio", sio)
|
||||
return sio
|
||||
|
||||
|
||||
def make_session(db, test_agent, test_customer, test_organization, channel):
|
||||
return SessionToAgentRepository(db).create_session(
|
||||
session_id=uuid4(),
|
||||
agent_id=test_agent.id,
|
||||
customer_id=test_customer.id,
|
||||
organization_id=test_organization.id,
|
||||
channel=channel,
|
||||
)
|
||||
|
||||
|
||||
def make_channel_session(db, test_agent, test_customer, test_organization):
|
||||
session = make_session(db, test_agent, test_customer, test_organization, 'telegram')
|
||||
account = ChannelAccountRepository(db).create_account(
|
||||
organization_id=test_organization.id,
|
||||
channel_type='telegram',
|
||||
external_account_id='bot123',
|
||||
credentials={'bot_token': '123:abc'},
|
||||
display_name='@testbot',
|
||||
)
|
||||
ChannelConversationRepository(db).create(
|
||||
channel_account_id=account.id,
|
||||
channel_type='telegram',
|
||||
external_conversation_id='222',
|
||||
external_user_id='111',
|
||||
session_id=session.session_id,
|
||||
organization_id=test_organization.id,
|
||||
agent_id=test_agent.id,
|
||||
customer_id=test_customer.id,
|
||||
)
|
||||
return session, account
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_web_session_uses_socket_emit_only(db, test_agent, test_customer, test_organization, mock_sio):
|
||||
session = make_session(db, test_agent, test_customer, test_organization, 'web')
|
||||
result = await deliver_to_customer(db, session, {'message': 'hi'}, source=MessageSource.HUMAN)
|
||||
assert result.ok is True
|
||||
mock_sio.emit.assert_awaited_once_with(
|
||||
'chat_response', {'message': 'hi'}, room=str(session.session_id), namespace='/widget')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_channel_session_routes_to_adapter(db, test_agent, test_customer, test_organization, mock_sio, monkeypatch):
|
||||
session, _ = make_channel_session(db, test_agent, test_customer, test_organization)
|
||||
adapter = MagicMock()
|
||||
adapter.check_delivery_window.return_value = WindowStatus.OK
|
||||
adapter.format_outbound.side_effect = lambda t: t
|
||||
adapter.send_text = AsyncMock(return_value=SendResult(ok=True))
|
||||
monkeypatch.setattr(message_delivery, "get_adapter", lambda c: adapter)
|
||||
|
||||
result = await deliver_to_customer(db, session, {'message': 'hello'}, source=MessageSource.HUMAN)
|
||||
|
||||
assert result.ok is True
|
||||
adapter.send_text.assert_awaited_once()
|
||||
assert adapter.send_text.await_args.args[2] == 'hello'
|
||||
mock_sio.emit.assert_awaited_once() # dashboard viewers still get the event
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_channel_session_window_expired(db, test_agent, test_customer, test_organization, mock_sio, monkeypatch):
|
||||
session, _ = make_channel_session(db, test_agent, test_customer, test_organization)
|
||||
adapter = MagicMock()
|
||||
adapter.check_delivery_window.return_value = WindowStatus.TEMPLATE_REQUIRED
|
||||
adapter.send_text = AsyncMock()
|
||||
monkeypatch.setattr(message_delivery, "get_adapter", lambda c: adapter)
|
||||
|
||||
result = await deliver_to_customer(db, session, {'message': 'hello'}, source=MessageSource.HUMAN)
|
||||
|
||||
assert result.ok is False
|
||||
assert result.reason == 'window_expired'
|
||||
assert result.can_template is True
|
||||
adapter.send_text.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_channel_session_without_conversation_fails(db, test_agent, test_customer, test_organization, mock_sio, monkeypatch):
|
||||
session = make_session(db, test_agent, test_customer, test_organization, 'telegram')
|
||||
monkeypatch.setattr(message_delivery, "get_adapter", lambda c: MagicMock())
|
||||
|
||||
result = await deliver_to_customer(db, session, {'message': 'hello'}, source=MessageSource.HUMAN)
|
||||
|
||||
assert result.ok is False
|
||||
assert result.reason == 'no_channel_conversation'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_failure_reported(db, test_agent, test_customer, test_organization, mock_sio, monkeypatch):
|
||||
session, _ = make_channel_session(db, test_agent, test_customer, test_organization)
|
||||
adapter = MagicMock()
|
||||
adapter.check_delivery_window.return_value = WindowStatus.OK
|
||||
adapter.format_outbound.side_effect = lambda t: t
|
||||
adapter.send_text = AsyncMock(return_value=SendResult(ok=False, error='chat not found'))
|
||||
monkeypatch.setattr(message_delivery, "get_adapter", lambda c: adapter)
|
||||
|
||||
result = await deliver_to_customer(db, session, {'message': 'hello'}, source=MessageSource.HUMAN)
|
||||
|
||||
assert result.ok is False
|
||||
assert result.reason == 'chat not found'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_refused_token_marks_the_account_for_reconnection(db, test_agent, test_customer, test_organization, mock_sio, monkeypatch):
|
||||
session, account = make_channel_session(db, test_agent, test_customer, test_organization)
|
||||
adapter = _channel_adapter(False)
|
||||
adapter.send_text = AsyncMock(return_value=SendResult(
|
||||
ok=False, error='Authentication Error', auth_failed=True))
|
||||
monkeypatch.setattr(message_delivery, "get_adapter", lambda c: adapter)
|
||||
|
||||
result = await deliver_to_customer(db, session, {'message': 'hello'}, source=MessageSource.AI)
|
||||
assert result.ok is False
|
||||
db.refresh(account)
|
||||
assert needs_reconnect(account) is True
|
||||
|
||||
# Once a send goes through again, the account is healthy
|
||||
adapter.send_text = AsyncMock(return_value=SendResult(ok=True, external_message_id='wamid.OK'))
|
||||
result = await deliver_to_customer(db, session, {'message': 'hello'}, source=MessageSource.AI)
|
||||
assert result.ok is True
|
||||
db.refresh(account)
|
||||
assert needs_reconnect(account) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_interactive_auth_failure_still_marks_the_account(db, test_agent, test_customer, test_organization, mock_sio, monkeypatch):
|
||||
"""The interactive attempt and the text fallback both fail; the result
|
||||
that decides is the fallback's."""
|
||||
session, account = make_channel_session(db, test_agent, test_customer, test_organization)
|
||||
refused = SendResult(ok=False, error='Authentication Error', auth_failed=True)
|
||||
adapter = _channel_adapter(True)
|
||||
adapter.send_interactive = AsyncMock(return_value=refused)
|
||||
adapter.send_text = AsyncMock(return_value=refused)
|
||||
monkeypatch.setattr(message_delivery, "get_adapter", lambda c: adapter)
|
||||
|
||||
await deliver_to_customer(db, session, {'message': 'Pick one'},
|
||||
interactive=from_options('Pick one', ['A', 'B']),
|
||||
source=MessageSource.AI)
|
||||
|
||||
db.refresh(account)
|
||||
assert needs_reconnect(account) is True
|
||||
|
||||
|
||||
def _channel_adapter(supports_interactive: bool):
|
||||
adapter = MagicMock()
|
||||
adapter.channel_type = 'whatsapp'
|
||||
adapter.supports_interactive = supports_interactive
|
||||
adapter.interactive_limits = WHATSAPP_LIMITS if supports_interactive else None
|
||||
adapter.reports_delivery_status = True
|
||||
adapter.max_text_length = 4096
|
||||
adapter.check_delivery_window.return_value = WindowStatus.OK
|
||||
adapter.format_outbound.side_effect = lambda t: t
|
||||
adapter.send_text = AsyncMock(return_value=SendResult(ok=True, external_message_id='wamid.TXT'))
|
||||
adapter.send_interactive = AsyncMock(return_value=SendResult(ok=True, external_message_id='wamid.INT'))
|
||||
return adapter
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_quick_replies_go_out_as_buttons(db, test_agent, test_customer, test_organization, mock_sio, monkeypatch):
|
||||
session, _ = make_channel_session(db, test_agent, test_customer, test_organization)
|
||||
adapter = _channel_adapter(supports_interactive=True)
|
||||
monkeypatch.setattr(message_delivery, "get_adapter", lambda c: adapter)
|
||||
|
||||
result = await deliver_to_customer(db, session, {'message': 'Confirm?'}, interactive=from_options('Confirm?', ['Yes', 'No']), source=MessageSource.HUMAN)
|
||||
|
||||
assert result.ok
|
||||
sent = adapter.send_interactive.await_args.args[2]
|
||||
assert [b.title for b in sent.buttons] == ['Yes', 'No']
|
||||
adapter.send_text.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_quick_replies_fall_back_to_text_without_support(db, test_agent, test_customer, test_organization, mock_sio, monkeypatch):
|
||||
session, _ = make_channel_session(db, test_agent, test_customer, test_organization)
|
||||
adapter = _channel_adapter(supports_interactive=False)
|
||||
monkeypatch.setattr(message_delivery, "get_adapter", lambda c: adapter)
|
||||
|
||||
await deliver_to_customer(db, session, {'message': 'Confirm?'}, interactive=from_options('Confirm?', ['Yes', 'No']), source=MessageSource.HUMAN)
|
||||
|
||||
adapter.send_interactive.assert_not_awaited()
|
||||
assert adapter.send_text.await_args.args[2] == 'Confirm?\n\n1. Yes\n2. No'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejected_interactive_send_falls_back_to_text(db, test_agent, test_customer, test_organization, mock_sio, monkeypatch):
|
||||
session, _ = make_channel_session(db, test_agent, test_customer, test_organization)
|
||||
adapter = _channel_adapter(supports_interactive=True)
|
||||
adapter.send_interactive = AsyncMock(return_value=SendResult(ok=False, error='bad param'))
|
||||
monkeypatch.setattr(message_delivery, "get_adapter", lambda c: adapter)
|
||||
|
||||
result = await deliver_to_customer(db, session, {'message': 'Confirm?'}, interactive=from_options('Confirm?', ['Yes']), source=MessageSource.HUMAN)
|
||||
|
||||
assert result.ok
|
||||
assert adapter.send_text.await_args.args[2] == 'Confirm?\n\n1. Yes'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sent_message_id_lands_on_the_given_row(db, test_agent, test_customer, test_organization, mock_sio, monkeypatch):
|
||||
session, _ = make_channel_session(db, test_agent, test_customer, test_organization)
|
||||
monkeypatch.setattr(message_delivery, "get_adapter", lambda c: _channel_adapter(False))
|
||||
chat_repo = ChatRepository(db)
|
||||
common = {'session_id': str(session.session_id), 'organization_id': str(test_organization.id),
|
||||
'message_type': 'bot'}
|
||||
target = chat_repo.create_message({**common, 'message': 'first'})
|
||||
later = chat_repo.create_message({**common, 'message': 'second'})
|
||||
|
||||
await deliver_to_customer(db, session, {'message': 'first'}, message_id=target.id, source=MessageSource.HUMAN)
|
||||
|
||||
db.refresh(target)
|
||||
db.refresh(later)
|
||||
assert target.external_message_id == 'wamid.TXT'
|
||||
assert later.external_message_id is None
|
||||
|
||||
|
||||
def _bot_row(db, session, test_organization, text='Confirm?'):
|
||||
return ChatRepository(db).create_message({
|
||||
'session_id': str(session.session_id), 'organization_id': str(test_organization.id),
|
||||
'message_type': 'bot', 'message': text})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_what_was_sent_is_recorded_on_the_row(db, test_agent, test_customer, test_organization, mock_sio, monkeypatch):
|
||||
session, _ = make_channel_session(db, test_agent, test_customer, test_organization)
|
||||
monkeypatch.setattr(message_delivery, "get_adapter", lambda c: _channel_adapter(True))
|
||||
row = _bot_row(db, session, test_organization)
|
||||
|
||||
await deliver_to_customer(db, session, {'message': 'Confirm?'}, message_id=row.id,
|
||||
interactive=from_options('Confirm?', ['Yes', 'No']), source=MessageSource.HUMAN)
|
||||
|
||||
db.refresh(row)
|
||||
record = decode_sent_choices(row.attributes[SENT_CHOICES_ATTRIBUTE])
|
||||
assert record['as_text'] is False
|
||||
assert [c['text'] for c in record['choices']] == ['Yes', 'No']
|
||||
assert row.external_message_id == 'wamid.INT'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_text_fallback_is_recorded_as_such(db, test_agent, test_customer, test_organization, mock_sio, monkeypatch):
|
||||
session, _ = make_channel_session(db, test_agent, test_customer, test_organization)
|
||||
monkeypatch.setattr(message_delivery, "get_adapter", lambda c: _channel_adapter(False))
|
||||
row = _bot_row(db, session, test_organization)
|
||||
|
||||
await deliver_to_customer(db, session, {'message': 'Confirm?'}, message_id=row.id,
|
||||
interactive=from_options('Confirm?', ['Yes']), source=MessageSource.HUMAN)
|
||||
|
||||
db.refresh(row)
|
||||
assert decode_sent_choices(row.attributes[SENT_CHOICES_ATTRIBUTE])['as_text'] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_long_reply_goes_as_text_then_the_choices(db, test_agent, test_customer, test_organization, mock_sio, monkeypatch):
|
||||
session, _ = make_channel_session(db, test_agent, test_customer, test_organization)
|
||||
adapter = _channel_adapter(True)
|
||||
monkeypatch.setattr(message_delivery, "get_adapter", lambda c: adapter)
|
||||
row = _bot_row(db, session, test_organization)
|
||||
long_reply = 'x' * 1100
|
||||
|
||||
await deliver_to_customer(db, session, {'message': long_reply}, message_id=row.id,
|
||||
interactive=from_options(long_reply, ['Yes', 'No']), source=MessageSource.HUMAN)
|
||||
|
||||
assert adapter.send_text.await_args.args[2] == long_reply
|
||||
choices = adapter.send_interactive.await_args.args[2]
|
||||
assert choices.body == 'Choose an option:' and [b.title for b in choices.buttons] == ['Yes', 'No']
|
||||
db.refresh(row)
|
||||
assert row.external_message_id == 'wamid.INT' # a tap replies to the choices
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_both_messages_of_a_split_reply_are_announced(db, test_agent, test_customer, test_organization,
|
||||
mock_sio, monkeypatch):
|
||||
session, _ = make_channel_session(db, test_agent, test_customer, test_organization)
|
||||
monkeypatch.setattr(message_delivery, "get_adapter", lambda c: _channel_adapter(True))
|
||||
seen = []
|
||||
|
||||
async def listen(event):
|
||||
if event.kind == EventKind.OUTBOUND:
|
||||
seen.append((event.outbound.external_message_id, event.outbound.interactive is not None))
|
||||
register_event_listener(listen)
|
||||
long_reply = 'x' * 1100
|
||||
|
||||
await deliver_to_customer(db, session, {'message': long_reply}, message_id=_bot_row(db, session, test_organization).id,
|
||||
interactive=from_options(long_reply, ['Yes', 'No']), source=MessageSource.AI)
|
||||
|
||||
assert seen == [('wamid.TXT', False), ('wamid.INT', True)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_channels_without_receipts_record_no_message_id(db, test_agent, test_customer, test_organization, mock_sio, monkeypatch):
|
||||
session, _ = make_channel_session(db, test_agent, test_customer, test_organization)
|
||||
adapter = _channel_adapter(False)
|
||||
adapter.reports_delivery_status = False
|
||||
monkeypatch.setattr(message_delivery, "get_adapter", lambda c: adapter)
|
||||
row = _bot_row(db, session, test_organization)
|
||||
|
||||
await deliver_to_customer(db, session, {'message': 'hi'}, message_id=row.id, source=MessageSource.HUMAN)
|
||||
|
||||
db.refresh(row)
|
||||
assert row.external_message_id is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_choices_on_a_channel_without_receipts_record_the_offers_id(
|
||||
db, test_agent, test_customer, test_organization, mock_sio, monkeypatch):
|
||||
"""A tap is matched back to the message that offered it (Messenger, Telegram)."""
|
||||
session, _ = make_channel_session(db, test_agent, test_customer, test_organization)
|
||||
adapter = _channel_adapter(True)
|
||||
adapter.reports_delivery_status = False
|
||||
monkeypatch.setattr(message_delivery, "get_adapter", lambda c: adapter)
|
||||
row = _bot_row(db, session, test_organization)
|
||||
|
||||
await deliver_to_customer(db, session, {'message': 'Coming?'}, message_id=row.id,
|
||||
interactive=from_options('Coming?', ['Yes', 'No']), source=MessageSource.AI)
|
||||
|
||||
db.refresh(row)
|
||||
assert row.external_message_id == 'wamid.INT'
|
||||
assert decode_sent_choices(row.attributes[SENT_CHOICES_ATTRIBUTE])["numbered"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_choices_written_into_the_text_split_a_long_reply_and_are_numbered(
|
||||
db, test_agent, test_customer, test_organization, mock_sio, monkeypatch):
|
||||
"""Instagram: the reply plus its numbered choices must fit, or the reply
|
||||
goes on its own first — never cut, which would lose the question."""
|
||||
session, _ = make_channel_session(db, test_agent, test_customer, test_organization)
|
||||
adapter = _channel_adapter(True)
|
||||
adapter.interactive_limits = INSTAGRAM_LIMITS
|
||||
adapter.reports_delivery_status = False
|
||||
monkeypatch.setattr(message_delivery, "get_adapter", lambda c: adapter)
|
||||
row = _bot_row(db, session, test_organization)
|
||||
reply = 'x' * 960 + ' Which one do you want?' # fits alone, not with the choices
|
||||
|
||||
await deliver_to_customer(db, session, {'message': reply}, message_id=row.id,
|
||||
interactive=from_options(reply, ['First', 'Second', 'Third'], INSTAGRAM_LIMITS),
|
||||
source=MessageSource.AI)
|
||||
|
||||
assert adapter.send_text.await_args.args[2] == reply
|
||||
assert adapter.send_interactive.await_args.args[2].body == 'Choose an option:'
|
||||
db.refresh(row)
|
||||
assert decode_sent_choices(row.attributes[SENT_CHOICES_ATTRIBUTE])["numbered"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bookkeeping_failure_never_fails_a_delivered_message(db, test_agent, test_customer, test_organization, mock_sio, monkeypatch):
|
||||
session, _ = make_channel_session(db, test_agent, test_customer, test_organization)
|
||||
monkeypatch.setattr(message_delivery, "get_adapter", lambda c: _channel_adapter(False))
|
||||
monkeypatch.setattr(message_delivery, "record_sent", AsyncMock(side_effect=RuntimeError("socket down")))
|
||||
row = _bot_row(db, session, test_organization)
|
||||
|
||||
result = await deliver_to_customer(db, session, {'message': 'hi'}, message_id=row.id, source=MessageSource.HUMAN)
|
||||
|
||||
assert result.ok is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_long_reply_counts_as_delivered_when_only_its_choices_fail(
|
||||
db, test_agent, test_customer, test_organization, mock_sio, monkeypatch):
|
||||
session, _ = make_channel_session(db, test_agent, test_customer, test_organization)
|
||||
adapter = _channel_adapter(True)
|
||||
adapter.send_text = AsyncMock(side_effect=[SendResult(ok=True, external_message_id='wamid.BODY'),
|
||||
SendResult(ok=False, error='rate limited')])
|
||||
adapter.send_interactive = AsyncMock(return_value=SendResult(ok=False, error='bad param'))
|
||||
monkeypatch.setattr(message_delivery, "get_adapter", lambda c: adapter)
|
||||
row = _bot_row(db, session, test_organization)
|
||||
long_reply = 'x' * 1100
|
||||
|
||||
result = await deliver_to_customer(db, session, {'message': long_reply}, message_id=row.id,
|
||||
interactive=from_options(long_reply, ['Yes', 'No']), source=MessageSource.HUMAN)
|
||||
|
||||
assert result.ok is True
|
||||
db.refresh(row)
|
||||
assert row.external_message_id == 'wamid.BODY'
|
||||
assert SENT_CHOICES_ATTRIBUTE not in (row.attributes or {})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def outbound_events():
|
||||
seen = []
|
||||
|
||||
async def listen(event):
|
||||
seen.append(event)
|
||||
register_event_listener(listen)
|
||||
return seen
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_channel_send_is_announced_with_who_wrote_it(
|
||||
db, test_agent, test_customer, test_organization, mock_sio, monkeypatch, outbound_events):
|
||||
session, account = make_channel_session(db, test_agent, test_customer, test_organization)
|
||||
monkeypatch.setattr(message_delivery, "get_adapter", lambda c: _channel_adapter(False))
|
||||
row = _bot_row(db, session, test_organization, text='hi')
|
||||
|
||||
result = await deliver_to_customer(db, session, {'message': 'hi'}, message_id=row.id,
|
||||
source=MessageSource.AI)
|
||||
|
||||
assert result.external_message_id == 'wamid.TXT'
|
||||
(event,) = outbound_events
|
||||
assert event.kind == EventKind.OUTBOUND
|
||||
assert event.channel_account_id == account.id
|
||||
assert (event.outbound.source, event.outbound.message_id, event.outbound.text) == \
|
||||
(MessageSource.AI, row.id, 'hi')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_and_web_sends_are_not_announced(
|
||||
db, test_agent, test_customer, test_organization, mock_sio, monkeypatch, outbound_events):
|
||||
web = make_session(db, test_agent, test_customer, test_organization, 'web')
|
||||
await deliver_to_customer(db, web, {'message': 'hi'}, source=MessageSource.AI)
|
||||
|
||||
session, _ = make_channel_session(db, test_agent, test_customer, test_organization)
|
||||
adapter = _channel_adapter(False)
|
||||
adapter.send_text = AsyncMock(return_value=SendResult(ok=False, error='blocked'))
|
||||
monkeypatch.setattr(message_delivery, "get_adapter", lambda c: adapter)
|
||||
await deliver_to_customer(db, session, {'message': 'hi'}, source=MessageSource.AI)
|
||||
|
||||
assert outbound_events == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("source,kept", [
|
||||
(MessageSource.HUMAN, False), # a person took over from there
|
||||
(MessageSource.AI, True), # the AI's own turns don't make it stale
|
||||
(MessageSource.SYSTEM, True), # e.g. a quota notice before the AI could answer
|
||||
(MessageSource.APP, True),
|
||||
])
|
||||
async def test_only_a_person_replying_ends_the_business_message_context(
|
||||
db, test_agent, test_customer, test_organization, mock_sio, monkeypatch, source, kept):
|
||||
session, _ = make_channel_session(db, test_agent, test_customer, test_organization)
|
||||
monkeypatch.setattr(message_delivery, "get_adapter", lambda c: _channel_adapter(False))
|
||||
conversation = ChannelConversationRepository(db).get_by_session(session.session_id)
|
||||
remember_business_message(db, conversation, "Coming at 5?", "wamid.T")
|
||||
|
||||
await deliver_to_customer(db, session, {'message': 'ok'}, source=source)
|
||||
|
||||
db.refresh(conversation)
|
||||
assert (LAST_BUSINESS_MESSAGE in (conversation.extra or {})) is kept
|
||||
Reference in new issue
Block a user