Initial commit: Message Gateway project

- FastAPI приложение для отправки мониторинговых алертов в мессенджеры
- Поддержка Telegram и MAX/VK
- Интеграция с Grafana, Zabbix, AlertManager
- Автоматическое создание тикетов в Jira
- Управление группами мессенджеров через API
- Декораторы для авторизации и скрытия эндпоинтов
- Подробная документация в папке docs/

Автор: Сергей Антропов
Сайт: https://devops.org.ru
This commit is contained in:
2025-11-12 20:25:11 +03:00
commit b90def35ed
72 changed files with 10609 additions and 0 deletions

6
app/core/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
"""
Общие утилиты и функции для приложения.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""

183
app/core/auth.py Normal file
View File

@@ -0,0 +1,183 @@
"""
Утилиты для аутентификации и авторизации.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import logging
from typing import Optional, Callable, Any
from functools import wraps
from fastapi import HTTPException, Security, Depends, Request
from fastapi.security import APIKeyHeader
from starlette.requests import Request as StarletteRequest
from app.core.config import get_settings
logger = logging.getLogger(__name__)
# Request может быть как из FastAPI, так и из Starlette
# Оба типа совместимы, поэтому используем StarletteRequest как базовый тип
RequestType = StarletteRequest
# Схема безопасности для API ключа
api_key_header = APIKeyHeader(
name="X-API-Key",
auto_error=False,
description="API ключ для авторизации"
)
def verify_api_key(api_key: Optional[str] = Security(api_key_header)) -> bool:
"""
Проверить API ключ для авторизации (обязательная авторизация).
Используется как зависимость FastAPI (Depends) для отображения в Swagger UI.
Также помечает контекст запроса, что API ключ был проверен.
Args:
api_key: API ключ из заголовка X-API-Key.
Returns:
True если API ключ верный.
Raises:
HTTPException: Если API ключ неверный или не указан.
"""
settings = get_settings()
# Если API ключ не установлен в настройках, доступ запрещен
if not settings.api_key:
logger.warning("API_KEY не установлен - доступ запрещен")
raise HTTPException(
status_code=401,
detail="API ключ не настроен на сервере"
)
# Если API ключ не передан, доступ запрещен
if not api_key:
raise HTTPException(
status_code=401,
detail="Неверный или отсутствующий API ключ",
headers={"WWW-Authenticate": "ApiKey"}
)
# Проверяем API ключ
if api_key != settings.api_key:
logger.warning(f"Неверный API ключ: {api_key[:10]}...")
raise HTTPException(
status_code=401,
detail="Неверный или отсутствующий API ключ",
headers={"WWW-Authenticate": "ApiKey"}
)
# Помечаем, что API ключ был проверен через dependency
# Это позволяет декоратору @require_api_key не выполнять повторную проверку
try:
from starlette.context import contextvars
# Используем contextvars для хранения информации о проверке
# Но это может не работать во всех случаях
pass
except ImportError:
pass
return True
def verify_api_key_optional(api_key: Optional[str] = Security(api_key_header)) -> Optional[bool]:
"""
Проверить API ключ для авторизации (опциональная авторизация).
Используется как зависимость FastAPI (Depends).
Args:
api_key: API ключ из заголовка X-API-Key.
Returns:
True если API ключ верный, None если не передан, выбрасывает исключение если неверный.
Raises:
HTTPException: Если API ключ неверный.
"""
settings = get_settings()
# Если API ключ не установлен в настройках, возвращаем None (нет авторизации)
if not settings.api_key:
return None
# Если API ключ не передан, возвращаем None (нет авторизации)
if not api_key:
return None
# Проверяем API ключ
if api_key != settings.api_key:
logger.warning(f"Неверный API ключ: {api_key[:10]}...")
raise HTTPException(
status_code=401,
detail="Неверный или отсутствующий API ключ",
headers={"WWW-Authenticate": "ApiKey"}
)
return True
# Удобные константы для использования в endpoints (через dependencies)
require_api_key_dependency = Depends(verify_api_key)
require_api_key_optional = Depends(verify_api_key_optional)
def require_api_key(func: Callable) -> Callable:
"""
Декоратор для пометки функции как требующей API ключ.
Использование:
@require_api_key
@router.post("/endpoint", dependencies=[require_api_key_dependency])
async def my_endpoint(request: Request, ...):
...
Примечание: Декоратор используется только для пометки функции.
Фактическая проверка API ключа выполняется через `dependencies=[require_api_key_dependency]`,
который также обеспечивает отображение замочка в Swagger UI.
Декоратор не выполняет проверку API ключа - это делает dependency.
Декоратор оставлен для удобства и возможного расширения в будущем.
Args:
func: Функция для декорирования.
Returns:
Декорированная функция с пометкой о необходимости API ключа.
"""
# Помечаем функцию, что она требует API ключ
func.__requires_api_key__ = True
# Просто возвращаем функцию без изменений
# Проверка API ключа выполняется через dependency
return func
def hide_from_api(func: Callable) -> Callable:
"""
Декоратор для скрытия эндпоинта из API документации (Swagger UI).
Использование:
@hide_from_api
@router.post("/debug/dump")
async def debug_endpoint(...):
...
Примечание: Декоратор помечает функцию как скрытую от API.
Эндпоинт все еще будет работать, но не будет отображаться в Swagger UI.
Декоратор должен быть применен ПЕРЕД декоратором route (снизу вверх).
Args:
func: Функция для декорирования.
Returns:
Декорированная функция с пометкой о скрытии от API.
"""
# Помечаем функцию, что она должна быть скрыта от API
func.__hide_from_api__ = True
# Просто возвращаем функцию без изменений
# Скрытие из API выполняется в custom_openapi
return func

127
app/core/button_utils.py Normal file
View File

@@ -0,0 +1,127 @@
"""
Утилиты для работы с кнопками в различных мессенджерах.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import logging
from typing import Optional, Dict, Any, List
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
logger = logging.getLogger(__name__)
def convert_telegram_buttons_to_dict(buttons: Optional[InlineKeyboardMarkup]) -> Optional[Dict[str, Any]]:
"""
Преобразовать кнопки Telegram (InlineKeyboardMarkup) в универсальный формат Dict.
Args:
buttons: InlineKeyboardMarkup с кнопками или None.
Returns:
Словарь с кнопками в универсальном формате или None.
Формат:
{
"inline_keyboard": [
[
{"text": "Кнопка 1", "url": "https://example.com"},
{"text": "Кнопка 2", "url": "https://example2.com"}
],
[
{"text": "Кнопка 3", "url": "https://example3.com"}
]
]
}
"""
if buttons is None:
return None
if not isinstance(buttons, InlineKeyboardMarkup):
logger.warning(f"Неизвестный тип кнопок: {type(buttons)}")
return None
inline_keyboard = []
for row in buttons.inline_keyboard:
row_buttons = []
for button in row:
if isinstance(button, InlineKeyboardButton):
button_dict = {"text": button.text}
if button.url:
button_dict["url"] = button.url
if button.callback_data:
button_dict["callback_data"] = button.callback_data
row_buttons.append(button_dict)
if row_buttons:
inline_keyboard.append(row_buttons)
if not inline_keyboard:
return None
return {"inline_keyboard": inline_keyboard}
def convert_dict_to_telegram_buttons(buttons_dict: Optional[Dict[str, Any]]) -> Optional[InlineKeyboardMarkup]:
"""
Преобразовать универсальный формат Dict в кнопки Telegram (InlineKeyboardMarkup).
Args:
buttons_dict: Словарь с кнопками в универсальном формате или None.
Returns:
InlineKeyboardMarkup с кнопками или None.
"""
if buttons_dict is None:
return None
inline_keyboard_data = buttons_dict.get("inline_keyboard", [])
if not inline_keyboard_data:
return None
inline_keyboard = []
for row in inline_keyboard_data:
row_buttons = []
for button_data in row:
if isinstance(button_data, dict):
text = button_data.get("text")
url = button_data.get("url")
callback_data = button_data.get("callback_data")
if text:
if url:
row_buttons.append(InlineKeyboardButton(text=text, url=url))
elif callback_data:
row_buttons.append(InlineKeyboardButton(text=text, callback_data=callback_data))
if row_buttons:
inline_keyboard.append(row_buttons)
if not inline_keyboard:
return None
return InlineKeyboardMarkup(inline_keyboard)
def convert_dict_to_vk_buttons(buttons_dict: Optional[Dict[str, Any]]) -> Optional[str]:
"""
Преобразовать универсальный формат Dict в кнопки VK (JSON строка).
Args:
buttons_dict: Словарь с кнопками в универсальном формате или None.
Returns:
JSON строка с кнопками для VK API или None.
"""
if buttons_dict is None:
return None
inline_keyboard_data = buttons_dict.get("inline_keyboard", [])
if not inline_keyboard_data:
return None
# Формируем клавиатуру для VK API
# VK API использует другой формат клавиатуры
# Для простоты возвращаем None, так как VK API требует более сложной структуры
# В реальной реализации нужно преобразовать в формат VK Keyboard
logger.warning("Преобразование кнопок в формат VK пока не реализовано полностью")
return None

132
app/core/config.py Normal file
View File

@@ -0,0 +1,132 @@
"""
Конфигурация приложения.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import os
import logging
from typing import Optional
from pydantic_settings import BaseSettings, SettingsConfigDict
logger = logging.getLogger(__name__)
class Settings(BaseSettings):
"""Настройки приложения из переменных окружения."""
# Telegram настройки
telegram_bot_token: Optional[str] = None
telegram_enabled: bool = True
# MAX/VK настройки
max_access_token: Optional[str] = None
max_api_version: str = "5.131"
max_enabled: bool = False
# Общие настройки мессенджеров
default_messenger: str = "telegram" # По умолчанию Telegram
# Файлы конфигурации
groups_config_path: str = "/app/config/groups.json"
templates_path: str = "/app/templates"
# API ключ для авторизации
api_key: Optional[str] = None
# Grafana настройки
grafana_url: Optional[str] = None
# Zabbix настройки
zabbix_url: Optional[str] = None
# Kubernetes кластер настройки
k8s_cluster_grafana_subdomain: Optional[str] = None
k8s_cluster_prometheus_subdomain: Optional[str] = None
k8s_cluster_alertmanager_subdomain: Optional[str] = None
# Prometheus Pushgateway настройки
pushgateway_url: Optional[str] = None
pushgateway_job: str = "MessageGateway"
# OpenTelemetry настройки
otel_enabled: bool = False
otel_service_name: str = "monitoring-message-gateway"
otel_exporter_otlp_endpoint: Optional[str] = None
otel_exporter_otlp_protocol: str = "http/json"
otel_traces_exporter: str = "otlp_proto_http"
otel_exporter_otlp_insecure: bool = True
otel_python_log_correlation: bool = False
# Jira настройки
jira_enabled: bool = False
jira_url: Optional[str] = None
jira_email: Optional[str] = None
jira_api_token: Optional[str] = None
jira_project_key: Optional[str] = None
jira_default_assignee: Optional[str] = None
jira_default_issue_type: str = "Bug"
jira_mapping_config_path: str = "/app/config/jira_mapping.json"
jira_create_on_alert: bool = True
jira_create_on_resolved: bool = False
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
env_ignore_empty=True
)
def validate_required(self) -> None:
"""Проверка обязательных переменных окружения."""
if not self.telegram_bot_token:
logger.warning("TELEGRAM_BOT_TOKEN не установлен - приложение может не работать")
# Не выбрасываем исключение, чтобы приложение могло запуститься
def get_k8s_grafana_url(self, cluster: str) -> str:
"""Получить URL Grafana для Kubernetes кластера."""
if not self.k8s_cluster_grafana_subdomain:
raise ValueError("K8S_CLUSTER_GRAFANA_SUBDOMAIN не установлен")
return f"{cluster}.{self.k8s_cluster_grafana_subdomain}"
def get_k8s_prometheus_url(self, cluster: str) -> str:
"""Получить URL Prometheus для Kubernetes кластера."""
if not self.k8s_cluster_prometheus_subdomain:
raise ValueError("K8S_CLUSTER_PROMETHEUS_SUBDOMAIN не установлен")
return f"{cluster}.{self.k8s_cluster_prometheus_subdomain}"
def get_k8s_alertmanager_url(self, cluster: str) -> str:
"""Получить URL AlertManager для Kubernetes кластера."""
if not self.k8s_cluster_alertmanager_subdomain:
raise ValueError("K8S_CLUSTER_ALERTMANAGER_SUBDOMAIN не установлен")
return f"{cluster}.{self.k8s_cluster_alertmanager_subdomain}"
# Глобальный экземпляр настроек (валидация отложена до первого использования)
_settings_instance: Optional[Settings] = None
def get_settings() -> Settings:
"""
Получить экземпляр настроек (lazy initialization).
Returns:
Экземпляр Settings.
"""
global _settings_instance
if _settings_instance is None:
_settings_instance = Settings()
return _settings_instance
# Глобальный экземпляр настроек (lazy initialization)
class _SettingsProxy:
"""Прокси для ленивой инициализации settings."""
def __getattr__(self, name):
"""Получить атрибут из settings."""
return getattr(get_settings(), name)
settings = _SettingsProxy()

