Initial commit: Message Gateway project

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

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

0
app/__init__.py Normal file
View File

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

@@ -0,0 +1,6 @@
"""
API модули приложения.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""

6
app/api/v1/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
"""
API версии 1.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""

View File

@@ -0,0 +1,23 @@
"""
Эндпоинты API версии 1.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from app.api.v1.endpoints import (
health,
monitoring,
debug,
jira,
message,
groups,
)
__all__ = [
"health",
"monitoring",
"debug",
"jira",
"message",
"groups",
]

View File

@@ -0,0 +1,114 @@
"""
Эндпоинты для отладки.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import json
import logging
from typing import Dict, Any
from fastapi import APIRouter, HTTPException, Body
import aiofiles
from app.core.metrics import metrics
from app.core.auth import hide_from_api
# Импортируем settings в функции, чтобы избежать циклических зависимостей
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/debug", tags=["debug"])
@hide_from_api
@router.post(
"/dump",
name="JSON Debug dump",
response_model=Dict[str, Any],
include_in_schema=False, # Скрываем эндпоинт из Swagger UI
responses={
200: {
"description": "Данные успешно сохранены",
"content": {
"application/json": {
"example": {
"status": "ok",
"message": "Данные сохранены в dump.json",
"data": {
"test": "data",
"timestamp": "2024-01-01T00:00:00Z",
"source": "grafana",
"alert": {
"title": "Test alert",
"state": "alerting"
}
}
}
}
}
},
500: {
"description": "Ошибка сохранения данных",
"content": {
"application/json": {
"example": {"detail": "Ошибка записи в файл"}
}
}
}
}
)
async def dump_request(
dump: Dict[str, Any] = Body(
...,
description="JSON данные для сохранения в файл dump.json",
examples=[
{
"test": "data",
"timestamp": "2024-01-01T00:00:00Z",
"source": "grafana",
"alert": {
"title": "Test alert",
"state": "alerting"
}
},
{
"source": "zabbix",
"event": {
"event-id": "8819711",
"event-name": "High CPU utilization",
"status": "PROBLEM"
}
},
{
"source": "alertmanager",
"status": "firing",
"commonLabels": {
"alertname": "HighCPUUsage",
"severity": "critical"
}
}
]
)
) -> Dict[str, Any]:
"""
Сохранить JSON данные в файл для отладки.
Используется для сохранения входящих webhook запросов для анализа.
Подробная документация: см. docs/api/debug.md
"""
metrics.increment_api_endpoint("debug_dump")
logger.info("Получен запрос на сохранение данных для отладки")
try:
dump_path = "/app/app/dump.json"
async with aiofiles.open(dump_path, 'w', encoding='utf-8') as f:
await f.write(json.dumps(dump, indent=4, ensure_ascii=False))
logger.info(f"Данные сохранены в {dump_path}")
return {
"status": "ok",
"message": "Данные сохранены в dump.json",
"data": dump
}
except Exception as e:
logger.error(f"Ошибка сохранения данных: {e}")
raise HTTPException(status_code=500, detail=f"Ошибка записи в файл: {str(e)}")

View File

@@ -0,0 +1,511 @@
"""
Эндпоинты для управления группами мессенджеров.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import logging
from typing import Dict, Any, Optional
from fastapi import APIRouter, HTTPException, Query, Path, Body, Request
from app.core.metrics import metrics
from app.core.groups import groups_config
from app.core.auth import require_api_key, require_api_key_dependency, require_api_key_optional
from app.models.group import (
CreateGroupRequest,
UpdateGroupRequest,
DeleteGroupRequest,
GroupInfo
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/groups", tags=["groups"])
@router.get(
"/messengers",
name="Получить список поддерживаемых мессенджеров",
response_model=Dict[str, Any],
responses={
200: {
"description": "Список поддерживаемых мессенджеров",
"content": {
"application/json": {
"example": {
"status": "ok",
"messengers": [
{
"type": "telegram",
"name": "Telegram",
"supports_threads": True,
"enabled": True
},
{
"type": "max",
"name": "MAX/VK",
"supports_threads": False,
"enabled": False
}
]
}
}
}
}
}
)
async def get_messengers() -> Dict[str, Any]:
"""
Получить список поддерживаемых мессенджеров.
Returns:
Список поддерживаемых мессенджеров с их характеристиками.
Подробная документация: см. docs/api/groups.md
"""
from app.core.config import get_settings
settings = get_settings()
messengers = [
{
"type": "telegram",
"name": "Telegram",
"supports_threads": True,
"enabled": settings.telegram_enabled
},
{
"type": "max",
"name": "MAX/VK",
"supports_threads": False,
"enabled": settings.max_enabled
}
]
return {
"status": "ok",
"messengers": messengers
}
@router.get(
"",
name="Получить список групп",
response_model=Dict[str, Any],
responses={
200: {
"description": "Список групп успешно получен",
"content": {
"application/json": {
"examples": {
"without_api_key": {
"summary": "Без API ключа (только названия)",
"value": {
"status": "ok",
"groups": [
{"name": "monitoring", "chat_id": None},
{"name": "alerts", "chat_id": None}
],
"count": 2
}
},
"with_api_key": {
"summary": "С API ключом (полная информация)",
"value": {
"status": "ok",
"groups": [
{
"name": "monitoring",
"messenger": "telegram",
"chat_id": -1001234567890,
"thread_id": 0
},
{
"name": "alerts_max",
"messenger": "max",
"chat_id": "123456789",
"thread_id": None
}
],
"count": 2
}
}
}
}
}
},
401: {
"description": "Неверный API ключ",
"content": {
"application/json": {
"example": {"detail": "Неверный или отсутствующий API ключ"}
}
}
},
500: {
"description": "Ошибка сервера",
"content": {
"application/json": {
"example": {"detail": "Ошибка получения списка групп"}
}
}
}
}
)
async def get_groups(
request: Request,
api_key_header: Optional[bool] = require_api_key_optional
) -> Dict[str, Any]:
"""
Получить список всех групп.
Без API ключа: возвращает только названия групп без ID.
С API ключом (заголовок X-API-Key): возвращает полную информацию о группах включая ID.
Подробная документация: см. docs/api/groups.md
"""
metrics.increment_api_endpoint("groups_list")
# Если API ключ валиден, возвращаем полную информацию
include_ids = api_key_header is True
# Получаем группы
try:
groups_dict = await groups_config.get_all_groups(include_ids=include_ids)
# Формируем список групп
groups = []
for name, group_config in groups_dict.items():
if include_ids:
# Возвращаем полную информацию о группе
if isinstance(group_config, dict) and group_config is not None:
groups.append(GroupInfo(
name=name,
messenger=group_config.get("messenger"),
chat_id=group_config.get("chat_id"),
thread_id=group_config.get("thread_id")
))
else:
# Если group_config is None, значит include_ids=False, но мы здесь не должны быть
groups.append(GroupInfo(
name=name,
messenger=None,
chat_id=None,
thread_id=None
))
else:
# Возвращаем только название группы (group_config будет None)
groups.append(GroupInfo(
name=name,
messenger=None,
chat_id=None,
thread_id=None
))
return {
"status": "ok",
"groups": [group.model_dump() for group in groups],
"count": len(groups)
}
except Exception as e:
logger.error(f"Ошибка получения списка групп: {e}")
raise HTTPException(status_code=500, detail=f"Ошибка получения списка групп: {str(e)}")
@require_api_key
@router.post(
"",
name="Создать группу",
response_model=Dict[str, Any],
dependencies=[require_api_key_dependency],
responses={
200: {
"description": "Группа успешно создана",
"content": {
"application/json": {
"example": {
"status": "ok",
"message": "Группа 'monitoring' создана с ID -1001234567890"
}
}
}
},
400: {
"description": "Ошибка запроса (группа уже существует или неверные данные)",
"content": {
"application/json": {
"examples": {
"group_exists": {
"summary": "Группа уже существует",
"value": {"detail": "Группа 'monitoring' уже существует"}
},
"invalid_data": {
"summary": "Неверные данные",
"value": {"detail": "Неверный формат данных"}
}
}
}
}
},
401: {
"description": "Ошибка авторизации (неверный API ключ)",
"content": {
"application/json": {
"example": {"detail": "Неверный или отсутствующий API ключ"}
}
}
},
500: {
"description": "Ошибка сервера",
"content": {
"application/json": {
"example": {"detail": "Ошибка создания группы"}
}
}
}
}
)
async def create_group(
request: Request,
body: CreateGroupRequest = Body(
...,
examples=[
{
"group_name": "monitoring",
"messenger": "telegram",
"chat_id": -1001234567890,
"thread_id": 0
},
{
"group_name": "max_alerts",
"messenger": "max",
"chat_id": "123456789",
"thread_id": 0,
"config": {
"access_token": "your_max_access_token"
}
}
]
)
) -> Dict[str, Any]:
"""
Создать новую группу в конфигурации.
Требуется API ключ в заголовке X-API-Key.
Подробная документация: см. docs/api/groups.md
"""
metrics.increment_api_endpoint("groups_create")
# Создаем группу
try:
success = await groups_config.create_group(
group_name=body.group_name,
chat_id=body.chat_id,
messenger=body.messenger,
thread_id=body.thread_id,
config=body.config
)
if not success:
raise HTTPException(
status_code=400,
detail=f"Группа '{body.group_name}' уже существует"
)
return {
"status": "ok",
"message": f"Группа '{body.group_name}' создана с мессенджером '{body.messenger}' и ID {body.chat_id}"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Ошибка создания группы: {e}")
raise HTTPException(status_code=500, detail=f"Ошибка создания группы: {str(e)}")
@require_api_key
@router.put(
"/{group_name}",
name="Обновить группу",
response_model=Dict[str, Any],
dependencies=[require_api_key_dependency],
responses={
200: {
"description": "Группа успешно обновлена",
"content": {
"application/json": {
"example": {
"status": "ok",
"message": "Группа 'monitoring' обновлена с ID -1001234567891"
}
}
}
},
400: {
"description": "Ошибка запроса (группа не найдена или неверные данные)",
"content": {
"application/json": {
"example": {"detail": "Группа 'monitoring' не найдена"}
}
}
},
401: {
"description": "Ошибка авторизации (неверный API ключ)",
"content": {
"application/json": {
"example": {"detail": "Неверный или отсутствующий API ключ"}
}
}
},
500: {
"description": "Ошибка сервера",
"content": {
"application/json": {
"example": {"detail": "Ошибка обновления группы"}
}
}
}
}
)
async def update_group(
request: Request,
group_name: str = Path(
...,
description="Имя группы для обновления",
examples=["monitoring", "alerts", "devops"]
),
body: UpdateGroupRequest = Body(
...,
examples=[
{
"chat_id": -1001234567891,
"messenger": "telegram",
"thread_id": 0
},
{
"chat_id": "123456789",
"messenger": "max",
"config": {
"access_token": "your_access_token",
"api_version": "5.131"
}
}
]
)
) -> Dict[str, Any]:
"""
Обновить существующую группу в конфигурации.
Требуется API ключ в заголовке X-API-Key.
Подробная документация: см. docs/api/groups.md
"""
metrics.increment_api_endpoint("groups_update")
# Обновляем группу
try:
success = await groups_config.update_group(
group_name=group_name,
chat_id=body.chat_id,
messenger=body.messenger,
thread_id=body.thread_id,
config=body.config
)
if not success:
raise HTTPException(
status_code=400,
detail=f"Группа '{group_name}' не найдена"
)
return {
"status": "ok",
"message": f"Группа '{group_name}' обновлена"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Ошибка обновления группы: {e}")
raise HTTPException(status_code=500, detail=f"Ошибка обновления группы: {str(e)}")
@require_api_key
@router.delete(
"/{group_name}",
name="Удалить группу",
response_model=Dict[str, Any],
dependencies=[require_api_key_dependency],
responses={
200: {
"description": "Группа успешно удалена",
"content": {
"application/json": {
"example": {
"status": "ok",
"message": "Группа 'monitoring' удалена"
}
}
}
},
400: {
"description": "Ошибка запроса (группа не найдена)",
"content": {
"application/json": {
"example": {"detail": "Группа 'monitoring' не найдена"}
}
}
},
401: {
"description": "Ошибка авторизации (неверный API ключ)",
"content": {
"application/json": {
"example": {"detail": "Неверный или отсутствующий API ключ"}
}
}
},
500: {
"description": "Ошибка сервера",
"content": {
"application/json": {
"example": {"detail": "Ошибка удаления группы"}
}
}
}
}
)
async def delete_group(
request: Request,
group_name: str = Path(
...,
description="Имя группы для удаления",
examples=["monitoring", "alerts", "devops"]
)
) -> Dict[str, Any]:
"""
Удалить группу из конфигурации.
Требуется API ключ в заголовке X-API-Key.
Подробная документация: см. docs/api/groups.md
"""
metrics.increment_api_endpoint("groups_delete")
# Удаляем группу
try:
success = await groups_config.delete_group(group_name)
if not success:
raise HTTPException(
status_code=400,
detail=f"Группа '{group_name}' не найдена"
)
return {
"status": "ok",
"message": f"Группа '{group_name}' удалена"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Ошибка удаления группы: {e}")
raise HTTPException(status_code=500, detail=f"Ошибка удаления группы: {str(e)}")

View File

@@ -0,0 +1,111 @@
"""
Эндпоинты для проверки здоровья приложения.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import logging
from fastapi import APIRouter, HTTPException
from typing import Dict, Any
from app.core.metrics import metrics
from app.core.groups import groups_config
from app.core.config import get_settings
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/health", tags=["health"])
@router.get(
"",
name="Проверка здоровья приложения",
response_model=Dict[str, Any],
responses={
200: {
"description": "Приложение работает и готово",
"content": {
"application/json": {
"examples": {
"healthy": {
"summary": "Приложение здорово",
"value": {
"status": "healthy",
"state": "online",
"telegram_bot_configured": True,
"groups_config_available": True
}
},
"not_ready": {
"summary": "Приложение не готово",
"value": {
"status": "not_ready",
"state": "online",
"checks": {
"telegram_bot_configured": False,
"groups_config_available": True
}
}
}
}
}
}
},
503: {
"description": "Приложение не готово к работе",
"content": {
"application/json": {
"example": {
"status": "not_ready",
"checks": {
"telegram_bot_configured": False,
"groups_config_available": True
}
}
}
}
}
}
)
async def health_check() -> Dict[str, Any]:
"""
Проверка здоровья и готовности приложения для Kubernetes probes.
Объединенный endpoint для liveness и readiness probes.
Не требует аутентификации.
Подробная документация: см. docs/api/health.md
"""
metrics.increment_api_endpoint("health")
settings = get_settings()
checks = {
"telegram_bot_configured": bool(settings.telegram_bot_token),
"groups_config_available": False,
}
# Проверяем доступность конфигурации групп
try:
await groups_config.refresh_cache()
checks["groups_config_available"] = True
except Exception as e:
logger.error(f"Ошибка при проверке конфигурации групп: {e}")
checks["groups_config_available"] = False
# Если не все проверки пройдены, возвращаем 503
if not all(checks.values()):
raise HTTPException(
status_code=503,
detail={
"status": "not_ready",
"state": "online",
"checks": checks
}
)
return {
"status": "healthy",
"state": "online",
**checks
}

