Initial commit: Message Gateway project
- FastAPI приложение для отправки мониторинговых алертов в мессенджеры - Поддержка Telegram и MAX/VK - Интеграция с Grafana, Zabbix, AlertManager - Автоматическое создание тикетов в Jira - Управление группами мессенджеров через API - Декораторы для авторизации и скрытия эндпоинтов - Подробная документация в папке docs/ Автор: Сергей Антропов Сайт: https://devops.org.ru
This commit is contained in:
476
app/core/messengers/max.py
Normal file
476
app/core/messengers/max.py
Normal file
@@ -0,0 +1,476 @@
|
||||
"""
|
||||
Адаптер для работы с 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
|
||||
|
||||
Reference in New Issue
Block a user