Initial commit: Message Gateway project
- FastAPI приложение для отправки мониторинговых алертов в мессенджеры - Поддержка Telegram и MAX/VK - Интеграция с Grafana, Zabbix, AlertManager - Автоматическое создание тикетов в Jira - Управление группами мессенджеров через API - Декораторы для авторизации и скрытия эндпоинтов - Подробная документация в папке docs/ Автор: Сергей Антропов Сайт: https://devops.org.ru
This commit is contained in:
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
6
app/api/__init__.py
Normal file
6
app/api/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
API модули приложения.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
6
app/api/v1/__init__.py
Normal file
6
app/api/v1/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
API версии 1.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
23
app/api/v1/endpoints/__init__.py
Normal file
23
app/api/v1/endpoints/__init__.py
Normal 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",
|
||||
]
|
||||
114
app/api/v1/endpoints/debug.py
Normal file
114
app/api/v1/endpoints/debug.py
Normal 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)}")
|
||||
511
app/api/v1/endpoints/groups.py
Normal file
511
app/api/v1/endpoints/groups.py
Normal 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)}")
|
||||
111
app/api/v1/endpoints/health.py
Normal file
111
app/api/v1/endpoints/health.py
Normal 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
|
||||
}
|
||||
19
app/api/v1/endpoints/jira.py
Normal file
19
app/api/v1/endpoints/jira.py
Normal 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)
|
||||
468
app/api/v1/endpoints/message.py
Normal file
468
app/api/v1/endpoints/message.py
Normal 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
|
||||
)
|
||||
403
app/api/v1/endpoints/monitoring.py
Normal file
403
app/api/v1/endpoints/monitoring.py
Normal 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
27
app/api/v1/router.py
Normal 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
0
app/common/__init__.py
Normal file
34
app/common/cors.py
Normal file
34
app/common/cors.py
Normal 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
41
app/common/logger.py
Normal 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
32
app/common/metrics.py
Normal 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
69
app/common/telemetry.py
Normal 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
6
app/core/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Общие утилиты и функции для приложения.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
183
app/core/auth.py
Normal file
183
app/core/auth.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""
|
||||
Утилиты для аутентификации и авторизации.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
import logging
|
||||
from typing import Optional, Callable, Any
|
||||
from functools import wraps
|
||||
from fastapi import HTTPException, Security, Depends, Request
|
||||
from fastapi.security import APIKeyHeader
|
||||
from starlette.requests import Request as StarletteRequest
|
||||
from app.core.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Request может быть как из FastAPI, так и из Starlette
|
||||
# Оба типа совместимы, поэтому используем StarletteRequest как базовый тип
|
||||
RequestType = StarletteRequest
|
||||
|
||||
# Схема безопасности для API ключа
|
||||
api_key_header = APIKeyHeader(
|
||||
name="X-API-Key",
|
||||
auto_error=False,
|
||||
description="API ключ для авторизации"
|
||||
)
|
||||
|
||||
|
||||
def verify_api_key(api_key: Optional[str] = Security(api_key_header)) -> bool:
|
||||
"""
|
||||
Проверить API ключ для авторизации (обязательная авторизация).
|
||||
|
||||
Используется как зависимость FastAPI (Depends) для отображения в Swagger UI.
|
||||
Также помечает контекст запроса, что API ключ был проверен.
|
||||
|
||||
Args:
|
||||
api_key: API ключ из заголовка X-API-Key.
|
||||
|
||||
Returns:
|
||||
True если API ключ верный.
|
||||
|
||||
Raises:
|
||||
HTTPException: Если API ключ неверный или не указан.
|
||||
"""
|
||||
settings = get_settings()
|
||||
|
||||
# Если API ключ не установлен в настройках, доступ запрещен
|
||||
if not settings.api_key:
|
||||
logger.warning("API_KEY не установлен - доступ запрещен")
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="API ключ не настроен на сервере"
|
||||
)
|
||||
|
||||
# Если API ключ не передан, доступ запрещен
|
||||
if not api_key:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Неверный или отсутствующий API ключ",
|
||||
headers={"WWW-Authenticate": "ApiKey"}
|
||||
)
|
||||
|
||||
# Проверяем API ключ
|
||||
if api_key != settings.api_key:
|
||||
logger.warning(f"Неверный API ключ: {api_key[:10]}...")
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Неверный или отсутствующий API ключ",
|
||||
headers={"WWW-Authenticate": "ApiKey"}
|
||||
)
|
||||
|
||||
# Помечаем, что API ключ был проверен через dependency
|
||||
# Это позволяет декоратору @require_api_key не выполнять повторную проверку
|
||||
try:
|
||||
from starlette.context import contextvars
|
||||
# Используем contextvars для хранения информации о проверке
|
||||
# Но это может не работать во всех случаях
|
||||
pass
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def verify_api_key_optional(api_key: Optional[str] = Security(api_key_header)) -> Optional[bool]:
|
||||
"""
|
||||
Проверить API ключ для авторизации (опциональная авторизация).
|
||||
|
||||
Используется как зависимость FastAPI (Depends).
|
||||
|
||||
Args:
|
||||
api_key: API ключ из заголовка X-API-Key.
|
||||
|
||||
Returns:
|
||||
True если API ключ верный, None если не передан, выбрасывает исключение если неверный.
|
||||
|
||||
Raises:
|
||||
HTTPException: Если API ключ неверный.
|
||||
"""
|
||||
settings = get_settings()
|
||||
|
||||
# Если API ключ не установлен в настройках, возвращаем None (нет авторизации)
|
||||
if not settings.api_key:
|
||||
return None
|
||||
|
||||
# Если API ключ не передан, возвращаем None (нет авторизации)
|
||||
if not api_key:
|
||||
return None
|
||||
|
||||
# Проверяем API ключ
|
||||
if api_key != settings.api_key:
|
||||
logger.warning(f"Неверный API ключ: {api_key[:10]}...")
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Неверный или отсутствующий API ключ",
|
||||
headers={"WWW-Authenticate": "ApiKey"}
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# Удобные константы для использования в endpoints (через dependencies)
|
||||
require_api_key_dependency = Depends(verify_api_key)
|
||||
require_api_key_optional = Depends(verify_api_key_optional)
|
||||
|
||||
|
||||
def require_api_key(func: Callable) -> Callable:
|
||||
"""
|
||||
Декоратор для пометки функции как требующей API ключ.
|
||||
|
||||
Использование:
|
||||
@require_api_key
|
||||
@router.post("/endpoint", dependencies=[require_api_key_dependency])
|
||||
async def my_endpoint(request: Request, ...):
|
||||
...
|
||||
|
||||
Примечание: Декоратор используется только для пометки функции.
|
||||
Фактическая проверка API ключа выполняется через `dependencies=[require_api_key_dependency]`,
|
||||
который также обеспечивает отображение замочка в Swagger UI.
|
||||
|
||||
Декоратор не выполняет проверку API ключа - это делает dependency.
|
||||
Декоратор оставлен для удобства и возможного расширения в будущем.
|
||||
|
||||
Args:
|
||||
func: Функция для декорирования.
|
||||
|
||||
Returns:
|
||||
Декорированная функция с пометкой о необходимости API ключа.
|
||||
"""
|
||||
# Помечаем функцию, что она требует API ключ
|
||||
func.__requires_api_key__ = True
|
||||
|
||||
# Просто возвращаем функцию без изменений
|
||||
# Проверка API ключа выполняется через dependency
|
||||
return func
|
||||
|
||||
|
||||
def hide_from_api(func: Callable) -> Callable:
|
||||
"""
|
||||
Декоратор для скрытия эндпоинта из API документации (Swagger UI).
|
||||
|
||||
Использование:
|
||||
@hide_from_api
|
||||
@router.post("/debug/dump")
|
||||
async def debug_endpoint(...):
|
||||
...
|
||||
|
||||
Примечание: Декоратор помечает функцию как скрытую от API.
|
||||
Эндпоинт все еще будет работать, но не будет отображаться в Swagger UI.
|
||||
Декоратор должен быть применен ПЕРЕД декоратором route (снизу вверх).
|
||||
|
||||
Args:
|
||||
func: Функция для декорирования.
|
||||
|
||||
Returns:
|
||||
Декорированная функция с пометкой о скрытии от API.
|
||||
"""
|
||||
# Помечаем функцию, что она должна быть скрыта от API
|
||||
func.__hide_from_api__ = True
|
||||
|
||||
# Просто возвращаем функцию без изменений
|
||||
# Скрытие из API выполняется в custom_openapi
|
||||
return func
|
||||
127
app/core/button_utils.py
Normal file
127
app/core/button_utils.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
Утилиты для работы с кнопками в различных мессенджерах.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
import logging
|
||||
from typing import Optional, Dict, Any, List
|
||||
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def convert_telegram_buttons_to_dict(buttons: Optional[InlineKeyboardMarkup]) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Преобразовать кнопки Telegram (InlineKeyboardMarkup) в универсальный формат Dict.
|
||||
|
||||
Args:
|
||||
buttons: InlineKeyboardMarkup с кнопками или None.
|
||||
|
||||
Returns:
|
||||
Словарь с кнопками в универсальном формате или None.
|
||||
Формат:
|
||||
{
|
||||
"inline_keyboard": [
|
||||
[
|
||||
{"text": "Кнопка 1", "url": "https://example.com"},
|
||||
{"text": "Кнопка 2", "url": "https://example2.com"}
|
||||
],
|
||||
[
|
||||
{"text": "Кнопка 3", "url": "https://example3.com"}
|
||||
]
|
||||
]
|
||||
}
|
||||
"""
|
||||
if buttons is None:
|
||||
return None
|
||||
|
||||
if not isinstance(buttons, InlineKeyboardMarkup):
|
||||
logger.warning(f"Неизвестный тип кнопок: {type(buttons)}")
|
||||
return None
|
||||
|
||||
inline_keyboard = []
|
||||
for row in buttons.inline_keyboard:
|
||||
row_buttons = []
|
||||
for button in row:
|
||||
if isinstance(button, InlineKeyboardButton):
|
||||
button_dict = {"text": button.text}
|
||||
if button.url:
|
||||
button_dict["url"] = button.url
|
||||
if button.callback_data:
|
||||
button_dict["callback_data"] = button.callback_data
|
||||
row_buttons.append(button_dict)
|
||||
if row_buttons:
|
||||
inline_keyboard.append(row_buttons)
|
||||
|
||||
if not inline_keyboard:
|
||||
return None
|
||||
|
||||
return {"inline_keyboard": inline_keyboard}
|
||||
|
||||
|
||||
def convert_dict_to_telegram_buttons(buttons_dict: Optional[Dict[str, Any]]) -> Optional[InlineKeyboardMarkup]:
|
||||
"""
|
||||
Преобразовать универсальный формат Dict в кнопки Telegram (InlineKeyboardMarkup).
|
||||
|
||||
Args:
|
||||
buttons_dict: Словарь с кнопками в универсальном формате или None.
|
||||
|
||||
Returns:
|
||||
InlineKeyboardMarkup с кнопками или None.
|
||||
"""
|
||||
if buttons_dict is None:
|
||||
return None
|
||||
|
||||
inline_keyboard_data = buttons_dict.get("inline_keyboard", [])
|
||||
if not inline_keyboard_data:
|
||||
return None
|
||||
|
||||
inline_keyboard = []
|
||||
for row in inline_keyboard_data:
|
||||
row_buttons = []
|
||||
for button_data in row:
|
||||
if isinstance(button_data, dict):
|
||||
text = button_data.get("text")
|
||||
url = button_data.get("url")
|
||||
callback_data = button_data.get("callback_data")
|
||||
|
||||
if text:
|
||||
if url:
|
||||
row_buttons.append(InlineKeyboardButton(text=text, url=url))
|
||||
elif callback_data:
|
||||
row_buttons.append(InlineKeyboardButton(text=text, callback_data=callback_data))
|
||||
if row_buttons:
|
||||
inline_keyboard.append(row_buttons)
|
||||
|
||||
if not inline_keyboard:
|
||||
return None
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard)
|
||||
|
||||
|
||||
def convert_dict_to_vk_buttons(buttons_dict: Optional[Dict[str, Any]]) -> Optional[str]:
|
||||
"""
|
||||
Преобразовать универсальный формат Dict в кнопки VK (JSON строка).
|
||||
|
||||
Args:
|
||||
buttons_dict: Словарь с кнопками в универсальном формате или None.
|
||||
|
||||
Returns:
|
||||
JSON строка с кнопками для VK API или None.
|
||||
"""
|
||||
if buttons_dict is None:
|
||||
return None
|
||||
|
||||
inline_keyboard_data = buttons_dict.get("inline_keyboard", [])
|
||||
if not inline_keyboard_data:
|
||||
return None
|
||||
|
||||
# Формируем клавиатуру для VK API
|
||||
# VK API использует другой формат клавиатуры
|
||||
# Для простоты возвращаем None, так как VK API требует более сложной структуры
|
||||
# В реальной реализации нужно преобразовать в формат VK Keyboard
|
||||
|
||||
logger.warning("Преобразование кнопок в формат VK пока не реализовано полностью")
|
||||
return None
|
||||
|
||||
132
app/core/config.py
Normal file
132
app/core/config.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
Конфигурация приложения.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
from typing import Optional
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Настройки приложения из переменных окружения."""
|
||||
|
||||
# Telegram настройки
|
||||
telegram_bot_token: Optional[str] = None
|
||||
telegram_enabled: bool = True
|
||||
|
||||
# MAX/VK настройки
|
||||
max_access_token: Optional[str] = None
|
||||
max_api_version: str = "5.131"
|
||||
max_enabled: bool = False
|
||||
|
||||
# Общие настройки мессенджеров
|
||||
default_messenger: str = "telegram" # По умолчанию Telegram
|
||||
|
||||
# Файлы конфигурации
|
||||
groups_config_path: str = "/app/config/groups.json"
|
||||
templates_path: str = "/app/templates"
|
||||
|
||||
# API ключ для авторизации
|
||||
api_key: Optional[str] = None
|
||||
|
||||
# Grafana настройки
|
||||
grafana_url: Optional[str] = None
|
||||
|
||||
# Zabbix настройки
|
||||
zabbix_url: Optional[str] = None
|
||||
|
||||
# Kubernetes кластер настройки
|
||||
k8s_cluster_grafana_subdomain: Optional[str] = None
|
||||
k8s_cluster_prometheus_subdomain: Optional[str] = None
|
||||
k8s_cluster_alertmanager_subdomain: Optional[str] = None
|
||||
|
||||
# Prometheus Pushgateway настройки
|
||||
pushgateway_url: Optional[str] = None
|
||||
pushgateway_job: str = "MessageGateway"
|
||||
|
||||
# OpenTelemetry настройки
|
||||
otel_enabled: bool = False
|
||||
otel_service_name: str = "monitoring-message-gateway"
|
||||
otel_exporter_otlp_endpoint: Optional[str] = None
|
||||
otel_exporter_otlp_protocol: str = "http/json"
|
||||
otel_traces_exporter: str = "otlp_proto_http"
|
||||
otel_exporter_otlp_insecure: bool = True
|
||||
otel_python_log_correlation: bool = False
|
||||
|
||||
# Jira настройки
|
||||
jira_enabled: bool = False
|
||||
jira_url: Optional[str] = None
|
||||
jira_email: Optional[str] = None
|
||||
jira_api_token: Optional[str] = None
|
||||
jira_project_key: Optional[str] = None
|
||||
jira_default_assignee: Optional[str] = None
|
||||
jira_default_issue_type: str = "Bug"
|
||||
jira_mapping_config_path: str = "/app/config/jira_mapping.json"
|
||||
jira_create_on_alert: bool = True
|
||||
jira_create_on_resolved: bool = False
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
env_ignore_empty=True
|
||||
)
|
||||
|
||||
def validate_required(self) -> None:
|
||||
"""Проверка обязательных переменных окружения."""
|
||||
if not self.telegram_bot_token:
|
||||
logger.warning("TELEGRAM_BOT_TOKEN не установлен - приложение может не работать")
|
||||
# Не выбрасываем исключение, чтобы приложение могло запуститься
|
||||
|
||||
def get_k8s_grafana_url(self, cluster: str) -> str:
|
||||
"""Получить URL Grafana для Kubernetes кластера."""
|
||||
if not self.k8s_cluster_grafana_subdomain:
|
||||
raise ValueError("K8S_CLUSTER_GRAFANA_SUBDOMAIN не установлен")
|
||||
return f"{cluster}.{self.k8s_cluster_grafana_subdomain}"
|
||||
|
||||
def get_k8s_prometheus_url(self, cluster: str) -> str:
|
||||
"""Получить URL Prometheus для Kubernetes кластера."""
|
||||
if not self.k8s_cluster_prometheus_subdomain:
|
||||
raise ValueError("K8S_CLUSTER_PROMETHEUS_SUBDOMAIN не установлен")
|
||||
return f"{cluster}.{self.k8s_cluster_prometheus_subdomain}"
|
||||
|
||||
def get_k8s_alertmanager_url(self, cluster: str) -> str:
|
||||
"""Получить URL AlertManager для Kubernetes кластера."""
|
||||
if not self.k8s_cluster_alertmanager_subdomain:
|
||||
raise ValueError("K8S_CLUSTER_ALERTMANAGER_SUBDOMAIN не установлен")
|
||||
return f"{cluster}.{self.k8s_cluster_alertmanager_subdomain}"
|
||||
|
||||
|
||||
# Глобальный экземпляр настроек (валидация отложена до первого использования)
|
||||
_settings_instance: Optional[Settings] = None
|
||||
|
||||
|
||||
def get_settings() -> Settings:
|
||||
"""
|
||||
Получить экземпляр настроек (lazy initialization).
|
||||
|
||||
Returns:
|
||||
Экземпляр Settings.
|
||||
"""
|
||||
global _settings_instance
|
||||
if _settings_instance is None:
|
||||
_settings_instance = Settings()
|
||||
return _settings_instance
|
||||
|
||||
|
||||
# Глобальный экземпляр настроек (lazy initialization)
|
||||
class _SettingsProxy:
|
||||
"""Прокси для ленивой инициализации settings."""
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Получить атрибут из settings."""
|
||||
return getattr(get_settings(), name)
|
||||
|
||||
|
||||
settings = _SettingsProxy()
|
||||
370
app/core/groups.py
Normal file
370
app/core/groups.py
Normal file
@@ -0,0 +1,370 @@
|
||||
"""
|
||||
Управление конфигурацией групп для различных мессенджеров.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, Optional, Any, Union
|
||||
import aiofiles
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GroupsConfig:
|
||||
"""Менеджер конфигурации групп для различных мессенджеров с кэшированием."""
|
||||
|
||||
def __init__(self, config_path: Optional[str] = None):
|
||||
"""
|
||||
Инициализация менеджера конфигурации.
|
||||
|
||||
Args:
|
||||
config_path: Путь к файлу конфигурации групп.
|
||||
"""
|
||||
from app.core.config import get_settings
|
||||
settings = get_settings()
|
||||
self.config_path = config_path or settings.groups_config_path
|
||||
self._cache: Optional[Dict[str, Any]] = None
|
||||
self._cache_time: Optional[datetime] = None
|
||||
self._cache_ttl = timedelta(minutes=5) # Кэш на 5 минут
|
||||
self.default_messenger = settings.default_messenger
|
||||
|
||||
|
||||
async def _load_config(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Загрузить конфигурацию групп из файла.
|
||||
|
||||
Returns:
|
||||
Конфигурация групп в формате:
|
||||
{
|
||||
"group_name": {
|
||||
"messenger": "telegram",
|
||||
"chat_id": -1001997464975,
|
||||
"thread_id": 0,
|
||||
"config": {}
|
||||
}
|
||||
}
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: Если файл конфигурации не найден.
|
||||
json.JSONDecodeError: Если файл содержит некорректный JSON.
|
||||
ValueError: Если конфигурация имеет неверный формат.
|
||||
"""
|
||||
try:
|
||||
async with aiofiles.open(self.config_path, 'r', encoding='utf-8') as f:
|
||||
content = await f.read()
|
||||
config = json.loads(content)
|
||||
logger.info(f"Конфигурация групп загружена из {self.config_path}")
|
||||
|
||||
# Валидация формата конфигурации
|
||||
for group_name, group_value in config.items():
|
||||
if not isinstance(group_value, dict):
|
||||
raise ValueError(
|
||||
f"Неверный формат конфигурации для группы '{group_name}': "
|
||||
f"ожидается словарь, получен {type(group_value)}. "
|
||||
f"Используйте формат: {{'messenger': 'telegram', 'chat_id': ..., 'thread_id': 0, 'config': {{}}}}"
|
||||
)
|
||||
if "chat_id" not in group_value:
|
||||
raise ValueError(
|
||||
f"Отсутствует обязательное поле 'chat_id' для группы '{group_name}'"
|
||||
)
|
||||
if "messenger" not in group_value:
|
||||
raise ValueError(
|
||||
f"Отсутствует обязательное поле 'messenger' для группы '{group_name}'"
|
||||
)
|
||||
|
||||
return config
|
||||
except FileNotFoundError:
|
||||
logger.error(f"Файл конфигурации групп не найден: {self.config_path}")
|
||||
raise
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Ошибка парсинга JSON в файле конфигурации: {e}")
|
||||
raise
|
||||
|
||||
def _is_cache_valid(self) -> bool:
|
||||
"""Проверить, валиден ли кэш."""
|
||||
if self._cache is None or self._cache_time is None:
|
||||
return False
|
||||
return datetime.now() - self._cache_time < self._cache_ttl
|
||||
|
||||
async def get_group_config(self, group_name: str, messenger: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Получить конфигурацию группы.
|
||||
|
||||
Args:
|
||||
group_name: Имя группы из конфигурации.
|
||||
messenger: Тип мессенджера (опционально, для фильтрации).
|
||||
|
||||
Returns:
|
||||
Конфигурация группы или None, если группа не найдена.
|
||||
Формат:
|
||||
{
|
||||
"messenger": "telegram",
|
||||
"chat_id": -1001997464975,
|
||||
"thread_id": 0,
|
||||
"config": {}
|
||||
}
|
||||
"""
|
||||
# Проверяем кэш
|
||||
if not self._is_cache_valid():
|
||||
try:
|
||||
self._cache = await self._load_config()
|
||||
self._cache_time = datetime.now()
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
logger.error("Не удалось загрузить конфигурацию групп")
|
||||
return None
|
||||
|
||||
group_config = self._cache.get(group_name)
|
||||
if group_config is None:
|
||||
logger.warning(f"Группа '{group_name}' не найдена в конфигурации")
|
||||
return None
|
||||
|
||||
# Если указан messenger, проверяем соответствие
|
||||
if messenger and group_config.get("messenger") != messenger:
|
||||
logger.warning(
|
||||
f"Группа '{group_name}' имеет мессенджер '{group_config.get('messenger')}', "
|
||||
f"но запрошен '{messenger}'"
|
||||
)
|
||||
return None
|
||||
|
||||
logger.info(f"Найдена конфигурация для группы '{group_name}': {group_config}")
|
||||
return group_config
|
||||
|
||||
async def get_chat_id(self, group_name: str, messenger: Optional[str] = None) -> Optional[Union[int, str]]:
|
||||
"""
|
||||
Получить ID чата по имени группы.
|
||||
|
||||
Args:
|
||||
group_name: Имя группы из конфигурации.
|
||||
messenger: Тип мессенджера (опционально).
|
||||
|
||||
Returns:
|
||||
ID чата или None, если группа не найдена.
|
||||
"""
|
||||
group_config = await self.get_group_config(group_name, messenger)
|
||||
if group_config is None:
|
||||
return None
|
||||
return group_config.get("chat_id")
|
||||
|
||||
async def refresh_cache(self) -> None:
|
||||
"""Принудительно обновить кэш конфигурации."""
|
||||
try:
|
||||
self._cache = await self._load_config()
|
||||
self._cache_time = datetime.now()
|
||||
logger.info("Кэш конфигурации групп обновлен")
|
||||
except (FileNotFoundError, json.JSONDecodeError) as e:
|
||||
logger.error(f"Ошибка обновления кэша: {e}")
|
||||
|
||||
async def _save_config(self, config: Dict[str, Any]) -> None:
|
||||
"""
|
||||
Сохранить конфигурацию групп в файл.
|
||||
|
||||
Args:
|
||||
config: Нормализованная конфигурация групп в новом формате.
|
||||
|
||||
Raises:
|
||||
IOError: Если не удалось записать файл.
|
||||
"""
|
||||
try:
|
||||
async with aiofiles.open(self.config_path, 'w', encoding='utf-8') as f:
|
||||
await f.write(json.dumps(config, indent=2, ensure_ascii=False))
|
||||
logger.info(f"Конфигурация групп сохранена в {self.config_path}")
|
||||
# Обновляем кэш
|
||||
self._cache = config
|
||||
self._cache_time = datetime.now()
|
||||
except IOError as e:
|
||||
logger.error(f"Ошибка записи конфигурации групп: {e}")
|
||||
raise
|
||||
|
||||
async def get_all_groups(self, include_ids: bool = False, messenger: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Получить все группы из конфигурации.
|
||||
|
||||
Args:
|
||||
include_ids: Включать ли полную конфигурацию групп (включая ID, мессенджер и т.д.).
|
||||
messenger: Фильтр по типу мессенджера (опционально).
|
||||
|
||||
Returns:
|
||||
Словарь с группами.
|
||||
Если include_ids=False, возвращает только названия групп.
|
||||
Если include_ids=True, возвращает полную конфигурацию групп.
|
||||
"""
|
||||
if not self._is_cache_valid():
|
||||
try:
|
||||
self._cache = await self._load_config()
|
||||
self._cache_time = datetime.now()
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
logger.error("Не удалось загрузить конфигурацию групп")
|
||||
return {}
|
||||
|
||||
# Фильтруем по мессенджеру, если указан
|
||||
filtered_config = self._cache.copy()
|
||||
if messenger:
|
||||
filtered_config = {
|
||||
name: config
|
||||
for name, config in filtered_config.items()
|
||||
if config.get("messenger") == messenger
|
||||
}
|
||||
|
||||
if include_ids:
|
||||
return filtered_config.copy()
|
||||
else:
|
||||
# Возвращаем только названия групп без конфигурации
|
||||
return {name: None for name in filtered_config.keys()}
|
||||
|
||||
async def create_group(
|
||||
self,
|
||||
group_name: str,
|
||||
chat_id: Union[int, str],
|
||||
messenger: str = "telegram",
|
||||
thread_id: int = 0,
|
||||
config: Optional[Dict[str, Any]] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Создать новую группу в конфигурации.
|
||||
|
||||
Args:
|
||||
group_name: Имя группы.
|
||||
chat_id: ID чата (может быть int или str).
|
||||
messenger: Тип мессенджера (telegram, max).
|
||||
thread_id: ID треда в группе (по умолчанию 0).
|
||||
config: Дополнительная конфигурация для мессенджера (опционально).
|
||||
|
||||
Returns:
|
||||
True если группа создана успешно, False если группа уже существует.
|
||||
"""
|
||||
# Загружаем текущую конфигурацию
|
||||
if not self._is_cache_valid():
|
||||
try:
|
||||
self._cache = await self._load_config()
|
||||
self._cache_time = datetime.now()
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
# Если файл не существует, создаем новый
|
||||
self._cache = {}
|
||||
|
||||
# Проверяем, существует ли группа
|
||||
if group_name in self._cache:
|
||||
logger.warning(f"Группа '{group_name}' уже существует")
|
||||
return False
|
||||
|
||||
# Добавляем группу
|
||||
self._cache[group_name] = {
|
||||
"messenger": messenger,
|
||||
"chat_id": chat_id,
|
||||
"thread_id": thread_id,
|
||||
"config": config or {}
|
||||
}
|
||||
await self._save_config(self._cache)
|
||||
logger.info(f"Группа '{group_name}' создана с мессенджером '{messenger}' и ID {chat_id}")
|
||||
return True
|
||||
|
||||
async def update_group(
|
||||
self,
|
||||
group_name: str,
|
||||
chat_id: Optional[Union[int, str]] = None,
|
||||
messenger: Optional[str] = None,
|
||||
thread_id: Optional[int] = None,
|
||||
config: Optional[Dict[str, Any]] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Обновить существующую группу в конфигурации.
|
||||
|
||||
Args:
|
||||
group_name: Имя группы.
|
||||
chat_id: Новый ID чата (опционально).
|
||||
messenger: Новый тип мессенджера (опционально).
|
||||
thread_id: Новый ID треда (опционально).
|
||||
config: Новая дополнительная конфигурация (опционально).
|
||||
|
||||
Returns:
|
||||
True если группа обновлена успешно, False если группа не найдена.
|
||||
"""
|
||||
# Загружаем текущую конфигурацию
|
||||
if not self._is_cache_valid():
|
||||
try:
|
||||
self._cache = await self._load_config()
|
||||
self._cache_time = datetime.now()
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
logger.error("Не удалось загрузить конфигурацию групп")
|
||||
return False
|
||||
|
||||
# Проверяем, существует ли группа
|
||||
if group_name not in self._cache:
|
||||
logger.warning(f"Группа '{group_name}' не найдена")
|
||||
return False
|
||||
|
||||
# Обновляем группу (обновляем только указанные поля)
|
||||
old_config = self._cache[group_name].copy()
|
||||
if chat_id is not None:
|
||||
self._cache[group_name]["chat_id"] = chat_id
|
||||
if messenger is not None:
|
||||
self._cache[group_name]["messenger"] = messenger
|
||||
if thread_id is not None:
|
||||
self._cache[group_name]["thread_id"] = thread_id
|
||||
if config is not None:
|
||||
self._cache[group_name]["config"] = config
|
||||
|
||||
await self._save_config(self._cache)
|
||||
logger.info(f"Группа '{group_name}' обновлена: {old_config} -> {self._cache[group_name]}")
|
||||
return True
|
||||
|
||||
async def delete_group(self, group_name: str) -> bool:
|
||||
"""
|
||||
Удалить группу из конфигурации.
|
||||
|
||||
Args:
|
||||
group_name: Имя группы.
|
||||
|
||||
Returns:
|
||||
True если группа удалена успешно, False если группа не найдена.
|
||||
"""
|
||||
# Загружаем текущую конфигурацию
|
||||
if not self._is_cache_valid():
|
||||
try:
|
||||
self._cache = await self._load_config()
|
||||
self._cache_time = datetime.now()
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
logger.error("Не удалось загрузить конфигурацию групп")
|
||||
return False
|
||||
|
||||
# Проверяем, существует ли группа
|
||||
if group_name not in self._cache:
|
||||
logger.warning(f"Группа '{group_name}' не найдена")
|
||||
return False
|
||||
|
||||
# Удаляем группу
|
||||
del self._cache[group_name]
|
||||
await self._save_config(self._cache)
|
||||
logger.info(f"Группа '{group_name}' удалена")
|
||||
return True
|
||||
|
||||
|
||||
# Глобальный экземпляр менеджера конфигурации (lazy initialization)
|
||||
_groups_config_instance = None
|
||||
|
||||
|
||||
def get_groups_config() -> GroupsConfig:
|
||||
"""
|
||||
Получить экземпляр менеджера конфигурации групп (lazy initialization).
|
||||
|
||||
Returns:
|
||||
Экземпляр GroupsConfig.
|
||||
"""
|
||||
global _groups_config_instance
|
||||
if _groups_config_instance is None:
|
||||
_groups_config_instance = GroupsConfig()
|
||||
return _groups_config_instance
|
||||
|
||||
|
||||
# Глобальный экземпляр менеджера конфигурации (lazy initialization)
|
||||
class _GroupsConfigProxy:
|
||||
"""Прокси для ленивой инициализации groups_config."""
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Получить атрибут из groups_config."""
|
||||
return getattr(get_groups_config(), name)
|
||||
|
||||
|
||||
groups_config = _GroupsConfigProxy()
|
||||
236
app/core/jira_client.py
Normal file
236
app/core/jira_client.py
Normal file
@@ -0,0 +1,236 @@
|
||||
"""
|
||||
Клиент для работы с Jira API.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
from jira import JIRA
|
||||
from jira.exceptions import JIRAError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class JiraClient:
|
||||
"""Клиент для работы с Jira API."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
email: str,
|
||||
api_token: str
|
||||
):
|
||||
"""
|
||||
Инициализация клиента Jira.
|
||||
|
||||
Args:
|
||||
url: URL Jira сервера.
|
||||
email: Email пользователя Jira.
|
||||
api_token: API токен Jira.
|
||||
"""
|
||||
self.url = url.rstrip('/')
|
||||
self.email = email
|
||||
self.api_token = api_token
|
||||
self._client: Optional[JIRA] = None
|
||||
|
||||
def get_client(self) -> JIRA:
|
||||
"""
|
||||
Получить экземпляр клиента Jira (создается при первом обращении).
|
||||
|
||||
Returns:
|
||||
Экземпляр JIRA клиента.
|
||||
"""
|
||||
if self._client is None:
|
||||
try:
|
||||
self._client = JIRA(
|
||||
server=self.url,
|
||||
basic_auth=(self.email, self.api_token)
|
||||
)
|
||||
logger.info(f"Jira клиент подключен к {self.url}")
|
||||
except JIRAError as e:
|
||||
logger.error(f"Ошибка подключения к Jira: {e}")
|
||||
raise
|
||||
return self._client
|
||||
|
||||
def create_issue(
|
||||
self,
|
||||
project: str,
|
||||
summary: str,
|
||||
description: str,
|
||||
issue_type: str = "Bug",
|
||||
assignee: Optional[str] = None,
|
||||
priority: Optional[str] = None,
|
||||
labels: Optional[list] = None,
|
||||
components: Optional[list] = None
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Создать тикет в Jira.
|
||||
|
||||
Args:
|
||||
project: Ключ проекта Jira.
|
||||
summary: Заголовок тикета.
|
||||
description: Описание тикета.
|
||||
issue_type: Тип задачи.
|
||||
assignee: Email исполнителя (опционально).
|
||||
priority: Приоритет задачи (опционально).
|
||||
labels: Список меток (опционально).
|
||||
components: Список компонентов (опционально).
|
||||
|
||||
Returns:
|
||||
Ключ созданного тикета (например, "MON-123") или None в случае ошибки.
|
||||
"""
|
||||
try:
|
||||
client = self.get_client()
|
||||
|
||||
# Формируем словарь для создания тикета
|
||||
issue_dict = {
|
||||
'project': {'key': project},
|
||||
'summary': summary,
|
||||
'description': description,
|
||||
'issuetype': {'name': issue_type}
|
||||
}
|
||||
|
||||
# Добавляем приоритет, если указан
|
||||
if priority:
|
||||
issue_dict['priority'] = {'name': priority}
|
||||
|
||||
# Добавляем метки, если указаны
|
||||
if labels:
|
||||
issue_dict['labels'] = labels
|
||||
|
||||
# Добавляем компоненты, если указаны
|
||||
if components:
|
||||
issue_dict['components'] = [{'name': comp} for comp in components]
|
||||
|
||||
# Создаем тикет
|
||||
issue = client.create_issue(fields=issue_dict)
|
||||
|
||||
# Назначаем исполнителя, если указан
|
||||
if assignee:
|
||||
try:
|
||||
# Пытаемся найти пользователя по email или username
|
||||
users = client.search_users(query=assignee)
|
||||
if users:
|
||||
# Назначаем первого найденного пользователя
|
||||
user_account_id = users[0].accountId
|
||||
client.assign_issue(issue, user_account_id)
|
||||
logger.info(f"Исполнитель {assignee} (accountId: {user_account_id}) назначен на тикет {issue.key}")
|
||||
else:
|
||||
logger.warning(f"Пользователь {assignee} не найден в Jira, тикет создан без исполнителя")
|
||||
except JIRAError as e:
|
||||
logger.error(f"Ошибка назначения исполнителя {assignee}: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Неожиданная ошибка при назначении исполнителя: {e}")
|
||||
|
||||
logger.info(f"Тикет {issue.key} создан в Jira")
|
||||
return issue.key
|
||||
|
||||
except JIRAError as e:
|
||||
logger.error(f"Ошибка создания тикета в Jira: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Неожиданная ошибка при создании тикета: {e}")
|
||||
return None
|
||||
|
||||
def get_issue_url(self, issue_key: str) -> str:
|
||||
"""
|
||||
Получить URL тикета в Jira.
|
||||
|
||||
Args:
|
||||
issue_key: Ключ тикета (например, "MON-123").
|
||||
|
||||
Returns:
|
||||
URL тикета в Jira.
|
||||
"""
|
||||
return f"{self.url}/browse/{issue_key}"
|
||||
|
||||
def update_issue(
|
||||
self,
|
||||
issue_key: str,
|
||||
summary: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
assignee: Optional[str] = None,
|
||||
priority: Optional[str] = None,
|
||||
labels: Optional[list] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Обновить тикет в Jira.
|
||||
|
||||
Args:
|
||||
issue_key: Ключ тикета.
|
||||
summary: Новый заголовок (опционально).
|
||||
description: Новое описание (опционально).
|
||||
assignee: Новый исполнитель (опционально).
|
||||
priority: Новый приоритет (опционально).
|
||||
labels: Новые метки (опционально).
|
||||
|
||||
Returns:
|
||||
True если тикет обновлен успешно, False в противном случае.
|
||||
"""
|
||||
try:
|
||||
client = self.get_client()
|
||||
issue = client.issue(issue_key)
|
||||
|
||||
update_dict = {}
|
||||
|
||||
if summary:
|
||||
update_dict['summary'] = [{'set': summary}]
|
||||
if description:
|
||||
update_dict['description'] = [{'set': description}]
|
||||
if priority:
|
||||
update_dict['priority'] = [{'set': {'name': priority}}]
|
||||
if labels:
|
||||
update_dict['labels'] = [{'set': labels}]
|
||||
|
||||
if update_dict:
|
||||
issue.update(fields=update_dict)
|
||||
|
||||
if assignee:
|
||||
try:
|
||||
users = client.search_users(query=assignee)
|
||||
if users:
|
||||
user_account_id = users[0].accountId
|
||||
client.assign_issue(issue, user_account_id)
|
||||
logger.info(f"Исполнитель {assignee} (accountId: {user_account_id}) назначен на тикет {issue_key}")
|
||||
else:
|
||||
logger.warning(f"Пользователь {assignee} не найден в Jira")
|
||||
except JIRAError as e:
|
||||
logger.error(f"Ошибка назначения исполнителя {assignee}: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Неожиданная ошибка при назначении исполнителя: {e}")
|
||||
|
||||
logger.info(f"Тикет {issue_key} обновлен в Jira")
|
||||
return True
|
||||
|
||||
except JIRAError as e:
|
||||
logger.error(f"Ошибка обновления тикета {issue_key}: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Неожиданная ошибка при обновлении тикета: {e}")
|
||||
return False
|
||||
|
||||
def add_comment(self, issue_key: str, comment: str) -> bool:
|
||||
"""
|
||||
Добавить комментарий к тикету.
|
||||
|
||||
Args:
|
||||
issue_key: Ключ тикета.
|
||||
comment: Текст комментария.
|
||||
|
||||
Returns:
|
||||
True если комментарий добавлен успешно, False в противном случае.
|
||||
"""
|
||||
try:
|
||||
client = self.get_client()
|
||||
issue = client.issue(issue_key)
|
||||
issue.add_comment(comment)
|
||||
logger.info(f"Комментарий добавлен к тикету {issue_key}")
|
||||
return True
|
||||
except JIRAError as e:
|
||||
logger.error(f"Ошибка добавления комментария к тикету {issue_key}: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Неожиданная ошибка при добавлении комментария: {e}")
|
||||
return False
|
||||
|
||||
212
app/core/jira_mapping.py
Normal file
212
app/core/jira_mapping.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""
|
||||
Управление конфигурацией маппинга алертов в Jira тикеты.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, Any, Optional, List
|
||||
import aiofiles
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app.models.jira import JiraMappingConfig, JiraSourceMapping, JiraMapping, JiraMappingCondition
|
||||
from app.core.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class JiraMappingManager:
|
||||
"""Менеджер конфигурации маппинга алертов в Jira тикеты с кэшированием."""
|
||||
|
||||
def __init__(self, config_path: Optional[str] = None):
|
||||
"""
|
||||
Инициализация менеджера конфигурации.
|
||||
|
||||
Args:
|
||||
config_path: Путь к файлу конфигурации маппинга.
|
||||
"""
|
||||
settings = get_settings()
|
||||
self.config_path = config_path or settings.jira_mapping_config_path
|
||||
self._cache: Optional[JiraMappingConfig] = None
|
||||
self._cache_time: Optional[datetime] = None
|
||||
self._cache_ttl = timedelta(minutes=10) # Кэш на 10 минут
|
||||
|
||||
async def _load_config(self) -> JiraMappingConfig:
|
||||
"""
|
||||
Загрузить конфигурацию маппинга из файла.
|
||||
|
||||
Returns:
|
||||
Конфигурация маппинга алертов в Jira тикеты.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: Если файл конфигурации не найден.
|
||||
json.JSONDecodeError: Если файл содержит некорректный JSON.
|
||||
"""
|
||||
try:
|
||||
async with aiofiles.open(self.config_path, 'r', encoding='utf-8') as f:
|
||||
content = await f.read()
|
||||
config_dict = json.loads(content)
|
||||
config = JiraMappingConfig(**config_dict)
|
||||
logger.info(f"Конфигурация маппинга Jira загружена из {self.config_path}")
|
||||
return config
|
||||
except FileNotFoundError:
|
||||
logger.warning(f"Файл конфигурации маппинга Jira не найден: {self.config_path}")
|
||||
# Возвращаем пустую конфигурацию
|
||||
return JiraMappingConfig()
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Ошибка парсинга JSON в файле конфигурации маппинга: {e}")
|
||||
return JiraMappingConfig()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка загрузки конфигурации маппинга: {e}")
|
||||
return JiraMappingConfig()
|
||||
|
||||
def _is_cache_valid(self) -> bool:
|
||||
"""Проверить, валиден ли кэш."""
|
||||
if self._cache is None or self._cache_time is None:
|
||||
return False
|
||||
return datetime.now() - self._cache_time < self._cache_ttl
|
||||
|
||||
async def get_config(self) -> JiraMappingConfig:
|
||||
"""
|
||||
Получить конфигурацию маппинга (с кэшированием).
|
||||
|
||||
Returns:
|
||||
Конфигурация маппинга алертов в Jira тикеты.
|
||||
"""
|
||||
if not self._is_cache_valid():
|
||||
self._cache = await self._load_config()
|
||||
self._cache_time = datetime.now()
|
||||
return self._cache
|
||||
|
||||
async def find_mapping(
|
||||
self,
|
||||
source: str,
|
||||
alert_data: Dict[str, Any]
|
||||
) -> Optional[JiraMapping]:
|
||||
"""
|
||||
Найти подходящий маппинг для алерта.
|
||||
|
||||
Args:
|
||||
source: Источник алерта (alertmanager, grafana, zabbix).
|
||||
alert_data: Данные алерта.
|
||||
|
||||
Returns:
|
||||
Подходящий маппинг или None, если маппинг не найден.
|
||||
"""
|
||||
config = await self.get_config()
|
||||
|
||||
# Получаем конфигурацию для источника
|
||||
source_mapping: Optional[JiraSourceMapping] = None
|
||||
if source == "alertmanager" and config.alertmanager:
|
||||
source_mapping = config.alertmanager
|
||||
elif source == "grafana" and config.grafana:
|
||||
source_mapping = config.grafana
|
||||
elif source == "zabbix" and config.zabbix:
|
||||
source_mapping = config.zabbix
|
||||
|
||||
if not source_mapping:
|
||||
return None
|
||||
|
||||
# Ищем подходящий маппинг по условиям
|
||||
for mapping in source_mapping.mappings:
|
||||
if self._check_conditions(mapping.conditions, alert_data):
|
||||
return mapping
|
||||
|
||||
# Если маппинг не найден, возвращаем дефолтный маппинг
|
||||
return JiraMapping(
|
||||
conditions=JiraMappingCondition(),
|
||||
project=source_mapping.default_project,
|
||||
assignee=source_mapping.default_assignee,
|
||||
issue_type=source_mapping.default_issue_type,
|
||||
priority=source_mapping.default_priority,
|
||||
labels=[]
|
||||
)
|
||||
|
||||
def _check_conditions(
|
||||
self,
|
||||
conditions: JiraMappingCondition,
|
||||
alert_data: Dict[str, Any]
|
||||
) -> bool:
|
||||
"""
|
||||
Проверить, соответствуют ли данные алерта условиям маппинга.
|
||||
|
||||
Args:
|
||||
conditions: Условия маппинга.
|
||||
alert_data: Данные алерта.
|
||||
|
||||
Returns:
|
||||
True если условия выполнены, False в противном случае.
|
||||
"""
|
||||
# Проверяем severity
|
||||
if conditions.severity:
|
||||
if alert_data.get("severity") != conditions.severity:
|
||||
return False
|
||||
|
||||
# Проверяем namespace
|
||||
if conditions.namespace:
|
||||
if alert_data.get("namespace") != conditions.namespace:
|
||||
return False
|
||||
|
||||
# Проверяем state
|
||||
if conditions.state:
|
||||
if alert_data.get("state") != conditions.state:
|
||||
return False
|
||||
|
||||
# Проверяем status
|
||||
if conditions.status:
|
||||
if alert_data.get("status") != conditions.status:
|
||||
return False
|
||||
|
||||
# Проверяем event-severity
|
||||
if conditions.event_severity:
|
||||
if alert_data.get("event-severity") != conditions.event_severity:
|
||||
return False
|
||||
|
||||
# Проверяем теги
|
||||
if conditions.tags:
|
||||
alert_tags = alert_data.get("tags", {})
|
||||
for key, value in conditions.tags.items():
|
||||
if alert_tags.get(key) != value:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
async def refresh_cache(self) -> None:
|
||||
"""Принудительно обновить кэш конфигурации."""
|
||||
try:
|
||||
self._cache = await self._load_config()
|
||||
self._cache_time = datetime.now()
|
||||
logger.info("Кэш конфигурации маппинга Jira обновлен")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления кэша: {e}")
|
||||
|
||||
|
||||
# Глобальный экземпляр менеджера конфигурации маппинга (lazy initialization)
|
||||
_jira_mapping_manager_instance = None
|
||||
|
||||
|
||||
def get_jira_mapping_manager() -> JiraMappingManager:
|
||||
"""
|
||||
Получить экземпляр менеджера конфигурации маппинга Jira (lazy initialization).
|
||||
|
||||
Returns:
|
||||
Экземпляр JiraMappingManager.
|
||||
"""
|
||||
global _jira_mapping_manager_instance
|
||||
if _jira_mapping_manager_instance is None:
|
||||
_jira_mapping_manager_instance = JiraMappingManager()
|
||||
return _jira_mapping_manager_instance
|
||||
|
||||
|
||||
# Для обратной совместимости (lazy initialization)
|
||||
class _JiraMappingManagerProxy:
|
||||
"""Прокси для ленивой инициализации jira_mapping_manager."""
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Получить атрибут из jira_mapping_manager."""
|
||||
return getattr(get_jira_mapping_manager(), name)
|
||||
|
||||
|
||||
jira_mapping_manager = _JiraMappingManagerProxy()
|
||||
|
||||
330
app/core/jira_utils.py
Normal file
330
app/core/jira_utils.py
Normal file
@@ -0,0 +1,330 @@
|
||||
"""
|
||||
Утилиты для работы с Jira тикетами.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from app.models.alertmanager import PrometheusAlert
|
||||
from app.models.grafana import GrafanaAlert
|
||||
from app.models.zabbix import ZabbixAlert
|
||||
from app.models.jira import JiraMapping
|
||||
from app.core.jira_client import JiraClient
|
||||
from app.core.jira_mapping import jira_mapping_manager
|
||||
from app.core.config import get_settings
|
||||
from app.core.utils import add_spaces_to_alert_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_jira_ticket_from_alert(
|
||||
alert: Any,
|
||||
source: str,
|
||||
k8s_cluster: Optional[str] = None,
|
||||
mapping: Optional[JiraMapping] = None
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Создать Jira тикет на основе алерта.
|
||||
|
||||
Args:
|
||||
alert: Данные алерта (PrometheusAlert, GrafanaAlert, ZabbixAlert).
|
||||
source: Источник алерта (alertmanager, grafana, zabbix).
|
||||
k8s_cluster: Имя Kubernetes кластера (опционально).
|
||||
mapping: Маппинг для создания тикета (опционально).
|
||||
|
||||
Returns:
|
||||
Ключ созданного тикета или None в случае ошибки.
|
||||
"""
|
||||
from app.core.config import get_settings
|
||||
settings = get_settings()
|
||||
|
||||
if not settings.jira_enabled:
|
||||
logger.debug("Jira отключен, тикет не создается")
|
||||
return None
|
||||
|
||||
if not settings.jira_url or not settings.jira_email or not settings.jira_api_token:
|
||||
logger.warning("Jira не настроен (отсутствуют URL, email или API token)")
|
||||
return None
|
||||
|
||||
# Создаем клиент Jira
|
||||
try:
|
||||
jira_client = JiraClient(
|
||||
url=settings.jira_url,
|
||||
email=settings.jira_email,
|
||||
api_token=settings.jira_api_token
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания Jira клиента: {e}")
|
||||
return None
|
||||
|
||||
# Получаем маппинг, если не указан
|
||||
if not mapping:
|
||||
alert_data = _extract_alert_data(alert, source, k8s_cluster)
|
||||
mapping = await jira_mapping_manager.find_mapping(source, alert_data)
|
||||
|
||||
if not mapping:
|
||||
logger.warning(f"Маппинг не найден для источника {source}")
|
||||
return None
|
||||
|
||||
# Формируем данные тикета
|
||||
summary = _generate_jira_summary(alert, source, mapping)
|
||||
description = _generate_jira_description(alert, source, k8s_cluster, mapping)
|
||||
|
||||
# Создаем тикет
|
||||
try:
|
||||
issue_key = jira_client.create_issue(
|
||||
project=mapping.project,
|
||||
summary=summary,
|
||||
description=description,
|
||||
issue_type=mapping.issue_type,
|
||||
assignee=mapping.assignee,
|
||||
priority=mapping.priority,
|
||||
labels=mapping.labels
|
||||
)
|
||||
|
||||
if issue_key:
|
||||
logger.info(f"Jira тикет {issue_key} создан для алерта из {source}")
|
||||
# Увеличиваем счетчик созданных тикетов
|
||||
from app.core.metrics import metrics
|
||||
metrics.increment_jira_ticket_created(
|
||||
source=source,
|
||||
project=mapping.project,
|
||||
k8s_cluster=k8s_cluster or "",
|
||||
chat="",
|
||||
thread=0
|
||||
)
|
||||
|
||||
return issue_key
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания Jira тикета: {e}")
|
||||
# Увеличиваем счетчик ошибок
|
||||
from app.core.metrics import metrics
|
||||
metrics.increment_jira_ticket_error(
|
||||
source=source,
|
||||
k8s_cluster=k8s_cluster or "",
|
||||
chat="",
|
||||
thread=0
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _extract_alert_data(
|
||||
alert: Any,
|
||||
source: str,
|
||||
k8s_cluster: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Извлечь данные алерта для маппинга.
|
||||
|
||||
Args:
|
||||
alert: Данные алерта.
|
||||
source: Источник алерта.
|
||||
k8s_cluster: Имя Kubernetes кластера.
|
||||
|
||||
Returns:
|
||||
Словарь с данными алерта.
|
||||
"""
|
||||
alert_data = {}
|
||||
|
||||
if source == "alertmanager" and isinstance(alert, PrometheusAlert):
|
||||
alert_data["status"] = alert.status
|
||||
alert_data.update(alert.commonLabels)
|
||||
alert_data.update(alert.commonAnnotations)
|
||||
if k8s_cluster:
|
||||
alert_data["k8s_cluster"] = k8s_cluster
|
||||
elif source == "grafana" and isinstance(alert, GrafanaAlert):
|
||||
alert_data["state"] = alert.state
|
||||
alert_data["tags"] = alert.tags
|
||||
alert_data["ruleName"] = alert.ruleName
|
||||
elif source == "zabbix" and isinstance(alert, ZabbixAlert):
|
||||
alert_data["status"] = alert.status
|
||||
alert_data["event-severity"] = alert.event_severity or ""
|
||||
alert_data["event-name"] = alert.event_name
|
||||
alert_data["host-name"] = alert.host_name
|
||||
|
||||
return alert_data
|
||||
|
||||
|
||||
def _generate_jira_summary(
|
||||
alert: Any,
|
||||
source: str,
|
||||
mapping: JiraMapping
|
||||
) -> str:
|
||||
"""
|
||||
Сгенерировать заголовок Jira тикета.
|
||||
|
||||
Args:
|
||||
alert: Данные алерта.
|
||||
source: Источник алерта.
|
||||
mapping: Маппинг для создания тикета.
|
||||
|
||||
Returns:
|
||||
Заголовок тикета.
|
||||
"""
|
||||
severity_prefix = ""
|
||||
alert_name = ""
|
||||
|
||||
if source == "alertmanager" and isinstance(alert, PrometheusAlert):
|
||||
severity = alert.commonLabels.get("severity", "")
|
||||
alert_name = alert.commonLabels.get("alertname", "")
|
||||
if severity:
|
||||
severity_prefix = f"[{severity.upper()}] "
|
||||
if alert_name:
|
||||
alert_name = add_spaces_to_alert_name(alert_name)
|
||||
summary = alert.commonAnnotations.get("summary", alert_name)
|
||||
elif source == "grafana" and isinstance(alert, GrafanaAlert):
|
||||
alert_name = alert.ruleName
|
||||
if alert.state == "alerting":
|
||||
severity_prefix = "[ALERTING] "
|
||||
summary = alert.title or alert_name
|
||||
elif source == "zabbix" and isinstance(alert, ZabbixAlert):
|
||||
severity = alert.event_severity or ""
|
||||
alert_name = alert.event_name
|
||||
if severity:
|
||||
severity_prefix = f"[{severity.upper()}] "
|
||||
summary = alert.alert_subject or alert_name
|
||||
else:
|
||||
summary = "Unknown Alert"
|
||||
|
||||
return f"{severity_prefix}{alert_name}: {summary}"[:255] # Ограничение Jira
|
||||
|
||||
|
||||
def _generate_jira_description(
|
||||
alert: Any,
|
||||
source: str,
|
||||
k8s_cluster: Optional[str] = None,
|
||||
mapping: Optional[JiraMapping] = None
|
||||
) -> str:
|
||||
"""
|
||||
Сгенерировать описание Jira тикета.
|
||||
|
||||
Args:
|
||||
alert: Данные алерта.
|
||||
source: Источник алерта.
|
||||
k8s_cluster: Имя Kubernetes кластера.
|
||||
mapping: Маппинг для создания тикета.
|
||||
|
||||
Returns:
|
||||
Описание тикета в формате Markdown.
|
||||
"""
|
||||
from app.core.config import get_settings
|
||||
settings = get_settings()
|
||||
|
||||
# Загружаем шаблон описания
|
||||
try:
|
||||
environment = Environment(loader=FileSystemLoader(settings.templates_path))
|
||||
template_name = f"jira_{source}.tmpl"
|
||||
try:
|
||||
template = environment.get_template(template_name)
|
||||
except Exception:
|
||||
# Если шаблон не найден, используем общий шаблон
|
||||
template = environment.get_template("jira_common.tmpl")
|
||||
except Exception:
|
||||
# Если общий шаблон не найден, используем простое описание
|
||||
return _generate_simple_description(alert, source, k8s_cluster)
|
||||
|
||||
# Формируем словарь для шаблона
|
||||
template_data = _prepare_template_data(alert, source, k8s_cluster)
|
||||
|
||||
# Рендерим шаблон
|
||||
description = template.render(template_data)
|
||||
|
||||
return description
|
||||
|
||||
|
||||
def _generate_simple_description(
|
||||
alert: Any,
|
||||
source: str,
|
||||
k8s_cluster: Optional[str] = None
|
||||
) -> str:
|
||||
"""
|
||||
Сгенерировать простое описание тикета без шаблона.
|
||||
|
||||
Args:
|
||||
alert: Данные алерта.
|
||||
source: Источник алерта.
|
||||
k8s_cluster: Имя Kubernetes кластера.
|
||||
|
||||
Returns:
|
||||
Простое описание тикета.
|
||||
"""
|
||||
description = f"**Источник:** {source}\n\n"
|
||||
|
||||
if source == "alertmanager" and isinstance(alert, PrometheusAlert):
|
||||
description += f"**Статус:** {alert.status}\n\n"
|
||||
description += "**Метки:**\n"
|
||||
for key, value in alert.commonLabels.items():
|
||||
description += f"- {key}: {value}\n"
|
||||
description += "\n**Аннотации:**\n"
|
||||
for key, value in alert.commonAnnotations.items():
|
||||
description += f"- {key}: {value}\n"
|
||||
if k8s_cluster:
|
||||
description += f"\n**Kubernetes кластер:** {k8s_cluster}\n"
|
||||
elif source == "grafana" and isinstance(alert, GrafanaAlert):
|
||||
description += f"**Состояние:** {alert.state}\n\n"
|
||||
description += f"**Правило:** {alert.ruleName}\n\n"
|
||||
description += f"**Сообщение:** {alert.message or 'Нет сообщения'}\n\n"
|
||||
if alert.tags:
|
||||
description += "**Теги:**\n"
|
||||
for key, value in alert.tags.items():
|
||||
description += f"- {key}: {value}\n"
|
||||
elif source == "zabbix" and isinstance(alert, ZabbixAlert):
|
||||
description += f"**Статус:** {alert.status}\n\n"
|
||||
description += f"**Серьезность:** {alert.event_severity or 'Unknown'}\n\n"
|
||||
description += f"**Событие:** {alert.event_name}\n\n"
|
||||
description += f"**Хост:** {alert.host_name} ({alert.host_ip})\n\n"
|
||||
description += f"**Сообщение:** {alert.alert_message}\n"
|
||||
|
||||
return description
|
||||
|
||||
|
||||
def _prepare_template_data(
|
||||
alert: Any,
|
||||
source: str,
|
||||
k8s_cluster: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Подготовить данные для шаблона описания тикета.
|
||||
|
||||
Args:
|
||||
alert: Данные алерта.
|
||||
source: Источник алерта.
|
||||
k8s_cluster: Имя Kubernetes кластера.
|
||||
|
||||
Returns:
|
||||
Словарь с данными для шаблона.
|
||||
"""
|
||||
template_data = {
|
||||
"source": source,
|
||||
"k8s_cluster": k8s_cluster or "",
|
||||
}
|
||||
|
||||
if source == "alertmanager" and isinstance(alert, PrometheusAlert):
|
||||
template_data["status"] = alert.status
|
||||
template_data["common_labels"] = alert.commonLabels
|
||||
template_data["common_annotations"] = alert.commonAnnotations
|
||||
template_data["alertname"] = alert.commonLabels.get("alertname", "")
|
||||
template_data["severity"] = alert.commonLabels.get("severity", "")
|
||||
template_data["summary"] = alert.commonAnnotations.get("summary", "")
|
||||
template_data["description"] = alert.commonAnnotations.get("description", "")
|
||||
elif source == "grafana" and isinstance(alert, GrafanaAlert):
|
||||
template_data["state"] = alert.state
|
||||
template_data["title"] = alert.title
|
||||
template_data["ruleName"] = alert.ruleName
|
||||
template_data["message"] = alert.message or ""
|
||||
template_data["tags"] = alert.tags
|
||||
template_data["evalMatches"] = alert.evalMatches
|
||||
elif source == "zabbix" and isinstance(alert, ZabbixAlert):
|
||||
template_data["status"] = alert.status
|
||||
template_data["event_severity"] = alert.event_severity or ""
|
||||
template_data["event_name"] = alert.event_name
|
||||
template_data["alert_subject"] = alert.alert_subject
|
||||
template_data["alert_message"] = alert.alert_message
|
||||
template_data["host_name"] = alert.host_name
|
||||
template_data["host_ip"] = alert.host_ip
|
||||
template_data["host_port"] = alert.host_port
|
||||
|
||||
return template_data
|
||||
102
app/core/messenger_factory.py
Normal file
102
app/core/messenger_factory.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""
|
||||
Фабрика для создания клиентов мессенджеров.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from app.core.messengers.base import MessengerClient
|
||||
from app.core.messengers.telegram import TelegramMessengerClient
|
||||
from app.core.messengers.max import MaxMessengerClient
|
||||
from app.core.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MessengerFactory:
|
||||
"""Фабрика для создания клиентов мессенджеров."""
|
||||
|
||||
@staticmethod
|
||||
def create(messenger_type: str, **kwargs) -> MessengerClient:
|
||||
"""
|
||||
Создать клиент мессенджера.
|
||||
|
||||
Args:
|
||||
messenger_type: Тип мессенджера (telegram, max).
|
||||
**kwargs: Дополнительные параметры для инициализации клиента.
|
||||
|
||||
Returns:
|
||||
Экземпляр MessengerClient.
|
||||
|
||||
Raises:
|
||||
ValueError: Если тип мессенджера неизвестен.
|
||||
"""
|
||||
messenger_type = messenger_type.lower()
|
||||
|
||||
if messenger_type == "telegram":
|
||||
bot_token = kwargs.get("bot_token")
|
||||
return TelegramMessengerClient(bot_token=bot_token)
|
||||
elif messenger_type == "max":
|
||||
access_token = kwargs.get("access_token")
|
||||
api_version = kwargs.get("api_version", "5.131")
|
||||
return MaxMessengerClient(access_token=access_token, api_version=api_version)
|
||||
else:
|
||||
raise ValueError(f"Неизвестный тип мессенджера: {messenger_type}")
|
||||
|
||||
@staticmethod
|
||||
def create_from_config(group_config: Dict[str, Any]) -> MessengerClient:
|
||||
"""
|
||||
Создать клиент мессенджера из конфигурации группы.
|
||||
|
||||
Args:
|
||||
group_config: Конфигурация группы с полями:
|
||||
- messenger: Тип мессенджера (telegram, max)
|
||||
- config: Дополнительная конфигурация для мессенджера
|
||||
|
||||
Returns:
|
||||
Экземпляр MessengerClient.
|
||||
"""
|
||||
messenger_type = group_config.get("messenger", "telegram")
|
||||
config = group_config.get("config", {})
|
||||
|
||||
# Если конфигурация не указана, используем настройки из переменных окружения
|
||||
settings = get_settings()
|
||||
|
||||
if messenger_type == "telegram":
|
||||
bot_token = config.get("bot_token") or settings.telegram_bot_token
|
||||
return TelegramMessengerClient(bot_token=bot_token)
|
||||
elif messenger_type == "max":
|
||||
access_token = config.get("access_token") or settings.max_access_token
|
||||
api_version = config.get("api_version", settings.max_api_version or "5.131")
|
||||
return MaxMessengerClient(access_token=access_token, api_version=api_version)
|
||||
else:
|
||||
raise ValueError(f"Неизвестный тип мессенджера: {messenger_type}")
|
||||
|
||||
|
||||
# Глобальный кэш клиентов мессенджеров
|
||||
_messenger_clients_cache: Dict[str, MessengerClient] = {}
|
||||
|
||||
|
||||
def get_messenger_client(messenger_type: str, **kwargs) -> MessengerClient:
|
||||
"""
|
||||
Получить клиент мессенджера (с кэшированием).
|
||||
|
||||
Args:
|
||||
messenger_type: Тип мессенджера (telegram, max).
|
||||
**kwargs: Дополнительные параметры для инициализации клиента.
|
||||
|
||||
Returns:
|
||||
Экземпляр MessengerClient.
|
||||
"""
|
||||
# Для Telegram используем глобальный экземпляр, если bot_token не указан
|
||||
if messenger_type == "telegram" and "bot_token" not in kwargs:
|
||||
cache_key = "telegram_default"
|
||||
if cache_key not in _messenger_clients_cache:
|
||||
_messenger_clients_cache[cache_key] = MessengerFactory.create(messenger_type, **kwargs)
|
||||
return _messenger_clients_cache[cache_key]
|
||||
|
||||
# Для других мессенджеров создаем новый экземпляр
|
||||
return MessengerFactory.create(messenger_type, **kwargs)
|
||||
|
||||
16
app/core/messengers/__init__.py
Normal file
16
app/core/messengers/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
Модуль для работы с различными мессенджерами.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
from app.core.messengers.base import MessengerClient
|
||||
from app.core.messengers.telegram import TelegramMessengerClient
|
||||
from app.core.messengers.max import MaxMessengerClient
|
||||
|
||||
__all__ = [
|
||||
"MessengerClient",
|
||||
"TelegramMessengerClient",
|
||||
"MaxMessengerClient",
|
||||
]
|
||||
|
||||
174
app/core/messengers/base.py
Normal file
174
app/core/messengers/base.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""
|
||||
Базовый абстрактный класс для работы с мессенджерами.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional, Union, Dict, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MessengerClient(ABC):
|
||||
"""Базовый абстрактный класс для всех мессенджеров."""
|
||||
|
||||
@abstractmethod
|
||||
async def send_message(
|
||||
self,
|
||||
chat_id: Union[str, int],
|
||||
text: str,
|
||||
thread_id: Optional[int] = None,
|
||||
reply_markup: Optional[Dict[str, Any]] = None,
|
||||
disable_web_page_preview: bool = True,
|
||||
parse_mode: str = "HTML",
|
||||
**kwargs
|
||||
) -> bool:
|
||||
"""
|
||||
Отправить текстовое сообщение.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата или группы (может быть строкой или числом).
|
||||
text: Текст сообщения.
|
||||
thread_id: ID треда в группе (опционально, не все мессенджеры поддерживают).
|
||||
reply_markup: Клавиатура с кнопками (опционально, формат зависит от мессенджера).
|
||||
disable_web_page_preview: Отключить превью ссылок.
|
||||
parse_mode: Режим парсинга (HTML, Markdown, и т.д.).
|
||||
**kwargs: Дополнительные параметры для конкретного мессенджера.
|
||||
|
||||
Returns:
|
||||
True если сообщение отправлено успешно, False в противном случае.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def send_photo(
|
||||
self,
|
||||
chat_id: Union[str, int],
|
||||
photo: Union[str, bytes],
|
||||
caption: Optional[str] = None,
|
||||
thread_id: Optional[int] = None,
|
||||
parse_mode: str = "HTML",
|
||||
**kwargs
|
||||
) -> bool:
|
||||
"""
|
||||
Отправить фото.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата или группы.
|
||||
photo: Путь к файлу, URL, bytes или BytesIO объект с фото.
|
||||
caption: Подпись к фото (опционально).
|
||||
thread_id: ID треда в группе (опционально).
|
||||
parse_mode: Режим парсинга (HTML, Markdown).
|
||||
**kwargs: Дополнительные параметры.
|
||||
|
||||
Returns:
|
||||
True если фото отправлено успешно, False в противном случае.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def send_video(
|
||||
self,
|
||||
chat_id: Union[str, int],
|
||||
video: Union[str, bytes],
|
||||
caption: Optional[str] = None,
|
||||
thread_id: Optional[int] = None,
|
||||
parse_mode: str = "HTML",
|
||||
duration: Optional[int] = None,
|
||||
width: Optional[int] = None,
|
||||
height: Optional[int] = None,
|
||||
**kwargs
|
||||
) -> bool:
|
||||
"""
|
||||
Отправить видео.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата или группы.
|
||||
video: Путь к файлу, URL, bytes или BytesIO объект с видео.
|
||||
caption: Подпись к видео (опционально).
|
||||
thread_id: ID треда в группе (опционально).
|
||||
parse_mode: Режим парсинга (HTML, Markdown).
|
||||
duration: Длительность видео в секундах (опционально).
|
||||
width: Ширина видео (опционально).
|
||||
height: Высота видео (опционально).
|
||||
**kwargs: Дополнительные параметры.
|
||||
|
||||
Returns:
|
||||
True если видео отправлено успешно, False в противном случае.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def send_audio(
|
||||
self,
|
||||
chat_id: Union[str, int],
|
||||
audio: Union[str, bytes],
|
||||
caption: Optional[str] = None,
|
||||
thread_id: Optional[int] = None,
|
||||
parse_mode: str = "HTML",
|
||||
duration: Optional[int] = None,
|
||||
performer: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> bool:
|
||||
"""
|
||||
Отправить аудио.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата или группы.
|
||||
audio: Путь к файлу, URL, bytes или BytesIO объект с аудио.
|
||||
caption: Подпись к аудио (опционально).
|
||||
thread_id: ID треда в группе (опционально).
|
||||
parse_mode: Режим парсинга (HTML, Markdown).
|
||||
duration: Длительность аудио в секундах (опционально).
|
||||
performer: Исполнитель (опционально).
|
||||
title: Название трека (опционально).
|
||||
**kwargs: Дополнительные параметры.
|
||||
|
||||
Returns:
|
||||
True если аудио отправлено успешно, False в противном случае.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def send_document(
|
||||
self,
|
||||
chat_id: Union[str, int],
|
||||
document: Union[str, bytes],
|
||||
caption: Optional[str] = None,
|
||||
thread_id: Optional[int] = None,
|
||||
parse_mode: str = "HTML",
|
||||
filename: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> bool:
|
||||
"""
|
||||
Отправить документ.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата или группы.
|
||||
document: Путь к файлу, URL, bytes или BytesIO объект с документом.
|
||||
caption: Подпись к документу (опционально).
|
||||
thread_id: ID треда в группе (опционально).
|
||||
parse_mode: Режим парсинга (HTML, Markdown).
|
||||
filename: Имя файла (опционально).
|
||||
**kwargs: Дополнительные параметры.
|
||||
|
||||
Returns:
|
||||
True если документ отправлен успешно, False в противном случае.
|
||||
"""
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def messenger_type(self) -> str:
|
||||
"""Тип мессенджера (telegram, max, и т.д.)."""
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def supports_threads(self) -> bool:
|
||||
"""Поддерживает ли мессенджер треды."""
|
||||
pass
|
||||
|
||||
476
app/core/messengers/max.py
Normal file
476
app/core/messengers/max.py
Normal file
@@ -0,0 +1,476 @@
|
||||
"""
|
||||
Адаптер для работы с MAX/VK через MessengerClient интерфейс.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
import logging
|
||||
import io
|
||||
from typing import Optional, Union, Dict, Any
|
||||
import httpx
|
||||
|
||||
from app.core.messengers.base import MessengerClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MaxMessengerClient(MessengerClient):
|
||||
"""Адаптер для MAX/VK, реализующий интерфейс MessengerClient."""
|
||||
|
||||
def __init__(self, access_token: str, api_version: str = "5.131"):
|
||||
"""
|
||||
Инициализация клиента MAX/VK.
|
||||
|
||||
Args:
|
||||
access_token: Access token для VK API.
|
||||
api_version: Версия VK API (по умолчанию 5.131).
|
||||
|
||||
Raises:
|
||||
ValueError: Если access_token не указан.
|
||||
"""
|
||||
if not access_token:
|
||||
raise ValueError("MAX_ACCESS_TOKEN не установлен")
|
||||
self.access_token = access_token
|
||||
self.api_version = api_version
|
||||
self.api_url = "https://api.vk.com/method"
|
||||
|
||||
@property
|
||||
def messenger_type(self) -> str:
|
||||
"""Тип мессенджера."""
|
||||
return "max"
|
||||
|
||||
@property
|
||||
def supports_threads(self) -> bool:
|
||||
"""MAX/VK не поддерживает треды."""
|
||||
return False
|
||||
|
||||
def _convert_html_to_vk_format(self, text: str) -> str:
|
||||
"""
|
||||
Конвертировать HTML в формат VK.
|
||||
|
||||
VK поддерживает свою разметку:
|
||||
- [bold]текст[/bold] - жирный
|
||||
- [italic]текст[/italic] - курсив
|
||||
- [code]текст[/code] - код
|
||||
|
||||
Args:
|
||||
text: Текст с HTML разметкой.
|
||||
|
||||
Returns:
|
||||
Текст с VK разметкой.
|
||||
"""
|
||||
# Простая конвертация HTML в VK формат
|
||||
# Заменяем <b> и </b> на [bold] и [/bold]
|
||||
text = text.replace("<b>", "[bold]").replace("</b>", "[/bold]")
|
||||
text = text.replace("<strong>", "[bold]").replace("</strong>", "[/bold]")
|
||||
|
||||
# Заменяем <i> и </i> на [italic] и [/italic]
|
||||
text = text.replace("<i>", "[italic]").replace("</i>", "[/italic]")
|
||||
text = text.replace("<em>", "[italic]").replace("</em>", "[/italic]")
|
||||
|
||||
# Заменяем <code> и </code> на [code] и [/code]
|
||||
text = text.replace("<code>", "[code]").replace("</code>", "[/code]")
|
||||
|
||||
# Заменяем <pre> и </pre> на [code] и [/code]
|
||||
text = text.replace("<pre>", "[code]").replace("</pre>", "[/code]")
|
||||
|
||||
# Заменяем <br> и <br/> на перенос строки
|
||||
text = text.replace("<br>", "\n").replace("<br/>", "\n").replace("<br />", "\n")
|
||||
|
||||
# Удаляем другие HTML теги (простая очистка)
|
||||
import re
|
||||
text = re.sub(r'<[^>]+>', '', text)
|
||||
|
||||
return text
|
||||
|
||||
async def _download_file(self, url: str) -> bytes:
|
||||
"""
|
||||
Скачать файл по URL.
|
||||
|
||||
Args:
|
||||
url: URL файла.
|
||||
|
||||
Returns:
|
||||
Байты файла.
|
||||
"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
return response.content
|
||||
|
||||
async def _upload_photo_to_vk(self, photo: Union[str, bytes], peer_id: Union[str, int]) -> Optional[str]:
|
||||
"""
|
||||
Загрузить фото в VK и получить attachment.
|
||||
|
||||
Args:
|
||||
photo: URL или bytes фото.
|
||||
peer_id: ID получателя.
|
||||
|
||||
Returns:
|
||||
Attachment string для VK API или None в случае ошибки.
|
||||
"""
|
||||
try:
|
||||
# Если photo - это URL, скачиваем файл
|
||||
if isinstance(photo, str) and (photo.startswith('http://') or photo.startswith('https://')):
|
||||
photo_bytes = await self._download_file(photo)
|
||||
elif isinstance(photo, bytes):
|
||||
photo_bytes = photo
|
||||
else:
|
||||
logger.error(f"Неподдерживаемый тип photo: {type(photo)}")
|
||||
return None
|
||||
|
||||
# Получаем URL для загрузки фото
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Шаг 1: Получаем upload server
|
||||
upload_url_params = {
|
||||
"access_token": self.access_token,
|
||||
"peer_id": peer_id,
|
||||
"v": self.api_version
|
||||
}
|
||||
upload_url_response = await client.get(
|
||||
f"{self.api_url}/photos.getMessagesUploadServer",
|
||||
params=upload_url_params
|
||||
)
|
||||
upload_url_data = upload_url_response.json()
|
||||
|
||||
if "error" in upload_url_data:
|
||||
logger.error(f"Ошибка получения upload server: {upload_url_data['error']}")
|
||||
return None
|
||||
|
||||
upload_url = upload_url_data["response"]["upload_url"]
|
||||
|
||||
# Шаг 2: Загружаем фото
|
||||
files = {"photo": ("photo.jpg", photo_bytes, "image/jpeg")}
|
||||
upload_response = await client.post(upload_url, files=files)
|
||||
upload_data = upload_response.json()
|
||||
|
||||
if "error" in upload_data:
|
||||
logger.error(f"Ошибка загрузки фото: {upload_data['error']}")
|
||||
return None
|
||||
|
||||
# Шаг 3: Сохраняем фото
|
||||
save_params = {
|
||||
"access_token": self.access_token,
|
||||
"server": upload_data["server"],
|
||||
"photo": upload_data["photo"],
|
||||
"hash": upload_data["hash"],
|
||||
"v": self.api_version
|
||||
}
|
||||
save_response = await client.get(
|
||||
f"{self.api_url}/photos.saveMessagesPhoto",
|
||||
params=save_params
|
||||
)
|
||||
save_data = save_response.json()
|
||||
|
||||
if "error" in save_data:
|
||||
logger.error(f"Ошибка сохранения фото: {save_data['error']}")
|
||||
return None
|
||||
|
||||
# Формируем attachment string
|
||||
photo_data = save_data["response"][0]
|
||||
attachment = f"photo{photo_data['owner_id']}_{photo_data['id']}"
|
||||
return attachment
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка загрузки фото в VK: {e}")
|
||||
return None
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
chat_id: Union[str, int],
|
||||
text: str,
|
||||
thread_id: Optional[int] = None,
|
||||
reply_markup: Optional[Dict[str, Any]] = None,
|
||||
disable_web_page_preview: bool = True,
|
||||
parse_mode: str = "HTML",
|
||||
**kwargs
|
||||
) -> bool:
|
||||
"""
|
||||
Отправить текстовое сообщение в MAX/VK.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата или группы (может быть строкой или числом).
|
||||
text: Текст сообщения.
|
||||
thread_id: ID треда в группе (игнорируется для VK).
|
||||
reply_markup: Клавиатура с кнопками (опционально, формат VK).
|
||||
disable_web_page_preview: Отключить превью ссылок (игнорируется для VK).
|
||||
parse_mode: Режим парсинга (HTML конвертируется в VK формат).
|
||||
**kwargs: Дополнительные параметры (attachment, и т.д.).
|
||||
|
||||
Returns:
|
||||
True если сообщение отправлено успешно, False в противном случае.
|
||||
"""
|
||||
if thread_id is not None:
|
||||
logger.warning("MAX/VK не поддерживает треды, параметр thread_id игнорируется")
|
||||
|
||||
try:
|
||||
# Конвертируем HTML в формат VK
|
||||
if parse_mode == "HTML":
|
||||
text = self._convert_html_to_vk_format(text)
|
||||
|
||||
# Преобразуем chat_id в int
|
||||
peer_id = int(chat_id) if isinstance(chat_id, str) else chat_id
|
||||
|
||||
# Генерируем random_id для VK API (должен быть уникальным для каждого сообщения)
|
||||
import random
|
||||
random_id = random.randint(1, 2**31 - 1)
|
||||
|
||||
# Параметры для отправки сообщения
|
||||
params = {
|
||||
"access_token": self.access_token,
|
||||
"peer_id": peer_id,
|
||||
"message": text,
|
||||
"v": self.api_version,
|
||||
"random_id": random_id # VK требует random_id
|
||||
}
|
||||
|
||||
# Добавляем attachment, если есть
|
||||
if "attachment" in kwargs:
|
||||
params["attachment"] = kwargs["attachment"]
|
||||
|
||||
# Добавляем клавиатуру, если есть
|
||||
if reply_markup:
|
||||
import json
|
||||
params["keyboard"] = json.dumps(reply_markup)
|
||||
|
||||
# Отправляем сообщение
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
f"{self.api_url}/messages.send",
|
||||
params=params
|
||||
)
|
||||
response_data = response.json()
|
||||
|
||||
if "error" in response_data:
|
||||
error = response_data["error"]
|
||||
logger.error(f"Ошибка отправки сообщения в VK: {error}")
|
||||
return False
|
||||
|
||||
message_id = response_data.get("response")
|
||||
if message_id:
|
||||
logger.info(f"Сообщение отправлено в VK чат {peer_id}, message_id: {message_id}")
|
||||
return True
|
||||
else:
|
||||
logger.error("Не удалось получить message_id из ответа VK API")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Неожиданная ошибка при отправке сообщения в VK: {e}")
|
||||
return False
|
||||
|
||||
async def send_photo(
|
||||
self,
|
||||
chat_id: Union[str, int],
|
||||
photo: Union[str, bytes],
|
||||
caption: Optional[str] = None,
|
||||
thread_id: Optional[int] = None,
|
||||
parse_mode: str = "HTML",
|
||||
**kwargs
|
||||
) -> bool:
|
||||
"""
|
||||
Отправить фото в MAX/VK.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата или группы.
|
||||
photo: URL или bytes фото.
|
||||
caption: Подпись к фото (опционально).
|
||||
thread_id: ID треда в группе (игнорируется для VK).
|
||||
parse_mode: Режим парсинга (HTML конвертируется в VK формат).
|
||||
**kwargs: Дополнительные параметры.
|
||||
|
||||
Returns:
|
||||
True если фото отправлено успешно, False в противном случае.
|
||||
"""
|
||||
if thread_id is not None:
|
||||
logger.warning("MAX/VK не поддерживает треды, параметр thread_id игнорируется")
|
||||
|
||||
try:
|
||||
peer_id = int(chat_id) if isinstance(chat_id, str) else chat_id
|
||||
|
||||
# Загружаем фото в VK
|
||||
attachment = await self._upload_photo_to_vk(photo, peer_id)
|
||||
if not attachment:
|
||||
logger.error("Не удалось загрузить фото в VK")
|
||||
return False
|
||||
|
||||
# Формируем текст сообщения
|
||||
text = caption or ""
|
||||
if parse_mode == "HTML" and text:
|
||||
text = self._convert_html_to_vk_format(text)
|
||||
|
||||
# Отправляем сообщение с фото
|
||||
return await self.send_message(
|
||||
chat_id=peer_id,
|
||||
text=text,
|
||||
attachment=attachment,
|
||||
parse_mode=parse_mode
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Неожиданная ошибка при отправке фото в VK: {e}")
|
||||
return False
|
||||
|
||||
async def send_video(
|
||||
self,
|
||||
chat_id: Union[str, int],
|
||||
video: Union[str, bytes],
|
||||
caption: Optional[str] = None,
|
||||
thread_id: Optional[int] = None,
|
||||
parse_mode: str = "HTML",
|
||||
duration: Optional[int] = None,
|
||||
width: Optional[int] = None,
|
||||
height: Optional[int] = None,
|
||||
**kwargs
|
||||
) -> bool:
|
||||
"""
|
||||
Отправить видео в MAX/VK.
|
||||
|
||||
Примечание: VK API требует более сложную логику для загрузки видео.
|
||||
В текущей реализации отправляется только ссылка на видео.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата или группы.
|
||||
video: URL или bytes видео.
|
||||
caption: Подпись к видео (опционально).
|
||||
thread_id: ID треда в группе (игнорируется для VK).
|
||||
parse_mode: Режим парсинга (HTML конвертируется в VK формат).
|
||||
duration: Длительность видео в секундах (игнорируется для VK).
|
||||
width: Ширина видео (игнорируется для VK).
|
||||
height: Высота видео (игнорируется для VK).
|
||||
**kwargs: Дополнительные параметры.
|
||||
|
||||
Returns:
|
||||
True если видео отправлено успешно, False в противном случае.
|
||||
"""
|
||||
if thread_id is not None:
|
||||
logger.warning("MAX/VK не поддерживает треды, параметр thread_id игнорируется")
|
||||
|
||||
try:
|
||||
peer_id = int(chat_id) if isinstance(chat_id, str) else chat_id
|
||||
|
||||
# Если video - это URL, отправляем как ссылку
|
||||
if isinstance(video, str) and (video.startswith('http://') or video.startswith('https://')):
|
||||
text = caption or video
|
||||
if parse_mode == "HTML" and text:
|
||||
text = self._convert_html_to_vk_format(text)
|
||||
return await self.send_message(
|
||||
chat_id=peer_id,
|
||||
text=text,
|
||||
parse_mode=parse_mode
|
||||
)
|
||||
else:
|
||||
logger.warning("Загрузка видео через bytes пока не поддерживается в VK адаптере")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Неожиданная ошибка при отправке видео в VK: {e}")
|
||||
return False
|
||||
|
||||
async def send_audio(
|
||||
self,
|
||||
chat_id: Union[str, int],
|
||||
audio: Union[str, bytes],
|
||||
caption: Optional[str] = None,
|
||||
thread_id: Optional[int] = None,
|
||||
parse_mode: str = "HTML",
|
||||
duration: Optional[int] = None,
|
||||
performer: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> bool:
|
||||
"""
|
||||
Отправить аудио в MAX/VK.
|
||||
|
||||
Примечание: VK API требует специальную логику для загрузки аудио.
|
||||
В текущей реализации отправляется только ссылка на аудио.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата или группы.
|
||||
audio: URL или bytes аудио.
|
||||
caption: Подпись к аудио (опционально).
|
||||
thread_id: ID треда в группе (игнорируется для VK).
|
||||
parse_mode: Режим парсинга (HTML конвертируется в VK формат).
|
||||
duration: Длительность аудио в секундах (игнорируется для VK).
|
||||
performer: Исполнитель (игнорируется для VK).
|
||||
title: Название трека (игнорируется для VK).
|
||||
**kwargs: Дополнительные параметры.
|
||||
|
||||
Returns:
|
||||
True если аудио отправлено успешно, False в противном случае.
|
||||
"""
|
||||
if thread_id is not None:
|
||||
logger.warning("MAX/VK не поддерживает треды, параметр thread_id игнорируется")
|
||||
|
||||
try:
|
||||
peer_id = int(chat_id) if isinstance(chat_id, str) else chat_id
|
||||
|
||||
# Если audio - это URL, отправляем как ссылку
|
||||
if isinstance(audio, str) and (audio.startswith('http://') or audio.startswith('https://')):
|
||||
text = caption or audio
|
||||
if parse_mode == "HTML" and text:
|
||||
text = self._convert_html_to_vk_format(text)
|
||||
return await self.send_message(
|
||||
chat_id=peer_id,
|
||||
text=text,
|
||||
parse_mode=parse_mode
|
||||
)
|
||||
else:
|
||||
logger.warning("Загрузка аудио через bytes пока не поддерживается в VK адаптере")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Неожиданная ошибка при отправке аудио в VK: {e}")
|
||||
return False
|
||||
|
||||
async def send_document(
|
||||
self,
|
||||
chat_id: Union[str, int],
|
||||
document: Union[str, bytes],
|
||||
caption: Optional[str] = None,
|
||||
thread_id: Optional[int] = None,
|
||||
parse_mode: str = "HTML",
|
||||
filename: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> bool:
|
||||
"""
|
||||
Отправить документ в MAX/VK.
|
||||
|
||||
Примечание: VK API требует специальную логику для загрузки документов.
|
||||
В текущей реализации отправляется только ссылка на документ.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата или группы.
|
||||
document: URL или bytes документа.
|
||||
caption: Подпись к документу (опционально).
|
||||
thread_id: ID треда в группе (игнорируется для VK).
|
||||
parse_mode: Режим парсинга (HTML конвертируется в VK формат).
|
||||
filename: Имя файла (игнорируется для VK).
|
||||
**kwargs: Дополнительные параметры.
|
||||
|
||||
Returns:
|
||||
True если документ отправлен успешно, False в противном случае.
|
||||
"""
|
||||
if thread_id is not None:
|
||||
logger.warning("MAX/VK не поддерживает треды, параметр thread_id игнорируется")
|
||||
|
||||
try:
|
||||
peer_id = int(chat_id) if isinstance(chat_id, str) else chat_id
|
||||
|
||||
# Если document - это URL, отправляем как ссылку
|
||||
if isinstance(document, str) and (document.startswith('http://') or document.startswith('https://')):
|
||||
text = caption or document
|
||||
if parse_mode == "HTML" and text:
|
||||
text = self._convert_html_to_vk_format(text)
|
||||
return await self.send_message(
|
||||
chat_id=peer_id,
|
||||
text=text,
|
||||
parse_mode=parse_mode
|
||||
)
|
||||
else:
|
||||
logger.warning("Загрузка документов через bytes пока не поддерживается в VK адаптере")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Неожиданная ошибка при отправке документа в VK: {e}")
|
||||
return False
|
||||
|
||||
255
app/core/messengers/telegram.py
Normal file
255
app/core/messengers/telegram.py
Normal file
@@ -0,0 +1,255 @@
|
||||
"""
|
||||
Адаптер для работы с Telegram через MessengerClient интерфейс.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
import logging
|
||||
import io
|
||||
from typing import Optional, Union, Dict, Any
|
||||
from telegram import InlineKeyboardMarkup
|
||||
|
||||
from app.core.messengers.base import MessengerClient
|
||||
from app.core.telegram_client import TelegramClient
|
||||
from app.core.button_utils import convert_dict_to_telegram_buttons
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TelegramMessengerClient(MessengerClient):
|
||||
"""Адаптер для Telegram, реализующий интерфейс MessengerClient."""
|
||||
|
||||
def __init__(self, bot_token: Optional[str] = None):
|
||||
"""
|
||||
Инициализация клиента Telegram.
|
||||
|
||||
Args:
|
||||
bot_token: Токен бота Telegram. Если не указан, используется из настроек.
|
||||
"""
|
||||
self._client = TelegramClient(bot_token=bot_token)
|
||||
|
||||
@property
|
||||
def messenger_type(self) -> str:
|
||||
"""Тип мессенджера."""
|
||||
return "telegram"
|
||||
|
||||
@property
|
||||
def supports_threads(self) -> bool:
|
||||
"""Telegram поддерживает треды."""
|
||||
return True
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
chat_id: Union[str, int],
|
||||
text: str,
|
||||
thread_id: Optional[int] = None,
|
||||
reply_markup: Optional[Dict[str, Any]] = None,
|
||||
disable_web_page_preview: bool = True,
|
||||
parse_mode: str = "HTML",
|
||||
**kwargs
|
||||
) -> bool:
|
||||
"""
|
||||
Отправить текстовое сообщение в Telegram.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата или группы (преобразуется в int).
|
||||
text: Текст сообщения.
|
||||
thread_id: ID треда в группе (опционально).
|
||||
reply_markup: Клавиатура с кнопками (опционально).
|
||||
disable_web_page_preview: Отключить превью ссылок.
|
||||
parse_mode: Режим парсинга (HTML, Markdown).
|
||||
**kwargs: Дополнительные параметры (игнорируются).
|
||||
|
||||
Returns:
|
||||
True если сообщение отправлено успешно, False в противном случае.
|
||||
"""
|
||||
# Преобразуем chat_id в int
|
||||
chat_id_int = int(chat_id) if isinstance(chat_id, str) else chat_id
|
||||
|
||||
# Преобразуем reply_markup из Dict в InlineKeyboardMarkup, если нужно
|
||||
telegram_reply_markup = None
|
||||
if reply_markup:
|
||||
if isinstance(reply_markup, InlineKeyboardMarkup):
|
||||
telegram_reply_markup = reply_markup
|
||||
elif isinstance(reply_markup, dict):
|
||||
# Преобразуем словарь в InlineKeyboardMarkup
|
||||
telegram_reply_markup = convert_dict_to_telegram_buttons(reply_markup)
|
||||
|
||||
return await self._client.send_message(
|
||||
chat_id=chat_id_int,
|
||||
text=text,
|
||||
message_thread_id=thread_id,
|
||||
reply_markup=telegram_reply_markup,
|
||||
disable_web_page_preview=disable_web_page_preview,
|
||||
parse_mode=parse_mode
|
||||
)
|
||||
|
||||
async def send_photo(
|
||||
self,
|
||||
chat_id: Union[str, int],
|
||||
photo: Union[str, bytes],
|
||||
caption: Optional[str] = None,
|
||||
thread_id: Optional[int] = None,
|
||||
parse_mode: str = "HTML",
|
||||
**kwargs
|
||||
) -> bool:
|
||||
"""
|
||||
Отправить фото в Telegram.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата или группы (преобразуется в int).
|
||||
photo: Путь к файлу, URL, bytes или BytesIO объект с фото.
|
||||
caption: Подпись к фото (опционально).
|
||||
thread_id: ID треда в группе (опционально).
|
||||
parse_mode: Режим парсинга (HTML, Markdown).
|
||||
**kwargs: Дополнительные параметры (игнорируются).
|
||||
|
||||
Returns:
|
||||
True если фото отправлено успешно, False в противном случае.
|
||||
"""
|
||||
chat_id_int = int(chat_id) if isinstance(chat_id, str) else chat_id
|
||||
|
||||
# Преобразуем bytes в BytesIO, если нужно
|
||||
if isinstance(photo, bytes):
|
||||
photo = io.BytesIO(photo)
|
||||
|
||||
return await self._client.send_photo(
|
||||
chat_id=chat_id_int,
|
||||
photo=photo,
|
||||
caption=caption,
|
||||
message_thread_id=thread_id,
|
||||
parse_mode=parse_mode
|
||||
)
|
||||
|
||||
async def send_video(
|
||||
self,
|
||||
chat_id: Union[str, int],
|
||||
video: Union[str, bytes],
|
||||
caption: Optional[str] = None,
|
||||
thread_id: Optional[int] = None,
|
||||
parse_mode: str = "HTML",
|
||||
duration: Optional[int] = None,
|
||||
width: Optional[int] = None,
|
||||
height: Optional[int] = None,
|
||||
**kwargs
|
||||
) -> bool:
|
||||
"""
|
||||
Отправить видео в Telegram.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата или группы (преобразуется в int).
|
||||
video: Путь к файлу, URL, bytes или BytesIO объект с видео.
|
||||
caption: Подпись к видео (опционально).
|
||||
thread_id: ID треда в группе (опционально).
|
||||
parse_mode: Режим парсинга (HTML, Markdown).
|
||||
duration: Длительность видео в секундах (опционально).
|
||||
width: Ширина видео (опционально).
|
||||
height: Высота видео (опционально).
|
||||
**kwargs: Дополнительные параметры (игнорируются).
|
||||
|
||||
Returns:
|
||||
True если видео отправлено успешно, False в противном случае.
|
||||
"""
|
||||
chat_id_int = int(chat_id) if isinstance(chat_id, str) else chat_id
|
||||
|
||||
# Преобразуем bytes в BytesIO, если нужно
|
||||
if isinstance(video, bytes):
|
||||
video = io.BytesIO(video)
|
||||
|
||||
return await self._client.send_video(
|
||||
chat_id=chat_id_int,
|
||||
video=video,
|
||||
caption=caption,
|
||||
message_thread_id=thread_id,
|
||||
parse_mode=parse_mode,
|
||||
duration=duration,
|
||||
width=width,
|
||||
height=height
|
||||
)
|
||||
|
||||
async def send_audio(
|
||||
self,
|
||||
chat_id: Union[str, int],
|
||||
audio: Union[str, bytes],
|
||||
caption: Optional[str] = None,
|
||||
thread_id: Optional[int] = None,
|
||||
parse_mode: str = "HTML",
|
||||
duration: Optional[int] = None,
|
||||
performer: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> bool:
|
||||
"""
|
||||
Отправить аудио в Telegram.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата или группы (преобразуется в int).
|
||||
audio: Путь к файлу, URL, bytes или BytesIO объект с аудио.
|
||||
caption: Подпись к аудио (опционально).
|
||||
thread_id: ID треда в группе (опционально).
|
||||
parse_mode: Режим парсинга (HTML, Markdown).
|
||||
duration: Длительность аудио в секундах (опционально).
|
||||
performer: Исполнитель (опционально).
|
||||
title: Название трека (опционально).
|
||||
**kwargs: Дополнительные параметры (игнорируются).
|
||||
|
||||
Returns:
|
||||
True если аудио отправлено успешно, False в противном случае.
|
||||
"""
|
||||
chat_id_int = int(chat_id) if isinstance(chat_id, str) else chat_id
|
||||
|
||||
# Преобразуем bytes в BytesIO, если нужно
|
||||
if isinstance(audio, bytes):
|
||||
audio = io.BytesIO(audio)
|
||||
|
||||
return await self._client.send_audio(
|
||||
chat_id=chat_id_int,
|
||||
audio=audio,
|
||||
caption=caption,
|
||||
message_thread_id=thread_id,
|
||||
parse_mode=parse_mode,
|
||||
duration=duration,
|
||||
performer=performer,
|
||||
title=title
|
||||
)
|
||||
|
||||
async def send_document(
|
||||
self,
|
||||
chat_id: Union[str, int],
|
||||
document: Union[str, bytes],
|
||||
caption: Optional[str] = None,
|
||||
thread_id: Optional[int] = None,
|
||||
parse_mode: str = "HTML",
|
||||
filename: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> bool:
|
||||
"""
|
||||
Отправить документ в Telegram.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата или группы (преобразуется в int).
|
||||
document: Путь к файлу, URL, bytes или BytesIO объект с документом.
|
||||
caption: Подпись к документу (опционально).
|
||||
thread_id: ID треда в группе (опционально).
|
||||
parse_mode: Режим парсинга (HTML, Markdown).
|
||||
filename: Имя файла (опционально).
|
||||
**kwargs: Дополнительные параметры (игнорируются).
|
||||
|
||||
Returns:
|
||||
True если документ отправлен успешно, False в противном случае.
|
||||
"""
|
||||
chat_id_int = int(chat_id) if isinstance(chat_id, str) else chat_id
|
||||
|
||||
# Преобразуем bytes в BytesIO, если нужно
|
||||
if isinstance(document, bytes):
|
||||
document = io.BytesIO(document)
|
||||
|
||||
return await self._client.send_document(
|
||||
chat_id=chat_id_int,
|
||||
document=document,
|
||||
caption=caption,
|
||||
message_thread_id=thread_id,
|
||||
parse_mode=parse_mode,
|
||||
filename=filename
|
||||
)
|
||||
|
||||
316
app/core/metrics.py
Normal file
316
app/core/metrics.py
Normal file
@@ -0,0 +1,316 @@
|
||||
"""
|
||||
Централизованное управление метриками Prometheus.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
import logging
|
||||
from typing import Optional
|
||||
from prometheus_client import Counter, CollectorRegistry, push_to_gateway
|
||||
from functools import lru_cache
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MetricsManager:
|
||||
"""Менеджер метрик Prometheus."""
|
||||
|
||||
def __init__(self):
|
||||
"""Инициализация менеджера метрик."""
|
||||
self.registry = CollectorRegistry()
|
||||
self._init_metrics()
|
||||
|
||||
def _init_metrics(self) -> None:
|
||||
"""Инициализация всех метрик."""
|
||||
# API эндпоинты
|
||||
self.api_endpoint_count = Counter(
|
||||
'tg_monitoring_gateway_api_endpoint_total',
|
||||
'Общее количество обращений к эндпоинтам API',
|
||||
labelnames=['endpoint'],
|
||||
registry=self.registry
|
||||
)
|
||||
|
||||
# Сообщения по источникам
|
||||
self.total_messages = Counter(
|
||||
'tg_monitoring_gateway_total_messages',
|
||||
'Всего сообщений получено',
|
||||
labelnames=['source', 'k8s_cluster', 'chat', 'thread'],
|
||||
registry=self.registry
|
||||
)
|
||||
|
||||
self.sent_messages = Counter(
|
||||
'tg_monitoring_gateway_sent_messages',
|
||||
'Сообщений успешно отправлено',
|
||||
labelnames=['source', 'k8s_cluster', 'chat', 'thread'],
|
||||
registry=self.registry
|
||||
)
|
||||
|
||||
self.reject_messages = Counter(
|
||||
'tg_monitoring_gateway_reject_messages',
|
||||
'Сообщений отклонено (стоп-слова)',
|
||||
labelnames=['source', 'k8s_cluster', 'chat', 'thread'],
|
||||
registry=self.registry
|
||||
)
|
||||
|
||||
self.error_messages = Counter(
|
||||
'tg_monitoring_gateway_error_messages',
|
||||
'Ошибок отправки сообщений',
|
||||
labelnames=['source', 'k8s_cluster', 'chat', 'thread'],
|
||||
registry=self.registry
|
||||
)
|
||||
|
||||
self.firing_messages = Counter(
|
||||
'tg_monitoring_gateway_firing_messages',
|
||||
'Горящих алертов',
|
||||
labelnames=['source', 'k8s_cluster', 'chat', 'thread'],
|
||||
registry=self.registry
|
||||
)
|
||||
|
||||
self.critical_messages = Counter(
|
||||
'tg_monitoring_gateway_critical_messages',
|
||||
'Критических алертов',
|
||||
labelnames=['source', 'k8s_cluster', 'chat', 'thread'],
|
||||
registry=self.registry
|
||||
)
|
||||
|
||||
self.resolved_messages = Counter(
|
||||
'tg_monitoring_gateway_resolved_messages',
|
||||
'Исправленных алертов',
|
||||
labelnames=['source', 'k8s_cluster', 'chat', 'thread'],
|
||||
registry=self.registry
|
||||
)
|
||||
|
||||
# Jira метрики
|
||||
self.jira_tickets_created = Counter(
|
||||
'tg_monitoring_gateway_jira_tickets_created',
|
||||
'Jira тикетов создано',
|
||||
labelnames=['source', 'k8s_cluster', 'chat', 'thread', 'project'],
|
||||
registry=self.registry
|
||||
)
|
||||
|
||||
self.jira_tickets_errors = Counter(
|
||||
'tg_monitoring_gateway_jira_tickets_errors',
|
||||
'Ошибок создания Jira тикетов',
|
||||
labelnames=['source', 'k8s_cluster', 'chat', 'thread'],
|
||||
registry=self.registry
|
||||
)
|
||||
|
||||
def increment_api_endpoint(self, endpoint: str) -> None:
|
||||
"""
|
||||
Увеличить счетчик обращений к эндпоинту API.
|
||||
|
||||
Args:
|
||||
endpoint: Имя эндпоинта.
|
||||
"""
|
||||
self.api_endpoint_count.labels(endpoint=endpoint).inc()
|
||||
self._push_metrics()
|
||||
|
||||
def increment_total_message(
|
||||
self,
|
||||
source: str,
|
||||
k8s_cluster: Optional[str] = None,
|
||||
chat: Optional[str] = None,
|
||||
thread: Optional[int] = None
|
||||
) -> None:
|
||||
"""
|
||||
Увеличить счетчик полученных сообщений.
|
||||
|
||||
Args:
|
||||
source: Источник сообщения (grafana, zabbix, alertmanager).
|
||||
k8s_cluster: Имя Kubernetes кластера (опционально).
|
||||
chat: Имя чата (опционально).
|
||||
thread: ID треда (опционально).
|
||||
"""
|
||||
k8s_cluster = k8s_cluster or ""
|
||||
chat = chat or ""
|
||||
thread = thread or 0
|
||||
self.total_messages.labels(
|
||||
source=source,
|
||||
k8s_cluster=k8s_cluster,
|
||||
chat=chat,
|
||||
thread=thread
|
||||
).inc()
|
||||
self._push_metrics()
|
||||
|
||||
def increment_sent_message(
|
||||
self,
|
||||
source: str,
|
||||
k8s_cluster: Optional[str] = None,
|
||||
chat: Optional[str] = None,
|
||||
thread: Optional[int] = None
|
||||
) -> None:
|
||||
"""Увеличить счетчик отправленных сообщений."""
|
||||
k8s_cluster = k8s_cluster or ""
|
||||
chat = chat or ""
|
||||
thread = thread or 0
|
||||
self.sent_messages.labels(
|
||||
source=source,
|
||||
k8s_cluster=k8s_cluster,
|
||||
chat=chat,
|
||||
thread=thread
|
||||
).inc()
|
||||
self._push_metrics()
|
||||
|
||||
def increment_reject_message(
|
||||
self,
|
||||
source: str,
|
||||
k8s_cluster: Optional[str] = None,
|
||||
chat: Optional[str] = None,
|
||||
thread: Optional[int] = None
|
||||
) -> None:
|
||||
"""Увеличить счетчик отклоненных сообщений."""
|
||||
k8s_cluster = k8s_cluster or ""
|
||||
chat = chat or ""
|
||||
thread = thread or 0
|
||||
self.reject_messages.labels(
|
||||
source=source,
|
||||
k8s_cluster=k8s_cluster,
|
||||
chat=chat,
|
||||
thread=thread
|
||||
).inc()
|
||||
self._push_metrics()
|
||||
|
||||
def increment_error_message(
|
||||
self,
|
||||
source: str,
|
||||
k8s_cluster: Optional[str] = None,
|
||||
chat: Optional[str] = None,
|
||||
thread: Optional[int] = None
|
||||
) -> None:
|
||||
"""Увеличить счетчик ошибок отправки."""
|
||||
k8s_cluster = k8s_cluster or ""
|
||||
chat = chat or ""
|
||||
thread = thread or 0
|
||||
self.error_messages.labels(
|
||||
source=source,
|
||||
k8s_cluster=k8s_cluster,
|
||||
chat=chat,
|
||||
thread=thread
|
||||
).inc()
|
||||
self._push_metrics()
|
||||
|
||||
def increment_firing_message(
|
||||
self,
|
||||
source: str,
|
||||
k8s_cluster: Optional[str] = None,
|
||||
chat: Optional[str] = None,
|
||||
thread: Optional[int] = None
|
||||
) -> None:
|
||||
"""Увеличить счетчик горящих алертов."""
|
||||
k8s_cluster = k8s_cluster or ""
|
||||
chat = chat or ""
|
||||
thread = thread or 0
|
||||
self.firing_messages.labels(
|
||||
source=source,
|
||||
k8s_cluster=k8s_cluster,
|
||||
chat=chat,
|
||||
thread=thread
|
||||
).inc()
|
||||
self._push_metrics()
|
||||
|
||||
def increment_critical_message(
|
||||
self,
|
||||
source: str,
|
||||
k8s_cluster: Optional[str] = None,
|
||||
chat: Optional[str] = None,
|
||||
thread: Optional[int] = None
|
||||
) -> None:
|
||||
"""Увеличить счетчик критических алертов."""
|
||||
k8s_cluster = k8s_cluster or ""
|
||||
chat = chat or ""
|
||||
thread = thread or 0
|
||||
self.critical_messages.labels(
|
||||
source=source,
|
||||
k8s_cluster=k8s_cluster,
|
||||
chat=chat,
|
||||
thread=thread
|
||||
).inc()
|
||||
self._push_metrics()
|
||||
|
||||
def increment_resolved_message(
|
||||
self,
|
||||
source: str,
|
||||
k8s_cluster: Optional[str] = None,
|
||||
chat: Optional[str] = None,
|
||||
thread: Optional[int] = None
|
||||
) -> None:
|
||||
"""Увеличить счетчик исправленных алертов."""
|
||||
k8s_cluster = k8s_cluster or ""
|
||||
chat = chat or ""
|
||||
thread = thread or 0
|
||||
self.resolved_messages.labels(
|
||||
source=source,
|
||||
k8s_cluster=k8s_cluster,
|
||||
chat=chat,
|
||||
thread=thread
|
||||
).inc()
|
||||
self._push_metrics()
|
||||
|
||||
def increment_jira_ticket_created(
|
||||
self,
|
||||
source: str,
|
||||
project: str,
|
||||
k8s_cluster: Optional[str] = None,
|
||||
chat: Optional[str] = None,
|
||||
thread: Optional[int] = None
|
||||
) -> None:
|
||||
"""Увеличить счетчик созданных Jira тикетов."""
|
||||
k8s_cluster = k8s_cluster or ""
|
||||
chat = chat or ""
|
||||
thread = thread or 0
|
||||
self.jira_tickets_created.labels(
|
||||
source=source,
|
||||
k8s_cluster=k8s_cluster,
|
||||
chat=chat,
|
||||
thread=thread,
|
||||
project=project
|
||||
).inc()
|
||||
self._push_metrics()
|
||||
|
||||
def increment_jira_ticket_error(
|
||||
self,
|
||||
source: str,
|
||||
k8s_cluster: Optional[str] = None,
|
||||
chat: Optional[str] = None,
|
||||
thread: Optional[int] = None
|
||||
) -> None:
|
||||
"""Увеличить счетчик ошибок создания Jira тикетов."""
|
||||
k8s_cluster = k8s_cluster or ""
|
||||
chat = chat or ""
|
||||
thread = thread or 0
|
||||
self.jira_tickets_errors.labels(
|
||||
source=source,
|
||||
k8s_cluster=k8s_cluster,
|
||||
chat=chat,
|
||||
thread=thread
|
||||
).inc()
|
||||
self._push_metrics()
|
||||
|
||||
def _push_metrics(self) -> None:
|
||||
"""Отправить метрики в Pushgateway."""
|
||||
from app.core.config import get_settings
|
||||
settings = get_settings()
|
||||
|
||||
if not settings.pushgateway_url:
|
||||
return
|
||||
|
||||
try:
|
||||
push_to_gateway(
|
||||
settings.pushgateway_url,
|
||||
job=settings.pushgateway_job,
|
||||
registry=self.registry
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки метрик в Pushgateway: {e}")
|
||||
|
||||
|
||||
# Глобальный экземпляр менеджера метрик
|
||||
@lru_cache(maxsize=1)
|
||||
def get_metrics_manager() -> MetricsManager:
|
||||
"""Получить глобальный экземпляр менеджера метрик."""
|
||||
return MetricsManager()
|
||||
|
||||
|
||||
metrics = get_metrics_manager()
|
||||
345
app/core/telegram_client.py
Normal file
345
app/core/telegram_client.py
Normal file
@@ -0,0 +1,345 @@
|
||||
"""
|
||||
Клиент для работы с Telegram Bot API.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
import logging
|
||||
import io
|
||||
from typing import Optional, Union
|
||||
from telegram import Bot, InlineKeyboardMarkup
|
||||
from telegram.error import TelegramError
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TelegramClient:
|
||||
"""Клиент для отправки сообщений в Telegram."""
|
||||
|
||||
def __init__(self, bot_token: Optional[str] = None):
|
||||
"""
|
||||
Инициализация клиента Telegram.
|
||||
|
||||
Args:
|
||||
bot_token: Токен бота Telegram. Если не указан, используется из настроек.
|
||||
|
||||
Raises:
|
||||
ValueError: Если токен не указан.
|
||||
"""
|
||||
# Импортируем settings здесь, чтобы избежать циклических зависимостей
|
||||
from app.core.config import get_settings
|
||||
settings = get_settings()
|
||||
self.bot_token = bot_token or settings.telegram_bot_token
|
||||
if not self.bot_token:
|
||||
raise ValueError("TELEGRAM_BOT_TOKEN не установлен")
|
||||
self._bot: Optional[Bot] = None
|
||||
|
||||
async def get_bot(self) -> Bot:
|
||||
"""
|
||||
Получить экземпляр бота (создается при первом обращении).
|
||||
|
||||
Returns:
|
||||
Экземпляр Bot.
|
||||
"""
|
||||
if self._bot is None:
|
||||
self._bot = Bot(token=self.bot_token)
|
||||
return self._bot
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
chat_id: int,
|
||||
text: str,
|
||||
message_thread_id: Optional[int] = None,
|
||||
reply_markup: Optional[InlineKeyboardMarkup] = None,
|
||||
disable_web_page_preview: bool = True,
|
||||
parse_mode: str = "HTML"
|
||||
) -> bool:
|
||||
"""
|
||||
Отправить сообщение в Telegram.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата или группы.
|
||||
text: Текст сообщения.
|
||||
message_thread_id: ID треда в группе (опционально).
|
||||
reply_markup: Клавиатура с кнопками (опционально).
|
||||
disable_web_page_preview: Отключить превью ссылок.
|
||||
parse_mode: Режим парсинга (HTML, Markdown).
|
||||
|
||||
Returns:
|
||||
True если сообщение отправлено успешно, False в противном случае.
|
||||
"""
|
||||
try:
|
||||
bot = await self.get_bot()
|
||||
await bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text=text,
|
||||
message_thread_id=message_thread_id,
|
||||
disable_web_page_preview=disable_web_page_preview,
|
||||
parse_mode=parse_mode,
|
||||
reply_markup=reply_markup
|
||||
)
|
||||
logger.info(f"Сообщение отправлено в чат {chat_id}, тред {message_thread_id}")
|
||||
return True
|
||||
except TelegramError as e:
|
||||
logger.error(f"Ошибка отправки сообщения в Telegram: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Неожиданная ошибка при отправке сообщения: {e}")
|
||||
return False
|
||||
|
||||
async def send_photo(
|
||||
self,
|
||||
chat_id: int,
|
||||
photo: Union[str, bytes, io.BytesIO],
|
||||
caption: Optional[str] = None,
|
||||
message_thread_id: Optional[int] = None,
|
||||
parse_mode: str = "HTML"
|
||||
) -> bool:
|
||||
"""
|
||||
Отправить фото в Telegram.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата или группы.
|
||||
photo: Путь к файлу, URL, bytes или BytesIO объект с фото.
|
||||
caption: Подпись к фото (опционально).
|
||||
message_thread_id: ID треда в группе (опционально).
|
||||
parse_mode: Режим парсинга (HTML, Markdown).
|
||||
|
||||
Returns:
|
||||
True если фото отправлено успешно, False в противном случае.
|
||||
"""
|
||||
try:
|
||||
bot = await self.get_bot()
|
||||
|
||||
# Если photo - это URL, скачиваем файл
|
||||
if isinstance(photo, str) and (photo.startswith('http://') or photo.startswith('https://')):
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(photo)
|
||||
photo = io.BytesIO(response.content)
|
||||
|
||||
# Если photo - это bytes, преобразуем в BytesIO
|
||||
if isinstance(photo, bytes):
|
||||
photo = io.BytesIO(photo)
|
||||
|
||||
await bot.send_photo(
|
||||
chat_id=chat_id,
|
||||
photo=photo,
|
||||
caption=caption,
|
||||
message_thread_id=message_thread_id,
|
||||
parse_mode=parse_mode
|
||||
)
|
||||
logger.info(f"Фото отправлено в чат {chat_id}, тред {message_thread_id}")
|
||||
return True
|
||||
except TelegramError as e:
|
||||
logger.error(f"Ошибка отправки фото в Telegram: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Неожиданная ошибка при отправке фото: {e}")
|
||||
return False
|
||||
|
||||
async def send_video(
|
||||
self,
|
||||
chat_id: int,
|
||||
video: Union[str, bytes, io.BytesIO],
|
||||
caption: Optional[str] = None,
|
||||
message_thread_id: Optional[int] = None,
|
||||
parse_mode: str = "HTML",
|
||||
duration: Optional[int] = None,
|
||||
width: Optional[int] = None,
|
||||
height: Optional[int] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Отправить видео в Telegram.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата или группы.
|
||||
video: Путь к файлу, URL, bytes или BytesIO объект с видео.
|
||||
caption: Подпись к видео (опционально).
|
||||
message_thread_id: ID треда в группе (опционально).
|
||||
parse_mode: Режим парсинга (HTML, Markdown).
|
||||
duration: Длительность видео в секундах (опционально).
|
||||
width: Ширина видео (опционально).
|
||||
height: Высота видео (опционально).
|
||||
|
||||
Returns:
|
||||
True если видео отправлено успешно, False в противном случае.
|
||||
"""
|
||||
try:
|
||||
bot = await self.get_bot()
|
||||
|
||||
# Если video - это URL, скачиваем файл
|
||||
if isinstance(video, str) and (video.startswith('http://') or video.startswith('https://')):
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(video)
|
||||
video = io.BytesIO(response.content)
|
||||
|
||||
# Если video - это bytes, преобразуем в BytesIO
|
||||
if isinstance(video, bytes):
|
||||
video = io.BytesIO(video)
|
||||
|
||||
await bot.send_video(
|
||||
chat_id=chat_id,
|
||||
video=video,
|
||||
caption=caption,
|
||||
message_thread_id=message_thread_id,
|
||||
parse_mode=parse_mode,
|
||||
duration=duration,
|
||||
width=width,
|
||||
height=height
|
||||
)
|
||||
logger.info(f"Видео отправлено в чат {chat_id}, тред {message_thread_id}")
|
||||
return True
|
||||
except TelegramError as e:
|
||||
logger.error(f"Ошибка отправки видео в Telegram: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Неожиданная ошибка при отправке видео: {e}")
|
||||
return False
|
||||
|
||||
async def send_audio(
|
||||
self,
|
||||
chat_id: int,
|
||||
audio: Union[str, bytes, io.BytesIO],
|
||||
caption: Optional[str] = None,
|
||||
message_thread_id: Optional[int] = None,
|
||||
parse_mode: str = "HTML",
|
||||
duration: Optional[int] = None,
|
||||
performer: Optional[str] = None,
|
||||
title: Optional[str] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Отправить аудио в Telegram.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата или группы.
|
||||
audio: Путь к файлу, URL, bytes или BytesIO объект с аудио.
|
||||
caption: Подпись к аудио (опционально).
|
||||
message_thread_id: ID треда в группе (опционально).
|
||||
parse_mode: Режим парсинга (HTML, Markdown).
|
||||
duration: Длительность аудио в секундах (опционально).
|
||||
performer: Исполнитель (опционально).
|
||||
title: Название трека (опционально).
|
||||
|
||||
Returns:
|
||||
True если аудио отправлено успешно, False в противном случае.
|
||||
"""
|
||||
try:
|
||||
bot = await self.get_bot()
|
||||
|
||||
# Если audio - это URL, скачиваем файл
|
||||
if isinstance(audio, str) and (audio.startswith('http://') or audio.startswith('https://')):
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(audio)
|
||||
audio = io.BytesIO(response.content)
|
||||
|
||||
# Если audio - это bytes, преобразуем в BytesIO
|
||||
if isinstance(audio, bytes):
|
||||
audio = io.BytesIO(audio)
|
||||
|
||||
await bot.send_audio(
|
||||
chat_id=chat_id,
|
||||
audio=audio,
|
||||
caption=caption,
|
||||
message_thread_id=message_thread_id,
|
||||
parse_mode=parse_mode,
|
||||
duration=duration,
|
||||
performer=performer,
|
||||
title=title
|
||||
)
|
||||
logger.info(f"Аудио отправлено в чат {chat_id}, тред {message_thread_id}")
|
||||
return True
|
||||
except TelegramError as e:
|
||||
logger.error(f"Ошибка отправки аудио в Telegram: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Неожиданная ошибка при отправке аудио: {e}")
|
||||
return False
|
||||
|
||||
async def send_document(
|
||||
self,
|
||||
chat_id: int,
|
||||
document: Union[str, bytes, io.BytesIO],
|
||||
caption: Optional[str] = None,
|
||||
message_thread_id: Optional[int] = None,
|
||||
parse_mode: str = "HTML",
|
||||
filename: Optional[str] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Отправить документ в Telegram.
|
||||
|
||||
Args:
|
||||
chat_id: ID чата или группы.
|
||||
document: Путь к файлу, URL, bytes или BytesIO объект с документом.
|
||||
caption: Подпись к документу (опционально).
|
||||
message_thread_id: ID треда в группе (опционально).
|
||||
parse_mode: Режим парсинга (HTML, Markdown).
|
||||
filename: Имя файла (опционально).
|
||||
|
||||
Returns:
|
||||
True если документ отправлен успешно, False в противном случае.
|
||||
"""
|
||||
try:
|
||||
bot = await self.get_bot()
|
||||
document_url = None
|
||||
|
||||
# Если document - это URL, скачиваем файл
|
||||
if isinstance(document, str) and (document.startswith('http://') or document.startswith('https://')):
|
||||
document_url = document
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(document)
|
||||
document = io.BytesIO(response.content)
|
||||
if not filename:
|
||||
# Пытаемся извлечь имя файла из URL
|
||||
filename = document_url.split('/')[-1].split('?')[0]
|
||||
|
||||
# Если document - это bytes, преобразуем в BytesIO
|
||||
if isinstance(document, bytes):
|
||||
document = io.BytesIO(document)
|
||||
|
||||
await bot.send_document(
|
||||
chat_id=chat_id,
|
||||
document=document,
|
||||
caption=caption,
|
||||
message_thread_id=message_thread_id,
|
||||
parse_mode=parse_mode,
|
||||
filename=filename
|
||||
)
|
||||
logger.info(f"Документ отправлен в чат {chat_id}, тред {message_thread_id}")
|
||||
return True
|
||||
except TelegramError as e:
|
||||
logger.error(f"Ошибка отправки документа в Telegram: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Неожиданная ошибка при отправке документа: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# Глобальный экземпляр клиента (lazy initialization)
|
||||
_telegram_client_instance: Optional[TelegramClient] = None
|
||||
|
||||
|
||||
def get_telegram_client() -> TelegramClient:
|
||||
"""
|
||||
Получить экземпляр клиента Telegram (lazy initialization).
|
||||
|
||||
Returns:
|
||||
Экземпляр TelegramClient.
|
||||
"""
|
||||
global _telegram_client_instance
|
||||
if _telegram_client_instance is None:
|
||||
_telegram_client_instance = TelegramClient()
|
||||
return _telegram_client_instance
|
||||
|
||||
|
||||
# Для обратной совместимости (lazy initialization)
|
||||
# telegram_client будет создан при первом использовании
|
||||
class _TelegramClientProxy:
|
||||
"""Прокси для ленивой инициализации telegram_client."""
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Получить атрибут из telegram_client."""
|
||||
return getattr(get_telegram_client(), name)
|
||||
|
||||
|
||||
telegram_client = _TelegramClientProxy()
|
||||
97
app/core/utils.py
Normal file
97
app/core/utils.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
Вспомогательные утилиты.
|
||||
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
import re
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Список стоп-слов (алерты, которые не должны отправляться)
|
||||
STOP_WORDS = [
|
||||
r"^InfoInhibitor",
|
||||
r"^Watchdog",
|
||||
r"^[E,e]tcdHighCommitDurations",
|
||||
r"^[E,e]tcdHighNumberOfFailedGRPCRequests",
|
||||
r"^[K,k]ubePersistentVolumeFillingUp",
|
||||
r"^[K,k]ubePersistentVolumeInodesFillingUp",
|
||||
]
|
||||
|
||||
|
||||
def check_stop_words(name: str) -> bool:
|
||||
"""
|
||||
Проверить, содержит ли название алерта стоп-слова.
|
||||
|
||||
Args:
|
||||
name: Название алерта.
|
||||
|
||||
Returns:
|
||||
True если алерт должен быть заблокирован, False в противном случае.
|
||||
"""
|
||||
logger.debug(f"Проверка стоп-слов для алерта: '{name}'")
|
||||
|
||||
for pattern in STOP_WORDS:
|
||||
if re.search(pattern, name):
|
||||
logger.warning(f"Алерт '{name}' заблокирован стоп-словом: {pattern}")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def add_spaces_to_alert_name(name: str) -> str:
|
||||
"""
|
||||
Добавить пробелы в название алерта для лучшей читаемости.
|
||||
|
||||
Пример: "HighCPUUsage" -> "High CPU Usage"
|
||||
|
||||
Args:
|
||||
name: Название алерта без пробелов.
|
||||
|
||||
Returns:
|
||||
Название алерта с пробелами.
|
||||
"""
|
||||
if not name:
|
||||
return name
|
||||
|
||||
result = name[0]
|
||||
for letter in name[1:]:
|
||||
if letter.isupper():
|
||||
result += f' {letter}'
|
||||
else:
|
||||
result += letter
|
||||
|
||||
# Исправляем известные сокращения
|
||||
result = result.replace('C P U', 'CPU')
|
||||
result = result.replace('etcd', 'ETCD')
|
||||
result = result.replace('A P I', 'API')
|
||||
result = result.replace('K 8 S', 'K8S')
|
||||
result = result.replace('P V C', 'PVC')
|
||||
result = result.replace('G R P C', 'GRPC')
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def truncate_message(message: str, max_length: int = 4090) -> str:
|
||||
"""
|
||||
Обрезать сообщение до максимальной длины для Telegram.
|
||||
|
||||
Telegram имеет лимит в 4096 символов на сообщение.
|
||||
|
||||
Args:
|
||||
message: Исходное сообщение.
|
||||
max_length: Максимальная длина сообщения.
|
||||
|
||||
Returns:
|
||||
Обрезанное сообщение с индикацией обрезки.
|
||||
"""
|
||||
if len(message) <= max_length:
|
||||
return message
|
||||
|
||||
truncated = message[:max_length - 10]
|
||||
truncated += "\n\n... (сообщение обрезано)"
|
||||
logger.warning(f"Сообщение обрезано с {len(message)} до {max_length} символов")
|
||||
return truncated
|
||||
187
app/main.py
Normal file
187
app/main.py
Normal 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
16
app/models/__init__.py
Normal 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",
|
||||
]
|
||||
69
app/models/alertmanager.py
Normal file
69
app/models/alertmanager.py
Normal 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
80
app/models/grafana.py
Normal 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
106
app/models/group.py
Normal 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
73
app/models/jira.py
Normal 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
195
app/models/message.py
Normal 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
81
app/models/zabbix.py
Normal 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
0
app/modules/__init__.py
Normal file
311
app/modules/alertmanager.py
Normal file
311
app/modules/alertmanager.py
Normal 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
261
app/modules/grafana.py
Normal 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
252
app/modules/zabbix.py
Normal 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
|
||||
Reference in New Issue
Block a user