370
app/core/groups.py Normal file
View File

@@ -0,0 +1,370 @@
"""
Управление конфигурацией групп для различных мессенджеров.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import json
import logging
from typing import Dict, Optional, Any, Union
import aiofiles
from datetime import datetime, timedelta
logger = logging.getLogger(__name__)
class GroupsConfig:
"""Менеджер конфигурации групп для различных мессенджеров с кэшированием."""
def __init__(self, config_path: Optional[str] = None):
"""
Инициализация менеджера конфигурации.
Args:
config_path: Путь к файлу конфигурации групп.
"""
from app.core.config import get_settings
settings = get_settings()
self.config_path = config_path or settings.groups_config_path
self._cache: Optional[Dict[str, Any]] = None
self._cache_time: Optional[datetime] = None
self._cache_ttl = timedelta(minutes=5) # Кэш на 5 минут
self.default_messenger = settings.default_messenger
async def _load_config(self) -> Dict[str, Any]:
"""
Загрузить конфигурацию групп из файла.
Returns:
Конфигурация групп в формате:
{
"group_name": {
"messenger": "telegram",
"chat_id": -1001997464975,
"thread_id": 0,
"config": {}
}
}
Raises:
FileNotFoundError: Если файл конфигурации не найден.
json.JSONDecodeError: Если файл содержит некорректный JSON.
ValueError: Если конфигурация имеет неверный формат.
"""
try:
async with aiofiles.open(self.config_path, 'r', encoding='utf-8') as f:
content = await f.read()
config = json.loads(content)
logger.info(f"Конфигурация групп загружена из {self.config_path}")
# Валидация формата конфигурации
for group_name, group_value in config.items():
if not isinstance(group_value, dict):
raise ValueError(
f"Неверный формат конфигурации для группы '{group_name}': "
f"ожидается словарь, получен {type(group_value)}. "
f"Используйте формат: {{'messenger': 'telegram', 'chat_id': ..., 'thread_id': 0, 'config': {{}}}}"
)
if "chat_id" not in group_value:
raise ValueError(
f"Отсутствует обязательное поле 'chat_id' для группы '{group_name}'"
)
if "messenger" not in group_value:
raise ValueError(
f"Отсутствует обязательное поле 'messenger' для группы '{group_name}'"
)
return config
except FileNotFoundError:
logger.error(f"Файл конфигурации групп не найден: {self.config_path}")
raise
except json.JSONDecodeError as e:
logger.error(f"Ошибка парсинга JSON в файле конфигурации: {e}")
raise
def _is_cache_valid(self) -> bool:
"""Проверить, валиден ли кэш."""
if self._cache is None or self._cache_time is None:
return False
return datetime.now() - self._cache_time < self._cache_ttl
async def get_group_config(self, group_name: str, messenger: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""
Получить конфигурацию группы.
Args:
group_name: Имя группы из конфигурации.
messenger: Тип мессенджера (опционально, для фильтрации).
Returns:
Конфигурация группы или None, если группа не найдена.
Формат:
{
"messenger": "telegram",
"chat_id": -1001997464975,
"thread_id": 0,
"config": {}
}
"""
# Проверяем кэш
if not self._is_cache_valid():
try:
self._cache = await self._load_config()
self._cache_time = datetime.now()
except (FileNotFoundError, json.JSONDecodeError):
logger.error("Не удалось загрузить конфигурацию групп")
return None
group_config = self._cache.get(group_name)
if group_config is None:
logger.warning(f"Группа '{group_name}' не найдена в конфигурации")
return None
# Если указан messenger, проверяем соответствие
if messenger and group_config.get("messenger") != messenger:
logger.warning(
f"Группа '{group_name}' имеет мессенджер '{group_config.get('messenger')}', "
f"но запрошен '{messenger}'"
)
return None
logger.info(f"Найдена конфигурация для группы '{group_name}': {group_config}")
return group_config
async def get_chat_id(self, group_name: str, messenger: Optional[str] = None) -> Optional[Union[int, str]]:
"""
Получить ID чата по имени группы.
Args:
group_name: Имя группы из конфигурации.
messenger: Тип мессенджера (опционально).
Returns:
ID чата или None, если группа не найдена.
"""
group_config = await self.get_group_config(group_name, messenger)
if group_config is None:
return None
return group_config.get("chat_id")
async def refresh_cache(self) -> None:
"""Принудительно обновить кэш конфигурации."""
try:
self._cache = await self._load_config()
self._cache_time = datetime.now()
logger.info("Кэш конфигурации групп обновлен")
except (FileNotFoundError, json.JSONDecodeError) as e:
logger.error(f"Ошибка обновления кэша: {e}")
async def _save_config(self, config: Dict[str, Any]) -> None:
"""
Сохранить конфигурацию групп в файл.
Args:
config: Нормализованная конфигурация групп в новом формате.
Raises:
IOError: Если не удалось записать файл.
"""
try:
async with aiofiles.open(self.config_path, 'w', encoding='utf-8') as f:
await f.write(json.dumps(config, indent=2, ensure_ascii=False))
logger.info(f"Конфигурация групп сохранена в {self.config_path}")
# Обновляем кэш
self._cache = config
self._cache_time = datetime.now()
except IOError as e:
logger.error(f"Ошибка записи конфигурации групп: {e}")
raise
async def get_all_groups(self, include_ids: bool = False, messenger: Optional[str] = None) -> Dict[str, Any]:
"""
Получить все группы из конфигурации.
Args:
include_ids: Включать ли полную конфигурацию групп (включая ID, мессенджер и т.д.).
messenger: Фильтр по типу мессенджера (опционально).
Returns:
Словарь с группами.
Если include_ids=False, возвращает только названия групп.
Если include_ids=True, возвращает полную конфигурацию групп.
"""
if not self._is_cache_valid():
try:
self._cache = await self._load_config()
self._cache_time = datetime.now()
except (FileNotFoundError, json.JSONDecodeError):
logger.error("Не удалось загрузить конфигурацию групп")
return {}
# Фильтруем по мессенджеру, если указан
filtered_config = self._cache.copy()
if messenger:
filtered_config = {
name: config
for name, config in filtered_config.items()
if config.get("messenger") == messenger
}
if include_ids:
return filtered_config.copy()
else:
# Возвращаем только названия групп без конфигурации
return {name: None for name in filtered_config.keys()}
async def create_group(
self,
group_name: str,
chat_id: Union[int, str],
messenger: str = "telegram",
thread_id: int = 0,
config: Optional[Dict[str, Any]] = None
) -> bool:
"""
Создать новую группу в конфигурации.
Args:
group_name: Имя группы.
chat_id: ID чата (может быть int или str).
messenger: Тип мессенджера (telegram, max).
thread_id: ID треда в группе (по умолчанию 0).
config: Дополнительная конфигурация для мессенджера (опционально).
Returns:
True если группа создана успешно, False если группа уже существует.
"""
# Загружаем текущую конфигурацию
if not self._is_cache_valid():
try:
self._cache = await self._load_config()
self._cache_time = datetime.now()
except (FileNotFoundError, json.JSONDecodeError):
# Если файл не существует, создаем новый
self._cache = {}
# Проверяем, существует ли группа
if group_name in self._cache:
logger.warning(f"Группа '{group_name}' уже существует")
return False
# Добавляем группу
self._cache[group_name] = {
"messenger": messenger,
"chat_id": chat_id,
"thread_id": thread_id,
"config": config or {}
}
await self._save_config(self._cache)
logger.info(f"Группа '{group_name}' создана с мессенджером '{messenger}' и ID {chat_id}")
return True
async def update_group(
self,
group_name: str,
chat_id: Optional[Union[int, str]] = None,
messenger: Optional[str] = None,
thread_id: Optional[int] = None,
config: Optional[Dict[str, Any]] = None
) -> bool:
"""
Обновить существующую группу в конфигурации.
Args:
group_name: Имя группы.
chat_id: Новый ID чата (опционально).
messenger: Новый тип мессенджера (опционально).
thread_id: Новый ID треда (опционально).
config: Новая дополнительная конфигурация (опционально).
Returns:
True если группа обновлена успешно, False если группа не найдена.
"""
# Загружаем текущую конфигурацию
if not self._is_cache_valid():
try:
self._cache = await self._load_config()
self._cache_time = datetime.now()
except (FileNotFoundError, json.JSONDecodeError):
logger.error("Не удалось загрузить конфигурацию групп")
return False
# Проверяем, существует ли группа
if group_name not in self._cache:
logger.warning(f"Группа '{group_name}' не найдена")
return False
# Обновляем группу (обновляем только указанные поля)
old_config = self._cache[group_name].copy()
if chat_id is not None:
self._cache[group_name]["chat_id"] = chat_id
if messenger is not None:
self._cache[group_name]["messenger"] = messenger
if thread_id is not None:
self._cache[group_name]["thread_id"] = thread_id
if config is not None:
self._cache[group_name]["config"] = config
await self._save_config(self._cache)
logger.info(f"Группа '{group_name}' обновлена: {old_config} -> {self._cache[group_name]}")
return True
async def delete_group(self, group_name: str) -> bool:
"""
Удалить группу из конфигурации.
Args:
group_name: Имя группы.
Returns:
True если группа удалена успешно, False если группа не найдена.
"""
# Загружаем текущую конфигурацию
if not self._is_cache_valid():
try:
self._cache = await self._load_config()
self._cache_time = datetime.now()
except (FileNotFoundError, json.JSONDecodeError):
logger.error("Не удалось загрузить конфигурацию групп")
return False
# Проверяем, существует ли группа
if group_name not in self._cache:
logger.warning(f"Группа '{group_name}' не найдена")
return False
# Удаляем группу
del self._cache[group_name]
await self._save_config(self._cache)
logger.info(f"Группа '{group_name}' удалена")
return True
# Глобальный экземпляр менеджера конфигурации (lazy initialization)
_groups_config_instance = None
def get_groups_config() -> GroupsConfig:
"""
Получить экземпляр менеджера конфигурации групп (lazy initialization).
Returns:
Экземпляр GroupsConfig.
"""
global _groups_config_instance
if _groups_config_instance is None:
_groups_config_instance = GroupsConfig()
return _groups_config_instance
# Глобальный экземпляр менеджера конфигурации (lazy initialization)
class _GroupsConfigProxy:
"""Прокси для ленивой инициализации groups_config."""
def __getattr__(self, name):
"""Получить атрибут из groups_config."""
return getattr(get_groups_config(), name)
groups_config = _GroupsConfigProxy()

