feat(modules): 帮助中心、工单、工作流与多渠道接入

补齐 FAQ、帮助中心、工单、可视化工作流及 Telegram/WhatsApp/Slack 等渠道模块。
This commit is contained in:
WangShen007 committed 2026-10-17 22:05:00 +08:00
1 parent 69a71aabbb
commit e0baca15a9
225 files changed
+59337

No files matched your search

View File
Whitespace-only changes.
+131
View File
@@ -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
+147
View File
@@ -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**"