Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 14 additions & 1 deletion app/airq/controllers/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,23 +23,36 @@ def _get_supported_locale(locale: str) -> str:
def sms_reply(locale: str) -> str:
supported_locale = _get_supported_locale(locale)
g.locale = supported_locale

zipcode = request.values.get("Body", "").strip()

phone_number = request.values.get("From", "").strip()
if phone_number.startswith("whatsapp:"):
phone_number = phone_number.lstrip("whatsapp:")
identifier_type = ClientIdentifierType.WHATSAPP
else:
identifier_type = ClientIdentifierType.PHONE_NUMBER

response = commands.handle_command(
zipcode, phone_number, ClientIdentifierType.PHONE_NUMBER, supported_locale
zipcode, phone_number, identifier_type, supported_locale
)

return response.serialize()


def test_command(locale: str) -> str:
supported_locale = _get_supported_locale(locale)
g.locale = supported_locale

command = request.args.get("command", "").strip()

if request.headers.getlist("X-Forwarded-For"):
ip = request.headers.getlist("X-Forwarded-For")[0]
else:
ip = request.remote_addr

response = commands.handle_command(
command, ip, ClientIdentifierType.IP, supported_locale
)

return response.as_html()
19 changes: 15 additions & 4 deletions app/airq/lib/twilio.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@
import logging
import typing

from airq import config

from twilio.base.exceptions import TwilioRestException
from twilio.rest import Client

from airq import config
from airq.models.clients import ClientIdentifierType


logger = logging.getLogger(__name__)

Expand All @@ -15,6 +16,9 @@ class TwilioErrorCode(enum.IntEnum):
OUT_OF_REGION = 21408
UNSUBSCRIBED = 21610

# See https://www.twilio.com/docs/whatsapp/tutorial/send-whatsapp-notification-messages-templates#encountering-error-code-63016
NO_CONVERSATION = 63016