236
app/core/jira_client.py Normal file
View File

@@ -0,0 +1,236 @@
"""
Клиент для работы с Jira API.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import logging
from typing import Optional, Dict, Any
from jira import JIRA
from jira.exceptions import JIRAError
logger = logging.getLogger(__name__)
class JiraClient:
"""Клиент для работы с Jira API."""
def __init__(
self,
url: str,
email: str,
api_token: str
):
"""
Инициализация клиента Jira.
Args:
url: URL Jira сервера.
email: Email пользователя Jira.
api_token: API токен Jira.
"""
self.url = url.rstrip('/')
self.email = email
self.api_token = api_token
self._client: Optional[JIRA] = None
def get_client(self) -> JIRA:
"""
Получить экземпляр клиента Jira (создается при первом обращении).
Returns:
Экземпляр JIRA клиента.
"""
if self._client is None:
try:
self._client = JIRA(
server=self.url,
basic_auth=(self.email, self.api_token)
)
logger.info(f"Jira клиент подключен к {self.url}")
except JIRAError as e:
logger.error(f"Ошибка подключения к Jira: {e}")
raise
return self._client
def create_issue(
self,
project: str,
summary: str,
description: str,
issue_type: str = "Bug",
assignee: Optional[str] = None,
priority: Optional[str] = None,
labels: Optional[list] = None,
components: Optional[list] = None
) -> Optional[str]:
"""
Создать тикет в Jira.
Args:
project: Ключ проекта Jira.
summary: Заголовок тикета.
description: Описание тикета.
issue_type: Тип задачи.
assignee: Email исполнителя (опционально).
priority: Приоритет задачи (опционально).
labels: Список меток (опционально).
components: Список компонентов (опционально).
Returns:
Ключ созданного тикета (например, "MON-123") или None в случае ошибки.
"""
try:
client = self.get_client()
# Формируем словарь для создания тикета
issue_dict = {
'project': {'key': project},
'summary': summary,
'description': description,
'issuetype': {'name': issue_type}
}
# Добавляем приоритет, если указан
if priority:
issue_dict['priority'] = {'name': priority}
# Добавляем метки, если указаны
if labels:
issue_dict['labels'] = labels
# Добавляем компоненты, если указаны
if components:
issue_dict['components'] = [{'name': comp} for comp in components]
# Создаем тикет
issue = client.create_issue(fields=issue_dict)
# Назначаем исполнителя, если указан
if assignee:
try:
# Пытаемся найти пользователя по email или username
users = client.search_users(query=assignee)
if users:
# Назначаем первого найденного пользователя
user_account_id = users[0].accountId
client.assign_issue(issue, user_account_id)
logger.info(f"Исполнитель {assignee} (accountId: {user_account_id}) назначен на тикет {issue.key}")
else:
logger.warning(f"Пользователь {assignee} не найден в Jira, тикет создан без исполнителя")
except JIRAError as e:
logger.error(f"Ошибка назначения исполнителя {assignee}: {e}")
except Exception as e:
logger.error(f"Неожиданная ошибка при назначении исполнителя: {e}")
logger.info(f"Тикет {issue.key} создан в Jira")
return issue.key
except JIRAError as e:
logger.error(f"Ошибка создания тикета в Jira: {e}")
return None
except Exception as e:
logger.error(f"Неожиданная ошибка при создании тикета: {e}")
return None
def get_issue_url(self, issue_key: str) -> str:
"""
Получить URL тикета в Jira.
Args:
issue_key: Ключ тикета (например, "MON-123").
Returns:
URL тикета в Jira.
"""
return f"{self.url}/browse/{issue_key}"
def update_issue(
self,
issue_key: str,
summary: Optional[str] = None,
description: Optional[str] = None,
assignee: Optional[str] = None,
priority: Optional[str] = None,
labels: Optional[list] = None
) -> bool:
"""
Обновить тикет в Jira.
Args:
issue_key: Ключ тикета.
summary: Новый заголовок (опционально).
description: Новое описание (опционально).
assignee: Новый исполнитель (опционально).
priority: Новый приоритет (опционально).
labels: Новые метки (опционально).
Returns:
True если тикет обновлен успешно, False в противном случае.
"""
try:
client = self.get_client()
issue = client.issue(issue_key)
update_dict = {}
if summary:
update_dict['summary'] = [{'set': summary}]
if description:
update_dict['description'] = [{'set': description}]
if priority:
update_dict['priority'] = [{'set': {'name': priority}}]
if labels:
update_dict['labels'] = [{'set': labels}]
if update_dict:
issue.update(fields=update_dict)
if assignee:
try:
users = client.search_users(query=assignee)
if users:
user_account_id = users[0].accountId
client.assign_issue(issue, user_account_id)
logger.info(f"Исполнитель {assignee} (accountId: {user_account_id}) назначен на тикет {issue_key}")
else:
logger.warning(f"Пользователь {assignee} не найден в Jira")
except JIRAError as e:
logger.error(f"Ошибка назначения исполнителя {assignee}: {e}")
except Exception as e:
logger.error(f"Неожиданная ошибка при назначении исполнителя: {e}")
logger.info(f"Тикет {issue_key} обновлен в Jira")
return True
except JIRAError as e:
logger.error(f"Ошибка обновления тикета {issue_key}: {e}")
return False
except Exception as e:
logger.error(f"Неожиданная ошибка при обновлении тикета: {e}")
return False
def add_comment(self, issue_key: str, comment: str) -> bool:
"""
Добавить комментарий к тикету.
Args:
issue_key: Ключ тикета.
comment: Текст комментария.
Returns:
True если комментарий добавлен успешно, False в противном случае.
"""
try:
client = self.get_client()
issue = client.issue(issue_key)
issue.add_comment(comment)
logger.info(f"Комментарий добавлен к тикету {issue_key}")
return True
except JIRAError as e:
logger.error(f"Ошибка добавления комментария к тикету {issue_key}: {e}")
return False
except Exception as e:
logger.error(f"Неожиданная ошибка при добавлении комментария: {e}")
return False

212
app/core/jira_mapping.py Normal file
View File

@@ -0,0 +1,212 @@
"""
Управление конфигурацией маппинга алертов в Jira тикеты.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import json
import logging
from typing import Dict, Any, Optional, List
import aiofiles
from datetime import datetime, timedelta
from app.models.jira import JiraMappingConfig, JiraSourceMapping, JiraMapping, JiraMappingCondition
from app.core.config import get_settings
logger = logging.getLogger(__name__)
class JiraMappingManager:
"""Менеджер конфигурации маппинга алертов в Jira тикеты с кэшированием."""
def __init__(self, config_path: Optional[str] = None):
"""
Инициализация менеджера конфигурации.
Args:
config_path: Путь к файлу конфигурации маппинга.
"""
settings = get_settings()
self.config_path = config_path or settings.jira_mapping_config_path
self._cache: Optional[JiraMappingConfig] = None
self._cache_time: Optional[datetime] = None
self._cache_ttl = timedelta(minutes=10) # Кэш на 10 минут
async def _load_config(self) -> JiraMappingConfig:
"""
Загрузить конфигурацию маппинга из файла.
Returns:
Конфигурация маппинга алертов в Jira тикеты.
Raises:
FileNotFoundError: Если файл конфигурации не найден.
json.JSONDecodeError: Если файл содержит некорректный JSON.
"""
try:
async with aiofiles.open(self.config_path, 'r', encoding='utf-8') as f:
content = await f.read()
config_dict = json.loads(content)
config = JiraMappingConfig(**config_dict)
logger.info(f"Конфигурация маппинга Jira загружена из {self.config_path}")
return config
except FileNotFoundError:
logger.warning(f"Файл конфигурации маппинга Jira не найден: {self.config_path}")
# Возвращаем пустую конфигурацию
return JiraMappingConfig()
except json.JSONDecodeError as e:
logger.error(f"Ошибка парсинга JSON в файле конфигурации маппинга: {e}")
return JiraMappingConfig()
except Exception as e:
logger.error(f"Ошибка загрузки конфигурации маппинга: {e}")
return JiraMappingConfig()
def _is_cache_valid(self) -> bool:
"""Проверить, валиден ли кэш."""
if self._cache is None or self._cache_time is None:
return False
return datetime.now() - self._cache_time < self._cache_ttl
async def get_config(self) -> JiraMappingConfig:
"""
Получить конфигурацию маппинга (с кэшированием).
Returns:
Конфигурация маппинга алертов в Jira тикеты.
"""
if not self._is_cache_valid():
self._cache = await self._load_config()
self._cache_time = datetime.now()
return self._cache
async def find_mapping(
self,
source: str,
alert_data: Dict[str, Any]
) -> Optional[JiraMapping]:
"""
Найти подходящий маппинг для алерта.
Args:
source: Источник алерта (alertmanager, grafana, zabbix).
alert_data: Данные алерта.
Returns:
Подходящий маппинг или None, если маппинг не найден.
"""
config = await self.get_config()
# Получаем конфигурацию для источника
source_mapping: Optional[JiraSourceMapping] = None
if source == "alertmanager" and config.alertmanager:
source_mapping = config.alertmanager
elif source == "grafana" and config.grafana:
source_mapping = config.grafana
elif source == "zabbix" and config.zabbix:
source_mapping = config.zabbix
if not source_mapping:
return None
# Ищем подходящий маппинг по условиям
for mapping in source_mapping.mappings:
if self._check_conditions(mapping.conditions, alert_data):
return mapping
# Если маппинг не найден, возвращаем дефолтный маппинг
return JiraMapping(
conditions=JiraMappingCondition(),
project=source_mapping.default_project,
assignee=source_mapping.default_assignee,
issue_type=source_mapping.default_issue_type,
priority=source_mapping.default_priority,
labels=[]
)
def _check_conditions(
self,
conditions: JiraMappingCondition,
alert_data: Dict[str, Any]
) -> bool:
"""
Проверить, соответствуют ли данные алерта условиям маппинга.
Args:
conditions: Условия маппинга.
alert_data: Данные алерта.
Returns:
True если условия выполнены, False в противном случае.
"""
# Проверяем severity
if conditions.severity:
if alert_data.get("severity") != conditions.severity:
return False
# Проверяем namespace
if conditions.namespace:
if alert_data.get("namespace") != conditions.namespace:
return False
# Проверяем state
if conditions.state:
if alert_data.get("state") != conditions.state:
return False
# Проверяем status
if conditions.status:
if alert_data.get("status") != conditions.status:
return False
# Проверяем event-severity
if conditions.event_severity:
if alert_data.get("event-severity") != conditions.event_severity:
return False
# Проверяем теги
if conditions.tags:
alert_tags = alert_data.get("tags", {})
for key, value in conditions.tags.items():
if alert_tags.get(key) != value:
return False
return True
async def refresh_cache(self) -> None:
"""Принудительно обновить кэш конфигурации."""
try:
self._cache = await self._load_config()
self._cache_time = datetime.now()
logger.info("Кэш конфигурации маппинга Jira обновлен")
except Exception as e:
logger.error(f"Ошибка обновления кэша: {e}")
# Глобальный экземпляр менеджера конфигурации маппинга (lazy initialization)
_jira_mapping_manager_instance = None
def get_jira_mapping_manager() -> JiraMappingManager:
"""
Получить экземпляр менеджера конфигурации маппинга Jira (lazy initialization).
Returns:
Экземпляр JiraMappingManager.
"""
global _jira_mapping_manager_instance
if _jira_mapping_manager_instance is None:
_jira_mapping_manager_instance = JiraMappingManager()
return _jira_mapping_manager_instance
# Для обратной совместимости (lazy initialization)
class _JiraMappingManagerProxy:
"""Прокси для ленивой инициализации jira_mapping_manager."""
def __getattr__(self, name):
"""Получить атрибут из jira_mapping_manager."""
return getattr(get_jira_mapping_manager(), name)
jira_mapping_manager = _JiraMappingManagerProxy()

