diff --git a/homeassistant/components/dialogflow/__init__.py b/homeassistant/components/dialogflow/__init__.py index 4f2876df296..45fee0f867e 100644 --- a/homeassistant/components/dialogflow/__init__.py +++ b/homeassistant/components/dialogflow/__init__.py @@ -17,6 +17,9 @@ SOURCE = "Home Assistant Dialogflow" CONFIG_SCHEMA = vol.Schema({DOMAIN: {}}, extra=vol.ALLOW_EXTRA) +V1 = 1 +V2 = 2 + class DialogFlowError(HomeAssistantError): """Raised when a DialogFlow error happens.""" @@ -84,23 +87,45 @@ async_remove_entry = config_entry_flow.webhook_async_remove_entry def dialogflow_error_response(message, error): """Return a response saying the error message.""" - dialogflow_response = DialogflowResponse(message["result"]["parameters"]) + api_version = get_api_version(message) + if api_version is V1: + parameters = message["result"]["parameters"] + elif api_version is V2: + parameters = message["queryResult"]["parameters"] + dialogflow_response = DialogflowResponse(parameters, api_version) dialogflow_response.add_speech(error) return dialogflow_response.as_dict() +def get_api_version(message): + """Get API version of Dialogflow message.""" + if message.get("id") is not None: + return V1 + if message.get("responseId") is not None: + return V2 + + async def async_handle_message(hass, message): """Handle a DialogFlow message.""" - req = message.get("result") - action_incomplete = req["actionIncomplete"] + _api_version = get_api_version(message) + if _api_version is V1: + _LOGGER.warning( + "Dialogflow V1 API will be removed on October 23, 2019. Please change your DialogFlow settings to use the V2 api" + ) + req = message.get("result") + action_incomplete = req.get("actionIncomplete", True) + if action_incomplete: + return - if action_incomplete: - return None + elif _api_version is V2: + req = message.get("queryResult") + if req.get("allRequiredParamsPresent", False) is False: + return action = req.get("action", "") parameters = req.get("parameters").copy() parameters["dialogflow_query"] = message - dialogflow_response = DialogflowResponse(parameters) + dialogflow_response = DialogflowResponse(parameters, _api_version) if action == "": raise DialogFlowError( @@ -123,10 +148,11 @@ async def async_handle_message(hass, message): class DialogflowResponse: """Help generating the response for Dialogflow.""" - def __init__(self, parameters): + def __init__(self, parameters, api_version): """Initialize the Dialogflow response.""" self.speech = None self.parameters = {} + self.api_version = api_version # Parameter names replace '.' and '-' for '_' for key, value in parameters.items(): underscored_key = key.replace(".", "_").replace("-", "_") @@ -143,4 +169,8 @@ class DialogflowResponse: def as_dict(self): """Return response in a Dialogflow valid dictionary.""" - return {"speech": self.speech, "displayText": self.speech, "source": SOURCE} + if self.api_version is V1: + return {"speech": self.speech, "displayText": self.speech, "source": SOURCE} + + if self.api_version is V2: + return {"fulfillmentText": self.speech, "source": SOURCE} diff --git a/tests/components/dialogflow/test_init.py b/tests/components/dialogflow/test_init.py index 25e563ebf9b..18a03ff2603 100644 --- a/tests/components/dialogflow/test_init.py +++ b/tests/components/dialogflow/test_init.py @@ -1,5 +1,6 @@ """The tests for the Dialogflow component.""" import json +import copy from unittest.mock import Mock import pytest @@ -90,108 +91,16 @@ async def fixture(hass, aiohttp_client): return await aiohttp_client(hass.http.app), webhook_id -async def test_intent_action_incomplete(fixture): - """Test when action is not completed.""" - mock_client, webhook_id = fixture - data = { +class _Data: + _v1 = { "id": REQUEST_ID, "timestamp": REQUEST_TIMESTAMP, "result": { "source": "agent", "resolvedQuery": "my zodiac sign is virgo", - "speech": "", - "action": "GetZodiacHoroscopeIntent", - "actionIncomplete": True, - "parameters": {"ZodiacSign": "virgo"}, - "metadata": { - "intentId": INTENT_ID, - "webhookUsed": "true", - "webhookForSlotFillingUsed": "false", - "intentName": INTENT_NAME, - }, - "fulfillment": {"speech": "", "messages": [{"type": 0, "speech": ""}]}, - "score": 1, - }, - "status": {"code": 200, "errorType": "success"}, - "sessionId": SESSION_ID, - "originalRequest": None, - } - - response = await mock_client.post( - "/api/webhook/{}".format(webhook_id), data=json.dumps(data) - ) - assert 200 == response.status - assert "" == await response.text() - - -async def test_intent_slot_filling(fixture): - """Test when Dialogflow asks for slot-filling return none.""" - mock_client, webhook_id = fixture - data = { - "id": REQUEST_ID, - "timestamp": REQUEST_TIMESTAMP, - "result": { - "source": "agent", - "resolvedQuery": "my zodiac sign is", - "speech": "", - "action": "GetZodiacHoroscopeIntent", - "actionIncomplete": True, - "parameters": {"ZodiacSign": ""}, - "contexts": [ - { - "name": CONTEXT_NAME, - "parameters": {"ZodiacSign.original": "", "ZodiacSign": ""}, - "lifespan": 2, - }, - { - "name": "tests_ha_dialog_context", - "parameters": {"ZodiacSign.original": "", "ZodiacSign": ""}, - "lifespan": 2, - }, - { - "name": "tests_ha_dialog_params_zodiacsign", - "parameters": {"ZodiacSign.original": "", "ZodiacSign": ""}, - "lifespan": 1, - }, - ], - "metadata": { - "intentId": INTENT_ID, - "webhookUsed": "true", - "webhookForSlotFillingUsed": "true", - "intentName": INTENT_NAME, - }, - "fulfillment": { - "speech": "What is the ZodiacSign?", - "messages": [{"type": 0, "speech": "What is the ZodiacSign?"}], - }, - "score": 0.77, - }, - "status": {"code": 200, "errorType": "success"}, - "sessionId": SESSION_ID, - "originalRequest": None, - } - - response = await mock_client.post( - "/api/webhook/{}".format(webhook_id), data=json.dumps(data) - ) - assert 200 == response.status - assert "" == await response.text() - - -async def test_intent_request_with_parameters(fixture): - """Test a request with parameters.""" - mock_client, webhook_id = fixture - data = { - "id": REQUEST_ID, - "timestamp": REQUEST_TIMESTAMP, - "result": { - "source": "agent", - "resolvedQuery": "my zodiac sign is virgo", - "speech": "", "action": "GetZodiacHoroscopeIntent", "actionIncomplete": False, "parameters": {"ZodiacSign": "virgo"}, - "contexts": [], "metadata": { "intentId": INTENT_ID, "webhookUsed": "true", @@ -205,6 +114,122 @@ async def test_intent_request_with_parameters(fixture): "sessionId": SESSION_ID, "originalRequest": None, } + + _v2 = { + "responseId": REQUEST_ID, + "timestamp": REQUEST_TIMESTAMP, + "queryResult": { + "queryText": "my zodiac sign is virgo", + "action": "GetZodiacHoroscopeIntent", + "allRequiredParamsPresent": True, + "parameters": {"ZodiacSign": "virgo"}, + "intent": { + "name": INTENT_ID, + "webhookState": "true", + "displayName": INTENT_NAME, + }, + "fulfillment": {"text": "", "messages": [{"type": 0, "speech": ""}]}, + "intentDetectionConfidence": 1, + }, + "status": {"code": 200, "errorType": "success"}, + "session": SESSION_ID, + "originalDetectIntentRequest": None, + } + + @property + def v1(self): + return copy.deepcopy(self._v1) + + @property + def v2(self): + return copy.deepcopy(self._v2) + + +Data = _Data() + + +async def test_v1_data(): + """Test for version 1 api based on message.""" + assert dialogflow.get_api_version(Data.v1) == 1 + + +async def test_v2_data(): + """Test for version 2 api based on message.""" + assert dialogflow.get_api_version(Data.v2) == 2 + + +async def test_intent_action_incomplete_v1(fixture): + """Test when action is not completed.""" + mock_client, webhook_id = fixture + data = Data.v1 + data["result"]["actionIncomplete"] = True + + response = await mock_client.post( + "/api/webhook/{}".format(webhook_id), data=json.dumps(data) + ) + assert 200 == response.status + assert "" == await response.text() + + +async def test_intent_action_incomplete_v2(fixture): + """Test when action is not completed.""" + mock_client, webhook_id = fixture + data = Data.v2 + data["queryResult"]["allRequiredParamsPresent"] = False + + response = await mock_client.post( + "/api/webhook/{}".format(webhook_id), data=json.dumps(data) + ) + assert 200 == response.status + assert "" == await response.text() + + +async def test_intent_slot_filling_v1(fixture): + """Test when Dialogflow asks for slot-filling return none.""" + mock_client, webhook_id = fixture + + data = Data.v1 + data["result"].update( + resolvedQuery="my zodiac sign is", + speech="", + actionIncomplete=True, + parameters={"ZodiacSign": ""}, + contexts=[ + { + "name": CONTEXT_NAME, + "parameters": {"ZodiacSign.original": "", "ZodiacSign": ""}, + "lifespan": 2, + }, + { + "name": "tests_ha_dialog_context", + "parameters": {"ZodiacSign.original": "", "ZodiacSign": ""}, + "lifespan": 2, + }, + { + "name": "tests_ha_dialog_params_zodiacsign", + "parameters": {"ZodiacSign.original": "", "ZodiacSign": ""}, + "lifespan": 1, + }, + ], + fulfillment={ + "speech": "What is the ZodiacSign?", + "messages": [{"type": 0, "speech": "What is the ZodiacSign?"}], + }, + score=0.77, + ) + data["result"]["metadata"].update(webhookForSlotFillingUsed="true") + + response = await mock_client.post( + "/api/webhook/{}".format(webhook_id), data=json.dumps(data) + ) + assert 200 == response.status + assert "" == await response.text() + + +async def test_intent_request_with_parameters_v1(fixture): + """Test a request with parameters.""" + mock_client, webhook_id = fixture + data = Data.v1 response = await mock_client.post( "/api/webhook/{}".format(webhook_id), data=json.dumps(data) ) @@ -213,33 +238,23 @@ async def test_intent_request_with_parameters(fixture): assert "You told us your sign is virgo." == text -async def test_intent_request_with_parameters_but_empty(fixture): +async def test_intent_request_with_parameters_v2(fixture): + """Test a request with parameters.""" + mock_client, webhook_id = fixture + data = Data.v2 + response = await mock_client.post( + "/api/webhook/{}".format(webhook_id), data=json.dumps(data) + ) + assert 200 == response.status + text = (await response.json()).get("fulfillmentText") + assert "You told us your sign is virgo." == text + + +async def test_intent_request_with_parameters_but_empty_v1(fixture): """Test a request with parameters but empty value.""" mock_client, webhook_id = fixture - data = { - "id": REQUEST_ID, - "timestamp": REQUEST_TIMESTAMP, - "result": { - "source": "agent", - "resolvedQuery": "my zodiac sign is virgo", - "speech": "", - "action": "GetZodiacHoroscopeIntent", - "actionIncomplete": False, - "parameters": {"ZodiacSign": ""}, - "contexts": [], - "metadata": { - "intentId": INTENT_ID, - "webhookUsed": "true", - "webhookForSlotFillingUsed": "false", - "intentName": INTENT_NAME, - }, - "fulfillment": {"speech": "", "messages": [{"type": 0, "speech": ""}]}, - "score": 1, - }, - "status": {"code": 200, "errorType": "success"}, - "sessionId": SESSION_ID, - "originalRequest": None, - } + data = Data.v1 + data["result"].update(parameters={"ZodiacSign": ""}) response = await mock_client.post( "/api/webhook/{}".format(webhook_id), data=json.dumps(data) ) @@ -248,33 +263,30 @@ async def test_intent_request_with_parameters_but_empty(fixture): assert "You told us your sign is ." == text -async def test_intent_request_without_slots(hass, fixture): +async def test_intent_request_with_parameters_but_empty_v2(fixture): + """Test a request with parameters but empty value.""" + mock_client, webhook_id = fixture + data = Data.v2 + data["queryResult"].update(parameters={"ZodiacSign": ""}) + response = await mock_client.post( + "/api/webhook/{}".format(webhook_id), data=json.dumps(data) + ) + assert 200 == response.status + text = (await response.json()).get("fulfillmentText") + assert "You told us your sign is ." == text + + +async def test_intent_request_without_slots_v1(hass, fixture): """Test a request without slots.""" mock_client, webhook_id = fixture - data = { - "id": REQUEST_ID, - "timestamp": REQUEST_TIMESTAMP, - "result": { - "source": "agent", - "resolvedQuery": "where are we", - "speech": "", - "action": "WhereAreWeIntent", - "actionIncomplete": False, - "parameters": {}, - "contexts": [], - "metadata": { - "intentId": INTENT_ID, - "webhookUsed": "true", - "webhookForSlotFillingUsed": "false", - "intentName": INTENT_NAME, - }, - "fulfillment": {"speech": "", "messages": [{"type": 0, "speech": ""}]}, - "score": 1, - }, - "status": {"code": 200, "errorType": "success"}, - "sessionId": SESSION_ID, - "originalRequest": None, - } + data = Data.v1 + data["result"].update( + resolvedQuery="where are we", + action="WhereAreWeIntent", + parameters={}, + contexts=[], + ) + response = await mock_client.post( "/api/webhook/{}".format(webhook_id), data=json.dumps(data) ) @@ -294,37 +306,45 @@ async def test_intent_request_without_slots(hass, fixture): assert "You are both home, you silly" == text -async def test_intent_request_calling_service(fixture, calls): +async def test_intent_request_without_slots_v2(hass, fixture): + """Test a request without slots.""" + mock_client, webhook_id = fixture + data = Data.v2 + data["queryResult"].update( + queryText="where are we", + action="WhereAreWeIntent", + parameters={}, + outputContexts=[], + ) + + response = await mock_client.post( + "/api/webhook/{}".format(webhook_id), data=json.dumps(data) + ) + assert 200 == response.status + text = (await response.json()).get("fulfillmentText") + + assert "Anne Therese is at unknown and Paulus is at unknown" == text + + hass.states.async_set("device_tracker.paulus", "home") + hass.states.async_set("device_tracker.anne_therese", "home") + + response = await mock_client.post( + "/api/webhook/{}".format(webhook_id), data=json.dumps(data) + ) + assert 200 == response.status + text = (await response.json()).get("fulfillmentText") + assert "You are both home, you silly" == text + + +async def test_intent_request_calling_service_v1(fixture, calls): """Test a request for calling a service. If this request is done async the test could finish before the action has been executed. Hard to test because it will be a race condition. """ mock_client, webhook_id = fixture - data = { - "id": REQUEST_ID, - "timestamp": REQUEST_TIMESTAMP, - "result": { - "source": "agent", - "resolvedQuery": "my zodiac sign is virgo", - "speech": "", - "action": "CallServiceIntent", - "actionIncomplete": False, - "parameters": {"ZodiacSign": "virgo"}, - "contexts": [], - "metadata": { - "intentId": INTENT_ID, - "webhookUsed": "true", - "webhookForSlotFillingUsed": "false", - "intentName": INTENT_NAME, - }, - "fulfillment": {"speech": "", "messages": [{"type": 0, "speech": ""}]}, - "score": 1, - }, - "status": {"code": 200, "errorType": "success"}, - "sessionId": SESSION_ID, - "originalRequest": None, - } + data = Data.v1 + data["result"]["action"] = "CallServiceIntent" call_count = len(calls) response = await mock_client.post( "/api/webhook/{}".format(webhook_id), data=json.dumps(data) @@ -338,33 +358,34 @@ async def test_intent_request_calling_service(fixture, calls): assert "virgo" == call.data.get("hello") -async def test_intent_with_no_action(fixture): +async def test_intent_request_calling_service_v2(fixture, calls): + """Test a request for calling a service. + + If this request is done async the test could finish before the action + has been executed. Hard to test because it will be a race condition. + """ + mock_client, webhook_id = fixture + data = Data.v2 + data["queryResult"]["action"] = "CallServiceIntent" + call_count = len(calls) + response = await mock_client.post( + "/api/webhook/{}".format(webhook_id), data=json.dumps(data) + ) + assert 200 == response.status + assert call_count + 1 == len(calls) + call = calls[-1] + assert "test" == call.domain + assert "dialogflow" == call.service + assert ["switch.test"] == call.data.get("entity_id") + assert "virgo" == call.data.get("hello") + + +async def test_intent_with_no_action_v1(fixture): """Test an intent with no defined action.""" mock_client, webhook_id = fixture - data = { - "id": REQUEST_ID, - "timestamp": REQUEST_TIMESTAMP, - "result": { - "source": "agent", - "resolvedQuery": "my zodiac sign is virgo", - "speech": "", - "action": "", - "actionIncomplete": False, - "parameters": {"ZodiacSign": ""}, - "contexts": [], - "metadata": { - "intentId": INTENT_ID, - "webhookUsed": "true", - "webhookForSlotFillingUsed": "false", - "intentName": INTENT_NAME, - }, - "fulfillment": {"speech": "", "messages": [{"type": 0, "speech": ""}]}, - "score": 1, - }, - "status": {"code": 200, "errorType": "success"}, - "sessionId": SESSION_ID, - "originalRequest": None, - } + data = Data.v1 + del data["result"]["action"] + assert "action" not in data["result"] response = await mock_client.post( "/api/webhook/{}".format(webhook_id), data=json.dumps(data) ) @@ -373,36 +394,41 @@ async def test_intent_with_no_action(fixture): assert "You have not defined an action in your Dialogflow intent." == text -async def test_intent_with_unknown_action(fixture): +async def test_intent_with_no_action_v2(fixture): + """Test an intent with no defined action.""" + mock_client, webhook_id = fixture + data = Data.v2 + del data["queryResult"]["action"] + assert "action" not in data["queryResult"] + response = await mock_client.post( + "/api/webhook/{}".format(webhook_id), data=json.dumps(data) + ) + assert 200 == response.status + text = (await response.json()).get("fulfillmentText") + assert "You have not defined an action in your Dialogflow intent." == text + + +async def test_intent_with_unknown_action_v1(fixture): """Test an intent with an action not defined in the conf.""" mock_client, webhook_id = fixture - data = { - "id": REQUEST_ID, - "timestamp": REQUEST_TIMESTAMP, - "result": { - "source": "agent", - "resolvedQuery": "my zodiac sign is virgo", - "speech": "", - "action": "unknown", - "actionIncomplete": False, - "parameters": {"ZodiacSign": ""}, - "contexts": [], - "metadata": { - "intentId": INTENT_ID, - "webhookUsed": "true", - "webhookForSlotFillingUsed": "false", - "intentName": INTENT_NAME, - }, - "fulfillment": {"speech": "", "messages": [{"type": 0, "speech": ""}]}, - "score": 1, - }, - "status": {"code": 200, "errorType": "success"}, - "sessionId": SESSION_ID, - "originalRequest": None, - } + data = Data.v1 + data["result"]["action"] = "unknown" response = await mock_client.post( "/api/webhook/{}".format(webhook_id), data=json.dumps(data) ) assert 200 == response.status text = (await response.json()).get("speech") assert "This intent is not yet configured within Home Assistant." == text + + +async def test_intent_with_unknown_action_v2(fixture): + """Test an intent with an action not defined in the conf.""" + mock_client, webhook_id = fixture + data = Data.v2 + data["queryResult"]["action"] = "unknown" + response = await mock_client.post( + "/api/webhook/{}".format(webhook_id), data=json.dumps(data) + ) + assert 200 == response.status + text = (await response.json()).get("fulfillmentText") + assert "This intent is not yet configured within Home Assistant." == text