Initial commit: Message Gateway project

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

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

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

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