330
app/core/jira_utils.py Normal file
View File

@@ -0,0 +1,330 @@
"""
Утилиты для работы с Jira тикетами.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import logging
from typing import Optional, Dict, Any
from jinja2 import Environment, FileSystemLoader
from app.models.alertmanager import PrometheusAlert
from app.models.grafana import GrafanaAlert
from app.models.zabbix import ZabbixAlert
from app.models.jira import JiraMapping
from app.core.jira_client import JiraClient
from app.core.jira_mapping import jira_mapping_manager
from app.core.config import get_settings
from app.core.utils import add_spaces_to_alert_name
logger = logging.getLogger(__name__)
async def create_jira_ticket_from_alert(
alert: Any,
source: str,
k8s_cluster: Optional[str] = None,
mapping: Optional[JiraMapping] = None
) -> Optional[str]:
"""
Создать Jira тикет на основе алерта.
Args:
alert: Данные алерта (PrometheusAlert, GrafanaAlert, ZabbixAlert).
source: Источник алерта (alertmanager, grafana, zabbix).
k8s_cluster: Имя Kubernetes кластера (опционально).
mapping: Маппинг для создания тикета (опционально).
Returns:
Ключ созданного тикета или None в случае ошибки.
"""
from app.core.config import get_settings
settings = get_settings()
if not settings.jira_enabled:
logger.debug("Jira отключен, тикет не создается")
return None
if not settings.jira_url or not settings.jira_email or not settings.jira_api_token:
logger.warning("Jira не настроен (отсутствуют URL, email или API token)")
return None
# Создаем клиент Jira
try:
jira_client = JiraClient(
url=settings.jira_url,
email=settings.jira_email,
api_token=settings.jira_api_token
)
except Exception as e:
logger.error(f"Ошибка создания Jira клиента: {e}")
return None
# Получаем маппинг, если не указан
if not mapping:
alert_data = _extract_alert_data(alert, source, k8s_cluster)
mapping = await jira_mapping_manager.find_mapping(source, alert_data)
if not mapping:
logger.warning(f"Маппинг не найден для источника {source}")
return None
# Формируем данные тикета
summary = _generate_jira_summary(alert, source, mapping)
description = _generate_jira_description(alert, source, k8s_cluster, mapping)
# Создаем тикет
try:
issue_key = jira_client.create_issue(
project=mapping.project,
summary=summary,
description=description,
issue_type=mapping.issue_type,
assignee=mapping.assignee,
priority=mapping.priority,
labels=mapping.labels
)
if issue_key:
logger.info(f"Jira тикет {issue_key} создан для алерта из {source}")
# Увеличиваем счетчик созданных тикетов
from app.core.metrics import metrics
metrics.increment_jira_ticket_created(
source=source,
project=mapping.project,
k8s_cluster=k8s_cluster or "",
chat="",
thread=0
)
return issue_key
except Exception as e:
logger.error(f"Ошибка создания Jira тикета: {e}")
# Увеличиваем счетчик ошибок
from app.core.metrics import metrics
metrics.increment_jira_ticket_error(
source=source,
k8s_cluster=k8s_cluster or "",
chat="",
thread=0
)
return None
def _extract_alert_data(
alert: Any,
source: str,
k8s_cluster: Optional[str] = None
) -> Dict[str, Any]:
"""
Извлечь данные алерта для маппинга.
Args:
alert: Данные алерта.
source: Источник алерта.
k8s_cluster: Имя Kubernetes кластера.
Returns:
Словарь с данными алерта.
"""
alert_data = {}
if source == "alertmanager" and isinstance(alert, PrometheusAlert):
alert_data["status"] = alert.status
alert_data.update(alert.commonLabels)
alert_data.update(alert.commonAnnotations)
if k8s_cluster:
alert_data["k8s_cluster"] = k8s_cluster
elif source == "grafana" and isinstance(alert, GrafanaAlert):
alert_data["state"] = alert.state
alert_data["tags"] = alert.tags
alert_data["ruleName"] = alert.ruleName
elif source == "zabbix" and isinstance(alert, ZabbixAlert):
alert_data["status"] = alert.status
alert_data["event-severity"] = alert.event_severity or ""
alert_data["event-name"] = alert.event_name
alert_data["host-name"] = alert.host_name
return alert_data
def _generate_jira_summary(
alert: Any,
source: str,
mapping: JiraMapping
) -> str:
"""
Сгенерировать заголовок Jira тикета.
Args:
alert: Данные алерта.
source: Источник алерта.
mapping: Маппинг для создания тикета.
Returns:
Заголовок тикета.
"""
severity_prefix = ""
alert_name = ""
if source == "alertmanager" and isinstance(alert, PrometheusAlert):
severity = alert.commonLabels.get("severity", "")
alert_name = alert.commonLabels.get("alertname", "")
if severity:
severity_prefix = f"[{severity.upper()}] "
if alert_name:
alert_name = add_spaces_to_alert_name(alert_name)
summary = alert.commonAnnotations.get("summary", alert_name)
elif source == "grafana" and isinstance(alert, GrafanaAlert):
alert_name = alert.ruleName
if alert.state == "alerting":
severity_prefix = "[ALERTING] "
summary = alert.title or alert_name
elif source == "zabbix" and isinstance(alert, ZabbixAlert):
severity = alert.event_severity or ""
alert_name = alert.event_name
if severity:
severity_prefix = f"[{severity.upper()}] "
summary = alert.alert_subject or alert_name
else:
summary = "Unknown Alert"
return f"{severity_prefix}{alert_name}: {summary}"[:255] # Ограничение Jira
def _generate_jira_description(
alert: Any,
source: str,
k8s_cluster: Optional[str] = None,
mapping: Optional[JiraMapping] = None
) -> str:
"""
Сгенерировать описание Jira тикета.
Args:
alert: Данные алерта.
source: Источник алерта.
k8s_cluster: Имя Kubernetes кластера.
mapping: Маппинг для создания тикета.
Returns:
Описание тикета в формате Markdown.
"""
from app.core.config import get_settings
settings = get_settings()
# Загружаем шаблон описания
try:
environment = Environment(loader=FileSystemLoader(settings.templates_path))
template_name = f"jira_{source}.tmpl"
try:
template = environment.get_template(template_name)
except Exception:
# Если шаблон не найден, используем общий шаблон
template = environment.get_template("jira_common.tmpl")
except Exception:
# Если общий шаблон не найден, используем простое описание
return _generate_simple_description(alert, source, k8s_cluster)
# Формируем словарь для шаблона
template_data = _prepare_template_data(alert, source, k8s_cluster)
# Рендерим шаблон
description = template.render(template_data)
return description
def _generate_simple_description(
alert: Any,
source: str,
k8s_cluster: Optional[str] = None
) -> str:
"""
Сгенерировать простое описание тикета без шаблона.
Args:
alert: Данные алерта.
source: Источник алерта.
k8s_cluster: Имя Kubernetes кластера.
Returns:
Простое описание тикета.
"""
description = f"**Источник:** {source}\n\n"
if source == "alertmanager" and isinstance(alert, PrometheusAlert):
description += f"**Статус:** {alert.status}\n\n"
description += "**Метки:**\n"
for key, value in alert.commonLabels.items():
description += f"- {key}: {value}\n"
description += "\n**Аннотации:**\n"
for key, value in alert.commonAnnotations.items():
description += f"- {key}: {value}\n"
if k8s_cluster:
description += f"\n**Kubernetes кластер:** {k8s_cluster}\n"
elif source == "grafana" and isinstance(alert, GrafanaAlert):
description += f"**Состояние:** {alert.state}\n\n"
description += f"**Правило:** {alert.ruleName}\n\n"
description += f"**Сообщение:** {alert.message or 'Нет сообщения'}\n\n"
if alert.tags:
description += "**Теги:**\n"
for key, value in alert.tags.items():
description += f"- {key}: {value}\n"
elif source == "zabbix" and isinstance(alert, ZabbixAlert):
description += f"**Статус:** {alert.status}\n\n"
description += f"**Серьезность:** {alert.event_severity or 'Unknown'}\n\n"
description += f"**Событие:** {alert.event_name}\n\n"
description += f"**Хост:** {alert.host_name} ({alert.host_ip})\n\n"
description += f"**Сообщение:** {alert.alert_message}\n"
return description
def _prepare_template_data(
alert: Any,
source: str,
k8s_cluster: Optional[str] = None
) -> Dict[str, Any]:
"""
Подготовить данные для шаблона описания тикета.
Args:
alert: Данные алерта.
source: Источник алерта.
k8s_cluster: Имя Kubernetes кластера.
Returns:
Словарь с данными для шаблона.
"""
template_data = {
"source": source,
"k8s_cluster": k8s_cluster or "",
}
if source == "alertmanager" and isinstance(alert, PrometheusAlert):
template_data["status"] = alert.status
template_data["common_labels"] = alert.commonLabels
template_data["common_annotations"] = alert.commonAnnotations
template_data["alertname"] = alert.commonLabels.get("alertname", "")
template_data["severity"] = alert.commonLabels.get("severity", "")
template_data["summary"] = alert.commonAnnotations.get("summary", "")
template_data["description"] = alert.commonAnnotations.get("description", "")
elif source == "grafana" and isinstance(alert, GrafanaAlert):
template_data["state"] = alert.state
template_data["title"] = alert.title
template_data["ruleName"] = alert.ruleName
template_data["message"] = alert.message or ""
template_data["tags"] = alert.tags
template_data["evalMatches"] = alert.evalMatches
elif source == "zabbix" and isinstance(alert, ZabbixAlert):
template_data["status"] = alert.status
template_data["event_severity"] = alert.event_severity or ""
template_data["event_name"] = alert.event_name
template_data["alert_subject"] = alert.alert_subject
template_data["alert_message"] = alert.alert_message
template_data["host_name"] = alert.host_name
template_data["host_ip"] = alert.host_ip
template_data["host_port"] = alert.host_port
return template_data

View File

@@ -0,0 +1,102 @@
"""
Фабрика для создания клиентов мессенджеров.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import logging
from typing import Optional, Dict, Any
from app.core.messengers.base import MessengerClient
from app.core.messengers.telegram import TelegramMessengerClient
from app.core.messengers.max import MaxMessengerClient
from app.core.config import get_settings
logger = logging.getLogger(__name__)
class MessengerFactory:
"""Фабрика для создания клиентов мессенджеров."""
@staticmethod
def create(messenger_type: str, **kwargs) -> MessengerClient:
"""
Создать клиент мессенджера.
Args:
messenger_type: Тип мессенджера (telegram, max).
**kwargs: Дополнительные параметры для инициализации клиента.
Returns:
Экземпляр MessengerClient.
Raises:
ValueError: Если тип мессенджера неизвестен.
"""
messenger_type = messenger_type.lower()
if messenger_type == "telegram":
bot_token = kwargs.get("bot_token")
return TelegramMessengerClient(bot_token=bot_token)
elif messenger_type == "max":
access_token = kwargs.get("access_token")
api_version = kwargs.get("api_version", "5.131")
return MaxMessengerClient(access_token=access_token, api_version=api_version)
else:
raise ValueError(f"Неизвестный тип мессенджера: {messenger_type}")
@staticmethod
def create_from_config(group_config: Dict[str, Any]) -> MessengerClient:
"""
Создать клиент мессенджера из конфигурации группы.
Args:
group_config: Конфигурация группы с полями:
- messenger: Тип мессенджера (telegram, max)
- config: Дополнительная конфигурация для мессенджера
Returns:
Экземпляр MessengerClient.
"""
messenger_type = group_config.get("messenger", "telegram")
config = group_config.get("config", {})
# Если конфигурация не указана, используем настройки из переменных окружения
settings = get_settings()
if messenger_type == "telegram":
bot_token = config.get("bot_token") or settings.telegram_bot_token
return TelegramMessengerClient(bot_token=bot_token)
elif messenger_type == "max":
access_token = config.get("access_token") or settings.max_access_token
api_version = config.get("api_version", settings.max_api_version or "5.131")
return MaxMessengerClient(access_token=access_token, api_version=api_version)
else:
raise ValueError(f"Неизвестный тип мессенджера: {messenger_type}")
# Глобальный кэш клиентов мессенджеров
_messenger_clients_cache: Dict[str, MessengerClient] = {}
def get_messenger_client(messenger_type: str, **kwargs) -> MessengerClient:
"""
Получить клиент мессенджера (с кэшированием).
Args:
messenger_type: Тип мессенджера (telegram, max).
**kwargs: Дополнительные параметры для инициализации клиента.
Returns:
Экземпляр MessengerClient.
"""
# Для Telegram используем глобальный экземпляр, если bot_token не указан
if messenger_type == "telegram" and "bot_token" not in kwargs:
cache_key = "telegram_default"
if cache_key not in _messenger_clients_cache:
_messenger_clients_cache[cache_key] = MessengerFactory.create(messenger_type, **kwargs)
return _messenger_clients_cache[cache_key]
# Для других мессенджеров создаем новый экземпляр
return MessengerFactory.create(messenger_type, **kwargs)

