1
0
mirror of https://github.com/home-assistant/core.git synced 2026-04-17 07:34:07 +01:00
Files
core/homeassistant/components/telegram_bot/bot.py
2026-03-25 16:18:25 +01:00

1228 lines
44 KiB
Python

"""Telegram bot classes and utilities."""
from abc import abstractmethod
import asyncio
from collections.abc import Awaitable, Callable, Iterable, Sequence
import io
import logging
import os
from pathlib import Path
from types import MappingProxyType
from typing import Any, cast
import httpx
from telegram import (
Bot,
CallbackQuery,
File,
InlineKeyboardButton,
InlineKeyboardMarkup,
InputMedia,
InputMediaAnimation,
InputMediaAudio,
InputMediaDocument,
InputMediaPhoto,
InputMediaVideo,
InputPollOption,
Message,
PhotoSize,
ReplyKeyboardMarkup,
ReplyKeyboardRemove,
Update,
User,
)
from telegram.constants import InputMediaType, ParseMode
from telegram.error import TelegramError
from telegram.ext import CallbackContext, filters
from telegram.request import HTTPXRequest
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_COMMAND,
CONF_API_KEY,
HTTP_BASIC_AUTHENTICATION,
HTTP_BEARER_AUTHENTICATION,
HTTP_DIGEST_AUTHENTICATION,
)
from homeassistant.core import Context, HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.util.json import JsonValueType
from .const import (
ATTR_ARGS,
ATTR_AUTHENTICATION,
ATTR_CAPTION,
ATTR_CHAT_ID,
ATTR_CHAT_INSTANCE,
ATTR_DATA,
ATTR_DATE,
ATTR_DISABLE_NOTIF,
ATTR_DISABLE_WEB_PREV,
ATTR_FILE,
ATTR_FILE_ID,
ATTR_FILE_MIME_TYPE,
ATTR_FILE_NAME,
ATTR_FILE_PATH,
ATTR_FILE_SIZE,
ATTR_FROM_FIRST,
ATTR_FROM_LAST,
ATTR_INLINE_MESSAGE_ID,
ATTR_KEYBOARD,
ATTR_KEYBOARD_INLINE,
ATTR_MEDIA,
ATTR_MEDIA_TYPE,
ATTR_MESSAGE,
ATTR_MESSAGE_ID,
ATTR_MESSAGE_TAG,
ATTR_MESSAGE_THREAD_ID,
ATTR_MSG,
ATTR_MSGID,
ATTR_ONE_TIME_KEYBOARD,
ATTR_OPEN_PERIOD,
ATTR_PARSER,
ATTR_PASSWORD,
ATTR_PROTECT_CONTENT,
ATTR_REPLY_TO_MSGID,
ATTR_REPLYMARKUP,
ATTR_RESIZE_KEYBOARD,
ATTR_STICKER_ID,
ATTR_TEXT,
ATTR_TIMEOUT,
ATTR_TITLE,
ATTR_URL,
ATTR_USER_ID,
ATTR_USERNAME,
ATTR_VERIFY_SSL,
CONF_API_ENDPOINT,
CONF_CHAT_ID,
CONF_PROXY_URL,
DEFAULT_TIMEOUT_SECONDS,
DOMAIN,
EVENT_TELEGRAM_ATTACHMENT,
EVENT_TELEGRAM_CALLBACK,
EVENT_TELEGRAM_COMMAND,
EVENT_TELEGRAM_SENT,
EVENT_TELEGRAM_TEXT,
PARSER_HTML,
PARSER_MD,
PARSER_MD2,
PARSER_PLAIN_TEXT,
SERVICE_EDIT_CAPTION,
SERVICE_EDIT_MESSAGE,
SERVICE_SEND_DOCUMENT,
SERVICE_SEND_PHOTO,
SERVICE_SEND_STICKER,
SERVICE_SEND_VIDEO,
SERVICE_SEND_VOICE,
)
from .helpers import signal
_FILE_TYPES = ("animation", "document", "photo", "sticker", "video", "voice")
_LOGGER = logging.getLogger(__name__)
type TelegramBotConfigEntry = ConfigEntry[TelegramNotificationService]
def _get_bot_info(bot: Bot, config_entry: ConfigEntry) -> dict[str, Any]:
return {
"config_entry_id": config_entry.entry_id,
"id": bot.id,
"first_name": bot.first_name,
"last_name": bot.last_name,
"username": bot.username,
}
class BaseTelegramBot:
"""The base class for the telegram bot."""
def __init__(
self, hass: HomeAssistant, config: TelegramBotConfigEntry, bot: Bot
) -> None:
"""Initialize the bot base class."""
self.hass = hass
self.config = config
self.most_recent_chat_id: int | None = None
self._bot = bot
@abstractmethod
async def shutdown(self) -> None:
"""Shutdown the bot application."""
async def handle_update(self, update: Update, context: CallbackContext) -> bool:
"""Handle updates from bot application set up by the respective platform."""
_LOGGER.debug("Handling update %s", update)
# establish event type: text, command or callback_query
if update.callback_query:
# NOTE: Check for callback query first since effective message will be populated with the message
# in .callback_query (python-telegram-bot docs are wrong)
event_type, event_data = self._get_callback_query_event_data(
update.callback_query
)
elif update.effective_message:
event_type, event_data = self._get_message_event_data(
update.effective_message
)
else:
_LOGGER.warning("Unhandled update: %s", update)
return True
self.most_recent_chat_id = event_data[ATTR_CHAT_ID]
if not self.authorize_update(update):
return False
event_data["bot"] = _get_bot_info(self._bot, self.config)
event_context = Context()
_LOGGER.debug("Firing event %s: %s", event_type, event_data)
self.hass.bus.async_fire(event_type, event_data, context=event_context)
async_dispatcher_send(self.hass, signal(self._bot), event_type, event_data)
return True
@staticmethod
def _get_command_event_data(command_text: str | None) -> dict[str, str | list]:
if not command_text or not command_text.startswith("/"):
return {}
command_parts = command_text.split()
command = command_parts[0]
args = command_parts[1:]
return {ATTR_COMMAND: command, ATTR_ARGS: args}
def _get_message_event_data(self, message: Message) -> tuple[str, dict[str, Any]]:
event_data: dict[str, Any] = {
ATTR_MSGID: message.message_id,
ATTR_CHAT_ID: message.chat.id,
ATTR_DATE: message.date,
ATTR_MESSAGE_THREAD_ID: message.message_thread_id,
}
if filters.COMMAND.filter(message):
# This is a command message - set event type to command and split data into command and args
event_type = EVENT_TELEGRAM_COMMAND
event_data.update(self._get_command_event_data(message.text))
elif filters.ATTACHMENT.filter(message):
event_type = EVENT_TELEGRAM_ATTACHMENT
event_data[ATTR_TEXT] = message.caption
event_data.update(self._get_file_id_event_data(message))
else:
event_type = EVENT_TELEGRAM_TEXT
event_data[ATTR_TEXT] = message.text
if message.from_user:
event_data.update(self._get_user_event_data(message.from_user))
return event_type, event_data
def _get_file_id_event_data(self, message: Message) -> dict[str, Any]:
"""Extract file_id from a message attachment, if any."""
if filters.PHOTO.filter(message):
photos = cast(Sequence[PhotoSize], message.effective_attachment)
return {
ATTR_FILE_ID: photos[-1].file_id,
ATTR_FILE_MIME_TYPE: "image/jpeg", # telegram always uses jpeg for photos
ATTR_FILE_SIZE: photos[-1].file_size,
}
return {
k: getattr(message.effective_attachment, v)
for k, v in (
(ATTR_FILE_ID, "file_id"),
(ATTR_FILE_NAME, "file_name"),
(ATTR_FILE_MIME_TYPE, "mime_type"),
(ATTR_FILE_SIZE, "file_size"),
)
if hasattr(message.effective_attachment, v)
}
def _get_user_event_data(self, user: User) -> dict[str, Any]:
return {
ATTR_USER_ID: user.id,
ATTR_FROM_FIRST: user.first_name,
ATTR_FROM_LAST: user.last_name,
}
def _get_callback_query_event_data(
self, callback_query: CallbackQuery
) -> tuple[str, dict[str, Any]]:
event_type = EVENT_TELEGRAM_CALLBACK
event_data: dict[str, Any] = {
ATTR_MSGID: callback_query.id,
ATTR_CHAT_INSTANCE: callback_query.chat_instance,
ATTR_DATA: callback_query.data,
ATTR_MSG: None,
ATTR_CHAT_ID: None,
}
if callback_query.message:
event_data[ATTR_MSG] = callback_query.message.to_dict()
event_data[ATTR_CHAT_ID] = callback_query.message.chat.id
if callback_query.from_user:
event_data.update(self._get_user_event_data(callback_query.from_user))
# Split data into command and args if possible
event_data.update(self._get_command_event_data(callback_query.data))
return event_type, event_data
def authorize_update(self, update: Update) -> bool:
"""Make sure either user or chat is in allowed_chat_ids."""
from_user = update.effective_user.id if update.effective_user else None
from_chat = update.effective_chat.id if update.effective_chat else None
allowed_chat_ids: list[int] = [
subentry.data[CONF_CHAT_ID] for subentry in self.config.subentries.values()
]
if from_user in allowed_chat_ids or from_chat in allowed_chat_ids:
return True
_LOGGER.error(
(
"Unauthorized update - neither user id %s nor chat id %s is in allowed"
" chats: %s"
),
from_user,
from_chat,
allowed_chat_ids,
)
return False
class TelegramNotificationService:
"""Implement the notification services for the Telegram Bot domain."""
def __init__(
self,
hass: HomeAssistant,
app: BaseTelegramBot | None,
bot: Bot,
config: TelegramBotConfigEntry,
parser: str,
) -> None:
"""Initialize the service."""
self.app = app
self.config = config
self._parsers: dict[str, str | None] = {
PARSER_HTML: ParseMode.HTML,
PARSER_MD: ParseMode.MARKDOWN,
PARSER_MD2: ParseMode.MARKDOWN_V2,
PARSER_PLAIN_TEXT: None,
}
self.parse_mode = self._parsers[parser]
self.bot = bot
self.hass = hass
self._last_message_id: dict[int, int] = {}
def _get_msg_ids(
self, msg_data: dict[str, Any], chat_id: int
) -> tuple[Any | None, int | None]:
"""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 message last sent in the chat_id.
"""
message_id: Any | None = None
inline_message_id: int | None = None
if ATTR_MESSAGE_ID in msg_data:
message_id = msg_data[ATTR_MESSAGE_ID]
if (
isinstance(message_id, str)
and (message_id == "last")
and (chat_id in self._last_message_id)
):
message_id = self._last_message_id[chat_id]
else:
inline_message_id = msg_data[ATTR_INLINE_MESSAGE_ID]
return message_id, inline_message_id
def _get_msg_kwargs(self, data: dict[str, Any]) -> dict[str, Any]:
"""Get parameters in message data kwargs."""
def _make_row_inline_keyboard(row_keyboard: Any) -> list[InlineKeyboardButton]:
"""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`
- also supports urls instead of callback commands
"""
buttons = []
if isinstance(row_keyboard, str):
for key in row_keyboard.split(","):
if ":/" in key:
# check if command or URL
if "https://" in key:
label = key.split(":")[0]
url = key[len(label) + 1 :]
buttons.append(InlineKeyboardButton(label, url=url))
else:
# 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):
for entry in row_keyboard:
text_btn, data_btn = entry
if data_btn.startswith("https://"):
buttons.append(InlineKeyboardButton(text_btn, url=data_btn))
else:
buttons.append(
InlineKeyboardButton(text_btn, callback_data=data_btn)
)
else:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_inline_keyboard",
)
return buttons
# Defaults
params: dict[str, Any] = {
ATTR_PARSER: self.parse_mode,
ATTR_DISABLE_NOTIF: False,
ATTR_DISABLE_WEB_PREV: None,
ATTR_REPLY_TO_MSGID: None,
ATTR_REPLYMARKUP: None,
ATTR_TIMEOUT: None,
ATTR_MESSAGE_TAG: None,
ATTR_MESSAGE_THREAD_ID: None,
}
if data is not None:
if ATTR_PARSER in data:
params[ATTR_PARSER] = data[ATTR_PARSER]
if ATTR_TIMEOUT in data:
params[ATTR_TIMEOUT] = data[ATTR_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]
if ATTR_MESSAGE_TAG in data:
params[ATTR_MESSAGE_TAG] = data[ATTR_MESSAGE_TAG]
if ATTR_MESSAGE_THREAD_ID in data:
params[ATTR_MESSAGE_THREAD_ID] = data[ATTR_MESSAGE_THREAD_ID]
# Keyboards:
if ATTR_KEYBOARD in data:
keys = data[ATTR_KEYBOARD]
keys = keys if isinstance(keys, list) else [keys]
if keys:
params[ATTR_REPLYMARKUP] = ReplyKeyboardMarkup(
[[key.strip() for key in row.split(",")] for row in keys],
resize_keyboard=data.get(ATTR_RESIZE_KEYBOARD, False),
one_time_keyboard=data.get(ATTR_ONE_TIME_KEYBOARD, False),
)
else:
params[ATTR_REPLYMARKUP] = ReplyKeyboardRemove(True)
elif ATTR_KEYBOARD_INLINE in data:
keys = data.get(ATTR_KEYBOARD_INLINE)
keys = keys if isinstance(keys, list) else [keys]
params[ATTR_REPLYMARKUP] = InlineKeyboardMarkup(
[_make_row_inline_keyboard(row) for row in keys]
)
if params[ATTR_PARSER] == PARSER_PLAIN_TEXT:
params[ATTR_PARSER] = None
return params
async def _send_msg_formatted(
self,
func_send: Callable[..., Awaitable[Message | tuple[Message, ...]]],
message_tag: str | None,
*args_msg: Any,
context: Context | None = None,
**kwargs_msg: Any,
) -> dict[str, JsonValueType]:
"""Sends a message and formats the response.
:return: dict with chat_id keys and message_id values for successful sends
"""
chat_id: int = kwargs_msg.pop(ATTR_CHAT_ID)
_LOGGER.debug("%s to chat ID %s", func_send.__name__, chat_id)
response: Message | tuple[Message, ...] = await self._send_msg(
func_send,
message_tag,
chat_id,
*args_msg,
context=context,
**kwargs_msg,
)
if isinstance(response, Iterable):
return {str(chat_id): [message.id for message in response]}
return {str(chat_id): response.id}
async def _send_msg(
self,
func_send: Callable[..., Awaitable[Any]],
message_tag: str | None,
*args_msg: Any,
context: Context | None = None,
**kwargs_msg: Any,
) -> Any:
"""Send one message."""
out = await func_send(*args_msg, **kwargs_msg)
message = out
if isinstance(message, Iterable):
message = out[-1]
if isinstance(message, Message):
chat_id = message.chat_id
message_id = message.message_id
self._last_message_id[chat_id] = message_id
_LOGGER.debug(
"Last message ID: %s (from chat_id %s)",
self._last_message_id,
chat_id,
)
event_data: dict[str, Any] = {
ATTR_CHAT_ID: chat_id,
ATTR_MESSAGE_ID: message_id,
}
if message_tag is not None:
event_data[ATTR_MESSAGE_TAG] = message_tag
if kwargs_msg.get(ATTR_MESSAGE_THREAD_ID) is not None:
event_data[ATTR_MESSAGE_THREAD_ID] = kwargs_msg[ATTR_MESSAGE_THREAD_ID]
event_data["bot"] = _get_bot_info(self.bot, self.config)
self.hass.bus.async_fire(EVENT_TELEGRAM_SENT, event_data, context=context)
async_dispatcher_send(
self.hass, signal(self.bot), EVENT_TELEGRAM_SENT, event_data
)
return out
async def send_message(
self,
message: str,
chat_id: int,
context: Context | None = None,
**kwargs: dict[str, Any],
) -> dict[str, JsonValueType]:
"""Send a message to one or multiple pre-allowed chat IDs."""
title = kwargs.get(ATTR_TITLE)
text = f"{title}\n{message}" if title else message
params = self._get_msg_kwargs(kwargs)
return await self._send_msg_formatted(
self.bot.send_message,
params[ATTR_MESSAGE_TAG],
text,
chat_id=chat_id,
parse_mode=params[ATTR_PARSER],
disable_web_page_preview=params[ATTR_DISABLE_WEB_PREV],
disable_notification=params[ATTR_DISABLE_NOTIF],
reply_to_message_id=params[ATTR_REPLY_TO_MSGID],
reply_markup=params[ATTR_REPLYMARKUP],
read_timeout=params[ATTR_TIMEOUT],
message_thread_id=params[ATTR_MESSAGE_THREAD_ID],
context=context,
)
async def send_media_group(
self,
chat_id: int,
context: Context | None = None,
**kwargs: Any,
) -> dict[str, JsonValueType]:
"""Send media group to a chat ID.
:returns: a dict mapping each chat_id to a list of message_ids for the sent media group.
"""
params = self._get_msg_kwargs(kwargs)
media: list[
InputMediaAudio | InputMediaDocument | InputMediaPhoto | InputMediaVideo
] = []
input_media: list[dict[str, Any]] = kwargs[ATTR_MEDIA]
for entry in input_media:
file_content = await load_data(
self.hass,
url=entry.get(ATTR_URL),
filepath=entry.get(ATTR_FILE),
username=entry.get(ATTR_USERNAME, ""),
password=entry.get(ATTR_PASSWORD, ""),
authentication=entry.get(ATTR_AUTHENTICATION),
verify_ssl=entry[ATTR_VERIFY_SSL],
)
_LOGGER.debug("downloaded: %s", entry[ATTR_URL])
caption: str | None = entry.get(ATTR_CAPTION)
if entry[ATTR_MEDIA_TYPE] == InputMediaType.AUDIO:
media.append(InputMediaAudio(file_content, caption=caption))
elif entry[ATTR_MEDIA_TYPE] == InputMediaType.DOCUMENT:
media.append(InputMediaDocument(file_content, caption=caption))
elif entry[ATTR_MEDIA_TYPE] == InputMediaType.PHOTO:
media.append(InputMediaPhoto(file_content, caption=caption))
else:
media.append(InputMediaVideo(file_content, caption=caption))
return await self._send_msg_formatted(
self.bot.send_media_group,
params[ATTR_MESSAGE_TAG],
chat_id=chat_id,
media=media,
disable_notification=params[ATTR_DISABLE_NOTIF],
protect_content=kwargs.get(ATTR_PROTECT_CONTENT, False),
message_thread_id=params[ATTR_MESSAGE_THREAD_ID],
reply_to_message_id=params[ATTR_REPLY_TO_MSGID],
parse_mode=params[ATTR_PARSER],
context=context,
)
async def delete_message(
self,
chat_id: int,
context: Context | None = None,
**kwargs: dict[str, Any],
) -> bool:
"""Delete a previously sent message."""
message_id, _ = self._get_msg_ids(kwargs, chat_id)
_LOGGER.debug("Delete message %s in chat ID %s", message_id, chat_id)
deleted: bool = await self._send_msg(
self.bot.delete_message,
None,
chat_id,
message_id,
context=context,
)
# reduce message_id anyway:
if chat_id in self._last_message_id:
# change last msg_id for deque(n_msgs)?
self._last_message_id[chat_id] -= 1
return deleted
async def edit_message_media(
self,
media_type: str,
chat_id: int,
context: Context | None = None,
**kwargs: Any,
) -> Any:
"Edit message media of a previously sent message."
message_id, inline_message_id = self._get_msg_ids(kwargs, chat_id)
params = self._get_msg_kwargs(kwargs)
_LOGGER.debug(
"Edit message media %s in chat ID %s with params: %s",
message_id or inline_message_id,
chat_id,
params,
)
file_content = await load_data(
self.hass,
url=kwargs.get(ATTR_URL),
filepath=kwargs.get(ATTR_FILE),
username=kwargs.get(ATTR_USERNAME, ""),
password=kwargs.get(ATTR_PASSWORD, ""),
authentication=kwargs.get(ATTR_AUTHENTICATION),
verify_ssl=kwargs.get(ATTR_VERIFY_SSL, False),
)
media: InputMedia
if media_type == InputMediaType.ANIMATION:
media = InputMediaAnimation(
file_content,
caption=kwargs.get(ATTR_CAPTION),
parse_mode=params[ATTR_PARSER],
)
elif media_type == InputMediaType.AUDIO:
media = InputMediaAudio(
file_content,
caption=kwargs.get(ATTR_CAPTION),
parse_mode=params[ATTR_PARSER],
)
elif media_type == InputMediaType.DOCUMENT:
media = InputMediaDocument(
file_content,
caption=kwargs.get(ATTR_CAPTION),
parse_mode=params[ATTR_PARSER],
)
elif media_type == InputMediaType.PHOTO:
media = InputMediaPhoto(
file_content,
caption=kwargs.get(ATTR_CAPTION),
parse_mode=params[ATTR_PARSER],
)
else:
media = InputMediaVideo(
file_content,
caption=kwargs.get(ATTR_CAPTION),
parse_mode=params[ATTR_PARSER],
)
return await self._send_msg(
self.bot.edit_message_media,
params[ATTR_MESSAGE_TAG],
media=media,
chat_id=chat_id,
message_id=message_id,
inline_message_id=inline_message_id,
reply_markup=params[ATTR_REPLYMARKUP],
read_timeout=params[ATTR_TIMEOUT],
context=context,
)
async def edit_message(
self,
type_edit: str,
chat_id: int,
context: Context | None = None,
**kwargs: dict[str, Any],
) -> Any:
"""Edit a previously sent message."""
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 = f"{title}\n{message}" if title else message
_LOGGER.debug("Editing message with ID %s", message_id or inline_message_id)
return await self._send_msg(
self.bot.edit_message_text,
params[ATTR_MESSAGE_TAG],
text,
chat_id=chat_id,
message_id=message_id,
inline_message_id=inline_message_id,
parse_mode=params[ATTR_PARSER],
disable_web_page_preview=params[ATTR_DISABLE_WEB_PREV],
reply_markup=params[ATTR_REPLYMARKUP],
read_timeout=params[ATTR_TIMEOUT],
context=context,
)
if type_edit == SERVICE_EDIT_CAPTION:
return await self._send_msg(
self.bot.edit_message_caption,
params[ATTR_MESSAGE_TAG],
chat_id=chat_id,
message_id=message_id,
inline_message_id=inline_message_id,
caption=kwargs.get(ATTR_CAPTION),
reply_markup=params[ATTR_REPLYMARKUP],
read_timeout=params[ATTR_TIMEOUT],
parse_mode=params[ATTR_PARSER],
context=context,
)
return await self._send_msg(
self.bot.edit_message_reply_markup,
params[ATTR_MESSAGE_TAG],
chat_id=chat_id,
message_id=message_id,
inline_message_id=inline_message_id,
reply_markup=params[ATTR_REPLYMARKUP],
read_timeout=params[ATTR_TIMEOUT],
context=context,
)
async def answer_callback_query(
self,
message: str | None,
callback_query_id: str,
show_alert: bool = False,
context: Context | None = None,
**kwargs: dict[str, Any],
) -> None:
"""Answer a callback originated with a press in an inline keyboard."""
params = self._get_msg_kwargs(kwargs)
_LOGGER.debug(
"Answer callback query with callback ID %s: %s, alert: %s",
callback_query_id,
message,
show_alert,
)
await self._send_msg(
self.bot.answer_callback_query,
params[ATTR_MESSAGE_TAG],
callback_query_id,
text=message,
show_alert=show_alert,
read_timeout=params[ATTR_TIMEOUT],
context=context,
)
async def send_chat_action(
self,
chat_id: int,
chat_action: str = "",
context: Context | None = None,
**kwargs: Any,
) -> dict[str, JsonValueType]:
"""Send a chat action to pre-allowed chat IDs."""
result: dict[str, JsonValueType] = {}
_LOGGER.debug("Send action %s in chat ID %s", chat_action, chat_id)
is_successful = await self._send_msg(
self.bot.send_chat_action,
None,
chat_id=chat_id,
action=chat_action,
message_thread_id=kwargs.get(ATTR_MESSAGE_THREAD_ID),
context=context,
)
result[str(chat_id)] = is_successful
return result
async def send_file(
self,
file_type: str,
context: Context | None = None,
**kwargs: Any,
) -> dict[str, JsonValueType]:
"""Send a photo, sticker, video, or document."""
params = self._get_msg_kwargs(kwargs)
file_content = await load_data(
self.hass,
url=kwargs.get(ATTR_URL),
filepath=kwargs.get(ATTR_FILE),
username=kwargs.get(ATTR_USERNAME, ""),
password=kwargs.get(ATTR_PASSWORD, ""),
authentication=kwargs.get(ATTR_AUTHENTICATION),
verify_ssl=kwargs.get(ATTR_VERIFY_SSL, False),
)
if file_type == SERVICE_SEND_PHOTO:
return await self._send_msg_formatted(
self.bot.send_photo,
params[ATTR_MESSAGE_TAG],
chat_id=kwargs[ATTR_CHAT_ID],
photo=file_content,
caption=kwargs.get(ATTR_CAPTION),
disable_notification=params[ATTR_DISABLE_NOTIF],
reply_to_message_id=params[ATTR_REPLY_TO_MSGID],
reply_markup=params[ATTR_REPLYMARKUP],
read_timeout=params[ATTR_TIMEOUT],
parse_mode=params[ATTR_PARSER],
message_thread_id=params[ATTR_MESSAGE_THREAD_ID],
context=context,
)
if file_type == SERVICE_SEND_STICKER:
return await self._send_msg_formatted(
self.bot.send_sticker,
params[ATTR_MESSAGE_TAG],
chat_id=kwargs[ATTR_CHAT_ID],
sticker=file_content,
disable_notification=params[ATTR_DISABLE_NOTIF],
reply_to_message_id=params[ATTR_REPLY_TO_MSGID],
reply_markup=params[ATTR_REPLYMARKUP],
read_timeout=params[ATTR_TIMEOUT],
message_thread_id=params[ATTR_MESSAGE_THREAD_ID],
context=context,
)
if file_type == SERVICE_SEND_VIDEO:
return await self._send_msg_formatted(
self.bot.send_video,
params[ATTR_MESSAGE_TAG],
chat_id=kwargs[ATTR_CHAT_ID],
video=file_content,
caption=kwargs.get(ATTR_CAPTION),
disable_notification=params[ATTR_DISABLE_NOTIF],
reply_to_message_id=params[ATTR_REPLY_TO_MSGID],
reply_markup=params[ATTR_REPLYMARKUP],
read_timeout=params[ATTR_TIMEOUT],
parse_mode=params[ATTR_PARSER],
message_thread_id=params[ATTR_MESSAGE_THREAD_ID],
context=context,
)
if file_type == SERVICE_SEND_DOCUMENT:
return await self._send_msg_formatted(
self.bot.send_document,
params[ATTR_MESSAGE_TAG],
chat_id=kwargs[ATTR_CHAT_ID],
document=file_content,
caption=kwargs.get(ATTR_CAPTION),
disable_notification=params[ATTR_DISABLE_NOTIF],
reply_to_message_id=params[ATTR_REPLY_TO_MSGID],
reply_markup=params[ATTR_REPLYMARKUP],
read_timeout=params[ATTR_TIMEOUT],
parse_mode=params[ATTR_PARSER],
message_thread_id=params[ATTR_MESSAGE_THREAD_ID],
context=context,
)
if file_type == SERVICE_SEND_VOICE:
return await self._send_msg_formatted(
self.bot.send_voice,
params[ATTR_MESSAGE_TAG],
chat_id=kwargs[ATTR_CHAT_ID],
voice=file_content,
caption=kwargs.get(ATTR_CAPTION),
disable_notification=params[ATTR_DISABLE_NOTIF],
reply_to_message_id=params[ATTR_REPLY_TO_MSGID],
reply_markup=params[ATTR_REPLYMARKUP],
read_timeout=params[ATTR_TIMEOUT],
message_thread_id=params[ATTR_MESSAGE_THREAD_ID],
context=context,
)
# SERVICE_SEND_ANIMATION
return await self._send_msg_formatted(
self.bot.send_animation,
params[ATTR_MESSAGE_TAG],
chat_id=kwargs[ATTR_CHAT_ID],
animation=file_content,
caption=kwargs.get(ATTR_CAPTION),
disable_notification=params[ATTR_DISABLE_NOTIF],
reply_to_message_id=params[ATTR_REPLY_TO_MSGID],
reply_markup=params[ATTR_REPLYMARKUP],
read_timeout=params[ATTR_TIMEOUT],
parse_mode=params[ATTR_PARSER],
message_thread_id=params[ATTR_MESSAGE_THREAD_ID],
context=context,
)
async def send_sticker(
self,
context: Context | None = None,
**kwargs: Any,
) -> dict[str, JsonValueType]:
"""Send a sticker from a telegram sticker pack."""
params = self._get_msg_kwargs(kwargs)
stickerid = kwargs.get(ATTR_STICKER_ID)
if stickerid:
return await self._send_msg_formatted(
self.bot.send_sticker,
params[ATTR_MESSAGE_TAG],
chat_id=kwargs[ATTR_CHAT_ID],
sticker=stickerid,
disable_notification=params[ATTR_DISABLE_NOTIF],
reply_to_message_id=params[ATTR_REPLY_TO_MSGID],
reply_markup=params[ATTR_REPLYMARKUP],
read_timeout=params[ATTR_TIMEOUT],
message_thread_id=params[ATTR_MESSAGE_THREAD_ID],
context=context,
)
return await self.send_file(SERVICE_SEND_STICKER, context, **kwargs)
async def send_location(
self,
latitude: Any,
longitude: Any,
context: Context | None = None,
**kwargs: dict[str, Any],
) -> dict[str, JsonValueType]:
"""Send a location."""
latitude = float(latitude)
longitude = float(longitude)
params = self._get_msg_kwargs(kwargs)
return await self._send_msg_formatted(
self.bot.send_location,
params[ATTR_MESSAGE_TAG],
chat_id=kwargs[ATTR_CHAT_ID],
latitude=latitude,
longitude=longitude,
disable_notification=params[ATTR_DISABLE_NOTIF],
reply_to_message_id=params[ATTR_REPLY_TO_MSGID],
read_timeout=params[ATTR_TIMEOUT],
message_thread_id=params[ATTR_MESSAGE_THREAD_ID],
context=context,
)
async def send_poll(
self,
question: str,
options: Sequence[str | InputPollOption],
is_anonymous: bool | None,
allows_multiple_answers: bool | None,
context: Context | None = None,
**kwargs: dict[str, Any],
) -> dict[str, JsonValueType]:
"""Send a poll."""
params = self._get_msg_kwargs(kwargs)
openperiod = kwargs.get(ATTR_OPEN_PERIOD)
return await self._send_msg_formatted(
self.bot.send_poll,
params[ATTR_MESSAGE_TAG],
chat_id=kwargs[ATTR_CHAT_ID],
question=question,
options=options,
is_anonymous=is_anonymous,
allows_multiple_answers=allows_multiple_answers,
open_period=openperiod,
disable_notification=params[ATTR_DISABLE_NOTIF],
reply_to_message_id=params[ATTR_REPLY_TO_MSGID],
read_timeout=params[ATTR_TIMEOUT],
message_thread_id=params[ATTR_MESSAGE_THREAD_ID],
context=context,
)
async def leave_chat(
self,
chat_id: int,
context: Context | None = None,
**kwargs: dict[str, Any],
) -> Any:
"""Remove bot from chat."""
_LOGGER.debug("Leave from chat ID %s", chat_id)
return await self._send_msg(self.bot.leave_chat, None, chat_id, context=context)
async def set_message_reaction(
self,
reaction: str,
chat_id: int,
is_big: bool = False,
context: Context | None = None,
**kwargs: dict[str, Any],
) -> None:
"""Set the bot's reaction for a given message."""
message_id, _ = self._get_msg_ids(kwargs, chat_id)
params = self._get_msg_kwargs(kwargs)
_LOGGER.debug(
"Set reaction to message %s in chat ID %s to %s with params: %s",
message_id,
chat_id,
reaction,
params,
)
await self._send_msg(
self.bot.set_message_reaction,
params[ATTR_MESSAGE_TAG],
chat_id,
message_id,
reaction=reaction,
is_big=is_big,
read_timeout=params[ATTR_TIMEOUT],
context=context,
)
async def download_file(
self,
file_id: str,
directory_path: str | None = None,
file_name: str | None = None,
context: Context | None = None,
**kwargs: dict[str, Any],
) -> dict[str, JsonValueType]:
"""Download a file from Telegram."""
if not directory_path:
directory_path = self.hass.config.path(DOMAIN)
file: File = await self._send_msg(
self.bot.get_file,
None,
file_id=file_id,
context=context,
)
if not file.file_path:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="action_failed",
translation_placeholders={
"error": "No file path returned from Telegram"
},
)
if not file_name:
file_name = os.path.basename(file.file_path)
custom_path = os.path.join(directory_path, file_name)
await self.hass.async_add_executor_job(
self._prepare_download_directory, directory_path
)
_LOGGER.debug("Download file %s to %s", file_id, custom_path)
try:
file_content = await file.download_as_bytearray()
await self.hass.async_add_executor_job(
Path(custom_path).write_bytes, file_content
)
except (RuntimeError, OSError, TelegramError) as exc:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="action_failed",
translation_placeholders={"error": str(exc)},
) from exc
return {ATTR_FILE_PATH: custom_path}
@staticmethod
def _prepare_download_directory(directory_path: str) -> None:
"""Create download directory if it does not exist."""
if not os.path.exists(directory_path):
_LOGGER.debug("directory %s does not exist, creating it", directory_path)
os.makedirs(directory_path, exist_ok=True)
def initialize_bot(hass: HomeAssistant, p_config: MappingProxyType[str, Any]) -> Bot:
"""Initialize telegram bot with proxy support."""
api_key: str = p_config[CONF_API_KEY]
# set up timeouts to handle large file downloads and uploads
# server-side file size limit is 2GB
read_timeout = DEFAULT_TIMEOUT_SECONDS
media_write_timeout = DEFAULT_TIMEOUT_SECONDS
proxy_url: str | None = p_config.get(CONF_PROXY_URL)
if proxy_url is not None:
proxy = httpx.Proxy(proxy_url)
request = HTTPXRequest(
connection_pool_size=8,
proxy=proxy,
read_timeout=read_timeout,
media_write_timeout=media_write_timeout,
)
get_updates_request = HTTPXRequest(proxy=proxy)
else:
request = HTTPXRequest(
connection_pool_size=8,
read_timeout=read_timeout,
media_write_timeout=media_write_timeout,
)
get_updates_request = None
base_url: str = p_config[CONF_API_ENDPOINT]
return Bot(
token=api_key,
base_url=f"{base_url}/bot",
base_file_url=f"{base_url}/file/bot",
request=request,
get_updates_request=get_updates_request,
)
async def load_data(
hass: HomeAssistant,
url: str | None,
filepath: str | None,
username: str,
password: str,
authentication: str | None,
verify_ssl: bool,
num_retries: int = 5,
) -> io.BytesIO:
"""Load data into ByteIO/File container from a source."""
if url is not None:
# Load data from URL
params: dict[str, Any] = {}
headers: dict[str, str] = {}
_validate_credentials_input(authentication, username, password)
if authentication == HTTP_BEARER_AUTHENTICATION:
headers = {"Authorization": f"Bearer {password}"}
elif authentication == HTTP_DIGEST_AUTHENTICATION:
params["auth"] = httpx.DigestAuth(username, password)
elif authentication == HTTP_BASIC_AUTHENTICATION:
params["auth"] = httpx.BasicAuth(username, password)
retry_num = 0
async with get_async_client(hass, verify_ssl) as client:
while retry_num < num_retries:
try:
response = await client.get(
url, headers=headers, timeout=DEFAULT_TIMEOUT_SECONDS, **params
)
except (httpx.HTTPError, httpx.InvalidURL) as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="failed_to_load_url",
translation_placeholders={"error": str(err)},
) from err
if response.status_code != 200:
_LOGGER.warning(
"Status code %s (retry #%s) loading %s",
response.status_code,
retry_num + 1,
url,
)
else:
data = io.BytesIO(response.content)
if data.read():
data.seek(0)
data.name = url
_LOGGER.debug("file downloaded: %s", url)
return data
_LOGGER.warning("Empty data (retry #%s) in %s)", retry_num + 1, url)
retry_num += 1
if retry_num < num_retries:
await asyncio.sleep(
1
) # Add a sleep to allow other async operations to proceed
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="failed_to_load_url",
translation_placeholders={"error": str(response.status_code)},
)
elif filepath is not None:
if hass.config.is_allowed_path(filepath):
return await hass.async_add_executor_job(_read_file_as_bytesio, filepath)
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="allowlist_external_dirs_error",
)
else:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="missing_input",
translation_placeholders={"field": "URL or File"},
)
def _validate_credentials_input(
authentication: str | None, username: str | None, password: str | None
) -> None:
if (
authentication in (HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION)
and not username
):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="missing_input",
translation_placeholders={"field": "Username"},
)
if (
authentication
in (
HTTP_BASIC_AUTHENTICATION,
HTTP_BEARER_AUTHENTICATION,
HTTP_BEARER_AUTHENTICATION,
)
and not password
):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="missing_input",
translation_placeholders={"field": "Password"},
)
def _read_file_as_bytesio(file_path: str) -> io.BytesIO:
"""Read a file and return it as a BytesIO object."""
try:
with open(file_path, "rb") as file:
data = io.BytesIO(file.read())
data.name = file_path
return data
except OSError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="failed_to_load_file",
translation_placeholders={"error": str(err)},
) from err