Upgrade Dialogflow to work with V2 API (#25975)

* Handle v1 and v2 dialog api requests

* Stylistic changes
This commit is contained in:
Jay 2019-08-18 00:19:13 -04:00 committed by Paulus Schoutsen
parent 65aa4148a4
commit 6907e8e9dc
2 changed files with 283 additions and 227 deletions

View File

@ -17,6 +17,9 @@ SOURCE = "Home Assistant Dialogflow"
CONFIG_SCHEMA = vol.Schema({DOMAIN: {}}, extra=vol.ALLOW_EXTRA) CONFIG_SCHEMA = vol.Schema({DOMAIN: {}}, extra=vol.ALLOW_EXTRA)
V1 = 1
V2 = 2
class DialogFlowError(HomeAssistantError): class DialogFlowError(HomeAssistantError):
"""Raised when a DialogFlow error happens.""" """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): def dialogflow_error_response(message, error):
"""Return a response saying the error message.""" """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) dialogflow_response.add_speech(error)
return dialogflow_response.as_dict() 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): async def async_handle_message(hass, message):
"""Handle a DialogFlow message.""" """Handle a DialogFlow message."""
_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") req = message.get("result")
action_incomplete = req["actionIncomplete"] action_incomplete = req.get("actionIncomplete", True)
if action_incomplete: if action_incomplete:
return None return
elif _api_version is V2:
req = message.get("queryResult")
if req.get("allRequiredParamsPresent", False) is False:
return
action = req.get("action", "") action = req.get("action", "")
parameters = req.get("parameters").copy() parameters = req.get("parameters").copy()
parameters["dialogflow_query"] = message parameters["dialogflow_query"] = message
dialogflow_response = DialogflowResponse(parameters) dialogflow_response = DialogflowResponse(parameters, _api_version)
if action == "": if action == "":
raise DialogFlowError( raise DialogFlowError(
@ -123,10 +148,11 @@ async def async_handle_message(hass, message):
class DialogflowResponse: class DialogflowResponse:
"""Help generating the response for Dialogflow.""" """Help generating the response for Dialogflow."""
def __init__(self, parameters): def __init__(self, parameters, api_version):
"""Initialize the Dialogflow response.""" """Initialize the Dialogflow response."""
self.speech = None self.speech = None
self.parameters = {} self.parameters = {}
self.api_version = api_version
# Parameter names replace '.' and '-' for '_' # Parameter names replace '.' and '-' for '_'
for key, value in parameters.items(): for key, value in parameters.items():
underscored_key = key.replace(".", "_").replace("-", "_") underscored_key = key.replace(".", "_").replace("-", "_")
@ -143,4 +169,8 @@ class DialogflowResponse:
def as_dict(self): def as_dict(self):
"""Return response in a Dialogflow valid dictionary.""" """Return response in a Dialogflow valid dictionary."""
if self.api_version is V1:
return {"speech": self.speech, "displayText": self.speech, "source": SOURCE} return {"speech": self.speech, "displayText": self.speech, "source": SOURCE}
if self.api_version is V2:
return {"fulfillmentText": self.speech, "source": SOURCE}

View File

@ -1,5 +1,6 @@
"""The tests for the Dialogflow component.""" """The tests for the Dialogflow component."""
import json import json
import copy
from unittest.mock import Mock from unittest.mock import Mock
import pytest import pytest
@ -90,18 +91,15 @@ async def fixture(hass, aiohttp_client):
return await aiohttp_client(hass.http.app), webhook_id return await aiohttp_client(hass.http.app), webhook_id
async def test_intent_action_incomplete(fixture): class _Data:
"""Test when action is not completed.""" _v1 = {
mock_client, webhook_id = fixture
data = {
"id": REQUEST_ID, "id": REQUEST_ID,
"timestamp": REQUEST_TIMESTAMP, "timestamp": REQUEST_TIMESTAMP,
"result": { "result": {
"source": "agent", "source": "agent",
"resolvedQuery": "my zodiac sign is virgo", "resolvedQuery": "my zodiac sign is virgo",
"speech": "",
"action": "GetZodiacHoroscopeIntent", "action": "GetZodiacHoroscopeIntent",
"actionIncomplete": True, "actionIncomplete": False,
"parameters": {"ZodiacSign": "virgo"}, "parameters": {"ZodiacSign": "virgo"},
"metadata": { "metadata": {
"intentId": INTENT_ID, "intentId": INTENT_ID,
@ -117,6 +115,55 @@ async def test_intent_action_incomplete(fixture):
"originalRequest": None, "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( response = await mock_client.post(
"/api/webhook/{}".format(webhook_id), data=json.dumps(data) "/api/webhook/{}".format(webhook_id), data=json.dumps(data)
) )
@ -124,20 +171,30 @@ async def test_intent_action_incomplete(fixture):
assert "" == await response.text() assert "" == await response.text()
async def test_intent_slot_filling(fixture): 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.""" """Test when Dialogflow asks for slot-filling return none."""
mock_client, webhook_id = fixture mock_client, webhook_id = fixture
data = {
"id": REQUEST_ID, data = Data.v1
"timestamp": REQUEST_TIMESTAMP, data["result"].update(
"result": { resolvedQuery="my zodiac sign is",
"source": "agent", speech="",
"resolvedQuery": "my zodiac sign is", actionIncomplete=True,
"speech": "", parameters={"ZodiacSign": ""},
"action": "GetZodiacHoroscopeIntent", contexts=[
"actionIncomplete": True,
"parameters": {"ZodiacSign": ""},
"contexts": [
{ {
"name": CONTEXT_NAME, "name": CONTEXT_NAME,
"parameters": {"ZodiacSign.original": "", "ZodiacSign": ""}, "parameters": {"ZodiacSign.original": "", "ZodiacSign": ""},
@ -154,22 +211,13 @@ async def test_intent_slot_filling(fixture):
"lifespan": 1, "lifespan": 1,
}, },
], ],
"metadata": { fulfillment={
"intentId": INTENT_ID,
"webhookUsed": "true",
"webhookForSlotFillingUsed": "true",
"intentName": INTENT_NAME,
},
"fulfillment": {
"speech": "What is the ZodiacSign?", "speech": "What is the ZodiacSign?",
"messages": [{"type": 0, "speech": "What is the ZodiacSign?"}], "messages": [{"type": 0, "speech": "What is the ZodiacSign?"}],
}, },
"score": 0.77, score=0.77,
}, )
"status": {"code": 200, "errorType": "success"}, data["result"]["metadata"].update(webhookForSlotFillingUsed="true")
"sessionId": SESSION_ID,
"originalRequest": None,
}
response = await mock_client.post( response = await mock_client.post(
"/api/webhook/{}".format(webhook_id), data=json.dumps(data) "/api/webhook/{}".format(webhook_id), data=json.dumps(data)
@ -178,33 +226,10 @@ async def test_intent_slot_filling(fixture):
assert "" == await response.text() assert "" == await response.text()
async def test_intent_request_with_parameters(fixture): async def test_intent_request_with_parameters_v1(fixture):
"""Test a request with parameters.""" """Test a request with parameters."""
mock_client, webhook_id = fixture mock_client, webhook_id = fixture
data = { data = Data.v1
"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",
"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( response = await mock_client.post(
"/api/webhook/{}".format(webhook_id), data=json.dumps(data) "/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 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.""" """Test a request with parameters but empty value."""
mock_client, webhook_id = fixture mock_client, webhook_id = fixture
data = { data = Data.v1
"id": REQUEST_ID, data["result"].update(parameters={"ZodiacSign": ""})
"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,
}
response = await mock_client.post( response = await mock_client.post(
"/api/webhook/{}".format(webhook_id), data=json.dumps(data) "/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 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.""" """Test a request without slots."""
mock_client, webhook_id = fixture mock_client, webhook_id = fixture
data = { data = Data.v1
"id": REQUEST_ID, data["result"].update(
"timestamp": REQUEST_TIMESTAMP, resolvedQuery="where are we",
"result": { action="WhereAreWeIntent",
"source": "agent", parameters={},
"resolvedQuery": "where are we", contexts=[],
"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,
}
response = await mock_client.post( response = await mock_client.post(
"/api/webhook/{}".format(webhook_id), data=json.dumps(data) "/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 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. """Test a request for calling a service.
If this request is done async the test could finish before the action 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. has been executed. Hard to test because it will be a race condition.
""" """
mock_client, webhook_id = fixture mock_client, webhook_id = fixture
data = { data = Data.v1
"id": REQUEST_ID, data["result"]["action"] = "CallServiceIntent"
"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,
}
call_count = len(calls) call_count = len(calls)
response = await mock_client.post( response = await mock_client.post(
"/api/webhook/{}".format(webhook_id), data=json.dumps(data) "/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") 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.""" """Test an intent with no defined action."""
mock_client, webhook_id = fixture mock_client, webhook_id = fixture
data = { data = Data.v1
"id": REQUEST_ID, del data["result"]["action"]
"timestamp": REQUEST_TIMESTAMP, assert "action" not in data["result"]
"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,
}
response = await mock_client.post( response = await mock_client.post(
"/api/webhook/{}".format(webhook_id), data=json.dumps(data) "/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 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.""" """Test an intent with an action not defined in the conf."""
mock_client, webhook_id = fixture mock_client, webhook_id = fixture
data = { data = Data.v1
"id": REQUEST_ID, data["result"]["action"] = "unknown"
"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,
}
response = await mock_client.post( response = await mock_client.post(
"/api/webhook/{}".format(webhook_id), data=json.dumps(data) "/api/webhook/{}".format(webhook_id), data=json.dumps(data)
) )
assert 200 == response.status assert 200 == response.status
text = (await response.json()).get("speech") text = (await response.json()).get("speech")
assert "This intent is not yet configured within Home Assistant." == text 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