View File

@@ -0,0 +1,16 @@
"""
Модуль для работы с различными мессенджерами.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from app.core.messengers.base import MessengerClient
from app.core.messengers.telegram import TelegramMessengerClient
from app.core.messengers.max import MaxMessengerClient
__all__ = [
"MessengerClient",
"TelegramMessengerClient",
"MaxMessengerClient",
]

174
app/core/messengers/base.py Normal file
View File

@@ -0,0 +1,174 @@
"""
Базовый абстрактный класс для работы с мессенджерами.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import logging
from abc import ABC, abstractmethod
from typing import Optional, Union, Dict, Any
logger = logging.getLogger(__name__)
class MessengerClient(ABC):
"""Базовый абстрактный класс для всех мессенджеров."""
@abstractmethod
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:
"""
Отправить текстовое сообщение.
Args:
chat_id: ID чата или группы (может быть строкой или числом).
text: Текст сообщения.
thread_id: ID треда в группе (опционально, не все мессенджеры поддерживают).
reply_markup: Клавиатура с кнопками (опционально, формат зависит от мессенджера).
disable_web_page_preview: Отключить превью ссылок.
parse_mode: Режим парсинга (HTML, Markdown, и т.д.).
**kwargs: Дополнительные параметры для конкретного мессенджера.
Returns:
True если сообщение отправлено успешно, False в противном случае.
"""
pass
@abstractmethod
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:
"""
Отправить фото.
Args:
chat_id: ID чата или группы.
photo: Путь к файлу, URL, bytes или BytesIO объект с фото.
caption: Подпись к фото (опционально).
thread_id: ID треда в группе (опционально).
parse_mode: Режим парсинга (HTML, Markdown).
**kwargs: Дополнительные параметры.
Returns:
True если фото отправлено успешно, False в противном случае.
"""
pass
@abstractmethod
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:
"""
Отправить видео.
Args:
chat_id: ID чата или группы.
video: Путь к файлу, URL, bytes или BytesIO объект с видео.
caption: Подпись к видео (опционально).
thread_id: ID треда в группе (опционально).
parse_mode: Режим парсинга (HTML, Markdown).
duration: Длительность видео в секундах (опционально).
width: Ширина видео (опционально).
height: Высота видео (опционально).
**kwargs: Дополнительные параметры.
Returns:
True если видео отправлено успешно, False в противном случае.
"""
pass
@abstractmethod
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:
"""
Отправить аудио.
Args:
chat_id: ID чата или группы.
audio: Путь к файлу, URL, bytes или BytesIO объект с аудио.
caption: Подпись к аудио (опционально).
thread_id: ID треда в группе (опционально).
parse_mode: Режим парсинга (HTML, Markdown).
duration: Длительность аудио в секундах (опционально).
performer: Исполнитель (опционально).
title: Название трека (опционально).
**kwargs: Дополнительные параметры.
Returns:
True если аудио отправлено успешно, False в противном случае.
"""
pass
@abstractmethod
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:
"""
Отправить документ.
Args:
chat_id: ID чата или группы.
document: Путь к файлу, URL, bytes или BytesIO объект с документом.
caption: Подпись к документу (опционально).
thread_id: ID треда в группе (опционально).
parse_mode: Режим парсинга (HTML, Markdown).
filename: Имя файла (опционально).
**kwargs: Дополнительные параметры.
Returns:
True если документ отправлен успешно, False в противном случае.
"""
pass
@property
@abstractmethod
def messenger_type(self) -> str:
"""Тип мессенджера (telegram, max, и т.д.)."""
pass
@property
@abstractmethod
def supports_threads(self) -> bool:
"""Поддерживает ли мессенджер треды."""
pass