View File

@@ -0,0 +1,19 @@
"""
Эндпоинты для работы с Jira (только для внутреннего использования).
Создание тикетов по кнопке и работа с маппингом выполняются внутренними процессами.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import logging
from typing import Dict, Any
from fastapi import APIRouter
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/jira", tags=["jira"])
# Этот модуль оставлен пустым, так как все операции с Jira выполняются внутренними процессами
# Создание тикетов происходит автоматически при обработке алертов (см. app/modules/*.py)
# Маппинг загружается автоматически из config/jira_mapping.json (см. app/core/jira_mapping.py)

View File

@@ -0,0 +1,468 @@
"""
Эндпоинты для отправки простых сообщений в мессенджеры.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import logging
from typing import Dict, Any, Optional
from fastapi import APIRouter, HTTPException, Query, Request, Body
from app.core.metrics import metrics
from app.core.groups import groups_config
from app.core.messenger_factory import MessengerFactory
from app.core.auth import require_api_key, require_api_key_dependency
from app.models.message import (
SendMessageRequest,
SendPhotoRequest,
SendVideoRequest,
SendAudioRequest,
SendDocumentRequest
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/message", tags=["message"])
async def _send_message_to_group(
group_name: str,
thread_id: int,
messenger: Optional[str],
send_func,
*args,
**kwargs
) -> Dict[str, Any]:
"""
Вспомогательная функция для отправки сообщений в группу.
Args:
group_name: Имя группы из конфигурации.
thread_id: ID треда в группе (0 для основной группы).
messenger: Тип мессенджера (опционально).
send_func: Функция отправки сообщения.
*args: Дополнительные аргументы для функции отправки.
**kwargs: Дополнительные параметры для функции отправки.
Returns:
Результат отправки сообщения.
Raises:
HTTPException: Если группа не найдена или произошла ошибка отправки.
"""
# Получаем конфигурацию группы
group_config = await groups_config.get_group_config(group_name, messenger)
if group_config is None:
raise HTTPException(
status_code=400,
detail=f"Группа '{group_name}' не найдена в конфигурации"
)
messenger_type = group_config.get("messenger", "telegram")
chat_id = group_config.get("chat_id")
group_thread_id = group_config.get("thread_id", 0)
# Используем thread_id из параметра, если указан, иначе из конфигурации группы
final_thread_id = thread_id if thread_id > 0 else group_thread_id
# Создаем клиент мессенджера
messenger_client = MessengerFactory.create_from_config(group_config)
# Проверяем поддержку тредов
if not messenger_client.supports_threads and final_thread_id > 0:
logger.warning(f"Мессенджер '{messenger_type}' не поддерживает треды, thread_id будет проигнорирован")
final_thread_id = None
elif final_thread_id == 0:
final_thread_id = None
# Вызываем функцию отправки
success = await send_func(
messenger_client,
chat_id=chat_id,
thread_id=final_thread_id,
*args,
**kwargs
)
if not success:
raise HTTPException(
status_code=500,
detail=f"Ошибка отправки сообщения в {messenger_type}"
)
return {
"status": "ok",
"message": f"Сообщение отправлено в чат {group_name}, тред {thread_id if thread_id > 0 else 0}"
}
@require_api_key
@router.post(
"/text",
name="Отправить текстовое сообщение",
response_model=Dict[str, Any],
dependencies=[require_api_key_dependency],
responses={
200: {
"description": "Сообщение отправлено успешно",
"content": {
"application/json": {
"example": {
"status": "ok",
"message": "Сообщение отправлено в чат monitoring, тред 0"
}
}
}
},
400: {
"description": "Ошибка запроса",
"content": {
"application/json": {
"example": {"detail": "Группа 'monitoring' не найдена в конфигурации"}
}
}
},
401: {
"description": "Требуется API ключ",
"content": {
"application/json": {
"example": {"detail": "Неверный или отсутствующий API ключ"}
}
}
},
500: {
"description": "Ошибка отправки сообщения",
"content": {
"application/json": {
"example": {"detail": "Ошибка отправки сообщения в telegram"}
}
}
}
}
)
async def send_text_message(
request: Request,
body: SendMessageRequest = Body(...),
messenger: Optional[str] = Query(None, description="Тип мессенджера (telegram, max). Если не указан, используется из конфигурации группы", examples=["telegram", "max"])
) -> Dict[str, Any]:
"""
Отправить текстовое сообщение в группу мессенджера.
Подробная документация: см. docs/api/message.md
"""
metrics.increment_api_endpoint("message_text")
async def send_func(client, chat_id, thread_id, **kwargs):
return await client.send_message(
chat_id=chat_id,
text=body.text,
thread_id=thread_id,
disable_web_page_preview=body.disable_web_page_preview,
parse_mode=body.parse_mode
)
return await _send_message_to_group(
group_name=body.tg_group,
thread_id=body.tg_thread_id,
messenger=messenger,
send_func=send_func
)
@require_api_key
@router.post(
"/photo",
name="Отправить фото",
response_model=Dict[str, Any],
dependencies=[require_api_key_dependency],
responses={
200: {
"description": "Фото отправлено успешно",
"content": {
"application/json": {
"example": {
"status": "ok",
"message": "Фото отправлено в чат monitoring, тред 0"
}
}
}
},
400: {
"description": "Ошибка запроса",
"content": {
"application/json": {
"example": {"detail": "Группа 'monitoring' не найдена в конфигурации"}
}
}
},
401: {
"description": "Требуется API ключ",
"content": {
"application/json": {
"example": {"detail": "Неверный или отсутствующий API ключ"}
}
}
},
500: {
"description": "Ошибка отправки фото",
"content": {
"application/json": {
"example": {"detail": "Ошибка отправки фото в telegram"}
}
}
}
}
)
async def send_photo(
request: Request,
body: SendPhotoRequest = Body(...),
messenger: Optional[str] = Query(None, description="Тип мессенджера (telegram, max). Если не указан, используется из конфигурации группы", examples=["telegram", "max"])
) -> Dict[str, Any]:
"""
Отправить фото в группу мессенджера.
Подробная документация: см. docs/api/message.md
"""
metrics.increment_api_endpoint("message_photo")
async def send_func(client, chat_id, thread_id, **kwargs):
return await client.send_photo(
chat_id=chat_id,
photo=body.photo,
caption=body.caption,
thread_id=thread_id,
parse_mode=body.parse_mode
)
return await _send_message_to_group(
group_name=body.tg_group,
thread_id=body.tg_thread_id,
messenger=messenger,
send_func=send_func
)
@require_api_key
@router.post(
"/video",
name="Отправить видео",
response_model=Dict[str, Any],
dependencies=[require_api_key_dependency],
responses={
200: {
"description": "Видео отправлено успешно",
"content": {
"application/json": {
"example": {
"status": "ok",
"message": "Видео отправлено в чат monitoring, тред 0"
}
}
}
},
400: {
"description": "Ошибка запроса",
"content": {
"application/json": {
"example": {"detail": "Группа 'monitoring' не найдена в конфигурации"}
}
}
},
401: {
"description": "Требуется API ключ",
"content": {
"application/json": {
"example": {"detail": "Неверный или отсутствующий API ключ"}
}
}
},
500: {
"description": "Ошибка отправки видео",
"content": {
"application/json": {
"example": {"detail": "Ошибка отправки видео в telegram"}
}
}
}
}
)
async def send_video(
request: Request,
body: SendVideoRequest = Body(...),
messenger: Optional[str] = Query(None, description="Тип мессенджера (telegram, max). Если не указан, используется из конфигурации группы", examples=["telegram", "max"])
) -> Dict[str, Any]:
"""
Отправить видео в группу мессенджера.
Подробная документация: см. docs/api/message.md
"""
metrics.increment_api_endpoint("message_video")
async def send_func(client, chat_id, thread_id, **kwargs):
return await client.send_video(
chat_id=chat_id,
video=body.video,
caption=body.caption,
thread_id=thread_id,
parse_mode=body.parse_mode,
duration=body.duration,
width=body.width,
height=body.height
)
return await _send_message_to_group(
group_name=body.tg_group,
thread_id=body.tg_thread_id,
messenger=messenger,
send_func=send_func
)
@require_api_key
@router.post(
"/audio",
name="Отправить аудио",
response_model=Dict[str, Any],
dependencies=[require_api_key_dependency],
responses={
200: {
"description": "Аудио отправлено успешно",
"content": {
"application/json": {
"example": {
"status": "ok",
"message": "Аудио отправлено в чат monitoring, тред 0"
}
}
}
},
400: {
"description": "Ошибка запроса",
"content": {
"application/json": {
"example": {"detail": "Группа 'monitoring' не найдена в конфигурации"}
}
}
},
401: {
"description": "Требуется API ключ",
"content": {
"application/json": {
"example": {"detail": "Неверный или отсутствующий API ключ"}
}
}
},
500: {
"description": "Ошибка отправки аудио",
"content": {
"application/json": {
"example": {"detail": "Ошибка отправки аудио в telegram"}
}
}
}
}
)
async def send_audio(
request: Request,
body: SendAudioRequest = Body(...),
messenger: Optional[str] = Query(None, description="Тип мессенджера (telegram, max). Если не указан, используется из конфигурации группы", examples=["telegram", "max"])
) -> Dict[str, Any]:
"""
Отправить аудио в группу мессенджера.
Подробная документация: см. docs/api/message.md
"""
metrics.increment_api_endpoint("message_audio")
async def send_func(client, chat_id, thread_id, **kwargs):
return await client.send_audio(
chat_id=chat_id,
audio=body.audio,
caption=body.caption,
thread_id=thread_id,
parse_mode=body.parse_mode,
duration=body.duration,
performer=body.performer,
title=body.title
)
return await _send_message_to_group(
group_name=body.tg_group,
thread_id=body.tg_thread_id,
messenger=messenger,
send_func=send_func
)
@require_api_key
@router.post(
"/document",
name="Отправить документ",
response_model=Dict[str, Any],
dependencies=[require_api_key_dependency],
responses={
200: {
"description": "Документ отправлен успешно",
"content": {
"application/json": {
"example": {
"status": "ok",
"message": "Документ отправлен в чат monitoring, тред 0"
}
}
}
},
400: {
"description": "Ошибка запроса",
"content": {
"application/json": {
"example": {"detail": "Группа 'monitoring' не найдена в конфигурации"}
}
}
},
401: {
"description": "Требуется API ключ",
"content": {
"application/json": {
"example": {"detail": "Неверный или отсутствующий API ключ"}
}
}
},
500: {
"description": "Ошибка отправки документа",
"content": {
"application/json": {
"example": {"detail": "Ошибка отправки документа в telegram"}
}
}
}
}
)
async def send_document(
request: Request,
body: SendDocumentRequest = Body(...),
messenger: Optional[str] = Query(None, description="Тип мессенджера (telegram, max). Если не указан, используется из конфигурации группы", examples=["telegram", "max"])
) -> Dict[str, Any]:
"""
Отправить документ в группу мессенджера.
Подробная документация: см. docs/api/message.md
"""
metrics.increment_api_endpoint("message_document")
async def send_func(client, chat_id, thread_id, **kwargs):
return await client.send_document(
chat_id=chat_id,
document=body.document,
caption=body.caption,
thread_id=thread_id,
parse_mode=body.parse_mode,
filename=body.filename
)
return await _send_message_to_group(
group_name=body.tg_group,
thread_id=body.tg_thread_id,
messenger=messenger,
send_func=send_func
)

View File

@@ -0,0 +1,403 @@
"""
Эндпоинты для обработки webhooks из систем мониторинга (Grafana, Zabbix, AlertManager).
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import logging
from fastapi import APIRouter, HTTPException, Path, Body, Query
from typing import Dict, Optional
from app.models.grafana import GrafanaAlert
from app.models.zabbix import ZabbixAlert
from app.models.alertmanager import PrometheusAlert
from app.modules.grafana import send as grafana_send
from app.modules.zabbix import send as zabbix_send
from app.modules.alertmanager import send as alertmanager_send
from app.core.metrics import metrics
logger = logging.getLogger(__name__)
router = APIRouter(tags=["monitoring"])
@router.post(
"/grafana/{group_name}/{thread_id}",
name="Отправка вебхуков из Grafana",
response_model=Dict[str, str],
summary="Отправить алерт из Grafana",
description="Эндпоинт для обработки webhooks из Grafana. **Не требует авторизации.**",
responses={
200: {
"description": "Сообщение успешно отправлено",
"content": {
"application/json": {
"example": {"status": "ok", "message": "Сообщение отправлено"}
}
}
},
400: {
"description": "Некорректные данные запроса",
"content": {
"application/json": {
"example": {"detail": "Неверный формат данных для Grafana алерта"}
}
}
},
404: {
"description": "Группа не найдена",
"content": {
"application/json": {
"example": {"detail": "Группа 'monitoring' не найдена в конфигурации"}
}
}
},
500: {
"description": "Ошибка сервера",
"content": {
"application/json": {
"example": {"detail": "Ошибка отправки сообщения"}
}
}
}
}
)
async def send_grafana_alert(
group_name: str = Path(
...,
description="Имя группы из конфигурации (config/groups.json)",
examples=["monitoring", "alerts", "devops"]
),
thread_id: int = Path(
...,
description="ID треда в группе (0 для отправки в основную группу без треда, поддерживается только для Telegram)",
examples=[0, 123, 456]
),
messenger: Optional[str] = Query(
None,
description="Тип мессенджера (telegram, max). Если не указан, используется из конфигурации группы",
examples=["telegram", "max"]
),
alert: GrafanaAlert = Body(
...,
description="Данные алерта из Grafana",
examples=[
{
"title": "[Alerting] High CPU Usage",
"ruleId": 674180201771804383,
"ruleName": "High CPU Usage Alert",
"state": "alerting",
"evalMatches": [
{
"value": 95.5,
"metric": "cpu_usage_percent",
"tags": {"host": "server01", "instance": "production"}
}
],
"orgId": 1,
"dashboardId": 123,
"panelId": 456,
"tags": {"severity": "critical", "environment": "production"},
"ruleUrl": "http://grafana.cism-ms.ru/alerting/list",
"message": "CPU usage is above 90% threshold for more than 5 minutes"
},
{
"title": "[OK] High CPU Usage",
"ruleId": 674180201771804383,
"ruleName": "High CPU Usage Alert",
"state": "ok",
"evalMatches": [
{
"value": 45.2,
"metric": "cpu_usage_percent",
"tags": {"host": "server01", "instance": "production"}
}
],
"orgId": 1,
"dashboardId": 123,
"panelId": 456,
"tags": {"severity": "critical", "environment": "production"},
"ruleUrl": "http://grafana.cism-ms.ru/alerting/list",
"message": "CPU usage has returned to normal levels"
}
]
)
) -> Dict[str, str]:
"""
Отправить алерт из Grafana в мессенджер.
Принимает webhook от Grafana и отправляет сообщение в указанную группу мессенджера.
Не требует авторизации (API ключ не нужен).
Подробная документация: см. docs/monitoring/grafana.md
"""
metrics.increment_api_endpoint("grafana")
logger.info(f"Получен алерт Grafana для группы {group_name}, тред {thread_id}, мессенджер {messenger}")
try:
await grafana_send(group_name, thread_id, alert, messenger)
return {
"status": "ok",
"message": "Сообщение отправлено"
}
except ValueError as e:
logger.error(f"Ошибка валидации: {e}")
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Ошибка отправки алерта Grafana: {e}")
raise HTTPException(status_code=500, detail=f"Ошибка отправки сообщения: {str(e)}")
@router.post(
"/zabbix/{group_name}/{thread_id}",
name="Отправка вебхуков из Zabbix",
response_model=Dict[str, str],
summary="Отправить алерт из Zabbix",
description="Эндпоинт для обработки webhooks из Zabbix. **Не требует авторизации.**",
responses={
200: {
"description": "Сообщение успешно отправлено",
"content": {
"application/json": {
"example": {"status": "ok", "message": "Сообщение отправлено"}
}
}
},
400: {
"description": "Некорректные данные запроса",
"content": {
"application/json": {
"example": {"detail": "Неверный формат данных для Zabbix алерта"}
}
}
},
404: {
"description": "Группа не найдена",
"content": {
"application/json": {
"example": {"detail": "Группа 'monitoring' не найдена в конфигурации"}
}
}
},
500: {
"description": "Ошибка сервера",
"content": {
"application/json": {
"example": {"detail": "Ошибка отправки сообщения"}
}
}
}
}
)
async def send_zabbix_alert(
group_name: str = Path(
...,
description="Имя группы из конфигурации (config/groups.json)",
examples=["monitoring", "alerts", "devops"]
),
thread_id: int = Path(
...,
description="ID треда в группе (0 для отправки в основную группу без треда, поддерживается только для Telegram)",
examples=[0, 123, 456]
),
messenger: Optional[str] = Query(
None,
description="Тип мессенджера (telegram, max). Если не указан, используется из конфигурации группы",
examples=["telegram", "max"]
),
alert: ZabbixAlert = Body(
...,
description="Данные алерта из Zabbix",
examples=[
{
"link": "https://zabbix.example.com/tr_events.php?triggerid=42667&eventid=8819711",
"status": "PROBLEM",
"action-id": "7",
"alert-subject": "Problem: High CPU utilization (over 90% for 5m)",
"alert-message": "Problem started at 16:48:44 on 2024.02.08\r\nProblem name: High CPU utilization (over 90% for 5m)\r\nHost: pnode28\r\nSeverity: Warning\r\nCurrent utilization: 95.2 %\r\n",
"event-id": "8819711",
"event-name": "High CPU utilization (over 90% for 5m)",
"event-nseverity": "2",
"event-opdata": "Current utilization: 95.2 %",
"event-severity": "Warning",
"host-name": "pnode28",
"host-ip": "10.14.253.38",
"host-port": "10050"
},
{
"link": "https://zabbix.example.com/tr_events.php?triggerid=42667&eventid=8819711",
"status": "OK",
"action-id": "7",
"alert-subject": "Resolved in 1m 0s: High CPU utilization (over 90% for 5m)",
"alert-message": "Problem has been resolved at 16:49:44 on 2024.02.08\r\nProblem name: High CPU utilization (over 90% for 5m)\r\nProblem duration: 1m 0s\r\nHost: pnode28\r\nSeverity: Warning\r\nOriginal problem ID: 8819711\r\n",
"event-id": "8819711",
"event-name": "High CPU utilization (over 90% for 5m)",
"event-nseverity": "2",
"event-opdata": "Current utilization: 70.9 %",
"event-recovery-date": "2024.02.08",
"event-recovery-time": "16:49:44",
"event-duration": "1m 0s",
"event-recovery-name": "High CPU utilization (over 90% for 5m)",
"event-recovery-status": "RESOLVED",
"event-recovery-tags": "Application:CPU",
"event-severity": "Warning",
"host-name": "pnode28",
"host-ip": "10.14.253.38",
"host-port": "10050"
}
]
)
) -> Dict[str, str]:
"""
Отправить алерт из Zabbix в мессенджер.
Принимает webhook от Zabbix и отправляет сообщение в указанную группу мессенджера.
Не требует авторизации (API ключ не нужен).
Подробная документация: см. docs/monitoring/zabbix.md
"""
metrics.increment_api_endpoint("zabbix")
logger.info(f"Получен алерт Zabbix для группы {group_name}, тред {thread_id}, мессенджер {messenger}")
try:
await zabbix_send(group_name, thread_id, alert, messenger)
return {
"status": "ok",
"message": "Сообщение отправлено"
}
except ValueError as e:
logger.error(f"Ошибка валидации: {e}")
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Ошибка отправки алерта Zabbix: {e}")
raise HTTPException(status_code=500, detail=f"Ошибка отправки сообщения: {str(e)}")
@router.post(
"/alertmanager/{k8s_cluster}/{group_name}/{thread_id}",
name="Отправка вебхуков из AlertManager",
response_model=Dict[str, str],
summary="Отправить алерт из AlertManager",
description="Эндпоинт для обработки webhooks из AlertManager. **Не требует авторизации.**",
responses={
200: {
"description": "Сообщение успешно отправлено",
"content": {
"application/json": {
"example": {"status": "ok", "message": "Сообщение отправлено"}
}
}
},
400: {
"description": "Некорректные данные запроса",
"content": {
"application/json": {
"example": {"detail": "Неверный формат данных для AlertManager алерта"}
}
}
},
404: {
"description": "Группа не найдена",
"content": {
"application/json": {
"example": {"detail": "Группа 'monitoring' не найдена в конфигурации"}
}
}
},
500: {
"description": "Ошибка сервера",
"content": {
"application/json": {
"example": {"detail": "Ошибка отправки сообщения"}
}
}
}
}
)
async def send_alertmanager_alert(
k8s_cluster: str = Path(
...,
description="Имя Kubernetes кластера (используется для формирования URL к Grafana/Prometheus)",
examples=["production", "staging", "development"]
),
group_name: str = Path(
...,
description="Имя группы из конфигурации (config/groups.json)",
examples=["monitoring", "alerts", "devops"]
),
thread_id: int = Path(
...,
description="ID треда в группе (0 для отправки в основную группу без треда, поддерживается только для Telegram)",
examples=[0, 123, 456]
),
messenger: Optional[str] = Query(
None,
description="Тип мессенджера (telegram, max). Если не указан, используется из конфигурации группы",
examples=["telegram", "max"]
),
alert: PrometheusAlert = Body(
...,
description="Данные алерта из AlertManager",
examples=[
{
"status": "firing",
"externalURL": "http://alertmanager.example.com",
"commonLabels": {
"alertname": "HighCPUUsage",
"severity": "critical",
"namespace": "production",
"pod": "app-deployment-7d8f9b4c5-abc123",
"container": "app-container"
},
"commonAnnotations": {
"summary": "High CPU usage detected in production namespace",
"description": "CPU usage is above 90% for 5 minutes on pod app-deployment-7d8f9b4c5-abc123",
"runbook_url": "https://wiki.example.com/runbooks/high-cpu-usage"
}
},
{
"status": "resolved",
"externalURL": "http://alertmanager.example.com",
"commonLabels": {
"alertname": "HighCPUUsage",
"severity": "critical",
"namespace": "production",
"pod": "app-deployment-7d8f9b4c5-abc123",
"container": "app-container"
},
"commonAnnotations": {
"summary": "High CPU usage resolved in production namespace",
"description": "CPU usage has returned to normal levels on pod app-deployment-7d8f9b4c5-abc123"
}
}
]
)
) -> Dict[str, str]:
"""
Отправить алерт из AlertManager в мессенджер.
Эндпоинт для обработки webhooks из AlertManager.
Не требует авторизации (API ключ не нужен).
Подробная документация: см. docs/monitoring/alertmanager.md
"""
metrics.increment_api_endpoint("alertmanager")
logger.info(f"Получен алерт AlertManager для кластера {k8s_cluster}, группы {group_name}, тред {thread_id}, мессенджер {messenger}")
try:
if not isinstance(alert, PrometheusAlert):
raise HTTPException(status_code=400, detail="Неверный формат данных для AlertManager алерта")
await alertmanager_send(k8s_cluster, group_name, thread_id, alert, messenger)
return {
"status": "ok",
"message": "Сообщение отправлено"
}
except HTTPException:
raise
except ValueError as e:
logger.error(f"Ошибка валидации: {e}")
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Ошибка отправки алерта AlertManager: {e}")
raise HTTPException(status_code=500, detail=f"Ошибка отправки сообщения: {str(e)}")

27
app/api/v1/router.py Normal file
View File

@@ -0,0 +1,27 @@
"""
Роутер API версии 1.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from fastapi import APIRouter
from app.api.v1.endpoints import (
health,
monitoring,
debug,
jira,
message,
groups,
)
# Создаем роутер для API v1
api_router = APIRouter(prefix="/api/v1")
# Подключаем эндпоинты
api_router.include_router(health.router)
api_router.include_router(monitoring.router)
api_router.include_router(debug.router)
api_router.include_router(jira.router)
api_router.include_router(message.router)
api_router.include_router(groups.router)

0
app/common/__init__.py Normal file
View File

34
app/common/cors.py Normal file
View File

@@ -0,0 +1,34 @@
"""
Настройка CORS для приложения.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import logging
from fastapi.middleware.cors import CORSMiddleware
from typing import List
from app.core.config import settings
logger = logging.getLogger(__name__)
def add(app):
"""
Добавить CORS middleware к приложению FastAPI.
Args:
app: Экземпляр приложения FastAPI.
"""
# Разрешаем все источники (можно настроить через переменные окружения)
allow_origins: List[str] = ["*"]
app.add_middleware(
CORSMiddleware,
allow_origins=allow_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
logger.info("CORS middleware добавлен")

41
app/common/logger.py Normal file
View File

@@ -0,0 +1,41 @@
"""
Настройка логирования приложения.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import logging
import os
from opentelemetry.instrumentation.logging import LoggingInstrumentor
# Настраиваем уровень логирования сначала
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# Настройка логирования с поддержкой OpenTelemetry
try:
from app.core.config import settings
if settings.otel_enabled:
LoggingInstrumentor().instrument(
set_logging_format=True,
logging_format='%(levelname)s:\t %(message)s traceID=%(otelTraceID)s spanID=%(otelSpanID)s'
)
logger.info("OpenTelemetry логирование включено")
else:
LoggingInstrumentor().instrument(
set_logging_format=True,
logging_format='%(levelname)s:\t %(message)s'
)
except Exception as e:
logger.warning(f"Ошибка настройки OpenTelemetry логирования: {e}")
# Используем базовое логирование
LoggingInstrumentor().instrument(
set_logging_format=True,
logging_format='%(levelname)s:\t %(message)s'
)

32
app/common/metrics.py Normal file
View File

@@ -0,0 +1,32 @@
from prometheus_client import make_asgi_app, Counter, Gauge, CollectorRegistry, push_to_gateway # Добавляем метрики для прометея
from fastapi import Request
import os
#def add_middleware(app):
# # Обьявляем метрики, которые будем собирать
# registry = getMetricsRegistry()
# all_requests = Counter('tg_monitoring_gateway_all_requests_counter', 'Счетчик запросов', registry=registry)
# # Запускаем счетчик запросов
# @app.middleware("request_count")
# def request_count(request: Request, call_next):
# all_requests.inc()
# pushMetricsRegistry(registry, all_requests)
# response = call_next(request)
# return response
#def getMetricsRegistry():
# registry = CollectorRegistry()
# return(registry)
#def pushMetricsRegistry(registry, metric):
# pushgateway_url = os.getenv('PUSHGATEWAY_URL')
# pushgateway_job = os.getenv('PUSHGATEWAY_JOB')
# push_to_gateway(pushgateway_url, job=pushgateway_job, registry=registry)
# return(metric)
#def requestsCount(registry, endpoint):
# all_requests = Counter('tg_monitoring_gateway_api_requests_counter', 'Счетчик запросов к API', labelnames=['endpoint'], registry=registry)
# all_requests.labels(endpoint=endpoint).inc()
# pushMetricsRegistry(registry, all_requests)
# return(endpoint)

69
app/common/telemetry.py Normal file
View File

@@ -0,0 +1,69 @@
"""
Настройка телеметрии OpenTelemetry.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import logging
from opentelemetry import trace
from opentelemetry.sdk.resources import Resource, SERVICE_NAME
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.sampling import TraceIdRatioBased
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
logger = logging.getLogger(__name__)
def setup_telemetry():
"""
Настройка провайдера телеметрии OpenTelemetry.
Returns:
TracerProvider: Провайдер трейсинга.
"""
from app.core.config import settings
resource = Resource(attributes={SERVICE_NAME: settings.otel_service_name})
sampler = TraceIdRatioBased(1.0) # 100% семплирование
# Настройка экспортера OTLP
exporter_config = {}
if settings.otel_exporter_otlp_endpoint:
exporter_config['endpoint'] = settings.otel_exporter_otlp_endpoint
exporter_config['insecure'] = settings.otel_exporter_otlp_insecure
exporter = OTLPSpanExporter(**exporter_config)
provider = TracerProvider(sampler=sampler, resource=resource)
processor = SimpleSpanProcessor(exporter)
provider.add_span_processor(processor)
trace.set_tracer_provider(provider)
logger.info(f"Telemetry настроен для сервиса: {settings.otel_service_name}")
return provider
def add(app):
"""
Добавить инструментацию OpenTelemetry к приложению FastAPI.
Args:
app: Экземпляр приложения FastAPI.
"""
from app.core.config import settings
if settings.otel_enabled:
try:
tracer_provider = setup_telemetry()
FastAPIInstrumentor.instrument_app(
app,
tracer_provider=tracer_provider,
excluded_urls="/openapi.json,/docs,/redoc"
)
logger.info("OpenTelemetry инструментация добавлена")
except Exception as e:
logger.error(f"Ошибка настройки OpenTelemetry: {e}")
else:
logger.info("OpenTelemetry отключен")

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

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

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

476
app/core/messengers/max.py Normal file
View File

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

View File

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

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

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

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

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

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

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

187
app/main.py Normal file
View File

@@ -0,0 +1,187 @@
"""
Главный модуль приложения Telegram Gateway.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import logging
from fastapi import FastAPI
from app.common.cors import add as add_cors
from app.common.telemetry import add as add_telemetry
from app.api.v1.router import api_router
# Настройка логирования (базовая настройка, детальная настройка в app.common.logger)
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# Создаем приложение FastAPI
app = FastAPI(
title="Message Gateway",
summary="Отправляем мониторинговые алерты в телеграм/MAX и создаем тикеты в Jira",
description=(
"Приложение для оповещений, приходящих из Grafana/Zabbix/AlertManager "
"посредством вебхука, в телеграм/MAX. С возможностью отправок в треды и создания тикетов в Jira. "
"<br><br><b>Что бы начать отправлять сообщения</b>, добавьте бота "
"<b>@CismGlobalMonitoring_bot</b> в чат и <b>внесите изменения в группы</b>"
),
version="0.2.0",
contact={
"name": "Сергей Антропов",
"url": "https://devops.org.ru/contact/",
"email": "sergey@antropoff.ru",
},
license_info={
"name": "Apache 2.0",
"url": "https://www.apache.org/licenses/LICENSE-2.0.html",
},
debug=False,
swagger_ui_init_oauth={
"clientId": "api-key",
"appName": "Message Gateway API",
"usePkceWithAuthorizationCodeGrant": False,
}
)
# Добавляем схему безопасности в Swagger
from fastapi.openapi.utils import get_openapi
from fastapi.routing import APIRoute
def custom_openapi():
if app.openapi_schema:
return app.openapi_schema
# Собираем пути, которые должны быть скрыты от API
hidden_paths = set()
# Перебираем все routes и находим те, которые помечены как скрытые
for route in app.routes:
# Проверяем только APIRoute
if not isinstance(route, APIRoute):
continue
# Получаем endpoint функцию
endpoint = route.endpoint
# Проверяем, помечен ли endpoint как скрытый от API
if hasattr(endpoint, "__hide_from_api__") and endpoint.__hide_from_api__:
# Получаем полный путь (с учетом префиксов)
path = route.path
# Нормализуем путь (убираем параметры типа {param} для сопоставления)
# Но нам нужно точное сопоставление, поэтому используем полный путь
hidden_paths.add(path)
logger.debug(f"Эндпоинт {path} помечен как скрытый от API")
# Генерируем схему как обычно
openapi_schema = get_openapi(
title=app.title,
version=app.version,
description=app.description,
routes=app.routes,
)
# Удаляем скрытые пути из схемы
if hidden_paths:
paths = openapi_schema.get("paths", {})
# Создаем новый словарь paths без скрытых путей
filtered_paths = {}
for path, path_item in paths.items():
# Проверяем, должен ли путь быть скрыт
# Путь в схеме должен точно совпадать с путем в route.path
# route.path уже содержит все префиксы (router prefix + route path)
should_hide = path in hidden_paths
if not should_hide:
filtered_paths[path] = path_item
else:
logger.debug(f"Удаляем путь {path} из OpenAPI схемы")
openapi_schema["paths"] = filtered_paths
# Добавляем схему безопасности API Key
if "components" not in openapi_schema:
openapi_schema["components"] = {}
if "securitySchemes" not in openapi_schema["components"]:
openapi_schema["components"]["securitySchemes"] = {}
# Определяем имя схемы безопасности, которое использует FastAPI
# FastAPI автоматически генерирует имя на основе класса Security
# Обычно это имя класса в camelCase (например, "APIKeyHeader")
api_key_scheme_name = None
# Перебираем существующие схемы безопасности и находим API Key схему
for scheme_name, scheme_def in openapi_schema["components"].get("securitySchemes", {}).items():
if scheme_def.get("type") == "apiKey" and scheme_def.get("name") == "X-API-Key":
api_key_scheme_name = scheme_name
break
# Если схема не найдена, создаем новую с именем "ApiKeyAuth"
if not api_key_scheme_name:
api_key_scheme_name = "ApiKeyAuth"
openapi_schema["components"]["securitySchemes"][api_key_scheme_name] = {
"type": "apiKey",
"in": "header",
"name": "X-API-Key",
"description": "API ключ для авторизации. Получите его из переменной окружения API_KEY."
}
else:
# Обновляем существующую схему, если она уже есть
openapi_schema["components"]["securitySchemes"][api_key_scheme_name] = {
"type": "apiKey",
"in": "header",
"name": "X-API-Key",
"description": "API ключ для авторизации. Получите его из переменной окружения API_KEY."
}
# Если имя схемы не "ApiKeyAuth", переименовываем ее
if api_key_scheme_name != "ApiKeyAuth":
# Сохраняем старую схему
old_scheme = openapi_schema["components"]["securitySchemes"].pop(api_key_scheme_name)
# Создаем новую схему с именем "ApiKeyAuth"
openapi_schema["components"]["securitySchemes"]["ApiKeyAuth"] = old_scheme
# Заменяем все использования старого имени на новое в security эндпоинтов
for path, path_item in openapi_schema.get("paths", {}).items():
for method, operation in path_item.items():
if isinstance(operation, dict) and "security" in operation:
security_list = operation["security"]
for security_item in security_list:
if api_key_scheme_name in security_item:
# Заменяем старое имя на новое
security_item["ApiKeyAuth"] = security_item.pop(api_key_scheme_name)
# Убеждаемся, что схема "ApiKeyAuth" существует
if "ApiKeyAuth" not in openapi_schema["components"]["securitySchemes"]:
openapi_schema["components"]["securitySchemes"]["ApiKeyAuth"] = {
"type": "apiKey",
"in": "header",
"name": "X-API-Key",
"description": "API ключ для авторизации. Получите его из переменной окружения API_KEY."
}
app.openapi_schema = openapi_schema
return app.openapi_schema
app.openapi = custom_openapi
# Добавляем CORS
add_cors(app)
# Добавляем телеметрию
add_telemetry(app)
# Подключаем роутер API v1
app.include_router(api_router)
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"app.main:app",
host="0.0.0.0",
port=8000,
reload=False
)

