From 45649824ca65b49cc676393d660e8c18dd488acf Mon Sep 17 00:00:00 2001 From: Marcel Hoppe Date: Tue, 28 Aug 2018 00:20:12 +0200 Subject: [PATCH] rewrite hangouts to use intents instead of commands (#16220) * rewrite hangouts to use intents instead of commands * small fixes * remove configured_hangouts check and CONFIG_SCHEMA * Lint * add import from .config_flow --- .../components/conversation/__init__.py | 38 +--- homeassistant/components/conversation/util.py | 35 ++++ homeassistant/components/hangouts/__init__.py | 53 +++++- homeassistant/components/hangouts/const.py | 26 +-- .../components/hangouts/hangouts_bot.py | 174 ++++++++++-------- tests/components/test_conversation.py | 12 +- 6 files changed, 196 insertions(+), 142 deletions(-) create mode 100644 homeassistant/components/conversation/util.py diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index 9cb00a84583a..d8d386f5ca04 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -11,6 +11,7 @@ import voluptuous as vol from homeassistant import core from homeassistant.components import http +from homeassistant.components.conversation.util import create_matcher from homeassistant.components.http.data_validator import ( RequestDataValidator) from homeassistant.components.cover import (INTENT_OPEN_COVER, @@ -74,7 +75,7 @@ def async_register(hass, intent_type, utterances): if isinstance(utterance, REGEX_TYPE): conf.append(utterance) else: - conf.append(_create_matcher(utterance)) + conf.append(create_matcher(utterance)) async def async_setup(hass, config): @@ -91,7 +92,7 @@ async def async_setup(hass, config): if conf is None: conf = intents[intent_type] = [] - conf.extend(_create_matcher(utterance) for utterance in utterances) + conf.extend(create_matcher(utterance) for utterance in utterances) async def process(service): """Parse text into commands.""" @@ -146,39 +147,6 @@ async def async_setup(hass, config): return True -def _create_matcher(utterance): - """Create a regex that matches the utterance.""" - # Split utterance into parts that are type: NORMAL, GROUP or OPTIONAL - # Pattern matches (GROUP|OPTIONAL): Change light to [the color] {name} - parts = re.split(r'({\w+}|\[[\w\s]+\] *)', utterance) - # Pattern to extract name from GROUP part. Matches {name} - group_matcher = re.compile(r'{(\w+)}') - # Pattern to extract text from OPTIONAL part. Matches [the color] - optional_matcher = re.compile(r'\[([\w ]+)\] *') - - pattern = ['^'] - for part in parts: - group_match = group_matcher.match(part) - optional_match = optional_matcher.match(part) - - # Normal part - if group_match is None and optional_match is None: - pattern.append(part) - continue - - # Group part - if group_match is not None: - pattern.append( - r'(?P<{}>[\w ]+?)\s*'.format(group_match.groups()[0])) - - # Optional part - elif optional_match is not None: - pattern.append(r'(?:{} *)?'.format(optional_match.groups()[0])) - - pattern.append('$') - return re.compile(''.join(pattern), re.I) - - async def _process(hass, text): """Process a line of text.""" intents = hass.data.get(DOMAIN, {}) diff --git a/homeassistant/components/conversation/util.py b/homeassistant/components/conversation/util.py new file mode 100644 index 000000000000..60d861afdbe4 --- /dev/null +++ b/homeassistant/components/conversation/util.py @@ -0,0 +1,35 @@ +"""Util for Conversation.""" +import re + + +def create_matcher(utterance): + """Create a regex that matches the utterance.""" + # Split utterance into parts that are type: NORMAL, GROUP or OPTIONAL + # Pattern matches (GROUP|OPTIONAL): Change light to [the color] {name} + parts = re.split(r'({\w+}|\[[\w\s]+\] *)', utterance) + # Pattern to extract name from GROUP part. Matches {name} + group_matcher = re.compile(r'{(\w+)}') + # Pattern to extract text from OPTIONAL part. Matches [the color] + optional_matcher = re.compile(r'\[([\w ]+)\] *') + + pattern = ['^'] + for part in parts: + group_match = group_matcher.match(part) + optional_match = optional_matcher.match(part) + + # Normal part + if group_match is None and optional_match is None: + pattern.append(part) + continue + + # Group part + if group_match is not None: + pattern.append( + r'(?P<{}>[\w ]+?)\s*'.format(group_match.groups()[0])) + + # Optional part + elif optional_match is not None: + pattern.append(r'(?:{} *)?'.format(optional_match.groups()[0])) + + pattern.append('$') + return re.compile(''.join(pattern), re.I) diff --git a/homeassistant/components/hangouts/__init__.py b/homeassistant/components/hangouts/__init__.py index 8ebacc3736b6..72a7e015a224 100644 --- a/homeassistant/components/hangouts/__init__.py +++ b/homeassistant/components/hangouts/__init__.py @@ -11,28 +11,56 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.helpers import dispatcher +import homeassistant.helpers.config_validation as cv -from .config_flow import configured_hangouts from .const import ( - CONF_BOT, CONF_COMMANDS, CONF_REFRESH_TOKEN, DOMAIN, + CONF_BOT, CONF_INTENTS, CONF_REFRESH_TOKEN, DOMAIN, EVENT_HANGOUTS_CONNECTED, EVENT_HANGOUTS_CONVERSATIONS_CHANGED, MESSAGE_SCHEMA, SERVICE_SEND_MESSAGE, - SERVICE_UPDATE) + SERVICE_UPDATE, CONF_SENTENCES, CONF_MATCHERS, + CONF_ERROR_SUPPRESSED_CONVERSATIONS, INTENT_SCHEMA, TARGETS_SCHEMA) + +# We need an import from .config_flow, without it .config_flow is never loaded. +from .config_flow import HangoutsFlowHandler # noqa: F401 + REQUIREMENTS = ['hangups==0.4.5'] _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Optional(CONF_INTENTS, default={}): vol.Schema({ + cv.string: INTENT_SCHEMA + }), + vol.Optional(CONF_ERROR_SUPPRESSED_CONVERSATIONS, default=[]): + [TARGETS_SCHEMA] + }) +}, extra=vol.ALLOW_EXTRA) + async def async_setup(hass, config): """Set up the Hangouts bot component.""" - config = config.get(DOMAIN, {}) - hass.data[DOMAIN] = {CONF_COMMANDS: config.get(CONF_COMMANDS, [])} + from homeassistant.components.conversation import create_matcher - if configured_hangouts(hass) is None: - hass.async_add_job(hass.config_entries.flow.async_init( - DOMAIN, context={'source': config_entries.SOURCE_IMPORT} - )) + config = config.get(DOMAIN) + if config is None: + return True + + hass.data[DOMAIN] = {CONF_INTENTS: config.get(CONF_INTENTS), + CONF_ERROR_SUPPRESSED_CONVERSATIONS: + config.get(CONF_ERROR_SUPPRESSED_CONVERSATIONS)} + + for data in hass.data[DOMAIN][CONF_INTENTS].values(): + matchers = [] + for sentence in data[CONF_SENTENCES]: + matchers.append(create_matcher(sentence)) + + data[CONF_MATCHERS] = matchers + + hass.async_add_job(hass.config_entries.flow.async_init( + DOMAIN, context={'source': config_entries.SOURCE_IMPORT} + )) return True @@ -47,7 +75,8 @@ async def async_setup_entry(hass, config): bot = HangoutsBot( hass, config.data.get(CONF_REFRESH_TOKEN), - hass.data[DOMAIN][CONF_COMMANDS]) + hass.data[DOMAIN][CONF_INTENTS], + hass.data[DOMAIN][CONF_ERROR_SUPPRESSED_CONVERSATIONS]) hass.data[DOMAIN][CONF_BOT] = bot except GoogleAuthError as exception: _LOGGER.error("Hangouts failed to log in: %s", str(exception)) @@ -62,6 +91,10 @@ async def async_setup_entry(hass, config): hass, EVENT_HANGOUTS_CONVERSATIONS_CHANGED, bot.async_update_conversation_commands) + dispatcher.async_dispatcher_connect( + hass, + EVENT_HANGOUTS_CONVERSATIONS_CHANGED, + bot.async_handle_update_error_suppressed_conversations) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, bot.async_handle_hass_stop) diff --git a/homeassistant/components/hangouts/const.py b/homeassistant/components/hangouts/const.py index 7083307f3e22..3b96edf93a29 100644 --- a/homeassistant/components/hangouts/const.py +++ b/homeassistant/components/hangouts/const.py @@ -4,7 +4,6 @@ import logging import voluptuous as vol from homeassistant.components.notify import ATTR_MESSAGE, ATTR_TARGET -from homeassistant.const import CONF_NAME import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger('homeassistant.components.hangouts') @@ -18,17 +17,18 @@ CONF_BOT = 'bot' CONF_CONVERSATIONS = 'conversations' CONF_DEFAULT_CONVERSATIONS = 'default_conversations' +CONF_ERROR_SUPPRESSED_CONVERSATIONS = 'error_suppressed_conversations' -CONF_COMMANDS = 'commands' -CONF_WORD = 'word' -CONF_EXPRESSION = 'expression' - -EVENT_HANGOUTS_COMMAND = 'hangouts_command' +CONF_INTENTS = 'intents' +CONF_INTENT_TYPE = 'intent_type' +CONF_SENTENCES = 'sentences' +CONF_MATCHERS = 'matchers' EVENT_HANGOUTS_CONNECTED = 'hangouts_connected' EVENT_HANGOUTS_DISCONNECTED = 'hangouts_disconnected' EVENT_HANGOUTS_USERS_CHANGED = 'hangouts_users_changed' EVENT_HANGOUTS_CONVERSATIONS_CHANGED = 'hangouts_conversations_changed' +EVENT_HANGOUTS_MESSAGE_RECEIVED = 'hangouts_message_received' CONF_CONVERSATION_ID = 'id' CONF_CONVERSATION_NAME = 'name' @@ -59,20 +59,10 @@ MESSAGE_SCHEMA = vol.Schema({ vol.Required(ATTR_MESSAGE): [MESSAGE_SEGMENT_SCHEMA] }) -COMMAND_SCHEMA = vol.All( +INTENT_SCHEMA = vol.All( # Basic Schema vol.Schema({ - vol.Exclusive(CONF_WORD, 'trigger'): cv.string, - vol.Exclusive(CONF_EXPRESSION, 'trigger'): cv.is_regex, - vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_SENTENCES): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_CONVERSATIONS): [TARGETS_SCHEMA] }), - # Make sure it's either a word or an expression command - cv.has_at_least_one_key(CONF_WORD, CONF_EXPRESSION) ) - -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - vol.Optional(CONF_COMMANDS, default=[]): [COMMAND_SCHEMA] - }) -}, extra=vol.ALLOW_EXTRA) diff --git a/homeassistant/components/hangouts/hangouts_bot.py b/homeassistant/components/hangouts/hangouts_bot.py index d9ffb4cbace7..15f4156d3744 100644 --- a/homeassistant/components/hangouts/hangouts_bot.py +++ b/homeassistant/components/hangouts/hangouts_bot.py @@ -1,13 +1,14 @@ """The Hangouts Bot.""" import logging -import re -from homeassistant.helpers import dispatcher +from homeassistant.helpers import dispatcher, intent from .const import ( - ATTR_MESSAGE, ATTR_TARGET, CONF_CONVERSATIONS, CONF_EXPRESSION, CONF_NAME, - CONF_WORD, DOMAIN, EVENT_HANGOUTS_COMMAND, EVENT_HANGOUTS_CONNECTED, - EVENT_HANGOUTS_CONVERSATIONS_CHANGED, EVENT_HANGOUTS_DISCONNECTED) + ATTR_MESSAGE, ATTR_TARGET, CONF_CONVERSATIONS, DOMAIN, + EVENT_HANGOUTS_CONNECTED, EVENT_HANGOUTS_CONVERSATIONS_CHANGED, + EVENT_HANGOUTS_DISCONNECTED, EVENT_HANGOUTS_MESSAGE_RECEIVED, + CONF_MATCHERS, CONF_CONVERSATION_ID, + CONF_CONVERSATION_NAME) _LOGGER = logging.getLogger(__name__) @@ -15,20 +16,34 @@ _LOGGER = logging.getLogger(__name__) class HangoutsBot: """The Hangouts Bot.""" - def __init__(self, hass, refresh_token, commands): + def __init__(self, hass, refresh_token, intents, error_suppressed_convs): """Set up the client.""" self.hass = hass self._connected = False self._refresh_token = refresh_token - self._commands = commands + self._intents = intents + self._conversation_intents = None - self._word_commands = None - self._expression_commands = None self._client = None self._user_list = None self._conversation_list = None + self._error_suppressed_convs = error_suppressed_convs + self._error_suppressed_conv_ids = None + + dispatcher.async_dispatcher_connect( + self.hass, EVENT_HANGOUTS_MESSAGE_RECEIVED, + self._async_handle_conversation_message) + + def _resolve_conversation_id(self, obj): + if CONF_CONVERSATION_ID in obj: + return obj[CONF_CONVERSATION_ID] + if CONF_CONVERSATION_NAME in obj: + conv = self._resolve_conversation_name(obj[CONF_CONVERSATION_NAME]) + if conv is not None: + return conv.id_ + return None def _resolve_conversation_name(self, name): for conv in self._conversation_list.get_all(): @@ -38,89 +53,100 @@ class HangoutsBot: def async_update_conversation_commands(self, _): """Refresh the commands for every conversation.""" - self._word_commands = {} - self._expression_commands = {} + self._conversation_intents = {} - for command in self._commands: - if command.get(CONF_CONVERSATIONS): + for intent_type, data in self._intents.items(): + if data.get(CONF_CONVERSATIONS): conversations = [] - for conversation in command.get(CONF_CONVERSATIONS): - if 'id' in conversation: - conversations.append(conversation['id']) - elif 'name' in conversation: - conversations.append(self._resolve_conversation_name( - conversation['name']).id_) - command['_' + CONF_CONVERSATIONS] = conversations + for conversation in data.get(CONF_CONVERSATIONS): + conv_id = self._resolve_conversation_id(conversation) + if conv_id is not None: + conversations.append(conv_id) + data['_' + CONF_CONVERSATIONS] = conversations else: - command['_' + CONF_CONVERSATIONS] = \ + data['_' + CONF_CONVERSATIONS] = \ [conv.id_ for conv in self._conversation_list.get_all()] - if command.get(CONF_WORD): - for conv_id in command['_' + CONF_CONVERSATIONS]: - if conv_id not in self._word_commands: - self._word_commands[conv_id] = {} - word = command[CONF_WORD].lower() - self._word_commands[conv_id][word] = command - elif command.get(CONF_EXPRESSION): - command['_' + CONF_EXPRESSION] = re.compile( - command.get(CONF_EXPRESSION)) + for conv_id in data['_' + CONF_CONVERSATIONS]: + if conv_id not in self._conversation_intents: + self._conversation_intents[conv_id] = {} - for conv_id in command['_' + CONF_CONVERSATIONS]: - if conv_id not in self._expression_commands: - self._expression_commands[conv_id] = [] - self._expression_commands[conv_id].append(command) + self._conversation_intents[conv_id][intent_type] = data try: self._conversation_list.on_event.remove_observer( - self._handle_conversation_event) + self._async_handle_conversation_event) except ValueError: pass self._conversation_list.on_event.add_observer( - self._handle_conversation_event) + self._async_handle_conversation_event) - def _handle_conversation_event(self, event): + def async_handle_update_error_suppressed_conversations(self, _): + """Resolve the list of error suppressed conversations.""" + self._error_suppressed_conv_ids = [] + for conversation in self._error_suppressed_convs: + conv_id = self._resolve_conversation_id(conversation) + if conv_id is not None: + self._error_suppressed_conv_ids.append(conv_id) + + async def _async_handle_conversation_event(self, event): from hangups import ChatMessageEvent - if event.__class__ is ChatMessageEvent: - self._handle_conversation_message( - event.conversation_id, event.user_id, event) + if isinstance(event, ChatMessageEvent): + dispatcher.async_dispatcher_send(self.hass, + EVENT_HANGOUTS_MESSAGE_RECEIVED, + event.conversation_id, + event.user_id, event) - def _handle_conversation_message(self, conv_id, user_id, event): + async def _async_handle_conversation_message(self, + conv_id, user_id, event): """Handle a message sent to a conversation.""" user = self._user_list.get_user(user_id) if user.is_self: return + message = event.text _LOGGER.debug("Handling message '%s' from %s", - event.text, user.full_name) + message, user.full_name) - event_data = None + intents = self._conversation_intents.get(conv_id) + if intents is not None: + is_error = False + try: + intent_result = await self._async_process(intents, message) + except (intent.UnknownIntent, intent.IntentHandleError) as err: + is_error = True + intent_result = intent.IntentResponse() + intent_result.async_set_speech(str(err)) + + if intent_result is None: + is_error = True + intent_result = intent.IntentResponse() + intent_result.async_set_speech( + "Sorry, I didn't understand that") + + message = intent_result.as_dict().get('speech', {})\ + .get('plain', {}).get('speech') + + if (message is not None) and not ( + is_error and conv_id in self._error_suppressed_conv_ids): + await self._async_send_message( + [{'text': message, 'parse_str': True}], + [{CONF_CONVERSATION_ID: conv_id}]) + + async def _async_process(self, intents, text): + """Detect a matching intent.""" + for intent_type, data in intents.items(): + for matcher in data.get(CONF_MATCHERS, []): + match = matcher.match(text) - pieces = event.text.split(' ') - cmd = pieces[0].lower() - command = self._word_commands.get(conv_id, {}).get(cmd) - if command: - event_data = { - 'command': command[CONF_NAME], - 'conversation_id': conv_id, - 'user_id': user_id, - 'user_name': user.full_name, - 'data': pieces[1:] - } - else: - # After single-word commands, check all regex commands in the room - for command in self._expression_commands.get(conv_id, []): - match = command['_' + CONF_EXPRESSION].match(event.text) if not match: continue - event_data = { - 'command': command[CONF_NAME], - 'conversation_id': conv_id, - 'user_id': user_id, - 'user_name': user.full_name, - 'data': match.groupdict() - } - if event_data is not None: - self.hass.bus.fire(EVENT_HANGOUTS_COMMAND, event_data) + + response = await self.hass.helpers.intent.async_handle( + DOMAIN, intent_type, + {key: {'value': value} for key, value + in match.groupdict().items()}, text) + return response async def async_connect(self): """Login to the Google Hangouts.""" @@ -163,10 +189,12 @@ class HangoutsBot: conversations = [] for target in targets: conversation = None - if 'id' in target: - conversation = self._conversation_list.get(target['id']) - elif 'name' in target: - conversation = self._resolve_conversation_name(target['name']) + if CONF_CONVERSATION_ID in target: + conversation = self._conversation_list.get( + target[CONF_CONVERSATION_ID]) + elif CONF_CONVERSATION_NAME in target: + conversation = self._resolve_conversation_name( + target[CONF_CONVERSATION_NAME]) if conversation is not None: conversations.append(conversation) @@ -200,8 +228,8 @@ class HangoutsBot: users_in_conversation = [] for user in conv.users: users_in_conversation.append(user.full_name) - conversations[str(i)] = {'id': str(conv.id_), - 'name': conv.name, + conversations[str(i)] = {CONF_CONVERSATION_ID: str(conv.id_), + CONF_CONVERSATION_NAME: conv.name, 'users': users_in_conversation} self.hass.states.async_set("{}.conversations".format(DOMAIN), diff --git a/tests/components/test_conversation.py b/tests/components/test_conversation.py index 6a1d5a55c47e..61247b5bdde1 100644 --- a/tests/components/test_conversation.py +++ b/tests/components/test_conversation.py @@ -290,11 +290,11 @@ async def test_http_api_wrong_data(hass, aiohttp_client): def test_create_matcher(): """Test the create matcher method.""" # Basic sentence - pattern = conversation._create_matcher('Hello world') + pattern = conversation.create_matcher('Hello world') assert pattern.match('Hello world') is not None # Match a part - pattern = conversation._create_matcher('Hello {name}') + pattern = conversation.create_matcher('Hello {name}') match = pattern.match('hello world') assert match is not None assert match.groupdict()['name'] == 'world' @@ -302,7 +302,7 @@ def test_create_matcher(): assert no_match is None # Optional and matching part - pattern = conversation._create_matcher('Turn on [the] {name}') + pattern = conversation.create_matcher('Turn on [the] {name}') match = pattern.match('turn on the kitchen lights') assert match is not None assert match.groupdict()['name'] == 'kitchen lights' @@ -313,7 +313,7 @@ def test_create_matcher(): assert match is None # Two different optional parts, 1 matching part - pattern = conversation._create_matcher('Turn on [the] [a] {name}') + pattern = conversation.create_matcher('Turn on [the] [a] {name}') match = pattern.match('turn on the kitchen lights') assert match is not None assert match.groupdict()['name'] == 'kitchen lights' @@ -325,13 +325,13 @@ def test_create_matcher(): assert match.groupdict()['name'] == 'kitchen light' # Strip plural - pattern = conversation._create_matcher('Turn {name}[s] on') + pattern = conversation.create_matcher('Turn {name}[s] on') match = pattern.match('turn kitchen lights on') assert match is not None assert match.groupdict()['name'] == 'kitchen light' # Optional 2 words - pattern = conversation._create_matcher('Turn [the great] {name} on') + pattern = conversation.create_matcher('Turn [the great] {name} on') match = pattern.match('turn the great kitchen lights on') assert match is not None assert match.groupdict()['name'] == 'kitchen lights'