Add webhook + IFTTT example (#16817)

* Add webhook + IFTTT example

* Abort if not externally accessible

* Abort on local url

* Add description to create entry

* Make body optional

* Allow ifttt setup without config

* Add tests

* Lint

* Fix Lint + Tests

* Fix typing
This commit is contained in:
Paulus Schoutsen 2018-09-30 14:45:48 +02:00 committed by GitHub
parent 06d959ed43
commit f5632a5da5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 448 additions and 91 deletions

View file

@ -1,24 +1,13 @@
"""Helpers to resolve client ID/secret."""
import asyncio
from ipaddress import ip_address
from html.parser import HTMLParser
from ipaddress import ip_address, ip_network
from urllib.parse import urlparse, urljoin
import aiohttp
from aiohttp.client_exceptions import ClientError
# IP addresses of loopback interfaces
ALLOWED_IPS = (
ip_address('127.0.0.1'),
ip_address('::1'),
)
# RFC1918 - Address allocation for Private Internets
ALLOWED_NETWORKS = (
ip_network('10.0.0.0/8'),
ip_network('172.16.0.0/12'),
ip_network('192.168.0.0/16'),
)
from homeassistant.util.network import is_local
async def verify_redirect_uri(hass, client_id, redirect_uri):
@ -185,9 +174,7 @@ def _parse_client_id(client_id):
# Not an ip address
pass
if (address is None or
address in ALLOWED_IPS or
any(address in network for network in ALLOWED_NETWORKS)):
if address is None or is_local(address):
return parts
raise ValueError('Hostname should be a domain name or local IP address')

View file

@ -1,74 +0,0 @@
"""
Support to trigger Maker IFTTT recipes.
For more details about this component, please refer to the documentation at
https://home-assistant.io/components/ifttt/
"""
import logging
import requests
import voluptuous as vol
import homeassistant.helpers.config_validation as cv
REQUIREMENTS = ['pyfttt==0.3']
_LOGGER = logging.getLogger(__name__)
ATTR_EVENT = 'event'
ATTR_VALUE1 = 'value1'
ATTR_VALUE2 = 'value2'
ATTR_VALUE3 = 'value3'
CONF_KEY = 'key'
DOMAIN = 'ifttt'
SERVICE_TRIGGER = 'trigger'
SERVICE_TRIGGER_SCHEMA = vol.Schema({
vol.Required(ATTR_EVENT): cv.string,
vol.Optional(ATTR_VALUE1): cv.string,
vol.Optional(ATTR_VALUE2): cv.string,
vol.Optional(ATTR_VALUE3): cv.string,
})
CONFIG_SCHEMA = vol.Schema({
DOMAIN: vol.Schema({
vol.Required(CONF_KEY): cv.string,
}),
}, extra=vol.ALLOW_EXTRA)
def trigger(hass, event, value1=None, value2=None, value3=None):
"""Trigger a Maker IFTTT recipe."""
data = {
ATTR_EVENT: event,
ATTR_VALUE1: value1,
ATTR_VALUE2: value2,
ATTR_VALUE3: value3,
}
hass.services.call(DOMAIN, SERVICE_TRIGGER, data)
def setup(hass, config):
"""Set up the IFTTT service component."""
key = config[DOMAIN][CONF_KEY]
def trigger_service(call):
"""Handle IFTTT trigger service calls."""
event = call.data[ATTR_EVENT]
value1 = call.data.get(ATTR_VALUE1)
value2 = call.data.get(ATTR_VALUE2)
value3 = call.data.get(ATTR_VALUE3)
try:
import pyfttt
pyfttt.send_event(key, event, value1, value2, value3)
except requests.exceptions.RequestException:
_LOGGER.exception("Error communicating with IFTTT")
hass.services.register(DOMAIN, SERVICE_TRIGGER, trigger_service,
schema=SERVICE_TRIGGER_SCHEMA)
return True

View file

@ -0,0 +1,18 @@
{
"config": {
"abort": {
"not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive IFTTT messages.",
"one_instance_allowed": "Only a single instance is necessary."
},
"create_entry": {
"default": "To send events to Home Assistant, you will need to use the \"Make a web request\" action from the [IFTTT Webhook applet]({applet_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data."
},
"step": {
"user": {
"description": "Are you sure you want to set up IFTTT?",
"title": "Set up the IFTTT Webhook Applet"
}
},
"title": "IFTTT"
}
}

View file

@ -0,0 +1,135 @@
"""
Support to trigger Maker IFTTT recipes.
For more details about this component, please refer to the documentation at
https://home-assistant.io/components/ifttt/
"""
from ipaddress import ip_address
import logging
from urllib.parse import urlparse
import requests
import voluptuous as vol
import homeassistant.helpers.config_validation as cv
from homeassistant import config_entries
from homeassistant.util.network import is_local
REQUIREMENTS = ['pyfttt==0.3']
DEPENDENCIES = ['webhook']
_LOGGER = logging.getLogger(__name__)
EVENT_RECEIVED = 'ifttt_webhook_received'
ATTR_EVENT = 'event'
ATTR_VALUE1 = 'value1'
ATTR_VALUE2 = 'value2'
ATTR_VALUE3 = 'value3'
CONF_KEY = 'key'
CONF_WEBHOOK_ID = 'webhook_id'
DOMAIN = 'ifttt'
SERVICE_TRIGGER = 'trigger'
SERVICE_TRIGGER_SCHEMA = vol.Schema({
vol.Required(ATTR_EVENT): cv.string,
vol.Optional(ATTR_VALUE1): cv.string,
vol.Optional(ATTR_VALUE2): cv.string,
vol.Optional(ATTR_VALUE3): cv.string,
})
CONFIG_SCHEMA = vol.Schema({
vol.Optional(DOMAIN): vol.Schema({
vol.Required(CONF_KEY): cv.string,
}),
}, extra=vol.ALLOW_EXTRA)
async def async_setup(hass, config):
"""Set up the IFTTT service component."""
if DOMAIN not in config:
return True
key = config[DOMAIN][CONF_KEY]
def trigger_service(call):
"""Handle IFTTT trigger service calls."""
event = call.data[ATTR_EVENT]
value1 = call.data.get(ATTR_VALUE1)
value2 = call.data.get(ATTR_VALUE2)
value3 = call.data.get(ATTR_VALUE3)
try:
import pyfttt
pyfttt.send_event(key, event, value1, value2, value3)
except requests.exceptions.RequestException:
_LOGGER.exception("Error communicating with IFTTT")
hass.services.async_register(DOMAIN, SERVICE_TRIGGER, trigger_service,
schema=SERVICE_TRIGGER_SCHEMA)
return True
async def handle_webhook(hass, webhook_id, data):
"""Handle webhook callback."""
if isinstance(data, dict):
data['webhook_id'] = webhook_id
hass.bus.async_fire(EVENT_RECEIVED, data)
async def async_setup_entry(hass, entry):
"""Configure based on config entry."""
hass.components.webhook.async_register(
entry.data['webhook_id'], handle_webhook)
return True
async def async_unload_entry(hass, entry):
"""Unload a config entry."""
hass.components.webhook.async_unregister(entry.data['webhook_id'])
return True
@config_entries.HANDLERS.register(DOMAIN)
class ConfigFlow(config_entries.ConfigFlow):
"""Handle an IFTTT config flow."""
async def async_step_user(self, user_input=None):
"""Handle a user initiated set up flow."""
if self._async_current_entries():
return self.async_abort(reason='one_instance_allowed')
try:
url_parts = urlparse(self.hass.config.api.base_url)
if is_local(ip_address(url_parts.hostname)):
return self.async_abort(reason='not_internet_accessible')
except ValueError:
# If it's not an IP address, it's very likely publicly accessible
pass
if user_input is None:
return self.async_show_form(
step_id='user',
)
webhook_id = self.hass.components.webhook.async_generate_id()
webhook_url = \
self.hass.components.webhook.async_generate_url(webhook_id)
return self.async_create_entry(
title='IFTTT Webhook',
data={
CONF_WEBHOOK_ID: webhook_id
},
description_placeholders={
'applet_url': 'https://ifttt.com/maker_webhooks',
'webhook_url': webhook_url,
'docs_url':
'https://www.home-assistant.io/components/ifttt/'
}
)

View file

@ -0,0 +1,18 @@
{
"config": {
"title": "IFTTT",
"step": {
"user": {
"title": "Set up the IFTTT Webhook Applet",
"description": "Are you sure you want to set up IFTTT?"
}
},
"abort": {
"one_instance_allowed": "Only a single instance is necessary.",
"not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive IFTTT messages."
},
"create_entry": {
"default": "To send events to Home Assistant, you will need to use the \"Make a web request\" action from the [IFTTT Webhook applet]({applet_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data."
}
}
}

View file

@ -0,0 +1,94 @@
"""Webhooks for Home Assistant.
For more details about this component, please refer to the documentation at
https://home-assistant.io/components/webhook/
"""
import json
import logging
from aiohttp.web import Response
from homeassistant.core import callback
from homeassistant.loader import bind_hass
from homeassistant.auth.util import generate_secret
from homeassistant.components.http.view import HomeAssistantView
DOMAIN = 'webhook'
DEPENDENCIES = ['http']
_LOGGER = logging.getLogger(__name__)
@callback
@bind_hass
def async_register(hass, webhook_id, handler):
"""Register a webhook."""
handlers = hass.data.setdefault(DOMAIN, {})
if webhook_id in handlers:
raise ValueError('Handler is already defined!')
handlers[webhook_id] = handler
@callback
@bind_hass
def async_unregister(hass, webhook_id):
"""Remove a webhook."""
handlers = hass.data.setdefault(DOMAIN, {})
handlers.pop(webhook_id, None)
@callback
def async_generate_id():
"""Generate a webhook_id."""
return generate_secret(entropy=32)
@callback
@bind_hass
def async_generate_url(hass, webhook_id):
"""Generate a webhook_id."""
return "{}/api/webhook/{}".format(hass.config.api.base_url, webhook_id)
async def async_setup(hass, config):
"""Initialize the webhook component."""
hass.http.register_view(WebhookView)
return True
class WebhookView(HomeAssistantView):
"""Handle incoming webhook requests."""
url = "/api/webhook/{webhook_id}"
name = "api:webhook"
requires_auth = False
async def post(self, request, webhook_id):
"""Handle webhook call."""
hass = request.app['hass']
handlers = hass.data.setdefault(DOMAIN, {})
handler = handlers.get(webhook_id)
# Always respond successfully to not give away if a hook exists or not.
if handler is None:
_LOGGER.warning(
'Received message for unregistered webhook %s', webhook_id)
return Response(status=200)
body = await request.text()
try:
data = json.loads(body) if body else {}
except ValueError:
_LOGGER.warning(
'Received webhook %s with invalid JSON', webhook_id)
return Response(status=200)
try:
response = await handler(hass, webhook_id, data)
if response is None:
response = Response(status=200)
return response
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Error processing webhook %s", webhook_id)
return Response(status=200)

View file

@ -141,6 +141,7 @@ FLOWS = [
'deconz',
'homematicip_cloud',
'hue',
'ifttt',
'ios',
'mqtt',
'nest',

View file

@ -153,7 +153,10 @@ class FlowHandler:
}
@callback
def async_create_entry(self, *, title: str, data: Dict) -> Dict:
def async_create_entry(self, *, title: str, data: Dict,
description: Optional[str] = None,
description_placeholders: Optional[Dict] = None) \
-> Dict:
"""Finish config flow and create a config entry."""
return {
'version': self.VERSION,
@ -162,6 +165,8 @@ class FlowHandler:
'handler': self.handler,
'title': title,
'data': data,
'description': description,
'description_placeholders': description_placeholders,
}
@callback

View file

@ -0,0 +1,22 @@
"""Network utilities."""
from ipaddress import IPv4Address, IPv6Address, ip_address, ip_network
from typing import Union
# IP addresses of loopback interfaces
LOCAL_IPS = (
ip_address('127.0.0.1'),
ip_address('::1'),
)
# RFC1918 - Address allocation for Private Internets
LOCAL_NETWORKS = (
ip_network('10.0.0.0/8'),
ip_network('172.16.0.0/12'),
ip_network('192.168.0.0/16'),
)
def is_local(address: Union[IPv4Address, IPv6Address]) -> bool:
"""Check if an address is local."""
return address in LOCAL_IPS or \
any(address in network for network in LOCAL_NETWORKS)

View file

@ -206,6 +206,8 @@ def test_create_account(hass, client):
'title': 'Test Entry',
'type': 'create_entry',
'version': 1,
'description': None,
'description_placeholders': None,
}
@ -266,6 +268,8 @@ def test_two_step_flow(hass, client):
'type': 'create_entry',
'title': 'user-title',
'version': 1,
'description': None,
'description_placeholders': None,
}

