diff --git a/CHANGELOG.md b/CHANGELOG.md index 38f167e2..132e7f8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to this project will be documented in this file. - Webhooks support HTTP PUT (#249) - Webhooks can use different req. bodies and headers for "up" and "down" events. (#249) - Show check's code instead of full URL on 992px - 1200px wide screens. (#253) +- Add WhatsApp integration (uses Twilio same as the SMS integration) ### Bug Fixes - Fix badges for tags containing special characters (#240, #237) diff --git a/README.md b/README.md index 6151fd76..25890c2d 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,7 @@ Configurations settings loaded from environment variables: | TWILIO_ACCOUNT | `None` | TWILIO_AUTH | `None` | TWILIO_FROM | `None` +| TWILIO_USE_WHATSAPP | `"False"` | PD_VENDOR_KEY | `None` | TRELLO_APP_KEY | `None` | MATRIX_HOMESERVER | `None` diff --git a/hc/api/models.py b/hc/api/models.py index 900045c1..08daa68c 100644 --- a/hc/api/models.py +++ b/hc/api/models.py @@ -39,6 +39,7 @@ CHANNEL_KINDS = ( ("zendesk", "Zendesk"), ("trello", "Trello"), ("matrix", "Matrix"), + ("whatsapp", "WhatsApp"), ) PO_PRIORITIES = {-2: "lowest", -1: "low", 0: "normal", 1: "high", 2: "emergency"} @@ -328,6 +329,8 @@ class Channel(models.Model): return transports.Trello(self) elif self.kind == "matrix": return transports.Matrix(self) + elif self.kind == "whatsapp": + return transports.WhatsApp(self) else: raise NotImplementedError("Unknown channel kind: %s" % self.kind) @@ -495,7 +498,7 @@ class Channel(models.Model): @property def sms_number(self): - assert self.kind == "sms" + assert self.kind in ("sms", "whatsapp") if self.value.startswith("{"): doc = json.loads(self.value) return doc["value"] @@ -556,6 +559,18 @@ class Channel(models.Model): doc = json.loads(self.value) return doc.get("down") + @property + def whatsapp_notify_up(self): + assert self.kind == "whatsapp" + doc = json.loads(self.value) + return doc["up"] + + @property + def whatsapp_notify_down(self): + assert self.kind == "whatsapp" + doc = json.loads(self.value) + return doc["down"] + class Notification(models.Model): class Meta: diff --git a/hc/api/tests/test_notify.py b/hc/api/tests/test_notify.py index 6eac293a..0ce48d8d 100644 --- a/hc/api/tests/test_notify.py +++ b/hc/api/tests/test_notify.py @@ -522,7 +522,7 @@ class NotifyTestCase(BaseTestCase): mock_post.return_value.status_code = 200 self.channel.notify(self.check) - assert Notification.objects.count() == 1 + self.assertEqual(Notification.objects.count(), 1) args, kwargs = mock_post.call_args payload = kwargs["data"] @@ -575,3 +575,51 @@ class NotifyTestCase(BaseTestCase): self.channel.notify(self.check) self.assertTrue(mock_post.called) + + @patch("hc.api.transports.requests.request") + def test_whatsapp(self, mock_post): + definition = {"value": "+1234567890", "up": True, "down": True} + + self._setup_data("whatsapp", json.dumps(definition)) + self.check.last_ping = now() - td(hours=2) + + mock_post.return_value.status_code = 200 + + self.channel.notify(self.check) + self.assertEqual(Notification.objects.count(), 1) + + args, kwargs = mock_post.call_args + payload = kwargs["data"] + self.assertEqual(payload["To"], "whatsapp:+1234567890") + + # sent SMS counter should go up + self.profile.refresh_from_db() + self.assertEqual(self.profile.sms_sent, 1) + + @patch("hc.api.transports.requests.request") + def test_whatsapp_obeys_up_down_flags(self, mock_post): + definition = {"value": "+1234567890", "up": True, "down": False} + + self._setup_data("whatsapp", json.dumps(definition)) + self.check.last_ping = now() - td(hours=2) + + self.channel.notify(self.check) + self.assertEqual(Notification.objects.count(), 0) + + self.assertFalse(mock_post.called) + + @patch("hc.api.transports.requests.request") + def test_whatsapp_limit(self, mock_post): + # At limit already: + self.profile.last_sms_date = now() + self.profile.sms_sent = 50 + self.profile.save() + + definition = {"value": "+1234567890", "up": True, "down": True} + self._setup_data("whatsapp", json.dumps(definition)) + + self.channel.notify(self.check) + self.assertFalse(mock_post.called) + + n = Notification.objects.get() + self.assertTrue("Monthly message limit exceeded" in n.error) diff --git a/hc/api/transports.py b/hc/api/transports.py index f61b6cf0..07f92eac 100644 --- a/hc/api/transports.py +++ b/hc/api/transports.py @@ -415,6 +415,33 @@ class Sms(HttpTransport): return self.post(url, data=data, auth=auth) +class WhatsApp(HttpTransport): + URL = "https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json" + + def is_noop(self, check): + if check.status == "down": + return not self.channel.whatsapp_notify_down + else: + return not self.channel.whatsapp_notify_up + + def notify(self, check): + profile = Profile.objects.for_user(self.channel.project.owner) + if not profile.authorize_sms(): + return "Monthly message limit exceeded" + + url = self.URL % settings.TWILIO_ACCOUNT + auth = (settings.TWILIO_ACCOUNT, settings.TWILIO_AUTH) + text = tmpl("whatsapp_message.html", check=check, site_name=settings.SITE_NAME) + + data = { + "From": "whatsapp:%s" % settings.TWILIO_FROM, + "To": "whatsapp:%s" % self.channel.sms_number, + "Body": text, + } + + return self.post(url, data=data, auth=auth) + + class Trello(HttpTransport): URL = "https://api.trello.com/1/cards" diff --git a/hc/front/forms.py b/hc/front/forms.py index dda7af49..30494ade 100644 --- a/hc/front/forms.py +++ b/hc/front/forms.py @@ -133,6 +133,8 @@ class AddSmsForm(forms.Form): error_css_class = "has-error" label = forms.CharField(max_length=100, required=False) value = forms.CharField(max_length=16, validators=[phone_validator]) + down = forms.BooleanField(required=False, initial=True) + up = forms.BooleanField(required=False, initial=True) class ChannelNameForm(forms.Form): diff --git a/hc/front/tests/test_add_whatsapp.py b/hc/front/tests/test_add_whatsapp.py new file mode 100644 index 00000000..8c5dbd1f --- /dev/null +++ b/hc/front/tests/test_add_whatsapp.py @@ -0,0 +1,70 @@ +from django.test.utils import override_settings +from hc.api.models import Channel +from hc.test import BaseTestCase + +TEST_CREDENTIALS = { + "TWILIO_ACCOUNT": "foo", + "TWILIO_AUTH": "foo", + "TWILIO_FROM": "123", + "TWILIO_USE_WHATSAPP": True, +} + + +@override_settings(**TEST_CREDENTIALS) +class AddWhatsAppTestCase(BaseTestCase): + url = "/integrations/add_whatsapp/" + + def test_instructions_work(self): + self.client.login(username="alice@example.org", password="password") + r = self.client.get(self.url) + self.assertContains(r, "Get a WhatsApp message") + + @override_settings(USE_PAYMENTS=True) + def test_it_warns_about_limits(self): + self.profile.sms_limit = 0 + self.profile.save() + + self.client.login(username="alice@example.org", password="password") + r = self.client.get(self.url) + self.assertContains(r, "upgrade to a") + + def test_it_creates_channel(self): + form = { + "label": "My Phone", + "value": "+1234567890", + "down": "true", + "up": "true", + } + + self.client.login(username="alice@example.org", password="password") + r = self.client.post(self.url, form) + self.assertRedirects(r, "/integrations/") + + c = Channel.objects.get() + self.assertEqual(c.kind, "whatsapp") + self.assertEqual(c.sms_number, "+1234567890") + self.assertEqual(c.name, "My Phone") + self.assertTrue(c.whatsapp_notify_down) + self.assertTrue(c.whatsapp_notify_up) + self.assertEqual(c.project, self.project) + + def test_it_obeys_up_down_flags(self): + form = {"label": "My Phone", "value": "+1234567890"} + + self.client.login(username="alice@example.org", password="password") + r = self.client.post(self.url, form) + self.assertRedirects(r, "/integrations/") + + c = Channel.objects.get() + self.assertEqual(c.kind, "whatsapp") + self.assertEqual(c.sms_number, "+1234567890") + self.assertEqual(c.name, "My Phone") + self.assertFalse(c.whatsapp_notify_down) + self.assertFalse(c.whatsapp_notify_up) + self.assertEqual(c.project, self.project) + + @override_settings(TWILIO_USE_WHATSAPP=False) + def test_it_obeys_use_whatsapp_flag(self): + self.client.login(username="alice@example.org", password="password") + r = self.client.get(self.url) + self.assertEqual(r.status_code, 404) diff --git a/hc/front/urls.py b/hc/front/urls.py index 4bb6b004..d3643d4f 100644 --- a/hc/front/urls.py +++ b/hc/front/urls.py @@ -39,6 +39,7 @@ channel_urls = [ path("telegram/bot/", views.telegram_bot, name="hc-telegram-webhook"), path("add_telegram/", views.add_telegram, name="hc-add-telegram"), path("add_sms/", views.add_sms, name="hc-add-sms"), + path("add_whatsapp/", views.add_whatsapp, name="hc-add-whatsapp"), path("add_trello/", views.add_trello, name="hc-add-trello"), path("add_trello/settings/", views.trello_settings, name="hc-trello-settings"), path("add_matrix/", views.add_matrix, name="hc-add-matrix"), diff --git a/hc/front/views.py b/hc/front/views.py index d9fa5837..8be9c81c 100644 --- a/hc/front/views.py +++ b/hc/front/views.py @@ -232,6 +232,7 @@ def index(request): "enable_discord": settings.DISCORD_CLIENT_ID is not None, "enable_telegram": settings.TELEGRAM_TOKEN is not None, "enable_sms": settings.TWILIO_AUTH is not None, + "enable_whatsapp": settings.TWILIO_USE_WHATSAPP, "enable_pd": settings.PD_VENDOR_KEY is not None, "enable_trello": settings.TRELLO_APP_KEY is not None, "enable_matrix": settings.MATRIX_ACCESS_TOKEN is not None, @@ -603,6 +604,7 @@ def channels(request): "enable_discord": settings.DISCORD_CLIENT_ID is not None, "enable_telegram": settings.TELEGRAM_TOKEN is not None, "enable_sms": settings.TWILIO_AUTH is not None, + "enable_whatsapp": settings.TWILIO_USE_WHATSAPP, "enable_pd": settings.PD_VENDOR_KEY is not None, "enable_trello": settings.TRELLO_APP_KEY is not None, "enable_matrix": settings.MATRIX_ACCESS_TOKEN is not None, @@ -1222,6 +1224,39 @@ def add_sms(request): return render(request, "integrations/add_sms.html", ctx) +@login_required +def add_whatsapp(request): + if not settings.TWILIO_USE_WHATSAPP: + raise Http404("whatsapp integration is not available") + + if request.method == "POST": + form = AddSmsForm(request.POST) + if form.is_valid(): + channel = Channel(project=request.project, kind="whatsapp") + channel.name = form.cleaned_data["label"] + channel.value = json.dumps( + { + "value": form.cleaned_data["value"], + "up": form.cleaned_data["up"], + "down": form.cleaned_data["down"], + } + ) + channel.save() + + channel.assign_all_checks() + return redirect("hc-channels") + else: + form = AddSmsForm() + + ctx = { + "page": "channels", + "project": request.project, + "form": form, + "profile": request.project.owner_profile, + } + return render(request, "integrations/add_whatsapp.html", ctx) + + @login_required def add_trello(request): if settings.TRELLO_APP_KEY is None: diff --git a/hc/settings.py b/hc/settings.py index 53f0e813..64946e59 100644 --- a/hc/settings.py +++ b/hc/settings.py @@ -187,10 +187,11 @@ PUSHBULLET_CLIENT_SECRET = os.getenv("PUSHBULLET_CLIENT_SECRET") TELEGRAM_BOT_NAME = os.getenv("TELEGRAM_BOT_NAME", "ExampleBot") TELEGRAM_TOKEN = os.getenv("TELEGRAM_TOKEN") -# SMS (Twilio) integration +# SMS and WhatsApp (Twilio) integration TWILIO_ACCOUNT = os.getenv("TWILIO_ACCOUNT") TWILIO_AUTH = os.getenv("TWILIO_AUTH") TWILIO_FROM = os.getenv("TWILIO_FROM") +TWILIO_USE_WHATSAPP = envbool("TWILIO_USE_WHATSAPP", "False") # PagerDuty PD_VENDOR_KEY = os.getenv("PD_VENDOR_KEY") diff --git a/static/img/integrations/whatsapp.png b/static/img/integrations/whatsapp.png new file mode 100644 index 00000000..74eb6b95 Binary files /dev/null and b/static/img/integrations/whatsapp.png differ diff --git a/templates/front/channels.html b/templates/front/channels.html index c60585df..22400afe 100644 --- a/templates/front/channels.html +++ b/templates/front/channels.html @@ -15,8 +15,8 @@ {% endif %}
Name, Details | @@ -90,6 +90,14 @@ list {{ ch.trello_board_list|last }} {% elif ch.kind == "matrix" %} Matrix {{ ch.value }} + {% elif ch.kind == "whatsapp" %} + WhatsApp to {{ ch.sms_number }} + {% if ch.whatsapp_notify_down and not ch.whatsapp_notify_up %} + (down only) + {% endif %} + {% if ch.whatsapp_notify_up and not ch.whatsapp_notify_down %} + (up only) + {% endif %} {% else %} {{ ch.kind }} {% endif %} @@ -127,7 +135,7 @@ {% else %} Never {% endif %} - {% if ch.kind == "sms" %} + {% if ch.kind == "sms" or ch.kind == "whatsapp" %}
---|
Get a WhatsApp message when a check goes up or down.
+ + Add Integration ++ Get a WhatsApp message when a check goes up or down. +
+ + {% if show_pricing and profile.sms_limit == 0 %} ++ Paid plan required. + WhatsApp messaging is not available on the free plan–sending the messages + cost too much! Please upgrade to a + paid plan to enable WhatsApp messaging. +
+ {% endif %} + +