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 @@
Used {{ profile.sms_sent_this_month }} of {{ profile.sms_limit }} sends this month.
{% endif %} @@ -191,6 +193,17 @@ Add Integration + {% if enable_call %} +Get a phone call when a check goes down.
+ Add Integration +Receive a HTTP callback when a check goes down.
Add Integration+ 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 %} + +The maximum number of SMS & WhatsApp notifications per month.
+The maximum number of SMS, WhatsApp and phone call notifications per month.
-The limit is applied to the combined number of sent SMS and - WhatsApp notifications.
+The limit is applied to the combined number of sent SMS, WhatsApp and phone + call notifications.