"""
Адаптер для работы с 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