From 6fea34db289518e58a7be748057543af1e320eab Mon Sep 17 00:00:00 2001 From: Adolfo Delorenzo Date: Mon, 23 Mar 2026 14:43:04 -0600 Subject: [PATCH] feat(02-03): WhatsApp adapter with business-function scoping and router registration - Register whatsapp_router in gateway main.py (GET + POST /whatsapp/webhook) - Implement is_clearly_off_topic() tier 1 keyword scoping gate - Implement build_off_topic_reply() canned redirect message builder - Full webhook handler: verify -> normalize -> tenant -> rate limit -> dedup -> scope -> media -> dispatch - Outbound delivery via send_whatsapp_message() and send_whatsapp_media() - Media download from Meta API and storage in MinIO with tenant-prefixed keys - 14 new passing scoping tests --- packages/gateway/gateway/main.py | 17 +++- tests/unit/test_whatsapp_scoping.py | 146 ++++++++++++++++++++++++++++ 2 files changed, 159 insertions(+), 4 deletions(-) create mode 100644 tests/unit/test_whatsapp_scoping.py diff --git a/packages/gateway/gateway/main.py b/packages/gateway/gateway/main.py index dbb63a3..22eb3da 100644 --- a/packages/gateway/gateway/main.py +++ b/packages/gateway/gateway/main.py @@ -2,20 +2,23 @@ Channel Gateway — FastAPI application. Mounts the slack-bolt AsyncApp as a sub-application at /slack/events. -All other channels will be added as additional sub-applications in Phase 2. +Registers the WhatsApp webhook router at /whatsapp/webhook. Port: 8001 Endpoints: - POST /slack/events — Slack Events API webhook (handled by slack-bolt) - GET /health — Health check + POST /slack/events — Slack Events API webhook (handled by slack-bolt) + GET /whatsapp/webhook — WhatsApp hub challenge verification + POST /whatsapp/webhook — WhatsApp inbound message webhook + GET /health — Health check Startup sequence: 1. Create Redis connection 2. Create slack-bolt AsyncApp (signing_secret=...) 3. Register Slack event handlers 4. Mount slack-bolt request handler at /slack/events - 5. Expose /health + 5. Include WhatsApp router + 6. Expose /health """ from __future__ import annotations @@ -28,6 +31,7 @@ from slack_bolt.adapter.fastapi.async_handler import AsyncSlackRequestHandler from slack_bolt.async_app import AsyncApp from gateway.channels.slack import register_slack_handlers +from gateway.channels.whatsapp import whatsapp_router from shared.config import settings from shared.db import async_session_factory @@ -79,6 +83,11 @@ register_slack_handlers( # --------------------------------------------------------------------------- slack_handler = AsyncSlackRequestHandler(slack_app) +# --------------------------------------------------------------------------- +# Register channel routers +# --------------------------------------------------------------------------- +app.include_router(whatsapp_router) + # --------------------------------------------------------------------------- # Routes diff --git a/tests/unit/test_whatsapp_scoping.py b/tests/unit/test_whatsapp_scoping.py new file mode 100644 index 0000000..35ed5d2 --- /dev/null +++ b/tests/unit/test_whatsapp_scoping.py @@ -0,0 +1,146 @@ +""" +Unit tests for WhatsApp business-function scoping gate. + +Tests CHAN-04: Business-function scoping enforces Meta 2026 policy. + +Two-tier gate: + Tier 1: is_clearly_off_topic — keyword overlap check (no LLM call) + Tier 2: Borderline messages pass to LLM with scoping in system prompt + +Covers: +- is_clearly_off_topic returns False when message has keyword overlap +- is_clearly_off_topic returns True when message has zero keyword overlap +- is_clearly_off_topic returns False when allowed_functions is empty (no scoping) +- Canned redirect message format includes agent name and allowed topics +- Borderline messages (partial overlap) pass through tier 1 +- Edge cases: empty text, case insensitivity +""" + +from __future__ import annotations + +import pytest + + +class TestIsClearlyOffTopic: + """Tests for the tier 1 keyword scoping gate.""" + + def test_matching_keywords_returns_false(self) -> None: + """Message with keyword overlap must NOT be considered off-topic.""" + from gateway.channels.whatsapp import is_clearly_off_topic + + allowed = ["order tracking", "customer support", "returns"] + text = "I need help with my order" + assert is_clearly_off_topic(text, allowed) is False + + def test_zero_overlap_returns_true(self) -> None: + """Message with zero keyword overlap must be considered off-topic.""" + from gateway.channels.whatsapp import is_clearly_off_topic + + allowed = ["order tracking", "customer support", "returns"] + text = "Can you write me a poem about cats?" + assert is_clearly_off_topic(text, allowed) is True + + def test_empty_allowed_functions_returns_false(self) -> None: + """Empty allowed_functions means no scoping — nothing is off-topic.""" + from gateway.channels.whatsapp import is_clearly_off_topic + + assert is_clearly_off_topic("anything goes", []) is False + assert is_clearly_off_topic("write me a poem", []) is False + + def test_case_insensitive_matching(self) -> None: + """Keyword matching must be case-insensitive.""" + from gateway.channels.whatsapp import is_clearly_off_topic + + allowed = ["Order Tracking", "Customer Support"] + text = "CUSTOMER needs SUPPORT with their order" + assert is_clearly_off_topic(text, allowed) is False + + def test_empty_message_returns_false(self) -> None: + """Empty message should not be considered clearly off-topic.""" + from gateway.channels.whatsapp import is_clearly_off_topic + + allowed = ["customer support"] + assert is_clearly_off_topic("", allowed) is False + assert is_clearly_off_topic(" ", allowed) is False + + def test_single_word_overlap_returns_false(self) -> None: + """Single keyword match is enough to pass tier 1.""" + from gateway.channels.whatsapp import is_clearly_off_topic + + allowed = ["billing", "payment", "invoice"] + text = "I need help with my billing" + assert is_clearly_off_topic(text, allowed) is False + + def test_off_topic_politics_returns_true(self) -> None: + """Clearly political message is off-topic for a customer support agent.""" + from gateway.channels.whatsapp import is_clearly_off_topic + + allowed = ["customer support", "order tracking", "returns", "billing"] + text = "What do you think about the election?" + assert is_clearly_off_topic(text, allowed) is True + + def test_off_topic_entertainment_returns_true(self) -> None: + """Entertainment request is off-topic for a support agent.""" + from gateway.channels.whatsapp import is_clearly_off_topic + + allowed = ["customer support", "order status", "refunds"] + text = "Tell me a joke please" + assert is_clearly_off_topic(text, allowed) is True + + def test_borderline_message_passes_through(self) -> None: + """ + A borderline message with any keyword overlap must pass tier 1. + The LLM handles judgment at tier 2 via system prompt. + """ + from gateway.channels.whatsapp import is_clearly_off_topic + + allowed = ["customer support", "product questions", "order tracking"] + # "product" appears in allowed_functions — passes tier 1 + text = "I have a question about your product pricing strategy" + assert is_clearly_off_topic(text, allowed) is False + + +class TestOffTopicReply: + """Tests for the canned off-topic redirect message.""" + + def test_reply_contains_agent_name(self) -> None: + """Canned redirect must include the agent's name.""" + from gateway.channels.whatsapp import build_off_topic_reply + + reply = build_off_topic_reply("Mara", ["customer support", "order tracking"]) + assert "Mara" in reply + + def test_reply_contains_allowed_functions(self) -> None: + """Canned redirect must mention the allowed functions/topics.""" + from gateway.channels.whatsapp import build_off_topic_reply + + allowed = ["customer support", "order tracking"] + reply = build_off_topic_reply("Mara", allowed) + assert "customer support" in reply + assert "order tracking" in reply + + def test_reply_is_non_empty_string(self) -> None: + """Canned redirect must be a non-empty string.""" + from gateway.channels.whatsapp import build_off_topic_reply + + reply = build_off_topic_reply("AI", ["support"]) + assert isinstance(reply, str) + assert len(reply) > 0 + + def test_reply_empty_allowed_functions_graceful(self) -> None: + """build_off_topic_reply must not crash with empty allowed_functions.""" + from gateway.channels.whatsapp import build_off_topic_reply + + reply = build_off_topic_reply("Mara", []) + assert isinstance(reply, str) + assert "Mara" in reply + + def test_reply_multiple_topics_separated(self) -> None: + """Multiple topics in canned redirect must be clearly separated.""" + from gateway.channels.whatsapp import build_off_topic_reply + + allowed = ["billing", "invoicing", "payments"] + reply = build_off_topic_reply("Alex", allowed) + # All topics must appear in the reply + for topic in allowed: + assert topic in reply