Skip to content
Open
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
226a18e
Add Meta Cloud API messaging provider for WhatsApp
SmittieC Mar 5, 2026
8808a3d
Resolve WhatsApp phone number ID via Meta Cloud API
SmittieC Mar 5, 2026
c2506b0
Use phone_number_id as from identifier for Meta Cloud API WhatsApp ch…
SmittieC Mar 5, 2026
12ff881
Add Meta Cloud API WhatsApp webhook integration
SmittieC Mar 5, 2026
2f6506f
Add X-Hub-Signature-256 verification for Meta Cloud API webhooks
SmittieC Mar 5, 2026
45520d1
Verify Meta webhook signature once using the first channel's app_secret
SmittieC Mar 5, 2026
959106f
Remove unused phone_number_id field from MetaCloudAPIMessage
SmittieC Mar 5, 2026
a3fb00a
Fix typo: mesaging_provider -> messaging_provider
SmittieC Mar 5, 2026
9bec7c0
Harden Meta Cloud API webhook and refactor helpers
SmittieC Mar 5, 2026
35f7005
Query MessagingProvider directly in verify_webhook instead of through…
SmittieC Mar 5, 2026
58322c8
Split long messages in MetaCloudAPIService to respect WhatsApp's 4096…
SmittieC Mar 5, 2026
02f330e
Use verify_token hash for efficient DB lookup in Meta webhook verific…
SmittieC Mar 5, 2026
05b60c2
Refactor meta_webhook to module-level functions and add missing tests
SmittieC Mar 5, 2026
ee17335
Address PR #2975 review comments
SmittieC Mar 5, 2026
d822a95
Merge branch 'main' into cs/whatsapp
SmittieC Mar 5, 2026
c6f252d
Merge migrations and fix conflict
SmittieC Mar 6, 2026
86e698f
Add developer docs for Meta Cloud API messaging provider configuration
SmittieC Mar 6, 2026
4d1aa14
Refactor Meta Cloud API webhook to class-based view
SmittieC Mar 6, 2026
2714d88
Add example message structure in extract_message_values docstring
SmittieC Mar 6, 2026
2e893fe
Link to example instead
SmittieC Mar 6, 2026
e09de8e
Fix url
SmittieC Mar 6, 2026
de26e8a
Update apps/channels/meta_webhook.py
SmittieC Mar 9, 2026
4a5ca39
fix: address PR #2975 review comments
SmittieC Mar 9, 2026
694d11b
Add logging to Meta Cloud API webhook post handler
SmittieC Mar 9, 2026
8fa0ace
Fix flaky test_chatbot_table_redirect_url by clearing cached team slug
SmittieC Mar 10, 2026
cc7853b
fix: route Meta webhook messages to their own channel by phone_number_id
SmittieC Mar 13, 2026
75b1220
fix: reject Meta webhook payloads spanning multiple messaging providers
SmittieC Mar 13, 2026
36331f2
Merge branch 'main' into cs/whatsapp
SmittieC Mar 13, 2026
aed1d25
Don't log phone number ids
SmittieC Mar 13, 2026
33ba093
Resolve migration number conflicts
SmittieC Mar 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/channels/datamodels.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 20 additions & 1 deletion apps/channels/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,14 +235,33 @@ def clean_number(self):
number_obj = phonenumbers.parse(self.cleaned_data["number"])
number = phonenumbers.format_number(number_obj, phonenumbers.PhoneNumberFormat.E164)
service = self.messaging_provider.get_messaging_service()
if not service.is_valid_number(number):
if self.messaging_provider.type == MessagingProviderType.meta_cloud_api:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can't this be moved into MessagingService.is_valid_number

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4a5ca39. Added is_valid_number to MetaCloudAPIService which wraps the phone number ID lookup. The form's clean method now calls service.is_valid_number(number) uniformly for all providers — no provider-type check needed for validation.

try:
phone_number_id = service.get_phone_number_id(number)
except Exception:
raise forms.ValidationError(
"Could not validate this number with Meta right now. Please try again."
) from None
if not phone_number_id:
raise forms.ValidationError(
f"{number} was not found in the WhatsApp Business Account. "
"Please verify the number is registered with your business."
)
self._phone_number_id = phone_number_id
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels hacky - what about doing basic phone number validation here (make sure it's a correctly formatted phone number) and then moving the service level check to the clean method and putting the phone_number_id into form.cleaned_data.

I think that would follow normal Django patterns better and you won't need to rely on a temporary field on the form.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4a5ca39. clean_number now only does phone number format validation. The service-level check (is_valid_number) moved to clean, and phone_number_id goes into cleaned_data directly — no more temp attribute or post_save override.

elif 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

def post_save(self, channel: ExperimentChannel):
super().post_save(channel)
if hasattr(self, "_phone_number_id"):
channel.extra_data["phone_number_id"] = self._phone_number_id
channel.save(update_fields=["extra_data"])


class SureAdhereChannelForm(WebhookUrlFormBase):
sureadhere_tenant_id = forms.CharField(
Expand Down
60 changes: 60 additions & 0 deletions apps/channels/meta_webhook.py
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 2 additions & 0 deletions apps/channels/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
27 changes: 25 additions & 2 deletions apps/channels/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -174,11 +181,27 @@ 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):
return ExperimentChannel.objects.filter(
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, phone_number_id: str, message_data: dict):
message = MetaCloudAPIMessage.parse(message_data)
experiment_channel = get_experiment_channel(
ChannelPlatform.WHATSAPP,
extra_data__phone_number_id=phone_number_id,
messaging_provider__type=MessagingProviderType.meta_cloud_api,
)
if not experiment_channel:
log.info("No experiment channel found for incoming Meta Cloud API message")
return
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's better to handle this in the view and pass through the channel ID in the task.

This also allows passing through the team slug in the task which is useful for debugging.

It also means we don't ever have to fire off a task if there is no channel.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4a5ca39. The view now passes channel_id and team_slug to the task instead of phone_number_id. The task looks up by ID directly — no redundant filter query, and tasks are never fired for missing channels.

channel = WhatsappChannel(experiment_channel.experiment.default_version, experiment_channel)
update_taskbadger_data(self, channel, message)
channel.new_user_message(message)
68 changes: 68 additions & 0 deletions apps/channels/tests/test_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,74 @@ 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}"

# Simulate what ChannelFormWrapper.save() does: it creates the channel with
# extra_data set to the extra form's cleaned_data, then calls post_save.
channel = ExperimentChannelFactory.create(
team=experiment.team,
experiment=experiment,
platform=ChannelPlatform.WHATSAPP,
messaging_provider=provider,
extra_data={"number": form.cleaned_data["number"]},
)
form.post_save(channel)

channel.refresh_from_db()
assert channel.extra_data["phone_number_id"] == "12345"
assert channel.extra_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):
Expand Down
Loading
Loading