Files

14 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
02-agent-features 03 execute 1
packages/gateway/gateway/channels/whatsapp.py
packages/gateway/gateway/normalize.py
packages/gateway/gateway/main.py
packages/shared/shared/models/message.py
packages/shared/shared/config.py
tests/unit/test_whatsapp_verify.py
tests/unit/test_whatsapp_normalize.py
tests/unit/test_whatsapp_scoping.py
true
CHAN-03
CHAN-04
truths artifacts key_links
A WhatsApp message to the AI employee produces a reply in the same WhatsApp conversation
Webhook signature is verified via HMAC-SHA256 on raw body bytes before any JSON parsing
Per-tenant phone number isolation — each tenant's WhatsApp connection uses its own phone_number_id
Clearly off-topic messages get a canned rejection without an LLM call (tier 1 allowlist gate)
Borderline messages are handled by the LLM with business-function scoping in the system prompt (tier 2)
Media attachments (images, documents) are downloaded, stored in MinIO, and passed to the orchestrator
path provides exports
packages/gateway/gateway/channels/whatsapp.py WhatsApp webhook handler, signature verification, message sending
whatsapp_router
path provides exports
packages/gateway/gateway/normalize.py normalize_whatsapp_event function alongside existing Slack normalizer
normalize_whatsapp_event
path provides contains
packages/shared/shared/models/message.py MediaAttachment model added to MessageContent class MediaAttachment
from to via pattern
packages/gateway/gateway/channels/whatsapp.py gateway/normalize.py normalize_whatsapp_event called after signature verification normalize_whatsapp_event
from to via pattern
packages/gateway/gateway/channels/whatsapp.py router/tenant.py resolve_tenant with phone_number_id as workspace_id resolve_tenant
from to via pattern
packages/gateway/gateway/channels/whatsapp.py Celery handle_message task handle_message.delay() after normalization handle_message.delay
Build the WhatsApp Business Cloud API adapter in the Channel Gateway: webhook verification, signature checking, message normalization to KonstructMessage, business-function scoping gate, media handling (download + MinIO storage), and outbound message delivery. Extend the shared message model with typed media attachments.

Purpose: Adds the second messaging channel, enabling SMBs to deploy their AI employee on WhatsApp -- the most common business communication channel globally. Output: WhatsApp adapter, media model extension, business-function scoping, passing tests.

Note: Outbound wiring in orchestrator tasks.py (channel-aware response routing) and multimodal LLM interpretation of media are handled in Plan 02-05 to avoid file conflicts with Plans 02-01 and 02-02 which also modify tasks.py.

<execution_context> @/home/adelorenzo/.claude/get-shit-done/workflows/execute-plan.md @/home/adelorenzo/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/02-agent-features/02-CONTEXT.md @.planning/phases/02-agent-features/02-RESEARCH.md

@packages/gateway/gateway/channels/slack.py @packages/gateway/gateway/normalize.py @packages/gateway/gateway/main.py @packages/shared/shared/models/message.py @packages/shared/shared/config.py @packages/router/router/tenant.py @packages/router/router/ratelimit.py @packages/router/router/idempotency.py

Channel adapter sequence (from Phase 1):

  1. Receive webhook -> verify signature
  2. Normalize to KonstructMessage
  3. resolve_tenant(workspace_id) -> tenant_id
  4. check_rate_limit(tenant_id, channel)
  5. is_duplicate(message_id) -> skip if True
  6. Post placeholder / typing indicator
  7. handle_message.delay(msg_payload)

From packages/shared/shared/models/message.py:

  • KonstructMessage: id, tenant_id, channel, channel_metadata, sender, content, timestamp, thread_id, reply_to, context
  • MessageContent: text, html, attachments (list[dict]), mentions (list[str])
  • SenderInfo: user_id, display_name, role
  • ChannelType: slack (needs 'whatsapp' added)

From packages/router/router/tenant.py:

  • resolve_tenant(workspace_id: str, session) -> str (tenant_id)
  • workspace_id for WhatsApp = phone_number_id from channel_connections

From packages/shared/shared/config.py:

  • Settings class with pydantic-settings
  • Need to add: whatsapp_app_secret, whatsapp_verify_token
