mirror of
https://github.com/home-assistant/core.git
synced 2025-07-21 12:17:07 +00:00
Hangouts (#16049)
* add a component for hangouts * add a notify component for hangouts * add an extra message as title * add support to listen to all conversations hangouts has * move hangouts to package and add parameter documentation * update .coveragerc and requirements_all.txt * makes linter happy again * bugfix * add conversations parameter to command words * Move the resolution of conversation names to conversations in own a function * typo * rename group of exclusion form 'id' to 'id or name' * refactoring and use config_flow * makes linter happy again * remove unused imports * fix not working regex commands * fix translations * cleanup * remove step_init * remove logging entry * clean up events * move constant * remove unsed import * add new files to .converagerc * isort imports * add hangouts_utils to ignored packages * upadte doc and format * fix I/O not in executor jon * rename SERVICE_UPDATE_USERS_AND_CONVERSATIONS to SERVICE_UPDATE * move EVENT_HANGOUTS_{CONNECTED,DISCONNECTED} to dispatcher * add config flow tests * Update tox.ini
This commit is contained in:
parent
dd9d53c83e
commit
ef0eab0f40
@ -116,6 +116,12 @@ omit =
|
|||||||
homeassistant/components/google.py
|
homeassistant/components/google.py
|
||||||
homeassistant/components/*/google.py
|
homeassistant/components/*/google.py
|
||||||
|
|
||||||
|
homeassistant/components/hangouts/__init__.py
|
||||||
|
homeassistant/components/hangouts/const.py
|
||||||
|
homeassistant/components/hangouts/hangouts_bot.py
|
||||||
|
homeassistant/components/hangouts/hangups_utils.py
|
||||||
|
homeassistant/components/*/hangouts.py
|
||||||
|
|
||||||
homeassistant/components/hdmi_cec.py
|
homeassistant/components/hdmi_cec.py
|
||||||
homeassistant/components/*/hdmi_cec.py
|
homeassistant/components/*/hdmi_cec.py
|
||||||
|
|
||||||
|
31
homeassistant/components/hangouts/.translations/en.json
Normal file
31
homeassistant/components/hangouts/.translations/en.json
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "Google Hangouts is already configured",
|
||||||
|
"unknown": "Unknown error occurred."
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"invalid_2fa": "Invalid 2 Factor Authorization, please try again.",
|
||||||
|
"invalid_2fa_method": "Invalig 2FA Method (Verify on Phone).",
|
||||||
|
"invalid_login": "Invalid Login, please try again."
|
||||||
|
},
|
||||||
|
"step": {
|
||||||
|
"2fa": {
|
||||||
|
"data": {
|
||||||
|
"2fa": "2FA Pin"
|
||||||
|
},
|
||||||
|
"description": "",
|
||||||
|
"title": "2-Factor-Authorization"
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"data": {
|
||||||
|
"email": "E-Mail Address",
|
||||||
|
"password": "Password"
|
||||||
|
},
|
||||||
|
"description": "",
|
||||||
|
"title": "Google Hangouts Login"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"title": "Google Hangouts"
|
||||||
|
}
|
||||||
|
}
|
87
homeassistant/components/hangouts/__init__.py
Normal file
87
homeassistant/components/hangouts/__init__.py
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
"""
|
||||||
|
The hangouts bot component.
|
||||||
|
|
||||||
|
For more details about this platform, please refer to the documentation at
|
||||||
|
https://home-assistant.io/components/hangouts/
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant import config_entries
|
||||||
|
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
|
||||||
|
from homeassistant.helpers import dispatcher
|
||||||
|
|
||||||
|
from .config_flow import configured_hangouts
|
||||||
|
from .const import (
|
||||||
|
CONF_BOT, CONF_COMMANDS, CONF_REFRESH_TOKEN, DOMAIN,
|
||||||
|
EVENT_HANGOUTS_CONNECTED, EVENT_HANGOUTS_CONVERSATIONS_CHANGED,
|
||||||
|
MESSAGE_SCHEMA, SERVICE_SEND_MESSAGE,
|
||||||
|
SERVICE_UPDATE)
|
||||||
|
|
||||||
|
REQUIREMENTS = ['hangups==0.4.5']
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup(hass, config):
|
||||||
|
"""Set up the Hangouts bot component."""
|
||||||
|
config = config.get(DOMAIN, [])
|
||||||
|
hass.data[DOMAIN] = {CONF_COMMANDS: config[CONF_COMMANDS]}
|
||||||
|
|
||||||
|
if configured_hangouts(hass) is None:
|
||||||
|
hass.async_add_job(hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={'source': config_entries.SOURCE_IMPORT}
|
||||||
|
))
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(hass, config):
|
||||||
|
"""Set up a config entry."""
|
||||||
|
from hangups.auth import GoogleAuthError
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .hangouts_bot import HangoutsBot
|
||||||
|
|
||||||
|
bot = HangoutsBot(
|
||||||
|
hass,
|
||||||
|
config.data.get(CONF_REFRESH_TOKEN),
|
||||||
|
hass.data[DOMAIN][CONF_COMMANDS])
|
||||||
|
hass.data[DOMAIN][CONF_BOT] = bot
|
||||||
|
except GoogleAuthError as exception:
|
||||||
|
_LOGGER.error("Hangouts failed to log in: %s", str(exception))
|
||||||
|
return False
|
||||||
|
|
||||||
|
dispatcher.async_dispatcher_connect(
|
||||||
|
hass,
|
||||||
|
EVENT_HANGOUTS_CONNECTED,
|
||||||
|
bot.async_handle_update_users_and_conversations)
|
||||||
|
|
||||||
|
dispatcher.async_dispatcher_connect(
|
||||||
|
hass,
|
||||||
|
EVENT_HANGOUTS_CONVERSATIONS_CHANGED,
|
||||||
|
bot.async_update_conversation_commands)
|
||||||
|
|
||||||
|
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP,
|
||||||
|
bot.async_handle_hass_stop)
|
||||||
|
|
||||||
|
await bot.async_connect()
|
||||||
|
|
||||||
|
hass.services.async_register(DOMAIN, SERVICE_SEND_MESSAGE,
|
||||||
|
bot.async_handle_send_message,
|
||||||
|
schema=MESSAGE_SCHEMA)
|
||||||
|
hass.services.async_register(DOMAIN,
|
||||||
|
SERVICE_UPDATE,
|
||||||
|
bot.
|
||||||
|
async_handle_update_users_and_conversations,
|
||||||
|
schema=vol.Schema({}))
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_unload_entry(hass, _):
|
||||||
|
"""Unload a config entry."""
|
||||||
|
bot = hass.data[DOMAIN].pop(CONF_BOT)
|
||||||
|
await bot.async_disconnect()
|
||||||
|
return True
|
107
homeassistant/components/hangouts/config_flow.py
Normal file
107
homeassistant/components/hangouts/config_flow.py
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
"""Config flow to configure Google Hangouts."""
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant import config_entries, data_entry_flow
|
||||||
|
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
|
||||||
|
from homeassistant.core import callback
|
||||||
|
|
||||||
|
from .const import CONF_2FA, CONF_REFRESH_TOKEN
|
||||||
|
from .const import DOMAIN as HANGOUTS_DOMAIN
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def configured_hangouts(hass):
|
||||||
|
"""Return the configures Google Hangouts Account."""
|
||||||
|
entries = hass.config_entries.async_entries(HANGOUTS_DOMAIN)
|
||||||
|
if entries:
|
||||||
|
return entries[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@config_entries.HANDLERS.register(HANGOUTS_DOMAIN)
|
||||||
|
class HangoutsFlowHandler(data_entry_flow.FlowHandler):
|
||||||
|
"""Config flow Google Hangouts."""
|
||||||
|
|
||||||
|
VERSION = 1
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize Google Hangouts config flow."""
|
||||||
|
self._credentials = None
|
||||||
|
self._refresh_token = None
|
||||||
|
|
||||||
|
async def async_step_user(self, user_input=None):
|
||||||
|
"""Handle a flow start."""
|
||||||
|
errors = {}
|
||||||
|
|
||||||
|
if configured_hangouts(self.hass) is not None:
|
||||||
|
return self.async_abort(reason="already_configured")
|
||||||
|
|
||||||
|
if user_input is not None:
|
||||||
|
from hangups import get_auth
|
||||||
|
from .hangups_utils import (HangoutsCredentials,
|
||||||
|
HangoutsRefreshToken,
|
||||||
|
GoogleAuthError, Google2FAError)
|
||||||
|
self._credentials = HangoutsCredentials(user_input[CONF_EMAIL],
|
||||||
|
user_input[CONF_PASSWORD])
|
||||||
|
self._refresh_token = HangoutsRefreshToken(None)
|
||||||
|
try:
|
||||||
|
await self.hass.async_add_executor_job(get_auth,
|
||||||
|
self._credentials,
|
||||||
|
self._refresh_token)
|
||||||
|
|
||||||
|
return await self.async_step_final()
|
||||||
|
except GoogleAuthError as err:
|
||||||
|
if isinstance(err, Google2FAError):
|
||||||
|
return await self.async_step_2fa()
|
||||||
|
msg = str(err)
|
||||||
|
if msg == 'Unknown verification code input':
|
||||||
|
errors['base'] = 'invalid_2fa_method'
|
||||||
|
else:
|
||||||
|
errors['base'] = 'invalid_login'
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id='user',
|
||||||
|
data_schema=vol.Schema({
|
||||||
|
vol.Required(CONF_EMAIL): str,
|
||||||
|
vol.Required(CONF_PASSWORD): str
|
||||||
|
}),
|
||||||
|
errors=errors
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_step_2fa(self, user_input=None):
|
||||||
|
"""Handle the 2fa step, if needed."""
|
||||||
|
errors = {}
|
||||||
|
|
||||||
|
if user_input is not None:
|
||||||
|
from hangups import get_auth
|
||||||
|
from .hangups_utils import GoogleAuthError
|
||||||
|
self._credentials.set_verification_code(user_input[CONF_2FA])
|
||||||
|
try:
|
||||||
|
await self.hass.async_add_executor_job(get_auth,
|
||||||
|
self._credentials,
|
||||||
|
self._refresh_token)
|
||||||
|
|
||||||
|
return await self.async_step_final()
|
||||||
|
except GoogleAuthError:
|
||||||
|
errors['base'] = 'invalid_2fa'
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id=CONF_2FA,
|
||||||
|
data_schema=vol.Schema({
|
||||||
|
vol.Required(CONF_2FA): str,
|
||||||
|
}),
|
||||||
|
errors=errors
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_step_final(self):
|
||||||
|
"""Handle the final step, create the config entry."""
|
||||||
|
return self.async_create_entry(
|
||||||
|
title=self._credentials.get_email(),
|
||||||
|
data={
|
||||||
|
CONF_EMAIL: self._credentials.get_email(),
|
||||||
|
CONF_REFRESH_TOKEN: self._refresh_token.get()
|
||||||
|
})
|
||||||
|
|
||||||
|
async def async_step_import(self, _):
|
||||||
|
"""Handle a flow import."""
|
||||||
|
return self.async_abort(reason='already_configured')
|
78
homeassistant/components/hangouts/const.py
Normal file
78
homeassistant/components/hangouts/const.py
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
"""Constants for Google Hangouts Component."""
|
||||||
|
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')
|
||||||
|
|
||||||
|
|
||||||
|
DOMAIN = 'hangouts'
|
||||||
|
|
||||||
|
CONF_2FA = '2fa'
|
||||||
|
CONF_REFRESH_TOKEN = 'refresh_token'
|
||||||
|
CONF_BOT = 'bot'
|
||||||
|
|
||||||
|
CONF_CONVERSATIONS = 'conversations'
|
||||||
|
CONF_DEFAULT_CONVERSATIONS = 'default_conversations'
|
||||||
|
|
||||||
|
CONF_COMMANDS = 'commands'
|
||||||
|
CONF_WORD = 'word'
|
||||||
|
CONF_EXPRESSION = 'expression'
|
||||||
|
|
||||||
|
EVENT_HANGOUTS_COMMAND = 'hangouts_command'
|
||||||
|
|
||||||
|
EVENT_HANGOUTS_CONNECTED = 'hangouts_connected'
|
||||||
|
EVENT_HANGOUTS_DISCONNECTED = 'hangouts_disconnected'
|
||||||
|
EVENT_HANGOUTS_USERS_CHANGED = 'hangouts_users_changed'
|
||||||
|
EVENT_HANGOUTS_CONVERSATIONS_CHANGED = 'hangouts_conversations_changed'
|
||||||
|
|
||||||
|
CONF_CONVERSATION_ID = 'id'
|
||||||
|
CONF_CONVERSATION_NAME = 'name'
|
||||||
|
|
||||||
|
SERVICE_SEND_MESSAGE = 'send_message'
|
||||||
|
SERVICE_UPDATE = 'update'
|
||||||
|
|
||||||
|
|
||||||
|
TARGETS_SCHEMA = vol.All(
|
||||||
|
vol.Schema({
|
||||||
|
vol.Exclusive(CONF_CONVERSATION_ID, 'id or name'): cv.string,
|
||||||
|
vol.Exclusive(CONF_CONVERSATION_NAME, 'id or name'): cv.string
|
||||||
|
}),
|
||||||
|
cv.has_at_least_one_key(CONF_CONVERSATION_ID, CONF_CONVERSATION_NAME)
|
||||||
|
)
|
||||||
|
MESSAGE_SEGMENT_SCHEMA = vol.Schema({
|
||||||
|
vol.Required('text'): cv.string,
|
||||||
|
vol.Optional('is_bold'): cv.boolean,
|
||||||
|
vol.Optional('is_italic'): cv.boolean,
|
||||||
|
vol.Optional('is_strikethrough'): cv.boolean,
|
||||||
|
vol.Optional('is_underline'): cv.boolean,
|
||||||
|
vol.Optional('parse_str'): cv.boolean,
|
||||||
|
vol.Optional('link_target'): cv.string
|
||||||
|
})
|
||||||
|
|
||||||
|
MESSAGE_SCHEMA = vol.Schema({
|
||||||
|
vol.Required(ATTR_TARGET): [TARGETS_SCHEMA],
|
||||||
|
vol.Required(ATTR_MESSAGE): [MESSAGE_SEGMENT_SCHEMA]
|
||||||
|
})
|
||||||
|
|
||||||
|
COMMAND_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.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)
|
229
homeassistant/components/hangouts/hangouts_bot.py
Normal file
229
homeassistant/components/hangouts/hangouts_bot.py
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
"""The Hangouts Bot."""
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
|
from homeassistant.helpers import dispatcher
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class HangoutsBot:
|
||||||
|
"""The Hangouts Bot."""
|
||||||
|
|
||||||
|
def __init__(self, hass, refresh_token, commands):
|
||||||
|
"""Set up the client."""
|
||||||
|
self.hass = hass
|
||||||
|
self._connected = False
|
||||||
|
|
||||||
|
self._refresh_token = refresh_token
|
||||||
|
|
||||||
|
self._commands = commands
|
||||||
|
|
||||||
|
self._word_commands = None
|
||||||
|
self._expression_commands = None
|
||||||
|
self._client = None
|
||||||
|
self._user_list = None
|
||||||
|
self._conversation_list = None
|
||||||
|
|
||||||
|
def _resolve_conversation_name(self, name):
|
||||||
|
for conv in self._conversation_list.get_all():
|
||||||
|
if conv.name == name:
|
||||||
|
return conv
|
||||||
|
return None
|
||||||
|
|
||||||
|
def async_update_conversation_commands(self, _):
|
||||||
|
"""Refresh the commands for every conversation."""
|
||||||
|
self._word_commands = {}
|
||||||
|
self._expression_commands = {}
|
||||||
|
|
||||||
|
for command in self._commands:
|
||||||
|
if command.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
|
||||||
|
else:
|
||||||
|
command['_' + 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 command['_' + CONF_CONVERSATIONS]:
|
||||||
|
if conv_id not in self._expression_commands:
|
||||||
|
self._expression_commands[conv_id] = []
|
||||||
|
self._expression_commands[conv_id].append(command)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._conversation_list.on_event.remove_observer(
|
||||||
|
self._handle_conversation_event)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
self._conversation_list.on_event.add_observer(
|
||||||
|
self._handle_conversation_event)
|
||||||
|
|
||||||
|
def _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)
|
||||||
|
|
||||||
|
def _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
|
||||||
|
|
||||||
|
_LOGGER.debug("Handling message '%s' from %s",
|
||||||
|
event.text, user.full_name)
|
||||||
|
|
||||||
|
event_data = None
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
async def async_connect(self):
|
||||||
|
"""Login to the Google Hangouts."""
|
||||||
|
from .hangups_utils import HangoutsRefreshToken, HangoutsCredentials
|
||||||
|
|
||||||
|
from hangups import Client
|
||||||
|
from hangups import get_auth
|
||||||
|
session = await self.hass.async_add_executor_job(
|
||||||
|
get_auth, HangoutsCredentials(None, None, None),
|
||||||
|
HangoutsRefreshToken(self._refresh_token))
|
||||||
|
|
||||||
|
self._client = Client(session)
|
||||||
|
self._client.on_connect.add_observer(self._on_connect)
|
||||||
|
self._client.on_disconnect.add_observer(self._on_disconnect)
|
||||||
|
|
||||||
|
self.hass.loop.create_task(self._client.connect())
|
||||||
|
|
||||||
|
def _on_connect(self):
|
||||||
|
_LOGGER.debug('Connected!')
|
||||||
|
self._connected = True
|
||||||
|
dispatcher.async_dispatcher_send(self.hass, EVENT_HANGOUTS_CONNECTED)
|
||||||
|
|
||||||
|
def _on_disconnect(self):
|
||||||
|
"""Handle disconnecting."""
|
||||||
|
_LOGGER.debug('Connection lost!')
|
||||||
|
self._connected = False
|
||||||
|
dispatcher.async_dispatcher_send(self.hass,
|
||||||
|
EVENT_HANGOUTS_DISCONNECTED)
|
||||||
|
|
||||||
|
async def async_disconnect(self):
|
||||||
|
"""Disconnect the client if it is connected."""
|
||||||
|
if self._connected:
|
||||||
|
await self._client.disconnect()
|
||||||
|
|
||||||
|
async def async_handle_hass_stop(self, _):
|
||||||
|
"""Run once when Home Assistant stops."""
|
||||||
|
await self.async_disconnect()
|
||||||
|
|
||||||
|
async def _async_send_message(self, message, targets):
|
||||||
|
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 conversation is not None:
|
||||||
|
conversations.append(conversation)
|
||||||
|
|
||||||
|
if not conversations:
|
||||||
|
return False
|
||||||
|
|
||||||
|
from hangups import ChatMessageSegment, hangouts_pb2
|
||||||
|
messages = []
|
||||||
|
for segment in message:
|
||||||
|
if 'parse_str' in segment and segment['parse_str']:
|
||||||
|
messages.extend(ChatMessageSegment.from_str(segment['text']))
|
||||||
|
else:
|
||||||
|
if 'parse_str' in segment:
|
||||||
|
del segment['parse_str']
|
||||||
|
messages.append(ChatMessageSegment(**segment))
|
||||||
|
messages.append(ChatMessageSegment('',
|
||||||
|
segment_type=hangouts_pb2.
|
||||||
|
SEGMENT_TYPE_LINE_BREAK))
|
||||||
|
|
||||||
|
if not messages:
|
||||||
|
return False
|
||||||
|
for conv in conversations:
|
||||||
|
await conv.send_message(messages)
|
||||||
|
|
||||||
|
async def _async_list_conversations(self):
|
||||||
|
import hangups
|
||||||
|
self._user_list, self._conversation_list = \
|
||||||
|
(await hangups.build_user_conversation_list(self._client))
|
||||||
|
users = {}
|
||||||
|
conversations = {}
|
||||||
|
for user in self._user_list.get_all():
|
||||||
|
users[str(user.id_.chat_id)] = {'full_name': user.full_name,
|
||||||
|
'is_self': user.is_self}
|
||||||
|
|
||||||
|
for conv in self._conversation_list.get_all():
|
||||||
|
users_in_conversation = {}
|
||||||
|
for user in conv.users:
|
||||||
|
users_in_conversation[str(user.id_.chat_id)] = \
|
||||||
|
{'full_name': user.full_name, 'is_self': user.is_self}
|
||||||
|
conversations[str(conv.id_)] = \
|
||||||
|
{'name': conv.name, 'users': users_in_conversation}
|
||||||
|
|
||||||
|
self.hass.states.async_set("{}.users".format(DOMAIN),
|
||||||
|
len(self._user_list.get_all()),
|
||||||
|
attributes=users)
|
||||||
|
self.hass.states.async_set("{}.conversations".format(DOMAIN),
|
||||||
|
len(self._conversation_list.get_all()),
|
||||||
|
attributes=conversations)
|
||||||
|
dispatcher.async_dispatcher_send(self.hass,
|
||||||
|
EVENT_HANGOUTS_CONVERSATIONS_CHANGED,
|
||||||
|
conversations)
|
||||||
|
|
||||||
|
async def async_handle_send_message(self, service):
|
||||||
|
"""Handle the send_message service."""
|
||||||
|
await self._async_send_message(service.data[ATTR_MESSAGE],
|
||||||
|
service.data[ATTR_TARGET])
|
||||||
|
|
||||||
|
async def async_handle_update_users_and_conversations(self, _=None):
|
||||||
|
"""Handle the update_users_and_conversations service."""
|
||||||
|
await self._async_list_conversations()
|
81
homeassistant/components/hangouts/hangups_utils.py
Normal file
81
homeassistant/components/hangouts/hangups_utils.py
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
"""Utils needed for Google Hangouts."""
|
||||||
|
|
||||||
|
from hangups import CredentialsPrompt, GoogleAuthError, RefreshTokenCache
|
||||||
|
|
||||||
|
|
||||||
|
class Google2FAError(GoogleAuthError):
|
||||||
|
"""A Google authentication request failed."""
|
||||||
|
|
||||||
|
|
||||||
|
class HangoutsCredentials(CredentialsPrompt):
|
||||||
|
"""Google account credentials.
|
||||||
|
|
||||||
|
This implementation gets the user data as params.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, email, password, pin=None):
|
||||||
|
"""Google account credentials.
|
||||||
|
|
||||||
|
:param email: Google account email address.
|
||||||
|
:param password: Google account password.
|
||||||
|
:param pin: Google account verification code.
|
||||||
|
"""
|
||||||
|
self._email = email
|
||||||
|
self._password = password
|
||||||
|
self._pin = pin
|
||||||
|
|
||||||
|
def get_email(self):
|
||||||
|
"""Return email.
|
||||||
|
|
||||||
|
:return: Google account email address.
|
||||||
|
"""
|
||||||
|
return self._email
|
||||||
|
|
||||||
|
def get_password(self):
|
||||||
|
"""Return password.
|
||||||
|
|
||||||
|
:return: Google account password.
|
||||||
|
"""
|
||||||
|
return self._password
|
||||||
|
|
||||||
|
def get_verification_code(self):
|
||||||
|
"""Return the verification code.
|
||||||
|
|
||||||
|
:return: Google account verification code.
|
||||||
|
"""
|
||||||
|
if self._pin is None:
|
||||||
|
raise Google2FAError()
|
||||||
|
return self._pin
|
||||||
|
|
||||||
|
def set_verification_code(self, pin):
|
||||||
|
"""Set the verification code.
|
||||||
|
|
||||||
|
:param pin: Google account verification code.
|
||||||
|
"""
|
||||||
|
self._pin = pin
|
||||||
|
|
||||||
|
|
||||||
|
class HangoutsRefreshToken(RefreshTokenCache):
|
||||||
|
"""Memory-based cache for refresh token."""
|
||||||
|
|
||||||
|
def __init__(self, token):
|
||||||
|
"""Memory-based cache for refresh token.
|
||||||
|
|
||||||
|
:param token: Initial refresh token.
|
||||||
|
"""
|
||||||
|
super().__init__("")
|
||||||
|
self._token = token
|
||||||
|
|
||||||
|
def get(self):
|
||||||
|
"""Get cached refresh token.
|
||||||
|
|
||||||
|
:return: Cached refresh token.
|
||||||
|
"""
|
||||||
|
return self._token
|
||||||
|
|
||||||
|
def set(self, refresh_token):
|
||||||
|
"""Cache a refresh token.
|
||||||
|
|
||||||
|
:param refresh_token: Refresh token to cache.
|
||||||
|
"""
|
||||||
|
self._token = refresh_token
|
12
homeassistant/components/hangouts/services.yaml
Normal file
12
homeassistant/components/hangouts/services.yaml
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
update:
|
||||||
|
description: Updates the list of users and conversations.
|
||||||
|
|
||||||
|
send_message:
|
||||||
|
description: Send a notification to a specific target.
|
||||||
|
fields:
|
||||||
|
target:
|
||||||
|
description: List of targets with id or name. [Required]
|
||||||
|
example: '[{"id": "UgxrXzVrARmjx_C6AZx4AaABAagBo-6UCw"}, {"name": "Test Conversation"}]'
|
||||||
|
message:
|
||||||
|
description: List of message segments, only the "text" field is required in every segment. [Required]
|
||||||
|
example: '[{"text":"test", "is_bold": false, "is_italic": false, "is_strikethrough": false, "is_underline": false, "parse_str": false, "link_target": "http://google.com"}, ...]'
|
31
homeassistant/components/hangouts/strings.json
Normal file
31
homeassistant/components/hangouts/strings.json
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "Google Hangouts is already configured",
|
||||||
|
"unknown": "Unknown error occurred."
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"invalid_login": "Invalid Login, please try again.",
|
||||||
|
"invalid_2fa": "Invalid 2 Factor Authorization, please try again.",
|
||||||
|
"invalid_2fa_method": "Invalig 2FA Method (Verify on Phone)."
|
||||||
|
},
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"data": {
|
||||||
|
"email": "E-Mail Address",
|
||||||
|
"password": "Password"
|
||||||
|
},
|
||||||
|
"description": "",
|
||||||
|
"title": "Google Hangouts Login"
|
||||||
|
},
|
||||||
|
"2fa": {
|
||||||
|
"data": {
|
||||||
|
"2fa": "2FA Pin"
|
||||||
|
},
|
||||||
|
"description": "",
|
||||||
|
"title": "2-Factor-Authorization"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"title": "Google Hangouts"
|
||||||
|
}
|
||||||
|
}
|
66
homeassistant/components/notify/hangouts.py
Normal file
66
homeassistant/components/notify/hangouts.py
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
"""
|
||||||
|
Hangouts notification service.
|
||||||
|
|
||||||
|
For more details about this platform, please refer to the documentation at
|
||||||
|
https://home-assistant.io/components/notify.hangouts/
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.components.notify import (ATTR_TARGET, PLATFORM_SCHEMA,
|
||||||
|
NOTIFY_SERVICE_SCHEMA,
|
||||||
|
BaseNotificationService,
|
||||||
|
ATTR_MESSAGE)
|
||||||
|
|
||||||
|
from homeassistant.components.hangouts.const \
|
||||||
|
import (DOMAIN, SERVICE_SEND_MESSAGE,
|
||||||
|
TARGETS_SCHEMA, CONF_DEFAULT_CONVERSATIONS)
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEPENDENCIES = [DOMAIN]
|
||||||
|
|
||||||
|
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||||
|
vol.Required(CONF_DEFAULT_CONVERSATIONS): [TARGETS_SCHEMA]
|
||||||
|
})
|
||||||
|
|
||||||
|
NOTIFY_SERVICE_SCHEMA = NOTIFY_SERVICE_SCHEMA.extend({
|
||||||
|
vol.Optional(ATTR_TARGET): [TARGETS_SCHEMA]
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def get_service(hass, config, discovery_info=None):
|
||||||
|
"""Get the Hangouts notification service."""
|
||||||
|
return HangoutsNotificationService(config.get(CONF_DEFAULT_CONVERSATIONS))
|
||||||
|
|
||||||
|
|
||||||
|
class HangoutsNotificationService(BaseNotificationService):
|
||||||
|
"""Send Notifications to Hangouts conversations."""
|
||||||
|
|
||||||
|
def __init__(self, default_conversations):
|
||||||
|
"""Set up the notification service."""
|
||||||
|
self._default_conversations = default_conversations
|
||||||
|
|
||||||
|
def send_message(self, message="", **kwargs):
|
||||||
|
"""Send the message to the Google Hangouts server."""
|
||||||
|
target_conversations = None
|
||||||
|
if ATTR_TARGET in kwargs:
|
||||||
|
target_conversations = []
|
||||||
|
for target in kwargs.get(ATTR_TARGET):
|
||||||
|
target_conversations.append({'id': target})
|
||||||
|
else:
|
||||||
|
target_conversations = self._default_conversations
|
||||||
|
|
||||||
|
messages = []
|
||||||
|
if 'title' in kwargs:
|
||||||
|
messages.append({'text': kwargs['title'], 'is_bold': True})
|
||||||
|
|
||||||
|
messages.append({'text': message, 'parse_str': True})
|
||||||
|
service_data = {
|
||||||
|
ATTR_TARGET: target_conversations,
|
||||||
|
ATTR_MESSAGE: messages
|
||||||
|
}
|
||||||
|
|
||||||
|
return self.hass.services.call(
|
||||||
|
DOMAIN, SERVICE_SEND_MESSAGE, service_data=service_data)
|
@ -136,6 +136,7 @@ HANDLERS = Registry()
|
|||||||
# Components that have config flows. In future we will auto-generate this list.
|
# Components that have config flows. In future we will auto-generate this list.
|
||||||
FLOWS = [
|
FLOWS = [
|
||||||
'cast',
|
'cast',
|
||||||
|
'hangouts',
|
||||||
'deconz',
|
'deconz',
|
||||||
'homematicip_cloud',
|
'homematicip_cloud',
|
||||||
'hue',
|
'hue',
|
||||||
|
@ -413,6 +413,9 @@ ha-ffmpeg==1.9
|
|||||||
# homeassistant.components.media_player.philips_js
|
# homeassistant.components.media_player.philips_js
|
||||||
ha-philipsjs==0.0.5
|
ha-philipsjs==0.0.5
|
||||||
|
|
||||||
|
# homeassistant.components.hangouts
|
||||||
|
hangups==0.4.5
|
||||||
|
|
||||||
# homeassistant.components.sensor.geo_rss_events
|
# homeassistant.components.sensor.geo_rss_events
|
||||||
haversine==0.4.5
|
haversine==0.4.5
|
||||||
|
|
||||||
|
@ -71,6 +71,9 @@ gTTS-token==1.1.1
|
|||||||
# homeassistant.components.ffmpeg
|
# homeassistant.components.ffmpeg
|
||||||
ha-ffmpeg==1.9
|
ha-ffmpeg==1.9
|
||||||
|
|
||||||
|
# homeassistant.components.hangouts
|
||||||
|
hangups==0.4.5
|
||||||
|
|
||||||
# homeassistant.components.sensor.geo_rss_events
|
# homeassistant.components.sensor.geo_rss_events
|
||||||
haversine==0.4.5
|
haversine==0.4.5
|
||||||
|
|
||||||
|
@ -51,6 +51,7 @@ TEST_REQUIREMENTS = (
|
|||||||
'feedparser',
|
'feedparser',
|
||||||
'foobot_async',
|
'foobot_async',
|
||||||
'gTTS-token',
|
'gTTS-token',
|
||||||
|
'hangups',
|
||||||
'HAP-python',
|
'HAP-python',
|
||||||
'ha-ffmpeg',
|
'ha-ffmpeg',
|
||||||
'haversine',
|
'haversine',
|
||||||
@ -105,7 +106,8 @@ TEST_REQUIREMENTS = (
|
|||||||
|
|
||||||
IGNORE_PACKAGES = (
|
IGNORE_PACKAGES = (
|
||||||
'homeassistant.components.recorder.models',
|
'homeassistant.components.recorder.models',
|
||||||
'homeassistant.components.homekit.*'
|
'homeassistant.components.homekit.*',
|
||||||
|
'homeassistant.components.hangouts.hangups_utils'
|
||||||
)
|
)
|
||||||
|
|
||||||
IGNORE_PIN = ('colorlog>2.1,<3', 'keyring>=9.3,<10.0', 'urllib3')
|
IGNORE_PIN = ('colorlog>2.1,<3', 'keyring>=9.3,<10.0', 'urllib3')
|
||||||
|
1
tests/components/hangouts/__init__.py
Normal file
1
tests/components/hangouts/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Tests for the Hangouts Component."""
|
92
tests/components/hangouts/test_config_flow.py
Normal file
92
tests/components/hangouts/test_config_flow.py
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
"""Tests for the Google Hangouts config flow."""
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from homeassistant import data_entry_flow
|
||||||
|
from homeassistant.components.hangouts import config_flow
|
||||||
|
|
||||||
|
|
||||||
|
async def test_flow_works(hass, aioclient_mock):
|
||||||
|
"""Test config flow without 2fa."""
|
||||||
|
flow = config_flow.HangoutsFlowHandler()
|
||||||
|
|
||||||
|
flow.hass = hass
|
||||||
|
|
||||||
|
with patch('hangups.get_auth'):
|
||||||
|
result = await flow.async_step_user(
|
||||||
|
{'email': 'test@test.com', 'password': '1232456'})
|
||||||
|
assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
||||||
|
assert result['title'] == 'test@test.com'
|
||||||
|
|
||||||
|
|
||||||
|
async def test_flow_works_with_2fa(hass, aioclient_mock):
|
||||||
|
"""Test config flow with 2fa."""
|
||||||
|
from homeassistant.components.hangouts.hangups_utils import Google2FAError
|
||||||
|
|
||||||
|
flow = config_flow.HangoutsFlowHandler()
|
||||||
|
|
||||||
|
flow.hass = hass
|
||||||
|
|
||||||
|
with patch('hangups.get_auth', side_effect=Google2FAError):
|
||||||
|
result = await flow.async_step_user(
|
||||||
|
{'email': 'test@test.com', 'password': '1232456'})
|
||||||
|
assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
assert result['step_id'] == '2fa'
|
||||||
|
|
||||||
|
with patch('hangups.get_auth'):
|
||||||
|
result = await flow.async_step_2fa({'2fa': 123456})
|
||||||
|
assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
||||||
|
assert result['title'] == 'test@test.com'
|
||||||
|
|
||||||
|
|
||||||
|
async def test_flow_with_unknown_2fa(hass, aioclient_mock):
|
||||||
|
"""Test config flow with invalid 2fa method."""
|
||||||
|
from homeassistant.components.hangouts.hangups_utils import GoogleAuthError
|
||||||
|
|
||||||
|
flow = config_flow.HangoutsFlowHandler()
|
||||||
|
|
||||||
|
flow.hass = hass
|
||||||
|
|
||||||
|
with patch('hangups.get_auth',
|
||||||
|
side_effect=GoogleAuthError('Unknown verification code input')):
|
||||||
|
result = await flow.async_step_user(
|
||||||
|
{'email': 'test@test.com', 'password': '1232456'})
|
||||||
|
assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
assert result['errors']['base'] == 'invalid_2fa_method'
|
||||||
|
|
||||||
|
|
||||||
|
async def test_flow_invalid_login(hass, aioclient_mock):
|
||||||
|
"""Test config flow with invalid 2fa method."""
|
||||||
|
from homeassistant.components.hangouts.hangups_utils import GoogleAuthError
|
||||||
|
|
||||||
|
flow = config_flow.HangoutsFlowHandler()
|
||||||
|
|
||||||
|
flow.hass = hass
|
||||||
|
|
||||||
|
with patch('hangups.get_auth',
|
||||||
|
side_effect=GoogleAuthError):
|
||||||
|
result = await flow.async_step_user(
|
||||||
|
{'email': 'test@test.com', 'password': '1232456'})
|
||||||
|
assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
assert result['errors']['base'] == 'invalid_login'
|
||||||
|
|
||||||
|
|
||||||
|
async def test_flow_invalid_2fa(hass, aioclient_mock):
|
||||||
|
"""Test config flow with 2fa."""
|
||||||
|
from homeassistant.components.hangouts.hangups_utils import Google2FAError
|
||||||
|
|
||||||
|
flow = config_flow.HangoutsFlowHandler()
|
||||||
|
|
||||||
|
flow.hass = hass
|
||||||
|
|
||||||
|
with patch('hangups.get_auth', side_effect=Google2FAError):
|
||||||
|
result = await flow.async_step_user(
|
||||||
|
{'email': 'test@test.com', 'password': '1232456'})
|
||||||
|
assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
assert result['step_id'] == '2fa'
|
||||||
|
|
||||||
|
with patch('hangups.get_auth', side_effect=Google2FAError):
|
||||||
|
result = await flow.async_step_2fa({'2fa': 123456})
|
||||||
|
|
||||||
|
assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
assert result['errors']['base'] == 'invalid_2fa'
|
Loading…
x
Reference in New Issue
Block a user