mirror of
https://github.com/Electric-Special/ha-core.git
synced 2026-03-21 03:03:17 +01:00
Validate conversation_command in start timer intent (#149915)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com>
This commit is contained in:
@@ -30,6 +30,7 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
TIMER_NOT_FOUND_RESPONSE = "timer_not_found"
|
TIMER_NOT_FOUND_RESPONSE = "timer_not_found"
|
||||||
MULTIPLE_TIMERS_MATCHED_RESPONSE = "multiple_timers_matched"
|
MULTIPLE_TIMERS_MATCHED_RESPONSE = "multiple_timers_matched"
|
||||||
NO_TIMER_SUPPORT_RESPONSE = "no_timer_support"
|
NO_TIMER_SUPPORT_RESPONSE = "no_timer_support"
|
||||||
|
NO_TIMER_COMMAND_RESPONSE = "no_timer_command"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -192,6 +193,17 @@ class MultipleTimersMatchedError(intent.IntentHandleError):
|
|||||||
super().__init__("Multiple timers matched", MULTIPLE_TIMERS_MATCHED_RESPONSE)
|
super().__init__("Multiple timers matched", MULTIPLE_TIMERS_MATCHED_RESPONSE)
|
||||||
|
|
||||||
|
|
||||||
|
class NoTimerCommandError(intent.IntentHandleError):
|
||||||
|
"""Error when a conversation command does not match any intent."""
|
||||||
|
|
||||||
|
def __init__(self, command: str) -> None:
|
||||||
|
"""Initialize error."""
|
||||||
|
super().__init__(
|
||||||
|
f"Intent not recognized: {command}",
|
||||||
|
NO_TIMER_COMMAND_RESPONSE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TimersNotSupportedError(intent.IntentHandleError):
|
class TimersNotSupportedError(intent.IntentHandleError):
|
||||||
"""Error when a timer intent is used from a device that isn't registered to handle timer events."""
|
"""Error when a timer intent is used from a device that isn't registered to handle timer events."""
|
||||||
|
|
||||||
@@ -836,6 +848,12 @@ class StartTimerIntentHandler(intent.IntentHandler):
|
|||||||
# Fail early if this is not a delayed command
|
# Fail early if this is not a delayed command
|
||||||
raise TimersNotSupportedError(intent_obj.device_id)
|
raise TimersNotSupportedError(intent_obj.device_id)
|
||||||
|
|
||||||
|
# Validate conversation command if provided
|
||||||
|
if conversation_command and not await self._validate_conversation_command(
|
||||||
|
intent_obj, conversation_command
|
||||||
|
):
|
||||||
|
raise NoTimerCommandError(conversation_command)
|
||||||
|
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
if "name" in slots:
|
if "name" in slots:
|
||||||
name = slots["name"]["value"]
|
name = slots["name"]["value"]
|
||||||
@@ -865,6 +883,48 @@ class StartTimerIntentHandler(intent.IntentHandler):
|
|||||||
|
|
||||||
return intent_obj.create_response()
|
return intent_obj.create_response()
|
||||||
|
|
||||||
|
async def _validate_conversation_command(
|
||||||
|
self, intent_obj: intent.Intent, conversation_command: str
|
||||||
|
) -> bool:
|
||||||
|
"""Validate that a conversation command can be executed."""
|
||||||
|
from homeassistant.components.conversation import ( # noqa: PLC0415
|
||||||
|
ConversationInput,
|
||||||
|
async_get_agent,
|
||||||
|
default_agent,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Only validate if using the default agent
|
||||||
|
conversation_agent = async_get_agent(
|
||||||
|
intent_obj.hass, intent_obj.conversation_agent_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if conversation_agent is None or not isinstance(
|
||||||
|
conversation_agent, default_agent.DefaultAgent
|
||||||
|
):
|
||||||
|
return True # Skip validation
|
||||||
|
|
||||||
|
test_input = ConversationInput(
|
||||||
|
text=conversation_command,
|
||||||
|
context=intent_obj.context,
|
||||||
|
conversation_id=None,
|
||||||
|
device_id=intent_obj.device_id,
|
||||||
|
satellite_id=intent_obj.satellite_id,
|
||||||
|
language=intent_obj.language,
|
||||||
|
agent_id=conversation_agent.entity_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# check for sentence trigger
|
||||||
|
if (
|
||||||
|
await conversation_agent.async_recognize_sentence_trigger(test_input)
|
||||||
|
) is not None:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# check for intent
|
||||||
|
if (await conversation_agent.async_recognize_intent(test_input)) is not None:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class CancelTimerIntentHandler(intent.IntentHandler):
|
class CancelTimerIntentHandler(intent.IntentHandler):
|
||||||
"""Intent handler for cancelling a timer."""
|
"""Intent handler for cancelling a timer."""
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
"""Tests for intent timers."""
|
"""Tests for intent timers."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from homeassistant.components import conversation
|
||||||
from homeassistant.components.intent.timers import (
|
from homeassistant.components.intent.timers import (
|
||||||
|
TIMER_DATA,
|
||||||
MultipleTimersMatchedError,
|
MultipleTimersMatchedError,
|
||||||
|
NoTimerCommandError,
|
||||||
TimerEventType,
|
TimerEventType,
|
||||||
TimerInfo,
|
TimerInfo,
|
||||||
TimerManager,
|
TimerManager,
|
||||||
@@ -32,6 +35,8 @@ from tests.common import MockConfigEntry
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def init_components(hass: HomeAssistant) -> None:
|
async def init_components(hass: HomeAssistant) -> None:
|
||||||
"""Initialize required components for tests."""
|
"""Initialize required components for tests."""
|
||||||
|
assert await async_setup_component(hass, "homeassistant", {})
|
||||||
|
assert await async_setup_component(hass, "conversation", {})
|
||||||
assert await async_setup_component(hass, "intent", {})
|
assert await async_setup_component(hass, "intent", {})
|
||||||
|
|
||||||
|
|
||||||
@@ -1463,6 +1468,106 @@ async def test_start_timer_with_conversation_command(
|
|||||||
assert mock_converse.call_args.args[1] == test_command
|
assert mock_converse.call_args.args[1] == test_command
|
||||||
|
|
||||||
|
|
||||||
|
async def test_start_timer_with_sentence_trigger_validation(
|
||||||
|
hass: HomeAssistant, init_components
|
||||||
|
) -> None:
|
||||||
|
"""Test starting a timer with a conversation command validates against sentence triggers."""
|
||||||
|
device_id = "test_device"
|
||||||
|
timer_name = "test timer"
|
||||||
|
test_command = "turn on the lights"
|
||||||
|
agent_id = None # Default agent
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.conversation.async_get_agent"
|
||||||
|
) as mock_get_agent:
|
||||||
|
mock_agent = MagicMock(spec=conversation.default_agent.DefaultAgent)
|
||||||
|
mock_agent.async_recognize_sentence_trigger = AsyncMock(
|
||||||
|
return_value=MagicMock(),
|
||||||
|
)
|
||||||
|
mock_agent.async_recognize_intent = AsyncMock(return_value=None)
|
||||||
|
mock_get_agent.return_value = mock_agent
|
||||||
|
|
||||||
|
result = await intent.async_handle(
|
||||||
|
hass,
|
||||||
|
"test",
|
||||||
|
intent.INTENT_START_TIMER,
|
||||||
|
{
|
||||||
|
"name": {"value": timer_name},
|
||||||
|
"seconds": {"value": 5},
|
||||||
|
"conversation_command": {"value": test_command},
|
||||||
|
},
|
||||||
|
device_id=device_id,
|
||||||
|
conversation_agent_id=agent_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.response_type == intent.IntentResponseType.ACTION_DONE
|
||||||
|
|
||||||
|
# Verify the sentence trigger was checked
|
||||||
|
mock_agent.async_recognize_sentence_trigger.assert_called_once()
|
||||||
|
|
||||||
|
# Verify timer was created successfully
|
||||||
|
timer_manager = hass.data[TIMER_DATA]
|
||||||
|
assert len(timer_manager.timers) == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_start_timer_with_invalid_conversation_command(
|
||||||
|
hass: HomeAssistant, init_components
|
||||||
|
) -> None:
|
||||||
|
"""Test starting a timer with an invalid conversation command fails validation."""
|
||||||
|
device_id = "test_device"
|
||||||
|
timer_name = "test timer"
|
||||||
|
invalid_command = "invalid command that does not exist"
|
||||||
|
agent_id = None # Default agent
|
||||||
|
|
||||||
|
with pytest.raises(NoTimerCommandError):
|
||||||
|
await intent.async_handle(
|
||||||
|
hass,
|
||||||
|
"test",
|
||||||
|
intent.INTENT_START_TIMER,
|
||||||
|
{
|
||||||
|
"name": {"value": timer_name},
|
||||||
|
"seconds": {"value": 5},
|
||||||
|
"conversation_command": {"value": invalid_command},
|
||||||
|
},
|
||||||
|
device_id=device_id,
|
||||||
|
conversation_agent_id=agent_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify no timer was created
|
||||||
|
timer_manager = hass.data[TIMER_DATA]
|
||||||
|
assert len(timer_manager.timers) == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_start_timer_with_conversation_command_skip_validation(
|
||||||
|
hass: HomeAssistant, init_components
|
||||||
|
) -> None:
|
||||||
|
"""Test starting a timer with a conversation command skips validation for non-default agents."""
|
||||||
|
device_id = "test_device"
|
||||||
|
timer_name = "test timer"
|
||||||
|
invalid_command = "invalid command that does not exist"
|
||||||
|
agent_id = "conversation.test_llm_agent"
|
||||||
|
|
||||||
|
# This should NOT raise an error because validation is skipped for all non-default agents
|
||||||
|
result = await intent.async_handle(
|
||||||
|
hass,
|
||||||
|
"test",
|
||||||
|
intent.INTENT_START_TIMER,
|
||||||
|
{
|
||||||
|
"name": {"value": timer_name},
|
||||||
|
"seconds": {"value": 5},
|
||||||
|
"conversation_command": {"value": invalid_command},
|
||||||
|
},
|
||||||
|
device_id=device_id,
|
||||||
|
conversation_agent_id=agent_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.response_type == intent.IntentResponseType.ACTION_DONE
|
||||||
|
|
||||||
|
# Verify timer was created successfully despite invalid command
|
||||||
|
timer_manager = hass.data[TIMER_DATA]
|
||||||
|
assert len(timer_manager.timers) == 1
|
||||||
|
|
||||||
|
|
||||||
async def test_pause_unpause_timer_disambiguate(
|
async def test_pause_unpause_timer_disambiguate(
|
||||||
hass: HomeAssistant, init_components
|
hass: HomeAssistant, init_components
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user