Task 1: Media model extension, WhatsApp normalizer, and signature verification with tests packages/shared/shared/models/message.py, packages/gateway/gateway/normalize.py, packages/shared/shared/config.py, tests/unit/test_whatsapp_verify.py, tests/unit/test_whatsapp_normalize.py - MediaAttachment has media_type (image|document|audio|video), url, storage_key, mime_type, filename, size_bytes - MessageContent.media is a list[MediaAttachment] (new field, defaults to []) - ChannelType enum includes 'whatsapp' - normalize_whatsapp_event converts Meta webhook payload to KonstructMessage with correct field mapping - normalize_whatsapp_event extracts media attachments (image/document) into MediaAttachment objects - normalize_whatsapp_event sets channel='whatsapp', sender.user_id=wa_id, thread_id=wa_id (WhatsApp has no threads) - verify_whatsapp_signature raises HTTPException(403) when signature is invalid - verify_whatsapp_signature returns raw body bytes when signature is valid - verify_whatsapp_signature uses hmac.compare_digest for timing-safe comparison - WhatsApp webhook GET verification returns hub.challenge when token matches - WhatsApp webhook GET verification returns 403 when token doesn't match 1. Extend `packages/shared/shared/models/message.py`: - Add MediaType(StrEnum): IMAGE, DOCUMENT, AUDIO, VIDEO - Add MediaAttachment(BaseModel): media_type, url (str|None), storage_key (str|None), mime_type (str|None), filename (str|None), size_bytes (int|None) - Add `media: list[MediaAttachment] = []` field to MessageContent - Add 'whatsapp' to ChannelType enum
2. Extend `packages/shared/shared/config.py`:
   - Add whatsapp_app_secret: str = "" and whatsapp_verify_token: str = "" to Settings

3. Create `normalize_whatsapp_event()` in normalize.py:
   - Takes: parsed webhook JSON body (dict)
   - Extracts: entry[0].changes[0].value -- this is the Meta Cloud API v20.0 structure
   - Maps: messages[0].from -> sender.user_id, messages[0].text.body -> content.text
   - For media messages (type=image/document): extract media_id, set MediaAttachment with media_type and a placeholder URL (actual download happens in the adapter)
   - Sets channel='whatsapp', thread_id=sender_wa_id (WhatsApp conversations are per-phone-number, not threaded)
   - Sets channel_metadata with phone_number_id, message_id from webhook

4. Write tests:
   - test_whatsapp_verify.py: Valid signature passes, invalid signature raises 403, timing-safe comparison used
   - test_whatsapp_normalize.py: Text message normalizes correctly, image message normalizes with MediaAttachment, correct field mapping for sender/channel/metadata

