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:
Elias Wernicke
2026-02-08 19:56:07 +01:00
committed by GitHub
parent ba6f1343cc
commit b9bfbc9e98
2 changed files with 166 additions and 1 deletions

View File

@@ -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."""

View File

@@ -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: