1
mirror of https://github.com/home-assistant/core synced 2024-07-18 12:02:20 +02:00

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
This commit is contained in:
Marcel Hoppe 2018-08-28 00:20:12 +02:00 committed by Paulus Schoutsen
parent 6f0c30ff84
commit 45649824ca
6 changed files with 196 additions and 142 deletions

View File

@ -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, {})

View File

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

View File

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

View File

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

View File

@ -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),

View File

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