From aa05f7a37a2af5bd5ca104c7227761966fc1a3b5 Mon Sep 17 00:00:00 2001 From: Amogh M Aradhya Date: Tue, 31 Jan 2023 13:33:33 +0530 Subject: [PATCH 01/22] added whatsapp transport --- funnel/models/notification.py | 2 +- funnel/transports/base.py | 2 + funnel/transports/whatsapp.py | 110 +++++++++++++++++++++++++++++++++- funnel/views/notification.py | 28 ++++++++- 4 files changed, 139 insertions(+), 3 deletions(-) diff --git a/funnel/models/notification.py b/funnel/models/notification.py index 55984e113..3352492a7 100644 --- a/funnel/models/notification.py +++ b/funnel/models/notification.py @@ -1303,7 +1303,7 @@ def main_notification_preferences(self) -> NotificationPreferences: by_sms=True, by_webpush=False, by_telegram=False, - by_whatsapp=False, + by_whatsapp=True, ) db.session.add(main) return main diff --git a/funnel/transports/base.py b/funnel/transports/base.py index 335c271be..fc933b493 100644 --- a/funnel/transports/base.py +++ b/funnel/transports/base.py @@ -24,5 +24,7 @@ def init(): platform_transports['email'] = True if sms_init(): platform_transports['sms'] = True + if app.config.get('WHATSAPP_TOKEN'): + platform_transports['whatsapp'] = True # Other transports are not supported yet diff --git a/funnel/transports/whatsapp.py b/funnel/transports/whatsapp.py index 03f963f21..2113f4397 100644 --- a/funnel/transports/whatsapp.py +++ b/funnel/transports/whatsapp.py @@ -1,3 +1,111 @@ -"""Support functions for sending a WhatsApp message. Forthcoming.""" +"""Support functions for sending an Whatsapp messages.""" from __future__ import annotations + +from typing import Union + +from models import PhoneNumber, PhoneNumberBlockedError +import phonenumbers +import requests + +from baseframe import _ + +from .. import app +from .exc import ( + TransportConnectionError, + TransportRecipientError, + TransportTransactionError, +) + +__all__ = ['send_wa_via_meta', 'send_wa_via_on_premise'] + + +def get_phone_number( + phone: Union[str, phonenumbers.PhoneNumber, PhoneNumber] +) -> PhoneNumber: + if isinstance(phone, PhoneNumber): + if not phone.number: + raise TransportRecipientError(_("This phone number is not available")) + return phone + try: + phone_number = PhoneNumber.add(phone) + except PhoneNumberBlockedError as exc: + raise TransportRecipientError(_("This phone number has been blocked")) from exc + if not phone_number.allow_whatsapp: + raise TransportRecipientError(_("Whatsapp is disabled for this phone number")) + if not phone_number.number: + # This should never happen as :meth:`PhoneNumber.add` will restore the number + raise TransportRecipientError(_("This phone number is not available")) + return phone_number + + +def send_wa_via_meta(phone: str, message, callback: bool = True) -> str: + """ + Send the Whatsapp message using Meta Cloud API. + + :param phone: Phone number + :param message: Message to deliver to phone number + :param callback: Whether to request a status callback + :return: Transaction id + """ + phone_number = get_phone_number(phone) + sid = app.config['WHATSAPP_PHONE_ID'] + token = app.config['WHATSAPP_TOKEN'] + payload = { + "messaging_product": "whatsapp", + "recipient_type": "individual", + 'to': phone_number.number, + "type": "template", + 'body': str(message), + 'DltEntityId': message.registered_entityid, + } + try: + r = requests.post( + f'https://graph.facebook.com/v15.0/{sid}/messages', + timeout=30, + auth=(token), + data=payload, + ) + if r.status_code == 200: + jsonresponse = r.json() + transactionid = jsonresponse['messages'].get('id') + return transactionid + raise TransportTransactionError(_("Whatsapp API error"), r.status_code, r.text) + except requests.ConnectionError as exc: + raise TransportConnectionError(_("Whatsapp not reachable")) from exc + + +def send_wa_via_on_premise(phone: str, message, callback: bool = True) -> str: + """ + Send the Whatsapp message using Meta Cloud API. + + :param phone: Phone number + :param message: Message to deliver to phone number + :param callback: Whether to request a status callback + :return: Transaction id + """ + phone_number = get_phone_number(phone) + sid = app.config['WHATSAPP_PHONE_ID'] + token = app.config['WHATSAPP_TOKEN'] + payload = { + "messaging_product": "whatsapp", + "recipient_type": "individual", + 'to': phone_number.number, + "type": "template", + 'body': str(message), + 'DltEntityId': message.registered_entityid, + } + try: + r = requests.post( + f'https://graph.facebook.com/v15.0/{sid}/messages', + timeout=30, + auth=(token), + data=payload, + ) + if r.status_code == 200: + jsonresponse = r.json() + transactionid = jsonresponse['messages'].get('id') + return transactionid + raise TransportTransactionError(_("Whatsapp API error"), r.status_code, r.text) + except requests.ConnectionError as exc: + raise TransportConnectionError(_("Whatsapp not reachable")) from exc diff --git a/funnel/views/notification.py b/funnel/views/notification.py index 4e7b4dfdb..c8e1047ea 100644 --- a/funnel/views/notification.py +++ b/funnel/views/notification.py @@ -546,8 +546,34 @@ def dispatch_transport_sms(user_notification, view): ) +@rqjob +@transport_worker_wrapper +def dispatch_transport_whatsapp(user_notification, view): + if not user_notification.user.main_notification_preferences.by_transport( + 'whatsapp' + ): + # Cancel delivery if user's main switch is off. This was already checked, but + # the worker may be delayed and the user may have changed their preference. + user_notification.messageid_whatsapp = 'cancelled' + return + user_notification.messageid_whatsapp = sms.send( + str(view.transport_for('sms')), view.sms_with_unsubscribe() + ) + statsd.incr( + 'notification.transport', + tags={ + 'notification_type': user_notification.notification_type, + 'transport': 'whatsapp', + }, + ) + + # Add transport workers here as their worker methods are written -transport_workers = {'email': dispatch_transport_email, 'sms': dispatch_transport_sms} +transport_workers = { + 'email': dispatch_transport_email, + 'sms': dispatch_transport_sms, + 'whatsapp': dispatch_transport_whatsapp, +} # --- Notification background workers -------------------------------------------------- From 4d8732d9cda82c9214cffa4f6a8fe00764e882bd Mon Sep 17 00:00:00 2001 From: Amogh M Aradhya Date: Tue, 7 Feb 2023 13:36:03 +0530 Subject: [PATCH 02/22] Added webhook for WhatsApp --- funnel/transports/base.py | 2 +- funnel/transports/whatsapp.py | 80 +++++++++++++++++++++++------ funnel/views/api/whatsapp_events.py | 54 +++++++++++++++++++ 3 files changed, 120 insertions(+), 16 deletions(-) create mode 100644 funnel/views/api/whatsapp_events.py diff --git a/funnel/transports/base.py b/funnel/transports/base.py index fc933b493..afcb32582 100644 --- a/funnel/transports/base.py +++ b/funnel/transports/base.py @@ -24,7 +24,7 @@ def init(): platform_transports['email'] = True if sms_init(): platform_transports['sms'] = True - if app.config.get('WHATSAPP_TOKEN'): + if app.config.get('WHATSAPP_TOKEN') and app.config.get('WHATSAPP_PHONE_ID'): platform_transports['whatsapp'] = True # Other transports are not supported yet diff --git a/funnel/transports/whatsapp.py b/funnel/transports/whatsapp.py index 2113f4397..190e7ecb9 100644 --- a/funnel/transports/whatsapp.py +++ b/funnel/transports/whatsapp.py @@ -1,23 +1,31 @@ -"""Support functions for sending an Whatsapp messages.""" +"""Support functions for sending an WhatsApp messages.""" from __future__ import annotations -from typing import Union +from dataclasses import dataclass +from typing import Callable, Optional, Union, cast -from models import PhoneNumber, PhoneNumberBlockedError import phonenumbers import requests from baseframe import _ from .. import app +from ..models import PhoneNumber, PhoneNumberBlockedError, sa from .exc import ( TransportConnectionError, TransportRecipientError, TransportTransactionError, ) -__all__ = ['send_wa_via_meta', 'send_wa_via_on_premise'] + +@dataclass +class WhatsappSender: + """An SMS sender by number prefix.""" + + requires_config: set + func: Callable + init: Optional[Callable] = None def get_phone_number( @@ -31,17 +39,17 @@ def get_phone_number( phone_number = PhoneNumber.add(phone) except PhoneNumberBlockedError as exc: raise TransportRecipientError(_("This phone number has been blocked")) from exc - if not phone_number.allow_whatsapp: - raise TransportRecipientError(_("Whatsapp is disabled for this phone number")) + if not phone_number.allow_wa: + raise TransportRecipientError(_("WhatsApp is disabled for this phone number")) if not phone_number.number: # This should never happen as :meth:`PhoneNumber.add` will restore the number raise TransportRecipientError(_("This phone number is not available")) return phone_number -def send_wa_via_meta(phone: str, message, callback: bool = True) -> str: +def send_via_meta(phone: str, message, callback: bool = True) -> str: """ - Send the Whatsapp message using Meta Cloud API. + Send the WhatsApp message using Meta Cloud API. :param phone: Phone number :param message: Message to deliver to phone number @@ -57,7 +65,6 @@ def send_wa_via_meta(phone: str, message, callback: bool = True) -> str: 'to': phone_number.number, "type": "template", 'body': str(message), - 'DltEntityId': message.registered_entityid, } try: r = requests.post( @@ -69,13 +76,14 @@ def send_wa_via_meta(phone: str, message, callback: bool = True) -> str: if r.status_code == 200: jsonresponse = r.json() transactionid = jsonresponse['messages'].get('id') + phone_number.msg_wa_sent_at = sa.func.utcnow() return transactionid - raise TransportTransactionError(_("Whatsapp API error"), r.status_code, r.text) + raise TransportTransactionError(_("WhatsApp API error"), r.status_code, r.text) except requests.ConnectionError as exc: - raise TransportConnectionError(_("Whatsapp not reachable")) from exc + raise TransportConnectionError(_("WhatsApp not reachable")) from exc -def send_wa_via_on_premise(phone: str, message, callback: bool = True) -> str: +def send_via_hosted(phone: str, message, callback: bool = True) -> str: """ Send the Whatsapp message using Meta Cloud API. @@ -93,7 +101,6 @@ def send_wa_via_on_premise(phone: str, message, callback: bool = True) -> str: 'to': phone_number.number, "type": "template", 'body': str(message), - 'DltEntityId': message.registered_entityid, } try: r = requests.post( @@ -105,7 +112,50 @@ def send_wa_via_on_premise(phone: str, message, callback: bool = True) -> str: if r.status_code == 200: jsonresponse = r.json() transactionid = jsonresponse['messages'].get('id') + phone_number.msg_wa_sent_at = sa.func.utcnow() + return transactionid - raise TransportTransactionError(_("Whatsapp API error"), r.status_code, r.text) + raise TransportTransactionError(_("WhatsApp API error"), r.status_code, r.text) except requests.ConnectionError as exc: - raise TransportConnectionError(_("Whatsapp not reachable")) from exc + raise TransportConnectionError(_("WhatsApp not reachable")) from exc + + +#: Supported senders (ordered by priority) +sender_registry = [ + WhatsappSender( + {'WHATSAPP_PHONE_ID_HOSTED', 'WHATSAPP_TOKEN_HOSTED'}, send_via_hosted + ), + WhatsappSender({'WHATSAPP_PHONE_ID_META', 'WHATSAPP_TOKEN_META'}, send_via_meta), +] + +senders = [] + + +def init() -> bool: + """Process available senders.""" + for provider in sender_registry: + if all(app.config.get(var) for var in provider.requires_config): + senders.append(provider.func) + if provider.init: + provider.init() + return bool(senders) + + +def send( + phone: Union[str, phonenumbers.PhoneNumber, PhoneNumber], + message, + callback: bool = True, +) -> str: + """ + Send a WhatsApp message to a given phone number and return a transaction id. + + :param phone_number: Phone number + :param message: Message to deliver to phone number + :param callback: Whether to request a status callback + :return: Transaction id + """ + phone_number = get_phone_number(phone) + phone = cast(str, phone_number.number) + for sender in senders: + return sender() + raise TransportRecipientError(_("No service provider available for this recipient")) diff --git a/funnel/views/api/whatsapp_events.py b/funnel/views/api/whatsapp_events.py new file mode 100644 index 000000000..6b7a85512 --- /dev/null +++ b/funnel/views/api/whatsapp_events.py @@ -0,0 +1,54 @@ +"""Callback handlers from WhatsApp.""" + +from __future__ import annotations + +from flask import current_app, request + +from baseframe import statsd + +from ... import app +from ...models import PhoneNumber, PhoneNumberError, canonical_phone_number, db, sa +from ...typing import ReturnView +from ...utils import abort_null + + +@app.route('/api/1/whatsapp/meta_event', methods=['POST']) +def process_whatsapp_event() -> ReturnView: + """Process WhatsApp callback event.""" + # Register the fact that we got a WhatsApp event. + # If there are too many rejects, then most likely a hack attempt. + statsd.incr('phone_number.event', tags={'engine': 'whatsapp', 'stage': 'received'}) + whatsapp_to = abort_null(request.form.get('display_phone_number', '')) + if not whatsapp_to: + return {'status': 'eror', 'error': 'invalid_phone'}, 422 + # Exotel sends back 0-prefixed phone numbers, not plus-prefixed intl. numbers + if whatsapp_to.startswith('00'): + whatsapp_to = '+' + whatsapp_to[2:] + elif whatsapp_to.startswith('0'): + whatsapp_to = '+91' + whatsapp_to[1:] + try: + whatsapp_to = canonical_phone_number(whatsapp_to) + except PhoneNumberError: + return {'status': 'error', 'error': 'invalid_phone'}, 422 + + whatsapp_message = PhoneNumber.query.filter_by(number=whatsapp_to).one_or_none() + + if request.form['status'] == 'delivered': + whatsapp_message.msg_wa_delivered_at = sa.func.utcnow() + db.session.commit() + + current_app.logger.info( + "WhatsApp event for phone: %s %s", + whatsapp_to, + request.form['status'], + ) + + statsd.incr( + 'phone_number.event', + tags={ + 'engine': 'whatsapp', + 'stage': 'processed', + 'event': request.form['status'], + }, + ) + return {'status': 'ok', 'message': 'whatsapp_notification_processed'} From 9cd55a071c22f5115c8b055d8211692842e90029 Mon Sep 17 00:00:00 2001 From: Kiran Jonnalagadda Date: Tue, 7 Feb 2023 16:26:45 +0530 Subject: [PATCH 03/22] Move WhatsApp transport into its own folder --- funnel/transports/whatsapp/__init__.py | 7 +++++++ funnel/transports/{whatsapp.py => whatsapp/send.py} | 10 ++++++---- funnel/transports/whatsapp/template.py | 1 + 3 files changed, 14 insertions(+), 4 deletions(-) create mode 100644 funnel/transports/whatsapp/__init__.py rename funnel/transports/{whatsapp.py => whatsapp/send.py} (94%) create mode 100644 funnel/transports/whatsapp/template.py diff --git a/funnel/transports/whatsapp/__init__.py b/funnel/transports/whatsapp/__init__.py new file mode 100644 index 000000000..2bb9edbbd --- /dev/null +++ b/funnel/transports/whatsapp/__init__.py @@ -0,0 +1,7 @@ +"""WhatsApp transport support.""" +# flake8: noqa + +from __future__ import annotations + +from .send import * +from .template import * diff --git a/funnel/transports/whatsapp.py b/funnel/transports/whatsapp/send.py similarity index 94% rename from funnel/transports/whatsapp.py rename to funnel/transports/whatsapp/send.py index 190e7ecb9..733395ac8 100644 --- a/funnel/transports/whatsapp.py +++ b/funnel/transports/whatsapp/send.py @@ -10,9 +10,9 @@ from baseframe import _ -from .. import app -from ..models import PhoneNumber, PhoneNumberBlockedError, sa -from .exc import ( +from ... import app +from ...models import PhoneNumber, PhoneNumberBlockedError, sa +from ..exc import ( TransportConnectionError, TransportRecipientError, TransportTransactionError, @@ -21,7 +21,7 @@ @dataclass class WhatsappSender: - """An SMS sender by number prefix.""" + """A WhatsApp sender.""" requires_config: set func: Callable @@ -34,11 +34,13 @@ def get_phone_number( if isinstance(phone, PhoneNumber): if not phone.number: raise TransportRecipientError(_("This phone number is not available")) + # TODO: Confirm this phone number is available on WhatsApp return phone try: phone_number = PhoneNumber.add(phone) except PhoneNumberBlockedError as exc: raise TransportRecipientError(_("This phone number has been blocked")) from exc + # TODO: Confirm this phone number is available on WhatsApp (replacing `allow_wa`) if not phone_number.allow_wa: raise TransportRecipientError(_("WhatsApp is disabled for this phone number")) if not phone_number.number: diff --git a/funnel/transports/whatsapp/template.py b/funnel/transports/whatsapp/template.py new file mode 100644 index 000000000..95c763e3b --- /dev/null +++ b/funnel/transports/whatsapp/template.py @@ -0,0 +1 @@ +"""WhatsApp template validator.""" From 247e54f2504c1ba1d6e93fa4796127e3eea4d855 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 30 Oct 2023 05:33:55 +0000 Subject: [PATCH 04/22] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- funnel/transports/whatsapp/send.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/funnel/transports/whatsapp/send.py b/funnel/transports/whatsapp/send.py index 733395ac8..1a9e00f4a 100644 --- a/funnel/transports/whatsapp/send.py +++ b/funnel/transports/whatsapp/send.py @@ -2,8 +2,9 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass -from typing import Callable, Optional, Union, cast +from typing import cast import phonenumbers import requests @@ -25,11 +26,11 @@ class WhatsappSender: requires_config: set func: Callable - init: Optional[Callable] = None + init: Callable | None = None def get_phone_number( - phone: Union[str, phonenumbers.PhoneNumber, PhoneNumber] + phone: str | phonenumbers.PhoneNumber | PhoneNumber, ) -> PhoneNumber: if isinstance(phone, PhoneNumber): if not phone.number: @@ -144,7 +145,7 @@ def init() -> bool: def send( - phone: Union[str, phonenumbers.PhoneNumber, PhoneNumber], + phone: str | phonenumbers.PhoneNumber | PhoneNumber, message, callback: bool = True, ) -> str: From 6f23b083e3f91c1b8371b0171c9fcd949e37f51b Mon Sep 17 00:00:00 2001 From: Amogh M Aradhya Date: Fri, 10 Nov 2023 12:20:57 +0530 Subject: [PATCH 05/22] Modified webhook --- funnel/views/api/__init__.py | 1 + funnel/views/api/whatsapp_events.py | 47 +++++++++++++++++++++++++---- 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/funnel/views/api/__init__.py b/funnel/views/api/__init__.py index ebadd6087..60df96480 100644 --- a/funnel/views/api/__init__.py +++ b/funnel/views/api/__init__.py @@ -12,4 +12,5 @@ shortlink, sms_events, support, + whatsapp_events, ) diff --git a/funnel/views/api/whatsapp_events.py b/funnel/views/api/whatsapp_events.py index 6b7a85512..c39335e61 100644 --- a/funnel/views/api/whatsapp_events.py +++ b/funnel/views/api/whatsapp_events.py @@ -2,7 +2,7 @@ from __future__ import annotations -from flask import current_app, request +from flask import current_app, jsonify, request from baseframe import statsd @@ -12,13 +12,34 @@ from ...utils import abort_null +@app.route('/api/1/whatsapp/meta_event', methods=['GET']) +def process_whatsapp_webhook_verification(): + """Meta requires to verify the webhook URL by sending a GET request with a token.""" + verify_token = app.config['WHATSAPP_WEBHOOK_VERIFY_CODE'] + mode = request.args.get("hub.mode") + token = request.args.get("hub.verify_token") + challenge = request.args.get("hub.challenge") + + if mode and token: + if mode == "subscribe" and token == verify_token: + return challenge, 200 + return "Forbidden", 403 + return "Success", 200 + + @app.route('/api/1/whatsapp/meta_event', methods=['POST']) def process_whatsapp_event() -> ReturnView: """Process WhatsApp callback event.""" # Register the fact that we got a WhatsApp event. # If there are too many rejects, then most likely a hack attempt. statsd.incr('phone_number.event', tags={'engine': 'whatsapp', 'stage': 'received'}) - whatsapp_to = abort_null(request.form.get('display_phone_number', '')) + whatsapp_to = abort_null( + request.json.get("entry", [{}])[0] + .get("changes", [{}])[0] + .get("value", {}) + .get("statuses", [{}])[0] + .get("recipient_id") + ) if not whatsapp_to: return {'status': 'eror', 'error': 'invalid_phone'}, 422 # Exotel sends back 0-prefixed phone numbers, not plus-prefixed intl. numbers @@ -26,6 +47,8 @@ def process_whatsapp_event() -> ReturnView: whatsapp_to = '+' + whatsapp_to[2:] elif whatsapp_to.startswith('0'): whatsapp_to = '+91' + whatsapp_to[1:] + elif whatsapp_to.startswith('91'): + whatsapp_to = '+' + whatsapp_to try: whatsapp_to = canonical_phone_number(whatsapp_to) except PhoneNumberError: @@ -33,14 +56,26 @@ def process_whatsapp_event() -> ReturnView: whatsapp_message = PhoneNumber.query.filter_by(number=whatsapp_to).one_or_none() - if request.form['status'] == 'delivered': + status = ( + request.json.get("entry", [{}])[0] + .get("changes", [{}])[0] + .get("value", {}) + .get("statuses", [{}])[0] + .get("status") + ) + + if status == 'sent': + whatsapp_message.msg_wa_sent_at = sa.func.utcnow() + if status == 'delivered': whatsapp_message.msg_wa_delivered_at = sa.func.utcnow() + if status == 'failed': + whatsapp_message.msg_wa_failed_at = sa.func.utcnow() db.session.commit() current_app.logger.info( "WhatsApp event for phone: %s %s", whatsapp_to, - request.form['status'], + status, ) statsd.incr( @@ -48,7 +83,7 @@ def process_whatsapp_event() -> ReturnView: tags={ 'engine': 'whatsapp', 'stage': 'processed', - 'event': request.form['status'], + 'event': status, }, ) - return {'status': 'ok', 'message': 'whatsapp_notification_processed'} + return jsonify({"status": "ok"}), 200 From 133a9477e6bba7a63d5fc28deb884bdb8e6f85d4 Mon Sep 17 00:00:00 2001 From: Amogh M Aradhya Date: Fri, 10 Nov 2023 18:51:53 +0530 Subject: [PATCH 06/22] Updated webhook --- funnel/views/api/whatsapp_events.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/funnel/views/api/whatsapp_events.py b/funnel/views/api/whatsapp_events.py index c39335e61..9ac09183d 100644 --- a/funnel/views/api/whatsapp_events.py +++ b/funnel/views/api/whatsapp_events.py @@ -2,7 +2,7 @@ from __future__ import annotations -from flask import current_app, jsonify, request +from flask import current_app, request from baseframe import statsd @@ -42,15 +42,9 @@ def process_whatsapp_event() -> ReturnView: ) if not whatsapp_to: return {'status': 'eror', 'error': 'invalid_phone'}, 422 - # Exotel sends back 0-prefixed phone numbers, not plus-prefixed intl. numbers - if whatsapp_to.startswith('00'): - whatsapp_to = '+' + whatsapp_to[2:] - elif whatsapp_to.startswith('0'): - whatsapp_to = '+91' + whatsapp_to[1:] - elif whatsapp_to.startswith('91'): - whatsapp_to = '+' + whatsapp_to + try: - whatsapp_to = canonical_phone_number(whatsapp_to) + whatsapp_to = canonical_phone_number('+' + whatsapp_to) except PhoneNumberError: return {'status': 'error', 'error': 'invalid_phone'}, 422 @@ -86,4 +80,4 @@ def process_whatsapp_event() -> ReturnView: 'event': status, }, ) - return jsonify({"status": "ok"}), 200 + return {"status": "ok"}, 200 From 9d533d7ca1b7633bb9145adb404fbb2353043d02 Mon Sep 17 00:00:00 2001 From: Amogh M Aradhya Date: Fri, 10 Nov 2023 19:14:24 +0530 Subject: [PATCH 07/22] Modified WhatsApp Transport --- .testenv | 3 ++ funnel/transports/whatsapp/send.py | 21 +++++++----- funnel/transports/whatsapp/template.py | 46 ++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 8 deletions(-) diff --git a/.testenv b/.testenv index f60567fe7..50a3e617d 100644 --- a/.testenv +++ b/.testenv @@ -52,3 +52,6 @@ APP_FUNNEL_SHORTLINK_DOMAIN=f.test:3002 APP_FUNNEL_DEFAULT_DOMAIN=funnel.test APP_FUNNEL_UNSUBSCRIBE_DOMAIN=bye.test APP_SHORTLINK_SITE_ID=shortlink-test +APP_FUNNEL_WHATSAPP_TOKEN='' +APP_FUNNEL_WHATSAPP_PHONE_ID='' +APP_FUNNEL_WHATSAPP_WEBHOOK_SECRET='' diff --git a/funnel/transports/whatsapp/send.py b/funnel/transports/whatsapp/send.py index 1a9e00f4a..fa3073fd7 100644 --- a/funnel/transports/whatsapp/send.py +++ b/funnel/transports/whatsapp/send.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json from collections.abc import Callable from dataclasses import dataclass from typing import cast @@ -42,7 +43,7 @@ def get_phone_number( except PhoneNumberBlockedError as exc: raise TransportRecipientError(_("This phone number has been blocked")) from exc # TODO: Confirm this phone number is available on WhatsApp (replacing `allow_wa`) - if not phone_number.allow_wa: + if not phone_number.has_wa: raise TransportRecipientError(_("WhatsApp is disabled for this phone number")) if not phone_number.number: # This should never happen as :meth:`PhoneNumber.add` will restore the number @@ -62,23 +63,27 @@ def send_via_meta(phone: str, message, callback: bool = True) -> str: phone_number = get_phone_number(phone) sid = app.config['WHATSAPP_PHONE_ID'] token = app.config['WHATSAPP_TOKEN'] + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", # Adjust the content type based on your API requirements + } payload = { "messaging_product": "whatsapp", "recipient_type": "individual", 'to': phone_number.number, - "type": "template", - 'body': str(message), + 'type': 'template', + 'template': json.dumps(message.template), } try: r = requests.post( - f'https://graph.facebook.com/v15.0/{sid}/messages', + f'https://graph.facebook.com/v18.0/{sid}/messages', timeout=30, - auth=(token), + headers=headers, data=payload, ) if r.status_code == 200: jsonresponse = r.json() - transactionid = jsonresponse['messages'].get('id') + transactionid = jsonresponse['messages'][0].get('id') phone_number.msg_wa_sent_at = sa.func.utcnow() return transactionid raise TransportTransactionError(_("WhatsApp API error"), r.status_code, r.text) @@ -88,7 +93,7 @@ def send_via_meta(phone: str, message, callback: bool = True) -> str: def send_via_hosted(phone: str, message, callback: bool = True) -> str: """ - Send the Whatsapp message using Meta Cloud API. + Send the Whatsapp message using On-Premise API. :param phone: Phone number :param message: Message to deliver to phone number @@ -107,7 +112,7 @@ def send_via_hosted(phone: str, message, callback: bool = True) -> str: } try: r = requests.post( - f'https://graph.facebook.com/v15.0/{sid}/messages', + f'https://graph.facebook.com/v18.0/{sid}/messages', timeout=30, auth=(token), data=payload, diff --git a/funnel/transports/whatsapp/template.py b/funnel/transports/whatsapp/template.py index 95c763e3b..57fedc44d 100644 --- a/funnel/transports/whatsapp/template.py +++ b/funnel/transports/whatsapp/template.py @@ -1 +1,47 @@ """WhatsApp template validator.""" + +from __future__ import annotations + + +class WhatsappTemplate: + """Whatsapp template formatter.""" + + registered_template_name = None + registered_template_language_code = None + registered_template = None + template = None + + +class OTPTemplate(WhatsappTemplate): + """OTP template formatter.""" + + registered_template_name = "otp2" + registered_template_language_code = "en" + # Registered template for reference + registered_template = """ + + OTP is *{{1}}* for Hasgeek. + + If you did not request this, report misuse at https://has.gy/not-my-otp +""" + template = { + 'name': registered_template_name, + 'language': { + 'code': registered_template_language_code, + }, + 'components': [ + { + 'type': 'body', + "parameters": [ + { + "type": "text", + }, + ], + } + ], + } + otp: str + + def __init__(self, otp: str = ''): + self.otp = otp + self.template['components'][0]['parameters'][0]['text'] = otp From c7bcdf3e7ba1c67a105614548475fc8419983fd1 Mon Sep 17 00:00:00 2001 From: Amogh M Aradhya Date: Fri, 10 Nov 2023 19:15:56 +0530 Subject: [PATCH 08/22] Updated key for the secret --- funnel/views/api/whatsapp_events.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/funnel/views/api/whatsapp_events.py b/funnel/views/api/whatsapp_events.py index 9ac09183d..852634901 100644 --- a/funnel/views/api/whatsapp_events.py +++ b/funnel/views/api/whatsapp_events.py @@ -15,7 +15,7 @@ @app.route('/api/1/whatsapp/meta_event', methods=['GET']) def process_whatsapp_webhook_verification(): """Meta requires to verify the webhook URL by sending a GET request with a token.""" - verify_token = app.config['WHATSAPP_WEBHOOK_VERIFY_CODE'] + verify_token = app.config['WHATSAPP_WEBHOOK_SECRET'] mode = request.args.get("hub.mode") token = request.args.get("hub.verify_token") challenge = request.args.get("hub.challenge") From e52c7cc82787c496df4026272837fd1ecc8b1024 Mon Sep 17 00:00:00 2001 From: Amogh M Aradhya Date: Fri, 10 Nov 2023 19:52:13 +0530 Subject: [PATCH 09/22] Added whatsapp as a medium to send OTPs --- funnel/views/otp.py | 49 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/funnel/views/otp.py b/funnel/views/otp.py index cc9c7b711..baf892fab 100644 --- a/funnel/views/otp.py +++ b/funnel/views/otp.py @@ -34,6 +34,7 @@ TransportRecipientError, TransportTransactionError, sms, + whatsapp, ) from ..transports.email import jsonld_view_action, send_email from ..utils import blake2b160_hex, mask_email, mask_phone @@ -272,6 +273,30 @@ def send_sms( return msg return None + # Send whatsapp message + def send_whatsapp( + self, flash_success: bool = True, flash_failure: bool = True + ) -> str | None: + """Send an OTP via WhatsApp to a phone number.""" + if not self.phone: + return None + message = whatsapp.OTPTemplate(self.otp) + try: + whatsapp.send_via_meta(self.phone, message) + except TransportRecipientError as exc: + if flash_failure: + flash(str(exc), 'error') + else: + raise + if flash_success: + flash( + _("An OTP has been sent to your phone number {number}").format( + number=self.display_phone + ), + 'success', + ) + return message + def send_email( self, flash_success: bool = True, flash_failure: bool = True ) -> str | None: @@ -388,6 +413,30 @@ def send_sms( return msg return None + # Send whatsapp message + def send_whatsapp( + self, flash_success: bool = True, flash_failure: bool = True + ) -> str | None: + """Send an OTP via WhatsApp to a phone number.""" + if not self.phone: + return None + message = whatsapp.OTPTemplate(self.otp) + try: + whatsapp.send_via_meta(self.phone, message) + except TransportRecipientError as exc: + if flash_failure: + flash(str(exc), 'error') + else: + raise + if flash_success: + flash( + _("An OTP has been sent to your phone number {number}").format( + number=self.display_phone + ), + 'success', + ) + return message + def send_email( self, flash_success: bool = True, flash_failure: bool = True ) -> str | None: From 098e16b891f339537e8361803fa8c5e08debb3b1 Mon Sep 17 00:00:00 2001 From: Kiran Jonnalagadda Date: Wed, 15 Nov 2023 09:08:27 +0530 Subject: [PATCH 10/22] Fix typing --- funnel/models/phone_number.py | 4 ++- funnel/transports/base.py | 2 +- funnel/transports/exc.py | 3 -- funnel/transports/whatsapp/send.py | 41 +++++++++++--------------- funnel/transports/whatsapp/template.py | 8 ++--- 5 files changed, 26 insertions(+), 32 deletions(-) diff --git a/funnel/models/phone_number.py b/funnel/models/phone_number.py index f896267d0..0d043e390 100644 --- a/funnel/models/phone_number.py +++ b/funnel/models/phone_number.py @@ -260,7 +260,9 @@ class PhoneNumber(BaseMixin, Model): #: The phone number, centrepiece of this model. Stored normalized in E164 format. #: Validated by the :func:`_validate_phone` event handler - number = sa.orm.mapped_column(sa.Unicode, nullable=True, unique=True) + number: Mapped[str | None] = sa.orm.mapped_column( + sa.Unicode, nullable=True, unique=True + ) #: BLAKE2b 160-bit hash of :attr:`phone`. Kept permanently even if phone is #: removed. SQLAlchemy type LargeBinary maps to PostgreSQL BYTEA. Despite the name, diff --git a/funnel/transports/base.py b/funnel/transports/base.py index 329749c62..8ab23aee8 100644 --- a/funnel/transports/base.py +++ b/funnel/transports/base.py @@ -17,7 +17,7 @@ } -def init(): +def init() -> None: if app.config.get('MAIL_SERVER'): platform_transports['email'] = True if sms_init(): diff --git a/funnel/transports/exc.py b/funnel/transports/exc.py index 007687790..2ab878b6f 100644 --- a/funnel/transports/exc.py +++ b/funnel/transports/exc.py @@ -1,9 +1,6 @@ """Transport exceptions.""" -from __future__ import annotations - - class TransportError(Exception): """Base class for transport exceptions.""" diff --git a/funnel/transports/whatsapp/send.py b/funnel/transports/whatsapp/send.py index fa3073fd7..37de6d88b 100644 --- a/funnel/transports/whatsapp/send.py +++ b/funnel/transports/whatsapp/send.py @@ -19,14 +19,15 @@ TransportRecipientError, TransportTransactionError, ) +from .template import WhatsappTemplate @dataclass class WhatsappSender: """A WhatsApp sender.""" - requires_config: set - func: Callable + requires_config: set[str] + func: Callable[[str, WhatsappTemplate], str] init: Callable | None = None @@ -42,16 +43,14 @@ def get_phone_number( phone_number = PhoneNumber.add(phone) except PhoneNumberBlockedError as exc: raise TransportRecipientError(_("This phone number has been blocked")) from exc - # TODO: Confirm this phone number is available on WhatsApp (replacing `allow_wa`) - if not phone_number.has_wa: - raise TransportRecipientError(_("WhatsApp is disabled for this phone number")) if not phone_number.number: # This should never happen as :meth:`PhoneNumber.add` will restore the number raise TransportRecipientError(_("This phone number is not available")) + # TODO: Confirm this phone number is available on WhatsApp return phone_number -def send_via_meta(phone: str, message, callback: bool = True) -> str: +def send_via_meta(phone: str, message: WhatsappTemplate) -> str: """ Send the WhatsApp message using Meta Cloud API. @@ -63,14 +62,11 @@ def send_via_meta(phone: str, message, callback: bool = True) -> str: phone_number = get_phone_number(phone) sid = app.config['WHATSAPP_PHONE_ID'] token = app.config['WHATSAPP_TOKEN'] - headers = { - "Authorization": f"Bearer {token}", - "Content-Type": "application/json", # Adjust the content type based on your API requirements - } + headers = {'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'} payload = { - "messaging_product": "whatsapp", - "recipient_type": "individual", - 'to': phone_number.number, + 'messaging_product': 'whatsapp', + 'recipient_type': 'individual', + 'to': phone_number.number, # FIXME: Should the leading + be stripped? Confirm 'type': 'template', 'template': json.dumps(message.template), } @@ -91,7 +87,7 @@ def send_via_meta(phone: str, message, callback: bool = True) -> str: raise TransportConnectionError(_("WhatsApp not reachable")) from exc -def send_via_hosted(phone: str, message, callback: bool = True) -> str: +def send_via_hosted(phone: str, message: WhatsappTemplate) -> str: """ Send the Whatsapp message using On-Premise API. @@ -104,17 +100,17 @@ def send_via_hosted(phone: str, message, callback: bool = True) -> str: sid = app.config['WHATSAPP_PHONE_ID'] token = app.config['WHATSAPP_TOKEN'] payload = { - "messaging_product": "whatsapp", - "recipient_type": "individual", - 'to': phone_number.number, - "type": "template", + 'messaging_product': 'whatsapp', + 'recipient_type': 'individual', + 'to': phone_number.number, # FIXME: Should the leading + be stripped? Confirm + 'type': 'template', 'body': str(message), } try: r = requests.post( f'https://graph.facebook.com/v18.0/{sid}/messages', timeout=30, - auth=(token), + auth=(token), # FIXME: This is not a valid auth parameter data=payload, ) if r.status_code == 200: @@ -136,7 +132,7 @@ def send_via_hosted(phone: str, message, callback: bool = True) -> str: WhatsappSender({'WHATSAPP_PHONE_ID_META', 'WHATSAPP_TOKEN_META'}, send_via_meta), ] -senders = [] +senders: list[Callable[[str, WhatsappTemplate], str]] = [] def init() -> bool: @@ -151,8 +147,7 @@ def init() -> bool: def send( phone: str | phonenumbers.PhoneNumber | PhoneNumber, - message, - callback: bool = True, + message: WhatsappTemplate, ) -> str: """ Send a WhatsApp message to a given phone number and return a transaction id. @@ -165,5 +160,5 @@ def send( phone_number = get_phone_number(phone) phone = cast(str, phone_number.number) for sender in senders: - return sender() + return sender(phone, message) raise TransportRecipientError(_("No service provider available for this recipient")) diff --git a/funnel/transports/whatsapp/template.py b/funnel/transports/whatsapp/template.py index 57fedc44d..2b604eb62 100644 --- a/funnel/transports/whatsapp/template.py +++ b/funnel/transports/whatsapp/template.py @@ -6,10 +6,10 @@ class WhatsappTemplate: """Whatsapp template formatter.""" - registered_template_name = None - registered_template_language_code = None - registered_template = None - template = None + registered_template_name: str + registered_template_language_code: str + registered_template: str + template: str class OTPTemplate(WhatsappTemplate): From 57fbdc57d822d7c6cfc5db04632ca5d4c74b888a Mon Sep 17 00:00:00 2001 From: Kiran Jonnalagadda Date: Wed, 15 Nov 2023 09:09:54 +0530 Subject: [PATCH 11/22] Fix spelling --- funnel/transports/whatsapp/template.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/funnel/transports/whatsapp/template.py b/funnel/transports/whatsapp/template.py index 2b604eb62..b7806d833 100644 --- a/funnel/transports/whatsapp/template.py +++ b/funnel/transports/whatsapp/template.py @@ -4,7 +4,7 @@ class WhatsappTemplate: - """Whatsapp template formatter.""" + """WhatsApp template formatter.""" registered_template_name: str registered_template_language_code: str From c732b0f6454c249881122771167930128dd0c33d Mon Sep 17 00:00:00 2001 From: Kiran Jonnalagadda Date: Wed, 15 Nov 2023 11:11:23 +0530 Subject: [PATCH 12/22] More typing, remove callback param doc --- funnel/models/phone_number.py | 43 +++++++++++++++++++++--------- funnel/transports/whatsapp/send.py | 9 +++---- 2 files changed, 33 insertions(+), 19 deletions(-) diff --git a/funnel/models/phone_number.py b/funnel/models/phone_number.py index 0d043e390..f49c7515b 100644 --- a/funnel/models/phone_number.py +++ b/funnel/models/phone_number.py @@ -3,6 +3,7 @@ from __future__ import annotations import hashlib +from datetime import datetime from typing import TYPE_CHECKING, Any, ClassVar, Literal, overload import base58 @@ -267,7 +268,7 @@ class PhoneNumber(BaseMixin, Model): #: BLAKE2b 160-bit hash of :attr:`phone`. Kept permanently even if phone is #: removed. SQLAlchemy type LargeBinary maps to PostgreSQL BYTEA. Despite the name, #: we're only storing 20 bytes - blake2b160 = immutable( + blake2b160: Mapped[bytes] = immutable( sa.orm.mapped_column( sa.LargeBinary, sa.CheckConstraint( @@ -284,37 +285,53 @@ class PhoneNumber(BaseMixin, Model): # device, we record distinct timestamps for last sent, delivery and failure. #: Cached state for whether this phone number is known to have SMS support - has_sms = sa.orm.mapped_column(sa.Boolean, nullable=True) + has_sms: Mapped[bool | None] = sa.orm.mapped_column(sa.Boolean, nullable=True) #: Timestamp at which this number was determined to be valid/invalid for SMS - has_sms_at = sa.orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True) + has_sms_at: Mapped[datetime | None] = sa.orm.mapped_column( + sa.TIMESTAMP(timezone=True), nullable=True + ) #: Cached state for whether this phone number is known to be on WhatsApp or not - has_wa = sa.orm.mapped_column(sa.Boolean, nullable=True) + has_wa: Mapped[bool | None] = sa.orm.mapped_column(sa.Boolean, nullable=True) #: Timestamp at which this number was tested for availability on WhatsApp - has_wa_at = sa.orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True) + has_wa_at: Mapped[datetime | None] = sa.orm.mapped_column( + sa.TIMESTAMP(timezone=True), nullable=True + ) #: Timestamp of last SMS sent - msg_sms_sent_at = sa.orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True) + msg_sms_sent_at: Mapped[datetime | None] = sa.orm.mapped_column( + sa.TIMESTAMP(timezone=True), nullable=True + ) #: Timestamp of last SMS delivered - msg_sms_delivered_at = sa.orm.mapped_column( + msg_sms_delivered_at: Mapped[datetime | None] = sa.orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=True ) #: Timestamp of last SMS delivery failure - msg_sms_failed_at = sa.orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True) + msg_sms_failed_at: Mapped[datetime | None] = sa.orm.mapped_column( + sa.TIMESTAMP(timezone=True), nullable=True + ) #: Timestamp of last WA message sent - msg_wa_sent_at = sa.orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True) + msg_wa_sent_at: Mapped[datetime | None] = sa.orm.mapped_column( + sa.TIMESTAMP(timezone=True), nullable=True + ) #: Timestamp of last WA message delivered - msg_wa_delivered_at = sa.orm.mapped_column( + msg_wa_delivered_at: Mapped[datetime | None] = sa.orm.mapped_column( sa.TIMESTAMP(timezone=True), nullable=True ) #: Timestamp of last WA message delivery failure - msg_wa_failed_at = sa.orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True) + msg_wa_failed_at: Mapped[datetime | None] = sa.orm.mapped_column( + sa.TIMESTAMP(timezone=True), nullable=True + ) #: Timestamp of last known recipient activity resulting from sent messages - active_at = sa.orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True) + active_at: Mapped[datetime | None] = sa.orm.mapped_column( + sa.TIMESTAMP(timezone=True), nullable=True + ) #: Is this phone number blocked from being used? :attr:`phone` should be null if so. - blocked_at = sa.orm.mapped_column(sa.TIMESTAMP(timezone=True), nullable=True) + blocked_at: Mapped[datetime | None] = sa.orm.mapped_column( + sa.TIMESTAMP(timezone=True), nullable=True + ) __table_args__ = ( # If `blocked_at` is not None, `number` and `has_*` must be None diff --git a/funnel/transports/whatsapp/send.py b/funnel/transports/whatsapp/send.py index 37de6d88b..8ccce78de 100644 --- a/funnel/transports/whatsapp/send.py +++ b/funnel/transports/whatsapp/send.py @@ -56,7 +56,6 @@ def send_via_meta(phone: str, message: WhatsappTemplate) -> str: :param phone: Phone number :param message: Message to deliver to phone number - :param callback: Whether to request a status callback :return: Transaction id """ phone_number = get_phone_number(phone) @@ -66,7 +65,7 @@ def send_via_meta(phone: str, message: WhatsappTemplate) -> str: payload = { 'messaging_product': 'whatsapp', 'recipient_type': 'individual', - 'to': phone_number.number, # FIXME: Should the leading + be stripped? Confirm + 'to': cast(str, phone_number.number).lstrip('+'), 'type': 'template', 'template': json.dumps(message.template), } @@ -89,11 +88,10 @@ def send_via_meta(phone: str, message: WhatsappTemplate) -> str: def send_via_hosted(phone: str, message: WhatsappTemplate) -> str: """ - Send the Whatsapp message using On-Premise API. + Send the WhatsApp message using On-Premise API. :param phone: Phone number :param message: Message to deliver to phone number - :param callback: Whether to request a status callback :return: Transaction id """ phone_number = get_phone_number(phone) @@ -102,7 +100,7 @@ def send_via_hosted(phone: str, message: WhatsappTemplate) -> str: payload = { 'messaging_product': 'whatsapp', 'recipient_type': 'individual', - 'to': phone_number.number, # FIXME: Should the leading + be stripped? Confirm + 'to': cast(str, phone_number.number).lstrip('+'), 'type': 'template', 'body': str(message), } @@ -154,7 +152,6 @@ def send( :param phone_number: Phone number :param message: Message to deliver to phone number - :param callback: Whether to request a status callback :return: Transaction id """ phone_number = get_phone_number(phone) From d3c909ff5c546aee244a3f37f459e095ff90b8d3 Mon Sep 17 00:00:00 2001 From: Kiran Jonnalagadda Date: Wed, 15 Nov 2023 12:39:04 +0530 Subject: [PATCH 13/22] Typing fixes --- funnel/models/account.py | 16 +++++++--------- funnel/transports/__init__.py | 2 -- funnel/transports/sms/__init__.py | 2 -- funnel/transports/whatsapp/__init__.py | 2 -- 4 files changed, 7 insertions(+), 15 deletions(-) diff --git a/funnel/models/account.py b/funnel/models/account.py index 994b1593b..974e0ecb3 100644 --- a/funnel/models/account.py +++ b/funnel/models/account.py @@ -1515,7 +1515,7 @@ class AccountEmail(EmailAddressMixin, BaseMixin, Model): 'related': {'email', 'private', 'type'}, } - def __init__(self, account: Account, **kwargs) -> None: + def __init__(self, *, account: Account, **kwargs) -> None: email = kwargs.pop('email', None) if email: kwargs['email_address'] = EmailAddress.add_for(account, email) @@ -1701,13 +1701,11 @@ class AccountEmailClaim(EmailAddressMixin, BaseMixin, Model): 'related': {'email', 'private', 'type'}, } - def __init__(self, account: Account, **kwargs) -> None: - email = kwargs.pop('email', None) - if email: - kwargs['email_address'] = EmailAddress.add_for(account, email) + def __init__(self, account: Account, email: str, **kwargs) -> None: + kwargs['email_address'] = EmailAddress.add_for(account, email) super().__init__(account=account, **kwargs) - self.blake2b = hashlib.blake2b( - self.email.lower().encode(), digest_size=16 + self.blake2b = hashlib.blake2b( # self.email is not optional, so this ignore: + self.email.lower().encode(), digest_size=16 # type: ignore[union-attr] ).digest() def __repr__(self) -> str: @@ -1887,7 +1885,7 @@ class AccountPhone(PhoneNumberMixin, BaseMixin, Model): 'related': {'phone', 'private', 'type'}, } - def __init__(self, account, **kwargs) -> None: + def __init__(self, *, account: Account, **kwargs) -> None: phone = kwargs.pop('phone', None) if phone: kwargs['phone_number'] = PhoneNumber.add_for(account, phone) @@ -1902,7 +1900,7 @@ def __str__(self) -> str: return self.phone or '' @cached_property - def parsed(self) -> phonenumbers.PhoneNumber: + def parsed(self) -> phonenumbers.PhoneNumber | None: """Return parsed phone number using libphonenumbers.""" return self.phone_number.parsed diff --git a/funnel/transports/__init__.py b/funnel/transports/__init__.py index 7fbcf41f9..540311cc1 100644 --- a/funnel/transports/__init__.py +++ b/funnel/transports/__init__.py @@ -1,7 +1,5 @@ """Transport layer for communication with users (email, SMS, others).""" -from __future__ import annotations - from . import email, sms, telegram, webpush, whatsapp from .base import init, platform_transports from .exc import ( diff --git a/funnel/transports/sms/__init__.py b/funnel/transports/sms/__init__.py index 069fa32f8..b6850bd3f 100644 --- a/funnel/transports/sms/__init__.py +++ b/funnel/transports/sms/__init__.py @@ -1,7 +1,5 @@ """SMS transport support.""" # flake8: noqa -from __future__ import annotations - from .send import * from .template import * diff --git a/funnel/transports/whatsapp/__init__.py b/funnel/transports/whatsapp/__init__.py index 2bb9edbbd..489b6257e 100644 --- a/funnel/transports/whatsapp/__init__.py +++ b/funnel/transports/whatsapp/__init__.py @@ -1,7 +1,5 @@ """WhatsApp transport support.""" # flake8: noqa -from __future__ import annotations - from .send import * from .template import * From 22115ab57afb7462cb60fa1e1bd75b203a49cfde Mon Sep 17 00:00:00 2001 From: Kiran Jonnalagadda Date: Wed, 15 Nov 2023 12:46:03 +0530 Subject: [PATCH 14/22] Env config --- .testenv | 10 ++++++---- sample.env | 8 ++++++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/.testenv b/.testenv index 50a3e617d..b79554636 100644 --- a/.testenv +++ b/.testenv @@ -28,7 +28,7 @@ FLASK_RECAPTCHA_PRIVATE_KEY=6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe FLASK_RECAPTCHA_OPTIONS="" # Use hostaliases on supported platforms HOSTALIASES=${PWD}/HOSTALIASES -# These settings should be customisable from a .env file (TODO) +# These settings can be customised in .env.testing FLASK_SECRET_KEYS='["testkey"]' FLASK_LASTUSER_SECRET_KEYS='["testkey"]' FLASK_LASTUSER_COOKIE_DOMAIN='.funnel.test:3002' @@ -45,6 +45,11 @@ FLASK_IMGEE_HOST='http://imgee.test:4500' FLASK_IMAGE_URL_DOMAINS='["images.example.com"]' FLASK_IMAGE_URL_SCHEMES='["https"]' FLASK_SES_NOTIFICATION_TOPICS=null +# WhatsApp config (These null entries disable production entries in .env) +FLASK_WHATSAPP_PHONE_ID_META=null +FLASK_WHATSAPP_TOKEN_META=null +FLASK_WHATSAPP_PHONE_ID_HOSTED=null +FLASK_WHATSAPP_TOKEN_HOSTED=null # Per app config APP_FUNNEL_SITE_ID=hasgeek-test APP_FUNNEL_SERVER_NAME=funnel.test:3002 @@ -52,6 +57,3 @@ APP_FUNNEL_SHORTLINK_DOMAIN=f.test:3002 APP_FUNNEL_DEFAULT_DOMAIN=funnel.test APP_FUNNEL_UNSUBSCRIBE_DOMAIN=bye.test APP_SHORTLINK_SITE_ID=shortlink-test -APP_FUNNEL_WHATSAPP_TOKEN='' -APP_FUNNEL_WHATSAPP_PHONE_ID='' -APP_FUNNEL_WHATSAPP_WEBHOOK_SECRET='' diff --git a/sample.env b/sample.env index 159905cbc..c80fc1ccd 100644 --- a/sample.env +++ b/sample.env @@ -243,3 +243,11 @@ FLASK_SMS_DLT_TEMPLATE_IDS__update_template=null FLASK_SMS_DLT_TEMPLATE_IDS__comment_project_template=null FLASK_SMS_DLT_TEMPLATE_IDS__comment_proposal_template=null FLASK_SMS_DLT_TEMPLATE_IDS__comment_reply_template=null + +# --- WhatsApp integrations +# Meta Cloud API +FLASK_WHATSAPP_PHONE_ID_META=null +FLASK_WHATSAPP_TOKEN_META=null +# On-premise hosted +FLASK_WHATSAPP_PHONE_ID_HOSTED=null +FLASK_WHATSAPP_TOKEN_HOSTED=null From 356ffbbceb6684aa62001e454aca217b2eb0bbbe Mon Sep 17 00:00:00 2001 From: Kiran Jonnalagadda Date: Wed, 15 Nov 2023 12:51:56 +0530 Subject: [PATCH 15/22] Rename function; remove dupe method --- funnel/transports/whatsapp/send.py | 2 +- funnel/views/otp.py | 28 ++-------------------------- 2 files changed, 3 insertions(+), 27 deletions(-) diff --git a/funnel/transports/whatsapp/send.py b/funnel/transports/whatsapp/send.py index 8ccce78de..64a4f524e 100644 --- a/funnel/transports/whatsapp/send.py +++ b/funnel/transports/whatsapp/send.py @@ -143,7 +143,7 @@ def init() -> bool: return bool(senders) -def send( +def send_whatsapp( phone: str | phonenumbers.PhoneNumber | PhoneNumber, message: WhatsappTemplate, ) -> str: diff --git a/funnel/views/otp.py b/funnel/views/otp.py index baf892fab..cce89493a 100644 --- a/funnel/views/otp.py +++ b/funnel/views/otp.py @@ -276,13 +276,13 @@ def send_sms( # Send whatsapp message def send_whatsapp( self, flash_success: bool = True, flash_failure: bool = True - ) -> str | None: + ) -> whatsapp.WhatsappTemplate | None: """Send an OTP via WhatsApp to a phone number.""" if not self.phone: return None message = whatsapp.OTPTemplate(self.otp) try: - whatsapp.send_via_meta(self.phone, message) + whatsapp.send_whatsapp(self.phone, message) except TransportRecipientError as exc: if flash_failure: flash(str(exc), 'error') @@ -413,30 +413,6 @@ def send_sms( return msg return None - # Send whatsapp message - def send_whatsapp( - self, flash_success: bool = True, flash_failure: bool = True - ) -> str | None: - """Send an OTP via WhatsApp to a phone number.""" - if not self.phone: - return None - message = whatsapp.OTPTemplate(self.otp) - try: - whatsapp.send_via_meta(self.phone, message) - except TransportRecipientError as exc: - if flash_failure: - flash(str(exc), 'error') - else: - raise - if flash_success: - flash( - _("An OTP has been sent to your phone number {number}").format( - number=self.display_phone - ), - 'success', - ) - return message - def send_email( self, flash_success: bool = True, flash_failure: bool = True ) -> str | None: From b07333b380234ce5fd8eaecef55534fd992c85a2 Mon Sep 17 00:00:00 2001 From: Kiran Jonnalagadda Date: Wed, 15 Nov 2023 13:05:11 +0530 Subject: [PATCH 16/22] Fix WhatsApp notification delivery worker, add error handling to SMS worker --- funnel/views/notification.py | 49 ++++++++++++++++++++---------------- 1 file changed, 28 insertions(+), 21 deletions(-) diff --git a/funnel/views/notification.py b/funnel/views/notification.py index 1cb928f47..12013fa36 100644 --- a/funnel/views/notification.py +++ b/funnel/views/notification.py @@ -30,8 +30,7 @@ db, ) from ..serializers import token_serializer -from ..transports import TransportError, email, platform_transports, sms -from ..transports.sms import SmsTemplate +from ..transports import TransportError, email, platform_transports, sms, whatsapp from .helpers import make_cached_token from .jobs import rqjob @@ -385,7 +384,7 @@ def email_from(self) -> str: return f"{self.notification.preference_context.title} (via Hasgeek)" return "Hasgeek" - def sms(self) -> SmsTemplate: + def sms(self) -> sms.SmsTemplate: """ Render a short text message. Templates must use a single line with a link. @@ -397,7 +396,7 @@ def text(self) -> str: """Render a short plain text notification using the SMS template.""" return self.sms().text - def sms_with_unsubscribe(self) -> SmsTemplate: + def sms_with_unsubscribe(self) -> sms.SmsTemplate: """Add an unsubscribe link to the SMS message.""" msg = self.sms() msg.unsubscribe_url = self.unsubscribe_short_url('sms') @@ -419,13 +418,9 @@ def telegram(self) -> str: """ return self.text() - def whatsapp(self) -> str: - """ - Render a WhatsApp-formatted text message. - - Default implementation uses :meth:`text`. - """ - return self.text() + def whatsapp(self) -> whatsapp.WhatsappTemplate: + """Render a WhatsApp-formatted text message.""" + raise NotImplementedError("Subclasses must implement `whatsapp`") # --- Dispatch functions --------------------------------------------------------------- @@ -557,10 +552,10 @@ def dispatch_transport_email( # pylint: enable=consider-using-f-string ) ), - 'List-Help': f'<{url_for("notification_preferences")}>', + 'List-Help': f'<{url_for("notification_preferences", _external=True)}>', 'List-Unsubscribe': f'<{view.unsubscribe_url_email}>', 'List-Unsubscribe-Post': 'One-Click', - 'List-Archive': f'<{url_for("notifications")}>', + 'List-Archive': f'<{url_for("notifications", _external=True)}>', }, base_url=view.email_base_url, ) @@ -586,8 +581,13 @@ def dispatch_transport_sms( # the worker may be delayed and the user may have changed their preference. notification_recipient.messageid_sms = 'cancelled' return + try: + message = view.sms_with_unsubscribe() + except NotImplementedError: + notification_recipient.messageid_sms = 'not-implemented' + return notification_recipient.messageid_sms = sms.send_sms( - str(view.transport_for('sms')), view.sms_with_unsubscribe() + str(view.transport_for('sms')), message ) statsd.incr( 'notification.transport', @@ -598,23 +598,30 @@ def dispatch_transport_sms( ) -@rqjob +@rqjob() @transport_worker_wrapper -def dispatch_transport_whatsapp(user_notification, view): - if not user_notification.user.main_notification_preferences.by_transport( +def dispatch_transport_whatsapp( + notification_recipient: NotificationRecipient, view: RenderNotification +): + if not notification_recipient.user.main_notification_preferences.by_transport( 'whatsapp' ): # Cancel delivery if user's main switch is off. This was already checked, but # the worker may be delayed and the user may have changed their preference. - user_notification.messageid_whatsapp = 'cancelled' + notification_recipient.messageid_whatsapp = 'cancelled' return - user_notification.messageid_whatsapp = sms.send( - str(view.transport_for('sms')), view.sms_with_unsubscribe() + try: + message = view.whatsapp() + except NotImplementedError: + notification_recipient.messageid_whatsapp = 'not-implemented' + return + notification_recipient.messageid_whatsapp = whatsapp.send_whatsapp( + str(view.transport_for('whatsapp')), message ) statsd.incr( 'notification.transport', tags={ - 'notification_type': user_notification.notification_type, + 'notification_type': notification_recipient.notification_type, 'transport': 'whatsapp', }, ) From 38bef42b1eb3a2f43ed964f163d8eee8825a8d3c Mon Sep 17 00:00:00 2001 From: Kiran Jonnalagadda Date: Wed, 15 Nov 2023 13:17:10 +0530 Subject: [PATCH 17/22] Use walrus operator to batch notification deliveries --- funnel/views/notification.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/funnel/views/notification.py b/funnel/views/notification.py index 12013fa36..91e9a6901 100644 --- a/funnel/views/notification.py +++ b/funnel/views/notification.py @@ -648,9 +648,7 @@ def dispatch_notification_job(eventid: UUID, notification_ids: Sequence[UUID]) - for notification in notifications: if notification is not None: generator = notification.dispatch() - # TODO: Use walrus operator := after we move off Python 3.7 - batch = tuple(islice(generator, DISPATCH_BATCH_SIZE)) - while batch: + while batch := tuple(islice(generator, DISPATCH_BATCH_SIZE)): db.session.commit() notification_recipient_ids = [ notification_recipient.identity for notification_recipient in batch @@ -662,7 +660,6 @@ def dispatch_notification_job(eventid: UUID, notification_ids: Sequence[UUID]) - tags={'notification_type': notification.type}, ) # Continue to the next batch - batch = tuple(islice(generator, DISPATCH_BATCH_SIZE)) @rqjob() @@ -670,6 +667,7 @@ def dispatch_notification_recipients_job( notification_recipient_ids: Sequence[tuple[int, UUID]] ) -> None: """Process notifications for users and enqueue transport delivery.""" + # TODO: Can this be a single query instead of a loop of queries? queue = [ NotificationRecipient.query.get(identity) for identity in notification_recipient_ids From d568eba23aab46e282c7d6bb51b59c98331e1a17 Mon Sep 17 00:00:00 2001 From: Kiran Jonnalagadda Date: Wed, 15 Nov 2023 13:33:57 +0530 Subject: [PATCH 18/22] Update API event handler --- funnel/views/api/whatsapp_events.py | 68 ++++++++++++++++------------- 1 file changed, 38 insertions(+), 30 deletions(-) diff --git a/funnel/views/api/whatsapp_events.py b/funnel/views/api/whatsapp_events.py index 852634901..2526e214a 100644 --- a/funnel/views/api/whatsapp_events.py +++ b/funnel/views/api/whatsapp_events.py @@ -2,12 +2,18 @@ from __future__ import annotations -from flask import current_app, request +from flask import abort, current_app, request from baseframe import statsd from ... import app -from ...models import PhoneNumber, PhoneNumberError, canonical_phone_number, db, sa +from ...models import ( + PhoneNumber, + PhoneNumberInvalidError, + canonical_phone_number, + db, + sa, +) from ...typing import ReturnView from ...utils import abort_null @@ -16,58 +22,60 @@ def process_whatsapp_webhook_verification(): """Meta requires to verify the webhook URL by sending a GET request with a token.""" verify_token = app.config['WHATSAPP_WEBHOOK_SECRET'] - mode = request.args.get("hub.mode") - token = request.args.get("hub.verify_token") - challenge = request.args.get("hub.challenge") + mode = request.args.get('hub.mode') + token = request.args.get('hub.verify_token') + challenge = request.args.get('hub.challenge') if mode and token: - if mode == "subscribe" and token == verify_token: + if mode == 'subscribe' and token == verify_token: return challenge, 200 - return "Forbidden", 403 - return "Success", 200 + return 'Forbidden', 403 + return 'Success', 200 @app.route('/api/1/whatsapp/meta_event', methods=['POST']) def process_whatsapp_event() -> ReturnView: - """Process WhatsApp callback event.""" + """Process WhatsApp callback event from Meta Cloud API.""" # Register the fact that we got a WhatsApp event. # If there are too many rejects, then most likely a hack attempt. statsd.incr('phone_number.event', tags={'engine': 'whatsapp', 'stage': 'received'}) + if not request.json: + abort(400) whatsapp_to = abort_null( - request.json.get("entry", [{}])[0] - .get("changes", [{}])[0] - .get("value", {}) - .get("statuses", [{}])[0] - .get("recipient_id") + request.json.get('entry', [{}])[0] + .get('changes', [{}])[0] + .get('value', {}) + .get('statuses', [{}])[0] + .get('recipient_id') ) if not whatsapp_to: return {'status': 'eror', 'error': 'invalid_phone'}, 422 try: - whatsapp_to = canonical_phone_number('+' + whatsapp_to) - except PhoneNumberError: + whatsapp_to = canonical_phone_number(f'+{whatsapp_to}') + except PhoneNumberInvalidError: return {'status': 'error', 'error': 'invalid_phone'}, 422 - whatsapp_message = PhoneNumber.query.filter_by(number=whatsapp_to).one_or_none() + phone_number = PhoneNumber.add(phone=whatsapp_to) status = ( - request.json.get("entry", [{}])[0] - .get("changes", [{}])[0] - .get("value", {}) - .get("statuses", [{}])[0] - .get("status") + request.json.get('entry', [{}])[0] + .get('changes', [{}])[0] + .get('value', {}) + .get('statuses', [{}])[0] + .get('status') ) if status == 'sent': - whatsapp_message.msg_wa_sent_at = sa.func.utcnow() - if status == 'delivered': - whatsapp_message.msg_wa_delivered_at = sa.func.utcnow() - if status == 'failed': - whatsapp_message.msg_wa_failed_at = sa.func.utcnow() + phone_number.msg_wa_sent_at = sa.func.utcnow() + elif status == 'delivered': + phone_number.msg_wa_delivered_at = sa.func.utcnow() + elif status == 'failed': + phone_number.msg_wa_failed_at = sa.func.utcnow() db.session.commit() current_app.logger.info( - "WhatsApp event for phone: %s %s", + "WhatsApp Meta Cloud API event for phone: %s %s", whatsapp_to, status, ) @@ -75,9 +83,9 @@ def process_whatsapp_event() -> ReturnView: statsd.incr( 'phone_number.event', tags={ - 'engine': 'whatsapp', + 'engine': 'whatsapp-meta', 'stage': 'processed', 'event': status, }, ) - return {"status": "ok"}, 200 + return {'status': 'ok'}, 200 From 22d9eb4a7633cb2fd9becba80d11c586ea298d3f Mon Sep 17 00:00:00 2001 From: Kiran Jonnalagadda Date: Wed, 15 Nov 2023 13:39:16 +0530 Subject: [PATCH 19/22] Additional event handler fixes, and FIXME remarks --- funnel/views/api/whatsapp_events.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/funnel/views/api/whatsapp_events.py b/funnel/views/api/whatsapp_events.py index 2526e214a..00b1d1109 100644 --- a/funnel/views/api/whatsapp_events.py +++ b/funnel/views/api/whatsapp_events.py @@ -7,13 +7,7 @@ from baseframe import statsd from ... import app -from ...models import ( - PhoneNumber, - PhoneNumberInvalidError, - canonical_phone_number, - db, - sa, -) +from ...models import PhoneNumber, PhoneNumberInvalidError, db, sa from ...typing import ReturnView from ...utils import abort_null @@ -38,9 +32,17 @@ def process_whatsapp_event() -> ReturnView: """Process WhatsApp callback event from Meta Cloud API.""" # Register the fact that we got a WhatsApp event. # If there are too many rejects, then most likely a hack attempt. - statsd.incr('phone_number.event', tags={'engine': 'whatsapp', 'stage': 'received'}) + + # FIXME: Where is the call verification? + + statsd.incr( + 'phone_number.event', tags={'engine': 'whatsapp-meta', 'stage': 'received'} + ) if not request.json: abort(400) + + # FIXME: Handle multiple events in a single call, as it clearly implied by `changes` + # and `statuses` being a list in this call whatsapp_to = abort_null( request.json.get('entry', [{}])[0] .get('changes', [{}])[0] @@ -52,12 +54,10 @@ def process_whatsapp_event() -> ReturnView: return {'status': 'eror', 'error': 'invalid_phone'}, 422 try: - whatsapp_to = canonical_phone_number(f'+{whatsapp_to}') + phone_number = PhoneNumber.add(phone=f'+{whatsapp_to}') except PhoneNumberInvalidError: return {'status': 'error', 'error': 'invalid_phone'}, 422 - phone_number = PhoneNumber.add(phone=whatsapp_to) - status = ( request.json.get('entry', [{}])[0] .get('changes', [{}])[0] @@ -66,6 +66,7 @@ def process_whatsapp_event() -> ReturnView: .get('status') ) + phone_number.mark_has_wa(True) if status == 'sent': phone_number.msg_wa_sent_at = sa.func.utcnow() elif status == 'delivered': From 3433782b1accfead335a245ae1d5a91bb289d33d Mon Sep 17 00:00:00 2001 From: Kiran Jonnalagadda Date: Wed, 15 Nov 2023 13:39:48 +0530 Subject: [PATCH 20/22] Only mark WA availability on message delivery --- funnel/views/api/whatsapp_events.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/funnel/views/api/whatsapp_events.py b/funnel/views/api/whatsapp_events.py index 00b1d1109..0a5e1eba0 100644 --- a/funnel/views/api/whatsapp_events.py +++ b/funnel/views/api/whatsapp_events.py @@ -66,11 +66,11 @@ def process_whatsapp_event() -> ReturnView: .get('status') ) - phone_number.mark_has_wa(True) if status == 'sent': phone_number.msg_wa_sent_at = sa.func.utcnow() elif status == 'delivered': phone_number.msg_wa_delivered_at = sa.func.utcnow() + phone_number.mark_has_wa(True) elif status == 'failed': phone_number.msg_wa_failed_at = sa.func.utcnow() db.session.commit() From 9da920f00710f68fba01517e9083b63b656cfd1a Mon Sep 17 00:00:00 2001 From: Amogh M Aradhya Date: Mon, 20 Nov 2023 14:31:32 +0530 Subject: [PATCH 21/22] Updated callback handler --- funnel/views/api/whatsapp_events.py | 34 ++++++++++------------------- 1 file changed, 12 insertions(+), 22 deletions(-) diff --git a/funnel/views/api/whatsapp_events.py b/funnel/views/api/whatsapp_events.py index 0a5e1eba0..25df81713 100644 --- a/funnel/views/api/whatsapp_events.py +++ b/funnel/views/api/whatsapp_events.py @@ -32,9 +32,6 @@ def process_whatsapp_event() -> ReturnView: """Process WhatsApp callback event from Meta Cloud API.""" # Register the fact that we got a WhatsApp event. # If there are too many rejects, then most likely a hack attempt. - - # FIXME: Where is the call verification? - statsd.incr( 'phone_number.event', tags={'engine': 'whatsapp-meta', 'stage': 'received'} ) @@ -43,42 +40,35 @@ def process_whatsapp_event() -> ReturnView: # FIXME: Handle multiple events in a single call, as it clearly implied by `changes` # and `statuses` being a list in this call - whatsapp_to = abort_null( - request.json.get('entry', [{}])[0] - .get('changes', [{}])[0] - .get('value', {}) - .get('statuses', [{}])[0] - .get('recipient_id') - ) + statuses = request.json['entry'][0]['changes'][0]['value']['statuses'][0] + whatsapp_to = abort_null(statuses['recepient_id']) if not whatsapp_to: return {'status': 'eror', 'error': 'invalid_phone'}, 422 try: phone_number = PhoneNumber.add(phone=f'+{whatsapp_to}') except PhoneNumberInvalidError: + current_app.logger.info( + "WhatsApp Meta Cloud API event for phone: %s invalid number for Whatsapp", + whatsapp_to, + ) return {'status': 'error', 'error': 'invalid_phone'}, 422 - status = ( - request.json.get('entry', [{}])[0] - .get('changes', [{}])[0] - .get('value', {}) - .get('statuses', [{}])[0] - .get('status') - ) + msg_status = statuses['status'] - if status == 'sent': + if msg_status == 'sent': phone_number.msg_wa_sent_at = sa.func.utcnow() - elif status == 'delivered': + elif msg_status == 'delivered': phone_number.msg_wa_delivered_at = sa.func.utcnow() phone_number.mark_has_wa(True) - elif status == 'failed': + elif msg_status == 'failed': phone_number.msg_wa_failed_at = sa.func.utcnow() db.session.commit() current_app.logger.info( "WhatsApp Meta Cloud API event for phone: %s %s", whatsapp_to, - status, + msg_status, ) statsd.incr( @@ -86,7 +76,7 @@ def process_whatsapp_event() -> ReturnView: tags={ 'engine': 'whatsapp-meta', 'stage': 'processed', - 'event': status, + 'event': msg_status, }, ) return {'status': 'ok'}, 200 From f58932d14a382c532d4607fe32a16db353d80157 Mon Sep 17 00:00:00 2001 From: Kiran Jonnalagadda Date: Thu, 14 Dec 2023 01:22:28 +0530 Subject: [PATCH 22/22] Restore unresolved FIXME markers --- funnel/views/api/whatsapp_events.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/funnel/views/api/whatsapp_events.py b/funnel/views/api/whatsapp_events.py index 25df81713..a0ccb2abd 100644 --- a/funnel/views/api/whatsapp_events.py +++ b/funnel/views/api/whatsapp_events.py @@ -13,12 +13,13 @@ @app.route('/api/1/whatsapp/meta_event', methods=['GET']) -def process_whatsapp_webhook_verification(): - """Meta requires to verify the webhook URL by sending a GET request with a token.""" +def process_whatsapp_webhook_verification() -> ReturnView: + """Meta verifies the webhook URL by sending a GET request with a token.""" + # FIXME: This looks half baked. What is the "challenge" being returned? verify_token = app.config['WHATSAPP_WEBHOOK_SECRET'] - mode = request.args.get('hub.mode') - token = request.args.get('hub.verify_token') - challenge = request.args.get('hub.challenge') + mode = request.args['hub.mode'] + token = request.args['hub.verify_token'] + challenge = request.args['hub.challenge'] if mode and token: if mode == 'subscribe' and token == verify_token: @@ -32,6 +33,8 @@ def process_whatsapp_event() -> ReturnView: """Process WhatsApp callback event from Meta Cloud API.""" # Register the fact that we got a WhatsApp event. # If there are too many rejects, then most likely a hack attempt. + + # FIXME: Where is the call verification? statsd.incr( 'phone_number.event', tags={'engine': 'whatsapp-meta', 'stage': 'received'} )