diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index 15f2e6d8322..74a8383dd8b 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -33,11 +33,18 @@ SERVICE_PROCESS = "process" SERVICE_RELOAD = "reload" SERVICE_PROCESS_SCHEMA = vol.Schema( - {vol.Required(ATTR_TEXT): cv.string, vol.Optional(ATTR_LANGUAGE): cv.string} + { + vol.Required(ATTR_TEXT): cv.string, + vol.Optional(ATTR_LANGUAGE): cv.string, + } ) -SERVICE_RELOAD_SCHEMA = vol.Schema({vol.Optional(ATTR_LANGUAGE): cv.string}) +SERVICE_RELOAD_SCHEMA = vol.Schema( + { + vol.Optional(ATTR_LANGUAGE): cv.string, + } +) CONFIG_SCHEMA = vol.Schema( @@ -101,8 +108,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @websocket_api.websocket_command( { - "type": "conversation/process", - "text": str, + vol.Required("type"): "conversation/process", + vol.Required("text"): str, vol.Optional("conversation_id"): vol.Any(str, None), vol.Optional("language"): str, } @@ -114,7 +121,7 @@ async def websocket_process( msg: dict[str, Any], ) -> None: """Process text.""" - result = await _async_converse( + result = await async_converse( hass, msg["text"], msg.get("conversation_id"), @@ -142,7 +149,11 @@ async def websocket_prepare( connection.send_result(msg["id"]) -@websocket_api.websocket_command({"type": "conversation/agent/info"}) +@websocket_api.websocket_command( + { + vol.Required("type"): "conversation/agent/info", + } +) @websocket_api.async_response async def websocket_get_agent_info( hass: HomeAssistant, @@ -161,7 +172,12 @@ async def websocket_get_agent_info( ) -@websocket_api.websocket_command({"type": "conversation/onboarding/set", "shown": bool}) +@websocket_api.websocket_command( + { + vol.Required("type"): "conversation/onboarding/set", + vol.Required("shown"): bool, + } +) @websocket_api.async_response async def websocket_set_onboarding( hass: HomeAssistant, @@ -197,7 +213,7 @@ class ConversationProcessView(http.HomeAssistantView): async def post(self, request, data): """Send a request for processing.""" hass = request.app["hass"] - result = await _async_converse( + result = await async_converse( hass, text=data["text"], conversation_id=data.get("conversation_id"), @@ -216,7 +232,7 @@ async def _get_agent(hass: core.HomeAssistant) -> AbstractConversationAgent: return agent -async def _async_converse( +async def async_converse( hass: core.HomeAssistant, text: str, conversation_id: str | None, diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index a87d6606db9..34d27583f3d 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -67,14 +67,13 @@ class DefaultAgent(AbstractConversationAgent): if "intent" not in self.hass.config.components: await setup.async_setup_component(self.hass, "intent", {}) - config = config.get(DOMAIN, {}) - self.hass.data.setdefault(DOMAIN, {}) - - if config: + if config and config.get(DOMAIN): _LOGGER.warning( "Custom intent sentences have been moved to config/custom_sentences" ) + self.hass.data.setdefault(DOMAIN, {}) + async def async_process( self, text: str, diff --git a/homeassistant/components/mobile_app/manifest.json b/homeassistant/components/mobile_app/manifest.json index eb0bf100aee..d7afb7b9998 100644 --- a/homeassistant/components/mobile_app/manifest.json +++ b/homeassistant/components/mobile_app/manifest.json @@ -5,7 +5,7 @@ "documentation": "https://www.home-assistant.io/integrations/mobile_app", "requirements": ["PyNaCl==1.5.0"], "dependencies": ["http", "webhook", "person", "tag", "websocket_api"], - "after_dependencies": ["cloud", "camera", "notify"], + "after_dependencies": ["cloud", "camera", "conversation", "notify"], "codeowners": ["@home-assistant/core"], "quality_scale": "internal", "iot_class": "local_push", diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index 107058352c1..7a86755bc5d 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -15,7 +15,13 @@ from nacl.exceptions import CryptoError from nacl.secret import SecretBox import voluptuous as vol -from homeassistant.components import camera, cloud, notify as hass_notify, tag +from homeassistant.components import ( + camera, + cloud, + conversation, + notify as hass_notify, + tag, +) from homeassistant.components.binary_sensor import ( DEVICE_CLASSES as BINARY_SENSOR_CLASSES, ) @@ -301,6 +307,28 @@ async def webhook_fire_event( return empty_okay_response() +@WEBHOOK_COMMANDS.register("conversation_process") +@validate_schema( + { + vol.Required("text"): cv.string, + vol.Optional("language"): cv.string, + vol.Optional("conversation_id"): cv.string, + } +) +async def webhook_conversation_process( + hass: HomeAssistant, config_entry: ConfigEntry, data: dict[str, Any] +) -> Response: + """Handle a conversation process webhook.""" + result = await conversation.async_converse( + hass, + text=data["text"], + language=data.get("language"), + conversation_id=data.get("conversation_id"), + context=registration_context(config_entry.data), + ) + return webhook_response(result.as_dict(), registration=config_entry.data) + + @WEBHOOK_COMMANDS.register("stream_camera") @validate_schema({vol.Required(ATTR_CAMERA_ENTITY_ID): cv.string}) async def webhook_stream_camera( diff --git a/tests/components/conversation/__init__.py b/tests/components/conversation/__init__.py index ea244c00df8..9f842d4ff5f 100644 --- a/tests/components/conversation/__init__.py +++ b/tests/components/conversation/__init__.py @@ -1 +1,30 @@ """Tests for the conversation component.""" +from __future__ import annotations + +from homeassistant.components import conversation +from homeassistant.core import Context +from homeassistant.helpers import intent + + +class MockAgent(conversation.AbstractConversationAgent): + """Test Agent.""" + + def __init__(self) -> None: + """Initialize the agent.""" + self.calls = [] + self.response = "Test response" + + async def async_process( + self, + text: str, + context: Context, + conversation_id: str | None = None, + language: str | None = None, + ) -> conversation.ConversationResult | None: + """Process some text.""" + self.calls.append((text, context, conversation_id, language)) + response = intent.IntentResponse(language=language) + response.async_set_speech(self.response) + return conversation.ConversationResult( + response=response, conversation_id=conversation_id + ) diff --git a/tests/components/conversation/conftest.py b/tests/components/conversation/conftest.py new file mode 100644 index 00000000000..5dbd52dc841 --- /dev/null +++ b/tests/components/conversation/conftest.py @@ -0,0 +1,15 @@ +"""Conversation test helpers.""" + +import pytest + +from homeassistant.components import conversation + +from . import MockAgent + + +@pytest.fixture +def mock_agent(hass): + """Mock agent.""" + agent = MockAgent() + conversation.async_set_agent(hass, agent) + return agent diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index 88ca0a078f6..52dc5ff9756 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -222,25 +222,8 @@ async def test_http_api_wrong_data(hass, init_components, hass_client): assert resp.status == HTTPStatus.BAD_REQUEST -async def test_custom_agent(hass, hass_client, hass_admin_user): +async def test_custom_agent(hass, hass_client, hass_admin_user, mock_agent): """Test a custom conversation agent.""" - - calls = [] - - class MyAgent(conversation.AbstractConversationAgent): - """Test Agent.""" - - async def async_process(self, text, context, conversation_id, language): - """Process some text.""" - calls.append((text, context, conversation_id, language)) - response = intent.IntentResponse(language=language) - response.async_set_speech("Test response") - return conversation.ConversationResult( - response=response, conversation_id=conversation_id - ) - - conversation.async_set_agent(hass, MyAgent()) - assert await async_setup_component(hass, "conversation", {}) client = await hass_client() @@ -270,11 +253,11 @@ async def test_custom_agent(hass, hass_client, hass_admin_user): "conversation_id": "test-conv-id", } - assert len(calls) == 1 - assert calls[0][0] == "Test Text" - assert calls[0][1].user_id == hass_admin_user.id - assert calls[0][2] == "test-conv-id" - assert calls[0][3] == "test-language" + assert len(mock_agent.calls) == 1 + assert mock_agent.calls[0][0] == "Test Text" + assert mock_agent.calls[0][1].user_id == hass_admin_user.id + assert mock_agent.calls[0][2] == "test-conv-id" + assert mock_agent.calls[0][3] == "test-language" @pytest.mark.parametrize( diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py index 0794aab0fda..996471c939f 100644 --- a/tests/components/mobile_app/test_webhook.py +++ b/tests/components/mobile_app/test_webhook.py @@ -22,6 +22,10 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from .const import CALL_SERVICE, FIRE_EVENT, REGISTER_CLEARTEXT, RENDER_TEMPLATE, UPDATE from tests.common import async_capture_events, async_mock_service +from tests.components.conversation.conftest import mock_agent + +# To avoid autoflake8 removing the import +mock_agent = mock_agent def encrypt_payload(secret_key, payload, encode_json=True): @@ -974,3 +978,42 @@ async def test_reregister_sensor(hass, create_registrations, webhook_client): assert reg_resp.status == HTTPStatus.CREATED entry = ent_reg.async_get("sensor.test_1_battery_state") assert entry.disabled_by is None + + +async def test_webhook_handle_conversation_process( + hass, create_registrations, webhook_client, mock_agent +): + """Test that we can converse.""" + webhook_client.server.app.router._frozen = False + + resp = await webhook_client.post( + "/api/webhook/{}".format(create_registrations[1]["webhook_id"]), + json={ + "type": "conversation_process", + "data": { + "text": "Turn the kitchen light off", + }, + }, + ) + + assert resp.status == HTTPStatus.OK + json = await resp.json() + assert json == { + "response": { + "response_type": "action_done", + "card": {}, + "speech": { + "plain": { + "extra_data": None, + "speech": "Test response", + } + }, + "language": hass.config.language, + "data": { + "targets": [], + "success": [], + "failed": [], + }, + }, + "conversation_id": None, + }