16
app/models/__init__.py Normal file
View File

@@ -0,0 +1,16 @@
"""
Модели данных для приложения.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from app.models.alertmanager import PrometheusAlert
from app.models.grafana import GrafanaAlert, EvalMatch
from app.models.zabbix import ZabbixAlert
__all__ = [
"PrometheusAlert",
"GrafanaAlert",
"EvalMatch",
"ZabbixAlert",
]

View File

@@ -0,0 +1,69 @@
"""
Модели данных для AlertManager webhooks.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from typing import Dict, Any, Optional
from pydantic import BaseModel, Field
class PrometheusAlert(BaseModel):
"""Модель данных вебхука из AlertManager."""
status: str = Field(..., description="Статус алерта (firing, resolved)", examples=["firing"])
externalURL: str = Field(..., description="Внешний URL AlertManager", examples=["http://alertmanager.example.com"])
commonLabels: Dict[str, str] = Field(..., description="Общие метки алерта")
commonAnnotations: Dict[str, str] = Field(..., description="Общие аннотации алерта")
model_config = {
"json_schema_extra": {
"examples": [
{
"status": "firing",
"externalURL": "http://alertmanager.example.com",
"commonLabels": {
"alertname": "HighCPUUsage",
"severity": "critical",
"namespace": "production",
"pod": "app-deployment-7d8f9b4c5-abc123",
"container": "app-container"
},
"commonAnnotations": {
"summary": "High CPU usage detected in production namespace",
"description": "CPU usage is above 90% for 5 minutes on pod app-deployment-7d8f9b4c5-abc123",
"runbook_url": "https://wiki.example.com/runbooks/high-cpu-usage"
}
},
{
"status": "resolved",
"externalURL": "http://alertmanager.example.com",
"commonLabels": {
"alertname": "HighCPUUsage",
"severity": "critical",
"namespace": "production",
"pod": "app-deployment-7d8f9b4c5-abc123",
"container": "app-container"
},
"commonAnnotations": {
"summary": "High CPU usage resolved in production namespace",
"description": "CPU usage has returned to normal levels on pod app-deployment-7d8f9b4c5-abc123"
}
},
{
"status": "firing",
"externalURL": "http://alertmanager.example.com",
"commonLabels": {
"alertname": "PodCrashLooping",
"severity": "warning",
"namespace": "staging",
"pod": "test-app-5f6g7h8i9-jkl456"
},
"commonAnnotations": {
"summary": "Pod is crash looping",
"description": "Pod test-app-5f6g7h8i9-jkl456 has restarted 5 times in the last 10 minutes"
}
}
]
}
}