View file

@ -0,0 +1 @@
"""Tests for the IFTTT component."""

View file

@ -0,0 +1,48 @@
"""Test the init file of IFTTT."""
from unittest.mock import Mock, patch
from homeassistant import data_entry_flow
from homeassistant.core import callback
from homeassistant.components import ifttt
async def test_config_flow_registers_webhook(hass, aiohttp_client):
"""Test setting up IFTTT and sending webhook."""
with patch('homeassistant.util.get_local_ip', return_value='example.com'):
result = await hass.config_entries.flow.async_init('ifttt', context={
'source': 'user'
})
assert result['type'] == data_entry_flow.RESULT_TYPE_FORM, result
result = await hass.config_entries.flow.async_configure(
result['flow_id'], {})
assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
webhook_id = result['result'].data['webhook_id']
ifttt_events = []
@callback
def handle_event(event):
"""Handle IFTTT event."""
ifttt_events.append(event)
hass.bus.async_listen(ifttt.EVENT_RECEIVED, handle_event)
client = await aiohttp_client(hass.http.app)
await client.post('/api/webhook/{}'.format(webhook_id), json={
'hello': 'ifttt'
})
assert len(ifttt_events) == 1
assert ifttt_events[0].data['webhook_id'] == webhook_id
assert ifttt_events[0].data['hello'] == 'ifttt'
async def test_config_flow_aborts_external_url(hass, aiohttp_client):
"""Test setting up IFTTT and sending webhook."""
hass.config.api = Mock(base_url='http://192.168.1.10')
result = await hass.config_entries.flow.async_init('ifttt', context={
'source': 'user'
})
assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
assert result['reason'] == 'not_internet_accessible'

