Initial commit: Message Gateway project
- FastAPI приложение для отправки мониторинговых алертов в мессенджеры - Поддержка Telegram и MAX/VK - Интеграция с Grafana, Zabbix, AlertManager - Автоматическое создание тикетов в Jira - Управление группами мессенджеров через API - Декораторы для авторизации и скрытия эндпоинтов - Подробная документация в папке docs/ Автор: Сергей Антропов Сайт: https://devops.org.ru
This commit is contained in:
6
app/core/__init__.py
Normal file
6
app/core/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Общие утилиты и функции для приложения.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
183
app/core/auth.py
Normal file
183
app/core/auth.py
Normal 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
127
app/core/button_utils.py
Normal 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
132
app/core/config.py
Normal 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
370
app/core/groups.py
Normal 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
236
app/core/jira_client.py
Normal 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
212
app/core/jira_mapping.py
Normal 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
330
app/core/jira_utils.py
Normal 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
|
||||
102
app/core/messenger_factory.py
Normal file
102
app/core/messenger_factory.py
Normal 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)
|
||||
|
||||
16
app/core/messengers/__init__.py
Normal file
16
app/core/messengers/__init__.py
Normal 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
174
app/core/messengers/base.py
Normal 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
476
app/core/messengers/max.py
Normal file
@@ -0,0 +1,476 @@
|
||||
"""
|
||||
Адаптер для работы с MAX/VK через MessengerClient интерфейс.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
import logging
|
||||
import io
|
||||
from typing import Optional, Union, Dict, Any
|
||||
import httpx
|
||||
|
||||
from app.core.messengers.base import MessengerClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MaxMessengerClient(MessengerClient):
|
||||
"""Адаптер для MAX/VK, реализующий интерфейс MessengerClient."""
|
||||
|
||||
def __init__(self, access_token: str, api_version: str = "5.131"):
|
||||
"""
|
||||
Инициализация клиента MAX/VK.
|
||||
|
||||
Args:
|
||||
access_token: Access token для VK API.
|
||||
api_version: Версия VK API (по умолчанию 5.131).
|
||||
|
||||
Raises:
|
||||
ValueError: Если access_token не указан.
|
||||
"""
|
||||
if not access_token:
|
||||
raise ValueError("MAX_ACCESS_TOKEN не установлен")
|
||||
self.access_token = access_token
|
||||
self.api_version = api_version
|
||||
self.api_url = "https://api.vk.com/method"
|
||||
|
||||
@property
|
||||
def messenger_type(self) -> str:
|
||||
"""Тип мессенджера."""
|
||||
return "max"
|
||||
|
||||
@property
|
||||
def supports_threads(self) -> bool:
|
||||
"""MAX/VK не поддерживает треды."""
|
||||
return False
|
||||
|
||||
def _convert_html_to_vk_format(self, text: str) -> str:
|
||||
"""
|
||||
Конвертировать HTML в формат VK.
|
||||
|
||||
VK поддерживает свою разметку:
|
||||
- [bold]текст[/bold] - жирный
|
||||
- [italic]текст[/italic] - курсив
|
||||
- [code]текст[/code] - код
|
||||
|
||||
Args:
|
||||
text: Текст с HTML разметкой.
|
||||
|
||||
Returns:
|
||||
Текст с VK разметкой.
|
||||
"""
|
||||
# Простая конвертация HTML в VK формат
|
||||
# Заменяем <b> и </b> на [bold] и [/bold]
|
||||
text = text.replace("<b>", "[bold]").replace("</b>", "[/bold]")
|
||||
text = text.replace("<strong>", "[bold]").replace("</strong>", "[/bold]")
|
||||
|
||||
# Заменяем <i> и </i> на [italic] и [/italic]
|
||||
text = text.replace("<i>", "[italic]").replace("</i>", "[/italic]")
|
||||
text = text.replace("<em>", "[italic]").replace("</em>", "[/italic]")
|
||||
|
||||
# Заменяем <code> и </code> на [code] и [/code]
|
||||
text = text.replace("<code>", "[code]").replace("</code>", "[/code]")
|
||||
|
||||
# Заменяем <pre> и </pre> на [code] и [/code]
|
||||
text = text.replace("<pre>", "[code]").replace("</pre>", "[/code]")
|
||||
|
||||
# Заменяем <br> и <br/> на перенос строки
|
||||
text = text.replace("<br>", "\n").replace("<br/>", "\n").replace("<br />", "\n")
|
||||
|
||||
# Удаляем другие HTML теги (простая очистка)
|
||||
import re
|
||||
text = re.sub(r'<[^>]+>', '', text)
|
||||
|
||||
return text
|
||||
|
||||
async def _download_file(self, url: str) -> bytes:
|
||||
"""
|
||||
Скачать файл по URL.
|
||||
|
||||
Args:
|
||||
url: URL файла.
|
||||
|
||||
Returns:
|
||||
Байты файла.
|
||||
"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
return response.content
|
||||
|
||||
async def _upload_photo_to_vk(self, photo: Union[str, bytes], peer_id: Union[str, int]) -> Optional[str]:
|
||||
"""
|
||||
Загрузить фото в VK и получить attachment.
|
||||
|
||||
Args:
|
||||
photo: URL или bytes фото.
|
||||
peer_id: ID получателя.
|
||||
|
||||
Returns:
|
||||
Attachment string для VK API или None в случае ошибки.
|
||||
"""
|
||||
try:
|
||||
# Если photo - это URL, скачиваем файл
|
||||
if isinstance(photo, str) and (photo.startswith('http://') or photo.startswith('https://')):
|
||||
photo_bytes = await self._download_file(photo)
|
||||
elif isinstance(photo, bytes):
|
||||
photo_bytes = photo
|
||||
else:
|
||||
logger.error(f"Неподдерживаемый тип photo: {type(photo)}")
|
||||
return None
|
||||
|
||||
# Получаем URL для загрузки фото
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Шаг 1: Получаем upload server
|
||||
upload_url_params = {
|
||||
"access_token": self.access_token,
|
||||
"peer_id": peer_id,
|
||||
"v": self.api_version
|
||||
}
|
||||
upload_url_response = await client.get(
|
||||
f"{self.api_url}/photos.getMessagesUploadServer",
|
||||
params=upload_url_params
|
||||
)
|
||||
upload_url_data = upload_url_response.json()
|
||||
|
||||
if "error" in upload_url_data:
|
||||
logger.error(f"Ошибка получения upload server: {upload_url_data['error']}")
|
||||
return None
|
||||
|
||||
upload_url = upload_url_data["response"]["upload_url"]
|
||||
|
||||
# Шаг 2: Загружаем фото
|
||||
files = {"photo": ("photo.jpg", photo_bytes, "image/jpeg")}
|
||||
upload_response = await client.post(upload_url, files=files)
|
||||
upload_data = upload_response.json()
|
||||
|
||||
if "error" in upload_data:
|
||||
logger.error(f"Ошибка загрузки фото: {upload_data['error']}")
|
||||
return None
|
||||
|
||||
# Шаг 3: Сохраняем фото
|
||||
save_params = {
|
||||
"access_token": self.access_token,
|
||||
"server": upload_data["server"],
|
||||
"photo": upload_data["photo"],
|
||||
"hash": upload_data["hash"],
|
||||
"v": self.api_version
|
||||
}
|
||||
save_response = await client.get(
|
||||
f"{self.api_url}/photos.saveMessagesPhoto",
|
||||
params=save_params
|
||||
)
|
||||
save_data = save_response.json()
|
||||
|
||||
if "error" in save_data:
|
||||
logger.error(f"Ошибка сохранения фото: {save_data['error']}")
|
||||
return None
|
||||
|
||||
# Формируем attachment string
|
||||
photo_data = save_data["response"][0]
|
||||
attachment = f"photo{photo_data['owner_id']}_{photo_data['id']}"
|
||||
return attachment
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка загрузки фото в VK: {e}")
|
||||
return None
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
chat_id: Union[str, int],
|
||||
text: str,
|
||||
thread_id: Optional[int] = None,
|
||||
reply_markup: Optional[Dict[str, Any]] = None,
|
||||
disable_web_page_preview: bool = True,
|
||||
parse_mode: str = "HTML",
|
||||
**kwargs
|
||||
) -> bool:
|
||||
"""
|
||||
Отправить текстовое сообщение в MAX/VK.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата или группы (может быть строкой или числом).
|
||||
text: Текст сообщения.
|
||||
thread_id: ID треда в группе (игнорируется для VK).
|
||||
reply_markup: Клавиатура с кнопками (опционально, формат VK).
|
||||
disable_web_page_preview: Отключить превью ссылок (игнорируется для VK).
|
||||
parse_mode: Режим парсинга (HTML конвертируется в VK формат).
|
||||
**kwargs: Дополнительные параметры (attachment, и т.д.).
|
||||
|
||||
Returns:
|
||||
True если сообщение отправлено успешно, False в противном случае.
|
||||
"""
|
||||
if thread_id is not None:
|
||||
logger.warning("MAX/VK не поддерживает треды, параметр thread_id игнорируется")
|
||||
|
||||
try:
|
||||
# Конвертируем HTML в формат VK
|
||||
if parse_mode == "HTML":
|
||||
text = self._convert_html_to_vk_format(text)
|
||||
|
||||
# Преобразуем chat_id в int
|
||||
peer_id = int(chat_id) if isinstance(chat_id, str) else chat_id
|
||||
|
||||
# Генерируем random_id для VK API (должен быть уникальным для каждого сообщения)
|
||||
import random
|
||||
random_id = random.randint(1, 2**31 - 1)
|
||||
|
||||
# Параметры для отправки сообщения
|
||||
params = {
|
||||
"access_token": self.access_token,
|
||||
"peer_id": peer_id,
|
||||
"message": text,
|
||||
"v": self.api_version,
|
||||
"random_id": random_id # VK требует random_id
|
||||
}
|
||||
|
||||
# Добавляем attachment, если есть
|
||||
if "attachment" in kwargs:
|
||||
params["attachment"] = kwargs["attachment"]
|
||||
|
||||
# Добавляем клавиатуру, если есть
|
||||
if reply_markup:
|
||||
import json
|
||||
params["keyboard"] = json.dumps(reply_markup)
|
||||
|
||||
# Отправляем сообщение
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
f"{self.api_url}/messages.send",
|
||||
params=params
|
||||
)
|
||||
response_data = response.json()
|
||||
|
||||
if "error" in response_data:
|
||||
error = response_data["error"]
|
||||
logger.error(f"Ошибка отправки сообщения в VK: {error}")
|
||||
return False
|
||||
|
||||
message_id = response_data.get("response")
|
||||
if message_id:
|
||||
logger.info(f"Сообщение отправлено в VK чат {peer_id}, message_id: {message_id}")
|
||||
return True
|
||||
else:
|
||||
logger.error("Не удалось получить message_id из ответа VK API")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Неожиданная ошибка при отправке сообщения в VK: {e}")
|
||||
return False
|
||||
|
||||
async def send_photo(
|
||||
self,
|
||||
chat_id: Union[str, int],
|
||||
photo: Union[str, bytes],
|
||||
caption: Optional[str] = None,
|
||||
thread_id: Optional[int] = None,
|
||||
parse_mode: str = "HTML",
|
||||
**kwargs
|
||||
) -> bool:
|
||||
"""
|
||||
Отправить фото в MAX/VK.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата или группы.
|
||||
photo: URL или bytes фото.
|
||||
caption: Подпись к фото (опционально).
|
||||
thread_id: ID треда в группе (игнорируется для VK).
|
||||
parse_mode: Режим парсинга (HTML конвертируется в VK формат).
|
||||
**kwargs: Дополнительные параметры.
|
||||
|
||||
Returns:
|
||||
True если фото отправлено успешно, False в противном случае.
|
||||
"""
|
||||
if thread_id is not None:
|
||||
logger.warning("MAX/VK не поддерживает треды, параметр thread_id игнорируется")
|
||||
|
||||
try:
|
||||
peer_id = int(chat_id) if isinstance(chat_id, str) else chat_id
|
||||
|
||||
# Загружаем фото в VK
|
||||
attachment = await self._upload_photo_to_vk(photo, peer_id)
|
||||
if not attachment:
|
||||
logger.error("Не удалось загрузить фото в VK")
|
||||
return False
|
||||
|
||||
# Формируем текст сообщения
|
||||
text = caption or ""
|
||||
if parse_mode == "HTML" and text:
|
||||
text = self._convert_html_to_vk_format(text)
|
||||
|
||||
# Отправляем сообщение с фото
|
||||
return await self.send_message(
|
||||
chat_id=peer_id,
|
||||
text=text,
|
||||
attachment=attachment,
|
||||
parse_mode=parse_mode
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Неожиданная ошибка при отправке фото в VK: {e}")
|
||||
return False
|
||||
|
||||
async def send_video(
|
||||
self,
|
||||
chat_id: Union[str, int],
|
||||
video: Union[str, bytes],
|
||||
caption: Optional[str] = None,
|
||||
thread_id: Optional[int] = None,
|
||||
parse_mode: str = "HTML",
|
||||
duration: Optional[int] = None,
|
||||
width: Optional[int] = None,
|
||||
height: Optional[int] = None,
|
||||
**kwargs
|
||||
) -> bool:
|
||||
"""
|
||||
Отправить видео в MAX/VK.
|
||||
|
||||
Примечание: VK API требует более сложную логику для загрузки видео.
|
||||
В текущей реализации отправляется только ссылка на видео.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата или группы.
|
||||
video: URL или bytes видео.
|
||||
caption: Подпись к видео (опционально).
|
||||
thread_id: ID треда в группе (игнорируется для VK).
|
||||
parse_mode: Режим парсинга (HTML конвертируется в VK формат).
|
||||
duration: Длительность видео в секундах (игнорируется для VK).
|
||||
width: Ширина видео (игнорируется для VK).
|
||||
height: Высота видео (игнорируется для VK).
|
||||
**kwargs: Дополнительные параметры.
|
||||
|
||||
Returns:
|
||||
True если видео отправлено успешно, False в противном случае.
|
||||
"""
|
||||
if thread_id is not None:
|
||||
logger.warning("MAX/VK не поддерживает треды, параметр thread_id игнорируется")
|
||||
|
||||
try:
|
||||
peer_id = int(chat_id) if isinstance(chat_id, str) else chat_id
|
||||
|
||||
# Если video - это URL, отправляем как ссылку
|
||||
if isinstance(video, str) and (video.startswith('http://') or video.startswith('https://')):
|
||||
text = caption or video
|
||||
if parse_mode == "HTML" and text:
|
||||
text = self._convert_html_to_vk_format(text)
|
||||
return await self.send_message(
|
||||
chat_id=peer_id,
|
||||
text=text,
|
||||
parse_mode=parse_mode
|
||||
)
|
||||
else:
|
||||
logger.warning("Загрузка видео через bytes пока не поддерживается в VK адаптере")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Неожиданная ошибка при отправке видео в VK: {e}")
|
||||
return False
|
||||
|
||||
async def send_audio(
|
||||
self,
|
||||
chat_id: Union[str, int],
|
||||
audio: Union[str, bytes],
|
||||
caption: Optional[str] = None,
|
||||
thread_id: Optional[int] = None,
|
||||
parse_mode: str = "HTML",
|
||||
duration: Optional[int] = None,
|
||||
performer: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> bool:
|
||||
"""
|
||||
Отправить аудио в MAX/VK.
|
||||
|
||||
Примечание: VK API требует специальную логику для загрузки аудио.
|
||||
В текущей реализации отправляется только ссылка на аудио.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата или группы.
|
||||
audio: URL или bytes аудио.
|
||||
caption: Подпись к аудио (опционально).
|
||||
thread_id: ID треда в группе (игнорируется для VK).
|
||||
parse_mode: Режим парсинга (HTML конвертируется в VK формат).
|
||||
duration: Длительность аудио в секундах (игнорируется для VK).
|
||||
performer: Исполнитель (игнорируется для VK).
|
||||
title: Название трека (игнорируется для VK).
|
||||
**kwargs: Дополнительные параметры.
|
||||
|
||||
Returns:
|
||||
True если аудио отправлено успешно, False в противном случае.
|
||||
"""
|
||||
if thread_id is not None:
|
||||
logger.warning("MAX/VK не поддерживает треды, параметр thread_id игнорируется")
|
||||
|
||||
try:
|
||||
peer_id = int(chat_id) if isinstance(chat_id, str) else chat_id
|
||||
|
||||
# Если audio - это URL, отправляем как ссылку
|
||||
if isinstance(audio, str) and (audio.startswith('http://') or audio.startswith('https://')):
|
||||
text = caption or audio
|
||||
if parse_mode == "HTML" and text:
|
||||
text = self._convert_html_to_vk_format(text)
|
||||
return await self.send_message(
|
||||
chat_id=peer_id,
|
||||
text=text,
|
||||
parse_mode=parse_mode
|
||||
)
|
||||
else:
|
||||
logger.warning("Загрузка аудио через bytes пока не поддерживается в VK адаптере")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Неожиданная ошибка при отправке аудио в VK: {e}")
|
||||
return False
|
||||
|
||||
async def send_document(
|
||||
self,
|
||||
chat_id: Union[str, int],
|
||||
document: Union[str, bytes],
|
||||
caption: Optional[str] = None,
|
||||
thread_id: Optional[int] = None,
|
||||
parse_mode: str = "HTML",
|
||||
filename: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> bool:
|
||||
"""
|
||||
Отправить документ в MAX/VK.
|
||||
|
||||
Примечание: VK API требует специальную логику для загрузки документов.
|
||||
В текущей реализации отправляется только ссылка на документ.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата или группы.
|
||||
document: URL или bytes документа.
|
||||
caption: Подпись к документу (опционально).
|
||||
thread_id: ID треда в группе (игнорируется для VK).
|
||||
parse_mode: Режим парсинга (HTML конвертируется в VK формат).
|
||||
filename: Имя файла (игнорируется для VK).
|
||||
**kwargs: Дополнительные параметры.
|
||||
|
||||
Returns:
|
||||
True если документ отправлен успешно, False в противном случае.
|
||||
"""
|
||||
if thread_id is not None:
|
||||
logger.warning("MAX/VK не поддерживает треды, параметр thread_id игнорируется")
|
||||
|
||||
try:
|
||||
peer_id = int(chat_id) if isinstance(chat_id, str) else chat_id
|
||||
|
||||
# Если document - это URL, отправляем как ссылку
|
||||
if isinstance(document, str) and (document.startswith('http://') or document.startswith('https://')):
|
||||
text = caption or document
|
||||
if parse_mode == "HTML" and text:
|
||||
text = self._convert_html_to_vk_format(text)
|
||||
return await self.send_message(
|
||||
chat_id=peer_id,
|
||||
text=text,
|
||||
parse_mode=parse_mode
|
||||
)
|
||||
else:
|
||||
logger.warning("Загрузка документов через bytes пока не поддерживается в VK адаптере")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Неожиданная ошибка при отправке документа в VK: {e}")
|
||||
return False
|
||||
|
||||
255
app/core/messengers/telegram.py
Normal file
255
app/core/messengers/telegram.py
Normal 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
316
app/core/metrics.py
Normal 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
345
app/core/telegram_client.py
Normal 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
97
app/core/utils.py
Normal 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
|
||||
Reference in New Issue
Block a user