80
app/models/grafana.py Normal file
View File

@@ -0,0 +1,80 @@
"""
Модели данных для Grafana webhooks.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from typing import List, Optional, Dict, Any
from pydantic import BaseModel, Field
class EvalMatch(BaseModel):
"""Модель для evalMatches из Grafana."""
value: float = Field(..., description="Значение метрики", examples=[95.5, 87.2, 45.2])
metric: str = Field(..., description="Название метрики", examples=["cpu_usage_percent", "memory_usage_percent", "disk_usage_percent"])
tags: Optional[Dict[str, Any]] = Field(None, description="Теги метрики", examples=[{"host": "server01", "instance": "production"}, None])
class GrafanaAlert(BaseModel):
"""Модель данных вебхука из Grafana."""
title: str = Field(..., description="Заголовок алерта", examples=["[Alerting] Test notification"])
ruleId: int = Field(..., description="ID правила алерта", examples=[674180201771804383])
ruleName: str = Field(..., description="Название правила", examples=["Test notification"])
state: str = Field(..., description="Состояние алерта (alerting, ok, paused, pending, no_data)", examples=["alerting"])
evalMatches: List[EvalMatch] = Field(default_factory=list, description="Совпадения метрик")
orgId: int = Field(..., description="ID организации", examples=[0])
dashboardId: int = Field(..., description="ID дашборда", examples=[1])
panelId: int = Field(..., description="ID панели", examples=[1])
tags: Dict[str, str] = Field(default_factory=dict, description="Теги алерта")
ruleUrl: str = Field(..., description="URL правила алерта", examples=["http://grafana.cism-ms.ru/"])
message: Optional[str] = Field(None, description="Сообщение алерта", examples=["Someone is testing the alert notification within Grafana."])
model_config = {
"json_schema_extra": {
"examples": [
{
"title": "[Alerting] High CPU Usage",
"ruleId": 674180201771804383,
"ruleName": "High CPU Usage Alert",
"state": "alerting",
"evalMatches": [
{
"value": 95.5,
"metric": "cpu_usage_percent",
"tags": {"host": "server01", "instance": "production"}
},
{
"value": 87.2,
"metric": "memory_usage_percent",
"tags": {"host": "server01", "instance": "production"}
}
],
"orgId": 1,
"dashboardId": 123,
"panelId": 456,
"tags": {"severity": "critical", "environment": "production"},
"ruleUrl": "http://grafana.cism-ms.ru/alerting/list",
"message": "CPU usage is above 90% threshold for more than 5 minutes"
},
{
"title": "[OK] High CPU Usage",
"ruleId": 674180201771804383,
"ruleName": "High CPU Usage Alert",
"state": "ok",
"evalMatches": [
{
"value": 45.2,
"metric": "cpu_usage_percent",
"tags": {"host": "server01", "instance": "production"}
}
],
"orgId": 1,
"dashboardId": 123,
"panelId": 456,
"tags": {"severity": "critical", "environment": "production"},
"ruleUrl": "http://grafana.cism-ms.ru/alerting/list",
"message": "CPU usage has returned to normal levels"
}
]
}
}

106
app/models/group.py Normal file
View File

@@ -0,0 +1,106 @@
"""
Модели данных для управления группами мессенджеров.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from typing import Optional, Dict, Any, Union
from pydantic import BaseModel, Field
class GroupInfo(BaseModel):
"""Информация о группе."""
name: str = Field(..., description="Имя группы", examples=["monitoring", "alerts", "devops"])
messenger: Optional[str] = Field(None, description="Тип мессенджера (telegram, max)", examples=["telegram", "max"])
chat_id: Optional[Union[int, str]] = Field(None, description="Chat ID группы (отображается только при наличии пароля)", examples=[-1001234567890, "123456789", None])
thread_id: Optional[int] = Field(None, description="ID треда в группе (опционально, только для Telegram)", examples=[0, 123, 456])
model_config = {
"json_schema_extra": {
"examples": [
{
"name": "monitoring",
"messenger": "telegram",
"chat_id": -1001234567890,
"thread_id": 0
},
{
"name": "alerts_max",
"messenger": "max",
"chat_id": "123456789",
"thread_id": None
},
{
"name": "devops",
"messenger": None,
"chat_id": None,
"thread_id": None
}
]
}
}
class CreateGroupRequest(BaseModel):
"""Запрос на создание группы."""
group_name: str = Field(..., description="Имя группы", examples=["monitoring", "alerts", "devops"])
chat_id: Union[int, str] = Field(..., description="ID чата (может быть int для Telegram или str для MAX/VK)", examples=[-1001234567890, "123456789"])
messenger: str = Field("telegram", description="Тип мессенджера (telegram, max)", examples=["telegram", "max"])
thread_id: int = Field(0, description="ID треда в группе (по умолчанию 0, только для Telegram)", examples=[0, 123, 456])
config: Optional[Dict[str, Any]] = Field(None, description="Дополнительная конфигурация для мессенджера (опционально)", examples=[None, {"access_token": "..."}, {"api_version": "5.131"}])
model_config = {
"json_schema_extra": {
"examples": [
{
"group_name": "monitoring",
"chat_id": -1001234567890,
"messenger": "telegram",
"thread_id": 0,
},
{
"group_name": "alerts_max",
"chat_id": "123456789",
"messenger": "max",
"thread_id": 0,
"config": {
"access_token": "your_access_token",
"api_version": "5.131"
}
}
]
}
}
class UpdateGroupRequest(BaseModel):
"""Запрос на обновление группы."""
chat_id: Optional[Union[int, str]] = Field(None, description="Новый Chat ID группы (можно получить через @userinfobot для Telegram)", examples=[-1001234567891, "123456789"])
messenger: Optional[str] = Field(None, description="Новый тип мессенджера (telegram, max)", examples=["telegram", "max"])
thread_id: Optional[int] = Field(None, description="Новый ID треда в группе (опционально, только для Telegram)", examples=[0, 123, 456])
config: Optional[Dict[str, Any]] = Field(None, description="Новая дополнительная конфигурация для мессенджера (опционально)", examples=[None, {"access_token": "..."}, {"api_version": "5.131"}])
model_config = {
"json_schema_extra": {
"examples": [
{
"chat_id": -1001234567891,
"messenger": "telegram",
"thread_id": 0,
},
{
"chat_id": "123456789",
"messenger": "max",
"config": {
"access_token": "your_access_token",
"api_version": "5.131"
}
}
]
}
}
class DeleteGroupRequest(BaseModel):
"""Запрос на удаление группы."""
pass

