From b30c352e37506d82af78379e1b6d333358c7ccbb Mon Sep 17 00:00:00 2001 From: Eugenio Panadero Date: Wed, 10 May 2017 06:42:17 +0200 Subject: [PATCH] Telegram Bot enhancements with callback queries and new notification services (#7454) * telegram_bot and notify.telegram enhancements: - Receive callback queries and produce `telegram_callback` events. - Custom reply_markup (keyboard or inline_keyboard) for every type of message (message, photo, location & document). - `disable_notification`, `disable_web_page_preview`, `reply_to_message_id` and `parse_mode` optional keyword args. - Line break between title and message fields: `'{}\n{}'.format(title, message)` - Move Telegram notification services to `telegram_bot` component and forward service calls from the telegram notify service to the telegram component, so now the `notify.telegram` platform depends of `telegram_bot`, and there is no need for `api_key` in the notifier configuration. The notifier calls the new notification services of the bot component: - telegram_bot/send_message - telegram_bot/send_photo - telegram_bot/send_document - telegram_bot/send_location - telegram_bot/edit_message - telegram_bot/edit_caption - telegram_bot/edit_replymarkup - telegram_bot/answer_callback_query - Added descriptions of the new notification services with a services.yaml file. - CONFIG_SCHEMA instead of PLATFORM_SCHEMA for the `telegram_bot` component, so only one platform is allowed. - Async component setup. * telegram_bot and notify.telegram enhancements: change in requirements_all.txt. --- homeassistant/components/notify/telegram.py | 182 ++---- .../components/telegram_bot/__init__.py | 526 ++++++++++++++++-- .../components/telegram_bot/polling.py | 12 +- .../components/telegram_bot/services.yaml | 227 ++++++++ .../components/telegram_bot/webhooks.py | 54 +- requirements_all.txt | 4 +- 6 files changed, 763 insertions(+), 242 deletions(-) create mode 100644 homeassistant/components/telegram_bot/services.yaml diff --git a/homeassistant/components/notify/telegram.py b/homeassistant/components/notify/telegram.py index 7ca2e1ed262..1bc2baa632e 100644 --- a/homeassistant/components/notify/telegram.py +++ b/homeassistant/components/notify/telegram.py @@ -4,186 +4,86 @@ Telegram platform for notify component. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/notify.telegram/ """ -import io import logging -import urllib -import requests import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.notify import ( - ATTR_TITLE, ATTR_DATA, PLATFORM_SCHEMA, BaseNotificationService) -from homeassistant.const import ( - CONF_API_KEY, ATTR_LOCATION, ATTR_LATITUDE, ATTR_LONGITUDE) + ATTR_MESSAGE, ATTR_TITLE, ATTR_DATA, ATTR_TARGET, + PLATFORM_SCHEMA, BaseNotificationService) +from homeassistant.const import ATTR_LOCATION _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['python-telegram-bot==5.3.1'] +DOMAIN = 'telegram_bot' +DEPENDENCIES = [DOMAIN] -ATTR_PHOTO = 'photo' ATTR_KEYBOARD = 'keyboard' +ATTR_INLINE_KEYBOARD = 'inline_keyboard' +ATTR_PHOTO = 'photo' ATTR_DOCUMENT = 'document' -ATTR_CAPTION = 'caption' -ATTR_URL = 'url' -ATTR_FILE = 'file' -ATTR_USERNAME = 'username' -ATTR_PASSWORD = 'password' CONF_CHAT_ID = 'chat_id' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_CHAT_ID): cv.string, + vol.Required(CONF_CHAT_ID): cv.positive_int, }) def get_service(hass, config, discovery_info=None): """Get the Telegram notification service.""" - import telegram - - try: - chat_id = config.get(CONF_CHAT_ID) - api_key = config.get(CONF_API_KEY) - bot = telegram.Bot(token=api_key) - username = bot.getMe()['username'] - _LOGGER.debug("Telegram bot is '%s'", username) - except urllib.error.HTTPError: - _LOGGER.error("Please check your access token") - return None - - return TelegramNotificationService(api_key, chat_id) - - -def load_data(url=None, file=None, username=None, password=None): - """Load photo/document into ByteIO/File container from a source.""" - try: - if url is not None: - # Load photo from URL - if username is not None and password is not None: - req = requests.get(url, auth=(username, password), timeout=15) - else: - req = requests.get(url, timeout=15) - return io.BytesIO(req.content) - - elif file is not None: - # Load photo from file - return open(file, "rb") - else: - _LOGGER.warning("Can't load photo no photo found in params!") - - except OSError as error: - _LOGGER.error("Can't load photo into ByteIO: %s", error) - - return None + chat_id = config.get(CONF_CHAT_ID) + return TelegramNotificationService(hass, chat_id) class TelegramNotificationService(BaseNotificationService): """Implement the notification service for Telegram.""" - def __init__(self, api_key, chat_id): + def __init__(self, hass, chat_id): """Initialize the service.""" - import telegram - - self._api_key = api_key self._chat_id = chat_id - self.bot = telegram.Bot(token=self._api_key) + self.hass = hass def send_message(self, message="", **kwargs): """Send a message to a user.""" - import telegram - - title = kwargs.get(ATTR_TITLE) + service_data = dict(target=kwargs.get(ATTR_TARGET, self._chat_id)) + if ATTR_TITLE in kwargs: + service_data.update({ATTR_TITLE: kwargs.get(ATTR_TITLE)}) + if message: + service_data.update({ATTR_MESSAGE: message}) data = kwargs.get(ATTR_DATA) - # Exists data for send a photo/location + # Get keyboard info + if data is not None and ATTR_KEYBOARD in data: + keys = data.get(ATTR_KEYBOARD) + keys = keys if isinstance(keys, list) else [keys] + service_data.update(keyboard=keys) + elif data is not None and ATTR_INLINE_KEYBOARD in data: + keys = data.get(ATTR_INLINE_KEYBOARD) + keys = keys if isinstance(keys, list) else [keys] + service_data.update(inline_keyboard=keys) + + # Send a photo, a document or a location if data is not None and ATTR_PHOTO in data: photos = data.get(ATTR_PHOTO, None) photos = photos if isinstance(photos, list) else [photos] - for photo_data in photos: - self.send_photo(photo_data) + service_data.update(photo_data) + self.hass.services.call( + DOMAIN, 'send_photo', service_data=service_data) return elif data is not None and ATTR_LOCATION in data: - return self.send_location(data.get(ATTR_LOCATION)) + service_data.update(data.get(ATTR_LOCATION)) + return self.hass.services.call( + DOMAIN, 'send_location', service_data=service_data) elif data is not None and ATTR_DOCUMENT in data: - return self.send_document(data.get(ATTR_DOCUMENT)) - elif data is not None and ATTR_KEYBOARD in data: - keys = data.get(ATTR_KEYBOARD) - keys = keys if isinstance(keys, list) else [keys] - return self.send_keyboard(message, keys) - - if title: - text = '{} {}'.format(title, message) - else: - text = message - - parse_mode = telegram.parsemode.ParseMode.MARKDOWN + service_data.update(data.get(ATTR_DOCUMENT)) + return self.hass.services.call( + DOMAIN, 'send_document', service_data=service_data) # Send message - try: - self.bot.sendMessage( - chat_id=self._chat_id, text=text, parse_mode=parse_mode) - except telegram.error.TelegramError: - _LOGGER.exception("Error sending message") - - def send_keyboard(self, message, keys): - """Display keyboard.""" - import telegram - - keyboard = telegram.ReplyKeyboardMarkup([ - [key.strip() for key in row.split(",")] for row in keys]) - try: - self.bot.sendMessage( - chat_id=self._chat_id, text=message, reply_markup=keyboard) - except telegram.error.TelegramError: - _LOGGER.exception("Error sending message") - - def send_photo(self, data): - """Send a photo.""" - import telegram - caption = data.get(ATTR_CAPTION) - - # Send photo - try: - photo = load_data( - url=data.get(ATTR_URL), - file=data.get(ATTR_FILE), - username=data.get(ATTR_USERNAME), - password=data.get(ATTR_PASSWORD), - ) - self.bot.sendPhoto( - chat_id=self._chat_id, photo=photo, caption=caption) - except telegram.error.TelegramError: - _LOGGER.exception("Error sending photo") - - def send_document(self, data): - """Send a document.""" - import telegram - caption = data.get(ATTR_CAPTION) - - # send photo - try: - document = load_data( - url=data.get(ATTR_URL), - file=data.get(ATTR_FILE), - username=data.get(ATTR_USERNAME), - password=data.get(ATTR_PASSWORD), - ) - self.bot.sendDocument( - chat_id=self._chat_id, document=document, caption=caption) - except telegram.error.TelegramError: - _LOGGER.exception("Error sending document") - - def send_location(self, gps): - """Send a location.""" - import telegram - latitude = float(gps.get(ATTR_LATITUDE, 0.0)) - longitude = float(gps.get(ATTR_LONGITUDE, 0.0)) - - # Send location - try: - self.bot.sendLocation( - chat_id=self._chat_id, latitude=latitude, longitude=longitude) - except telegram.error.TelegramError: - _LOGGER.exception("Error sending location") + _LOGGER.debug('TELEGRAM NOTIFIER calling %s.send_message with %s', + DOMAIN, service_data) + return self.hass.services.call( + DOMAIN, 'send_message', service_data=service_data) diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 200c4227f4d..235217d1942 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -1,45 +1,186 @@ -"""Component to receive telegram messages.""" -import asyncio -import logging +""" +Component to send and receive Telegram messages. +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/telegram_bot/ +""" +import asyncio +import io +from functools import partial +from ipaddress import ip_network +import logging +import os + +import requests import voluptuous as vol +from homeassistant.components.notify import ( + ATTR_MESSAGE, ATTR_TITLE, ATTR_DATA) +from homeassistant.config import load_yaml_config_file +from homeassistant.const import ( + CONF_PLATFORM, CONF_API_KEY, CONF_TIMEOUT, ATTR_LATITUDE, ATTR_LONGITUDE) 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' +REQUIREMENTS = ['python-telegram-bot==5.3.1'] _LOGGER = logging.getLogger(__name__) EVENT_TELEGRAM_COMMAND = 'telegram_command' EVENT_TELEGRAM_TEXT = 'telegram_text' +EVENT_TELEGRAM_CALLBACK = 'telegram_callback' +PARSER_MD = 'markdown' +PARSER_HTML = 'html' +ATTR_TEXT = 'text' ATTR_COMMAND = 'command' ATTR_USER_ID = 'user_id' ATTR_ARGS = 'args' +ATTR_MSG = 'message' +ATTR_CHAT_INSTANCE = 'chat_instance' +ATTR_CHAT_ID = 'chat_id' +ATTR_MSGID = 'id' ATTR_FROM_FIRST = 'from_first' ATTR_FROM_LAST = 'from_last' -ATTR_TEXT = 'text' - +ATTR_SHOW_ALERT = 'show_alert' +ATTR_MESSAGEID = 'message_id' +ATTR_PARSER = 'parse_mode' +ATTR_DISABLE_NOTIF = 'disable_notification' +ATTR_DISABLE_WEB_PREV = 'disable_web_page_preview' +ATTR_REPLY_TO_MSGID = 'reply_to_message_id' +ATTR_REPLYMARKUP = 'reply_markup' +ATTR_CALLBACK_QUERY = 'callback_query' +ATTR_CALLBACK_QUERY_ID = 'callback_query_id' +ATTR_TARGET = 'target' +ATTR_KEYBOARD = 'keyboard' +ATTR_KEYBOARD_INLINE = 'inline_keyboard' +ATTR_URL = 'url' +ATTR_FILE = 'file' +ATTR_CAPTION = 'caption' +ATTR_USERNAME = 'username' +ATTR_PASSWORD = 'password' CONF_ALLOWED_CHAT_IDS = 'allowed_chat_ids' +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 = 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]) +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: 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]), + vol.Optional(ATTR_PARSER, default=PARSER_MD): cv.string, + vol.Optional(CONF_TRUSTED_NETWORKS, default=DEFAULT_TRUSTED_NETWORKS): + vol.All(cv.ensure_list, [ip_network]) + }) }, extra=vol.ALLOW_EXTRA) +BASE_SERVICE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_TARGET): vol.All(cv.ensure_list, [cv.positive_int]), + vol.Optional(ATTR_PARSER): cv.string, + vol.Optional(ATTR_DISABLE_NOTIF): cv.boolean, + vol.Optional(ATTR_DISABLE_WEB_PREV): cv.boolean, + vol.Optional(ATTR_KEYBOARD): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(ATTR_KEYBOARD_INLINE): cv.ensure_list, +}, extra=vol.ALLOW_EXTRA) +SERVICE_SEND_MESSAGE = 'send_message' +SERVICE_SCHEMA_SEND_MESSAGE = BASE_SERVICE_SCHEMA.extend({ + vol.Required(ATTR_MESSAGE): cv.template, + vol.Optional(ATTR_TITLE): cv.template, +}) +SERVICE_SEND_PHOTO = 'send_photo' +SERVICE_SEND_DOCUMENT = 'send_document' +SERVICE_SCHEMA_SEND_FILE = BASE_SERVICE_SCHEMA.extend({ + vol.Optional(ATTR_URL): cv.string, + vol.Optional(ATTR_FILE): cv.string, + vol.Optional(ATTR_CAPTION): cv.string, + vol.Optional(ATTR_USERNAME): cv.string, + vol.Optional(ATTR_PASSWORD): cv.string, +}) +SERVICE_SEND_LOCATION = 'send_location' +SERVICE_SCHEMA_SEND_LOCATION = BASE_SERVICE_SCHEMA.extend({ + vol.Required(ATTR_LONGITUDE): float, + vol.Required(ATTR_LATITUDE): float, +}) +SERVICE_EDIT_MESSAGE = 'edit_message' +SERVICE_SCHEMA_EDIT_MESSAGE = SERVICE_SCHEMA_SEND_MESSAGE.extend({ + vol.Required(ATTR_MESSAGEID): vol.Any(cv.positive_int, cv.string), + vol.Required(ATTR_CHAT_ID): cv.positive_int, +}) +SERVICE_EDIT_CAPTION = 'edit_caption' +SERVICE_SCHEMA_EDIT_CAPTION = vol.Schema({ + vol.Required(ATTR_MESSAGEID): vol.Any(cv.positive_int, cv.string), + vol.Required(ATTR_CHAT_ID): cv.positive_int, + vol.Required(ATTR_CAPTION): cv.string, + vol.Optional(ATTR_KEYBOARD_INLINE): cv.ensure_list, +}, extra=vol.ALLOW_EXTRA) +SERVICE_EDIT_REPLYMARKUP = 'edit_replymarkup' +SERVICE_SCHEMA_EDIT_REPLYMARKUP = vol.Schema({ + vol.Required(ATTR_MESSAGEID): vol.Any(cv.positive_int, cv.string), + vol.Required(ATTR_CHAT_ID): cv.positive_int, + vol.Required(ATTR_KEYBOARD_INLINE): cv.ensure_list, +}, extra=vol.ALLOW_EXTRA) +SERVICE_ANSWER_CALLBACK_QUERY = 'answer_callback_query' +SERVICE_SCHEMA_ANSWER_CALLBACK_QUERY = vol.Schema({ + vol.Required(ATTR_MESSAGE): cv.template, + vol.Required(ATTR_CALLBACK_QUERY_ID): cv.positive_int, + vol.Optional(ATTR_SHOW_ALERT): cv.boolean, +}, extra=vol.ALLOW_EXTRA) + +SERVICE_MAP = { + SERVICE_SEND_MESSAGE: SERVICE_SCHEMA_SEND_MESSAGE, + SERVICE_SEND_PHOTO: SERVICE_SCHEMA_SEND_FILE, + SERVICE_SEND_DOCUMENT: SERVICE_SCHEMA_SEND_FILE, + SERVICE_SEND_LOCATION: SERVICE_SCHEMA_SEND_LOCATION, + SERVICE_EDIT_MESSAGE: SERVICE_SCHEMA_EDIT_MESSAGE, + SERVICE_EDIT_CAPTION: SERVICE_SCHEMA_EDIT_CAPTION, + SERVICE_EDIT_REPLYMARKUP: SERVICE_SCHEMA_EDIT_REPLYMARKUP, + SERVICE_ANSWER_CALLBACK_QUERY: SERVICE_SCHEMA_ANSWER_CALLBACK_QUERY, +} + + +def load_data(url=None, file=None, username=None, password=None): + """Load photo/document into ByteIO/File container from a source.""" + try: + if url is not None: + # Load photo from URL + if username is not None and password is not None: + req = requests.get(url, auth=(username, password), timeout=15) + else: + req = requests.get(url, timeout=15) + return io.BytesIO(req.content) + + elif file is not None: + # Load photo from file + return open(file, "rb") + else: + _LOGGER.warning("Can't load photo. No photo found in params!") + + except OSError as error: + _LOGGER.error("Can't load photo into ByteIO: %s", error) + + return None + @asyncio.coroutine def async_setup(hass, config): - """Set up the telegram bot component.""" + """Set up the Telegram bot component.""" + conf = config[DOMAIN] + descriptions = yield from hass.loop.run_in_executor( + None, load_yaml_config_file, + os.path.join(os.path.dirname(__file__), 'services.yaml')) + @asyncio.coroutine def async_setup_platform(p_type, p_config=None, discovery_info=None): - """Set up a telegram bot platform.""" + """Set up a Telegram bot platform.""" platform = yield from async_prepare_setup_platform( hass, config, DOMAIN, p_type) @@ -48,20 +189,10 @@ def async_setup(hass, config): return _LOGGER.info("Setting up %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: + receiver_service = yield from \ + platform.async_setup_platform(hass, p_config, discovery_info) + if receiver_service is None: _LOGGER.error( "Failed to initialize Telegram bot %s", p_type) return @@ -70,22 +201,275 @@ def async_setup(hass, config): _LOGGER.exception('Error setting up platform %s', p_type) return - setup_tasks = [async_setup_platform(p_type, p_config) for p_type, p_config - in config_per_platform(config, DOMAIN)] + notify_service = TelegramNotificationService( + hass, + p_config.get(CONF_API_KEY), + p_config.get(CONF_ALLOWED_CHAT_IDS), + p_config.get(ATTR_PARSER) + ) - if setup_tasks: - yield from asyncio.wait(setup_tasks, loop=hass.loop) + @asyncio.coroutine + def async_send_telegram_message(service): + """Handle sending Telegram Bot message service calls.""" + def _render_template_attr(data, attribute): + attribute_templ = data.get(attribute) + if attribute_templ: + attribute_templ.hass = hass + data[attribute] = attribute_templ.async_render() - @asyncio.coroutine - def async_platform_discovered(platform, info): - """Handle the loading of a platform.""" - yield from async_setup_platform(platform, discovery_info=info) + msgtype = service.service + kwargs = dict(service.data) + _render_template_attr(kwargs, ATTR_MESSAGE) + _render_template_attr(kwargs, ATTR_TITLE) + _LOGGER.debug('NEW telegram_message "%s": %s', msgtype, kwargs) - discovery.async_listen_platform(hass, DOMAIN, async_platform_discovered) + if msgtype == SERVICE_SEND_MESSAGE: + yield from hass.async_add_job( + partial(notify_service.send_message, **kwargs)) + elif msgtype == SERVICE_SEND_PHOTO: + yield from hass.async_add_job( + partial(notify_service.send_file, True, **kwargs)) + elif msgtype == SERVICE_SEND_DOCUMENT: + yield from hass.async_add_job( + partial(notify_service.send_file, False, **kwargs)) + elif msgtype == SERVICE_SEND_LOCATION: + yield from hass.async_add_job( + partial(notify_service.send_location, **kwargs)) + elif msgtype == SERVICE_ANSWER_CALLBACK_QUERY: + yield from hass.async_add_job( + partial(notify_service.answer_callback_query, **kwargs)) + else: + yield from hass.async_add_job( + partial(notify_service.edit_message, msgtype, **kwargs)) + + # Register notification services + for service_notif, schema in SERVICE_MAP.items(): + hass.services.async_register( + DOMAIN, service_notif, async_send_telegram_message, + descriptions.get(service_notif), schema=schema) + + return True + + yield from async_setup_platform(conf.get(CONF_PLATFORM), conf) return True +class TelegramNotificationService: + """Implement the notification services for the Telegram Bot domain.""" + + def __init__(self, hass, api_key, allowed_chat_ids, parser): + """Initialize the service.""" + from telegram import Bot + from telegram.parsemode import ParseMode + + self.allowed_chat_ids = allowed_chat_ids + self._default_user = self.allowed_chat_ids[0] + self._last_message_id = {user: None for user in self.allowed_chat_ids} + self._parsers = {PARSER_HTML: ParseMode.HTML, + PARSER_MD: ParseMode.MARKDOWN} + self._parse_mode = self._parsers.get(parser) + self.bot = Bot(token=api_key) + self.hass = hass + + def _get_msg_ids(self, msg_data, chat_id): + """Get the message id to edit. + + This can be one of (message_id, inline_message_id) from a msg dict, + returning a tuple. + **You can use 'last' as message_id** to edit + the last sended message in the chat_id. + """ + message_id = inline_message_id = None + if ATTR_MESSAGEID in msg_data: + message_id = msg_data[ATTR_MESSAGEID] + if (isinstance(message_id, str) and (message_id == 'last') and + (self._last_message_id[chat_id] is not None)): + message_id = self._last_message_id[chat_id] + else: + inline_message_id = msg_data['inline_message_id'] + return message_id, inline_message_id + + def _get_target_chat_ids(self, target): + """Validate chat_id targets or return default target (fist defined). + + :param target: optional list of strings or ints (['12234'] or [12234]) + :return list of chat_id targets (integers) + """ + if target is not None: + if isinstance(target, int): + if target in self.allowed_chat_ids: + return [target] + _LOGGER.warning('BAD TARGET "%s", using default: %s', + target, self._default_user) + else: + try: + chat_ids = [int(t) for t in target + if int(t) in self.allowed_chat_ids] + if len(chat_ids) > 0: + return chat_ids + _LOGGER.warning('ALL BAD TARGETS: "%s"', target) + except (ValueError, TypeError): + _LOGGER.warning('BAD TARGET DATA "%s", using default: %s', + target, self._default_user) + return [self._default_user] + + def _get_msg_kwargs(self, data): + """Get parameters in message data kwargs.""" + def _make_row_of_kb(row_keyboard): + """Make a list of InlineKeyboardButtons from a list of tuples. + + :param row_keyboard: [(text_b1, data_callback_b1), + (text_b2, data_callback_b2), ...] + """ + from telegram import InlineKeyboardButton + if isinstance(row_keyboard, str): + return [InlineKeyboardButton( + key.strip()[1:].upper(), callback_data=key) + for key in row_keyboard.split(",")] + elif isinstance(row_keyboard, list): + return [InlineKeyboardButton( + text_btn, callback_data=data_btn) + for text_btn, data_btn in row_keyboard] + else: + raise ValueError(str(row_keyboard)) + + # Defaults + params = { + ATTR_PARSER: self._parse_mode, + ATTR_DISABLE_NOTIF: False, + ATTR_DISABLE_WEB_PREV: None, + ATTR_REPLY_TO_MSGID: None, + ATTR_REPLYMARKUP: None, + CONF_TIMEOUT: None + } + if data is not None: + if ATTR_PARSER in data: + params[ATTR_PARSER] = self._parsers.get( + data[ATTR_PARSER], self._parse_mode) + if CONF_TIMEOUT in data: + params[CONF_TIMEOUT] = data[CONF_TIMEOUT] + if ATTR_DISABLE_NOTIF in data: + params[ATTR_DISABLE_NOTIF] = data[ATTR_DISABLE_NOTIF] + if ATTR_DISABLE_WEB_PREV in data: + params[ATTR_DISABLE_WEB_PREV] = data[ATTR_DISABLE_WEB_PREV] + if ATTR_REPLY_TO_MSGID in data: + params[ATTR_REPLY_TO_MSGID] = data[ATTR_REPLY_TO_MSGID] + # Keyboards: + if ATTR_KEYBOARD in data: + from telegram import ReplyKeyboardMarkup + keys = data.get(ATTR_KEYBOARD) + keys = keys if isinstance(keys, list) else [keys] + params[ATTR_REPLYMARKUP] = ReplyKeyboardMarkup( + [[key.strip() for key in row.split(",")] for row in keys]) + elif ATTR_KEYBOARD_INLINE in data: + from telegram import InlineKeyboardMarkup + keys = data.get(ATTR_KEYBOARD_INLINE) + keys = keys if isinstance(keys, list) else [keys] + params[ATTR_REPLYMARKUP] = InlineKeyboardMarkup( + [_make_row_of_kb(row) for row in keys]) + return params + + def _send_msg(self, func_send, msg_error, *args_rep, **kwargs_rep): + """Send one message.""" + from telegram.error import TelegramError + try: + out = func_send(*args_rep, **kwargs_rep) + if not isinstance(out, bool) and hasattr(out, ATTR_MESSAGEID): + chat_id = out.chat_id + self._last_message_id[chat_id] = out[ATTR_MESSAGEID] + _LOGGER.debug('LAST MSG ID: %s (from chat_id %s)', + self._last_message_id, chat_id) + elif not isinstance(out, bool): + _LOGGER.warning('UPDATE LAST MSG??: out_type:%s, out=%s', + type(out), out) + return out + except TelegramError: + _LOGGER.exception(msg_error) + + def send_message(self, message="", target=None, **kwargs): + """Send a message to one or multiple pre-allowed chat_ids.""" + title = kwargs.get(ATTR_TITLE) + text = '{}\n{}'.format(title, message) if title else message + params = self._get_msg_kwargs(kwargs) + for chat_id in self._get_target_chat_ids(target): + _LOGGER.debug('send_message in chat_id %s with params: %s', + chat_id, params) + self._send_msg(self.bot.sendMessage, + "Error sending message", + chat_id, text, **params) + + def edit_message(self, type_edit, chat_id=None, **kwargs): + """Edit a previously sent message.""" + chat_id = self._get_target_chat_ids(chat_id)[0] + message_id, inline_message_id = self._get_msg_ids(kwargs, chat_id) + params = self._get_msg_kwargs(kwargs) + _LOGGER.debug('edit_message %s in chat_id %s with params: %s', + message_id or inline_message_id, chat_id, params) + if type_edit == SERVICE_EDIT_MESSAGE: + message = kwargs.get(ATTR_MESSAGE) + title = kwargs.get(ATTR_TITLE) + text = '{}\n{}'.format(title, message) if title else message + _LOGGER.debug('editing message w/id %s.', + message_id or inline_message_id) + return self._send_msg(self.bot.editMessageText, + "Error editing text message", + text, chat_id=chat_id, message_id=message_id, + inline_message_id=inline_message_id, + **params) + elif type_edit == SERVICE_EDIT_CAPTION: + func_send = self.bot.editMessageCaption + params[ATTR_CAPTION] = kwargs.get(ATTR_CAPTION) + else: + func_send = self.bot.editMessageReplyMarkup + return self._send_msg(func_send, + "Error editing message attributes", + chat_id=chat_id, message_id=message_id, + inline_message_id=inline_message_id, + **params) + + def answer_callback_query(self, message, callback_query_id, + show_alert=False, **kwargs): + """Answer a callback originated with a press in an inline keyboard.""" + params = self._get_msg_kwargs(kwargs) + _LOGGER.debug('answer_callback_query w/callback_id %s: %s, alert: %s.', + callback_query_id, message, show_alert) + self._send_msg(self.bot.answerCallbackQuery, + "Error sending answer callback query", + callback_query_id, + text=message, show_alert=show_alert, **params) + + def send_file(self, is_photo=True, target=None, **kwargs): + """Send a photo or a document.""" + file = load_data( + url=kwargs.get(ATTR_URL), + file=kwargs.get(ATTR_FILE), + username=kwargs.get(ATTR_USERNAME), + password=kwargs.get(ATTR_PASSWORD), + ) + params = self._get_msg_kwargs(kwargs) + caption = kwargs.get(ATTR_CAPTION) + func_send = self.bot.sendPhoto if is_photo else self.bot.sendDocument + for chat_id in self._get_target_chat_ids(target): + _LOGGER.debug('send file %s to chat_id %s. Caption: %s.', + file, chat_id, caption) + self._send_msg(func_send, "Error sending file", + chat_id, file, caption=caption, **params) + + def send_location(self, latitude, longitude, target=None, **kwargs): + """Send a location.""" + latitude = float(latitude) + longitude = float(longitude) + params = self._get_msg_kwargs(kwargs) + for chat_id in self._get_target_chat_ids(target): + _LOGGER.debug('send location %s/%s to chat_id %s.', + latitude, longitude, chat_id) + self._send_msg(self.bot.sendLocation, + "Error sending location", + chat_id=chat_id, + latitude=latitude, longitude=longitude, **params) + + class BaseTelegramBotEntity: """The base class for the telegram bot.""" @@ -94,32 +478,56 @@ class BaseTelegramBotEntity: self.allowed_chat_ids = allowed_chat_ids self.hass = hass + def _get_message_data(self, msg_data): + if (not msg_data or + ('text' not in msg_data and 'data' not in msg_data) or + 'from' not in msg_data or + msg_data['from'].get('id') not in self.allowed_chat_ids): + # Message is not correct. + _LOGGER.error("Incoming message does not have required data (%s)", + msg_data) + return None + + return { + ATTR_USER_ID: msg_data['from']['id'], + ATTR_FROM_FIRST: msg_data['from']['first_name'], + ATTR_FROM_LAST: msg_data['from']['last_name'] + } + def process_message(self, data): """Check for basic message rules and fire an event if message is ok.""" - data = data.get('message') + if ATTR_MSG in data: + event = EVENT_TELEGRAM_COMMAND + data = data.get(ATTR_MSG) + event_data = self._get_message_data(data) + if event_data is None: + return False - if (not data or - 'from' not in data or - 'text' not in data or - data['from'].get('id') not in self.allowed_chat_ids): - _LOGGER.error("Incoming message does not have required data") - return False + if data[ATTR_TEXT][0] == '/': + pieces = data[ATTR_TEXT].split(' ') + event_data[ATTR_COMMAND] = pieces[0] + event_data[ATTR_ARGS] = pieces[1:] + else: + event_data[ATTR_TEXT] = data[ATTR_TEXT] + event = EVENT_TELEGRAM_TEXT - event = EVENT_TELEGRAM_COMMAND - event_data = { - ATTR_USER_ID: data['from']['id'], - ATTR_FROM_FIRST: data['from'].get('first_name', 'N/A'), - ATTR_FROM_LAST: data['from'].get('last_name', 'N/A')} + self.hass.bus.async_fire(event, event_data) + return True + elif ATTR_CALLBACK_QUERY in data: + event = EVENT_TELEGRAM_CALLBACK + data = data.get(ATTR_CALLBACK_QUERY) + event_data = self._get_message_data(data) + if event_data is None: + return False - if data['text'][0] == '/': - pieces = data['text'].split(' ') - event_data[ATTR_COMMAND] = pieces[0] - event_data[ATTR_ARGS] = pieces[1:] + event_data[ATTR_DATA] = data[ATTR_DATA] + event_data[ATTR_MSG] = data[ATTR_MSG] + event_data[ATTR_CHAT_INSTANCE] = data[ATTR_CHAT_INSTANCE] + event_data[ATTR_MSGID] = data[ATTR_MSGID] + self.hass.bus.async_fire(event, event_data) + return True else: - event_data[ATTR_TEXT] = data['text'] - event = EVENT_TELEGRAM_TEXT - - self.hass.bus.async_fire(event, event_data) - - return True + # Some other thing... + _LOGGER.warning('SOME OTHER THING RECEIVED --> "%s"', data) + return False diff --git a/homeassistant/components/telegram_bot/polling.py b/homeassistant/components/telegram_bot/polling.py index 8ae0a07a480..161c4e356a2 100644 --- a/homeassistant/components/telegram_bot/polling.py +++ b/homeassistant/components/telegram_bot/polling.py @@ -11,19 +11,15 @@ 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.components.telegram_bot import ( + CONF_ALLOWED_CHAT_IDS, BaseTelegramBotEntity) +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.1'] - -PLATFORM_SCHEMA = PLATFORM_SCHEMA - @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): diff --git a/homeassistant/components/telegram_bot/services.yaml b/homeassistant/components/telegram_bot/services.yaml new file mode 100644 index 00000000000..4ce932d5f41 --- /dev/null +++ b/homeassistant/components/telegram_bot/services.yaml @@ -0,0 +1,227 @@ +send_message: + description: Send a notification + + fields: + message: + description: Message body of the notification. + example: The garage door has been open for 10 minutes. + + title: + description: Optional title for your notification. Will be composed as '%title\n%message' + example: 'Your Garage Door Friend' + + target: + description: An array of pre-authorized chat_ids to send the notification to. If not present, first allowed chat_id is the default. + example: '[12345, 67890] or 12345' + + parse_mode: + description: "Parser for the message text: `html` or `markdown`." + example: 'html' + + disable_notification: + description: Sends the message silently. iOS users and Web users will not receive a notification, Android users will receive a notification with no sound. + example: true + + disable_web_page_preview: + description: Disables link previews for links in the message. + example: true + + keyboard: + description: List of rows of commands, comma-separated, to make a custom keyboard. + example: '["/command1, /command2", "/command3"]' + + inline_keyboard: + description: List of rows of commands, comma-separated, to make a custom inline keyboard with buttons with asociated callback data. + example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' + +send_photo: + description: Send a photo + + fields: + url: + description: Remote path to an image. + example: 'http://example.org/path/to/the/image.png' + + file: + description: Local path to an image. + example: '/path/to/the/image.png' + + caption: + description: The title of the image. + example: 'My image' + + username: + description: Username for a URL which require HTTP basic authentication. + example: myuser + + password: + description: Password for a URL which require HTTP basic authentication. + example: myuser_pwd + + target: + description: An array of pre-authorized chat_ids to send the document to. If not present, first allowed chat_id is the default. + example: '[12345, 67890] or 12345' + + disable_notification: + description: Sends the message silently. iOS users and Web users will not receive a notification, Android users will receive a notification with no sound. + example: true + + keyboard: + description: List of rows of commands, comma-separated, to make a custom keyboard. + example: '["/command1, /command2", "/command3"]' + + inline_keyboard: + description: List of rows of commands, comma-separated, to make a custom inline keyboard with buttons with asociated callback data. + example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' + +send_document: + description: Send a document + + fields: + url: + description: Remote path to a document. + example: 'http://example.org/path/to/the/document.odf' + + file: + description: Local path to a document. + example: '/tmp/whatever.odf' + + caption: + description: The title of the document. + example: Document Title xy + + username: + description: Username for a URL which require HTTP basic authentication. + example: myuser + + password: + description: Password for a URL which require HTTP basic authentication. + example: myuser_pwd + + target: + description: An array of pre-authorized chat_ids to send the document to. If not present, first allowed chat_id is the default. + example: '[12345, 67890] or 12345' + + disable_notification: + description: Sends the message silently. iOS users and Web users will not receive a notification, Android users will receive a notification with no sound. + example: true + + keyboard: + description: List of rows of commands, comma-separated, to make a custom keyboard. + example: '["/command1, /command2", "/command3"]' + + inline_keyboard: + description: List of rows of commands, comma-separated, to make a custom inline keyboard with buttons with asociated callback data. + example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' + +send_location: + description: Send a location + + fields: + latitude: + description: The latitude to send. + example: -15.123 + + longitude: + description: The longitude to send. + example: 38.123 + + target: + description: An array of pre-authorized chat_ids to send the location to. If not present, first allowed chat_id is the default. + example: '[12345, 67890] or 12345' + + disable_notification: + description: Sends the message silently. iOS users and Web users will not receive a notification, Android users will receive a notification with no sound. + example: true + + keyboard: + description: List of rows of commands, comma-separated, to make a custom keyboard. + example: '["/command1, /command2", "/command3"]' + + inline_keyboard: + description: List of rows of commands, comma-separated, to make a custom inline keyboard with buttons with asociated callback data. + example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' + +edit_message: + description: Edit a previusly sent message. + + fields: + message_id: + description: id of the message to edit. + example: '{{ trigger.event.data.message.message_id }}' + + chat_id: + description: The chat_id where to edit the message. + example: 12345 + + message: + description: Message body of the notification. + example: The garage door has been open for 10 minutes. + + title: + description: Optional title for your notification. Will be composed as '%title\n%message' + example: 'Your Garage Door Friend' + + parse_mode: + description: "Parser for the message text: `html` or `markdown`." + example: 'html' + + disable_web_page_preview: + description: Disables link previews for links in the message. + example: true + + inline_keyboard: + description: List of rows of commands, comma-separated, to make a custom inline keyboard with buttons with asociated callback data. + example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' + +edit_caption: + description: Edit the caption of a previusly sent message. + + fields: + message_id: + description: id of the message to edit. + example: '{{ trigger.event.data.message.message_id }}' + + chat_id: + description: The chat_id where to edit the caption. + example: 12345 + + caption: + description: Message body of the notification. + example: The garage door has been open for 10 minutes. + + inline_keyboard: + description: List of rows of commands, comma-separated, to make a custom inline keyboard with buttons with asociated callback data. + example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' + +edit_replymarkup: + description: Edit the inline keyboard of a previusly sent message. + + fields: + message_id: + description: id of the message to edit. + example: '{{ trigger.event.data.message.message_id }}' + + chat_id: + description: The chat_id where to edit the reply_markup. + example: 12345 + + inline_keyboard: + description: List of rows of commands, comma-separated, to make a custom inline keyboard with buttons with asociated callback data. + example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], ["Text button2", "/button2"]], [["Text button3", "/button3"]]]' + +answer_callback_query: + description: Respond to a callback query originated by clicking on an online keyboard button. The answer will be displayed to the user as a notification at the top of the chat screen or as an alert. + + fields: + message: + description: Unformatted text message body of the notification. + example: "OK, I'm listening" + + callback_query_id: + description: Unique id of the callback response. + example: '{{ trigger.event.data.id }}' + + show_alert: + description: Show a permanent notification. + example: true diff --git a/homeassistant/components/telegram_bot/webhooks.py b/homeassistant/components/telegram_bot/webhooks.py index d647fab490b..690340fc378 100644 --- a/homeassistant/components/telegram_bot/webhooks.py +++ b/homeassistant/components/telegram_bot/webhooks.py @@ -5,60 +5,52 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/telegram_bot.webhooks/ """ import asyncio +import datetime as dt import logging -from ipaddress import ip_network -import voluptuous as vol - -from homeassistant.const import ( - EVENT_HOMEASSISTANT_STOP, 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 +from homeassistant.components.telegram_bot import ( + CONF_ALLOWED_CHAT_IDS, CONF_TRUSTED_NETWORKS, BaseTelegramBotEntity) +from homeassistant.const import ( + CONF_API_KEY, EVENT_HOMEASSISTANT_STOP, + HTTP_BAD_REQUEST, HTTP_UNAUTHORIZED) DEPENDENCIES = ['http'] -REQUIREMENTS = ['python-telegram-bot==5.3.1'] _LOGGER = logging.getLogger(__name__) TELEGRAM_HANDLER_URL = '/api/telegram_webhooks' REMOVE_HANDLER_URL = '' -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): +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the Telegram webhooks 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) + current_status = yield from hass.async_add_job(bot.getWebhookInfo) + + # Some logging of Bot current status: + last_error_date = getattr(current_status, 'last_error_date', None) + if (last_error_date is not None) and (isinstance(last_error_date, int)): + last_error_date = dt.datetime.fromtimestamp(last_error_date) + _LOGGER.info("telegram webhook last_error_date: %s. Status: %s", + last_error_date, current_status) + else: + _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): + result = yield from hass.async_add_job(bot.setWebhook, handler_url) + if result: _LOGGER.info("Set new telegram webhook %s", handler_url) else: _LOGGER.error("Set telegram webhook failed %s", handler_url) return False - hass.bus.listen_once( + hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STOP, lambda event: bot.setWebhook(REMOVE_HANDLER_URL)) hass.http.register_view(BotPushReceiver( diff --git a/requirements_all.txt b/requirements_all.txt index 624b1f22b06..27ae45c9ddf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -674,9 +674,7 @@ python-roku==3.1.3 # homeassistant.components.sensor.synologydsm python-synology==0.1.0 -# homeassistant.components.notify.telegram -# homeassistant.components.telegram_bot.polling -# homeassistant.components.telegram_bot.webhooks +# homeassistant.components.telegram_bot python-telegram-bot==5.3.1 # homeassistant.components.sensor.twitch