diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b5f2da3..0d2f1ea9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ All notable changes to this project will be documented in this file. - Updated Discord integration to use discord.com instead of discordapp.com - Add "Failure Keyword" filtering for inbound emails (#396) - Add support for multiple, comma-separated keywords (#396) +- New integration: phone calls (#403) ### Bug Fixes - Removing Pager Team integration, project appears to be discontinued diff --git a/hc/api/models.py b/hc/api/models.py index 8e7b3040..37e0771b 100644 --- a/hc/api/models.py +++ b/hc/api/models.py @@ -50,6 +50,7 @@ CHANNEL_KINDS = ( ("shell", "Shell Command"), ("zulip", "Zulip"), ("spike", "Spike"), + ("call", "Phone Call"), ) PO_PRIORITIES = {-2: "lowest", -1: "low", 0: "normal", 1: "high", 2: "emergency"} @@ -460,6 +461,8 @@ class Channel(models.Model): return transports.Zulip(self) elif self.kind == "spike": return transports.Spike(self) + elif self.kind == "call": + return transports.Call(self) else: raise NotImplementedError("Unknown channel kind: %s" % self.kind) @@ -640,7 +643,7 @@ class Channel(models.Model): @property def sms_number(self): - assert self.kind in ("sms", "whatsapp") + assert self.kind in ("call", "sms", "whatsapp") if self.value.startswith("{"): doc = json.loads(self.value) return doc["value"] diff --git a/hc/api/tests/test_notify.py b/hc/api/tests/test_notify.py index b7ec1c8b..a64da31f 100644 --- a/hc/api/tests/test_notify.py +++ b/hc/api/tests/test_notify.py @@ -754,6 +754,44 @@ class NotifyTestCase(BaseTestCase): self.assertEqual(email.to[0], "alice@example.org") self.assertEqual(email.subject, "Monthly WhatsApp Limit Reached") + @patch("hc.api.transports.requests.request") + def test_call(self, mock_post): + value = {"label": "foo", "value": "+1234567890"} + self._setup_data("call", json.dumps(value)) + self.check.last_ping = now() - td(hours=2) + + mock_post.return_value.status_code = 200 + + self.channel.notify(self.check) + assert Notification.objects.count() == 1 + + args, kwargs = mock_post.call_args + payload = kwargs["data"] + self.assertEqual(payload["To"], "+1234567890") + + @patch("hc.api.transports.requests.request") + def test_call_limit(self, mock_post): + # At limit already: + self.profile.last_sms_date = now() + self.profile.sms_sent = 50 + self.profile.save() + + definition = {"value": "+1234567890"} + self._setup_data("call", json.dumps(definition)) + + self.channel.notify(self.check) + self.assertFalse(mock_post.called) + + n = Notification.objects.get() + self.assertTrue("Monthly phone call limit exceeded" in n.error) + + # And email should have been sent + self.assertEqual(len(mail.outbox), 1) + + email = mail.outbox[0] + self.assertEqual(email.to[0], "alice@example.org") + self.assertEqual(email.subject, "Monthly Phone Call Limit Reached") + @patch("apprise.Apprise") @override_settings(APPRISE_ENABLED=True) def test_apprise_enabled(self, mock_apprise): diff --git a/hc/api/transports.py b/hc/api/transports.py index 1333a350..3ed517e5 100644 --- a/hc/api/transports.py +++ b/hc/api/transports.py @@ -478,6 +478,31 @@ class Sms(HttpTransport): return self.post(url, data=data, auth=auth) +class Call(HttpTransport): + URL = "https://api.twilio.com/2010-04-01/Accounts/%s/Calls.json" + + def is_noop(self, check): + return check.status != "down" + + def notify(self, check): + profile = Profile.objects.for_user(self.channel.project.owner) + if not profile.authorize_sms(): + profile.send_sms_limit_notice("phone call") + return "Monthly phone call limit exceeded" + + url = self.URL % settings.TWILIO_ACCOUNT + auth = (settings.TWILIO_ACCOUNT, settings.TWILIO_AUTH) + twiml = tmpl("call_message.html", check=check, site_name=settings.SITE_NAME) + + data = { + "From": settings.TWILIO_FROM, + "To": self.channel.sms_number, + "Twiml": twiml, + } + + return self.post(url, data=data, auth=auth) + + class WhatsApp(HttpTransport): URL = "https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json" diff --git a/hc/front/tests/test_add_call.py b/hc/front/tests/test_add_call.py new file mode 100644 index 00000000..ecf14ce9 --- /dev/null +++ b/hc/front/tests/test_add_call.py @@ -0,0 +1,59 @@ +from django.test.utils import override_settings +from hc.api.models import Channel +from hc.test import BaseTestCase + + +@override_settings(TWILIO_ACCOUNT="foo", TWILIO_AUTH="foo", TWILIO_FROM="123") +class AddCallTestCase(BaseTestCase): + def setUp(self): + super(AddCallTestCase, self).setUp() + self.url = "/projects/%s/add_call/" % self.project.code + + 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 phone call") + + @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"} + + self.client.login(username="alice@example.org", password="password") + r = self.client.post(self.url, form) + self.assertRedirects(r, self.channels_url) + + c = Channel.objects.get() + self.assertEqual(c.kind, "call") + self.assertEqual(c.sms_number, "+1234567890") + self.assertEqual(c.name, "My Phone") + self.assertEqual(c.project, self.project) + + def test_it_rejects_bad_number(self): + form = {"value": "not a phone number"} + + self.client.login(username="alice@example.org", password="password") + r = self.client.post(self.url, form) + self.assertContains(r, "Invalid phone number format.") + + def test_it_trims_whitespace(self): + form = {"value": " +1234567890 "} + + self.client.login(username="alice@example.org", password="password") + self.client.post(self.url, form) + + c = Channel.objects.get() + self.assertEqual(c.sms_number, "+1234567890") + + @override_settings(TWILIO_AUTH=None) + def test_it_requires_credentials(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 016918cb..bce15731 100644 --- a/hc/front/urls.py +++ b/hc/front/urls.py @@ -55,6 +55,7 @@ channel_urls = [ project_urls = [ path("add_apprise/", views.add_apprise, name="hc-add-apprise"), + path("add_call/", views.add_call, name="hc-add-call"), path("add_discord/", views.add_discord, name="hc-add-discord"), path("add_email/", views.add_email, name="hc-add-email"), path("add_matrix/", views.add_matrix, name="hc-add-matrix"), diff --git a/hc/front/views.py b/hc/front/views.py index 2df1777a..e892781c 100644 --- a/hc/front/views.py +++ b/hc/front/views.py @@ -269,6 +269,7 @@ def index(request): "enable_shell": settings.SHELL_ENABLED is True, "enable_slack_btn": settings.SLACK_CLIENT_ID is not None, "enable_sms": settings.TWILIO_AUTH is not None, + "enable_call": settings.TWILIO_AUTH is not None, "enable_telegram": settings.TELEGRAM_TOKEN is not None, "enable_trello": settings.TRELLO_APP_KEY is not None, "enable_whatsapp": settings.TWILIO_USE_WHATSAPP, @@ -702,6 +703,7 @@ def channels(request, code): "enable_shell": settings.SHELL_ENABLED is True, "enable_slack_btn": settings.SLACK_CLIENT_ID is not None, "enable_sms": settings.TWILIO_AUTH is not None, + "enable_call": settings.TWILIO_AUTH is not None, "enable_telegram": settings.TELEGRAM_TOKEN is not None, "enable_trello": settings.TRELLO_APP_KEY is not None, "enable_whatsapp": settings.TWILIO_USE_WHATSAPP, @@ -1515,6 +1517,32 @@ def add_sms(request, code): return render(request, "integrations/add_sms.html", ctx) +@require_setting("TWILIO_AUTH") +@login_required +def add_call(request, code): + project = _get_project_for_user(request, code) + if request.method == "POST": + form = forms.AddSmsForm(request.POST) + if form.is_valid(): + channel = Channel(project=project, kind="call") + channel.name = form.cleaned_data["label"] + channel.value = json.dumps({"value": form.cleaned_data["value"]}) + channel.save() + + channel.assign_all_checks() + return redirect("hc-p-channels", project.code) + else: + form = forms.AddSmsForm() + + ctx = { + "page": "channels", + "project": project, + "form": form, + "profile": project.owner_profile, + } + return render(request, "integrations/add_call.html", ctx) + + @require_setting("TWILIO_USE_WHATSAPP") @login_required def add_whatsapp(request, code): diff --git a/static/css/icomoon.css b/static/css/icomoon.css index 0cb69798..d0897aa5 100644 --- a/static/css/icomoon.css +++ b/static/css/icomoon.css @@ -1,10 +1,10 @@ @font-face { font-family: 'icomoon'; - src: url('../fonts/icomoon.eot?37tb6f'); - src: url('../fonts/icomoon.eot?37tb6f#iefix') format('embedded-opentype'), - url('../fonts/icomoon.ttf?37tb6f') format('truetype'), - url('../fonts/icomoon.woff?37tb6f') format('woff'), - url('../fonts/icomoon.svg?37tb6f#icomoon') format('svg'); + src: url('../fonts/icomoon.eot?e4bee3'); + src: url('../fonts/icomoon.eot?e4bee3#iefix') format('embedded-opentype'), + url('../fonts/icomoon.ttf?e4bee3') format('truetype'), + url('../fonts/icomoon.woff?e4bee3') format('woff'), + url('../fonts/icomoon.svg?e4bee3#icomoon') format('svg'); font-weight: normal; font-style: normal; } @@ -24,6 +24,10 @@ -moz-osx-font-smoothing: grayscale; } +.icon-call:before { + content: "\e91a"; + color: #e81a34; +} .icon-spike:before { content: "\e919"; color: #007bff; diff --git a/static/fonts/icomoon.eot b/static/fonts/icomoon.eot index 93b4cadc..8eec81ef 100644 Binary files a/static/fonts/icomoon.eot and b/static/fonts/icomoon.eot differ diff --git a/static/fonts/icomoon.svg b/static/fonts/icomoon.svg index 56733477..a796fb5d 100644 --- a/static/fonts/icomoon.svg +++ b/static/fonts/icomoon.svg @@ -44,4 +44,5 @@ + \ No newline at end of file diff --git a/static/fonts/icomoon.ttf b/static/fonts/icomoon.ttf index 65a8141f..f1890ce8 100644 Binary files a/static/fonts/icomoon.ttf and b/static/fonts/icomoon.ttf differ diff --git a/static/fonts/icomoon.woff b/static/fonts/icomoon.woff index 68106e2a..8a3cfe19 100644 Binary files a/static/fonts/icomoon.woff and b/static/fonts/icomoon.woff differ diff --git a/static/img/integrations/call.png b/static/img/integrations/call.png new file mode 100644 index 00000000..94a30dcd Binary files /dev/null and b/static/img/integrations/call.png differ diff --git a/templates/emails/sms-limit-subject.html b/templates/emails/sms-limit-subject.html index a5c6e634..3a18059b 100644 --- a/templates/emails/sms-limit-subject.html +++ b/templates/emails/sms-limit-subject.html @@ -1 +1 @@ -Monthly {{ transport }} Limit Reached \ No newline at end of file +Monthly {% if transport == "phone call" %}Phone Call{% else %}{{ transport }}{% endif %} Limit Reached \ No newline at end of file diff --git a/templates/front/channels.html b/templates/front/channels.html index 6c06b408..e8a331bd 100644 --- a/templates/front/channels.html +++ b/templates/front/channels.html @@ -68,6 +68,8 @@ {% endif %} {% elif ch.kind == "sms" %} SMS to {{ ch.sms_number }} + {% elif ch.kind == "call" %} + Phone call to {{ ch.sms_number }} {% elif ch.kind == "trello" %} Trello board {{ ch.trello_board_list|first }}, @@ -122,7 +124,7 @@ {% else %} Never {% endif %} - {% if ch.kind == "sms" or ch.kind == "whatsapp" %} + {% if ch.kind == "sms" or ch.kind == "whatsapp" or ch.kind == "call" %}

Used {{ profile.sms_sent_this_month }} of {{ profile.sms_limit }} sends this month.

{% endif %} @@ -191,6 +193,17 @@ Add Integration + {% if enable_call %} +
  • + Phone icon + +

    Phone Call

    +

    Get a phone call when a check goes down.

    + Add Integration +
  • + {% endif %} +
  • Webhook icon @@ -199,6 +212,7 @@

    Receive a HTTP callback when a check goes down.

    Add Integration
  • + {% if enable_apprise %}
  • + {% if enable_call %} +
    +
    + +

    + {% trans "Phone Call" %}
    +   +

    +
    +
    + {% endif %} + {% endif %} -
    +
    Spike.sh icon

    diff --git a/templates/integrations/add_call.html b/templates/integrations/add_call.html new file mode 100644 index 00000000..7f2b838f --- /dev/null +++ b/templates/integrations/add_call.html @@ -0,0 +1,84 @@ +{% extends "base.html" %} +{% load humanize static hc_extras %} + +{% block title %}Add Phone Call Integration - {{ site_name }}{% endblock %} + + +{% block content %} +
    +
    +

    Phone Call

    +

    + Get a phone call when a check goes down. When you pick up the call, + a text-to-speech engine will read out a message and then hang up. +

    + + {% if show_pricing and profile.sms_limit == 0 %} +

    + Paid plan required. + Phone call notifications are not available on the free plan–they + cost too much! Please upgrade to a + paid plan + to enable phone call notifications. +

    + {% endif %} + +

    Integration Settings

    + +
    + {% csrf_token %} +
    + +
    + + + {% if form.label.errors %} +
    + {{ form.label.errors|join:"" }} +
    + {% else %} + + Optional. If you add multiple phone numbers, + the labels will help you tell them apart. + + {% endif %} +
    +
    +
    + +
    + + + {% if form.value.errors %} +
    + {{ form.value.errors|join:"" }} +
    + {% else %} + + Make sure the phone number starts with "+" and has the + country code. + + {% endif %} +
    +
    +
    +
    + +
    +
    +
    +
    +
    +{% endblock %} diff --git a/templates/integrations/call_message.html b/templates/integrations/call_message.html new file mode 100644 index 00000000..0a0664b4 --- /dev/null +++ b/templates/integrations/call_message.html @@ -0,0 +1 @@ +Hello! A message from {{ site_name }}: The check "{{ check.name_then_code }}" is down. \ No newline at end of file diff --git a/templates/payments/pricing.html b/templates/payments/pricing.html index 47cba7a6..692405d5 100644 --- a/templates/payments/pricing.html +++ b/templates/payments/pricing.html @@ -87,7 +87,7 @@

  • API access
  • - 5 SMS & WhatsApp credits + 5 SMS, WhatsApp and call credits
  •  
  • @@ -120,7 +120,7 @@
  • API access
  • - 5 SMS & WhatsApp credits + 5 SMS, WhatsApp and call credits
  • Email support
  • @@ -156,7 +156,7 @@
  • API access
  • - 50 SMS & WhatsApp credits + 50 SMS, WhatsApp and call credits
  • Email support
  • @@ -192,7 +192,7 @@
  • API access
  • - 500 SMS & WhatsApp credits + 500 SMS, WhatsApp and call credits
  • Priority email support
  • @@ -303,10 +303,10 @@ {% if not request.user.is_authenticated %}