Files
replypier/backend/tests/channels/test_channel_quick_replies.py
T

257 lines
12 KiB
Python
Raw Normal View History

"""
Copyright 2024-2026 ChatterMate
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