""" Адаптер для работы с MAX/VK через MessengerClient интерфейс. Автор: Сергей Антропов Сайт: https://devops.org.ru """ import logging import io from typing import Optional, Union, Dict, Any import httpx from app.core.messengers.base import MessengerClient logger = logging.getLogger(__name__) class MaxMessengerClient(MessengerClient): """Адаптер для MAX/VK, реализующий интерфейс MessengerClient.""" def __init__(self, access_token: str, api_version: str = "5.131"): """ Инициализация клиента MAX/VK. Args: access_token: Access token для VK API. api_version: Версия VK API (по умолчанию 5.131). Raises: ValueError: Если access_token не указан. """ if not access_token: raise ValueError("MAX_ACCESS_TOKEN не установлен") self.access_token = access_token self.api_version = api_version self.api_url = "https://api.vk.com/method" @property def messenger_type(self) -> str: """Тип мессенджера.""" return "max" @property def supports_threads(self) -> bool: """MAX/VK не поддерживает треды.""" return False def _convert_html_to_vk_format(self, text: str) -> str: """ Конвертировать HTML в формат VK. VK поддерживает свою разметку: - [bold]текст[/bold] - жирный - [italic]текст[/italic] - курсив - [code]текст[/code] - код Args: text: Текст с HTML разметкой. Returns: Текст с VK разметкой. """ # Простая конвертация HTML в VK формат # Заменяем и на [bold] и [/bold] text = text.replace("", "[bold]").replace("", "[/bold]") text = text.replace("", "[bold]").replace("", "[/bold]") # Заменяем и на [italic] и [/italic] text = text.replace("", "[italic]").replace("", "[/italic]") text = text.replace("", "[italic]").replace("", "[/italic]") # Заменяем и на [code] и [/code] text = text.replace("", "[code]").replace("", "[/code]") # Заменяем
 и 