476
app/core/messengers/max.py Normal file
View 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

View File

@@ -0,0 +1,255 @@
"""
Адаптер для работы с Telegram через MessengerClient интерфейс.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import logging
import io
from typing import Optional, Union, Dict, Any
from telegram import InlineKeyboardMarkup
from app.core.messengers.base import MessengerClient
from app.core.telegram_client import TelegramClient
from app.core.button_utils import convert_dict_to_telegram_buttons
logger = logging.getLogger(__name__)
class TelegramMessengerClient(MessengerClient):
"""Адаптер для Telegram, реализующий интерфейс MessengerClient."""
def __init__(self, bot_token: Optional[str] = None):
"""
Инициализация клиента Telegram.
Args:
bot_token: Токен бота Telegram. Если не указан, используется из настроек.
"""
self._client = TelegramClient(bot_token=bot_token)
@property
def messenger_type(self) -> str:
"""Тип мессенджера."""
return "telegram"
@property
def supports_threads(self) -> bool:
"""Telegram поддерживает треды."""
return True
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:
"""
Отправить текстовое сообщение в Telegram.
Args:
chat_id: ID чата или группы (преобразуется в int).
text: Текст сообщения.
thread_id: ID треда в группе (опционально).
reply_markup: Клавиатура с кнопками (опционально).
disable_web_page_preview: Отключить превью ссылок.
parse_mode: Режим парсинга (HTML, Markdown).
**kwargs: Дополнительные параметры (игнорируются).
Returns:
True если сообщение отправлено успешно, False в противном случае.
"""
# Преобразуем chat_id в int
chat_id_int = int(chat_id) if isinstance(chat_id, str) else chat_id
# Преобразуем reply_markup из Dict в InlineKeyboardMarkup, если нужно
telegram_reply_markup = None
if reply_markup:
if isinstance(reply_markup, InlineKeyboardMarkup):
telegram_reply_markup = reply_markup
elif isinstance(reply_markup, dict):
# Преобразуем словарь в InlineKeyboardMarkup
telegram_reply_markup = convert_dict_to_telegram_buttons(reply_markup)
return await self._client.send_message(
chat_id=chat_id_int,
text=text,
message_thread_id=thread_id,
reply_markup=telegram_reply_markup,
disable_web_page_preview=disable_web_page_preview,
parse_mode=parse_mode
)
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:
"""
Отправить фото в Telegram.
Args:
chat_id: ID чата или группы (преобразуется в int).
photo: Путь к файлу, URL, bytes или BytesIO объект с фото.
caption: Подпись к фото (опционально).
thread_id: ID треда в группе (опционально).
parse_mode: Режим парсинга (HTML, Markdown).
**kwargs: Дополнительные параметры (игнорируются).
Returns:
True если фото отправлено успешно, False в противном случае.
"""
chat_id_int = int(chat_id) if isinstance(chat_id, str) else chat_id
# Преобразуем bytes в BytesIO, если нужно
if isinstance(photo, bytes):
photo = io.BytesIO(photo)
return await self._client.send_photo(
chat_id=chat_id_int,
photo=photo,
caption=caption,
message_thread_id=thread_id,
parse_mode=parse_mode
)
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:
"""
Отправить видео в Telegram.
Args:
chat_id: ID чата или группы (преобразуется в int).
video: Путь к файлу, URL, bytes или BytesIO объект с видео.
caption: Подпись к видео (опционально).
thread_id: ID треда в группе (опционально).
parse_mode: Режим парсинга (HTML, Markdown).
duration: Длительность видео в секундах (опционально).
width: Ширина видео (опционально).
height: Высота видео (опционально).
**kwargs: Дополнительные параметры (игнорируются).
Returns:
True если видео отправлено успешно, False в противном случае.
"""
chat_id_int = int(chat_id) if isinstance(chat_id, str) else chat_id
# Преобразуем bytes в BytesIO, если нужно
if isinstance(video, bytes):
video = io.BytesIO(video)
return await self._client.send_video(
chat_id=chat_id_int,
video=video,
caption=caption,
message_thread_id=thread_id,
parse_mode=parse_mode,
duration=duration,
width=width,
height=height
)
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:
"""
Отправить аудио в Telegram.
Args:
chat_id: ID чата или группы (преобразуется в int).
audio: Путь к файлу, URL, bytes или BytesIO объект с аудио.
caption: Подпись к аудио (опционально).
thread_id: ID треда в группе (опционально).
parse_mode: Режим парсинга (HTML, Markdown).
duration: Длительность аудио в секундах (опционально).
performer: Исполнитель (опционально).
title: Название трека (опционально).
**kwargs: Дополнительные параметры (игнорируются).
Returns:
True если аудио отправлено успешно, False в противном случае.
"""
chat_id_int = int(chat_id) if isinstance(chat_id, str) else chat_id
# Преобразуем bytes в BytesIO, если нужно
if isinstance(audio, bytes):
audio = io.BytesIO(audio)
return await self._client.send_audio(
chat_id=chat_id_int,
audio=audio,
caption=caption,
message_thread_id=thread_id,
parse_mode=parse_mode,
duration=duration,
performer=performer,
title=title
)
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:
"""
Отправить документ в Telegram.
Args:
chat_id: ID чата или группы (преобразуется в int).
document: Путь к файлу, URL, bytes или BytesIO объект с документом.
caption: Подпись к документу (опционально).
thread_id: ID треда в группе (опционально).
parse_mode: Режим парсинга (HTML, Markdown).
filename: Имя файла (опционально).
**kwargs: Дополнительные параметры (игнорируются).
Returns:
True если документ отправлен успешно, False в противном случае.
"""
chat_id_int = int(chat_id) if isinstance(chat_id, str) else chat_id
# Преобразуем bytes в BytesIO, если нужно
if isinstance(document, bytes):
document = io.BytesIO(document)
return await self._client.send_document(
chat_id=chat_id_int,
document=document,
caption=caption,
message_thread_id=thread_id,
parse_mode=parse_mode,
filename=filename
)

316
app/core/metrics.py Normal file
View File

@@ -0,0 +1,316 @@
"""
Централизованное управление метриками Prometheus.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import logging
from typing import Optional
from prometheus_client import Counter, CollectorRegistry, push_to_gateway
from functools import lru_cache
from app.core.config import settings
logger = logging.getLogger(__name__)
class MetricsManager:
"""Менеджер метрик Prometheus."""
def __init__(self):
"""Инициализация менеджера метрик."""
self.registry = CollectorRegistry()
self._init_metrics()
def _init_metrics(self) -> None:
"""Инициализация всех метрик."""
# API эндпоинты
self.api_endpoint_count = Counter(
'tg_monitoring_gateway_api_endpoint_total',
'Общее количество обращений к эндпоинтам API',
labelnames=['endpoint'],
registry=self.registry
)
# Сообщения по источникам
self.total_messages = Counter(
'tg_monitoring_gateway_total_messages',
'Всего сообщений получено',
labelnames=['source', 'k8s_cluster', 'chat', 'thread'],
registry=self.registry
)
self.sent_messages = Counter(
'tg_monitoring_gateway_sent_messages',
'Сообщений успешно отправлено',
labelnames=['source', 'k8s_cluster', 'chat', 'thread'],
registry=self.registry
)
self.reject_messages = Counter(
'tg_monitoring_gateway_reject_messages',
'Сообщений отклонено (стоп-слова)',
labelnames=['source', 'k8s_cluster', 'chat', 'thread'],
registry=self.registry
)
self.error_messages = Counter(
'tg_monitoring_gateway_error_messages',
'Ошибок отправки сообщений',
labelnames=['source', 'k8s_cluster', 'chat', 'thread'],
registry=self.registry
)
self.firing_messages = Counter(
'tg_monitoring_gateway_firing_messages',
'Горящих алертов',
labelnames=['source', 'k8s_cluster', 'chat', 'thread'],
registry=self.registry
)
self.critical_messages = Counter(
'tg_monitoring_gateway_critical_messages',
'Критических алертов',
labelnames=['source', 'k8s_cluster', 'chat', 'thread'],
registry=self.registry
)
self.resolved_messages = Counter(
'tg_monitoring_gateway_resolved_messages',
'Исправленных алертов',
labelnames=['source', 'k8s_cluster', 'chat', 'thread'],
registry=self.registry
)
# Jira метрики
self.jira_tickets_created = Counter(
'tg_monitoring_gateway_jira_tickets_created',
'Jira тикетов создано',
labelnames=['source', 'k8s_cluster', 'chat', 'thread', 'project'],
registry=self.registry
)
self.jira_tickets_errors = Counter(
'tg_monitoring_gateway_jira_tickets_errors',
'Ошибок создания Jira тикетов',
labelnames=['source', 'k8s_cluster', 'chat', 'thread'],
registry=self.registry
)
def increment_api_endpoint(self, endpoint: str) -> None:
"""
Увеличить счетчик обращений к эндпоинту API.
Args:
endpoint: Имя эндпоинта.
"""
self.api_endpoint_count.labels(endpoint=endpoint).inc()
self._push_metrics()
def increment_total_message(
self,
source: str,
k8s_cluster: Optional[str] = None,
chat: Optional[str] = None,
thread: Optional[int] = None
) -> None:
"""
Увеличить счетчик полученных сообщений.
Args:
source: Источник сообщения (grafana, zabbix, alertmanager).
k8s_cluster: Имя Kubernetes кластера (опционально).
chat: Имя чата (опционально).
thread: ID треда (опционально).
"""
k8s_cluster = k8s_cluster or ""
chat = chat or ""
thread = thread or 0
self.total_messages.labels(
source=source,
k8s_cluster=k8s_cluster,
chat=chat,
thread=thread
).inc()
self._push_metrics()
def increment_sent_message(
self,
source: str,
k8s_cluster: Optional[str] = None,
chat: Optional[str] = None,
thread: Optional[int] = None
) -> None:
"""Увеличить счетчик отправленных сообщений."""
k8s_cluster = k8s_cluster or ""
chat = chat or ""
thread = thread or 0
self.sent_messages.labels(
source=source,
k8s_cluster=k8s_cluster,
chat=chat,
thread=thread
).inc()
self._push_metrics()
def increment_reject_message(
self,
source: str,
k8s_cluster: Optional[str] = None,
chat: Optional[str] = None,
thread: Optional[int] = None
) -> None:
"""Увеличить счетчик отклоненных сообщений."""
k8s_cluster = k8s_cluster or ""
chat = chat or ""
thread = thread or 0
self.reject_messages.labels(
source=source,
k8s_cluster=k8s_cluster,
chat=chat,
thread=thread
).inc()
self._push_metrics()
def increment_error_message(
self,
source: str,
k8s_cluster: Optional[str] = None,
chat: Optional[str] = None,
thread: Optional[int] = None
) -> None:
"""Увеличить счетчик ошибок отправки."""
k8s_cluster = k8s_cluster or ""
chat = chat or ""
thread = thread or 0
self.error_messages.labels(
source=source,
k8s_cluster=k8s_cluster,
chat=chat,
thread=thread
).inc()
self._push_metrics()
def increment_firing_message(
self,
source: str,
k8s_cluster: Optional[str] = None,
chat: Optional[str] = None,
thread: Optional[int] = None
) -> None:
"""Увеличить счетчик горящих алертов."""
k8s_cluster = k8s_cluster or ""
chat = chat or ""
thread = thread or 0
self.firing_messages.labels(
source=source,
k8s_cluster=k8s_cluster,
chat=chat,
thread=thread
).inc()
self._push_metrics()
def increment_critical_message(
self,
source: str,
k8s_cluster: Optional[str] = None,
chat: Optional[str] = None,
thread: Optional[int] = None
) -> None:
"""Увеличить счетчик критических алертов."""
k8s_cluster = k8s_cluster or ""
chat = chat or ""
thread = thread or 0
self.critical_messages.labels(
source=source,
k8s_cluster=k8s_cluster,
chat=chat,
thread=thread
).inc()
self._push_metrics()
def increment_resolved_message(
self,
source: str,
k8s_cluster: Optional[str] = None,
chat: Optional[str] = None,
thread: Optional[int] = None
) -> None:
"""Увеличить счетчик исправленных алертов."""
k8s_cluster = k8s_cluster or ""
chat = chat or ""
thread = thread or 0
self.resolved_messages.labels(
source=source,
k8s_cluster=k8s_cluster,
chat=chat,
thread=thread
).inc()
self._push_metrics()
def increment_jira_ticket_created(
self,
source: str,
project: str,
k8s_cluster: Optional[str] = None,
chat: Optional[str] = None,
thread: Optional[int] = None
) -> None:
"""Увеличить счетчик созданных Jira тикетов."""
k8s_cluster = k8s_cluster or ""
chat = chat or ""
thread = thread or 0
self.jira_tickets_created.labels(
source=source,
k8s_cluster=k8s_cluster,
chat=chat,
thread=thread,
project=project
).inc()
self._push_metrics()
def increment_jira_ticket_error(
self,
source: str,
k8s_cluster: Optional[str] = None,
chat: Optional[str] = None,
thread: Optional[int] = None
) -> None:
"""Увеличить счетчик ошибок создания Jira тикетов."""
k8s_cluster = k8s_cluster or ""
chat = chat or ""
thread = thread or 0
self.jira_tickets_errors.labels(
source=source,
k8s_cluster=k8s_cluster,
chat=chat,
thread=thread
).inc()
self._push_metrics()
def _push_metrics(self) -> None:
"""Отправить метрики в Pushgateway."""
from app.core.config import get_settings
settings = get_settings()
if not settings.pushgateway_url:
return
try:
push_to_gateway(
settings.pushgateway_url,
job=settings.pushgateway_job,
registry=self.registry
)
except Exception as e:
logger.error(f"Ошибка отправки метрик в Pushgateway: {e}")
# Глобальный экземпляр менеджера метрик
@lru_cache(maxsize=1)
def get_metrics_manager() -> MetricsManager:
"""Получить глобальный экземпляр менеджера метрик."""
return MetricsManager()
metrics = get_metrics_manager()

