""" Клиент для работы с Telegram Bot API. Автор: Сергей Антропов Сайт: https://devops.org.ru """ import logging import io from typing import Optional, Union from telegram import Bot, InlineKeyboardMarkup from telegram.error import TelegramError import httpx logger = logging.getLogger(__name__) class TelegramClient: """Клиент для отправки сообщений в Telegram.""" def __init__(self, bot_token: Optional[str] = None): """ Инициализация клиента Telegram. Args: bot_token: Токен бота Telegram. Если не указан, используется из настроек. Raises: ValueError: Если токен не указан. """ # Импортируем settings здесь, чтобы избежать циклических зависимостей from app.core.config import get_settings settings = get_settings() self.bot_token = bot_token or settings.telegram_bot_token if not self.bot_token: raise ValueError("TELEGRAM_BOT_TOKEN не установлен") self._bot: Optional[Bot] = None async def get_bot(self) -> Bot: """ Получить экземпляр бота (создается при первом обращении). Returns: Экземпляр Bot. """ if self._bot is None: self._bot = Bot(token=self.bot_token) return self._bot async def send_message( self, chat_id: int, text: str, message_thread_id: Optional[int] = None, reply_markup: Optional[InlineKeyboardMarkup] = None, disable_web_page_preview: bool = True, parse_mode: str = "HTML" ) -> bool: """ Отправить сообщение в Telegram. Args: chat_id: ID чата или группы. text: Текст сообщения. message_thread_id: ID треда в группе (опционально). reply_markup: Клавиатура с кнопками (опционально). disable_web_page_preview: Отключить превью ссылок. parse_mode: Режим парсинга (HTML, Markdown). Returns: True если сообщение отправлено успешно, False в противном случае. """ try: bot = await self.get_bot() await bot.send_message( chat_id=chat_id, text=text, message_thread_id=message_thread_id, disable_web_page_preview=disable_web_page_preview, parse_mode=parse_mode, reply_markup=reply_markup ) logger.info(f"Сообщение отправлено в чат {chat_id}, тред {message_thread_id}") return True except TelegramError as e: logger.error(f"Ошибка отправки сообщения в Telegram: {e}") return False except Exception as e: logger.error(f"Неожиданная ошибка при отправке сообщения: {e}") return False async def send_photo( self, chat_id: int, photo: Union[str, bytes, io.BytesIO], caption: Optional[str] = None, message_thread_id: Optional[int] = None, parse_mode: str = "HTML" ) -> bool: """ Отправить фото в Telegram. Args: chat_id: ID чата или группы. photo: Путь к файлу, URL, bytes или BytesIO объект с фото. caption: Подпись к фото (опционально). message_thread_id: ID треда в группе (опционально). parse_mode: Режим парсинга (HTML, Markdown). Returns: True если фото отправлено успешно, False в противном случае. """ try: bot = await self.get_bot() # Если photo - это URL, скачиваем файл if isinstance(photo, str) and (photo.startswith('http://') or photo.startswith('https://')): async with httpx.AsyncClient() as client: response = await client.get(photo) photo = io.BytesIO(response.content) # Если photo - это bytes, преобразуем в BytesIO if isinstance(photo, bytes): photo = io.BytesIO(photo) await bot.send_photo( chat_id=chat_id, photo=photo, caption=caption, message_thread_id=message_thread_id, parse_mode=parse_mode ) logger.info(f"Фото отправлено в чат {chat_id}, тред {message_thread_id}") return True except TelegramError as e: logger.error(f"Ошибка отправки фото в Telegram: {e}") return False except Exception as e: logger.error(f"Неожиданная ошибка при отправке фото: {e}") return False async def send_video( self, chat_id: int, video: Union[str, bytes, io.BytesIO], caption: Optional[str] = None, message_thread_id: Optional[int] = None, parse_mode: str = "HTML", duration: Optional[int] = None, width: Optional[int] = None, height: Optional[int] = None ) -> bool: """ Отправить видео в Telegram. Args: chat_id: ID чата или группы. video: Путь к файлу, URL, bytes или BytesIO объект с видео. caption: Подпись к видео (опционально). message_thread_id: ID треда в группе (опционально). parse_mode: Режим парсинга (HTML, Markdown). duration: Длительность видео в секундах (опционально). width: Ширина видео (опционально). height: Высота видео (опционально). Returns: True если видео отправлено успешно, False в противном случае. """ try: bot = await self.get_bot() # Если video - это URL, скачиваем файл if isinstance(video, str) and (video.startswith('http://') or video.startswith('https://')): async with httpx.AsyncClient() as client: response = await client.get(video) video = io.BytesIO(response.content) # Если video - это bytes, преобразуем в BytesIO if isinstance(video, bytes): video = io.BytesIO(video) await bot.send_video( chat_id=chat_id, video=video, caption=caption, message_thread_id=message_thread_id, parse_mode=parse_mode, duration=duration, width=width, height=height ) logger.info(f"Видео отправлено в чат {chat_id}, тред {message_thread_id}") return True except TelegramError as e: logger.error(f"Ошибка отправки видео в Telegram: {e}") return False except Exception as e: logger.error(f"Неожиданная ошибка при отправке видео: {e}") return False async def send_audio( self, chat_id: int, audio: Union[str, bytes, io.BytesIO], caption: Optional[str] = None, message_thread_id: Optional[int] = None, parse_mode: str = "HTML", duration: Optional[int] = None, performer: Optional[str] = None, title: Optional[str] = None ) -> bool: """ Отправить аудио в Telegram. Args: chat_id: ID чата или группы. audio: Путь к файлу, URL, bytes или BytesIO объект с аудио. caption: Подпись к аудио (опционально). message_thread_id: ID треда в группе (опционально). parse_mode: Режим парсинга (HTML, Markdown). duration: Длительность аудио в секундах (опционально). performer: Исполнитель (опционально). title: Название трека (опционально). Returns: True если аудио отправлено успешно, False в противном случае. """ try: bot = await self.get_bot() # Если audio - это URL, скачиваем файл if isinstance(audio, str) and (audio.startswith('http://') or audio.startswith('https://')): async with httpx.AsyncClient() as client: response = await client.get(audio) audio = io.BytesIO(response.content) # Если audio - это bytes, преобразуем в BytesIO if isinstance(audio, bytes): audio = io.BytesIO(audio) await bot.send_audio( chat_id=chat_id, audio=audio, caption=caption, message_thread_id=message_thread_id, parse_mode=parse_mode, duration=duration, performer=performer, title=title ) logger.info(f"Аудио отправлено в чат {chat_id}, тред {message_thread_id}") return True except TelegramError as e: logger.error(f"Ошибка отправки аудио в Telegram: {e}") return False except Exception as e: logger.error(f"Неожиданная ошибка при отправке аудио: {e}") return False async def send_document( self, chat_id: int, document: Union[str, bytes, io.BytesIO], caption: Optional[str] = None, message_thread_id: Optional[int] = None, parse_mode: str = "HTML", filename: Optional[str] = None ) -> bool: """ Отправить документ в Telegram. Args: chat_id: ID чата или группы. document: Путь к файлу, URL, bytes или BytesIO объект с документом. caption: Подпись к документу (опционально). message_thread_id: ID треда в группе (опционально). parse_mode: Режим парсинга (HTML, Markdown). filename: Имя файла (опционально). Returns: True если документ отправлен успешно, False в противном случае. """ try: bot = await self.get_bot() document_url = None # Если document - это URL, скачиваем файл if isinstance(document, str) and (document.startswith('http://') or document.startswith('https://')): document_url = document async with httpx.AsyncClient() as client: response = await client.get(document) document = io.BytesIO(response.content) if not filename: # Пытаемся извлечь имя файла из URL filename = document_url.split('/')[-1].split('?')[0] # Если document - это bytes, преобразуем в BytesIO if isinstance(document, bytes): document = io.BytesIO(document) await bot.send_document( chat_id=chat_id, document=document, caption=caption, message_thread_id=message_thread_id, parse_mode=parse_mode, filename=filename ) logger.info(f"Документ отправлен в чат {chat_id}, тред {message_thread_id}") return True except TelegramError as e: logger.error(f"Ошибка отправки документа в Telegram: {e}") return False except Exception as e: logger.error(f"Неожиданная ошибка при отправке документа: {e}") return False # Глобальный экземпляр клиента (lazy initialization) _telegram_client_instance: Optional[TelegramClient] = None def get_telegram_client() -> TelegramClient: """ Получить экземпляр клиента Telegram (lazy initialization). Returns: Экземпляр TelegramClient. """ global _telegram_client_instance if _telegram_client_instance is None: _telegram_client_instance = TelegramClient() return _telegram_client_instance # Для обратной совместимости (lazy initialization) # telegram_client будет создан при первом использовании class _TelegramClientProxy: """Прокси для ленивой инициализации telegram_client.""" def __getattr__(self, name): """Получить атрибут из telegram_client.""" return getattr(get_telegram_client(), name) telegram_client = _TelegramClientProxy()