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

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

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