From 7cb8f49d62fc47f0edabf9b7d1d484cb07a38341 Mon Sep 17 00:00:00 2001 From: sander76 Date: Wed, 12 Apr 2017 06:10:56 +0200 Subject: [PATCH] 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 --- .coveragerc | 2 +- .../components/telegram_bot/__init__.py | 131 ++++++++++++++++ .../components/telegram_bot/polling.py | 121 +++++++++++++++ .../components/telegram_bot/webhooks.py | 97 ++++++++++++ homeassistant/components/telegram_webhooks.py | 143 ------------------ requirements_all.txt | 3 +- 6 files changed, 352 insertions(+), 145 deletions(-) create mode 100644 homeassistant/components/telegram_bot/__init__.py create mode 100644 homeassistant/components/telegram_bot/polling.py create mode 100644 homeassistant/components/telegram_bot/webhooks.py delete mode 100644 homeassistant/components/telegram_webhooks.py diff --git a/.coveragerc b/.coveragerc index a8e771c9ad2..578276cac43 100644 --- a/.coveragerc +++ b/.coveragerc @@ -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 diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py new file mode 100644 index 00000000000..92a87153d99 --- /dev/null +++ b/homeassistant/components/telegram_bot/__init__.py @@ -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 diff --git a/homeassistant/components/telegram_bot/polling.py b/homeassistant/components/telegram_bot/polling.py new file mode 100644 index 00000000000..3e0dfa89375 --- /dev/null +++ b/homeassistant/components/telegram_bot/polling.py @@ -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") diff --git a/homeassistant/components/telegram_bot/webhooks.py b/homeassistant/components/telegram_bot/webhooks.py new file mode 100644 index 00000000000..3ffc03780bd --- /dev/null +++ b/homeassistant/components/telegram_bot/webhooks.py @@ -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({}) diff --git a/homeassistant/components/telegram_webhooks.py b/homeassistant/components/telegram_webhooks.py deleted file mode 100644 index f952145f822..00000000000 --- a/homeassistant/components/telegram_webhooks.py +++ /dev/null @@ -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({}) diff --git a/requirements_all.txt b/requirements_all.txt index 49c13c1adc5..c1f8f1eec2b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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