на [code] и [/code] text = text.replace("
", "[code]").replace("
", "[/code]") # Заменяем
и
на перенос строки text = text.replace("
", "\n").replace("
", "\n").replace("
", "\n") # Удаляем другие HTML теги (простая очистка) import re text = re.sub(r'<[^>]+>', '', text) return text async def _download_file(self, url: str) -> bytes: """ Скачать файл по URL. Args: url: URL файла. Returns: Байты файла. """ async with httpx.AsyncClient() as client: response = await client.get(url) response.raise_for_status() return response.content async def _upload_photo_to_vk(self, photo: Union[str, bytes], peer_id: Union[str, int]) -> Optional[str]: """ Загрузить фото в VK и получить attachment. Args: photo: URL или bytes фото. peer_id: ID получателя. Returns: Attachment string для VK API или None в случае ошибки. """ try: # Если photo - это URL, скачиваем файл if isinstance(photo, str) and (photo.startswith('http://') or photo.startswith('https://')): photo_bytes = await self._download_file(photo) elif isinstance(photo, bytes): photo_bytes = photo else: logger.error(f"Неподдерживаемый тип photo: {type(photo)}") return None # Получаем URL для загрузки фото async with httpx.AsyncClient() as client: # Шаг 1: Получаем upload server upload_url_params = { "access_token": self.access_token, "peer_id": peer_id, "v": self.api_version } upload_url_response = await client.get( f"{self.api_url}/photos.getMessagesUploadServer", params=upload_url_params ) upload_url_data = upload_url_response.json() if "error" in upload_url_data: logger.error(f"Ошибка получения upload server: {upload_url_data['error']}") return None upload_url = upload_url_data["response"]["upload_url"] # Шаг 2: Загружаем фото files = {"photo": ("photo.jpg", photo_bytes, "image/jpeg")} upload_response = await client.post(upload_url, files=files) upload_data = upload_response.json() if "error" in upload_data: logger.error(f"Ошибка загрузки фото: {upload_data['error']}") return None # Шаг 3: Сохраняем фото save_params = { "access_token": self.access_token, "server": upload_data["server"], "photo": upload_data["photo"], "hash": upload_data["hash"], "v": self.api_version } save_response = await client.get( f"{self.api_url}/photos.saveMessagesPhoto", params=save_params ) save_data = save_response.json() if "error" in save_data: logger.error(f"Ошибка сохранения фото: {save_data['error']}") return None # Формируем attachment string photo_data = save_data["response"][0] attachment = f"photo{photo_data['owner_id']}_{photo_data['id']}" return attachment except Exception as e: logger.error(f"Ошибка загрузки фото в VK: {e}") return None async def send_message( self, chat_id: Union[str, int], text: str, thread_id: Optional[int] = None, reply_markup: Optional[Dict[str, Any]] = None, disable_web_page_preview: bool = True, parse_mode: str = "HTML", **kwargs ) -> bool: """ Отправить текстовое сообщение в MAX/VK. Args: chat_id: ID чата или группы (может быть строкой или числом). text: Текст сообщения. thread_id: ID треда в группе (игнорируется для VK). reply_markup: Клавиатура с кнопками (опционально, формат VK). disable_web_page_preview: Отключить превью ссылок (игнорируется для VK). parse_mode: Режим парсинга (HTML конвертируется в VK формат). **kwargs: Дополнительные параметры (attachment, и т.д.). Returns: True если сообщение отправлено успешно, False в противном случае. """ if thread_id is not None: logger.warning("MAX/VK не поддерживает треды, параметр thread_id игнорируется") try: # Конвертируем HTML в формат VK if parse_mode == "HTML": text = self._convert_html_to_vk_format(text) # Преобразуем chat_id в int peer_id = int(chat_id) if isinstance(chat_id, str) else chat_id # Генерируем random_id для VK API (должен быть уникальным для каждого сообщения) import random random_id = random.randint(1, 2**31 - 1) # Параметры для отправки сообщения params = { "access_token": self.access_token, "peer_id": peer_id, "message": text, "v": self.api_version, "random_id": random_id # VK требует random_id } # Добавляем attachment, если есть if "attachment" in kwargs: params["attachment"] = kwargs["attachment"] # Добавляем клавиатуру, если есть if reply_markup: import json params["keyboard"] = json.dumps(reply_markup) # Отправляем сообщение async with httpx.AsyncClient() as client: response = await client.post( f"{self.api_url}/messages.send", params=params ) response_data = response.json() if "error" in response_data: error = response_data["error"] logger.error(f"Ошибка отправки сообщения в VK: {error}") return False message_id = response_data.get("response") if message_id: logger.info(f"Сообщение отправлено в VK чат {peer_id}, message_id: {message_id}") return True else: logger.error("Не удалось получить message_id из ответа VK API") return False except Exception as e: logger.error(f"Неожиданная ошибка при отправке сообщения в VK: {e}") return False async def send_photo( self, chat_id: Union[str, int], photo: Union[str, bytes], caption: Optional[str] = None, thread_id: Optional[int] = None, parse_mode: str = "HTML", **kwargs ) -> bool: """ Отправить фото в MAX/VK. Args: chat_id: ID чата или группы. photo: URL или bytes фото. caption: Подпись к фото (опционально). thread_id: ID треда в группе (игнорируется для VK). parse_mode: Режим парсинга (HTML конвертируется в VK формат). **kwargs: Дополнительные параметры. Returns: True если фото отправлено успешно, False в противном случае. """ if thread_id is not None: logger.warning("MAX/VK не поддерживает треды, параметр thread_id игнорируется") try: peer_id = int(chat_id) if isinstance(chat_id, str) else chat_id # Загружаем фото в VK attachment = await self._upload_photo_to_vk(photo, peer_id) if not attachment: logger.error("Не удалось загрузить фото в VK") return False # Формируем текст сообщения text = caption or "" if parse_mode == "HTML" and text: text = self._convert_html_to_vk_format(text) # Отправляем сообщение с фото return await self.send_message( chat_id=peer_id, text=text, attachment=attachment, parse_mode=parse_mode ) except Exception as e: logger.error(f"Неожиданная ошибка при отправке фото в VK: {e}") return False async def send_video( self, chat_id: Union[str, int], video: Union[str, bytes], caption: Optional[str] = None, thread_id: Optional[int] = None, parse_mode: str = "HTML", duration: Optional[int] = None, width: Optional[int] = None, height: Optional[int] = None, **kwargs ) -> bool: """ Отправить видео в MAX/VK. Примечание: VK API требует более сложную логику для загрузки видео. В текущей реализации отправляется только ссылка на видео. Args: chat_id: ID чата или группы. video: URL или bytes видео. caption: Подпись к видео (опционально). thread_id: ID треда в группе (игнорируется для VK). parse_mode: Режим парсинга (HTML конвертируется в VK формат). duration: Длительность видео в секундах (игнорируется для VK). width: Ширина видео (игнорируется для VK). height: Высота видео (игнорируется для VK). **kwargs: Дополнительные параметры. Returns: True если видео отправлено успешно, False в противном случае. """ if thread_id is not None: logger.warning("MAX/VK не поддерживает треды, параметр thread_id игнорируется") try: peer_id = int(chat_id) if isinstance(chat_id, str) else chat_id # Если video - это URL, отправляем как ссылку if isinstance(video, str) and (video.startswith('http://') or video.startswith('https://')): text = caption or video if parse_mode == "HTML" and text: text = self._convert_html_to_vk_format(text) return await self.send_message( chat_id=peer_id, text=text, parse_mode=parse_mode ) else: logger.warning("Загрузка видео через bytes пока не поддерживается в VK адаптере") return False except Exception as e: logger.error(f"Неожиданная ошибка при отправке видео в VK: {e}") return False async def send_audio( self, chat_id: Union[str, int], audio: Union[str, bytes], caption: Optional[str] = None, thread_id: Optional[int] = None, parse_mode: str = "HTML", duration: Optional[int] = None, performer: Optional[str] = None, title: Optional[str] = None, **kwargs ) -> bool: """ Отправить аудио в MAX/VK. Примечание: VK API требует специальную логику для загрузки аудио. В текущей реализации отправляется только ссылка на аудио. Args: chat_id: ID чата или группы. audio: URL или bytes аудио. caption: Подпись к аудио (опционально). thread_id: ID треда в группе (игнорируется для VK). parse_mode: Режим парсинга (HTML конвертируется в VK формат). duration: Длительность аудио в секундах (игнорируется для VK). performer: Исполнитель (игнорируется для VK). title: Название трека (игнорируется для VK). **kwargs: Дополнительные параметры. Returns: True если аудио отправлено успешно, False в противном случае. """ if thread_id is not None: logger.warning("MAX/VK не поддерживает треды, параметр thread_id игнорируется") try: peer_id = int(chat_id) if isinstance(chat_id, str) else chat_id # Если audio - это URL, отправляем как ссылку if isinstance(audio, str) and (audio.startswith('http://') or audio.startswith('https://')): text = caption or audio if parse_mode == "HTML" and text: text = self._convert_html_to_vk_format(text) return await self.send_message( chat_id=peer_id, text=text, parse_mode=parse_mode ) else: logger.warning("Загрузка аудио через bytes пока не поддерживается в VK адаптере") return False except Exception as e: logger.error(f"Неожиданная ошибка при отправке аудио в VK: {e}") return False async def send_document( self, chat_id: Union[str, int], document: Union[str, bytes], caption: Optional[str] = None, thread_id: Optional[int] = None, parse_mode: str = "HTML", filename: Optional[str] = None, **kwargs ) -> bool: """ Отправить документ в MAX/VK. Примечание: VK API требует специальную логику для загрузки документов. В текущей реализации отправляется только ссылка на документ. Args: chat_id: ID чата или группы. document: URL или bytes документа. caption: Подпись к документу (опционально). thread_id: ID треда в группе (игнорируется для VK). parse_mode: Режим парсинга (HTML конвертируется в VK формат). filename: Имя файла (игнорируется для VK). **kwargs: Дополнительные параметры. Returns: True если документ отправлен успешно, False в противном случае. """ if thread_id is not None: logger.warning("MAX/VK не поддерживает треды, параметр thread_id игнорируется") try: peer_id = int(chat_id) if isinstance(chat_id, str) else chat_id # Если document - это URL, отправляем как ссылку if isinstance(document, str) and (document.startswith('http://') or document.startswith('https://')): text = caption or document if parse_mode == "HTML" and text: text = self._convert_html_to_vk_format(text) return await self.send_message( chat_id=peer_id, text=text, parse_mode=parse_mode ) else: logger.warning("Загрузка документов через bytes пока не поддерживается в VK адаптере") return False except Exception as e: logger.error(f"Неожиданная ошибка при отправке документа в VK: {e}") return False