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 28 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
47 changes: 33 additions & 14 deletions funnel/models/phone_number.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -260,12 +261,14 @@ 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,
#: we're only storing 20 bytes
blake2b160 = immutable(
blake2b160: Mapped[bytes] = immutable(
sa.orm.mapped_column(
sa.LargeBinary,
sa.CheckConstraint(
Expand All @@ -282,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
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"))
Loading
Loading