View file

@ -0,0 +1,98 @@
"""Test the webhook component."""
from unittest.mock import Mock
import pytest
from homeassistant.setup import async_setup_component
@pytest.fixture
def mock_client(hass, aiohttp_client):
"""Create http client for webhooks."""
hass.loop.run_until_complete(async_setup_component(hass, 'webhook', {}))
return hass.loop.run_until_complete(aiohttp_client(hass.http.app))
async def test_unregistering_webhook(hass, mock_client):
"""Test unregistering a webhook."""
hooks = []
webhook_id = hass.components.webhook.async_generate_id()
async def handle(*args):
"""Handle webhook."""
hooks.append(args)
hass.components.webhook.async_register(webhook_id, handle)
resp = await mock_client.post('/api/webhook/{}'.format(webhook_id))
assert resp.status == 200
assert len(hooks) == 1
hass.components.webhook.async_unregister(webhook_id)
resp = await mock_client.post('/api/webhook/{}'.format(webhook_id))
assert resp.status == 200
assert len(hooks) == 1
async def test_generate_webhook_url(hass):
"""Test we generate a webhook url correctly."""
hass.config.api = Mock(base_url='https://example.com')
url = hass.components.webhook.async_generate_url('some_id')
assert url == 'https://example.com/api/webhook/some_id'
async def test_posting_webhook_nonexisting(hass, mock_client):
"""Test posting to a nonexisting webhook."""
resp = await mock_client.post('/api/webhook/non-existing')
assert resp.status == 200
async def test_posting_webhook_invalid_json(hass, mock_client):
"""Test posting to a nonexisting webhook."""
hass.components.webhook.async_register('hello', None)
resp = await mock_client.post('/api/webhook/hello', data='not-json')
assert resp.status == 200
async def test_posting_webhook_json(hass, mock_client):
"""Test posting a webhook with JSON data."""
hooks = []
webhook_id = hass.components.webhook.async_generate_id()
async def handle(*args):
"""Handle webhook."""
hooks.append(args)
hass.components.webhook.async_register(webhook_id, handle)
resp = await mock_client.post('/api/webhook/{}'.format(webhook_id), json={
'data': True
})
assert resp.status == 200
assert len(hooks) == 1
assert hooks[0][0] is hass
assert hooks[0][1] == webhook_id
assert hooks[0][2] == {
'data': True
}
async def test_posting_webhook_no_data(hass, mock_client):
"""Test posting a webhook with no data."""
hooks = []
webhook_id = hass.components.webhook.async_generate_id()
async def handle(*args):
"""Handle webhook."""
hooks.append(args)
hass.components.webhook.async_register(webhook_id, handle)
resp = await mock_client.post('/api/webhook/{}'.format(webhook_id))
assert resp.status == 200
assert len(hooks) == 1
assert hooks[0][0] is hass
assert hooks[0][1] == webhook_id
assert hooks[0][2] == {}