@classmethod
def from_exc(cls, exc: TwilioRestException) -> typing.Optional["TwilioErrorCode"]:
for m in cls:
Expand All @@ -23,14 +27,21 @@ def from_exc(cls, exc: TwilioRestException) -> typing.Optional["TwilioErrorCode"
return None


def send_sms(
body: str, to_number: str, locale: str, media: typing.Optional[str] = None
def send_message(
body: str,
to_number: str,
type_code: ClientIdentifierType,
locale: str,
media: typing.Optional[str] = None,
):
from_number = config.TWILIO_NUMBERS.get(locale)
if not from_number:
logger.exception("Couldn't find a Twilio number for %s", locale)
return

if type_code == ClientIdentifierType.WHATSAPP:
from_number = "whatsapp:" + from_number

kwargs = dict(body=body, to=to_number, from_=from_number)
if media:
kwargs["media_url"] = media
Expand Down
38 changes: 31 additions & 7 deletions app/airq/models/clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,6 @@
from airq.lib.readings import Pm25
from airq.lib.readings import Readings
from airq.lib.sms import coerce_phone_number
from airq.lib.twilio import send_sms
from airq.lib.twilio import TwilioErrorCode
from airq.models.events import Event
from airq.models.events import EventType
from airq.models.zipcodes import Zipcode
Expand All @@ -34,6 +32,11 @@
class ClientIdentifierType(enum.Enum):
PHONE_NUMBER = 1
IP = 2
WHATSAPP = 3

@property
def can_receive_messages(self) -> bool:
return self != self.IP


class ClientQuery(BaseQuery):
Expand Down Expand Up @@ -69,7 +72,7 @@ def get_by_phone_number(self, phone_number: str) -> typing.Optional["Client"]:
#

def filter_phones(self) -> "ClientQuery":
return self.filter(Client.type_code == ClientIdentifierType.PHONE_NUMBER)
return self.filter(Client.type_code != ClientIdentifierType.IP)

def filter_inactive_since(self, timestamp: float) -> "ClientQuery":
return self.filter(Client.last_activity_at < timestamp).filter(
Expand Down Expand Up @@ -202,6 +205,10 @@ def __repr__(self) -> str:
# and therefore shouldn't include it in its state
EVENT_RESPONSE_TIME = datetime.timedelta(hours=1)

@property
def identifier_type(self) -> ClientIdentifierType:
return ClientIdentifierType(self.type_code)

@classmethod
def get_share_window(self) -> typing.Tuple[int, int]:
ts = timestamp()
Expand Down Expand Up @@ -346,12 +353,28 @@ def is_in_send_window(self) -> bool:
return send_start <= dt.hour < send_end

def send_message(self, message: str, media: typing.Optional[str] = None) -> bool:
if self.type_code == ClientIdentifierType.PHONE_NUMBER:
from airq.lib.twilio import send_message
from airq.lib.twilio import TwilioErrorCode

if self.identifier_type.can_receive_messages:
try:
send_sms(message, self.identifier, self.locale, media=media)
send_message(
message,
self.identifier,
self.identifier_type,
self.locale,
media=media,
)
except TwilioRestException as e:
code = TwilioErrorCode.from_exc(e)
if code:
if code == TwilioErrorCode.NO_CONVERSATION:
logger.exception(
'Sent non-template message "%s" to %s outside of a conversation',
message,
self,
)
return False
elif code:
logger.warning(
"Disabling alerts for recipient %s: %s",
self,
Expand All @@ -362,7 +385,6 @@ def send_message(self, message: str, media: typing.Optional[str] = None) -> bool
else:
raise
else:
# Other clients types don't yet support message sending.
logger.info("Not messaging client %s: %s", self.id, message)

return True
Expand Down Expand Up @@ -406,6 +428,8 @@ def maybe_notify(self) -> bool:
if was_alerted_recently and abs(curr_aqi - last_aqi) < 50:
return False

# Warning! This is a template message used by Whatsapp. If you change it, make sure to add the new copy
# in the Twilio console or we won't be able to send it to Whatsapp users.
message = gettext(
'Air quality in %(city)s %(zipcode)s has changed to %(curr_aqi_level)s (AQI %(curr_aqi)s).\n\nReply "M" for Menu or "E" to end alerts.',
city=self.zipcode.city.name,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""support whatsapp as enum member

Revision ID: a3c90e054d3a
Revises: 3dad84c99218
Create Date: 2021-08-24 05:28:52.434323

"""
from alembic import op
import sqlalchemy as sa

# Cribbed from https://markrailton.com/blog/creating-migrations-when-changing-an-enum-in-python-using-sql-alchemy

# revision identifiers, used by Alembic.
revision = "a3c90e054d3a"
down_revision = "3dad84c99218"
branch_labels = None
depends_on = None

# Enum 'type' for PostgreSQL
enum_name = "clientidentifiertype"
# Set temporary enum 'type' for PostgreSQL
tmp_enum_name = "tmp_" + enum_name

# Options for Enum
old_options = ("PHONE_NUMBER", "IP")
new_options = sorted(old_options + ("WHATSAPP",))

# Create enum fields
old_type = sa.Enum(*old_options, name=enum_name)
new_type = sa.Enum(*new_options, name=enum_name)


def upgrade():
# Rename current enum type to tmp_
op.execute("ALTER TYPE " + enum_name + " RENAME TO " + tmp_enum_name)
# Create new enum type in db
new_type.create(op.get_bind())
# Update column to use new enum type
op.execute(
"ALTER TABLE clients ALTER COLUMN type_code TYPE "
+ enum_name
+ " USING type_code::text::"
+ enum_name
)
# Drop old enum type
op.execute("DROP TYPE " + tmp_enum_name)


def downgrade():
# Instantiate db query
op.execute(
"DELETE FROM events WHERE client_id IN (SELECT id FROM clients WHERE type_code = 'WHATSAPP')"
)
op.execute("DELETE FROM clients WHERE type_code = 'WHATSAPP'")
# Rename enum type to tmp_
op.execute("ALTER TYPE " + enum_name + " RENAME TO " + tmp_enum_name)
# Create enum type using old values
old_type.create(op.get_bind())
# Set enum type as type for event_type column
op.execute(
"ALTER TABLE clients ALTER COLUMN type_code TYPE "
+ enum_name
+ " USING type_code::text::"
+ enum_name
)
# Drop temp enum type
op.execute("DROP TYPE " + tmp_enum_name)
4 changes: 2 additions & 2 deletions app/tests/test_clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ def test_send_message_raises_known_error_code(self):
self.assertTrue(client.is_enabled_for_alerts)
self.assertEqual(0, Event.query.count())
with mock.patch(
"airq.models.clients.send_sms",
"airq.lib.twilio.send_message",
side_effect=TwilioRestException(
"", "", code=TwilioErrorCode.OUT_OF_REGION.value
),
Expand All @@ -239,7 +239,7 @@ def test_send_message_raises_unknown_error_code(self):
self.assertTrue(client.is_enabled_for_alerts)
self.assertEqual(0, Event.query.count())
with mock.patch(
"airq.models.clients.send_sms",
"airq.lib.twilio.send_message",
side_effect=TwilioRestException("", "", code=77),
):
with self.assertRaises(Exception):
Expand Down