-
-
Notifications
You must be signed in to change notification settings - Fork 37.8k
Fix Telegram Bot send file to multiple targets, snapshots of HA cameras, variable templating, digest auth #7771
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 6 commits
834fb02
6bd7369
fc59068
235f9cb
d944f4f
c5fa282
e86dab3
12582a4
e0ced86
2366387
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -12,21 +12,25 @@ | |
| import os | ||
|
|
||
| import requests | ||
| from requests.auth import HTTPBasicAuth, HTTPDigestAuth | ||
| 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) | ||
| CONF_PLATFORM, CONF_API_KEY, CONF_TIMEOUT, ATTR_LATITUDE, ATTR_LONGITUDE, | ||
| HTTP_DIGEST_AUTHENTICATION) | ||
| import homeassistant.helpers.config_validation as cv | ||
| from homeassistant.exceptions import TemplateError | ||
| from homeassistant.setup import async_prepare_setup_platform | ||
|
|
||
| REQUIREMENTS = ['python-telegram-bot==6.0.1'] | ||
|
|
||
| _LOGGER = logging.getLogger(__name__) | ||
|
|
||
| ATTR_ARGS = 'args' | ||
| ATTR_AUTHENTICATION = 'authentication' | ||
| ATTR_CALLBACK_QUERY = 'callback_query' | ||
| ATTR_CALLBACK_QUERY_ID = 'callback_query_id' | ||
| ATTR_CAPTION = 'caption' | ||
|
|
@@ -104,16 +108,17 @@ | |
| 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_URL): cv.template, | ||
| vol.Optional(ATTR_FILE): cv.template, | ||
| vol.Optional(ATTR_CAPTION): cv.template, | ||
| vol.Optional(ATTR_USERNAME): cv.string, | ||
| vol.Optional(ATTR_PASSWORD): cv.string, | ||
| vol.Optional(ATTR_AUTHENTICATION): 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, | ||
| vol.Required(ATTR_LONGITUDE): cv.template, | ||
| vol.Required(ATTR_LATITUDE): cv.template, | ||
| }) | ||
| SERVICE_EDIT_MESSAGE = 'edit_message' | ||
| SERVICE_SCHEMA_EDIT_MESSAGE = SERVICE_SCHEMA_SEND_MESSAGE.extend({ | ||
|
|
@@ -124,7 +129,7 @@ | |
| SERVICE_SCHEMA_EDIT_CAPTION = vol.Schema({ | ||
| vol.Required(ATTR_MESSAGEID): vol.Any(cv.positive_int, cv.string), | ||
| vol.Required(ATTR_CHAT_ID): vol.Coerce(int), | ||
| vol.Required(ATTR_CAPTION): cv.string, | ||
| vol.Required(ATTR_CAPTION): cv.template, | ||
| vol.Optional(ATTR_KEYBOARD_INLINE): cv.ensure_list, | ||
| }, extra=vol.ALLOW_EXTRA) | ||
| SERVICE_EDIT_REPLYMARKUP = 'edit_replymarkup' | ||
|
|
@@ -136,7 +141,7 @@ | |
| 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.Required(ATTR_CALLBACK_QUERY_ID): vol.Coerce(int), | ||
| vol.Optional(ATTR_SHOW_ALERT): cv.boolean, | ||
| }, extra=vol.ALLOW_EXTRA) | ||
|
|
||
|
|
@@ -152,24 +157,41 @@ | |
| } | ||
|
|
||
|
|
||
| def load_data(url=None, file=None, username=None, password=None): | ||
| def load_data(url=None, file=None, username=None, password=None, | ||
| authentication=None, num_retries=5): | ||
| """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) | ||
|
|
||
| retry_num = 0 | ||
| while retry_num < num_retries: | ||
| params = {"timeout": 15} | ||
| if username is not None and password is not None: | ||
| if authentication == HTTP_DIGEST_AUTHENTICATION: | ||
| params["auth"] = HTTPDigestAuth(username, password) | ||
| else: | ||
| params["auth"] = HTTPBasicAuth(username, password) | ||
| req = requests.get(url, **params) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Now there's no request without authentication. Is that intended?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. authentication params are included only when are present: params = {"timeout": 15}
if username is not None and password is not None:
if authentication == HTTP_DIGEST_AUTHENTICATION:
params["auth"] = HTTPDigestAuth(username, password)
else:
params["auth"] = HTTPBasicAuth(username, password)What do you mean?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You're correct. I missed that point, sorry. |
||
| if not req.ok: | ||
| _LOGGER.warning("Status code %s (retry #%s) loading %s.", | ||
| req.status_code, retry_num + 1, url) | ||
| else: | ||
| data = io.BytesIO(req.content) | ||
| if data.read(): | ||
| data.seek(0) | ||
| return data | ||
| _LOGGER.warning("Empty data (retry #%s) in %s).", | ||
| retry_num + 1, url) | ||
| retry_num += 1 | ||
| _LOGGER.warning("Can't load photo in %s after %s retries.", | ||
| url, retry_num) | ||
| 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: | ||
| except (OSError, TypeError) as error: | ||
| _LOGGER.error("Can't load photo into ByteIO: %s", error) | ||
|
|
||
| return None | ||
|
|
@@ -219,13 +241,28 @@ def async_send_telegram_message(service): | |
| def _render_template_attr(data, attribute): | ||
| attribute_templ = data.get(attribute) | ||
| if attribute_templ: | ||
| attribute_templ.hass = hass | ||
| data[attribute] = attribute_templ.async_render() | ||
| if any([isinstance(attribute_templ, vtype) | ||
| for vtype in [float, int, str]]): | ||
| data[attribute] = attribute_templ | ||
| else: | ||
| attribute_templ.hass = hass | ||
| try: | ||
| data[attribute] = attribute_templ.async_render() | ||
| except TemplateError as exc: | ||
| _LOGGER.error( | ||
| "TemplateError in %s: %s -> %s", | ||
| attribute, attribute_templ.template, exc) | ||
| data[attribute] = attribute_templ.template | ||
|
|
||
| msgtype = service.service | ||
| kwargs = dict(service.data) | ||
| _render_template_attr(kwargs, ATTR_MESSAGE) | ||
| _render_template_attr(kwargs, ATTR_TITLE) | ||
| _render_template_attr(kwargs, ATTR_URL) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use a |
||
| _render_template_attr(kwargs, ATTR_FILE) | ||
| _render_template_attr(kwargs, ATTR_CAPTION) | ||
| _render_template_attr(kwargs, ATTR_LONGITUDE) | ||
| _render_template_attr(kwargs, ATTR_LATITUDE) | ||
| _LOGGER.debug("NEW telegram_message %s: %s", msgtype, kwargs) | ||
|
|
||
| if msgtype == SERVICE_SEND_MESSAGE: | ||
|
|
@@ -296,48 +333,58 @@ def _get_msg_ids(self, msg_data, chat_id): | |
| return message_id, inline_message_id | ||
|
|
||
| def _get_target_chat_ids(self, target): | ||
| """Validate chat_id targets or return default target (fist defined). | ||
| """Validate chat_id targets or return default target (first). | ||
|
|
||
| :param target: optional list of strings or ints (['12234'] or [12234]) | ||
| :param target: optional list of integers ([12234, -12345]) | ||
| :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", | ||
| try: | ||
| chat_ids = [int(t) for t in target | ||
| if int(t) in self.allowed_chat_ids] | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Chat ids in target have already been validated and coerced to integers.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's true, this is old, I'm going to simplify it |
||
| if len(chat_ids) > 0: | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. if chat_ids: |
||
| return chat_ids | ||
| _LOGGER.warning("Unallowed targets: %s", target) | ||
| except (ValueError, TypeError): | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When could this happen? Both target and allowed chat ids have been validated as list of integers before this. |
||
| _LOGGER.warning("Bad target data: %s, using default: %s", | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Shouldn't the warning about default be included in the above warning instead? The warning here should never happen. |
||
| 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), ...] | ||
| def _make_row_inline_keyboard(row_keyboard): | ||
| """Make a list of InlineKeyboardButtons. | ||
|
|
||
| It can accept: | ||
| - a list of tuples like: | ||
| `[(text_b1, data_callback_b1), | ||
| (text_b2, data_callback_b2), ...] | ||
| - a string like: `/cmd1, /cmd2, /cmd3` | ||
| - or a string like: `text_b1:/cmd1, text_b2:/cmd2` | ||
| """ | ||
| from telegram import InlineKeyboardButton | ||
| buttons = [] | ||
| if isinstance(row_keyboard, str): | ||
| return [InlineKeyboardButton( | ||
| key.strip()[1:].upper(), callback_data=key) | ||
| for key in row_keyboard.split(",")] | ||
| for key in row_keyboard.split(","): | ||
| if ':/' in key: | ||
| # commands like: 'Label:/cmd' become ('Label', '/cmd') | ||
| label = key.split(':/')[0] | ||
| command = key[len(label) + 1:] | ||
| buttons.append( | ||
| InlineKeyboardButton(label, callback_data=command)) | ||
| else: | ||
| # commands like: '/cmd' become ('CMD', '/cmd') | ||
| label = key.strip()[1:].upper() | ||
| buttons.append( | ||
| InlineKeyboardButton(label, callback_data=key)) | ||
| elif isinstance(row_keyboard, list): | ||
| return [InlineKeyboardButton( | ||
| text_btn, callback_data=data_btn) | ||
| for text_btn, data_btn in row_keyboard] | ||
| for entry in row_keyboard: | ||
| text_btn, data_btn = entry | ||
| buttons.append( | ||
| InlineKeyboardButton(text_btn, callback_data=data_btn)) | ||
| else: | ||
| raise ValueError(str(row_keyboard)) | ||
| return buttons | ||
|
|
||
| # Defaults | ||
| params = { | ||
|
|
@@ -372,7 +419,7 @@ def _make_row_of_kb(row_keyboard): | |
| 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]) | ||
| [_make_row_inline_keyboard(row) for row in keys]) | ||
| return params | ||
|
|
||
| def _send_msg(self, func_send, msg_error, *args_rep, **kwargs_rep): | ||
|
|
@@ -446,20 +493,26 @@ def answer_callback_query(self, message, callback_query_id, | |
|
|
||
| def send_file(self, is_photo=True, target=None, **kwargs): | ||
| """Send a photo or a document.""" | ||
| params = self._get_msg_kwargs(kwargs) | ||
| caption = kwargs.get(ATTR_CAPTION) | ||
| func_send = self.bot.sendPhoto if is_photo else self.bot.sendDocument | ||
| file = load_data( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Pick another variable name than file. It's reserved in python.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This one is still here. 😉
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. fixed! 😓 |
||
| url=kwargs.get(ATTR_URL), | ||
| file=kwargs.get(ATTR_FILE), | ||
| username=kwargs.get(ATTR_USERNAME), | ||
| password=kwargs.get(ATTR_PASSWORD), | ||
| authentication=kwargs.get(ATTR_AUTHENTICATION), | ||
| ) | ||
| 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) | ||
| if file: | ||
| for chat_id in self._get_target_chat_ids(target): | ||
| _LOGGER.debug("send file to chat_id %s. Caption: %s.", | ||
| chat_id, caption) | ||
| self._send_msg(func_send, "Error sending file", | ||
| chat_id, io.BytesIO(file.read()), | ||
| caption=caption, **params) | ||
| file.seek(0) | ||
| else: | ||
| _LOGGER.error("Can't send file with kwargs: %s", kwargs) | ||
|
|
||
| def send_location(self, latitude, longitude, target=None, **kwargs): | ||
| """Send a location.""" | ||
|
|
@@ -495,18 +548,23 @@ def _get_message_data(self, msg_data): | |
| _LOGGER.error("Incoming message does not have required data (%s)", | ||
| msg_data) | ||
| return False, None | ||
| if msg_data['from'].get('id') not in self.allowed_chat_ids \ | ||
| or msg_data['chat'].get('id') not in self.allowed_chat_ids: | ||
|
|
||
| if (msg_data['from'].get('id') not in self.allowed_chat_ids or | ||
| ('chat' in msg_data and | ||
| msg_data['chat'].get('id') not in self.allowed_chat_ids)): | ||
| # Origin is not allowed. | ||
| _LOGGER.error("Incoming message is not allowed (%s)", msg_data) | ||
| return True, None | ||
|
|
||
| return True, { | ||
| data = { | ||
| ATTR_USER_ID: msg_data['from']['id'], | ||
| ATTR_CHAT_ID: msg_data['chat']['id'], | ||
| ATTR_FROM_FIRST: msg_data['from']['first_name'], | ||
| ATTR_FROM_LAST: msg_data['from']['last_name'] | ||
| } | ||
| if 'chat' in msg_data: | ||
| data[ATTR_CHAT_ID] = msg_data['chat']['id'] | ||
|
|
||
| return True, data | ||
|
|
||
| def process_message(self, data): | ||
| """Check for basic message rules and fire an event if message is ok.""" | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pick another parameter name than
file. It's reserved in python.