Telegram bot component (incl. webhook and polling platform) (#6913)

* first commit.

* removed pointless string statement

* manually removed  # homeassistant.components.telegram_webhooks from requirements_all.txt

* deleted obsolete file.

* coveragerc abc
This commit is contained in:
sander76 2017-04-12 06:10:56 +02:00 committed by Paulus Schoutsen
parent edf500e66b
commit 7cb8f49d62
6 changed files with 352 additions and 145 deletions

View File

@ -431,7 +431,7 @@ omit =
homeassistant/components/switch/tplink.py
homeassistant/components/switch/transmission.py
homeassistant/components/switch/wake_on_lan.py
homeassistant/components/telegram_webhooks.py
homeassistant/components/telegram_bot/*
homeassistant/components/thingspeak.py
homeassistant/components/tts/amazon_polly.py
homeassistant/components/tts/picotts.py

View File

@ -0,0 +1,131 @@
"""
Component to receive telegram messages.
Either by polling or webhook.
"""
import asyncio
import logging
import voluptuous as vol
import homeassistant.helpers.config_validation as cv
from homeassistant.const import CONF_PLATFORM, CONF_API_KEY
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import discovery, config_per_platform
from homeassistant.setup import async_prepare_setup_platform
DOMAIN = 'telegram_bot'
_LOGGER = logging.getLogger(__name__)
EVENT_TELEGRAM_COMMAND = 'telegram_command'
EVENT_TELEGRAM_TEXT = 'telegram_text'
ATTR_COMMAND = 'command'
ATTR_USER_ID = 'user_id'
ATTR_ARGS = 'args'
ATTR_FROM_FIRST = 'from_first'
ATTR_FROM_LAST = 'from_last'
ATTR_TEXT = 'text'
CONF_ALLOWED_CHAT_IDS = 'allowed_chat_ids'
PLATFORM_SCHEMA = vol.Schema({
vol.Required(CONF_PLATFORM): cv.string,
vol.Required(CONF_API_KEY): cv.string,
vol.Required(CONF_ALLOWED_CHAT_IDS): vol.All(cv.ensure_list,
[cv.positive_int])
}, extra=vol.ALLOW_EXTRA)
@asyncio.coroutine
def async_setup(hass, config):
"""Setup the telegram bot component."""
@asyncio.coroutine
def async_setup_platform(p_type, p_config=None, discovery_info=None):
"""Setup a telegram bot platform."""
platform = yield from async_prepare_setup_platform(
hass, config, DOMAIN, p_type)
if platform is None:
_LOGGER.error("Unknown notification service specified")
return
_LOGGER.info("Setting up1 %s.%s", DOMAIN, p_type)
try:
if hasattr(platform, 'async_setup_platform'):
notify_service = yield from \
platform.async_setup_platform(hass, p_config,
discovery_info)
elif hasattr(platform, 'setup_platform'):
notify_service = yield from hass.loop.run_in_executor(
None, platform.setup_platform, hass, p_config,
discovery_info)
else:
raise HomeAssistantError("Invalid telegram bot platform.")
if notify_service is None:
_LOGGER.error(
"Failed to initialize telegram bot %s", p_type)
return
except Exception: # pylint: disable=broad-except
_LOGGER.exception('Error setting up platform %s', p_type)
return
return True
setup_tasks = [async_setup_platform(p_type, p_config) for p_type, p_config
in config_per_platform(config, DOMAIN)]
if setup_tasks:
yield from asyncio.wait(setup_tasks, loop=hass.loop)
@asyncio.coroutine
def async_platform_discovered(platform, info):
"""Callback to load a platform."""
yield from async_setup_platform(platform, discovery_info=info)
discovery.async_listen_platform(hass, DOMAIN, async_platform_discovered)
return True
class BaseTelegramBotEntity:
"""The base class for the telegram bot."""
def __init__(self, hass, allowed_chat_ids):
"""Initialize the bot base class."""
self.allowed_chat_ids = allowed_chat_ids
self.hass = hass
def process_message(self, data):
"""Check for basic message rules and fire an event if message is ok."""
data = data.get('message')
if (not data
or 'from' not in data
or 'text' not in data
or data['from'].get('id') not in self.allowed_chat_ids):
# Message is not correct.
_LOGGER.error("Incoming message does not have required data.")
return False
event = EVENT_TELEGRAM_COMMAND
event_data = {
ATTR_USER_ID: data['from']['id'],
ATTR_FROM_FIRST: data['from']['first_name'],
ATTR_FROM_LAST: data['from']['last_name']}
if data['text'][0] == '/':
pieces = data['text'].split(' ')
event_data[ATTR_COMMAND] = pieces[0]
event_data[ATTR_ARGS] = pieces[1:]
else:
event_data[ATTR_TEXT] = data['text']
event = EVENT_TELEGRAM_TEXT
self.hass.bus.async_fire(event, event_data)
return True

View File

@ -0,0 +1,121 @@
"""Telegram bot polling implementation."""
import asyncio
from asyncio.futures import CancelledError
import logging
import async_timeout
from aiohttp.client_exceptions import ClientError
from homeassistant.components.telegram_bot import CONF_ALLOWED_CHAT_IDS, \
BaseTelegramBotEntity, PLATFORM_SCHEMA
from homeassistant.const import EVENT_HOMEASSISTANT_START, \
EVENT_HOMEASSISTANT_STOP, CONF_API_KEY
from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
_LOGGER = logging.getLogger(__name__)
REQUIREMENTS = ['python-telegram-bot==5.3.0']
PLATFORM_SCHEMA = PLATFORM_SCHEMA
@asyncio.coroutine
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
"""Setup the polling platform."""
import telegram
bot = telegram.Bot(config[CONF_API_KEY])
pol = TelegramPoll(bot, hass, config[CONF_ALLOWED_CHAT_IDS])
@callback
def _start_bot(_event):
"""Start the bot."""
pol.start_polling()
@callback
def _stop_bot(_event):
"""Stop the bot."""
pol.stop_polling()
hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_START,
_start_bot
)
hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STOP,
_stop_bot
)
return True
class TelegramPoll(BaseTelegramBotEntity):
"""asyncio telegram incoming message handler."""
def __init__(self, bot, hass, allowed_chat_ids):
"""Initialize the polling instance."""
BaseTelegramBotEntity.__init__(self, hass, allowed_chat_ids)
self.update_id = 0
self.websession = async_get_clientsession(hass)
self.update_url = '{0}/getUpdates'.format(bot.base_url)
self.polling_task = None # The actuall polling task.
self.timeout = 15 # async post timeout
# polling timeout should always be less than async post timeout.
self.post_data = {'timeout': self.timeout - 5}
def start_polling(self):
"""Start the polling task."""
self.polling_task = self.hass.async_add_job(self.check_incoming())
def stop_polling(self):
"""Stop the polling task."""
self.polling_task.cancel()
@asyncio.coroutine
def get_updates(self, offset):
"""Bypass the default long polling method to enable asyncio."""
resp = None
_json = [] # The actual value to be returned.
if offset:
self.post_data['offset'] = offset
try:
with async_timeout.timeout(self.timeout, loop=self.hass.loop):
resp = yield from self.websession.post(
self.update_url, data=self.post_data,
headers={'connection': 'keep-alive'}
)
if resp.status != 200:
_LOGGER.error("Error %s on %s", resp.status, self.update_url)
_json = yield from resp.json()
except ValueError:
_LOGGER.error("Error parsing Json message")
except (asyncio.TimeoutError, ClientError):
_LOGGER.error("Client connection error")
finally:
if resp is not None:
yield from resp.release()
return _json
@asyncio.coroutine
def handle(self):
"""" Receiving and processing incoming messages."""
_updates = yield from self.get_updates(self.update_id)
for update in _updates['result']:
self.update_id = update['update_id'] + 1
self.process_message(update)
@asyncio.coroutine
def check_incoming(self):
""""Loop which continuously checks for incoming telegram messages."""
try:
while True:
# Each handle call sends a long polling post request
# to the telegram server. If no incoming message it will return
# an empty list. Calling self.handle() without any delay or
# timeout will for this reason not really stress the processor.
yield from self.handle()
except CancelledError:
_LOGGER.debug("Stopping telegram polling bot")

View File

@ -0,0 +1,97 @@
"""
Allows utilizing telegram webhooks.
See https://core.telegram.org/bots/webhooks for details
about webhooks.
"""
import asyncio
import logging
from ipaddress import ip_network
import voluptuous as vol
from homeassistant.const import (
HTTP_BAD_REQUEST, HTTP_UNAUTHORIZED)
import homeassistant.helpers.config_validation as cv
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.telegram_bot import CONF_ALLOWED_CHAT_IDS, \
BaseTelegramBotEntity, PLATFORM_SCHEMA
from homeassistant.const import CONF_API_KEY
from homeassistant.components.http.util import get_real_ip
DEPENDENCIES = ['http']
REQUIREMENTS = ['python-telegram-bot==5.3.0']
_LOGGER = logging.getLogger(__name__)
TELEGRAM_HANDLER_URL = '/api/telegram_webhooks'
CONF_TRUSTED_NETWORKS = 'trusted_networks'
DEFAULT_TRUSTED_NETWORKS = [
ip_network('149.154.167.197/32'),
ip_network('149.154.167.198/31'),
ip_network('149.154.167.200/29'),
ip_network('149.154.167.208/28'),
ip_network('149.154.167.224/29'),
ip_network('149.154.167.232/31')
]
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
vol.Optional(CONF_TRUSTED_NETWORKS, default=DEFAULT_TRUSTED_NETWORKS):
vol.All(cv.ensure_list, [ip_network])
})
def setup_platform(hass, config, async_add_devices, discovery_info=None):
"""Setup the polling platform."""
import telegram
bot = telegram.Bot(config[CONF_API_KEY])
current_status = bot.getWebhookInfo()
handler_url = "{0}{1}".format(hass.config.api.base_url,
TELEGRAM_HANDLER_URL)
if current_status and current_status['url'] != handler_url:
if bot.setWebhook(handler_url):
_LOGGER.info("set new telegram webhook %s", handler_url)
hass.http.register_view(
BotPushReceiver(
hass,
config[CONF_ALLOWED_CHAT_IDS],
config[CONF_TRUSTED_NETWORKS]))
else:
_LOGGER.error("set telegram webhook failed %s", handler_url)
class BotPushReceiver(HomeAssistantView, BaseTelegramBotEntity):
"""Handle pushes from telegram."""
requires_auth = False
url = TELEGRAM_HANDLER_URL
name = "telegram_webhooks"
def __init__(self, hass, allowed_chat_ids, trusted_networks):
"""Initialize the class."""
BaseTelegramBotEntity.__init__(self, hass, allowed_chat_ids)
self.trusted_networks = trusted_networks
@asyncio.coroutine
def post(self, request):
"""Accept the POST from telegram."""
real_ip = get_real_ip(request)
if not any(real_ip in net for net in self.trusted_networks):
_LOGGER.warning("Access denied from %s", real_ip)
return self.json_message('Access denied', HTTP_UNAUTHORIZED)
try:
data = yield from request.json()
except ValueError:
return self.json_message('Invalid JSON', HTTP_BAD_REQUEST)
if not self.process_message(data):
return self.json_message('Invalid message', HTTP_BAD_REQUEST)
else:
return self.json({})

View File

@ -1,143 +0,0 @@
"""
Allows utilizing telegram webhooks.
See https://core.telegram.org/bots/webhooks for details
about webhooks.
"""
import asyncio
import logging
from ipaddress import ip_network
import voluptuous as vol
from homeassistant.const import (
HTTP_BAD_REQUEST, HTTP_UNAUTHORIZED)
import homeassistant.helpers.config_validation as cv
from homeassistant.components.http import HomeAssistantView
from homeassistant.const import CONF_API_KEY
from homeassistant.components.http.util import get_real_ip
DOMAIN = 'telegram_webhooks'
DEPENDENCIES = ['http']
REQUIREMENTS = ['python-telegram-bot==5.3.0']
_LOGGER = logging.getLogger(__name__)
EVENT_TELEGRAM_COMMAND = 'telegram_command'
EVENT_TELEGRAM_TEXT = 'telegram_text'
TELEGRAM_HANDLER_URL = '/api/telegram_webhooks'
CONF_USER_ID = 'user_id'
CONF_TRUSTED_NETWORKS = 'trusted_networks'
DEFAULT_TRUSTED_NETWORKS = [
ip_network('149.154.167.197/32'),
ip_network('149.154.167.198/31'),
ip_network('149.154.167.200/29'),
ip_network('149.154.167.208/28'),
ip_network('149.154.167.224/29'),
ip_network('149.154.167.232/31')
]
ATTR_COMMAND = 'command'
ATTR_TEXT = 'text'
ATTR_USER_ID = 'user_id'
ATTR_ARGS = 'args'
CONFIG_SCHEMA = vol.Schema({
DOMAIN: vol.Schema({
vol.Optional(CONF_API_KEY): cv.string,
vol.Optional(CONF_TRUSTED_NETWORKS, default=DEFAULT_TRUSTED_NETWORKS):
vol.All(cv.ensure_list, [ip_network]),
vol.Required(CONF_USER_ID): {cv.string: cv.positive_int},
}),
}, extra=vol.ALLOW_EXTRA)
def setup(hass, config):
"""Setup the telegram_webhooks component.
register webhook if API_KEY is specified
register /api/telegram_webhooks as web service for telegram bot
"""
import telegram
conf = config[DOMAIN]
if CONF_API_KEY in conf:
bot = telegram.Bot(conf[CONF_API_KEY])
current_status = bot.getWebhookInfo()
_LOGGER.debug("telegram webhook status: %s", current_status)
handler_url = "{0}{1}".format(hass.config.api.base_url,
TELEGRAM_HANDLER_URL)
if current_status and current_status['url'] != handler_url:
if bot.setWebhook(handler_url):
_LOGGER.info("set new telegram webhook %s", handler_url)
else:
_LOGGER.error("set telegram webhook failed %s", handler_url)
hass.http.register_view(BotPushReceiver(conf[CONF_USER_ID],
conf[CONF_TRUSTED_NETWORKS]))
return True
class BotPushReceiver(HomeAssistantView):
"""Handle pushes from telegram."""
requires_auth = False
url = TELEGRAM_HANDLER_URL
name = "telegram_webhooks"
def __init__(self, user_id_array, trusted_networks):
"""Initialize users allowed to send messages to bot."""
self.trusted_networks = trusted_networks
self.users = {user_id: dev_id for dev_id, user_id in
user_id_array.items()}
_LOGGER.debug("users allowed: %s", self.users)
@asyncio.coroutine
def post(self, request):
"""Accept the POST from telegram."""
real_ip = get_real_ip(request)
if not any(real_ip in net for net in self.trusted_networks):
_LOGGER.warning("Access denied from %s", real_ip)
return self.json_message('Access denied', HTTP_UNAUTHORIZED)
try:
data = yield from request.json()
except ValueError:
_LOGGER.error("Received telegram data: %s", data)
return self.json_message('Invalid JSON', HTTP_BAD_REQUEST)
# check for basic message rules
data = data.get('message')
if not data or 'from' not in data or 'text' not in data:
return self.json({})
if data['from'].get('id') not in self.users:
_LOGGER.warning("User not allowed")
return self.json_message('Invalid user', HTTP_BAD_REQUEST)
_LOGGER.debug("Received telegram data: %s", data)
if not data['text']:
_LOGGER.warning('no text')
return self.json({})
if data['text'][:1] == '/':
# telegram command "/blabla arg1 arg2 ..."
pieces = data['text'].split(' ')
request.app['hass'].bus.async_fire(EVENT_TELEGRAM_COMMAND, {
ATTR_COMMAND: pieces[0],
ATTR_ARGS: " ".join(pieces[1:]),
ATTR_USER_ID: data['from']['id'],
})
# telegram text "bla bla"
request.app['hass'].bus.async_fire(EVENT_TELEGRAM_TEXT, {
ATTR_TEXT: data['text'],
ATTR_USER_ID: data['from']['id'],
})
return self.json({})

View File

@ -632,8 +632,9 @@ python-pushover==0.2
# homeassistant.components.sensor.synologydsm
python-synology==0.1.0
# homeassistant.components.telegram_webhooks
# homeassistant.components.notify.telegram
# homeassistant.components.telegram_bot.polling
# homeassistant.components.telegram_bot.webhooks
python-telegram-bot==5.3.0
# homeassistant.components.sensor.twitch