Note: Use `hmac.new()` not `hmac.HMAC()` for signature verification. Read raw body via `await request.body()` BEFORE any JSON parsing (Pitfall 5 from research).
cd /home/adelorenzo/repos/konstruct && python -m pytest tests/unit/test_whatsapp_verify.py tests/unit/test_whatsapp_normalize.py -x -v - MediaAttachment model exists with typed media fields - MessageContent has media list field - WhatsApp events normalize to KonstructMessage with correct mapping - Signature verification is timing-safe and works on raw bytes - ChannelType includes 'whatsapp' Task 2: WhatsApp adapter with business-function scoping, media download/storage, and outbound delivery packages/gateway/gateway/channels/whatsapp.py, packages/gateway/gateway/main.py, tests/unit/test_whatsapp_scoping.py - is_clearly_off_topic returns True for messages with zero keyword overlap with allowed_functions - is_clearly_off_topic returns False for messages containing keywords from allowed_functions - Off-topic messages receive a canned redirect response mentioning the agent name and allowed topics - Borderline messages (not clearly off-topic) pass through to the LLM with scoping in system prompt - WhatsApp webhook POST processes messages through the full adapter sequence (verify -> normalize -> resolve_tenant -> rate_limit -> dedup -> scoping -> dispatch) - send_whatsapp_message sends text via Meta Cloud API POST to /v20.0/{phone_number_id}/messages - send_whatsapp_media sends image/document via Meta Cloud API with media_id or URL - Media download fetches from Meta API (GET /media/{media_id}) and stores to MinIO with tenant-prefixed key 1. Create `packages/gateway/gateway/channels/whatsapp.py`: - whatsapp_router = APIRouter() - GET /whatsapp/webhook -- verification handshake: check hub.mode=="subscribe" and hub.verify_token matches settings, return hub.challenge as PlainTextResponse - POST /whatsapp/webhook -- inbound message handler: a. Read raw body via request.body() BEFORE parsing b. Verify HMAC-SHA256 signature (X-Hub-Signature-256 header) c. Parse JSON from raw body d. Skip non-message events (status updates, read receipts -- check for messages key) e. Normalize via normalize_whatsapp_event() f. Resolve tenant via phone_number_id as workspace_id (same resolve_tenant function as Slack) g. Check rate limit (reuse existing check_rate_limit) h. Check idempotency (reuse is_duplicate/mark_processed) i. Business-function scoping check (see below) j. If media: download from Meta API, upload to MinIO with key {tenant_id}/{agent_id}/{message_id}/{filename}, update MediaAttachment.storage_key and .url (presigned URL) k. Dispatch handle_message.delay() with msg payload + extras (bot_token from channel_connections.config['access_token'], phone_number_id) - Always return 200 OK to Meta (even on errors -- Meta retries on non-200)
2. Business-function scoping (two-tier gate per user decision):
   - Tier 1: is_clearly_off_topic(text, allowed_functions) -- simple keyword overlap check. If zero overlap with any allowed function keywords, return True. Agent's allowed_functions come from Agent model (add `allowed_functions: list[str] = []` field if not present, or use agent.tools as proxy).
   - If clearly off-topic: send canned redirect via send_whatsapp_message: "{agent.name} is here to help with {', '.join(allowed_functions)}. How can I assist you with one of those?"
   - Tier 2: Borderline messages pass to the LLM. The scoping is enforced via the system prompt (which already contains the agent's role and persona). Add to system prompt builder: if channel == 'whatsapp', append "You only handle: {allowed_functions}. If a request is outside these areas, politely redirect the user."

3. Outbound message delivery (used by the adapter for direct responses like off-topic canned replies):
   - async send_whatsapp_message(phone_number_id, access_token, recipient_wa_id, text) -> None
   - POST to https://graph.facebook.com/v20.0/{phone_number_id}/messages with messaging_product="whatsapp", to=recipient_wa_id, type="text", text={"body": text}
   - async send_whatsapp_media(phone_number_id, access_token, recipient_wa_id, media_url, media_type) for outbound media

4. Install boto3 for MinIO: `uv add boto3` in gateway package. Use endpoint_url=settings.minio_endpoint for S3-compatible MinIO access.

5. Register whatsapp_router in gateway main.py: `app.include_router(whatsapp_router)`

6. Write test_whatsapp_scoping.py:
   - Test is_clearly_off_topic with matching keywords -> False
   - Test is_clearly_off_topic with zero overlap -> True
   - Test canned redirect message format includes agent name and allowed functions
   - Test borderline message passes through (not rejected by tier 1)

Note: The orchestrator-side wiring (channel-aware outbound routing in tasks.py) is deferred to Plan 02-05 to avoid file conflicts with Plans 02-01 and 02-02. The WhatsApp adapter can handle direct responses (off-topic canned replies, webhook verification) independently. LLM-generated responses routed back through WhatsApp will be wired in Plan 02-05.
cd /home/adelorenzo/repos/konstruct && python -m pytest tests/unit/test_whatsapp_verify.py tests/unit/test_whatsapp_normalize.py tests/unit/test_whatsapp_scoping.py -x -v - WhatsApp webhook handler processes inbound messages through full adapter sequence - Signature verification on raw body bytes before JSON parsing - Business-function scoping: tier 1 rejects clearly off-topic, tier 2 scopes via system prompt - Media downloaded from Meta API and stored in MinIO with tenant-prefixed keys - Outbound text and media messages sent via Meta Cloud API (for adapter-direct responses) - Gateway routes registered and running - Canned redirect includes agent name and allowed topics - All Phase 1 tests still pass: `pytest tests/ -x` - WhatsApp tests pass: `pytest tests/unit/test_whatsapp_verify.py tests/unit/test_whatsapp_normalize.py tests/unit/test_whatsapp_scoping.py -x` - Gateway starts without errors with new routes registered

<success_criteria>

  • WhatsApp messages are normalized to KonstructMessage and dispatched through the existing pipeline
  • Webhook signature verification prevents unauthorized requests
  • Business-function scoping enforces Meta 2026 policy (tier 1 keyword gate + tier 2 LLM scoping)
  • Media attachments are downloaded, stored in MinIO, and available for downstream processing
  • Per-tenant phone number isolation via phone_number_id in channel_connections </success_criteria>
After completion, create `.planning/phases/02-agent-features/02-03-SUMMARY.md`