73
app/models/jira.py Normal file
View File

@@ -0,0 +1,73 @@
"""
Модели данных для Jira тикетов.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from typing import Optional, Dict, Any, List
from pydantic import BaseModel, Field
class JiraMappingCondition(BaseModel):
"""Условия для маппинга алерта в Jira тикет."""
severity: Optional[str] = None
namespace: Optional[str] = None
state: Optional[str] = None
status: Optional[str] = None
tags: Optional[Dict[str, str]] = None
event_severity: Optional[str] = Field(None, alias="event-severity")
class JiraMapping(BaseModel):
"""Маппинг алерта в Jira тикет."""
conditions: JiraMappingCondition
project: str = Field(..., description="Ключ проекта Jira")
assignee: Optional[str] = Field(None, description="Email исполнителя")
issue_type: str = Field("Bug", description="Тип задачи")
priority: str = Field("High", description="Приоритет задачи")
labels: Optional[List[str]] = Field(None, description="Метки задачи")
model_config = {"populate_by_name": True}
class JiraSourceMapping(BaseModel):
"""Маппинг для источника алертов."""
default_project: str = Field(..., description="Проект по умолчанию")
default_assignee: Optional[str] = Field(None, description="Исполнитель по умолчанию")
default_issue_type: str = Field("Bug", description="Тип задачи по умолчанию")
default_priority: str = Field("High", description="Приоритет по умолчанию")
mappings: List[JiraMapping] = Field(default_factory=list, description="Список маппингов")
class JiraMappingConfig(BaseModel):
"""Конфигурация маппинга алертов в Jira тикеты."""
alertmanager: Optional[JiraSourceMapping] = None
grafana: Optional[JiraSourceMapping] = None
zabbix: Optional[JiraSourceMapping] = None
class JiraIssue(BaseModel):
"""Модель Jira тикета."""
project: str = Field(..., description="Ключ проекта")
summary: str = Field(..., description="Заголовок тикета")
description: str = Field(..., description="Описание тикета")
issue_type: str = Field("Bug", description="Тип задачи")
assignee: Optional[str] = Field(None, description="Email исполнителя")
priority: Optional[str] = Field(None, description="Приоритет задачи")
labels: Optional[List[str]] = Field(None, description="Метки задачи")
components: Optional[List[str]] = Field(None, description="Компоненты проекта")
model_config = {
"json_schema_extra": {
"examples": [{
"project": "MON",
"summary": "[Critical] High CPU Usage - Production",
"description": "**Alert:** High CPU Usage\n\n**Severity:** Critical\n\n**Namespace:** production",
"issue_type": "Bug",
"assignee": "devops-team@example.com",
"priority": "Highest",
"labels": ["critical", "production", "alertmanager"]
}]
}
}

195
app/models/message.py Normal file
View File

@@ -0,0 +1,195 @@
"""
Модели данных для отправки простых сообщений в Telegram.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from typing import Optional
from pydantic import BaseModel, Field
class SendMessageRequest(BaseModel):
"""Запрос на отправку текстового сообщения."""
tg_group: str = Field(..., description="Имя группы Telegram из конфигурации", examples=["monitoring", "alerts", "devops"])
tg_thread_id: int = Field(0, description="ID треда в группе Telegram (0 для основной группы)", examples=[0, 123, 456])
text: str = Field(..., description="Текст сообщения", examples=["Привет! Это тестовое сообщение.", "<b>Важное уведомление</b>\n\nСистема работает нормально."])
parse_mode: Optional[str] = Field("HTML", description="Режим парсинга (HTML, Markdown, MarkdownV2)", examples=["HTML", "Markdown", "MarkdownV2"])
disable_web_page_preview: bool = Field(True, description="Отключить превью ссылок", examples=[True, False])
model_config = {
"json_schema_extra": {
"examples": [
{
"tg_group": "monitoring",
"tg_thread_id": 0,
"text": "Привет! Это тестовое сообщение.",
"parse_mode": "HTML",
"disable_web_page_preview": True
},
{
"tg_group": "alerts",
"tg_thread_id": 123,
"text": "<b>Критическое уведомление</b>\n\nСистема недоступна!\n\n<i>Время: 2024-02-08 16:49:44</i>",
"parse_mode": "HTML",
"disable_web_page_preview": False
},
{
"tg_group": "monitoring",
"tg_thread_id": 0,
"text": "**Важное уведомление**\n\nСистема работает нормально.\n\n[Подробнее](https://example.com)",
"parse_mode": "Markdown",
"disable_web_page_preview": True
}
]
}
}
class SendPhotoRequest(BaseModel):
"""Запрос на отправку фото."""
tg_group: str = Field(..., description="Имя группы Telegram из конфигурации", examples=["monitoring", "alerts"])
tg_thread_id: int = Field(0, description="ID треда в группе Telegram (0 для основной группы)", examples=[0, 123])
photo: str = Field(..., description="URL фото или путь к файлу (поддерживается автоматическая загрузка с URL)", examples=["https://example.com/image.jpg", "https://grafana.example.com/render/dashboard-solo?panelId=1&width=1000&height=500"])
caption: Optional[str] = Field(None, description="Подпись к фото", examples=["График производительности", "<b>График CPU</b>\n\nВремя: 2024-02-08 16:49:44"])
parse_mode: Optional[str] = Field("HTML", description="Режим парсинга подписи (HTML, Markdown, MarkdownV2)", examples=["HTML", "Markdown"])
model_config = {
"json_schema_extra": {
"examples": [
{
"tg_group": "monitoring",
"tg_thread_id": 0,
"photo": "https://example.com/image.jpg",
"caption": "Описание фото",
"parse_mode": "HTML"
},
{
"tg_group": "alerts",
"tg_thread_id": 123,
"photo": "https://grafana.example.com/render/dashboard-solo?panelId=1&width=1000&height=500",
"caption": "<b>График CPU</b>\n\n<i>Время: 2024-02-08 16:49:44</i>",
"parse_mode": "HTML"
}
]
}
}
class SendVideoRequest(BaseModel):
"""Запрос на отправку видео."""
tg_group: str = Field(..., description="Имя группы Telegram из конфигурации", examples=["monitoring", "alerts"])
tg_thread_id: int = Field(0, description="ID треда в группе Telegram (0 для основной группы)", examples=[0, 123])
video: str = Field(..., description="URL видео или путь к файлу (поддерживается автоматическая загрузка с URL)", examples=["https://example.com/video.mp4", "https://example.com/recording.webm"])
caption: Optional[str] = Field(None, description="Подпись к видео", examples=["Запись экрана", "<b>Запись работы системы</b>\n\nДлительность: 60 сек"])
parse_mode: Optional[str] = Field("HTML", description="Режим парсинга подписи (HTML, Markdown, MarkdownV2)", examples=["HTML", "Markdown"])
duration: Optional[int] = Field(None, description="Длительность видео в секундах", examples=[60, 120, 300])
width: Optional[int] = Field(None, description="Ширина видео в пикселях", examples=[1280, 1920])
height: Optional[int] = Field(None, description="Высота видео в пикселях", examples=[720, 1080])
model_config = {
"json_schema_extra": {
"examples": [
{
"tg_group": "monitoring",
"tg_thread_id": 0,
"video": "https://example.com/video.mp4",
"caption": "Описание видео",
"parse_mode": "HTML",
"duration": 60,
"width": 1280,
"height": 720
},
{
"tg_group": "alerts",
"tg_thread_id": 123,
"video": "https://example.com/recording.webm",
"caption": "<b>Запись работы системы</b>\n\n<i>Длительность: 60 сек</i>",
"parse_mode": "HTML",
"duration": 60,
"width": 1920,
"height": 1080
}
]
}
}
class SendAudioRequest(BaseModel):
"""Запрос на отправку аудио."""
tg_group: str = Field(..., description="Имя группы Telegram из конфигурации", examples=["monitoring", "alerts"])
tg_thread_id: int = Field(0, description="ID треда в группе Telegram (0 для основной группы)", examples=[0, 123])
audio: str = Field(..., description="URL аудио файла или путь к файлу (поддерживается автоматическая загрузка с URL)", examples=["https://example.com/audio.mp3", "https://example.com/notification.ogg"])
caption: Optional[str] = Field(None, description="Подпись к аудио", examples=["Аудио уведомление", "<b>Аудио запись</b>\n\nДлительность: 3 мин"])
parse_mode: Optional[str] = Field("HTML", description="Режим парсинга подписи (HTML, Markdown, MarkdownV2)", examples=["HTML", "Markdown"])
duration: Optional[int] = Field(None, description="Длительность аудио в секундах", examples=[180, 300])
performer: Optional[str] = Field(None, description="Исполнитель (для музыкальных файлов)", examples=["Artist Name", "System Notification"])
title: Optional[str] = Field(None, description="Название трека (для музыкальных файлов)", examples=["Song Title", "Alert Notification"])
model_config = {
"json_schema_extra": {
"examples": [
{
"tg_group": "monitoring",
"tg_thread_id": 0,
"audio": "https://example.com/audio.mp3",
"caption": "Описание аудио",
"parse_mode": "HTML",
"duration": 180,
"performer": "Artist Name",
"title": "Song Title"
},
{
"tg_group": "alerts",
"tg_thread_id": 123,
"audio": "https://example.com/notification.ogg",
"caption": "<b>Аудио уведомление</b>\n\n<i>Система работает нормально</i>",
"parse_mode": "HTML",
"duration": 30,
"performer": "System Notification",
"title": "Alert Notification"
}
]
}
}
class SendDocumentRequest(BaseModel):
"""Запрос на отправку документа."""
tg_group: str = Field(..., description="Имя группы Telegram из конфигурации", examples=["monitoring", "alerts"])
tg_thread_id: int = Field(0, description="ID треда в группе Telegram (0 для основной группы)", examples=[0, 123])
document: str = Field(..., description="URL документа или путь к файлу (поддерживается автоматическая загрузка с URL)", examples=["https://example.com/file.pdf", "https://example.com/report.xlsx"])
caption: Optional[str] = Field(None, description="Подпись к документу", examples=["Отчет за неделю", "<b>Отчет</b>\n\nДата: 2024-02-08"])
parse_mode: Optional[str] = Field("HTML", description="Режим парсинга подписи (HTML, Markdown, MarkdownV2)", examples=["HTML", "Markdown"])
filename: Optional[str] = Field(None, description="Имя файла (если не указано, используется имя из URL)", examples=["document.pdf", "report_2024-02-08.xlsx"])
model_config = {
"json_schema_extra": {
"examples": [
{
"tg_group": "monitoring",
"tg_thread_id": 0,
"document": "https://example.com/file.pdf",
"caption": "Описание документа",
"parse_mode": "HTML",
"filename": "document.pdf"
},
{
"tg_group": "alerts",
"tg_thread_id": 123,
"document": "https://example.com/report.xlsx",
"caption": "<b>Отчет за неделю</b>\n\n<i>Дата: 2024-02-08</i>",
"parse_mode": "HTML",
"filename": "report_2024-02-08.xlsx"
},
{
"tg_group": "monitoring",
"tg_thread_id": 0,
"document": "https://example.com/logs.txt",
"caption": "Логи системы",
"parse_mode": "HTML",
"filename": "system_logs_2024-02-08.txt"
}
]
}
}

