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
This commit is contained in:
@@ -2,20 +2,23 @@
|
|||||||
Channel Gateway — FastAPI application.
|
Channel Gateway — FastAPI application.
|
||||||
|
|
||||||
Mounts the slack-bolt AsyncApp as a sub-application at /slack/events.
|
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
|
Port: 8001
|
||||||
|
|
||||||
Endpoints:
|
Endpoints:
|
||||||
POST /slack/events — Slack Events API webhook (handled by slack-bolt)
|
POST /slack/events — Slack Events API webhook (handled by slack-bolt)
|
||||||
GET /health — Health check
|
GET /whatsapp/webhook — WhatsApp hub challenge verification
|
||||||
|
POST /whatsapp/webhook — WhatsApp inbound message webhook
|
||||||
|
GET /health — Health check
|
||||||
|
|
||||||
Startup sequence:
|
Startup sequence:
|
||||||
1. Create Redis connection
|
1. Create Redis connection
|
||||||
2. Create slack-bolt AsyncApp (signing_secret=...)
|
2. Create slack-bolt AsyncApp (signing_secret=...)
|
||||||
3. Register Slack event handlers
|
3. Register Slack event handlers
|
||||||
4. Mount slack-bolt request handler at /slack/events
|
4. Mount slack-bolt request handler at /slack/events
|
||||||
5. Expose /health
|
5. Include WhatsApp router
|
||||||
|
6. Expose /health
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
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 slack_bolt.async_app import AsyncApp
|
||||||
|
|
||||||
from gateway.channels.slack import register_slack_handlers
|
from gateway.channels.slack import register_slack_handlers
|
||||||
|
from gateway.channels.whatsapp import whatsapp_router
|
||||||
from shared.config import settings
|
from shared.config import settings
|
||||||
from shared.db import async_session_factory
|
from shared.db import async_session_factory
|
||||||
|
|
||||||
@@ -79,6 +83,11 @@ register_slack_handlers(
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
slack_handler = AsyncSlackRequestHandler(slack_app)
|
slack_handler = AsyncSlackRequestHandler(slack_app)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Register channel routers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
app.include_router(whatsapp_router)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Routes
|
# Routes
|
||||||
|
|||||||
146
tests/unit/test_whatsapp_scoping.py
Normal file
146
tests/unit/test_whatsapp_scoping.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user