feat(modules): 帮助中心、工单、工作流与多渠道接入
补齐 FAQ、帮助中心、工单、可视化工作流及 Telegram/WhatsApp/Slack 等渠道模块。
This commit is contained in:
1 parent
69a71aabbb
commit
e0baca15a9
225 files changed
+59337
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.channels import app_hooks
|
||||
from app.channels.app_hooks import (
|
||||
ChannelEvent, EventKind, MessageSource, OutboundRecord, emit_channel_event,
|
||||
inbound_event_id, outbound_event, register_event_listener, register_inbound_handler,
|
||||
)
|
||||
|
||||
|
||||
def _event() -> ChannelEvent:
|
||||
return ChannelEvent(kind=EventKind.INBOUND, event_id="a:1", organization_id=uuid4(),
|
||||
channel_account_id=uuid4(), occurred_at=datetime.now(timezone.utc))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_extension_means_every_hook_is_a_no_op():
|
||||
assert app_hooks.inbound_handler() is None
|
||||
await emit_channel_event(_event()) # nothing listening, nothing raised
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_failing_listener_never_stops_the_others():
|
||||
calls = []
|
||||
|
||||
async def broken(event):
|
||||
raise RuntimeError("extension bug")
|
||||
|
||||
async def working(event):
|
||||
calls.append(event.event_id)
|
||||
|
||||
register_event_listener(broken)
|
||||
register_event_listener(working)
|
||||
register_event_listener(working) # registering twice is a no-op
|
||||
|
||||
await emit_channel_event(_event())
|
||||
|
||||
assert calls == ["a:1"]
|
||||
|
||||
|
||||
def test_load_extensions_without_one_installed_is_harmless(monkeypatch):
|
||||
monkeypatch.setattr(app_hooks, "EXTENSION_HOOKS_MODULE", "app.no_such_extension_module")
|
||||
app_hooks.load_extensions()
|
||||
app_hooks.load_extensions() # idempotent
|
||||
assert app_hooks.inbound_handler() is None
|
||||
|
||||
|
||||
def test_load_extensions_imports_the_extension_once(monkeypatch):
|
||||
imported = []
|
||||
monkeypatch.setattr(app_hooks.importlib, "import_module", lambda name: imported.append(name))
|
||||
app_hooks.load_extensions()
|
||||
app_hooks.load_extensions()
|
||||
assert imported == [app_hooks.EXTENSION_HOOKS_MODULE]
|
||||
|
||||
|
||||
def test_reset_forgets_registrations():
|
||||
register_inbound_handler(AsyncMock(return_value=True))
|
||||
register_event_listener(AsyncMock())
|
||||
app_hooks.reset()
|
||||
assert app_hooks.inbound_handler() is None
|
||||
|
||||
|
||||
def test_event_ids_are_stable_for_the_same_message():
|
||||
account = uuid4()
|
||||
assert inbound_event_id(account, "wamid.1") == inbound_event_id(account, "wamid.1")
|
||||
record = OutboundRecord(message_id=7, external_message_id="wamid.OUT",
|
||||
source=MessageSource.AI, text="hi")
|
||||
first = outbound_event(organization_id=uuid4(), channel_account_id=account,
|
||||
session_id="s", customer_id=None, record=record)
|
||||
second = outbound_event(organization_id=uuid4(), channel_account_id=account,
|
||||
session_id="s", customer_id=None, record=record)
|
||||
assert first.event_id == second.event_id == f"{account}:out:wamid.OUT"
|
||||
|
||||
|
||||
def test_outbound_without_a_platform_id_is_keyed_by_its_row():
|
||||
account = uuid4()
|
||||
record = OutboundRecord(message_id=7, external_message_id=None,
|
||||
source=MessageSource.SYSTEM, text="hi")
|
||||
event = outbound_event(organization_id=uuid4(), channel_account_id=account,
|
||||
session_id=None, customer_id=None, record=record)
|
||||
assert event.event_id == f"{account}:out:row7"
|
||||
|
||||
|
||||
def test_a_broken_extension_is_reported_not_silently_skipped(monkeypatch, caplog):
|
||||
def import_module(name):
|
||||
raise ModuleNotFoundError("No module named 'some_dependency'", name="some_dependency")
|
||||
monkeypatch.setattr(app_hooks.importlib, "import_module", import_module)
|
||||
|
||||
with caplog.at_level("ERROR"):
|
||||
app_hooks.load_extensions()
|
||||
|
||||
assert "Channel extension failed to load" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_slow_listener_is_cut_off(monkeypatch):
|
||||
monkeypatch.setattr(app_hooks, "LISTENER_TIMEOUT_SECONDS", 0.01)
|
||||
calls = []
|
||||
|
||||
async def slow(event):
|
||||
await asyncio.sleep(5)
|
||||
|
||||
async def fast(event):
|
||||
calls.append(event.event_id)
|
||||
|
||||
register_event_listener(slow)
|
||||
register_event_listener(fast)
|
||||
|
||||
await emit_channel_event(_event())
|
||||
|
||||
assert calls == ["a:1"]
|
||||
@@ -0,0 +1,257 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
The AI's choices as tappable buttons beyond WhatsApp: Messenger and
|
||||
Instagram quick replies, Telegram inline keyboards — sending them within
|
||||
each channel's limits, and reading the taps back.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.channels import get_adapter, telegram
|
||||
from app.channels.base import (
|
||||
InteractiveButton, InteractiveKind, InteractiveMessage, InteractiveReplyType, InteractiveRow,
|
||||
InteractiveSection,
|
||||
)
|
||||
from app.channels.interactive import from_options, validate_interactive
|
||||
from app.channels.interactive_limits import (
|
||||
INSTAGRAM_LIMITS, MESSENGER_LIMITS, TELEGRAM_LIMITS, WHATSAPP_LIMITS,
|
||||
)
|
||||
from tests.channels.test_meta_adapters import _FakeResponse, graph_client # noqa: F401 (fixture)
|
||||
|
||||
LONG = "Reschedule to next Saturday morning"
|
||||
|
||||
|
||||
def _conversation(external_id="PSID1"):
|
||||
conversation = MagicMock()
|
||||
conversation.external_conversation_id = external_id
|
||||
return conversation
|
||||
|
||||
|
||||
# --- building choices within each channel's limits ---
|
||||
|
||||
def test_whatsapp_still_gets_a_list_for_long_options():
|
||||
assert from_options("Pick", ["Cancel", LONG], WHATSAPP_LIMITS).kind == InteractiveKind.LIST
|
||||
|
||||
|
||||
@pytest.mark.parametrize("limits", [MESSENGER_LIMITS, TELEGRAM_LIMITS])
|
||||
def test_channels_without_lists_get_buttons(limits):
|
||||
message = from_options("Pick", ["Cancel", LONG] + [f"Option {i}" for i in range(8)], limits)
|
||||
assert message.kind == InteractiveKind.BUTTONS
|
||||
assert len(message.buttons) == 10
|
||||
assert all(len(b.title) <= limits.button_title_max for b in message.buttons)
|
||||
assert validate_interactive(message, limits) == []
|
||||
|
||||
|
||||
def test_a_cut_button_title_keeps_the_full_option():
|
||||
button = from_options("Pick", ["Cancel", LONG], MESSENGER_LIMITS).buttons[1]
|
||||
assert button.title.endswith("…") and button.description == LONG
|
||||
|
||||
|
||||
def test_a_list_or_link_button_is_refused_where_the_channel_has_none():
|
||||
list_message = InteractiveMessage(kind=InteractiveKind.LIST, body="Pick", list_button_label="Choose",
|
||||
sections=[InteractiveSection(rows=[InteractiveRow(id="a", title="A")])])
|
||||
link = InteractiveMessage(kind=InteractiveKind.CTA_URL, body="Pay", display_text="Pay", url="https://x.io")
|
||||
assert "this channel can't show a list" in validate_interactive(list_message, MESSENGER_LIMITS)
|
||||
assert "this channel can't show a link button" in validate_interactive(link, TELEGRAM_LIMITS)
|
||||
|
||||
|
||||
def test_telegram_ids_are_limited_in_bytes():
|
||||
message = InteractiveMessage(kind=InteractiveKind.BUTTONS, body="Pick",
|
||||
buttons=[InteractiveButton(id="é" * 33, title="A")]) # 66 bytes, 33 chars
|
||||
assert any("bytes" in e for e in validate_interactive(message, TELEGRAM_LIMITS))
|
||||
assert validate_interactive(message, MESSENGER_LIMITS) == []
|
||||
|
||||
|
||||
def test_messenger_allows_thirteen_quick_replies():
|
||||
buttons = [InteractiveButton(id=str(i), title=str(i)) for i in range(14)]
|
||||
message = InteractiveMessage(kind=InteractiveKind.BUTTONS, body="Pick", buttons=buttons[:13])
|
||||
assert validate_interactive(message, MESSENGER_LIMITS) == []
|
||||
message.buttons = buttons
|
||||
assert validate_interactive(message, MESSENGER_LIMITS) != []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("channel,limits", [("whatsapp", WHATSAPP_LIMITS), ("messenger", MESSENGER_LIMITS),
|
||||
("instagram", INSTAGRAM_LIMITS), ("telegram", TELEGRAM_LIMITS),
|
||||
("slack", None), ("email", None)])
|
||||
def test_which_channels_can_show_buttons(channel, limits):
|
||||
adapter = get_adapter(channel)
|
||||
assert adapter.interactive_limits is limits
|
||||
assert adapter.supports_interactive is (limits is not None)
|
||||
|
||||
|
||||
# --- Messenger and Instagram ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_messenger_sends_quick_replies(graph_client, monkeypatch): # noqa: F811
|
||||
adapter = get_adapter("messenger")
|
||||
monkeypatch.setattr(adapter, "access_token", lambda account: "TOKEN")
|
||||
graph_client.response = _FakeResponse(200, {"message_id": "m.1"})
|
||||
|
||||
result = await adapter.send_interactive(MagicMock(), _conversation(), from_options("Coming?", ["Yes", "No"],
|
||||
MESSENGER_LIMITS))
|
||||
|
||||
assert result.external_message_id == "m.1"
|
||||
message = graph_client.calls[0]["json"]["message"]
|
||||
assert message == {"text": "Coming?", "quick_replies": [
|
||||
{"content_type": "text", "title": "Yes", "payload": "qr_0"},
|
||||
{"content_type": "text", "title": "No", "payload": "qr_1"}]}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_instagram_also_writes_the_choices_into_the_text(graph_client, monkeypatch): # noqa: F811
|
||||
"""Instagram doesn't show quick replies on desktop or the web."""
|
||||
adapter = get_adapter("instagram")
|
||||
monkeypatch.setattr(adapter, "access_token", lambda account: "TOKEN")
|
||||
graph_client.response = _FakeResponse(200, {"message_id": "m.1"})
|
||||
|
||||
await adapter.send_interactive(MagicMock(), _conversation(), from_options("Coming?", ["Yes", "No"],
|
||||
INSTAGRAM_LIMITS))
|
||||
|
||||
message = graph_client.calls[0]["json"]["message"]
|
||||
assert message["text"] == "Coming?\n\n1. Yes\n2. No"
|
||||
assert [q["payload"] for q in message["quick_replies"]] == ["qr_0", "qr_1"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_messenger_refuses_a_list():
|
||||
message = InteractiveMessage(kind=InteractiveKind.LIST, body="Pick", list_button_label="Choose",
|
||||
sections=[InteractiveSection(rows=[InteractiveRow(id="a", title="A")])])
|
||||
assert (await get_adapter("messenger").send_interactive(MagicMock(), _conversation(), message)).ok is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("channel", ["messenger", "instagram"])
|
||||
def test_a_quick_reply_tap_is_read(channel):
|
||||
payload = {"entry": [{"id": "PAGE", "messaging": [{
|
||||
"sender": {"id": "PSID1"}, "timestamp": 1720000000000,
|
||||
"message": {"mid": "m.2", "text": "No", "quick_reply": {"payload": "qr_1"}}}]}]}
|
||||
[inbound] = get_adapter(channel).parse_inbound(payload)
|
||||
assert (inbound.interactive.type, inbound.interactive.id, inbound.interactive.title) == (
|
||||
InteractiveReplyType.BUTTON_REPLY, "qr_1", "No")
|
||||
assert inbound.reply_to_message_id is None
|
||||
|
||||
|
||||
def test_a_swipe_reply_keeps_its_context_and_plain_text_has_no_tap():
|
||||
payload = {"entry": [{"id": "PAGE", "messaging": [{
|
||||
"sender": {"id": "PSID1"}, "timestamp": 1720000000000,
|
||||
"message": {"mid": "m.3", "text": "That one", "reply_to": {"mid": "m.1"}}}]}]}
|
||||
[inbound] = get_adapter("messenger").parse_inbound(payload)
|
||||
assert (inbound.interactive, inbound.reply_to_message_id) == (None, "m.1")
|
||||
|
||||
|
||||
# --- Telegram ---
|
||||
|
||||
@pytest.fixture
|
||||
def telegram_api(monkeypatch):
|
||||
call = AsyncMock(return_value={"ok": True, "result": {"message_id": 55}})
|
||||
monkeypatch.setattr(telegram, "_call_api", call)
|
||||
return call
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telegram_sends_an_inline_keyboard(telegram_api, monkeypatch):
|
||||
adapter = get_adapter("telegram")
|
||||
monkeypatch.setattr(adapter, "_bot_token", lambda account: "123:abc")
|
||||
|
||||
result = await adapter.send_interactive(MagicMock(), _conversation("222"),
|
||||
from_options("Coming?", ["Yes", "No"], TELEGRAM_LIMITS))
|
||||
|
||||
assert result.external_message_id == "55"
|
||||
method, payload = telegram_api.await_args.args[1], telegram_api.await_args.args[2]
|
||||
assert method == "sendMessage"
|
||||
assert payload["reply_markup"] == {"inline_keyboard": [[{"text": "Yes", "callback_data": "qr_0"}],
|
||||
[{"text": "No", "callback_data": "qr_1"}]]}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_webhook_is_registered_for_taps(telegram_api):
|
||||
await telegram.set_webhook("123:abc", "https://x.io/hook", "secret")
|
||||
assert telegram_api.await_args.args[2]["allowed_updates"] == ["message", "callback_query"]
|
||||
|
||||
|
||||
def _callback(chat_type="private", data="qr_1", with_message=True, date=1720000000):
|
||||
callback = {"id": "cb-9", "from": {"id": 111, "first_name": "Ada"}, "data": data}
|
||||
if with_message:
|
||||
callback["message"] = {
|
||||
"message_id": 41, "date": date, "chat": {"id": 222, "type": chat_type},
|
||||
"reply_markup": {"inline_keyboard": [[{"text": "Yes", "callback_data": "qr_0"}],
|
||||
[{"text": "No", "callback_data": "qr_1"}]]}}
|
||||
return {"update_id": 5, "callback_query": callback}
|
||||
|
||||
|
||||
def test_a_tap_is_read_with_its_title_from_the_keyboard():
|
||||
[inbound] = get_adapter("telegram").parse_inbound(_callback())
|
||||
assert (inbound.external_message_id, inbound.external_conversation_id, inbound.text) == ("cbq:cb-9", "222", "No")
|
||||
assert (inbound.interactive.id, inbound.reply_to_message_id) == ("qr_1", "41")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("update", [
|
||||
_callback(chat_type="group"), # anyone in a group could tap
|
||||
_callback(with_message=False), # the keyboard message is gone
|
||||
_callback(date=0), # inaccessible message
|
||||
_callback(data="qr_9"), # not a button on that keyboard
|
||||
_callback(data=None),
|
||||
])
|
||||
def test_taps_that_cant_be_trusted_are_dropped(update):
|
||||
assert get_adapter("telegram").parse_inbound(update) == []
|
||||
|
||||
|
||||
def test_a_tap_is_answered_and_nothing_else_is():
|
||||
assert telegram.callback_answer(_callback(), taken=True) == {"method": "answerCallbackQuery",
|
||||
"callback_query_id": "cb-9"}
|
||||
assert telegram.callback_answer({"update_id": 1, "message": {"text": "hi"}}, taken=False) is None
|
||||
|
||||
|
||||
def test_a_tap_we_didnt_act_on_says_so():
|
||||
assert telegram.callback_answer(_callback(), taken=False)["text"] == telegram.TAP_NOT_TAKEN
|
||||
|
||||
|
||||
def test_one_tap_counts_per_keyboard():
|
||||
[first] = get_adapter("telegram").parse_inbound(_callback(data="qr_0"))
|
||||
[second] = get_adapter("telegram").parse_inbound(_callback(data="qr_1"))
|
||||
assert telegram.dedupe_id(first) == telegram.dedupe_id(second) == "keyboard:41"
|
||||
message = get_adapter("telegram").parse_inbound({"update_id": 1, "message": {
|
||||
"message_id": 7, "from": {"id": 1}, "chat": {"id": 222, "type": "private"}, "date": 1, "text": "hi"}})[0]
|
||||
assert telegram.dedupe_id(message) == "7"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_keyboard_is_taken_off_a_tapped_message(telegram_api, monkeypatch):
|
||||
adapter = get_adapter("telegram")
|
||||
monkeypatch.setattr(adapter, "_bot_token", lambda account: "123:abc")
|
||||
await adapter.clear_buttons(MagicMock(), "222", "41")
|
||||
assert telegram_api.await_args.args[1:] == ("editMessageReplyMarkup", {
|
||||
"chat_id": "222", "message_id": 41, "reply_markup": {"inline_keyboard": []}})
|
||||
telegram_api.side_effect = RuntimeError("message is not modified")
|
||||
await adapter.clear_buttons(MagicMock(), "222", "41") # best effort: no raise
|
||||
|
||||
|
||||
@pytest.mark.parametrize("chat_id,offered", [("222", True), ("-1001234", False)])
|
||||
def test_telegram_groups_get_no_buttons(chat_id, offered):
|
||||
"""Anyone in a group could tap them, so taps there are ignored."""
|
||||
assert (get_adapter("telegram").choice_limits_for(_conversation(chat_id)) is not None) is offered
|
||||
|
||||
|
||||
def test_other_channels_offer_their_limits_in_every_conversation():
|
||||
assert get_adapter("messenger").choice_limits_for(_conversation()) is MESSENGER_LIMITS
|
||||
|
||||
|
||||
@pytest.mark.parametrize("channel,cap", [("messenger", 2000), ("instagram", 1000), ("telegram", 4096)])
|
||||
def test_a_channels_text_cap_matches_its_limits(channel, cap):
|
||||
adapter = get_adapter(channel)
|
||||
assert adapter.max_text_length == adapter.interactive_limits.body_max == cap
|
||||
assert len(adapter.format_outbound("x" * (cap + 50))) == cap
|
||||
@@ -0,0 +1,193 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from app.channels import get_adapter
|
||||
from app.channels.email import EmailAdapter, strip_quoted_reply, verify_webhook_token
|
||||
from app.channels.line import LineAdapter
|
||||
|
||||
|
||||
class TestEmailAdapter:
|
||||
def test_parse_sendgrid_style(self):
|
||||
payload = {
|
||||
"from": "Ada Lovelace <ada@example.com>",
|
||||
"to": "support@acme.com",
|
||||
"subject": "Order issue",
|
||||
"text": "My order never arrived.\n\nOn Mon, Jul 7 2026 support wrote:\n> earlier reply",
|
||||
"headers": "Message-ID: <abc123@mail.example.com>\nDate: Mon, 7 Jul 2026",
|
||||
}
|
||||
m = get_adapter("email").parse_inbound(payload)[0]
|
||||
assert m.external_conversation_id == "ada@example.com"
|
||||
assert m.text == "My order never arrived."
|
||||
assert m.profile["subject"] == "Order issue"
|
||||
assert m.profile["inbound_message_id"] == "<abc123@mail.example.com>"
|
||||
|
||||
def test_threading_headers_from_raw_headers(self):
|
||||
# Providers that forward the raw header block (dict or string) — the
|
||||
# threading headers are pulled back out for ticket-reply matching.
|
||||
string_headers = get_adapter("email").parse_inbound({
|
||||
"from": "ada@example.com", "text": "still broken",
|
||||
"headers": ("Message-ID: <r@mail>\n"
|
||||
"In-Reply-To: <ticket-1.a@acme.com>\n"
|
||||
"References: <ticket-1.root@acme.com> <ticket-1.a@acme.com>"),
|
||||
})[0]
|
||||
assert string_headers.profile["in_reply_to"] == "<ticket-1.a@acme.com>"
|
||||
assert string_headers.profile["references"] == (
|
||||
"<ticket-1.root@acme.com> <ticket-1.a@acme.com>")
|
||||
|
||||
dict_headers = get_adapter("email").parse_inbound({
|
||||
"from": "ada@example.com", "text": "still broken",
|
||||
"headers": {"In-Reply-To": "<ticket-2.a@acme.com>", "References": "<ticket-2.root@acme.com>"},
|
||||
})[0]
|
||||
assert dict_headers.profile["in_reply_to"] == "<ticket-2.a@acme.com>"
|
||||
assert dict_headers.profile["references"] == "<ticket-2.root@acme.com>"
|
||||
|
||||
def test_threading_headers_from_flattened_payload(self):
|
||||
# A provider that promotes headers to lowercased top-level fields.
|
||||
m = get_adapter("email").parse_inbound({
|
||||
"from": "ada@example.com", "text": "still broken",
|
||||
"in-reply-to": "<ticket-3.a@acme.com>",
|
||||
"references": "<ticket-3.root@acme.com> <ticket-3.a@acme.com>",
|
||||
})[0]
|
||||
assert m.profile["in_reply_to"] == "<ticket-3.a@acme.com>"
|
||||
assert m.profile["references"] == "<ticket-3.root@acme.com> <ticket-3.a@acme.com>"
|
||||
|
||||
def test_threading_headers_absent_default_to_empty(self):
|
||||
m = get_adapter("email").parse_inbound({
|
||||
"from": "ada@example.com", "text": "hi", "subject": "New question",
|
||||
})[0]
|
||||
assert m.profile["in_reply_to"] == ""
|
||||
assert m.profile["references"] == ""
|
||||
|
||||
def test_parse_brevo_style(self):
|
||||
payload = {
|
||||
"From": {"Address": "Ada@Example.com", "Name": "Ada"},
|
||||
"Subject": "Hi",
|
||||
"RawTextBody": "hello there",
|
||||
"MessageId": "<m1@brevo>",
|
||||
}
|
||||
m = get_adapter("email").parse_inbound(payload)[0]
|
||||
assert m.external_conversation_id == "ada@example.com"
|
||||
assert m.external_message_id == "<m1@brevo>"
|
||||
|
||||
def test_parse_no_sender_or_body_yields_nothing(self):
|
||||
assert get_adapter("email").parse_inbound({"text": "x"}) == []
|
||||
assert get_adapter("email").parse_inbound({"from": "a@b.co", "text": " "}) == []
|
||||
|
||||
def test_all_quoted_body_falls_back_to_raw(self):
|
||||
# Over-eager stripping must never drop a message entirely
|
||||
m = get_adapter("email").parse_inbound({"from": "a@b.co", "text": "> all quoted"})[0]
|
||||
assert m.text == "> all quoted"
|
||||
|
||||
def test_auto_generated_mail_dropped(self):
|
||||
payload = {"from": "a@b.co", "text": "I am out of office",
|
||||
"headers": "Auto-Submitted: auto-replied\nMessage-ID: <x@y>"}
|
||||
assert get_adapter("email").parse_inbound(payload) == []
|
||||
|
||||
def test_strip_quoted_reply(self):
|
||||
text = "New content\n> old line\nOn Tue, someone wrote:\nmore old"
|
||||
assert strip_quoted_reply(text) == "New content"
|
||||
|
||||
def test_smtp_config_per_inbox_and_fallback(self):
|
||||
import json
|
||||
from app.channels.email import smtp_config
|
||||
from app.models.channels import ChannelAccount
|
||||
from app.core.security import encrypt_api_key
|
||||
from app.core.config import settings
|
||||
|
||||
per = ChannelAccount(external_account_id="support@acme.com",
|
||||
encrypted_credentials=encrypt_api_key(json.dumps({
|
||||
"smtp_host": "smtp.acme.com", "smtp_port": 465,
|
||||
"smtp_username": "u", "smtp_password": "p", "from_email": "help@acme.com"})))
|
||||
cfg = smtp_config(per)
|
||||
assert cfg["host"] == "smtp.acme.com" and cfg["port"] == 465
|
||||
assert cfg["use_ssl"] is True and cfg["from_email"] == "help@acme.com"
|
||||
|
||||
fallback = ChannelAccount(external_account_id="x@y.com",
|
||||
encrypted_credentials=encrypt_api_key(json.dumps({})))
|
||||
cfg2 = smtp_config(fallback)
|
||||
assert cfg2["host"] == settings.SMTP_SERVER
|
||||
assert cfg2["from_email"] == "x@y.com" # falls back to the inbox address
|
||||
|
||||
def test_conversation_state(self):
|
||||
adapter = EmailAdapter()
|
||||
m = adapter.parse_inbound({"from": "a@b.co", "text": "hi", "subject": "S",
|
||||
"headers": "Message-ID: <x@y>"})[0]
|
||||
state = adapter.conversation_state(m)
|
||||
assert state == {"last_message_id": "<x@y>", "subject": "S"}
|
||||
|
||||
def test_webhook_token(self):
|
||||
account = MagicMock(webhook_secret="tok")
|
||||
assert verify_webhook_token(account, "tok") is True
|
||||
assert verify_webhook_token(account, "nope") is False
|
||||
assert verify_webhook_token(account, "") is False
|
||||
|
||||
|
||||
class TestLineAdapter:
|
||||
PAYLOAD = {
|
||||
"destination": "Ubot1",
|
||||
"events": [{
|
||||
"type": "message",
|
||||
"timestamp": 1720000000000,
|
||||
"source": {"type": "user", "userId": "Ucust9"},
|
||||
"message": {"type": "text", "id": "m100", "text": "konnichiwa"},
|
||||
}, {
|
||||
"type": "follow",
|
||||
"source": {"type": "user", "userId": "Ucust9"},
|
||||
}],
|
||||
}
|
||||
|
||||
def test_parse_inbound(self):
|
||||
messages = get_adapter("line").parse_inbound(self.PAYLOAD)
|
||||
assert len(messages) == 1
|
||||
m = messages[0]
|
||||
assert m.external_account_id == "Ubot1"
|
||||
assert m.external_conversation_id == "Ucust9"
|
||||
assert m.text == "konnichiwa"
|
||||
assert m.timestamp.tzinfo is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_typing_calls_loading_animation(self, monkeypatch):
|
||||
from unittest.mock import AsyncMock
|
||||
adapter = LineAdapter()
|
||||
account = MagicMock()
|
||||
conv = MagicMock(external_conversation_id="Ucust9")
|
||||
monkeypatch.setattr("app.channels.line.credentials", lambda a: {"channel_access_token": "tok"})
|
||||
post = AsyncMock()
|
||||
monkeypatch.setattr("app.channels.line._get_http_client",
|
||||
lambda: MagicMock(post=post))
|
||||
await adapter.send_typing(account, conv)
|
||||
assert post.await_args.args[0].endswith("/chat/loading/start")
|
||||
assert post.await_args.kwargs["json"]["chatId"] == "Ucust9"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_signature(self, monkeypatch):
|
||||
adapter = LineAdapter()
|
||||
body = json.dumps(self.PAYLOAD).encode()
|
||||
secret = "line-secret"
|
||||
signature = base64.b64encode(hmac.new(secret.encode(), body, hashlib.sha256).digest()).decode()
|
||||
account = MagicMock()
|
||||
monkeypatch.setattr("app.channels.line.credentials", lambda a: {"channel_secret": secret})
|
||||
assert await adapter.verify_webhook({"x-line-signature": signature}, body, account) is True
|
||||
assert await adapter.verify_webhook({"x-line-signature": "bad"}, body, account) is False
|
||||
assert await adapter.verify_webhook({}, body, None) is False
|
||||
@@ -0,0 +1,147 @@
|
||||
"""
|
||||
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 app.channels.base import (
|
||||
InteractiveButton, InteractiveKind, InteractiveMessage, InteractiveRow,
|
||||
InteractiveSection,
|
||||
)
|
||||
from app.channels.interactive import from_options, to_text_fallback, truncate, validate_interactive
|
||||
from app.channels.interactive_limits import WHATSAPP_LIMITS
|
||||
from app.channels.sent_choices import decode_sent_choices, encode_sent_choices, sent_choice_text
|
||||
|
||||
|
||||
class TestFromOptions:
|
||||
def test_few_short_options_become_buttons(self):
|
||||
message = from_options("Confirm the booking?", ["Yes", "No"])
|
||||
assert message.kind == InteractiveKind.BUTTONS
|
||||
assert [(b.id, b.title) for b in message.buttons] == [("qr_0", "Yes"), ("qr_1", "No")]
|
||||
assert validate_interactive(message) == []
|
||||
|
||||
def test_more_than_three_options_become_a_list(self):
|
||||
options = [f"Option {i}" for i in range(WHATSAPP_LIMITS.max_buttons + 1)]
|
||||
message = from_options("Pick one", options)
|
||||
assert message.kind == InteractiveKind.LIST
|
||||
assert [r.title for r in message.sections[0].rows] == options
|
||||
assert validate_interactive(message) == []
|
||||
|
||||
def test_one_long_option_switches_to_a_list_with_the_full_text(self):
|
||||
long_option = "Reschedule to next Saturday morning"
|
||||
message = from_options("What now?", ["Cancel", long_option])
|
||||
assert message.kind == InteractiveKind.LIST
|
||||
row = message.sections[0].rows[1]
|
||||
assert len(row.title) <= WHATSAPP_LIMITS.row_title_max and row.title.endswith("…")
|
||||
assert row.description == long_option
|
||||
assert validate_interactive(message) == []
|
||||
|
||||
def test_options_beyond_the_list_cap_are_dropped(self):
|
||||
message = from_options("Pick", [str(i) for i in range(WHATSAPP_LIMITS.max_list_rows + 5)])
|
||||
assert len(message.sections[0].rows) == WHATSAPP_LIMITS.max_list_rows
|
||||
|
||||
|
||||
class TestValidation:
|
||||
def test_buttons_limits(self):
|
||||
message = InteractiveMessage(
|
||||
kind=InteractiveKind.BUTTONS, body="x",
|
||||
buttons=[InteractiveButton(id="a", title="t" * (WHATSAPP_LIMITS.button_title_max + 1)),
|
||||
InteractiveButton(id="a", title="ok")] * 2)
|
||||
errors = validate_interactive(message)
|
||||
assert any("1-3 buttons" in e for e in errors)
|
||||
assert any("button title" in e for e in errors)
|
||||
assert any("unique" in e for e in errors)
|
||||
|
||||
def test_body_is_required(self):
|
||||
message = InteractiveMessage(kind=InteractiveKind.BUTTONS, body=" ",
|
||||
buttons=[InteractiveButton(id="a", title="A")])
|
||||
assert "body is required" in validate_interactive(message)
|
||||
|
||||
def test_list_needs_label_and_titled_sections_when_several(self):
|
||||
message = InteractiveMessage(
|
||||
kind=InteractiveKind.LIST, body="x",
|
||||
sections=[InteractiveSection(rows=[InteractiveRow(id="1", title="a")]),
|
||||
InteractiveSection(rows=[InteractiveRow(id="2", title="b")])])
|
||||
errors = validate_interactive(message)
|
||||
assert "list button label is required" in errors
|
||||
assert any("section titles are required" in e for e in errors)
|
||||
|
||||
def test_list_row_cap_counts_across_sections(self):
|
||||
rows = [InteractiveRow(id=str(i), title=str(i)) for i in range(WHATSAPP_LIMITS.max_list_rows)]
|
||||
message = InteractiveMessage(
|
||||
kind=InteractiveKind.LIST, body="x", list_button_label="Pick",
|
||||
sections=[InteractiveSection(title="A", rows=rows),
|
||||
InteractiveSection(title="B", rows=[InteractiveRow(id="x", title="x")])])
|
||||
assert any("rows in total" in e for e in validate_interactive(message))
|
||||
|
||||
def test_cta_url_must_be_https(self):
|
||||
message = InteractiveMessage(kind=InteractiveKind.CTA_URL, body="x",
|
||||
display_text="Open", url="http://example.com")
|
||||
assert any("https" in e for e in validate_interactive(message))
|
||||
message.url = "https://example.com"
|
||||
assert validate_interactive(message) == []
|
||||
|
||||
|
||||
class TestTextFallback:
|
||||
def test_numbered_choices_after_the_body(self):
|
||||
text = to_text_fallback(from_options("Confirm?", ["Yes", "No"]))
|
||||
assert text == "Confirm?\n\n1. Yes\n2. No"
|
||||
|
||||
def test_cut_titles_fall_back_to_the_full_option(self):
|
||||
long_option = "Reschedule to next Saturday morning"
|
||||
text = to_text_fallback(from_options("What now?", ["Cancel", long_option]))
|
||||
assert text.endswith(f"2. {long_option}")
|
||||
|
||||
def test_cta_shows_the_link(self):
|
||||
message = InteractiveMessage(kind=InteractiveKind.CTA_URL, body="Invoice ready",
|
||||
display_text="Pay", url="https://pay.example/1")
|
||||
assert to_text_fallback(message) == "Invoice ready\nPay: https://pay.example/1"
|
||||
|
||||
|
||||
class TestTextFallbackLimits:
|
||||
def test_a_long_body_is_shortened_never_the_choices(self):
|
||||
text = to_text_fallback(from_options("x" * 5000, ["Yes", "No"]), max_length=4096)
|
||||
assert len(text) <= 4096
|
||||
assert text.endswith("1. Yes\n2. No")
|
||||
assert "…" in text
|
||||
|
||||
def test_no_limit_keeps_everything(self):
|
||||
assert to_text_fallback(from_options("x" * 5000, ["Yes"])).startswith("x" * 5000)
|
||||
|
||||
def test_a_real_row_description_is_shown(self):
|
||||
message = InteractiveMessage(
|
||||
kind=InteractiveKind.LIST, body="Pick", list_button_label="Pick",
|
||||
sections=[InteractiveSection(rows=[InteractiveRow(id="s1", title="U12", description="09:00")])])
|
||||
assert to_text_fallback(message).endswith("1. U12 — 09:00")
|
||||
|
||||
|
||||
class TestSentChoicesRecord:
|
||||
def test_record_round_trips_encrypted_with_full_texts(self):
|
||||
long_option = "Reschedule to next Saturday morning"
|
||||
record = encode_sent_choices(from_options("What now?", ["Cancel", long_option]), as_text=False)
|
||||
|
||||
assert long_option not in str(record) # encrypted at rest
|
||||
decoded = decode_sent_choices(record)
|
||||
assert decoded["kind"] == "list" and decoded["as_text"] is False
|
||||
assert sent_choice_text(decoded, "qr_1") == long_option
|
||||
assert sent_choice_text(decoded, "qr_9") is None
|
||||
|
||||
def test_unreadable_records_are_ignored(self):
|
||||
assert decode_sent_choices(None) is None
|
||||
assert decode_sent_choices("garbage") is None
|
||||
assert sent_choice_text(None, "qr_0") is None
|
||||
|
||||
|
||||
def test_truncate_marks_the_cut():
|
||||
assert truncate("short", 10) == "short"
|
||||
assert truncate("a" * 30, 10) == "a" * 9 + "…"
|
||||
@@ -0,0 +1,769 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import pytest
|
||||
|
||||
from app.channels import get_adapter
|
||||
from app.channels.base import (
|
||||
DeliveryState, InteractiveButton, InteractiveKind, InteractiveMessage,
|
||||
InteractiveReplyType, InteractiveRow, InteractiveSection, WindowStatus,
|
||||
)
|
||||
from app.channels.meta_base import (
|
||||
verify_meta_signature,
|
||||
verify_challenge,
|
||||
exchange_for_long_lived_token,
|
||||
exchange_instagram_code,
|
||||
exchange_instagram_long_lived,
|
||||
graph_get,
|
||||
graph_list_all,
|
||||
graph_post,
|
||||
graph_post_json,
|
||||
instagram_token_payload,
|
||||
GRAPH_BASE,
|
||||
GRAPH_INSTAGRAM_BASE,
|
||||
WINDOW_HOURS,
|
||||
)
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def _host(url: str) -> str:
|
||||
"""The host a request actually went to.
|
||||
|
||||
Compared rather than prefix-matched: `startswith(GRAPH_BASE)` is also
|
||||
satisfied by `https://graph.facebook.com.evil.example/...`, so it does not
|
||||
really pin down where a token was sent.
|
||||
"""
|
||||
return urlparse(url).hostname or ""
|
||||
|
||||
|
||||
WHATSAPP_PAYLOAD = {
|
||||
"object": "whatsapp_business_account",
|
||||
"entry": [{
|
||||
"id": "WABA1",
|
||||
"changes": [{
|
||||
"field": "messages",
|
||||
"value": {
|
||||
"messaging_product": "whatsapp",
|
||||
"metadata": {"display_phone_number": "15550001111", "phone_number_id": "PN123"},
|
||||
"contacts": [{"profile": {"name": "Ada Lovelace"}, "wa_id": "447000000001"}],
|
||||
"messages": [{
|
||||
"from": "447000000001",
|
||||
"id": "wamid.ABC",
|
||||
"timestamp": "1720000000",
|
||||
"type": "text",
|
||||
"text": {"body": "hello whatsapp"},
|
||||
}],
|
||||
},
|
||||
}],
|
||||
}],
|
||||
}
|
||||
|
||||
WHATSAPP_STATUS_PAYLOAD = {
|
||||
"object": "whatsapp_business_account",
|
||||
"entry": [{
|
||||
"changes": [{
|
||||
"value": {
|
||||
"metadata": {"phone_number_id": "PN123"},
|
||||
"statuses": [{"id": "wamid.ABC", "status": "delivered"}],
|
||||
},
|
||||
}],
|
||||
}],
|
||||
}
|
||||
|
||||
MESSENGER_PAYLOAD = {
|
||||
"object": "page",
|
||||
"entry": [{
|
||||
"id": "PAGE9",
|
||||
"time": 1720000000000,
|
||||
"messaging": [{
|
||||
"sender": {"id": "PSID5"},
|
||||
"recipient": {"id": "PAGE9"},
|
||||
"timestamp": 1720000000000,
|
||||
"message": {"mid": "mid.1", "text": "hello messenger"},
|
||||
}],
|
||||
}],
|
||||
}
|
||||
|
||||
INSTAGRAM_PAYLOAD = {
|
||||
"object": "instagram",
|
||||
"entry": [{
|
||||
"id": "IG7",
|
||||
"messaging": [{
|
||||
"sender": {"id": "IGSID2"},
|
||||
"recipient": {"id": "IG7"},
|
||||
"timestamp": 1720000000000,
|
||||
"message": {"mid": "mid.3", "text": "hello instagram"},
|
||||
}],
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
class TestSignatureAndChallenge:
|
||||
def test_valid_signature(self, monkeypatch):
|
||||
monkeypatch.setattr(settings, "META_APP_SECRET", "s3cret")
|
||||
body = b'{"object":"page"}'
|
||||
sig = "sha256=" + hmac.new(b"s3cret", body, hashlib.sha256).hexdigest()
|
||||
assert verify_meta_signature(body, sig) is True
|
||||
|
||||
def test_invalid_or_missing_signature(self, monkeypatch):
|
||||
monkeypatch.setattr(settings, "META_APP_SECRET", "s3cret")
|
||||
assert verify_meta_signature(b"{}", "sha256=deadbeef") is False
|
||||
assert verify_meta_signature(b"{}", "") is False
|
||||
assert verify_meta_signature(b"{}", "sha1=abc") is False
|
||||
|
||||
def test_signature_rejected_without_configured_secret(self, monkeypatch):
|
||||
monkeypatch.setattr(settings, "META_APP_SECRET", "")
|
||||
monkeypatch.setattr(settings, "INSTAGRAM_APP_SECRET", "")
|
||||
assert verify_meta_signature(b"{}", "sha256=anything") is False
|
||||
|
||||
def test_accepts_a_payload_signed_with_the_instagram_secret(self, monkeypatch):
|
||||
"""Instagram Login webhooks are signed with the Instagram app secret;
|
||||
rejecting those would drop every Instagram DM at the door."""
|
||||
monkeypatch.setattr(settings, "META_APP_SECRET", "fb-secret")
|
||||
monkeypatch.setattr(settings, "INSTAGRAM_APP_SECRET", "ig-secret")
|
||||
body = b'{"object":"instagram"}'
|
||||
sig = "sha256=" + hmac.new(b"ig-secret", body, hashlib.sha256).hexdigest()
|
||||
assert verify_meta_signature(body, sig) is True
|
||||
|
||||
def test_a_signature_from_neither_secret_is_rejected(self, monkeypatch):
|
||||
monkeypatch.setattr(settings, "META_APP_SECRET", "fb-secret")
|
||||
monkeypatch.setattr(settings, "INSTAGRAM_APP_SECRET", "ig-secret")
|
||||
body = b'{"object":"instagram"}'
|
||||
sig = "sha256=" + hmac.new(b"attacker", body, hashlib.sha256).hexdigest()
|
||||
assert verify_meta_signature(body, sig) is False
|
||||
|
||||
def test_challenge(self, monkeypatch):
|
||||
monkeypatch.setattr(settings, "META_WEBHOOK_VERIFY_TOKEN", "vtok")
|
||||
assert verify_challenge("subscribe", "vtok", "12345") == "12345"
|
||||
assert verify_challenge("subscribe", "wrong", "12345") is None
|
||||
assert verify_challenge(None, None, None) is None
|
||||
|
||||
|
||||
class TestWhatsAppParse:
|
||||
def test_parse_text_message(self):
|
||||
messages = get_adapter("whatsapp").parse_inbound(WHATSAPP_PAYLOAD)
|
||||
assert len(messages) == 1
|
||||
m = messages[0]
|
||||
assert m.external_account_id == "PN123"
|
||||
assert m.external_conversation_id == "447000000001"
|
||||
assert m.external_message_id == "wamid.ABC"
|
||||
assert m.text == "hello whatsapp"
|
||||
assert m.profile["name"] == "Ada Lovelace"
|
||||
# wa_id declared verbatim; the normalize_msisdn boundary adds the '+'
|
||||
assert m.profile["phone"] == "447000000001"
|
||||
assert m.timestamp.tzinfo is not None
|
||||
|
||||
def test_status_callback_yields_nothing(self):
|
||||
assert get_adapter("whatsapp").parse_inbound(WHATSAPP_STATUS_PAYLOAD) == []
|
||||
|
||||
def test_template_quick_reply_keeps_text_and_payload(self):
|
||||
messages = get_adapter("whatsapp").parse_inbound(_wa_message({
|
||||
"type": "button", "button": {"text": "Yes please", "payload": "CONFIRM_42"},
|
||||
}))
|
||||
assert messages[0].text == "Yes please"
|
||||
assert messages[0].interactive.type == InteractiveReplyType.TEMPLATE_BUTTON
|
||||
assert messages[0].interactive.id == "CONFIRM_42"
|
||||
|
||||
def test_button_reply_keeps_id_and_reply_context(self):
|
||||
messages = get_adapter("whatsapp").parse_inbound(_wa_message({
|
||||
"type": "interactive",
|
||||
"context": {"from": "15550001111", "id": "wamid.OFFER"},
|
||||
"interactive": {"type": "button_reply",
|
||||
"button_reply": {"id": "qr_1", "title": "Tomorrow"}},
|
||||
}))
|
||||
m = messages[0]
|
||||
assert m.text == "Tomorrow"
|
||||
assert (m.interactive.type, m.interactive.id) == (InteractiveReplyType.BUTTON_REPLY, "qr_1")
|
||||
assert m.reply_to_message_id == "wamid.OFFER"
|
||||
|
||||
def test_list_reply_keeps_id_and_description(self):
|
||||
messages = get_adapter("whatsapp").parse_inbound(_wa_message({
|
||||
"type": "interactive",
|
||||
"interactive": {"type": "list_reply", "list_reply": {
|
||||
"id": "attendance:present", "title": "Present", "description": "On time"}},
|
||||
}))
|
||||
reply = messages[0].interactive
|
||||
assert reply.type == InteractiveReplyType.LIST_REPLY
|
||||
assert (reply.id, reply.title) == ("attendance:present", "Present")
|
||||
|
||||
def test_location_becomes_readable_text(self):
|
||||
messages = get_adapter("whatsapp").parse_inbound(_wa_message({
|
||||
"type": "location",
|
||||
"location": {"latitude": 51.5, "longitude": -0.12, "name": "Pitch 3"},
|
||||
}))
|
||||
m = messages[0]
|
||||
assert m.location.latitude == 51.5 and m.location.name == "Pitch 3"
|
||||
assert m.text == "Shared location: Pitch 3 (51.5, -0.12)"
|
||||
|
||||
def test_an_untitled_tap_reads_as_its_id_never_empty(self):
|
||||
messages = get_adapter("whatsapp").parse_inbound(_wa_message({
|
||||
"type": "interactive",
|
||||
"interactive": {"type": "button_reply", "button_reply": {"id": "confirm"}},
|
||||
}))
|
||||
assert messages[0].text == "confirm"
|
||||
|
||||
def test_a_tap_with_neither_title_nor_id_is_skipped(self):
|
||||
assert get_adapter("whatsapp").parse_inbound(_wa_message({
|
||||
"type": "interactive", "interactive": {"type": "button_reply", "button_reply": {}},
|
||||
})) == []
|
||||
|
||||
def test_media_is_still_skipped(self):
|
||||
assert get_adapter("whatsapp").parse_inbound(_wa_message({
|
||||
"type": "image", "image": {"id": "MEDIA1"},
|
||||
})) == []
|
||||
|
||||
|
||||
def _wa_message(message: dict) -> dict:
|
||||
"""A one-message WhatsApp webhook payload."""
|
||||
return {
|
||||
"object": "whatsapp_business_account",
|
||||
"entry": [{"changes": [{"value": {
|
||||
"metadata": {"phone_number_id": "PN123"},
|
||||
"messages": [{"from": "447", "id": "wamid.B", **message}],
|
||||
}}]}],
|
||||
}
|
||||
|
||||
|
||||
def _wa_statuses(*statuses: dict) -> dict:
|
||||
return {
|
||||
"object": "whatsapp_business_account",
|
||||
"entry": [{"changes": [{"value": {
|
||||
"metadata": {"phone_number_id": "PN123"},
|
||||
"statuses": list(statuses),
|
||||
}}]}],
|
||||
}
|
||||
|
||||
|
||||
class TestWhatsAppStatuses:
|
||||
def test_parses_status_with_pricing(self):
|
||||
events = get_adapter("whatsapp").parse_status_events(_wa_statuses({
|
||||
"id": "wamid.ABC", "status": "read", "timestamp": "1720000000",
|
||||
"recipient_id": "447",
|
||||
"pricing": {"billable": True, "pricing_model": "PMP", "category": "utility"},
|
||||
}))
|
||||
assert len(events) == 1
|
||||
e = events[0]
|
||||
assert (e.external_account_id, e.external_message_id) == ("PN123", "wamid.ABC")
|
||||
assert e.status == DeliveryState.READ
|
||||
assert (e.pricing_category, e.billable) == ("utility", True)
|
||||
assert e.timestamp.tzinfo is not None
|
||||
|
||||
def test_parses_per_message_pricing(self):
|
||||
[e] = get_adapter("whatsapp").parse_status_events(_wa_statuses({
|
||||
"id": "wamid.ABC", "status": "delivered", "timestamp": "1720000000", "recipient_id": "447",
|
||||
"pricing": {"billable": False, "pricing_model": "PMP", "category": "service",
|
||||
"type": "free_customer_service"},
|
||||
}))
|
||||
assert (e.pricing_category, e.pricing_type, e.pricing_model) == ("service", "free_customer_service", "PMP")
|
||||
|
||||
def test_a_status_without_pricing_or_errors_has_none(self):
|
||||
[e] = get_adapter("whatsapp").parse_status_events(_wa_statuses({
|
||||
"id": "wamid.ABC", "status": "sent", "timestamp": "1720000000", "recipient_id": "447"}))
|
||||
assert (e.pricing_type, e.pricing_model, e.error_code) == (None, None, None)
|
||||
|
||||
def test_failed_status_carries_the_reason(self):
|
||||
events = get_adapter("whatsapp").parse_status_events(_wa_statuses({
|
||||
"id": "wamid.X", "status": "failed",
|
||||
"errors": [{"code": 131047, "title": "Re-engagement message",
|
||||
"error_data": {"details": "More than 24 hours have passed"}}],
|
||||
}))
|
||||
assert events[0].status == DeliveryState.FAILED
|
||||
assert events[0].error == "More than 24 hours have passed"
|
||||
assert events[0].error_code == "131047"
|
||||
|
||||
def test_unknown_states_and_messages_are_ignored(self):
|
||||
adapter = get_adapter("whatsapp")
|
||||
assert adapter.parse_status_events(_wa_statuses({"id": "wamid.X", "status": "deleted"})) == []
|
||||
assert adapter.parse_status_events(WHATSAPP_PAYLOAD) == []
|
||||
|
||||
def test_other_channels_report_no_statuses(self):
|
||||
assert get_adapter("messenger").parse_status_events(MESSENGER_PAYLOAD) == []
|
||||
|
||||
|
||||
class TestMessengerInstagramParse:
|
||||
def test_parse_messenger(self):
|
||||
messages = get_adapter("messenger").parse_inbound(MESSENGER_PAYLOAD)
|
||||
assert len(messages) == 1
|
||||
m = messages[0]
|
||||
assert m.external_account_id == "PAGE9"
|
||||
assert m.external_conversation_id == "PSID5"
|
||||
assert m.external_message_id == "mid.1"
|
||||
assert m.text == "hello messenger"
|
||||
|
||||
def test_echo_and_receipts_skipped(self):
|
||||
payload = {
|
||||
"object": "page",
|
||||
"entry": [{"id": "PAGE9", "messaging": [
|
||||
{"sender": {"id": "PSID5"}, "message": {"mid": "m", "text": "x", "is_echo": True}},
|
||||
{"sender": {"id": "PSID5"}, "delivery": {"mids": ["m"]}},
|
||||
{"sender": {"id": "PSID5"}, "read": {"watermark": 1}},
|
||||
]}],
|
||||
}
|
||||
assert get_adapter("messenger").parse_inbound(payload) == []
|
||||
|
||||
def test_parse_instagram(self):
|
||||
messages = get_adapter("instagram").parse_inbound(INSTAGRAM_PAYLOAD)
|
||||
assert len(messages) == 1
|
||||
assert messages[0].external_account_id == "IG7"
|
||||
assert messages[0].external_conversation_id == "IGSID2"
|
||||
|
||||
def test_parse_instagram_changes_shape(self):
|
||||
"""Instagram also delivers a DM under changes[] with field=messages
|
||||
(this is the shape Meta's own webhook sample sends). Reading only
|
||||
messaging[] drops the message silently — the webhook still acks."""
|
||||
payload = {
|
||||
"object": "instagram",
|
||||
"entry": [{
|
||||
"id": "IG7",
|
||||
"changes": [{
|
||||
"field": "messages",
|
||||
"value": {
|
||||
"sender": {"id": "IGSID2"},
|
||||
"recipient": {"id": "IG7"},
|
||||
"timestamp": "1527459824",
|
||||
"message": {"mid": "mid.9", "text": "hello from changes"},
|
||||
},
|
||||
}],
|
||||
}],
|
||||
}
|
||||
messages = get_adapter("instagram").parse_inbound(payload)
|
||||
assert len(messages) == 1
|
||||
assert messages[0].external_account_id == "IG7"
|
||||
assert messages[0].external_conversation_id == "IGSID2"
|
||||
assert messages[0].text == "hello from changes"
|
||||
|
||||
def test_messenger_ignores_the_changes_shape(self):
|
||||
"""Only Instagram delivers messaging events that way."""
|
||||
payload = {"object": "page", "entry": [{"id": "PAGE9", "changes": [
|
||||
{"field": "messages", "value": {"sender": {"id": "PSID5"},
|
||||
"message": {"mid": "m", "text": "x"}}}]}]}
|
||||
assert get_adapter("messenger").parse_inbound(payload) == []
|
||||
|
||||
|
||||
class TestInstagramTransport:
|
||||
"""Instagram Login accounts hold an Instagram user token, which the Facebook
|
||||
graph rejects — every call for them must go to graph.instagram.com."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_instagram_sends_via_graph_instagram(self, graph_client, monkeypatch):
|
||||
adapter = get_adapter("instagram")
|
||||
monkeypatch.setattr(adapter, "access_token", lambda account: "IGTOKEN")
|
||||
graph_client.response = _FakeResponse(200, {"message_id": "m1"})
|
||||
conversation = MagicMock()
|
||||
conversation.external_conversation_id = "IGSID1"
|
||||
|
||||
await adapter.send_text(MagicMock(), conversation, "hi")
|
||||
|
||||
call = graph_client.calls[0]
|
||||
assert _host(call["url"]) == _host(GRAPH_INSTAGRAM_BASE)
|
||||
# messaging_type is Messenger-only; Instagram's send body omits it.
|
||||
assert "messaging_type" not in call["json"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_messenger_still_sends_via_graph_facebook(self, graph_client, monkeypatch):
|
||||
adapter = get_adapter("messenger")
|
||||
monkeypatch.setattr(adapter, "access_token", lambda account: "PAGETOKEN")
|
||||
graph_client.response = _FakeResponse(200, {"message_id": "m1"})
|
||||
conversation = MagicMock()
|
||||
conversation.external_conversation_id = "PSID1"
|
||||
|
||||
await adapter.send_text(MagicMock(), conversation, "hi")
|
||||
|
||||
call = graph_client.calls[0]
|
||||
assert _host(call["url"]) == _host(GRAPH_BASE)
|
||||
assert call["json"]["messaging_type"] == "RESPONSE"
|
||||
|
||||
|
||||
class TestProfileEnrichment:
|
||||
"""The inbound payload carries only a sender id, so the customer's real name
|
||||
is looked up on demand — otherwise everyone shows as 'Messenger user 2742…'."""
|
||||
|
||||
def _adapter(self, channel, monkeypatch, graph_return):
|
||||
adapter = get_adapter(channel)
|
||||
monkeypatch.setattr(adapter, "access_token", lambda account: "PAGE_TOKEN")
|
||||
graph = AsyncMock(return_value=graph_return)
|
||||
monkeypatch.setattr("app.channels.messenger.graph_get", graph)
|
||||
return adapter, graph
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_messenger_resolves_the_senders_name(self, monkeypatch):
|
||||
adapter, graph = self._adapter(
|
||||
"messenger", monkeypatch, (True, {"first_name": "Ada", "last_name": "Lovelace"}))
|
||||
assert await adapter.fetch_profile(MagicMock(), "PSID1") == {"name": "Ada Lovelace"}
|
||||
graph.assert_awaited_once_with(
|
||||
"PSID1", "PAGE_TOKEN", params={"fields": "first_name,last_name"},
|
||||
base="https://graph.facebook.com")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_graph_failure_degrades_to_no_name(self, monkeypatch):
|
||||
"""A lookup failure must not lose the message — it keeps the placeholder."""
|
||||
adapter, _ = self._adapter(
|
||||
"messenger", monkeypatch, (False, {"error": {"message": "rate limited"}}))
|
||||
assert await adapter.fetch_profile(MagicMock(), "PSID1") == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_instagram_uses_the_ig_name_fields(self, monkeypatch):
|
||||
adapter, graph = self._adapter(
|
||||
"instagram", monkeypatch, (True, {"name": "Ada", "username": "ada_l"}))
|
||||
assert await adapter.fetch_profile(MagicMock(), "IGSID1") == {"name": "Ada"}
|
||||
graph.assert_awaited_once_with(
|
||||
"IGSID1", "PAGE_TOKEN", params={"fields": "name,username"},
|
||||
base="https://graph.instagram.com")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_instagram_falls_back_to_username(self, monkeypatch):
|
||||
adapter, _ = self._adapter(
|
||||
"instagram", monkeypatch, (True, {"username": "ada_l"}))
|
||||
assert await adapter.fetch_profile(MagicMock(), "IGSID1") == {"name": "ada_l"}
|
||||
|
||||
|
||||
class TestDeliveryWindow:
|
||||
def _conversation(self, hours_ago: float):
|
||||
conv = MagicMock()
|
||||
conv.last_inbound_at = datetime.now(timezone.utc) - timedelta(hours=hours_ago)
|
||||
return conv
|
||||
|
||||
def test_within_window_ok(self):
|
||||
for channel in ("whatsapp", "messenger", "instagram"):
|
||||
assert get_adapter(channel).check_delivery_window(self._conversation(1)) is WindowStatus.OK
|
||||
|
||||
def test_expired_whatsapp_allows_template(self):
|
||||
status = get_adapter("whatsapp").check_delivery_window(self._conversation(WINDOW_HOURS + 1))
|
||||
assert status is WindowStatus.TEMPLATE_REQUIRED
|
||||
|
||||
def test_expired_messenger_instagram_undeliverable(self):
|
||||
for channel in ("messenger", "instagram"):
|
||||
status = get_adapter(channel).check_delivery_window(self._conversation(WINDOW_HOURS + 1))
|
||||
assert status is WindowStatus.UNDELIVERABLE
|
||||
|
||||
def test_naive_timestamp_treated_as_utc(self):
|
||||
conv = MagicMock()
|
||||
conv.last_inbound_at = datetime.utcnow() - timedelta(hours=1) # naive
|
||||
assert get_adapter("whatsapp").check_delivery_window(conv) is WindowStatus.OK
|
||||
|
||||
def test_no_inbound_at_means_expired(self):
|
||||
conv = MagicMock()
|
||||
conv.last_inbound_at = None
|
||||
assert get_adapter("whatsapp").check_delivery_window(conv) is WindowStatus.TEMPLATE_REQUIRED
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, status_code=200, body=None):
|
||||
self.status_code = status_code
|
||||
self._body = {} if body is None else body
|
||||
|
||||
def json(self):
|
||||
return self._body
|
||||
|
||||
|
||||
class _RecordingClient:
|
||||
"""Stands in for the shared Graph http client, capturing what was sent."""
|
||||
|
||||
def __init__(self):
|
||||
self.response = _FakeResponse()
|
||||
self.error = None
|
||||
self.calls = []
|
||||
|
||||
async def request(self, method, url, params=None, json=None, headers=None, data=None):
|
||||
self.calls.append({"method": method, "url": url, "params": params,
|
||||
"json": json, "headers": headers, "data": data})
|
||||
if self.error:
|
||||
raise self.error
|
||||
return self.response
|
||||
|
||||
async def post(self, url, json=None, headers=None):
|
||||
"""graph_post sends through .post rather than .request."""
|
||||
return await self.request("POST", url, json=json, headers=headers)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def graph_client(monkeypatch):
|
||||
client = _RecordingClient()
|
||||
monkeypatch.setattr("app.channels.meta_base._get_http_client", lambda: client)
|
||||
return client
|
||||
|
||||
|
||||
class TestGraphHelpers:
|
||||
"""graph_post_json/graph_get keep the whole response body, unlike
|
||||
graph_post which reduces it to a message id."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_json_returns_full_body(self, graph_client):
|
||||
created = {"id": "T1", "status": "PENDING", "category": "UTILITY"}
|
||||
graph_client.response = _FakeResponse(200, created)
|
||||
|
||||
ok, body = await graph_post_json("WABA1/message_templates", "tok", {"name": "hello"})
|
||||
|
||||
assert (ok, body) == (True, created)
|
||||
assert graph_client.calls[0]["method"] == "POST"
|
||||
assert graph_client.calls[0]["json"] == {"name": "hello"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_travels_in_header_not_url(self, graph_client):
|
||||
await graph_post_json("WABA1/message_templates", "sekret", {})
|
||||
|
||||
call = graph_client.calls[0]
|
||||
assert call["headers"]["Authorization"] == "Bearer sekret"
|
||||
assert "sekret" not in call["url"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_params_are_passed_and_a_4xx_is_reported_not_raised(self, graph_client):
|
||||
graph_client.response = _FakeResponse(400, {"error": {"message": "not found"}})
|
||||
|
||||
ok, body = await graph_get("WABA1/message_templates", "tok", {"fields": "name"})
|
||||
|
||||
assert ok is False
|
||||
assert body["error"]["message"] == "not found"
|
||||
assert graph_client.calls[0]["method"] == "GET"
|
||||
assert graph_client.calls[0]["params"] == {"fields": "name"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_network_error_is_reported_not_raised(self, graph_client):
|
||||
graph_client.error = RuntimeError("connection reset")
|
||||
|
||||
ok, body = await graph_get("WABA1/message_templates", "tok")
|
||||
|
||||
assert ok is False
|
||||
assert "connection reset" in body["error"]["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_long_lived_exchange_uses_app_credentials_not_a_bearer(self, graph_client, monkeypatch):
|
||||
"""Like the signup-code exchange, this authenticates with the app secret
|
||||
in the query, so it must carry no Authorization header."""
|
||||
monkeypatch.setattr(settings, "META_APP_ID", "APP1")
|
||||
monkeypatch.setattr(settings, "META_APP_SECRET", "SEKRET")
|
||||
graph_client.response = _FakeResponse(200, {"access_token": "LONGLIVED"})
|
||||
|
||||
ok, body = await exchange_for_long_lived_token("short-token")
|
||||
|
||||
assert (ok, body["access_token"]) == (True, "LONGLIVED")
|
||||
call = graph_client.calls[0]
|
||||
assert call["headers"].get("Authorization") is None
|
||||
assert call["params"]["grant_type"] == "fb_exchange_token"
|
||||
assert call["params"]["fb_exchange_token"] == "short-token"
|
||||
assert "SEKRET" not in call["url"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_instagram_code_exchange_uses_the_instagram_app_and_host(
|
||||
self, graph_client, monkeypatch):
|
||||
"""Instagram Login is its own OAuth: Instagram app credentials, its own
|
||||
host, form-encoded — the Facebook app id here would be rejected."""
|
||||
monkeypatch.setattr(settings, "INSTAGRAM_APP_ID", "IGAPP")
|
||||
monkeypatch.setattr(settings, "INSTAGRAM_APP_SECRET", "IGSECRET")
|
||||
monkeypatch.setattr(settings, "META_APP_ID", "FBAPP")
|
||||
graph_client.response = _FakeResponse(200, {"access_token": "IGShort", "user_id": 178})
|
||||
|
||||
ok, body = await exchange_instagram_code("CODE", "https://app.test/cb.html")
|
||||
|
||||
assert (ok, body["access_token"]) == (True, "IGShort")
|
||||
call = graph_client.calls[0]
|
||||
assert call["url"] == "https://api.instagram.com/oauth/access_token"
|
||||
assert call["data"]["client_id"] == "IGAPP"
|
||||
assert call["data"]["grant_type"] == "authorization_code"
|
||||
assert call["data"]["redirect_uri"] == "https://app.test/cb.html"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_instagram_long_lived_exchange_hits_graph_instagram(self, graph_client, monkeypatch):
|
||||
monkeypatch.setattr(settings, "INSTAGRAM_APP_SECRET", "IGSECRET")
|
||||
graph_client.response = _FakeResponse(200, {"access_token": "IGLong", "expires_in": 5183944})
|
||||
|
||||
ok, body = await exchange_instagram_long_lived("IGShort")
|
||||
|
||||
assert (ok, body["access_token"]) == (True, "IGLong")
|
||||
call = graph_client.calls[0]
|
||||
assert call["url"] == f"{GRAPH_INSTAGRAM_BASE}/access_token"
|
||||
assert call["params"]["grant_type"] == "ig_exchange_token"
|
||||
|
||||
def test_token_payload_accepts_both_response_shapes(self):
|
||||
"""Instagram has returned the token flat and wrapped in `data`; reading
|
||||
only one shape yields no token and looks like a login failure."""
|
||||
flat = {"access_token": "A", "user_id": 1}
|
||||
wrapped = {"data": [{"access_token": "A", "user_id": 1}]}
|
||||
assert instagram_token_payload(flat)["access_token"] == "A"
|
||||
assert instagram_token_payload(wrapped)["access_token"] == "A"
|
||||
# An empty/malformed `data` falls back rather than raising
|
||||
assert instagram_token_payload({"data": []}) == {"data": []}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_graph_list_all_follows_the_cursor(self, graph_client):
|
||||
"""Two full pages then a short one: it must return every node, not stop
|
||||
at the first page."""
|
||||
pages = [
|
||||
_FakeResponse(200, {"data": [{"id": "1"}, {"id": "2"}],
|
||||
"paging": {"cursors": {"after": "c1"}}}),
|
||||
_FakeResponse(200, {"data": [{"id": "3"}]}),
|
||||
]
|
||||
call_count = {"n": 0}
|
||||
|
||||
async def request(method, url, params=None, json=None, headers=None):
|
||||
graph_client.calls.append({"method": method, "url": url, "params": params})
|
||||
r = pages[call_count["n"]]
|
||||
call_count["n"] += 1
|
||||
return r
|
||||
|
||||
graph_client.request = request
|
||||
ok, items = await graph_list_all("me/accounts", "tok", {"limit": 2})
|
||||
|
||||
assert ok is True
|
||||
assert [i["id"] for i in items] == ["1", "2", "3"]
|
||||
assert graph_client.calls[1]["params"]["after"] == "c1"
|
||||
|
||||
|
||||
class TestWhatsAppInteractiveSend:
|
||||
@pytest.fixture
|
||||
def adapter(self, monkeypatch):
|
||||
adapter = get_adapter("whatsapp")
|
||||
monkeypatch.setattr(adapter, "access_token", lambda account: "WATOKEN")
|
||||
return adapter
|
||||
|
||||
@staticmethod
|
||||
def _conversation():
|
||||
conversation = MagicMock()
|
||||
conversation.external_conversation_id = "447000000001"
|
||||
return conversation
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reply_buttons_payload(self, graph_client, adapter):
|
||||
graph_client.response = _FakeResponse(200, {"messages": [{"id": "wamid.OUT"}]})
|
||||
message = InteractiveMessage(
|
||||
kind=InteractiveKind.BUTTONS, body="Mark attendance?", footer="CoachIQ",
|
||||
buttons=[InteractiveButton(id="present", title="Present"),
|
||||
InteractiveButton(id="absent", title="Absent")])
|
||||
|
||||
result = await adapter.send_interactive(MagicMock(), self._conversation(), message)
|
||||
|
||||
assert result.ok and result.external_message_id == "wamid.OUT"
|
||||
body = graph_client.calls[0]["json"]
|
||||
assert body["type"] == "interactive" and body["to"] == "447000000001"
|
||||
assert body["interactive"] == {
|
||||
"type": "button",
|
||||
"body": {"text": "Mark attendance?"},
|
||||
"footer": {"text": "CoachIQ"},
|
||||
"action": {"buttons": [
|
||||
{"type": "reply", "reply": {"id": "present", "title": "Present"}},
|
||||
{"type": "reply", "reply": {"id": "absent", "title": "Absent"}},
|
||||
]},
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_payload(self, graph_client, adapter):
|
||||
graph_client.response = _FakeResponse(200, {"messages": [{"id": "wamid.OUT"}]})
|
||||
message = InteractiveMessage(
|
||||
kind=InteractiveKind.LIST, body="Pick a session", header="Today",
|
||||
list_button_label="Sessions",
|
||||
sections=[InteractiveSection(title="Morning", rows=[
|
||||
InteractiveRow(id="s1", title="U12 09:00", description="Pitch 3"),
|
||||
InteractiveRow(id="s2", title="U14 10:30"),
|
||||
])])
|
||||
|
||||
await adapter.send_interactive(MagicMock(), self._conversation(), message)
|
||||
|
||||
interactive = graph_client.calls[0]["json"]["interactive"]
|
||||
assert interactive["type"] == "list"
|
||||
assert interactive["header"] == {"type": "text", "text": "Today"}
|
||||
assert interactive["action"] == {"button": "Sessions", "sections": [{
|
||||
"title": "Morning",
|
||||
"rows": [{"id": "s1", "title": "U12 09:00", "description": "Pitch 3"},
|
||||
{"id": "s2", "title": "U14 10:30"}],
|
||||
}]}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cta_url_payload(self, graph_client, adapter):
|
||||
message = InteractiveMessage(kind=InteractiveKind.CTA_URL, body="Your invoice",
|
||||
display_text="Pay now", url="https://pay.example/1")
|
||||
|
||||
await adapter.send_interactive(MagicMock(), self._conversation(), message)
|
||||
|
||||
interactive = graph_client.calls[0]["json"]["interactive"]
|
||||
assert interactive["type"] == "cta_url"
|
||||
assert interactive["action"] == {"name": "cta_url", "parameters": {
|
||||
"display_text": "Pay now", "url": "https://pay.example/1"}}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_channels_without_support_refuse(self):
|
||||
adapter = get_adapter("slack")
|
||||
assert adapter.supports_interactive is False
|
||||
result = await adapter.send_interactive(
|
||||
MagicMock(), MagicMock(), InteractiveMessage(kind=InteractiveKind.BUTTONS, body="x"))
|
||||
assert result.ok is False
|
||||
|
||||
|
||||
class TestWhatsAppReadReceipts:
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_read_has_no_typing_indicator(self, graph_client, monkeypatch):
|
||||
adapter = get_adapter("whatsapp")
|
||||
monkeypatch.setattr(adapter, "access_token", lambda account: "WATOKEN")
|
||||
graph_client.response = _FakeResponse(200, {"success": True})
|
||||
|
||||
await adapter.mark_read(MagicMock(), "wamid.IN")
|
||||
|
||||
assert graph_client.calls[0]["json"] == {
|
||||
"messaging_product": "whatsapp", "status": "read", "message_id": "wamid.IN"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_typing_marks_read_with_the_indicator(self, graph_client, monkeypatch):
|
||||
adapter = get_adapter("whatsapp")
|
||||
monkeypatch.setattr(adapter, "access_token", lambda account: "WATOKEN")
|
||||
conversation = MagicMock()
|
||||
conversation.extra = {"last_inbound_message_id": "wamid.IN"}
|
||||
|
||||
await adapter.send_typing(MagicMock(), conversation)
|
||||
|
||||
assert graph_client.calls[0]["json"]["typing_indicator"] == {"type": "text"}
|
||||
|
||||
|
||||
class TestGraphPostAuthFailure:
|
||||
"""A refused token is flagged so the account can be marked for
|
||||
reconnection; a refused message is not."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("status,body", [
|
||||
(401, {"error": {"message": "Authentication Error", "code": 0}}),
|
||||
(400, {"error": {"message": "Authentication Error", "code": 0}}),
|
||||
(400, {"error": {"message": "Session has expired", "code": 190, "error_subcode": 463}}),
|
||||
(400, {"error": {"message": "Error validating access token", "code": 102}}),
|
||||
(401, {}),
|
||||
])
|
||||
async def test_token_refusals_are_auth_failures(self, graph_client, status, body):
|
||||
graph_client.response = _FakeResponse(status, body)
|
||||
result = await graph_post("PN1/messages", "tok", {})
|
||||
assert result.ok is False
|
||||
assert result.auth_failed is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("body", [
|
||||
{"error": {"message": "Re-engagement message", "code": 131047}},
|
||||
{"error": {"message": "Invalid parameter", "code": 100}},
|
||||
{"error": "not a dict"},
|
||||
])
|
||||
async def test_message_refusals_are_not(self, graph_client, body):
|
||||
graph_client.response = _FakeResponse(400, body)
|
||||
result = await graph_post("PN1/messages", "tok", {})
|
||||
assert result.ok is False
|
||||
assert result.auth_failed is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_success_is_not(self, graph_client):
|
||||
graph_client.response = _FakeResponse(200, {"messages": [{"id": "wamid.1"}]})
|
||||
result = await graph_post("PN1/messages", "tok", {})
|
||||
assert result.ok is True
|
||||
assert result.auth_failed is False
|
||||
@@ -0,0 +1,417 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import time
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
import app.api.webhooks.slack as slack_webhook
|
||||
import app.services.slack_events as slack_events
|
||||
from app.api.webhooks.slack import _handle_lifecycle_event
|
||||
from app.channels import get_adapter
|
||||
from app.channels.slack import SlackAdapter, build_home_view, verify_slack_signature
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def make_event(event: dict, team_id="T111", event_id="Ev1"):
|
||||
return {"type": "event_callback", "team_id": team_id, "event_id": event_id, "event": event}
|
||||
|
||||
|
||||
MENTION_EVENT = make_event({
|
||||
"type": "app_mention",
|
||||
"user": "U42",
|
||||
"text": "<@UBOT> what are your opening hours?",
|
||||
"channel": "C9",
|
||||
"ts": "1720000000.000100",
|
||||
})
|
||||
|
||||
DM_EVENT = make_event({
|
||||
"type": "message",
|
||||
"channel_type": "im",
|
||||
"user": "U42",
|
||||
"text": "hello there",
|
||||
"channel": "D7",
|
||||
"ts": "1720000001.000200",
|
||||
})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def adapter():
|
||||
return SlackAdapter()
|
||||
|
||||
|
||||
def test_registry_resolves_slack():
|
||||
assert isinstance(get_adapter("slack"), SlackAdapter)
|
||||
|
||||
|
||||
def test_parse_mention_threads_conversation(adapter):
|
||||
messages = adapter.parse_inbound(MENTION_EVENT)
|
||||
assert len(messages) == 1
|
||||
m = messages[0]
|
||||
assert m.external_account_id == "T111"
|
||||
assert m.external_conversation_id == "C9:1720000000.000100"
|
||||
assert m.text == "what are your opening hours?" # mention stripped
|
||||
assert m.external_message_id == "Ev1"
|
||||
|
||||
|
||||
def test_parse_mention_reply_keeps_thread(adapter):
|
||||
payload = make_event({**MENTION_EVENT["event"], "thread_ts": "1719990000.000001"})
|
||||
m = adapter.parse_inbound(payload)[0]
|
||||
assert m.external_conversation_id == "C9:1719990000.000001"
|
||||
|
||||
|
||||
def test_parse_dm_uses_channel_as_conversation(adapter):
|
||||
m = adapter.parse_inbound(DM_EVENT)[0]
|
||||
assert m.external_conversation_id == "D7"
|
||||
assert m.text == "hello there"
|
||||
|
||||
|
||||
def test_bot_and_subtype_events_ignored(adapter):
|
||||
assert adapter.parse_inbound(make_event({**DM_EVENT["event"], "bot_id": "B1"})) == []
|
||||
assert adapter.parse_inbound(make_event({**DM_EVENT["event"], "subtype": "message_changed"})) == []
|
||||
assert adapter.parse_inbound(make_event({"type": "reaction_added"})) == []
|
||||
|
||||
|
||||
def test_split_conversation():
|
||||
assert SlackAdapter._split_conversation("C9:123.456") == ("C9", "123.456")
|
||||
assert SlackAdapter._split_conversation("D7") == ("D7", None)
|
||||
|
||||
|
||||
def test_format_outbound_mrkdwn(adapter):
|
||||
assert adapter.format_outbound("**bold** text") == "*bold* text"
|
||||
|
||||
|
||||
class TestLifecycleEvents:
|
||||
"""app_uninstalled / tokens_revoked must drop the workspace's credentials,
|
||||
but only when it's really our bot token that died."""
|
||||
|
||||
@pytest.fixture
|
||||
def repo(self):
|
||||
repo = MagicMock()
|
||||
repo.get_by_external_id.return_value = MagicMock(name="account")
|
||||
repo.delete.return_value = True
|
||||
return repo
|
||||
|
||||
def test_uninstall_deletes_account(self, repo):
|
||||
_handle_lifecycle_event(make_event({"type": "app_uninstalled"}), repo)
|
||||
repo.get_by_external_id.assert_called_once_with("slack", "T111")
|
||||
repo.delete.assert_called_once()
|
||||
|
||||
def test_bot_token_revoked_deletes_account(self, repo):
|
||||
payload = make_event({"type": "tokens_revoked", "tokens": {"bot": ["B123"]}})
|
||||
_handle_lifecycle_event(payload, repo)
|
||||
repo.delete.assert_called_once()
|
||||
|
||||
def test_user_token_revoke_keeps_account(self, repo):
|
||||
"""We only ever store a bot token — a user-token revoke leaves the
|
||||
workspace working, so deleting it would disconnect a healthy install."""
|
||||
payload = make_event({"type": "tokens_revoked", "tokens": {"oauth": ["U123"]}})
|
||||
_handle_lifecycle_event(payload, repo)
|
||||
repo.delete.assert_not_called()
|
||||
|
||||
def test_unknown_team_is_noop(self, repo):
|
||||
repo.get_by_external_id.return_value = None
|
||||
_handle_lifecycle_event(make_event({"type": "app_uninstalled"}), repo)
|
||||
repo.delete.assert_not_called()
|
||||
|
||||
def test_missing_team_id_is_noop(self, repo):
|
||||
payload = make_event({"type": "app_uninstalled"}, team_id="")
|
||||
_handle_lifecycle_event(payload, repo)
|
||||
repo.get_by_external_id.assert_not_called()
|
||||
repo.delete.assert_not_called()
|
||||
|
||||
def test_failed_delete_does_not_raise(self, repo):
|
||||
"""delete() reports failure by returning False; Slack still needs a 200."""
|
||||
repo.delete.return_value = False
|
||||
_handle_lifecycle_event(make_event({"type": "app_uninstalled"}), repo)
|
||||
repo.delete.assert_called_once()
|
||||
|
||||
def test_lifecycle_events_are_not_messages(self, adapter):
|
||||
"""They must stay out of the message path — parse_inbound yields nothing,
|
||||
which is why the route resolves the team off the envelope instead."""
|
||||
assert adapter.parse_inbound(make_event({"type": "app_uninstalled"})) == []
|
||||
assert adapter.parse_inbound(make_event({"type": "tokens_revoked"})) == []
|
||||
|
||||
|
||||
class TestSignature:
|
||||
def _sign(self, body: bytes, secret: str, ts: str) -> str:
|
||||
return "v0=" + hmac.new(secret.encode(), f"v0:{ts}:".encode() + body, hashlib.sha256).hexdigest()
|
||||
|
||||
def test_valid(self, monkeypatch):
|
||||
monkeypatch.setattr(settings, "SLACK_SIGNING_SECRET", "sek")
|
||||
ts = str(int(time.time()))
|
||||
body = b'{"type":"event_callback"}'
|
||||
headers = {"x-slack-request-timestamp": ts,
|
||||
"x-slack-signature": self._sign(body, "sek", ts)}
|
||||
assert verify_slack_signature(headers, body) is True
|
||||
|
||||
def test_wrong_secret_or_missing(self, monkeypatch):
|
||||
monkeypatch.setattr(settings, "SLACK_SIGNING_SECRET", "sek")
|
||||
ts = str(int(time.time()))
|
||||
body = b"{}"
|
||||
assert verify_slack_signature(
|
||||
{"x-slack-request-timestamp": ts,
|
||||
"x-slack-signature": self._sign(body, "other", ts)}, body) is False
|
||||
assert verify_slack_signature({}, body) is False
|
||||
|
||||
def test_replay_rejected(self, monkeypatch):
|
||||
monkeypatch.setattr(settings, "SLACK_SIGNING_SECRET", "sek")
|
||||
old_ts = str(int(time.time()) - 3600)
|
||||
body = b"{}"
|
||||
headers = {"x-slack-request-timestamp": old_ts,
|
||||
"x-slack-signature": self._sign(body, "sek", old_ts)}
|
||||
assert verify_slack_signature(headers, body) is False
|
||||
|
||||
def test_unconfigured_secret_fails_closed(self, monkeypatch):
|
||||
monkeypatch.setattr(settings, "SLACK_SIGNING_SECRET", "")
|
||||
assert verify_slack_signature({"x-slack-request-timestamp": "1",
|
||||
"x-slack-signature": "v0=x"}, b"{}") is False
|
||||
|
||||
|
||||
class TestSlackProfileAndTyping:
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_profile_resolves_real_name(self, adapter, monkeypatch):
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
acc = MagicMock()
|
||||
monkeypatch.setattr(adapter, "_access_token", lambda a: "tok")
|
||||
monkeypatch.setattr("app.channels.slack.slack_api", AsyncMock(return_value={
|
||||
"ok": True, "user": {"real_name": "Ada Lovelace",
|
||||
"profile": {"display_name": "ada", "email": "ada@acme.com"}}}))
|
||||
prof = await adapter.fetch_profile(acc, "U09UPKP7")
|
||||
assert prof["name"] == "Ada Lovelace"
|
||||
assert prof["email"] == "ada@acme.com"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_profile_handles_error(self, adapter, monkeypatch):
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
monkeypatch.setattr(adapter, "_access_token", lambda a: "tok")
|
||||
monkeypatch.setattr("app.channels.slack.slack_api",
|
||||
AsyncMock(return_value={"ok": False, "error": "missing_scope"}))
|
||||
assert await adapter.fetch_profile(MagicMock(), "U0") == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_typing_placeholder_then_edit(self, adapter, monkeypatch):
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
import app.channels.slack as slk
|
||||
acc = MagicMock(); acc.id = "acc1"
|
||||
conv = MagicMock(external_conversation_id="D7")
|
||||
monkeypatch.setattr(adapter, "_access_token", lambda a: "tok")
|
||||
calls = []
|
||||
async def fake_api(method, token, payload):
|
||||
calls.append((method, payload))
|
||||
if method == "chat.postMessage":
|
||||
return {"ok": True, "ts": "111.222"}
|
||||
if method == "chat.update":
|
||||
return {"ok": True, "ts": payload["ts"]}
|
||||
return {"ok": False}
|
||||
monkeypatch.setattr(slk, "slack_api", fake_api)
|
||||
|
||||
await adapter.send_typing(acc, conv) # posts placeholder
|
||||
result = await adapter.send_text(acc, conv, "Here is your answer") # edits it
|
||||
assert result.ok and result.external_message_id == "111.222"
|
||||
methods = [m for m, _ in calls]
|
||||
assert methods == ["chat.postMessage", "chat.update"] # not a second post
|
||||
# placeholder consumed
|
||||
assert slk._typing_placeholders.get(("acc1", "D7")) is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_placeholder_deleted_when_update_fails(self, adapter, monkeypatch):
|
||||
"""When chat.update fails, the stale '_typing…_' is deleted and a fresh
|
||||
message is posted — no dangling placeholder beside the reply."""
|
||||
from unittest.mock import MagicMock
|
||||
import app.channels.slack as slk
|
||||
acc = MagicMock(); acc.id = "acc9"
|
||||
conv = MagicMock(external_conversation_id="D9")
|
||||
monkeypatch.setattr(adapter, "_access_token", lambda a: "tok")
|
||||
calls = []
|
||||
async def fake_api(method, token, payload):
|
||||
calls.append((method, payload))
|
||||
if method == "chat.postMessage":
|
||||
return {"ok": True, "ts": "555.666"}
|
||||
if method == "chat.update":
|
||||
return {"ok": False, "error": "cant_update_message"}
|
||||
return {"ok": True}
|
||||
monkeypatch.setattr(slk, "slack_api", fake_api)
|
||||
|
||||
await adapter.send_typing(acc, conv)
|
||||
result = await adapter.send_text(acc, conv, "The answer")
|
||||
methods = [m for m, _ in calls]
|
||||
assert methods == ["chat.postMessage", "chat.update", "chat.delete", "chat.postMessage"]
|
||||
assert result.ok
|
||||
assert slk._typing_placeholders.get(("acc9", "D9")) is None
|
||||
|
||||
def test_prune_placeholders_drops_stale(self, monkeypatch):
|
||||
import app.channels.slack as slk
|
||||
slk._typing_placeholders.clear()
|
||||
slk._typing_placeholders[("a", "c1")] = ("1.1", 1000.0)
|
||||
slk._typing_placeholders[("a", "c2")] = ("2.2", 1000.0 + slk._PLACEHOLDER_TTL_SECONDS + 1)
|
||||
slk._prune_placeholders(1000.0 + slk._PLACEHOLDER_TTL_SECONDS + 2)
|
||||
assert ("a", "c1") not in slk._typing_placeholders # stale, pruned
|
||||
assert ("a", "c2") in slk._typing_placeholders # fresh, kept
|
||||
slk._typing_placeholders.clear()
|
||||
|
||||
|
||||
def test_parse_dm_with_thread_keys_by_thread(adapter):
|
||||
"""Assistant-sidebar messages are DMs carrying the assistant thread ts — they
|
||||
key by thread so the reply posts back into that thread."""
|
||||
ev = make_event({"type": "message", "channel_type": "im", "user": "U42",
|
||||
"text": "hi", "channel": "D7", "ts": "1720000002.0003",
|
||||
"thread_ts": "1719999999.0001"})
|
||||
m = adapter.parse_inbound(ev)[0]
|
||||
assert m.external_conversation_id == "D7:1719999999.0001"
|
||||
|
||||
|
||||
DASH = "https://app.replypier.site"
|
||||
|
||||
|
||||
class TestHomeView:
|
||||
def test_intro_and_dashboard_link_always_present(self):
|
||||
import json
|
||||
blob = json.dumps(build_home_view(None, DASH))
|
||||
assert "AI customer support" in blob # what it does, up top
|
||||
assert DASH in blob # dashboard link
|
||||
assert "How to use me" in blob
|
||||
|
||||
def test_no_agent_shows_connect_prompt(self):
|
||||
import json
|
||||
v = build_home_view(None, DASH)
|
||||
assert v["type"] == "home"
|
||||
assert "No agent connected" in json.dumps(v)
|
||||
|
||||
def test_connected_agent_card_and_truncation(self):
|
||||
import json
|
||||
v = build_home_view({"name": "Support", "is_active": True,
|
||||
"instruction": "x" * 300, "photo_url": None}, DASH)
|
||||
blob = json.dumps(v)
|
||||
assert "Connected agent" in blob and "Support" in blob
|
||||
section_texts = [b["text"]["text"] for b in v["blocks"] if b.get("type") == "section"]
|
||||
long_text = next(t for t in section_texts if t.startswith("x"))
|
||||
assert long_text.endswith("…") and len(long_text) <= 250
|
||||
|
||||
|
||||
class TestAppSurfaceDispatch:
|
||||
"""Webhook fan-out: the right event schedules the right background handler."""
|
||||
|
||||
def _repo(self, active=True):
|
||||
account = MagicMock(); account.id = "acc1"; account.is_active = active
|
||||
repo = MagicMock(); repo.get_by_external_id.return_value = account
|
||||
return repo
|
||||
|
||||
def test_messages_tab_open_schedules_welcome(self):
|
||||
bg = MagicMock()
|
||||
slack_webhook._dispatch_app_surface_event(
|
||||
{"type": "app_home_opened", "user": "U1", "channel": "D1", "tab": "messages"},
|
||||
"T1", self._repo(), bg)
|
||||
args = bg.add_task.call_args[0]
|
||||
assert args[0] is slack_webhook.deliver_home_welcome
|
||||
assert args[1:] == ("acc1", "U1", "D1")
|
||||
|
||||
def test_missing_tab_defaults_to_welcome(self):
|
||||
bg = MagicMock()
|
||||
slack_webhook._dispatch_app_surface_event(
|
||||
{"type": "app_home_opened", "user": "U1", "channel": "D1"}, "T1", self._repo(), bg)
|
||||
assert bg.add_task.call_args[0][0] is slack_webhook.deliver_home_welcome
|
||||
|
||||
def test_home_tab_open_schedules_home_publish(self):
|
||||
bg = MagicMock()
|
||||
slack_webhook._dispatch_app_surface_event(
|
||||
{"type": "app_home_opened", "user": "U1", "tab": "home"}, "T1", self._repo(), bg)
|
||||
args = bg.add_task.call_args[0]
|
||||
assert args[0] is slack_webhook.publish_agent_home and args[1:] == ("acc1", "U1")
|
||||
|
||||
def test_assistant_started_schedules_handler(self):
|
||||
bg = MagicMock()
|
||||
slack_webhook._dispatch_app_surface_event(
|
||||
{"type": "assistant_thread_started",
|
||||
"assistant_thread": {"channel_id": "D2", "thread_ts": "111.222"}},
|
||||
"T1", self._repo(), bg)
|
||||
args = bg.add_task.call_args[0]
|
||||
assert args[0] is slack_webhook.handle_assistant_thread_started
|
||||
assert args[1:] == ("acc1", "D2", "111.222")
|
||||
|
||||
def test_context_changed_is_noop(self):
|
||||
bg = MagicMock()
|
||||
slack_webhook._dispatch_app_surface_event(
|
||||
{"type": "assistant_thread_context_changed"}, "T1", self._repo(), bg)
|
||||
bg.add_task.assert_not_called()
|
||||
|
||||
def test_inactive_team_is_noop(self):
|
||||
bg = MagicMock()
|
||||
slack_webhook._dispatch_app_surface_event(
|
||||
{"type": "app_home_opened", "user": "U1", "tab": "messages"},
|
||||
"T1", self._repo(active=False), bg)
|
||||
bg.add_task.assert_not_called()
|
||||
|
||||
def test_missing_team_id_is_noop(self):
|
||||
bg = MagicMock()
|
||||
slack_webhook._dispatch_app_surface_event(
|
||||
{"type": "app_home_opened", "user": "U1"}, "", self._repo(), bg)
|
||||
bg.add_task.assert_not_called()
|
||||
|
||||
|
||||
class TestHomeWelcomeDelivery:
|
||||
def _patch_common(self, monkeypatch):
|
||||
db = MagicMock()
|
||||
monkeypatch.setattr(slack_events, "SessionLocal", lambda: db)
|
||||
account = MagicMock(); account.is_active = True
|
||||
repo = MagicMock(); repo.get_by_id.return_value = account
|
||||
monkeypatch.setattr(slack_events, "ChannelAccountRepository", lambda d: repo)
|
||||
monkeypatch.setattr(slack_events, "_assigned_agent", lambda d, a: None)
|
||||
monkeypatch.setattr(slack_events.SlackAdapter, "_access_token", staticmethod(lambda a: "tok"))
|
||||
api = AsyncMock()
|
||||
monkeypatch.setattr(slack_events, "slack_api", api)
|
||||
return api
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_welcome_sent_once(self, monkeypatch):
|
||||
api = self._patch_common(monkeypatch)
|
||||
redis = MagicMock()
|
||||
redis.set.side_effect = [True, None] # first claims the slot, second is taken
|
||||
monkeypatch.setattr(slack_events, "get_redis", lambda: redis)
|
||||
|
||||
await slack_events.deliver_home_welcome("acc1", "U1", "D1")
|
||||
await slack_events.deliver_home_welcome("acc1", "U1", "D1")
|
||||
|
||||
assert api.await_count == 1
|
||||
assert api.await_args[0][0] == "chat.postMessage"
|
||||
assert api.await_args[0][2]["channel"] == "D1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_channel_no_send(self, monkeypatch):
|
||||
api = self._patch_common(monkeypatch)
|
||||
monkeypatch.setattr(slack_events, "get_redis", lambda: None)
|
||||
await slack_events.deliver_home_welcome("acc1", "U1", "")
|
||||
api.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assistant_started_sets_status_prompts_and_welcome(self, monkeypatch):
|
||||
self._patch_common(monkeypatch)
|
||||
status = AsyncMock(); prompts = AsyncMock()
|
||||
monkeypatch.setattr(slack_events, "set_assistant_status", status)
|
||||
monkeypatch.setattr(slack_events, "set_suggested_prompts", prompts)
|
||||
api = AsyncMock()
|
||||
monkeypatch.setattr(slack_events, "slack_api", api)
|
||||
|
||||
await slack_events.handle_assistant_thread_started("acc1", "D2", "111.222")
|
||||
|
||||
status.assert_awaited_once()
|
||||
prompts.assert_awaited_once()
|
||||
api.assert_awaited_once()
|
||||
assert api.await_args[0][0] == "chat.postMessage"
|
||||
assert api.await_args[0][2].get("thread_ts") == "111.222"
|
||||
@@ -0,0 +1,172 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.channels.sms import get_provider, list_providers
|
||||
from app.channels.sms.base import SmsWebhookRequest
|
||||
from app.channels.sms.twilio import TwilioProvider
|
||||
|
||||
|
||||
def make_req(params=None, json_body=None, headers=None, url="https://x/api/v1/webhooks/sms/p/abc", raw=b""):
|
||||
pairs = list((params or {}).items())
|
||||
return SmsWebhookRequest(
|
||||
headers={k.lower(): v for k, v in (headers or {}).items()},
|
||||
params=params or {}, param_pairs=pairs, json_body=json_body,
|
||||
raw_body=raw or (json.dumps(json_body).encode() if json_body else b""), url=url)
|
||||
|
||||
|
||||
def account_with(creds: dict):
|
||||
from app.core.security import encrypt_api_key
|
||||
acc = MagicMock()
|
||||
acc.encrypted_credentials = encrypt_api_key(json.dumps(creds))
|
||||
acc.external_account_id = "+15550001111"
|
||||
return acc
|
||||
|
||||
|
||||
def test_all_providers_registered():
|
||||
names = {p.name for p in list_providers()}
|
||||
assert names == {"twilio", "vonage", "messagebird", "plivo", "brevo", "sns"}
|
||||
|
||||
|
||||
class TestTwilio:
|
||||
def test_parse(self):
|
||||
req = make_req(params={"From": "+44700", "To": "+1555", "Body": " hi ", "MessageSid": "SM1"})
|
||||
m = get_provider("twilio").parse_inbound(req, account_with({}))[0]
|
||||
assert m.external_conversation_id == "+44700" and m.text == "hi"
|
||||
# The sender IS the customer's number — declared so identity can use it
|
||||
assert m.profile["phone"] == "+44700"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_signature(self):
|
||||
url = "https://x/api/v1/webhooks/sms/twilio/abc"
|
||||
params = {"From": "+44", "Body": "hi", "MessageSid": "SM1"}
|
||||
payload = url + "".join(f"{k}{params[k]}" for k in sorted(params))
|
||||
sig = base64.b64encode(hmac.new(b"tok", payload.encode(), hashlib.sha1).digest()).decode()
|
||||
req = make_req(params=params, url=url, headers={"X-Twilio-Signature": sig})
|
||||
assert await get_provider("twilio").verify_webhook(req, account_with({"auth_token": "tok"})) is True
|
||||
bad = make_req(params=params, url=url, headers={"X-Twilio-Signature": "bad"})
|
||||
assert await get_provider("twilio").verify_webhook(bad, account_with({"auth_token": "tok"})) is False
|
||||
|
||||
|
||||
class TestVonage:
|
||||
def test_parse(self):
|
||||
req = make_req(params={"msisdn": "+44700", "to": "+1555", "text": "hello", "messageId": "v1"})
|
||||
m = get_provider("vonage").parse_inbound(req, account_with({}))[0]
|
||||
assert m.external_conversation_id == "+44700" and m.external_message_id == "v1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_signature_secret_accepts(self):
|
||||
req = make_req(params={"msisdn": "+44", "text": "x"})
|
||||
assert await get_provider("vonage").verify_webhook(req, account_with({})) is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hmac_signature(self):
|
||||
secret = "sig-sek"
|
||||
params = {"msisdn": "+44", "text": "hi", "to": "+1555"}
|
||||
parts = "".join(f"&{k}={params[k]}" for k in sorted(params))
|
||||
sig = hmac.new(secret.encode(), parts.encode(), hashlib.sha256).hexdigest().upper()
|
||||
req = make_req(params={**params, "sig": sig})
|
||||
assert await get_provider("vonage").verify_webhook(req, account_with({"signature_secret": secret})) is True
|
||||
bad = make_req(params={**params, "sig": "deadbeef"})
|
||||
assert await get_provider("vonage").verify_webhook(bad, account_with({"signature_secret": secret})) is False
|
||||
|
||||
|
||||
class TestMessageBird:
|
||||
def test_parse_json(self):
|
||||
req = make_req(json_body={"originator": "+44700", "recipient": "+1555", "payload": "hey", "id": "mb1"})
|
||||
m = get_provider("messagebird").parse_inbound(req, account_with({}))[0]
|
||||
assert m.external_conversation_id == "+44700" and m.text == "hey"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_signature(self):
|
||||
key = "signkey"
|
||||
body = b'{"originator":"+44","payload":"hi"}'
|
||||
ts = "1720000000"
|
||||
url = "https://x/api/v1/webhooks/sms/messagebird/abc"
|
||||
body_hash = hashlib.sha256(body).digest()
|
||||
parts = ts.encode() + b"\n" + url.encode() + b"\n" + body_hash
|
||||
sig = base64.b64encode(hmac.new(key.encode(), parts, hashlib.sha256).digest()).decode()
|
||||
req = make_req(json_body=json.loads(body), url=url, raw=body,
|
||||
headers={"MessageBird-Request-Timestamp": ts, "MessageBird-Signature": sig})
|
||||
assert await get_provider("messagebird").verify_webhook(req, account_with({"signing_key": key})) is True
|
||||
|
||||
|
||||
class TestPlivo:
|
||||
def test_parse(self):
|
||||
req = make_req(params={"From": "+44700", "To": "+1555", "Text": "yo", "MessageUUID": "pl1"})
|
||||
m = get_provider("plivo").parse_inbound(req, account_with({}))[0]
|
||||
assert m.external_conversation_id == "+44700" and m.external_message_id == "pl1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_signature_v3(self):
|
||||
token, nonce = "authtok", "n123"
|
||||
url = "https://x/api/v1/webhooks/sms/plivo/abc"
|
||||
sig = base64.b64encode(hmac.new(token.encode(), (url + nonce).encode(), hashlib.sha256).digest()).decode()
|
||||
req = make_req(url=url, headers={"X-Plivo-Signature-V3": sig, "X-Plivo-Signature-V3-Nonce": nonce})
|
||||
assert await get_provider("plivo").verify_webhook(req, account_with({"auth_token": token})) is True
|
||||
|
||||
|
||||
class TestBrevo:
|
||||
def test_parse(self):
|
||||
req = make_req(json_body={"from": "+44700", "to": "+1555", "text": "hi there"})
|
||||
m = get_provider("brevo").parse_inbound(req, account_with({}))[0]
|
||||
assert m.text == "hi there"
|
||||
|
||||
|
||||
class TestSns:
|
||||
def test_parse_notification(self):
|
||||
sms = {"originationNumber": "+44700", "destinationNumber": "+1555", "messageBody": "sns hi"}
|
||||
req = make_req(json_body={"Type": "Notification", "MessageId": "sns1", "Message": json.dumps(sms)})
|
||||
m = get_provider("sns").parse_inbound(req, account_with({}))[0]
|
||||
assert m.external_conversation_id == "+44700" and m.text == "sns hi"
|
||||
|
||||
def test_subscription_confirmation_not_a_message(self):
|
||||
req = make_req(json_body={"Type": "SubscriptionConfirmation", "SubscribeURL": "https://sns.x"})
|
||||
assert get_provider("sns").parse_inbound(req, account_with({})) == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_sns_cert_host_rejected(self):
|
||||
# An attacker-hosted cert on S3 (or anywhere off sns.<region>.amazonaws.com) is refused
|
||||
for host in ("https://evil.com/c.pem",
|
||||
"https://attacker.s3.amazonaws.com/c.pem",
|
||||
"https://sns.amazonaws.com/c.pem"): # no region segment
|
||||
req = make_req(json_body={"Type": "Notification", "SigningCertURL": host,
|
||||
"Signature": "x", "Message": "{}", "MessageId": "1",
|
||||
"Timestamp": "t", "TopicArn": "arn"})
|
||||
assert await get_provider("sns").verify_webhook(req, account_with({})) is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_valid_sns_host_bad_signature_rejected(self):
|
||||
req = make_req(json_body={"Type": "Notification",
|
||||
"SigningCertURL": "https://sns.us-east-1.amazonaws.com/c.pem",
|
||||
"Signature": base64.b64encode(b"bad").decode(),
|
||||
"Message": "{}", "MessageId": "1", "Timestamp": "t", "TopicArn": "arn"})
|
||||
# Valid host but signature verification fails (no real cert)
|
||||
assert await get_provider("sns").verify_webhook(req, account_with({})) is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_topic_arn_mismatch_rejected(self):
|
||||
req = make_req(json_body={"Type": "Notification", "TopicArn": "arn:other",
|
||||
"SigningCertURL": "https://sns.us-east-1.amazonaws.com/c.pem", "Signature": "x"})
|
||||
assert await get_provider("sns").verify_webhook(
|
||||
req, account_with({"topic_arn": "arn:mine"})) is False
|
||||
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
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.
|
||||
|
||||
The SMTP connection settings every sender shares: an inbox's own credentials,
|
||||
or the platform account (which local development points at Mailpit).
|
||||
"""
|
||||
|
||||
import smtplib
|
||||
|
||||
import pytest
|
||||
|
||||
from app.channels import email as email_channel
|
||||
from app.channels.email import inbox_smtp_config, open_smtp, platform_smtp_config
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def platform(monkeypatch):
|
||||
def configure(port, username="", password=""):
|
||||
monkeypatch.setattr(settings, "SMTP_SERVER", "smtp.example.com")
|
||||
monkeypatch.setattr(settings, "SMTP_PORT", port)
|
||||
monkeypatch.setattr(settings, "SMTP_USERNAME", username)
|
||||
monkeypatch.setattr(settings, "SMTP_PASSWORD", password)
|
||||
return configure
|
||||
|
||||
|
||||
@pytest.mark.parametrize("port,use_ssl", [(587, False), (465, True), ("1025", False)])
|
||||
def test_platform_config(platform, port, use_ssl):
|
||||
platform(port, "relay-user", "relay-pass")
|
||||
assert platform_smtp_config("noreply@example.com") == {
|
||||
"host": "smtp.example.com", "port": int(port), "username": "relay-user",
|
||||
"password": "relay-pass", "from_email": "noreply@example.com", "use_ssl": use_ssl,
|
||||
}
|
||||
|
||||
|
||||
def test_inbox_config_defaults():
|
||||
cfg = inbox_smtp_config({"smtp_host": "smtp.acme.com"}, "support@acme.com")
|
||||
assert cfg["port"] == 587 and cfg["use_ssl"] is False
|
||||
assert cfg["from_email"] == "support@acme.com"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("port,explicit,expected", [(465, False, False), (587, True, True), (465, None, True)])
|
||||
def test_inbox_explicit_ssl_wins(port, explicit, expected):
|
||||
creds = {"smtp_host": "smtp.acme.com", "smtp_port": port, "smtp_use_ssl": explicit}
|
||||
assert inbox_smtp_config(creds, "support@acme.com")["use_ssl"] is expected
|
||||
|
||||
|
||||
class FakeSMTP:
|
||||
def __init__(self, host, port, timeout=None):
|
||||
self.calls = []
|
||||
|
||||
def starttls(self):
|
||||
raise smtplib.SMTPNotSupportedError("STARTTLS extension not supported by server.")
|
||||
|
||||
def login(self, username, password):
|
||||
self.calls.append(("login", username))
|
||||
|
||||
|
||||
def test_platform_without_username_skips_login(platform, monkeypatch):
|
||||
"""Mailpit: no STARTTLS offered, no credentials configured."""
|
||||
platform(1025)
|
||||
monkeypatch.setattr(email_channel.smtplib, "SMTP", FakeSMTP)
|
||||
assert open_smtp(platform_smtp_config("noreply@example.com")).calls == []
|
||||
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from app.channels import get_adapter
|
||||
from app.channels.telegram import TelegramAdapter, MAX_MESSAGE_LENGTH
|
||||
|
||||
|
||||
USER_MESSAGE_UPDATE = {
|
||||
"update_id": 1,
|
||||
"message": {
|
||||
"message_id": 42,
|
||||
"from": {"id": 111, "is_bot": False, "first_name": "Ada", "last_name": "Lovelace", "username": "ada"},
|
||||
"chat": {"id": 222, "type": "private"},
|
||||
"date": 1720000000,
|
||||
"text": "hello there",
|
||||
},
|
||||
}
|
||||
|
||||
BOT_MESSAGE_UPDATE = {
|
||||
"update_id": 2,
|
||||
"message": {
|
||||
"message_id": 43,
|
||||
"from": {"id": 999, "is_bot": True, "first_name": "Bot"},
|
||||
"chat": {"id": 222, "type": "private"},
|
||||
"date": 1720000001,
|
||||
"text": "echo",
|
||||
},
|
||||
}
|
||||
|
||||
MEDIA_ONLY_UPDATE = {
|
||||
"update_id": 3,
|
||||
"message": {
|
||||
"message_id": 44,
|
||||
"from": {"id": 111, "is_bot": False, "first_name": "Ada"},
|
||||
"chat": {"id": 222, "type": "private"},
|
||||
"date": 1720000002,
|
||||
"photo": [{"file_id": "abc"}],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def adapter():
|
||||
return TelegramAdapter()
|
||||
|
||||
|
||||
def test_registry_resolves_telegram():
|
||||
assert isinstance(get_adapter("telegram"), TelegramAdapter)
|
||||
assert get_adapter("web") is None
|
||||
|
||||
|
||||
def test_parse_user_message(adapter):
|
||||
messages = adapter.parse_inbound(USER_MESSAGE_UPDATE)
|
||||
assert len(messages) == 1
|
||||
msg = messages[0]
|
||||
assert msg.external_conversation_id == "222"
|
||||
assert msg.external_user_id == "111"
|
||||
assert msg.external_message_id == "42"
|
||||
assert msg.text == "hello there"
|
||||
assert msg.profile["name"] == "Ada Lovelace"
|
||||
|
||||
|
||||
def test_parse_ignores_bot_messages(adapter):
|
||||
assert adapter.parse_inbound(BOT_MESSAGE_UPDATE) == []
|
||||
|
||||
|
||||
def test_parse_ignores_media_only_messages(adapter):
|
||||
assert adapter.parse_inbound(MEDIA_ONLY_UPDATE) == []
|
||||
|
||||
|
||||
def test_parse_ignores_non_message_updates(adapter):
|
||||
assert adapter.parse_inbound({"update_id": 4, "edited_message": {}}) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_webhook_accepts_matching_secret(adapter):
|
||||
account = MagicMock(webhook_secret="s3cret")
|
||||
assert await adapter.verify_webhook(
|
||||
{"x-telegram-bot-api-secret-token": "s3cret"}, b"{}", account) is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_webhook_rejects_bad_or_missing_secret(adapter):
|
||||
account = MagicMock(webhook_secret="s3cret")
|
||||
assert await adapter.verify_webhook(
|
||||
{"x-telegram-bot-api-secret-token": "wrong"}, b"{}", account) is False
|
||||
assert await adapter.verify_webhook({}, b"{}", account) is False
|
||||
assert await adapter.verify_webhook(
|
||||
{"x-telegram-bot-api-secret-token": "s3cret"}, b"{}", None) is False
|
||||
|
||||
|
||||
def test_format_outbound_enforces_length_cap(adapter):
|
||||
assert adapter.format_outbound("x" * (MAX_MESSAGE_LENGTH + 100)) == "x" * MAX_MESSAGE_LENGTH
|
||||
assert adapter.format_outbound("short **markdown**") == "short **markdown**"
|
||||
Reference in new issue
Block a user