Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support messaging over WhatsApp (foundation only) #1611

Draft
wants to merge 34 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
aa05f7a
added whatsapp transport
djamg Jan 31, 2023
4d8732d
Added webhook for WhatsApp
djamg Feb 7, 2023
088d600
Merge branch 'main' into whatsapp-integration
jace Feb 7, 2023
9cd55a0
Move WhatsApp transport into its own folder
jace Feb 7, 2023
be9dbe4
Merge branch 'main' into whatsapp-integration
jace Feb 12, 2023
daf3ab2
Merge branch 'main' into whatsapp-integration
jace Mar 7, 2023
d41b5b6
Merge branch 'main' into whatsapp-integration
jace Mar 13, 2023
e6f95f1
Merge branch 'main' into whatsapp-integration
jace May 11, 2023
b026451
Merge branch 'main' into whatsapp-integration
jace Oct 30, 2023
247e54f
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 30, 2023
689dc0a
Merge branch 'main' into whatsapp-integration
djamg Nov 9, 2023
6f23b08
Modified webhook
djamg Nov 10, 2023
133a947
Updated webhook
djamg Nov 10, 2023
9d533d7
Modified WhatsApp Transport
djamg Nov 10, 2023
c7bcdf3
Updated key for the secret
djamg Nov 10, 2023
e52c7cc
Added whatsapp as a medium to send OTPs
djamg Nov 10, 2023
632c8b2
Merge branch 'main' into whatsapp-integration
jace Nov 15, 2023
098e16b
Fix typing
jace Nov 15, 2023
57fbdc5
Fix spelling
jace Nov 15, 2023
c732b0f
More typing, remove callback param doc
jace Nov 15, 2023
d3c909f
Typing fixes
jace Nov 15, 2023
22115ab
Env config
jace Nov 15, 2023
356ffbb
Rename function; remove dupe method
jace Nov 15, 2023
b07333b
Fix WhatsApp notification delivery worker, add error handling to SMS …
jace Nov 15, 2023
38bef42
Use walrus operator to batch notification deliveries
jace Nov 15, 2023
d568eba
Update API event handler
jace Nov 15, 2023
22d9eb4
Additional event handler fixes, and FIXME remarks
jace Nov 15, 2023
3433782
Only mark WA availability on message delivery
jace Nov 15, 2023
9bf7daf
Merge branch 'main' into whatsapp-integration
jace Nov 20, 2023
9da920f
Updated callback handler
djamg Nov 20, 2023
37516e8
Merge branch 'main' into whatsapp-integration
jace Nov 20, 2023
0400176
Merge branch 'whatsapp-integration' of https://github.com/hasgeek/fun…
djamg Nov 20, 2023
c30a9c8
Merge branch 'main' into whatsapp-integration
jace Dec 13, 2023
f58932d
Restore unresolved FIXME markers
jace Dec 13, 2023
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
7 changes: 6 additions & 1 deletion .testenv
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand Down
16 changes: 7 additions & 9 deletions funnel/models/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion funnel/models/notification.py
Original file line number Diff line number Diff line change
Expand Up @@ -1427,7 +1427,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
Expand Down
2 changes: 1 addition & 1 deletion funnel/models/phone_number.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,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(
Expand Down
2 changes: 0 additions & 2 deletions funnel/transports/__init__.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down
4 changes: 3 additions & 1 deletion funnel/transports/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@
}


def init():
def init() -> None:
if app.config.get('MAIL_SERVER'):
platform_transports['email'] = True
if sms_init():
platform_transports['sms'] = True
if app.config.get('WHATSAPP_TOKEN') and app.config.get('WHATSAPP_PHONE_ID'):
platform_transports['whatsapp'] = True

# Other transports are not supported yet
3 changes: 0 additions & 3 deletions funnel/transports/exc.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
"""Transport exceptions."""


from __future__ import annotations


class TransportError(Exception):
"""Base class for transport exceptions."""

Expand Down
2 changes: 0 additions & 2 deletions funnel/transports/sms/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
"""SMS transport support."""
# flake8: noqa

from __future__ import annotations

from .send import *
from .template import *
3 changes: 0 additions & 3 deletions funnel/transports/whatsapp.py

This file was deleted.

5 changes: 5 additions & 0 deletions funnel/transports/whatsapp/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""WhatsApp transport support."""
# flake8: noqa

from .send import *
from .template import *
161 changes: 161 additions & 0 deletions funnel/transports/whatsapp/send.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
"""Support functions for sending an WhatsApp messages."""

from __future__ import annotations

import json
from collections.abc import Callable
from dataclasses import dataclass
from typing import cast

import phonenumbers
import requests

from baseframe import _

from ... import app
from ...models import PhoneNumber, PhoneNumberBlockedError, sa
from ..exc import (
TransportConnectionError,
TransportRecipientError,
TransportTransactionError,
)
from .template import WhatsappTemplate


@dataclass
class WhatsappSender:
"""A WhatsApp sender."""

requires_config: set[str]
func: Callable[[str, WhatsappTemplate], str]
init: Callable | None = None


def get_phone_number(
phone: str | phonenumbers.PhoneNumber | PhoneNumber,
) -> PhoneNumber:
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
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: WhatsappTemplate) -> str:
"""
Send the WhatsApp message using Meta Cloud API.

:param phone: Phone number
:param message: Message to deliver to phone number
:return: Transaction id
"""
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'}
payload = {
'messaging_product': 'whatsapp',
'recipient_type': 'individual',
'to': cast(str, phone_number.number).lstrip('+'),
'type': 'template',
'template': json.dumps(message.template),
}
try:
r = requests.post(
f'https://graph.facebook.com/v18.0/{sid}/messages',
timeout=30,
headers=headers,
data=payload,
)
if r.status_code == 200:
jsonresponse = r.json()
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)
except requests.ConnectionError as exc:
raise TransportConnectionError(_("WhatsApp not reachable")) from exc


def send_via_hosted(phone: str, message: WhatsappTemplate) -> str:
"""
Send the WhatsApp message using On-Premise API.

:param phone: Phone number
:param message: Message to deliver to phone number
: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': cast(str, phone_number.number).lstrip('+'),
'type': 'template',
'body': str(message),
}
try:
r = requests.post(
f'https://graph.facebook.com/v18.0/{sid}/messages',
timeout=30,
auth=(token), # FIXME: This is not a valid auth parameter
data=payload,
)
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)
except requests.ConnectionError as 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: list[Callable[[str, WhatsappTemplate], str]] = []


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_whatsapp(
phone: str | phonenumbers.PhoneNumber | PhoneNumber,
message: WhatsappTemplate,
) -> 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
:return: Transaction id
"""
phone_number = get_phone_number(phone)
phone = cast(str, phone_number.number)
for sender in senders:
return sender(phone, message)
raise TransportRecipientError(_("No service provider available for this recipient"))
47 changes: 47 additions & 0 deletions funnel/transports/whatsapp/template.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""WhatsApp template validator."""

from __future__ import annotations


class WhatsappTemplate:
"""WhatsApp template formatter."""

registered_template_name: str
registered_template_language_code: str
registered_template: str
template: str


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
1 change: 1 addition & 0 deletions funnel/views/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@
shortlink,
sms_events,
support,
whatsapp_events,
)
Loading
Loading