81
app/models/zabbix.py Normal file
View File

@@ -0,0 +1,81 @@
"""
Модели данных для Zabbix webhooks.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
from typing import Optional
from pydantic import BaseModel, Field
class ZabbixAlert(BaseModel):
"""Модель данных вебхука из Zabbix."""
link: str = Field(..., description="Ссылка на событие в Zabbix", examples=["{$ZABBIX_URL}/tr_events.php?triggerid=42667&eventid=8819711"])
status: str = Field(..., description="Статус события (OK, PROBLEM)", examples=["OK"])
action_id: str = Field(..., alias="action-id", description="ID действия", examples=["7"])
alert_subject: str = Field(..., alias="alert-subject", description="Тема алерта", examples=["Resolved in 1m 0s: High CPU utilization (over 90% for 5m)"])
alert_message: str = Field(..., alias="alert-message", description="Сообщение алерта", examples=["Problem has been resolved at 16:49:44 on 2024.02.08"])
event_id: str = Field(..., alias="event-id", description="ID события", examples=["8819711"])
event_name: str = Field(..., alias="event-name", description="Название события", examples=["High CPU utilization (over 90% for 5m)"])
event_nseverity: str = Field(..., alias="event-nseverity", description="Числовой уровень серьезности", examples=["2"])
event_opdata: Optional[str] = Field(None, alias="event-opdata", description="Операционные данные события", examples=["Current utilization: 70.9 %"])
event_recovery_date: Optional[str] = Field(None, alias="event-recovery-date", description="Дата восстановления", examples=["2024.02.08"])
event_recovery_time: Optional[str] = Field(None, alias="event-recovery-time", description="Время восстановления", examples=["16:49:44"])
event_duration: Optional[str] = Field(None, alias="event-duration", description="Длительность события", examples=["1m 0s"])
event_recovery_name: Optional[str] = Field(None, alias="event-recovery-name", description="Название восстановленного события", examples=["High CPU utilization (over 90% for 5m)"])
event_recovery_status: Optional[str] = Field(None, alias="event-recovery-status", description="Статус восстановления", examples=["RESOLVED"])
event_recovery_tags: Optional[str] = Field(None, alias="event-recovery-tags", description="Теги восстановленного события", examples=["Application:CPU"])
event_severity: Optional[str] = Field(None, alias="event-severity", description="Уровень серьезности (Disaster, High, Warning, Average, Information)", examples=["Warning"])
host_name: str = Field(..., alias="host-name", description="Имя хоста", examples=["pnode28"])
host_ip: str = Field(..., alias="host-ip", description="IP адрес хоста", examples=["10.14.253.38"])
host_port: str = Field(..., alias="host-port", description="Порт хоста", examples=["10050"])
model_config = {
"populate_by_name": True,
"json_schema_extra": {
"examples": [
{
"link": "https://zabbix.example.com/tr_events.php?triggerid=42667&eventid=8819711",
"status": "PROBLEM",
"action-id": "7",
"alert-subject": "Problem: High CPU utilization (over 90% for 5m)",
"alert-message": "Problem started at 16:48:44 on 2024.02.08\r\nProblem name: High CPU utilization (over 90% for 5m)\r\nHost: pnode28\r\nSeverity: Warning\r\nCurrent utilization: 95.2 %\r\n",
"event-id": "8819711",
"event-name": "High CPU utilization (over 90% for 5m)",
"event-nseverity": "2",
"event-opdata": "Current utilization: 95.2 %",
"event-recovery-date": None,
"event-recovery-time": None,
"event-duration": None,
"event-recovery-name": None,
"event-recovery-status": None,
"event-recovery-tags": None,
"event-severity": "Warning",
"host-name": "pnode28",
"host-ip": "10.14.253.38",
"host-port": "10050"
},
{
"link": "https://zabbix.example.com/tr_events.php?triggerid=42667&eventid=8819711",
"status": "OK",
"action-id": "7",
"alert-subject": "Resolved in 1m 0s: High CPU utilization (over 90% for 5m)",
"alert-message": "Problem has been resolved at 16:49:44 on 2024.02.08\r\nProblem name: High CPU utilization (over 90% for 5m)\r\nProblem duration: 1m 0s\r\nHost: pnode28\r\nSeverity: Warning\r\nOriginal problem ID: 8819711\r\n",
"event-id": "8819711",
"event-name": "High CPU utilization (over 90% for 5m)",
"event-nseverity": "2",
"event-opdata": "Current utilization: 70.9 %",
"event-recovery-date": "2024.02.08",
"event-recovery-time": "16:49:44",
"event-duration": "1m 0s",
"event-recovery-name": "High CPU utilization (over 90% for 5m)",
"event-recovery-status": "RESOLVED",
"event-recovery-tags": "Application:CPU",
"event-severity": "Warning",
"host-name": "pnode28",
"host-ip": "10.14.253.38",
"host-port": "10050"
}
]
}
}

0
app/modules/__init__.py Normal file
View File

311
app/modules/alertmanager.py Normal file
View File

@@ -0,0 +1,311 @@
"""
Модуль для обработки алертов из AlertManager.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import logging
from typing import Tuple, Optional
from jinja2 import Environment, FileSystemLoader
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
from app.models.alertmanager import PrometheusAlert
from app.core.groups import groups_config
from app.core.metrics import metrics
from app.core.jira_utils import create_jira_ticket_from_alert
from app.core.jira_mapping import jira_mapping_manager
from app.core.utils import check_stop_words, add_spaces_to_alert_name, truncate_message
from app.core.messenger_factory import MessengerFactory
from app.core.button_utils import convert_telegram_buttons_to_dict
logger = logging.getLogger(__name__)
def _get_status_icons(status: str, severity: Optional[str]) -> Tuple[str, str, str]:
"""
Получить иконки и название статуса в зависимости от статуса и серьезности алерта.
Args:
status: Статус алерта (firing, resolved, critical).
severity: Уровень серьезности.
Returns:
Кортеж (alert_icon, status_icon, status_name).
"""
if status == "critical" or severity == "critical":
return ("🔴", "💀", "Бросаем все и чиним")
elif status == "firing" or severity == "firing" or severity == "warning":
return ("🟡", "🔥", "Что-то сломалось")
elif status == "resolved":
return ("🟢", "", "Работает")
else:
return ("🔸", "", status)
async def send(
k8s_cluster: str,
group_name: str,
thread_id: int,
alert: PrometheusAlert,
messenger: Optional[str] = None
) -> None:
"""
Отправить алерт из AlertManager в мессенджер.
Args:
k8s_cluster: Имя Kubernetes кластера.
group_name: Имя группы из конфигурации.
thread_id: ID треда в группе (0 для основной группы).
alert: Данные алерта из AlertManager.
messenger: Тип мессенджера (опционально, если не указан - используется из конфигурации группы).
Raises:
ValueError: Если группа не найдена в конфигурации или алерт заблокирован стоп-словами.
"""
source = "alertmanager"
# Увеличиваем счетчик полученных сообщений
metrics.increment_total_message(source, k8s_cluster, group_name, thread_id)
# Проверяем стоп-слова
alert_name = alert.commonLabels.get("alertname", "")
if alert_name and check_stop_words(alert_name):
logger.info(f"Алерт '{alert_name}' заблокирован стоп-словами")
metrics.increment_reject_message(source, k8s_cluster, group_name, thread_id)
return # Не отправляем сообщение
# Получаем конфигурацию группы
group_config = await groups_config.get_group_config(group_name, messenger)
if group_config is None:
raise ValueError(f"Группа '{group_name}' не найдена в конфигурации")
messenger_type = group_config.get("messenger", "telegram")
chat_id = group_config.get("chat_id")
group_thread_id = group_config.get("thread_id", 0)
# Используем thread_id из параметра, если указан, иначе из конфигурации группы
final_thread_id = thread_id if thread_id > 0 else group_thread_id
# Создаем клиент мессенджера
messenger_client = MessengerFactory.create_from_config(group_config)
# Проверяем поддержку тредов
if not messenger_client.supports_threads and final_thread_id > 0:
logger.warning(f"Мессенджер '{messenger_type}' не поддерживает треды, thread_id будет проигнорирован")
final_thread_id = None
elif final_thread_id == 0:
final_thread_id = None
# Формируем сообщение
message, buttons = render_message(k8s_cluster, group_name, thread_id, alert)
# Обрезаем сообщение если оно слишком длинное
message = truncate_message(message)
# Создаем Jira тикет, если включено и нужно
jira_issue_key = None
from app.core.config import get_settings
settings = get_settings()
if settings.jira_enabled:
should_create_ticket = (
(settings.jira_create_on_alert and alert.status != "resolved") or
(settings.jira_create_on_resolved and alert.status == "resolved")
)
if should_create_ticket:
try:
jira_issue_key = await create_jira_ticket_from_alert(
alert=alert,
source=source,
k8s_cluster=k8s_cluster
)
if jira_issue_key:
# Добавляем кнопку Jira в сообщение
jira_button = _create_jira_button(jira_issue_key, settings)
if jira_button:
if buttons:
# Добавляем кнопку к существующим кнопкам
new_buttons = buttons.inline_keyboard.copy()
new_buttons.append([jira_button])
buttons = InlineKeyboardMarkup(new_buttons)
else:
# Если кнопок еще нет, создаем новую клавиатуру
buttons = InlineKeyboardMarkup([[jira_button]])
except Exception as e:
logger.error(f"Ошибка создания Jira тикета: {e}")
# Преобразуем кнопки в универсальный формат
buttons_dict = convert_telegram_buttons_to_dict(buttons)
# Отправляем сообщение
success = await messenger_client.send_message(
chat_id=chat_id,
text=message,
thread_id=final_thread_id,
reply_markup=buttons_dict,
disable_web_page_preview=True,
parse_mode="HTML"
)
if success:
metrics.increment_sent_message(source, k8s_cluster, group_name, thread_id)
# Увеличиваем счетчики в зависимости от статуса
severity = alert.commonLabels.get("severity", "")
if alert.status == "firing" or severity in ["firing", "warning"]:
metrics.increment_firing_message(source, k8s_cluster, group_name, thread_id)
elif alert.status == "critical" or severity == "critical":
metrics.increment_critical_message(source, k8s_cluster, group_name, thread_id)
elif alert.status == "resolved":
metrics.increment_resolved_message(source, k8s_cluster, group_name, thread_id)
else:
metrics.increment_error_message(source, k8s_cluster, group_name, thread_id)
raise Exception(f"Ошибка отправки сообщения в {group_config.get('messenger', 'unknown')}")
def _create_jira_button(issue_key: str, settings) -> Optional[InlineKeyboardButton]:
"""
Создать кнопку для ссылки на Jira тикет.
Args:
issue_key: Ключ тикета Jira (например, "MON-123").
settings: Настройки приложения.
Returns:
InlineKeyboardButton с ссылкой на тикет или None.
"""
if not issue_key or not settings.jira_url:
return None
jira_url = f"{settings.jira_url}/browse/{issue_key}"
return InlineKeyboardButton(f"📋 Jira: {issue_key}", url=jira_url)
def render_message(
k8s_cluster: str,
group_name: str,
thread_id: int,
alert: PrometheusAlert
) -> Tuple[str, Optional[InlineKeyboardMarkup]]:
"""
Сформировать сообщение и кнопки для мессенджера из алерта AlertManager.
Args:
k8s_cluster: Имя Kubernetes кластера.
group_name: Имя группы.
thread_id: ID треда в группе.
alert: Данные алерта из AlertManager.
Returns:
Кортеж (message, buttons).
"""
message_dict = {}
# Обрабатываем аннотации
another_annotations = ""
runbook_url = ""
for key, value in alert.commonAnnotations.items():
if key == "summary":
message_dict['summary'] = value.rstrip()
elif key == "description":
message_dict['description'] = value.rstrip()
elif key == "runbook_url":
message_dict['runbook_url'] = value
runbook_url = value
else:
another_annotations += f"<b>{key}</b>: {value}\n"
message_dict['another_annotations'] = another_annotations
# Обрабатываем метки
another_labels = ""
severity = ""
alertname = ""
for key, value in alert.commonLabels.items():
if key == "alertname":
alertname = add_spaces_to_alert_name(value)
message_dict['alertname'] = alertname
elif key == "severity":
message_dict['severity'] = value
severity = value
elif key in [
"daemonset", "statefulset", "replicaset", "job_name", "To", "integration",
"condition", "reason", "alertstate", "clustername", "namespace", "node",
"persistentvolumeclaim", "service", "container", "endpoint", "instance",
"job", "prometheus", "pod", "deployment", "metrics_path", "grpc_method",
"grpc_service", "uid"
]:
# Маппинг ключей для шаблона
if key == "namespace":
message_dict['ns'] = value
else:
message_dict[key] = value
else:
another_labels += f"{key}: {value}\n"
message_dict['another_labels'] = another_labels
# Получаем иконки статуса
alert_icon, status_icon, status_name = _get_status_icons(alert.status, severity)
message_dict['alert_icon'] = alert_icon
message_dict['status_icon'] = status_icon
message_dict['status_name'] = status_name
# Рендерим шаблон
from app.core.config import get_settings
settings = get_settings()
environment = Environment(loader=FileSystemLoader(settings.templates_path))
template = environment.get_template("alertmanager.tmpl")
message = template.render(message_dict)
# Формируем кнопки
buttons = render_buttons(k8s_cluster, runbook_url, alert.status)
logger.info("Сообщение AlertManager сформировано")
return message, buttons
def render_buttons(
k8s_cluster: str,
runbook_url: str,
alert_status: str
) -> Optional[InlineKeyboardMarkup]:
"""
Сформировать кнопки для сообщения мессенджера.
Args:
k8s_cluster: Имя Kubernetes кластера.
runbook_url: URL runbook с решением проблемы.
alert_status: Статус алерта.
Returns:
InlineKeyboardMarkup с кнопками или None.
"""
from app.core.config import get_settings
settings = get_settings()
buttons = []
try:
# Кнопки для мониторинга Kubernetes
grafana_url = settings.get_k8s_grafana_url(k8s_cluster)
prometheus_url = settings.get_k8s_prometheus_url(k8s_cluster)
alertmanager_url = settings.get_k8s_alertmanager_url(k8s_cluster)
buttons.append([
InlineKeyboardButton("Grafana", url=grafana_url),
InlineKeyboardButton("Prometheus", url=prometheus_url),
InlineKeyboardButton("Alertmanager", url=alertmanager_url)
])
except ValueError as e:
logger.warning(f"Не удалось сформировать URL для Kubernetes: {e}")
# Кнопка runbook (только для активных алертов)
if runbook_url and alert_status != "resolved":
buttons.append([InlineKeyboardButton("Вариант решения проблемы...", url=runbook_url)])
markup = InlineKeyboardMarkup(buttons) if buttons else None
logger.debug("Кнопки AlertManager сгенерированы")
return markup

