diff --git a/apps/channels/datamodels.py b/apps/channels/datamodels.py index 677854dfb4..46bb657870 100644 --- a/apps/channels/datamodels.py +++ b/apps/channels/datamodels.py @@ -197,6 +197,10 @@ def parse(message_data: dict): ) +# Meta Cloud API uses the same WhatsApp Business API message format as Turn.io +MetaCloudAPIMessage = TurnWhatsappMessage + + class FacebookMessage(BaseMessage): """ A wrapper class for user messages coming from Facebook diff --git a/apps/channels/forms.py b/apps/channels/forms.py index ec9112df24..3e2c82d770 100644 --- a/apps/channels/forms.py +++ b/apps/channels/forms.py @@ -233,15 +233,32 @@ class WhatsappChannelForm(WebhookUrlFormBase): def clean_number(self): try: number_obj = phonenumbers.parse(self.cleaned_data["number"]) - number = phonenumbers.format_number(number_obj, phonenumbers.PhoneNumberFormat.E164) - service = self.messaging_provider.get_messaging_service() + return phonenumbers.format_number(number_obj, phonenumbers.PhoneNumberFormat.E164) + except phonenumbers.NumberParseException: + raise forms.ValidationError("Enter a valid phone number (e.g. +12125552368).") from None + + def clean(self): + cleaned_data = super().clean() + number = cleaned_data.get("number") + if not number or not self.messaging_provider: + return cleaned_data + + service = self.messaging_provider.get_messaging_service() + try: if not service.is_valid_number(number): self.warning_message = ( f"{number} was not found at the provider. Please make sure it is there before proceeding" ) - return number - except phonenumbers.NumberParseException: - raise forms.ValidationError("Enter a valid phone number (e.g. +12125552368).") from None + except ValueError as e: + self.add_error("number", str(e)) + return cleaned_data + except Exception: + self.add_error("number", "Could not validate this number right now. Please try again.") + return cleaned_data + + if self.messaging_provider.type == MessagingProviderType.meta_cloud_api: + cleaned_data["phone_number_id"] = service.get_phone_number_id() + return cleaned_data class SureAdhereChannelForm(WebhookUrlFormBase): diff --git a/apps/channels/meta_webhook.py b/apps/channels/meta_webhook.py new file mode 100644 index 0000000000..1caa8e2391 --- /dev/null +++ b/apps/channels/meta_webhook.py @@ -0,0 +1,60 @@ +import hashlib +import hmac + +from django.http import HttpResponse, HttpResponseBadRequest +from django.utils.crypto import constant_time_compare + +from apps.service_providers.models import MessagingProvider, MessagingProviderType + + +def extract_message_values(data: dict) -> list[dict]: + """Extract value dicts that contain messages from Meta webhook payload. + + See https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/create-webhook-endpoint/ + + See https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/overview for an + example of the payload + """ + + values = [] + for entry in data.get("entry", []): + for change in entry.get("changes", []): + value = change.get("value", {}) + if value.get("messages") and value.get("metadata", {}).get("phone_number_id"): + values.append(value) + return values + + +def verify_webhook(request) -> HttpResponse: + """Handle the Meta webhook GET verification handshake.""" + mode = request.GET.get("hub.mode") + token = request.GET.get("hub.verify_token") + challenge = request.GET.get("hub.challenge") + + if mode != "subscribe" or not token or not challenge: + return HttpResponseBadRequest("Verification failed.") + + token_hash = hashlib.sha256(token.encode()).hexdigest() + exists = MessagingProvider.objects.filter( + type=MessagingProviderType.meta_cloud_api, + extra_data__verify_token_hash=token_hash, + ).exists() + + if exists: + return HttpResponse(challenge, content_type="text/plain") + + return HttpResponseBadRequest("Verification failed.") + + +def verify_signature(payload: bytes, signature_header: str, app_secret: str) -> bool: + """Verify the X-Hub-Signature-256 header from Meta webhooks.""" + if not signature_header.startswith("sha256=") or not app_secret: + return False + + expected_signature = signature_header.removeprefix("sha256=") + computed = hmac.new( + app_secret.encode(), + payload, + hashlib.sha256, + ).hexdigest() + return constant_time_compare(computed, expected_signature) diff --git a/apps/channels/models.py b/apps/channels/models.py index 220518051c..3680392e0d 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -241,6 +241,8 @@ def webhook_url(self) -> str: uri = reverse("channels:new_twilio_message") elif provider_type == MessagingProviderType.turnio: uri = reverse("channels:new_turn_message", kwargs={"experiment_id": self.experiment.public_id}) + elif provider_type == MessagingProviderType.meta_cloud_api: + uri = reverse("channels:new_meta_cloud_api_message") elif provider_type == MessagingProviderType.sureadhere: uri = reverse( "channels:new_sureadhere_message", diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index 71205a13f2..ca6de885d6 100644 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -7,7 +7,14 @@ from twilio.request_validator import RequestValidator from apps.channels.clients.connect_client import CommCareConnectClient, Message -from apps.channels.datamodels import BaseMessage, SureAdhereMessage, TelegramMessage, TurnWhatsappMessage, TwilioMessage +from apps.channels.datamodels import ( + BaseMessage, + MetaCloudAPIMessage, + SureAdhereMessage, + TelegramMessage, + TurnWhatsappMessage, + TwilioMessage, +) from apps.channels.models import ChannelPlatform, ExperimentChannel from apps.chat.channels import ( ApiChannel, @@ -174,7 +181,7 @@ def handle_commcare_connect_message(self, experiment_id: int, participant_data_i def get_experiment_channel(platform, **query_kwargs): query = get_experiment_channel_base_query(platform, **query_kwargs) - return query.select_related("experiment", "team").first() + return query.select_related("experiment", "team", "messaging_provider").first() def get_experiment_channel_base_query(platform, **query_kwargs): @@ -182,3 +189,22 @@ def get_experiment_channel_base_query(platform, **query_kwargs): platform=platform, **query_kwargs, ).filter(experiment__is_archived=False) + + +@shared_task(bind=True, base=TaskbadgerTask, ignore_result=True) +def handle_meta_cloud_api_message(self, channel_id: int, team_slug: str, message_data: dict): + message = MetaCloudAPIMessage.parse(message_data) + experiment_channel = ( + ExperimentChannel.objects.filter( + id=channel_id, + experiment__is_archived=False, + ) + .select_related("experiment", "team", "messaging_provider") + .first() + ) + if not experiment_channel: + log.info("No experiment channel found for channel_id=%s team=%s", channel_id, team_slug) + return + channel = WhatsappChannel(experiment_channel.experiment.default_version, experiment_channel) + update_taskbadger_data(self, channel, message) + channel.new_user_message(message) diff --git a/apps/channels/tests/test_forms.py b/apps/channels/tests/test_forms.py index 87373cdffc..59cdd7b1d4 100644 --- a/apps/channels/tests/test_forms.py +++ b/apps/channels/tests/test_forms.py @@ -81,6 +81,64 @@ def test_whatsapp_form_checks_number( ) +@pytest.mark.django_db() +@patch("apps.channels.forms.ExtraFormBase.messaging_provider", new_callable=PropertyMock) +@patch("apps.service_providers.messaging_service.httpx.get") +def test_whatsapp_form_meta_cloud_api_resolves_phone_number_id(mock_httpx_get, messaging_provider, experiment): + """Test that the phone number ID is fetched from Meta API and stored in extra_data""" + import httpx + + mock_httpx_get.return_value = httpx.Response( + 200, + json={ + "data": [ + {"id": "12345", "display_phone_number": "+1 (212) 555-2368"}, + ] + }, + request=httpx.Request("GET", "https://test"), + ) + provider = MessagingProviderFactory.create( + type=MessagingProviderType.meta_cloud_api, + config={"access_token": "test_token", "business_id": "biz_123"}, + ) + messaging_provider.return_value = provider + + form = WhatsappChannelForm( + experiment=experiment, data={"number": "+12125552368", "messaging_provider": provider.id} + ) + assert form.is_valid(), f"Form errors: {form.errors}" + + # ChannelFormWrapper.save() passes extra_form.cleaned_data as channel.extra_data, + # so phone_number_id should be in cleaned_data directly. + assert form.cleaned_data["phone_number_id"] == "12345" + assert form.cleaned_data["number"] == "+12125552368" + + +@pytest.mark.django_db() +@patch("apps.channels.forms.ExtraFormBase.messaging_provider", new_callable=PropertyMock) +@patch("apps.service_providers.messaging_service.httpx.get") +def test_whatsapp_form_meta_cloud_api_rejects_unknown_number(mock_httpx_get, messaging_provider, experiment): + """Test that form validation fails when the phone number is not found in the Meta Business Account""" + import httpx + + mock_httpx_get.return_value = httpx.Response( + 200, + json={"data": []}, + request=httpx.Request("GET", "https://test"), + ) + provider = MessagingProviderFactory.create( + type=MessagingProviderType.meta_cloud_api, + config={"access_token": "test_token", "business_id": "biz_123"}, + ) + messaging_provider.return_value = provider + + form = WhatsappChannelForm( + experiment=experiment, data={"number": "+12125552368", "messaging_provider": provider.id} + ) + assert not form.is_valid() + assert "was not found in the WhatsApp Business Account" in form.errors["number"][0] + + # Slack channel keyword uniqueness tests @pytest.mark.django_db() def test_slack_channel_new_with_keywords_succeeds(team_with_users, experiment): diff --git a/apps/channels/tests/test_meta_cloud_api_webhook.py b/apps/channels/tests/test_meta_cloud_api_webhook.py new file mode 100644 index 0000000000..930a500667 --- /dev/null +++ b/apps/channels/tests/test_meta_cloud_api_webhook.py @@ -0,0 +1,355 @@ +import hashlib +import hmac +import json +from unittest.mock import patch + +import pytest +from django.test import RequestFactory + +from apps.channels import meta_webhook +from apps.channels.models import ChannelPlatform +from apps.channels.views import MetaCloudAPIWebhookView +from apps.service_providers.models import MessagingProviderType +from apps.utils.factories.channels import ExperimentChannelFactory +from apps.utils.factories.service_provider_factories import MessagingProviderFactory + +APP_SECRET = "test_app_secret" +VERIFY_TOKEN = "test_verify_token" + + +def _make_signature(payload: bytes, secret: str = APP_SECRET) -> str: + sig = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest() + return f"sha256={sig}" + + +def _meta_webhook_payload(phone_number_id="12345"): + return { + "object": "whatsapp_business_account", + "entry": [ + { + "id": "BIZ_ID", + "changes": [ + { + "value": { + "messaging_product": "whatsapp", + "metadata": { + "display_phone_number": "+15551234567", + "phone_number_id": phone_number_id, + }, + "contacts": [{"profile": {"name": "User"}, "wa_id": "27456897512"}], + "messages": [ + { + "from": "27456897512", + "id": "wamid.abc123", + "timestamp": "1706709716", + "text": {"body": "Hello"}, + "type": "text", + } + ], + }, + "field": "messages", + } + ], + } + ], + } + + +@pytest.fixture() +def meta_cloud_api_provider(): + return MessagingProviderFactory.create( + type=MessagingProviderType.meta_cloud_api, + config={ + "access_token": "test_token", + "business_id": "biz_123", + "app_secret": APP_SECRET, + "verify_token": VERIFY_TOKEN, + }, + extra_data={ + "verify_token_hash": hashlib.sha256(VERIFY_TOKEN.encode()).hexdigest(), + }, + ) + + +@pytest.fixture() +def meta_cloud_api_channel(meta_cloud_api_provider): + return ExperimentChannelFactory.create( + platform=ChannelPlatform.WHATSAPP, + messaging_provider=meta_cloud_api_provider, + experiment__team=meta_cloud_api_provider.team, + extra_data={"number": "+15551234567", "phone_number_id": "12345"}, + ) + + +class TestMetaCloudAPIWebhookVerifySignature: + def test_valid_signature(self): + payload = b'{"test": "data"}' + signature = _make_signature(payload) + assert meta_webhook.verify_signature(payload, signature, APP_SECRET) is True + + def test_invalid_signature(self): + payload = b'{"test": "data"}' + assert meta_webhook.verify_signature(payload, "sha256=invalid", APP_SECRET) is False + + def test_missing_sha256_prefix(self): + payload = b'{"test": "data"}' + sig = hmac.new(APP_SECRET.encode(), payload, hashlib.sha256).hexdigest() + assert meta_webhook.verify_signature(payload, sig, APP_SECRET) is False + + def test_empty_app_secret(self): + payload = b'{"test": "data"}' + signature = _make_signature(payload) + assert meta_webhook.verify_signature(payload, signature, "") is False + + +class TestMetaCloudAPIWebhookExtractMessageValues: + def test_extracts_message_values(self): + data = _meta_webhook_payload() + values = meta_webhook.extract_message_values(data) + assert len(values) == 1 + assert values[0]["metadata"]["phone_number_id"] == "12345" + assert values[0]["messages"][0]["text"]["body"] == "Hello" + + +@pytest.mark.django_db() +class TestMetaCloudAPIWebhookVerifyWebhook: + def test_valid_verification(self, meta_cloud_api_provider): + """Simulate Meta's webhook verification process.""" + factory = RequestFactory() + request = factory.get( + "/", + { + "hub.mode": "subscribe", + "hub.verify_token": VERIFY_TOKEN, + "hub.challenge": "challenge_string", + }, + ) + response = meta_webhook.verify_webhook(request) + assert response.status_code == 200 + assert response.content == b"challenge_string" + + def test_invalid_token(self, meta_cloud_api_provider): + factory = RequestFactory() + request = factory.get( + "/", + { + "hub.mode": "subscribe", + "hub.verify_token": "wrong_token", + "hub.challenge": "challenge_string", + }, + ) + response = meta_webhook.verify_webhook(request) + assert response.status_code == 400 + + +@pytest.mark.django_db() +class TestNewMetaCloudApiMessageGetVerification: + """Test the GET verification flow through the view endpoint.""" + + def test_valid_verification_via_view(self, meta_cloud_api_provider): + factory = RequestFactory() + request = factory.get( + "/", + { + "hub.mode": "subscribe", + "hub.verify_token": VERIFY_TOKEN, + "hub.challenge": "challenge_string", + }, + ) + response = MetaCloudAPIWebhookView.as_view()(request) + assert response.status_code == 200 + assert response.content == b"challenge_string" + + def test_invalid_token_via_view(self, meta_cloud_api_provider): + factory = RequestFactory() + request = factory.get( + "/", + { + "hub.mode": "subscribe", + "hub.verify_token": "wrong_token", + "hub.challenge": "challenge_string", + }, + ) + response = MetaCloudAPIWebhookView.as_view()(request) + assert response.status_code == 400 + + +@pytest.mark.django_db() +class TestNewMetaCloudApiMessage: + def _post(self, payload_dict, app_secret=APP_SECRET): + factory = RequestFactory() + body = json.dumps(payload_dict).encode() + signature = _make_signature(body, app_secret) + request = factory.post( + "/", + data=body, + content_type="application/json", + HTTP_X_HUB_SIGNATURE_256=signature, + ) + return MetaCloudAPIWebhookView.as_view()(request) + + @patch("apps.channels.tasks.handle_meta_cloud_api_message.delay") + def test_valid_message_returns_200(self, mock_delay, meta_cloud_api_channel): + response = self._post(_meta_webhook_payload()) + assert response.status_code == 200 + mock_delay.assert_called_once() + + @patch("apps.channels.tasks.handle_meta_cloud_api_message.delay") + def test_task_dispatched_with_correct_args(self, mock_delay, meta_cloud_api_channel): + self._post(_meta_webhook_payload()) + mock_delay.assert_called_once_with( + channel_id=meta_cloud_api_channel.id, + team_slug=meta_cloud_api_channel.team.slug, + message_data=_meta_webhook_payload()["entry"][0]["changes"][0]["value"], + ) + + def test_invalid_signature_returns_200(self, meta_cloud_api_channel): + """Invalid signature returns 200 to prevent Meta from retrying.""" + factory = RequestFactory() + body = json.dumps(_meta_webhook_payload()).encode() + request = factory.post( + "/", + data=body, + content_type="application/json", + HTTP_X_HUB_SIGNATURE_256="sha256=invalid", + ) + response = MetaCloudAPIWebhookView.as_view()(request) + assert response.status_code == 200 + + def test_missing_signature_header_returns_200(self, meta_cloud_api_channel): + """Missing signature header returns 200 to prevent Meta from retrying.""" + factory = RequestFactory() + body = json.dumps(_meta_webhook_payload()).encode() + request = factory.post( + "/", + data=body, + content_type="application/json", + ) + response = MetaCloudAPIWebhookView.as_view()(request) + assert response.status_code == 200 + + @patch("apps.channels.tasks.handle_meta_cloud_api_message.delay") + def test_multi_phone_number_payload_routes_to_correct_channels( + self, mock_delay, meta_cloud_api_channel, meta_cloud_api_provider + ): + """Messages for different phone numbers must be routed to their own channels, not all to the first.""" + # Both channels share the same provider (same app, same app_secret) — only the phone number differs. + second_channel = ExperimentChannelFactory.create( + platform=ChannelPlatform.WHATSAPP, + messaging_provider=meta_cloud_api_provider, + experiment__team=meta_cloud_api_provider.team, + extra_data={"number": "+15559999999", "phone_number_id": "99999"}, + ) + + # Build a payload with two entries for two different phone numbers + payload = { + "object": "whatsapp_business_account", + "entry": [ + { + "id": "BIZ_ID", + "changes": [ + { + "value": _meta_webhook_payload("12345")["entry"][0]["changes"][0]["value"], + "field": "messages", + }, + { + "value": _meta_webhook_payload("99999")["entry"][0]["changes"][0]["value"], + "field": "messages", + }, + ], + } + ], + } + response = self._post(payload) + assert response.status_code == 200 + assert mock_delay.call_count == 2 + called_channel_ids = {call.kwargs["channel_id"] for call in mock_delay.call_args_list} + assert called_channel_ids == {meta_cloud_api_channel.id, second_channel.id} + + @patch("apps.channels.tasks.handle_meta_cloud_api_message.delay") + def test_unknown_phone_number_in_multi_payload_skipped(self, mock_delay, meta_cloud_api_channel): + """A value with an unknown phone_number_id is skipped; other values are still dispatched.""" + payload = { + "object": "whatsapp_business_account", + "entry": [ + { + "id": "BIZ_ID", + "changes": [ + { + "value": _meta_webhook_payload("12345")["entry"][0]["changes"][0]["value"], + "field": "messages", + }, + { + "value": _meta_webhook_payload("unknown_id")["entry"][0]["changes"][0]["value"], + "field": "messages", + }, + ], + } + ], + } + response = self._post(payload) + assert response.status_code == 200 + assert mock_delay.call_count == 1 + mock_delay.assert_called_once_with( + channel_id=meta_cloud_api_channel.id, + team_slug=meta_cloud_api_channel.team.slug, + message_data=_meta_webhook_payload("12345")["entry"][0]["changes"][0]["value"], + ) + + @patch("apps.channels.tasks.handle_meta_cloud_api_message.delay") + def test_cross_provider_payload_is_rejected(self, mock_delay, meta_cloud_api_channel): + """An attacker with their own legitimate channel cannot forge messages for a victim's + phone number by including it in a payload signed with their own app_secret.""" + attacker_secret = "attacker_app_secret" + attacker_provider = MessagingProviderFactory.create( + type=MessagingProviderType.meta_cloud_api, + config={ + "access_token": "attacker_token", + "business_id": "attacker_biz", + "app_secret": attacker_secret, + "verify_token": "attacker_verify", + }, + ) + ExperimentChannelFactory.create( + platform=ChannelPlatform.WHATSAPP, + messaging_provider=attacker_provider, + experiment__team=attacker_provider.team, + extra_data={"number": "+15550000001", "phone_number_id": "attacker_phone_id"}, + ) + + # Payload targeting BOTH attacker's phone_number_id and victim's phone_number_id, + # signed with the attacker's app_secret. + payload = { + "object": "whatsapp_business_account", + "entry": [ + { + "id": "BIZ_ID", + "changes": [ + { + "value": _meta_webhook_payload("attacker_phone_id")["entry"][0]["changes"][0]["value"], + "field": "messages", + }, + { + "value": _meta_webhook_payload("12345")["entry"][0]["changes"][0]["value"], + "field": "messages", + }, + ], + } + ], + } + response = self._post(payload, app_secret=attacker_secret) + assert response.status_code == 200 + mock_delay.assert_not_called() + + def test_invalid_json_returns_400(self): + factory = RequestFactory() + body = b"not json" + signature = _make_signature(body) + request = factory.post( + "/", + data=body, + content_type="application/json", + HTTP_X_HUB_SIGNATURE_256=signature, + ) + response = MetaCloudAPIWebhookView.as_view()(request) + assert response.status_code == 400 diff --git a/apps/channels/urls.py b/apps/channels/urls.py index 502e450727..f33090911c 100644 --- a/apps/channels/urls.py +++ b/apps/channels/urls.py @@ -15,6 +15,7 @@ name="new_sureadhere_message", ), path("whatsapp/turn//incoming_message", views.new_turn_message, name="new_turn_message"), + path("whatsapp/meta/incoming_message", views.MetaCloudAPIWebhookView.as_view(), name="new_meta_cloud_api_message"), path("api//incoming_message", views.NewApiMessageView.as_view(), name="new_api_message"), path( "api//v/incoming_message", diff --git a/apps/channels/views.py b/apps/channels/views.py index 738c32f7dd..93228daf79 100644 --- a/apps/channels/views.py +++ b/apps/channels/views.py @@ -16,6 +16,7 @@ ) from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse +from django.utils.decorators import method_decorator from django.views import View from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_POST @@ -28,7 +29,7 @@ from rest_framework.views import APIView from apps.api.permissions import verify_hmac -from apps.channels import tasks +from apps.channels import meta_webhook, tasks from apps.channels.datamodels import TwilioMessage from apps.channels.exceptions import ExperimentChannelException from apps.channels.forms import ChannelFormWrapper @@ -418,3 +419,92 @@ def delete_channel(request, team_slug, experiment_id: int, channel_id: int): "experiment": channel.experiment, }, ) + + +@method_decorator(waf_allow(WafRule.NoUserAgent_HEADER), name="dispatch") +@method_decorator(csrf_exempt, name="dispatch") +class MetaCloudAPIWebhookView(View): + def get(self, request): + log.debug("Meta Cloud API webhook verification request received") + return meta_webhook.verify_webhook(request) + + def post(self, request): + try: + data = json.loads(request.body) + except json.JSONDecodeError: + log.debug("Meta Cloud API webhook received invalid JSON") + return HttpResponseBadRequest("Invalid JSON.") + + # Meta webhooks include an "object" field indicating the source product. + # For the WhatsApp Business API it is always "whatsapp_business_account". + # See https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/overview + if data.get("object") != "whatsapp_business_account": + log.debug("Meta Cloud API webhook ignored: object=%s", data.get("object")) + return HttpResponse() + + try: + message_values = meta_webhook.extract_message_values(data) + if not message_values: + log.debug("Meta Cloud API webhook received payload with no messages") + return HttpResponse() + + except (KeyError, IndexError): + log.debug("Meta Cloud API webhook payload missing expected fields") + return HttpResponse() + + # A payload may contain values for different phone numbers (e.g. a Business Account with multiple numbers), + # so each value must be routed to its own channel. + unique_phone_ids = {v["metadata"]["phone_number_id"] for v in message_values} + channel_map = { + ch.extra_data["phone_number_id"]: ch + for ch in ExperimentChannel.objects.filter( + platform=ChannelPlatform.WHATSAPP, + extra_data__phone_number_id__in=list(unique_phone_ids), + messaging_provider__type=MessagingProviderType.meta_cloud_api, + ).select_related("experiment", "team", "messaging_provider") + } + + # Signature verification happens after the channel lookup because the app_secret + # needed to verify is stored in the channel's messaging provider (an encrypted field). + if not channel_map: + return HttpResponse() + + channels = list(channel_map.values()) + if not self._payload_has_valid_signature(channels, request_headers=request.headers, request_body=request.body): + log.warning("Meta Cloud API webhook signature verification failed for channel") + return HttpResponse() + + set_current_team(channels[0].team) + + for value in message_values: + phone_number_id = value["metadata"]["phone_number_id"] + ch = channel_map.get(phone_number_id) + if not ch: + continue + + tasks.handle_meta_cloud_api_message.delay( + channel_id=ch.id, + team_slug=ch.team.slug, + message_data=value, + ) + + return HttpResponse() + + def _payload_has_valid_signature( + self, channels: list[ExperimentChannel], request_headers: dict, request_body: dict + ) -> bool: + """Verify the X-Hub-Signature-256 of a Meta webhook payload. + + All channels in the payload must belong to the same messaging provider (i.e. share the + same Meta app and app_secret). Payloads that span multiple providers are rejected to + prevent an attacker with a legitimate channel from forging messages for a phone number + that belongs to a different Meta app by including it in a payload signed with their own + app_secret. + """ + if len({ch.messaging_provider_id for ch in channels}) > 1: + log.error("Meta Cloud API webhook payload spans multiple messaging providers") + return False + channel = channels[0] + app_secret = channel.messaging_provider.config.get("app_secret", "") + signature = request_headers.get("X-Hub-Signature-256", "") + return meta_webhook.verify_signature(request_body, signature, app_secret) diff --git a/apps/chat/channels.py b/apps/chat/channels.py index 7d160f2eac..026339e306 100644 --- a/apps/chat/channels.py +++ b/apps/chat/channels.py @@ -52,6 +52,7 @@ ) from apps.service_providers.llm_service.history_managers import ExperimentHistoryManager from apps.service_providers.llm_service.runnables import GenerationCancelled +from apps.service_providers.models import MessagingProviderType from apps.service_providers.speech_service import SynthesizedAudio from apps.service_providers.tracing import TraceInfo, TracingService from apps.service_providers.tracing.base import SpanNotificationConfig, TraceContext @@ -1154,34 +1155,38 @@ def supports_multimedia(self) -> bool: def supported_message_types(self): return self.messaging_service.supported_message_types + @property + def from_identifier(self) -> str: + """Returns the phone number ID for Meta Cloud API, or the phone number for other providers.""" + extra_data = self.experiment_channel.extra_data + if self.experiment_channel.messaging_provider.type == MessagingProviderType.meta_cloud_api: + phone_number_id = extra_data.get("phone_number_id") + if not phone_number_id: + raise ValueError("Meta Cloud API channel is missing phone_number_id in extra_data") + return phone_number_id + return extra_data["number"] + def echo_transcript(self, transcript: str): self._send_text_to_user_with_notification(f'I heard: "{transcript}"') def send_text_to_user(self, text: str): - from_number = self.experiment_channel.extra_data["number"] - to_number = self.participant_identifier - self.messaging_service.send_text_message( - message=text, from_=from_number, to=to_number, platform=ChannelPlatform.WHATSAPP + message=text, from_=self.from_identifier, to=self.participant_identifier, platform=ChannelPlatform.WHATSAPP ) def send_voice_to_user(self, synthetic_voice: SynthesizedAudio): - """ - Uploads the synthesized voice to AWS and send the public link to twilio - """ - from_number = self.experiment_channel.extra_data["number"] - to_number = self.participant_identifier - + """Uploads the synthesized voice to AWS and sends the public link to the messaging provider.""" self.messaging_service.send_voice_message( - synthetic_voice, from_=from_number, to=to_number, platform=ChannelPlatform.WHATSAPP + synthetic_voice, + from_=self.from_identifier, + to=self.participant_identifier, + platform=ChannelPlatform.WHATSAPP, ) def send_file_to_user(self, file: File): - from_number = self.experiment_channel.extra_data["number"] - to_number = self.participant_identifier self.messaging_service.send_file_to_user( - from_=from_number, - to=to_number, + from_=self.from_identifier, + to=self.participant_identifier, platform=ChannelPlatform.WHATSAPP, file=file, download_link=file.download_link(experiment_session_id=self.experiment_session.id), @@ -1223,9 +1228,7 @@ def echo_transcript(self, transcript: str): self._send_text_to_user_with_notification(f'I heard: "{transcript}"') def send_voice_to_user(self, synthetic_voice: SynthesizedAudio): - """ - Uploads the synthesized voice to AWS and send the public link to twilio - """ + """Uploads the synthesized voice to AWS and sends the public link to the messaging provider.""" from_ = self.experiment_channel.extra_data["page_id"] self.messaging_service.send_voice_message( synthetic_voice, from_=from_, to=self.participant_identifier, platform=ChannelPlatform.FACEBOOK diff --git a/apps/chatbots/tests/test_chatbot_tables.py b/apps/chatbots/tests/test_chatbot_tables.py index fa8b9dbb8e..4f6ad14083 100644 --- a/apps/chatbots/tests/test_chatbot_tables.py +++ b/apps/chatbots/tests/test_chatbot_tables.py @@ -2,6 +2,7 @@ from uuid import uuid4 import pytest +from django.core.cache import cache from django.urls import reverse from apps.chatbots.tables import ChatbotTable @@ -17,6 +18,9 @@ def test_chatbot_table_redirect_url(team_with_users): name="Redirect Test", description="Testing redirect URLs", owner=user, team=team, is_archived=False ) + # Clear cached team slugs to avoid stale entries from previous tests + cache.delete(f"team_slug:{team.id}") + table = ChatbotTable(Experiment.objects.filter(id=experiment.id)) row_attrs = list(table.rows)[0].attrs diff --git a/apps/service_providers/forms.py b/apps/service_providers/forms.py index 026a9cb8b6..277a380902 100644 --- a/apps/service_providers/forms.py +++ b/apps/service_providers/forms.py @@ -1,3 +1,5 @@ +import hashlib + from django import forms from django.core.validators import URLValidator from django.utils.translation import gettext_lazy as _ @@ -254,6 +256,29 @@ class SureAdhereMessagingConfigForm(ObfuscatingMixin, ProviderTypeConfigForm): ) +class MetaCloudAPIMessagingConfigForm(ObfuscatingMixin, ProviderTypeConfigForm): + obfuscate_fields = ["access_token", "app_secret", "verify_token"] + + business_id = forms.CharField(label=_("WhatsApp Business Account ID")) + access_token = forms.CharField(label=_("System User Access Token")) + app_secret = forms.CharField( + label=_("App Secret"), + help_text=_("Used to verify incoming webhook signatures (X-Hub-Signature-256)."), + ) + verify_token = forms.CharField( + label=_("Webhook Verify Token"), + help_text=_("Token used by Meta to verify the webhook URL. Must match the token configured in your Meta app."), + ) + + def save(self, instance): + instance = super().save(instance) + verify_token = self.cleaned_data["verify_token"] + instance.extra_data = { + "verify_token_hash": hashlib.sha256(verify_token.encode()).hexdigest(), + } + return instance + + class CommCareAuthConfigForm(ObfuscatingMixin, ProviderTypeConfigForm): obfuscate_fields = ["api_key"] diff --git a/apps/service_providers/messaging_service.py b/apps/service_providers/messaging_service.py index be4553d937..8cd9482bc0 100644 --- a/apps/service_providers/messaging_service.py +++ b/apps/service_providers/messaging_service.py @@ -7,6 +7,7 @@ import backoff import httpx +import phonenumbers import pydantic import requests from django.conf import settings @@ -335,6 +336,75 @@ def send_text_message(self, message: str, from_: str, to: str, platform: Channel response.raise_for_status() +class MetaCloudAPIService(MessagingService): + _type: ClassVar[str] = "meta_cloud_api" + supported_platforms: ClassVar[list] = [ChannelPlatform.WHATSAPP] + voice_replies_supported: ClassVar[bool] = False + supported_message_types = [MESSAGE_TYPES.TEXT] + + access_token: str + business_id: str + app_secret: str = "" + verify_token: str = "" + _phone_number_id: str | None = pydantic.PrivateAttr(default=None) + + META_API_BASE_URL: ClassVar[str] = "https://graph.facebook.com/v25.0" + META_API_TIMEOUT: ClassVar[int] = 30 + WHATSAPP_CHARACTER_LIMIT: ClassVar[int] = 4096 + + @property + def _headers(self) -> dict: + return { + "Authorization": f"Bearer {self.access_token}", + "Content-Type": "application/json", + } + + def is_valid_number(self, number: str) -> bool: + self._phone_number_id = self._fetch_phone_number_id(number) + if not self._phone_number_id: + raise ValueError( + f"{number} was not found in the WhatsApp Business Account. " + "Please verify the number is registered with your business." + ) + return True + + def get_phone_number_id(self) -> str | None: + """Return the phone number ID resolved by a prior `is_valid_number` call.""" + return self._phone_number_id + + def _fetch_phone_number_id(self, phone_number: str) -> str | None: + """Look up the phone number ID for the given E.164 phone number + using the WhatsApp Business Account Phone Number Management API.""" + url = f"{self.META_API_BASE_URL}/{self.business_id}/phone_numbers" + response = httpx.get( + url, headers=self._headers, params={"fields": "id,display_phone_number"}, timeout=self.META_API_TIMEOUT + ) + response.raise_for_status() + for entry in response.json().get("data", []): + display = entry.get("display_phone_number", "") + try: + parsed = phonenumbers.parse(display) + normalized = phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164) + except phonenumbers.NumberParseException: + continue + if normalized == phone_number: + return entry["id"] + return None + + def send_text_message(self, message: str, from_: str, to: str, platform: ChannelPlatform, **kwargs): + url = f"{self.META_API_BASE_URL}/{from_}/messages" + chunks = smart_split(message, chars_per_string=self.WHATSAPP_CHARACTER_LIMIT) + for chunk in chunks: + data = { + "messaging_product": "whatsapp", + "to": to, + "type": "text", + "text": {"body": chunk}, + } + response = httpx.post(url, headers=self._headers, json=data, timeout=self.META_API_TIMEOUT) + response.raise_for_status() + + class SlackService(MessagingService): _type: ClassVar[str] = "slack" supported_platforms: ClassVar[list] = [ChannelPlatform.SLACK] diff --git a/apps/service_providers/migrations/0045_alter_messagingprovider_type.py b/apps/service_providers/migrations/0045_alter_messagingprovider_type.py new file mode 100644 index 0000000000..f8ac116447 --- /dev/null +++ b/apps/service_providers/migrations/0045_alter_messagingprovider_type.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.11 on 2026-03-05 07:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('service_providers', '0044_update_openai_models'), + ] + + operations = [ + migrations.AlterField( + model_name='messagingprovider', + name='type', + field=models.CharField(choices=[('twilio', 'Twilio'), ('turnio', 'Turn.io'), ('sureadhere', 'SureAdhere'), ('slack', 'Slack'), ('meta_cloud_api', 'Meta Cloud API (WhatsApp)')], max_length=255), + ), + migrations.AddField( + model_name='messagingprovider', + name='extra_data', + field=models.JSONField(blank=True, default=dict), + ), + ] diff --git a/apps/service_providers/models.py b/apps/service_providers/models.py index 1ca64c037f..a39b4414f9 100644 --- a/apps/service_providers/models.py +++ b/apps/service_providers/models.py @@ -382,6 +382,7 @@ class MessagingProviderType(models.TextChoices): turnio = "turnio", _("Turn.io") sureadhere = "sureadhere", _("SureAdhere") slack = "slack", _("Slack") + meta_cloud_api = "meta_cloud_api", _("Meta Cloud API (WhatsApp)") @property def form_cls(self) -> type["ProviderTypeConfigForm"]: @@ -396,6 +397,8 @@ def form_cls(self) -> type["ProviderTypeConfigForm"]: return forms.SureAdhereMessagingConfigForm case MessagingProviderType.slack: return forms.SlackMessagingConfigForm + case MessagingProviderType.meta_cloud_api: + return forms.MetaCloudAPIMessagingConfigForm raise Exception(f"No config form configured for {self}") def get_messaging_service(self, config: dict) -> "messaging_service.MessagingService": @@ -410,6 +413,8 @@ def get_messaging_service(self, config: dict) -> "messaging_service.MessagingSer return messaging_service.SureAdhereService(**config) case MessagingProviderType.slack: return messaging_service.SlackService(**config) + case MessagingProviderType.meta_cloud_api: + return messaging_service.MetaCloudAPIService(**config) raise Exception(f"No messaging service configured for {self}") @staticmethod @@ -430,6 +435,7 @@ class MessagingProvider(BaseTeamModel, ProviderMixin): type = models.CharField(max_length=255, choices=MessagingProviderType.choices) name = models.CharField(max_length=255) config = encrypt(models.JSONField(default=dict)) + extra_data = models.JSONField(default=dict, blank=True) class Meta: ordering = ("type", "name") diff --git a/apps/service_providers/tests/test_messaging_providers.py b/apps/service_providers/tests/test_messaging_providers.py index 8ea7031018..4b039e13d2 100644 --- a/apps/service_providers/tests/test_messaging_providers.py +++ b/apps/service_providers/tests/test_messaging_providers.py @@ -1,7 +1,11 @@ +from unittest.mock import patch + +import httpx import pytest from pydantic import ValidationError from apps.channels.models import ChannelPlatform +from apps.service_providers.messaging_service import MetaCloudAPIService from apps.service_providers.models import MessagingProvider, MessagingProviderType @@ -40,7 +44,7 @@ def test_twilio_messaging_provider_error(config_key): @pytest.mark.parametrize( ("platform", "expected_provider_types"), [ - ("whatsapp", ["twilio", "turnio"]), + ("whatsapp", ["twilio", "turnio", "meta_cloud_api"]), ("telegram", []), ], ) @@ -59,6 +63,62 @@ def _test_messaging_provider_error(provider_type: MessagingProviderType, data): provider_type.get_messaging_service(data) +@pytest.fixture() +def meta_cloud_api_service(): + return MetaCloudAPIService(access_token="test_token", business_id="123456") + + +def _mock_phone_numbers_response(data): + return httpx.Response(200, json={"data": data}, request=httpx.Request("GET", "https://test")) + + +@pytest.mark.parametrize( + ("api_data", "lookup_number", "expected_id"), + [ + pytest.param( + [ + {"id": "111", "display_phone_number": "+1 (212) 555-2368"}, + {"id": "222", "display_phone_number": "+27 81 234 5678"}, + ], + "+12125552368", + "111", + id="formatted_number", + ), + pytest.param( + [{"id": "333", "display_phone_number": "+27812345678"}], + "+27812345678", + "333", + id="e164_number", + ), + pytest.param( + [{"id": "111", "display_phone_number": "+1 212 555 2368"}], + "+27812345678", + None, + id="no_match", + ), + pytest.param( + [], + "+12125552368", + None, + id="empty_response", + ), + pytest.param( + [ + {"id": "111", "display_phone_number": "not-a-number"}, + {"id": "222", "display_phone_number": "+27 81 234 5678"}, + ], + "+27812345678", + "222", + id="unparseable_number_skipped", + ), + ], +) +@patch("apps.service_providers.messaging_service.httpx.get") +def test_meta_cloud_api_get_phone_number_id(mock_get, meta_cloud_api_service, api_data, lookup_number, expected_id): + mock_get.return_value = _mock_phone_numbers_response(api_data) + assert meta_cloud_api_service._fetch_phone_number_id(lookup_number) == expected_id + + def _test_messaging_provider(team, provider_type: MessagingProviderType, data): form = provider_type.form_cls(team, data=data) assert form.is_valid() diff --git a/docs/developer_guides/meta_cloud_api_integration.md b/docs/developer_guides/meta_cloud_api_integration.md new file mode 100644 index 0000000000..b130df0c4a --- /dev/null +++ b/docs/developer_guides/meta_cloud_api_integration.md @@ -0,0 +1,80 @@ +# Meta Cloud API (WhatsApp) Integration + +This guide explains the configuration parameters required to set up a Meta Cloud API messaging provider for WhatsApp, and how each parameter is used throughout the system. + +## Provider Configuration + +When creating a Meta Cloud API messaging provider, four parameters are required: + +| Parameter | Label | Where it's used | +|-----------|-------|----------------| +| `business_id` | WhatsApp Business Account ID | Phone number validation during channel creation | +| `access_token` | System User Access Token | All Meta Graph API calls | +| `app_secret` | App Secret | Verifying incoming webhook payload signatures | +| `verify_token` | Webhook Verify Token | Meta's one-time webhook URL verification handshake | + +All parameters except `business_id` are stored as encrypted fields and obfuscated in the UI. + +## Parameter Details + +### `verify_token` — Webhook Verification + +When you configure a webhook URL in the Meta App Dashboard, Meta sends a **GET request** to verify that you own the endpoint. This request includes the `verify_token` you configured in Meta's dashboard as a query parameter (`hub.verify_token`). + +Our system matches this token by comparing a SHA-256 hash of the incoming token against a stored hash in the `MessagingProvider.extra_data` field. This avoids storing the raw token in a queryable column while still allowing efficient database lookups. If the hash matches, we respond with the `hub.challenge` value and Meta considers the webhook verified. + +**When it's used:** Once, during the initial webhook setup in the Meta App Dashboard. + + +### `app_secret` — Payload Signature Verification + +Every incoming POST webhook from Meta includes an `X-Hub-Signature-256` header containing an HMAC-SHA256 signature of the request body, signed with your app secret. We verify this signature on every incoming message to ensure the payload genuinely came from Meta and hasn't been tampered with. + +**When it's used:** On every incoming webhook POST request, after the channel is looked up (since the `app_secret` is stored in the channel's messaging provider config). + +### `business_id` — Phone Number Validation + +The `business_id` is your WhatsApp Business Account ID. During channel creation, when a user enters a phone number, we call the Meta Graph API's Phone Number Management endpoint to list all phone numbers registered under this business account: + +``` +GET https://graph.facebook.com/v25.0/{business_id}/phone_numbers +``` + +If a match is found, we store the corresponding `phone_number_id` in the channel's `extra_data`, which is then used in API calls when sending outbound messages. If no match is found, the form validation fails. + +**When it's used:** During channel creation (form validation in `WhatsappChannelForm.clean_number`). + +### `access_token` — API Authentication + +The `access_token` is a System User Access Token from your Meta Business account. It is included as a Bearer token in the `Authorization` header for all outgoing Meta Graph API calls, including: + +- **Phone number validation** — listing phone numbers under the business account (see `business_id` above) +- **Sending messages** — posting text messages to the WhatsApp Cloud API via `POST https://graph.facebook.com/v25.0/{phone_number_id}/messages` + +**When it's used:** Every outgoing API call to Meta. + +## How `phone_number_id` Ties It Together + +Meta's Cloud API identifies phone numbers by an internal `phone_number_id` rather than the phone number itself. This ID is: + +1. **Resolved at channel creation** — looked up via the `business_id` and `access_token` +2. **Stored in `ExperimentChannel.extra_data`** — persisted so it doesn't need to be re-fetched +3. **Used to route incoming webhooks** — the webhook payload includes the `phone_number_id` in `metadata`, which we match against the stored value to find the correct channel +4. **Used as the `from` identifier when sending messages** — the send endpoint is `/{phone_number_id}/messages` + +This differs from other WhatsApp providers (Twilio, Turn.io) which use the phone number directly as the identifier. The `WhatsappChannel.from_identifier` property abstracts this difference. + +## Webhook Architecture + +Unlike Turn.io (which uses per-experiment webhook URLs), the Meta Cloud API uses a **single global webhook endpoint**: + +``` +/channels/whatsapp/meta/incoming_message +``` + +This endpoint handles both: + +- **GET** requests for webhook verification (using `verify_token`) +- **POST** requests for incoming messages (verified with `app_secret`, routed by `phone_number_id`) + +All Meta Cloud API channels share this endpoint. Routing to the correct channel happens by matching the `phone_number_id` from the incoming payload against `ExperimentChannel.extra_data`. diff --git a/mkdocs.yml b/mkdocs.yml index e044c90e95..10da98b598 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -82,6 +82,7 @@ nav: - WAF Management: developer_guides/waf_management.md - Dynamic Filters: developer_guides/dynamic_filters.md - Slack Channel Integration: developer_guides/slack_channel_integration.md + - Meta Cloud API Integration: developer_guides/meta_cloud_api_integration.md - Index Managers: developer_guides/index_managers.md - Notifications: developer_guides/notifications.md - Testing: