- FastAPI приложение для отправки мониторинговых алертов в мессенджеры - Поддержка Telegram и MAX/VK - Интеграция с Grafana, Zabbix, AlertManager - Автоматическое создание тикетов в Jira - Управление группами мессенджеров через API - Декораторы для авторизации и скрытия эндпоинтов - Подробная документация в папке docs/ Автор: Сергей Антропов Сайт: https://devops.org.ru
477 lines
21 KiB
Python
477 lines
21 KiB
Python
"""
|
||
Адаптер для работы с 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
|
||
|