Files
MessageGateway/app/core/messengers/max.py
Sergey Antropov b90def35ed Initial commit: Message Gateway project
- FastAPI приложение для отправки мониторинговых алертов в мессенджеры
- Поддержка Telegram и MAX/VK
- Интеграция с Grafana, Zabbix, AlertManager
- Автоматическое создание тикетов в Jira
- Управление группами мессенджеров через API
- Декораторы для авторизации и скрытия эндпоинтов
- Подробная документация в папке docs/

Автор: Сергей Антропов
Сайт: https://devops.org.ru
2025-11-12 20:25:11 +03:00

477 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Адаптер для работы с 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 формат
# Заменяем <b> и </b> на [bold] и [/bold]
text = text.replace("<b>", "[bold]").replace("</b>", "[/bold]")
text = text.replace("<strong>", "[bold]").replace("</strong>", "[/bold]")
# Заменяем <i> и </i> на [italic] и [/italic]
text = text.replace("<i>", "[italic]").replace("</i>", "[/italic]")
text = text.replace("<em>", "[italic]").replace("</em>", "[/italic]")
# Заменяем <code> и </code> на [code] и [/code]
text = text.replace("<code>", "[code]").replace("</code>", "[/code]")
# Заменяем <pre> и </pre> на [code] и [/code]
text = text.replace("<pre>", "[code]").replace("</pre>", "[/code]")
# Заменяем <br> и <br/> на перенос строки
text = text.replace("<br>", "\n").replace("<br/>", "\n").replace("<br />", "\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