345
app/core/telegram_client.py Normal file
View File

@@ -0,0 +1,345 @@
"""
Клиент для работы с Telegram Bot API.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import logging
import io
from typing import Optional, Union
from telegram import Bot, InlineKeyboardMarkup
from telegram.error import TelegramError
import httpx
logger = logging.getLogger(__name__)
class TelegramClient:
"""Клиент для отправки сообщений в Telegram."""
def __init__(self, bot_token: Optional[str] = None):
"""
Инициализация клиента Telegram.
Args:
bot_token: Токен бота Telegram. Если не указан, используется из настроек.
Raises:
ValueError: Если токен не указан.
"""
# Импортируем settings здесь, чтобы избежать циклических зависимостей
from app.core.config import get_settings
settings = get_settings()
self.bot_token = bot_token or settings.telegram_bot_token
if not self.bot_token:
raise ValueError("TELEGRAM_BOT_TOKEN не установлен")
self._bot: Optional[Bot] = None
async def get_bot(self) -> Bot:
"""
Получить экземпляр бота (создается при первом обращении).
Returns:
Экземпляр Bot.
"""
if self._bot is None:
self._bot = Bot(token=self.bot_token)
return self._bot
async def send_message(
self,
chat_id: int,
text: str,
message_thread_id: Optional[int] = None,
reply_markup: Optional[InlineKeyboardMarkup] = None,
disable_web_page_preview: bool = True,
parse_mode: str = "HTML"
) -> bool:
"""
Отправить сообщение в Telegram.
Args:
chat_id: ID чата или группы.
text: Текст сообщения.
message_thread_id: ID треда в группе (опционально).
reply_markup: Клавиатура с кнопками (опционально).
disable_web_page_preview: Отключить превью ссылок.
parse_mode: Режим парсинга (HTML, Markdown).
Returns:
True если сообщение отправлено успешно, False в противном случае.
"""
try:
bot = await self.get_bot()
await bot.send_message(
chat_id=chat_id,
text=text,
message_thread_id=message_thread_id,
disable_web_page_preview=disable_web_page_preview,
parse_mode=parse_mode,
reply_markup=reply_markup
)
logger.info(f"Сообщение отправлено в чат {chat_id}, тред {message_thread_id}")
return True
except TelegramError as e:
logger.error(f"Ошибка отправки сообщения в Telegram: {e}")
return False
except Exception as e:
logger.error(f"Неожиданная ошибка при отправке сообщения: {e}")
return False
async def send_photo(
self,
chat_id: int,
photo: Union[str, bytes, io.BytesIO],
caption: Optional[str] = None,
message_thread_id: Optional[int] = None,
parse_mode: str = "HTML"
) -> bool:
"""
Отправить фото в Telegram.
Args:
chat_id: ID чата или группы.
photo: Путь к файлу, URL, bytes или BytesIO объект с фото.
caption: Подпись к фото (опционально).
message_thread_id: ID треда в группе (опционально).
parse_mode: Режим парсинга (HTML, Markdown).
Returns:
True если фото отправлено успешно, False в противном случае.
"""
try:
bot = await self.get_bot()
# Если photo - это URL, скачиваем файл
if isinstance(photo, str) and (photo.startswith('http://') or photo.startswith('https://')):
async with httpx.AsyncClient() as client:
response = await client.get(photo)
photo = io.BytesIO(response.content)
# Если photo - это bytes, преобразуем в BytesIO
if isinstance(photo, bytes):
photo = io.BytesIO(photo)
await bot.send_photo(
chat_id=chat_id,
photo=photo,
caption=caption,
message_thread_id=message_thread_id,
parse_mode=parse_mode
)
logger.info(f"Фото отправлено в чат {chat_id}, тред {message_thread_id}")
return True
except TelegramError as e:
logger.error(f"Ошибка отправки фото в Telegram: {e}")
return False
except Exception as e:
logger.error(f"Неожиданная ошибка при отправке фото: {e}")
return False
async def send_video(
self,
chat_id: int,
video: Union[str, bytes, io.BytesIO],
caption: Optional[str] = None,
message_thread_id: Optional[int] = None,
parse_mode: str = "HTML",
duration: Optional[int] = None,
width: Optional[int] = None,
height: Optional[int] = None
) -> bool:
"""
Отправить видео в Telegram.
Args:
chat_id: ID чата или группы.
video: Путь к файлу, URL, bytes или BytesIO объект с видео.
caption: Подпись к видео (опционально).
message_thread_id: ID треда в группе (опционально).
parse_mode: Режим парсинга (HTML, Markdown).
duration: Длительность видео в секундах (опционально).
width: Ширина видео (опционально).
height: Высота видео (опционально).
Returns:
True если видео отправлено успешно, False в противном случае.
"""
try:
bot = await self.get_bot()
# Если video - это URL, скачиваем файл
if isinstance(video, str) and (video.startswith('http://') or video.startswith('https://')):
async with httpx.AsyncClient() as client:
response = await client.get(video)
video = io.BytesIO(response.content)
# Если video - это bytes, преобразуем в BytesIO
if isinstance(video, bytes):
video = io.BytesIO(video)
await bot.send_video(
chat_id=chat_id,
video=video,
caption=caption,
message_thread_id=message_thread_id,
parse_mode=parse_mode,
duration=duration,
width=width,
height=height
)
logger.info(f"Видео отправлено в чат {chat_id}, тред {message_thread_id}")
return True
except TelegramError as e:
logger.error(f"Ошибка отправки видео в Telegram: {e}")
return False
except Exception as e:
logger.error(f"Неожиданная ошибка при отправке видео: {e}")
return False
async def send_audio(
self,
chat_id: int,
audio: Union[str, bytes, io.BytesIO],
caption: Optional[str] = None,
message_thread_id: Optional[int] = None,
parse_mode: str = "HTML",
duration: Optional[int] = None,
performer: Optional[str] = None,
title: Optional[str] = None
) -> bool:
"""
Отправить аудио в Telegram.
Args:
chat_id: ID чата или группы.
audio: Путь к файлу, URL, bytes или BytesIO объект с аудио.
caption: Подпись к аудио (опционально).
message_thread_id: ID треда в группе (опционально).
parse_mode: Режим парсинга (HTML, Markdown).
duration: Длительность аудио в секундах (опционально).
performer: Исполнитель (опционально).
title: Название трека (опционально).
Returns:
True если аудио отправлено успешно, False в противном случае.
"""
try:
bot = await self.get_bot()
# Если audio - это URL, скачиваем файл
if isinstance(audio, str) and (audio.startswith('http://') or audio.startswith('https://')):
async with httpx.AsyncClient() as client:
response = await client.get(audio)
audio = io.BytesIO(response.content)
# Если audio - это bytes, преобразуем в BytesIO
if isinstance(audio, bytes):
audio = io.BytesIO(audio)
await bot.send_audio(
chat_id=chat_id,
audio=audio,
caption=caption,
message_thread_id=message_thread_id,
parse_mode=parse_mode,
duration=duration,
performer=performer,
title=title
)
logger.info(f"Аудио отправлено в чат {chat_id}, тред {message_thread_id}")
return True
except TelegramError as e:
logger.error(f"Ошибка отправки аудио в Telegram: {e}")
return False
except Exception as e:
logger.error(f"Неожиданная ошибка при отправке аудио: {e}")
return False
async def send_document(
self,
chat_id: int,
document: Union[str, bytes, io.BytesIO],
caption: Optional[str] = None,
message_thread_id: Optional[int] = None,
parse_mode: str = "HTML",
filename: Optional[str] = None
) -> bool:
"""
Отправить документ в Telegram.
Args:
chat_id: ID чата или группы.
document: Путь к файлу, URL, bytes или BytesIO объект с документом.
caption: Подпись к документу (опционально).
message_thread_id: ID треда в группе (опционально).
parse_mode: Режим парсинга (HTML, Markdown).
filename: Имя файла (опционально).
Returns:
True если документ отправлен успешно, False в противном случае.
"""
try:
bot = await self.get_bot()
document_url = None
# Если document - это URL, скачиваем файл
if isinstance(document, str) and (document.startswith('http://') or document.startswith('https://')):
document_url = document
async with httpx.AsyncClient() as client:
response = await client.get(document)
document = io.BytesIO(response.content)
if not filename:
# Пытаемся извлечь имя файла из URL
filename = document_url.split('/')[-1].split('?')[0]
# Если document - это bytes, преобразуем в BytesIO
if isinstance(document, bytes):
document = io.BytesIO(document)
await bot.send_document(
chat_id=chat_id,
document=document,
caption=caption,
message_thread_id=message_thread_id,
parse_mode=parse_mode,
filename=filename
)
logger.info(f"Документ отправлен в чат {chat_id}, тред {message_thread_id}")
return True
except TelegramError as e:
logger.error(f"Ошибка отправки документа в Telegram: {e}")
return False
except Exception as e:
logger.error(f"Неожиданная ошибка при отправке документа: {e}")
return False
# Глобальный экземпляр клиента (lazy initialization)
_telegram_client_instance: Optional[TelegramClient] = None
def get_telegram_client() -> TelegramClient:
"""
Получить экземпляр клиента Telegram (lazy initialization).
Returns:
Экземпляр TelegramClient.
"""
global _telegram_client_instance
if _telegram_client_instance is None:
_telegram_client_instance = TelegramClient()
return _telegram_client_instance
# Для обратной совместимости (lazy initialization)
# telegram_client будет создан при первом использовании
class _TelegramClientProxy:
"""Прокси для ленивой инициализации telegram_client."""
def __getattr__(self, name):
"""Получить атрибут из telegram_client."""
return getattr(get_telegram_client(), name)
telegram_client = _TelegramClientProxy()

97
app/core/utils.py Normal file
View File

@@ -0,0 +1,97 @@
"""
Вспомогательные утилиты.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import re
import logging
from typing import List
logger = logging.getLogger(__name__)
# Список стоп-слов (алерты, которые не должны отправляться)
STOP_WORDS = [
r"^InfoInhibitor",
r"^Watchdog",
r"^[E,e]tcdHighCommitDurations",
r"^[E,e]tcdHighNumberOfFailedGRPCRequests",
r"^[K,k]ubePersistentVolumeFillingUp",
r"^[K,k]ubePersistentVolumeInodesFillingUp",
]
def check_stop_words(name: str) -> bool:
"""
Проверить, содержит ли название алерта стоп-слова.
Args:
name: Название алерта.
Returns:
True если алерт должен быть заблокирован, False в противном случае.
"""
logger.debug(f"Проверка стоп-слов для алерта: '{name}'")
for pattern in STOP_WORDS:
if re.search(pattern, name):
logger.warning(f"Алерт '{name}' заблокирован стоп-словом: {pattern}")
return True
return False
def add_spaces_to_alert_name(name: str) -> str:
"""
Добавить пробелы в название алерта для лучшей читаемости.
Пример: "HighCPUUsage" -> "High CPU Usage"
Args:
name: Название алерта без пробелов.
Returns:
Название алерта с пробелами.
"""
if not name:
return name
result = name[0]
for letter in name[1:]:
if letter.isupper():
result += f' {letter}'
else:
result += letter
# Исправляем известные сокращения
result = result.replace('C P U', 'CPU')
result = result.replace('etcd', 'ETCD')
result = result.replace('A P I', 'API')
result = result.replace('K 8 S', 'K8S')
result = result.replace('P V C', 'PVC')
result = result.replace('G R P C', 'GRPC')
return result
def truncate_message(message: str, max_length: int = 4090) -> str:
"""
Обрезать сообщение до максимальной длины для Telegram.
Telegram имеет лимит в 4096 символов на сообщение.
Args:
message: Исходное сообщение.
max_length: Максимальная длина сообщения.
Returns:
Обрезанное сообщение с индикацией обрезки.
"""
if len(message) <= max_length:
return message
truncated = message[:max_length - 10]
truncated += "\n\n... (сообщение обрезано)"
logger.warning(f"Сообщение обрезано с {len(message)} до {max_length} символов")
return truncated