diff --git a/.travis.yml b/.travis.yml index 846aee9..b732780 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,7 +12,6 @@ matrix: before_install: - python3 -m pip install -U pip - - python3 -m pip install coverage install: - python3 -m pip install -U -r dev-requirements.txt script: diff --git a/dev-requirements.txt b/dev-requirements.txt index ea97a6c..02a2640 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -2,3 +2,4 @@ pytest==4.2.0 pytest-asyncio==0.10.0 pytest-aiohttp==0.3.0 +coverage==4.5.2 diff --git a/readme.rst b/readme.rst index 9891e90..af077d9 100644 --- a/readme.rst +++ b/readme.rst @@ -54,9 +54,13 @@ Deployment In Heroku, set the environment variables: +- ``NEXMO_API_KEY``: The Nexmo API Key + +- ``NEXMO_API_SECRET``: The Nexmo API Key Secret + - ``NEXMO_APP_ID``: The Nexmo App ID -- ``NEXMO_PRIVATE_KEY_VOICE_APP``: The content of the private key (from private.key file). +- ``NEXMO_PRIVATE_KEY_VOICE_APP``: The path to the Nexmo App's private key (private.key file). It looks like the following: ``----- BEGIN PRIVATE KEY ---- blablahblah ---- END PRIVATE KEY ----`` diff --git a/tests/test_webservice.py b/tests/test_webservice.py index 2ac57e6..c6ff74c 100644 --- a/tests/test_webservice.py +++ b/tests/test_webservice.py @@ -1,7 +1,7 @@ import json import os +from unittest import mock -import mock import pytest from aiohttp import web @@ -13,6 +13,10 @@ routes, ) +mock_api_key = "apikey" + +mock_api_secret = "sssh" + mock_private_key = """ -----BEGIN PRIVATE KEY----- MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCxephXEnphnbrX @@ -29,12 +33,16 @@ def test_get_nexmo_client(monkeypatch): + monkeypatch.setitem(os.environ, "NEXMO_API_KEY", mock_api_key) + monkeypatch.setitem(os.environ, "NEXMO_API_SECRET", mock_api_secret) monkeypatch.setitem(os.environ, "NEXMO_APP_ID", "app_id") monkeypatch.setitem(os.environ, "NEXMO_PRIVATE_KEY_VOICE_APP", mock_private_key) client = get_nexmo_client() assert client.application_id == "app_id" assert client.private_key == mock_private_key + assert client.api_key == mock_api_key + assert client.api_secret == mock_api_secret def test_get_phone_numbers(monkeypatch): @@ -62,6 +70,7 @@ class FakeNexmoClient: def __init__(self): self.calls_created = [] self.speech_sent = [] + self.messages_sent = [] def create_call(self, params=None, **kwargs): self.calls_created.append(params) @@ -69,6 +78,9 @@ def create_call(self, params=None, **kwargs): def send_speech(self, params=None, **kwargs): self.speech_sent.append(params) + def send_message(self, params=None, **kwargs): + self.messages_sent.append(params) + @pytest.fixture def webservice_cli(loop, aiohttp_client, monkeypatch): @@ -171,3 +183,35 @@ async def test_answer_call_auto_record(webservice_cli_autorecord): assert response[1]["record"] is True assert response[1]["eventUrl"] == ["https://hooks.zapier.com/1111/2222"] + + +async def test_inbound_sms(webservice_cli): + with mock.patch("webservice.__main__.get_nexmo_client") as mock_nexmo_client: + nexmo_client = FakeNexmoClient() + mock_nexmo_client.return_value = nexmo_client + + reporter_number = "1234" + hotline_number = "5678" + text = "onetwothree" + + resp = await webservice_cli.get( + f"/webhook/inbound-sms/?msisdn={reporter_number}&to={hotline_number}&text={text}" + ) + + assert resp.status == 204 + + # One for each person on staff, one to respond to the reporter. + assert len(nexmo_client.messages_sent) == 3 + + # Check that the organizers got the message. + for n, phone_number_dict in enumerate(mock_phone_numbers): + message = nexmo_client.messages_sent[n] + assert message["from"] == hotline_number + assert message["to"] == phone_number_dict["phone"] + assert text in message["text"] + + # Check the response message + response_message = nexmo_client.messages_sent[-1] + assert response_message["to"] == reporter_number + assert response_message["from"] == hotline_number + assert "CoC" in response_message["text"] diff --git a/webservice/__main__.py b/webservice/__main__.py index 4985f2d..08a1771 100644 --- a/webservice/__main__.py +++ b/webservice/__main__.py @@ -18,10 +18,14 @@ def get_nexmo_client(): """Return an instance of Nexmo client library""" + api_key = os.environ.get("NEXMO_API_KEY") + api_secret = os.environ.get("NEXMO_API_SECRET") app_id = os.environ.get("NEXMO_APP_ID") private_key = os.environ.get("NEXMO_PRIVATE_KEY_VOICE_APP") - client = nexmo.Client(application_id=app_id, private_key=private_key) + client = nexmo.Client( + key=api_key, secret=api_secret, application_id=app_id, private_key=private_key + ) return client @@ -72,6 +76,7 @@ async def answer_call(request): Dial everyone on staff, adding them to the same conversation """ + hotline_number = request.rel_url.query["to"] conversation_uuid = request.rel_url.query["conversation_uuid"].strip() call_uuid = request.rel_url.query["uuid"].strip() greeting = "You've reached the PyCascades Code of Conduct Hotline." @@ -105,12 +110,9 @@ async def answer_call(request): client.create_call( { "to": [{"type": "phone", "number": phone_number_dict["phone"]}], - "from": { - "type": "phone", - "number": os.environ.get("NEXMO_HOTLINE_NUMBER"), - }, + "from": {"type": "phone", "number": hotline_number}, "answer_url": [ - f"https://pycascades-coc-hotline-2019.herokuapp.com/webhook/answer_conference_call/{conversation_uuid}/{call_uuid}/" + f"http://{request.host}/webhook/answer_conference_call/{conversation_uuid}/{call_uuid}/" ], } ) @@ -166,6 +168,44 @@ async def answer_conference_call(request): return web.json_response(ncco) +@routes.get("/webhook/inbound-sms/") +async def inbound_sms(request): + """Webhook event that receives and inbound SMS messages and notifies all + staff. + + It also sends the sender an acknowledgment. + + This should be configured in Nexmo to send using GET. + """ + hotline_number = request.rel_url.query["to"] + from_number = request.rel_url.query["msisdn"] + message = request.rel_url.query["text"] + + client = get_nexmo_client() + phone_numbers = get_phone_numbers() + + for phone_number_dict in phone_numbers: + client.send_message( + { + # Send from the number the received this message. + "from": hotline_number, + "to": phone_number_dict["phone"], + "text": f"{from_number}: {message}"[:140], + } + ) + + # Reply to the sender and acknowledge receipt. + client.send_message( + { + "from": hotline_number, + "to": from_number, + "text": "Thanks for contacting the CoC hotline. Someone should follow-up shortly. Note: they may follow up from a different number.", + } + ) + + return web.Response(status=204) + + if __name__ == "__main__": # pragma: no cover app = web.Application() app.router.add_routes(routes)