261
app/modules/grafana.py Normal file
View File

@@ -0,0 +1,261 @@
"""
Модуль для обработки алертов из Grafana.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import logging
from typing import Tuple, Optional, Dict, Any
from jinja2 import Environment, FileSystemLoader
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
from app.models.grafana import GrafanaAlert
from app.core.groups import groups_config
from app.core.metrics import metrics
from app.core.jira_utils import create_jira_ticket_from_alert
from app.core.utils import truncate_message
from app.core.messenger_factory import MessengerFactory
from app.core.button_utils import convert_telegram_buttons_to_dict
logger = logging.getLogger(__name__)
def _get_status_icons(state: str) -> Tuple[str, str, str]:
"""
Получить иконки и название статуса в зависимости от состояния алерта.
Args:
state: Состояние алерта из Grafana.
Returns:
Кортеж (alert_icon, status_icon, status_name).
"""
status_map = {
"alerting": ("🔴", "💀", "Бросаем все и чиним"),
"ok": ("🟢", "", "Заработало"),
"paused": ("🟡", "🐢", "Пауза? Серьезно?!"),
"pending": ("🟠", "🤷", "Что-то начинается..."),
"no_data": ("🔵", "🥴", "Данные куда-то пропали"),
}
return status_map.get(state, ("🔸", "", state))
async def send(group_name: str, thread_id: int, alert: GrafanaAlert, messenger: Optional[str] = None) -> None:
"""
Отправить алерт из Grafana в мессенджер.
Args:
group_name: Имя группы из конфигурации.
thread_id: ID треда в группе (0 для основной группы).
alert: Данные алерта из Grafana.
messenger: Тип мессенджера (опционально, если не указан - используется из конфигурации группы).
Raises:
ValueError: Если группа не найдена в конфигурации.
"""
source = "grafana"
k8s_cluster = ""
# Увеличиваем счетчик полученных сообщений
metrics.increment_total_message(source, k8s_cluster, group_name, thread_id)
# Получаем конфигурацию группы
group_config = await groups_config.get_group_config(group_name, messenger)
if group_config is None:
raise ValueError(f"Группа '{group_name}' не найдена в конфигурации")
messenger_type = group_config.get("messenger", "telegram")
chat_id = group_config.get("chat_id")
group_thread_id = group_config.get("thread_id", 0)
# Используем thread_id из параметра, если указан, иначе из конфигурации группы
final_thread_id = thread_id if thread_id > 0 else group_thread_id
# Создаем клиент мессенджера
messenger_client = MessengerFactory.create_from_config(group_config)
# Проверяем поддержку тредов
if not messenger_client.supports_threads and final_thread_id > 0:
logger.warning(f"Мессенджер '{messenger_type}' не поддерживает треды, thread_id будет проигнорирован")
final_thread_id = None
elif final_thread_id == 0:
final_thread_id = None
# Формируем сообщение
message, buttons = render_message(group_name, thread_id, alert)
# Обрезаем сообщение если оно слишком длинное
message = truncate_message(message)
# Создаем Jira тикет, если включено и нужно
jira_issue_key = None
from app.core.config import get_settings
settings = get_settings()
if settings.jira_enabled:
should_create_ticket = (
(settings.jira_create_on_alert and alert.state == "alerting") or
(settings.jira_create_on_resolved and alert.state == "ok")
)
if should_create_ticket:
try:
jira_issue_key = await create_jira_ticket_from_alert(
alert=alert,
source=source,
k8s_cluster=k8s_cluster
)
if jira_issue_key:
# Добавляем кнопку Jira в сообщение
jira_button = _create_jira_button(jira_issue_key, settings)
if jira_button:
if buttons:
# Добавляем кнопку к существующим кнопкам
new_buttons = buttons.inline_keyboard.copy()
new_buttons.append([jira_button])
buttons = InlineKeyboardMarkup(new_buttons)
else:
# Если кнопок еще нет, создаем новую клавиатуру
buttons = InlineKeyboardMarkup([[jira_button]])
except Exception as e:
logger.error(f"Ошибка создания Jira тикета: {e}")
# Преобразуем кнопки в универсальный формат
buttons_dict = convert_telegram_buttons_to_dict(buttons)
# Отправляем сообщение
success = await messenger_client.send_message(
chat_id=chat_id,
text=message,
thread_id=final_thread_id,
reply_markup=buttons_dict,
disable_web_page_preview=True,
parse_mode="HTML"
)
if success:
metrics.increment_sent_message(source, k8s_cluster, group_name, thread_id)
# Увеличиваем счетчики в зависимости от состояния
if alert.state == "alerting":
metrics.increment_firing_message(source, k8s_cluster, group_name, thread_id)
elif alert.state == "ok":
metrics.increment_resolved_message(source, k8s_cluster, group_name, thread_id)
else:
metrics.increment_error_message(source, k8s_cluster, group_name, thread_id)
raise Exception(f"Ошибка отправки сообщения в {group_config.get('messenger', 'unknown')}")
def _create_jira_button(issue_key: str, settings) -> Optional[InlineKeyboardButton]:
"""
Создать кнопку для ссылки на Jira тикет.
Args:
issue_key: Ключ тикета Jira (например, "MON-123").
settings: Настройки приложения.
Returns:
InlineKeyboardButton с ссылкой на тикет или None.
"""
if not issue_key or not settings.jira_url:
return None
jira_url = f"{settings.jira_url}/browse/{issue_key}"
return InlineKeyboardButton(f"📋 Jira: {issue_key}", url=jira_url)
def render_message(group_name: str, thread_id: int, alert: GrafanaAlert) -> Tuple[str, InlineKeyboardMarkup]:
"""
Сформировать сообщение и кнопки для мессенджера из алерта Grafana.
Args:
group_name: Имя группы.
thread_id: ID треда в группе.
alert: Данные алерта из Grafana.
Returns:
Кортеж (message, buttons).
"""
# Получаем иконки статуса
alert_icon, status_icon, status_name = _get_status_icons(alert.state)
# Формируем словарь для шаблона
message_dict = {
'state': alert.state,
'alert_icon': alert_icon,
'status_icon': status_icon,
'status_name': status_name,
'title': alert.title,
'message': alert.message or '',
'ruleid': alert.ruleId,
'rulename': alert.ruleName,
'orgid': alert.orgId,
'dashboardid': alert.dashboardId,
'panelid': alert.panelId,
}
# Обрабатываем evalMatches
labels_text = ""
for eval_match in alert.evalMatches:
labels_text += f"<b>{eval_match.metric}:</b> {eval_match.value}\n"
message_dict['labels'] = labels_text
# Обрабатываем теги
tags_text = ""
for key, value in alert.tags.items():
tags_text += f"<b>{key}:</b> {value}\n"
message_dict['tags'] = tags_text
# Получаем URL дашборда
dashboard_url = alert.ruleUrl.split("?")[0] if alert.ruleUrl else ""
rule_url = alert.ruleUrl
# Рендерим шаблон
from app.core.config import get_settings
settings = get_settings()
environment = Environment(loader=FileSystemLoader(settings.templates_path))
template = environment.get_template("grafana.tmpl")
message = template.render(message_dict)
# Формируем кнопки
buttons = render_buttons(dashboard_url, rule_url)
logger.info("Сообщение Grafana сформировано")
return message, buttons
def render_buttons(dashboard_url: str, rule_url: str) -> InlineKeyboardMarkup:
"""
Сформировать кнопки для сообщения мессенджера.
Args:
dashboard_url: URL дашборда Grafana.
rule_url: URL правила алерта.
Returns:
InlineKeyboardMarkup с кнопками.
"""
from app.core.config import get_settings
settings = get_settings()
buttons = []
# Главная кнопка Grafana
if settings.grafana_url:
buttons.append([InlineKeyboardButton("Графана", url=settings.grafana_url)])
# Кнопки дашборда и алерта
row_buttons = []
if dashboard_url:
row_buttons.append(InlineKeyboardButton("Дашборд", url=dashboard_url))
if rule_url:
row_buttons.append(InlineKeyboardButton("Алерт", url=rule_url))
if row_buttons:
buttons.append(row_buttons)
markup = InlineKeyboardMarkup(buttons) if buttons else None
logger.debug("Кнопки Grafana сгенерированы")
return markup

252
app/modules/zabbix.py Normal file
View File

@@ -0,0 +1,252 @@
"""
Модуль для обработки алертов из Zabbix.
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import logging
from typing import Tuple, Optional
from jinja2 import Environment, FileSystemLoader
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
from app.models.zabbix import ZabbixAlert
from app.core.groups import groups_config
from app.core.metrics import metrics
from app.core.jira_utils import create_jira_ticket_from_alert
from app.core.utils import truncate_message
from app.core.messenger_factory import MessengerFactory
from app.core.button_utils import convert_telegram_buttons_to_dict
logger = logging.getLogger(__name__)
def _get_status_icons(severity: Optional[str], status: str) -> Tuple[str, str, str]:
"""
Получить иконки и название статуса в зависимости от серьезности и статуса алерта.
Args:
severity: Уровень серьезности (Disaster, High, Warning, Average, Information).
status: Статус события (OK, PROBLEM).
Returns:
Кортеж (alert_icon, status_icon, status_name).
"""
severity = severity or "Information"
status_map = {
("Disaster", "PROBLEM"): ("🔴", "💀", "Катастрофа. Бросаем все и чиним."),
("Disaster", "OK"): ("🟢", "💀", "Катастрофы избежали. Все работает. Пошли смотреть логи!"),
("High", "PROBLEM"): ("🟠", "😡", "Оперативно реагируем, диагностируем и чиним."),
("High", "OK"): ("🟢", "😡", "Отреагировали. Продиагностировали и починили."),
("Warning", "PROBLEM"): ("🟡", "🤔", "Ой. Что-то сломалось! Пойдем посмотрим?!"),
("Warning", "OK"): ("🟢", "🤔", "Посмотрели. Доламывать не стали. Починили."),
("Average", "PROBLEM"): ("🔵", "😒", "Ну такое себе. Но можно глянуть..."),
("Average", "OK"): ("🟢", "😒", "Пока ничего критичного. Само починилось"),
("Information", "PROBLEM"): ("🟣", "👻", "Ничего критичного. Просто информирую"),
("Information", "OK"): ("🟢", "👻", "Все молодцы. IT + DevOps = 🤝"),
}
return status_map.get((severity, status), ("🔸", "", severity))
async def send(group_name: str, thread_id: int, alert: ZabbixAlert, messenger: Optional[str] = None) -> None:
"""
Отправить алерт из Zabbix в мессенджер.
Args:
group_name: Имя группы из конфигурации.
thread_id: ID треда в группе (0 для основной группы).
alert: Данные алерта из Zabbix.
messenger: Тип мессенджера (опционально, если не указан - используется из конфигурации группы).
Raises:
ValueError: Если группа не найдена в конфигурации.
"""
source = "zabbix"
k8s_cluster = ""
# Увеличиваем счетчик полученных сообщений
metrics.increment_total_message(source, k8s_cluster, group_name, thread_id)
# Получаем конфигурацию группы
group_config = await groups_config.get_group_config(group_name, messenger)
if group_config is None:
raise ValueError(f"Группа '{group_name}' не найдена в конфигурации")
messenger_type = group_config.get("messenger", "telegram")
chat_id = group_config.get("chat_id")
group_thread_id = group_config.get("thread_id", 0)
# Используем thread_id из параметра, если указан, иначе из конфигурации группы
final_thread_id = thread_id if thread_id > 0 else group_thread_id
# Создаем клиент мессенджера
messenger_client = MessengerFactory.create_from_config(group_config)
# Проверяем поддержку тредов
if not messenger_client.supports_threads and final_thread_id > 0:
logger.warning(f"Мессенджер '{messenger_type}' не поддерживает треды, thread_id будет проигнорирован")
final_thread_id = None
elif final_thread_id == 0:
final_thread_id = None
# Формируем сообщение
message, buttons = render_message(group_name, thread_id, alert)
# Обрезаем сообщение если оно слишком длинное
message = truncate_message(message)
# Создаем Jira тикет, если включено и нужно
jira_issue_key = None
from app.core.config import get_settings
settings = get_settings()
if settings.jira_enabled:
should_create_ticket = (
(settings.jira_create_on_alert and alert.status == "PROBLEM") or
(settings.jira_create_on_resolved and alert.status == "OK")
)
if should_create_ticket:
try:
jira_issue_key = await create_jira_ticket_from_alert(
alert=alert,
source=source,
k8s_cluster=k8s_cluster
)
if jira_issue_key:
# Добавляем кнопку Jira в сообщение
jira_button = _create_jira_button(jira_issue_key, settings)
if jira_button:
if buttons:
# Добавляем кнопку к существующим кнопкам
new_buttons = buttons.inline_keyboard.copy()
new_buttons.append([jira_button])
buttons = InlineKeyboardMarkup(new_buttons)
else:
# Если кнопок еще нет, создаем новую клавиатуру
buttons = InlineKeyboardMarkup([[jira_button]])
except Exception as e:
logger.error(f"Ошибка создания Jira тикета: {e}")
# Преобразуем кнопки в универсальный формат
buttons_dict = convert_telegram_buttons_to_dict(buttons)
# Отправляем сообщение
success = await messenger_client.send_message(
chat_id=chat_id,
text=message,
thread_id=final_thread_id,
reply_markup=buttons_dict,
disable_web_page_preview=True,
parse_mode="HTML"
)
if success:
metrics.increment_sent_message(source, k8s_cluster, group_name, thread_id)
# Увеличиваем счетчики в зависимости от статуса
if alert.status == "PROBLEM":
metrics.increment_firing_message(source, k8s_cluster, group_name, thread_id)
elif alert.status == "OK":
metrics.increment_resolved_message(source, k8s_cluster, group_name, thread_id)
else:
metrics.increment_error_message(source, k8s_cluster, group_name, thread_id)
raise Exception(f"Ошибка отправки сообщения в {group_config.get('messenger', 'unknown')}")
def _create_jira_button(issue_key: str, settings) -> Optional[InlineKeyboardButton]:
"""
Создать кнопку для ссылки на Jira тикет.
Args:
issue_key: Ключ тикета Jira (например, "MON-123").
settings: Настройки приложения.
Returns:
InlineKeyboardButton с ссылкой на тикет или None.
"""
if not issue_key or not settings.jira_url:
return None
jira_url = f"{settings.jira_url}/browse/{issue_key}"
return InlineKeyboardButton(f"📋 Jira: {issue_key}", url=jira_url)
def render_message(group_name: str, thread_id: int, alert: ZabbixAlert) -> Tuple[str, InlineKeyboardMarkup]:
"""
Сформировать сообщение и кнопки для мессенджера из алерта Zabbix.
Args:
group_name: Имя группы.
thread_id: ID треда в группе.
alert: Данные алерта из Zabbix.
Returns:
Кортеж (message, buttons).
"""
# Получаем иконки статуса
severity = alert.event_severity or "Information"
alert_icon, status_icon, status_name = _get_status_icons(severity, alert.status)
# Формируем словарь для шаблона
message_dict = {
'state': severity,
'alert_icon': alert_icon,
'status_icon': status_icon,
'status_name': status_name,
'title': alert.event_name,
'subject': alert.alert_subject,
'message': alert.alert_message,
'message_data': alert.event_opdata or '',
'label_date': alert.event_recovery_date or '',
'label_time': alert.event_recovery_time or '',
'label_duration': alert.event_duration or '',
'label_host': alert.host_name,
'label_ip': alert.host_ip,
'label_port': alert.host_port,
}
# Рендерим шаблон
from app.core.config import get_settings
settings = get_settings()
environment = Environment(loader=FileSystemLoader(settings.templates_path))
template = environment.get_template("zabbix.tmpl")
message = template.render(message_dict)
# Формируем кнопки
alert_url_path = alert.link.split("/")[-1] if alert.link else ""
buttons = render_buttons(alert_url_path)
logger.info("Сообщение Zabbix сформировано")
return message, buttons
def render_buttons(alert_url_path: str) -> InlineKeyboardMarkup:
"""
Сформировать кнопки для сообщения мессенджера.
Args:
alert_url_path: Путь к событию в Zabbix.
Returns:
InlineKeyboardMarkup с кнопками.
"""
from app.core.config import get_settings
settings = get_settings()
buttons = []
if settings.zabbix_url:
# Кнопка Zabbix
buttons.append([InlineKeyboardButton("Заббикс", url=settings.zabbix_url)])
# Кнопка перехода к алерту
if alert_url_path:
alert_url = f"{settings.zabbix_url}/{alert_url_path}"
buttons.append([InlineKeyboardButton("Перейти к алерту", url=alert_url)])
markup = InlineKeyboardMarkup(buttons) if buttons else None
logger.debug("Кнопки Zabbix сгенерированы")
return markup