"""
Telegram sender для отправки уведомлений через Telegram бот
"""
import logging
from typing import Optional
import asyncio
from django.conf import settings
from telegram import Bot, InlineKeyboardButton, InlineKeyboardMarkup
from telegram.error import TelegramError
from telegram.constants import ParseMode
logger = logging.getLogger(__name__)
class TelegramNotificationSender:
"""
Класс для отправки уведомлений через Telegram бот.
"""
# Маппинг категорий на emoji
CATEGORY_EMOJI = {
'communications': '💬',
'documents': '📄',
'requests': '📋',
'calendar': '📅',
'department': '👥',
'profile': '👤',
'feed': '📰',
'system': '⚙️',
}
@classmethod
def get_bot(cls) -> Optional[Bot]:
"""Получить экземпляр бота"""
token = getattr(settings, 'TELEGRAM_BOT_TOKEN', None)
if not token:
logger.error("TELEGRAM_BOT_TOKEN не настроен в settings")
return None
return Bot(token=token)
@classmethod
async def send_notification_async(
cls,
telegram_id: int,
notification,
site_url: str = None
) -> bool:
"""
Асинхронная отправка уведомления в Telegram
Args:
telegram_id: ID пользователя в Telegram
notification: объект Notification
site_url: базовый URL сайта
Returns:
True если отправлено успешно, False иначе
"""
logger.info(
f"[TelegramNotificationSender.send_notification_async] НАЧАЛО: "
f"telegram_id={telegram_id}, "
f"notification_id={notification.id}"
)
bot = cls.get_bot()
if not bot:
logger.error(
f"[TelegramNotificationSender] ❌ Bot не инициализирован (токен отсутствует)"
)
return False
try:
# Получаем категорию
category_code = notification.notification_type.category.code
category_name = notification.notification_type.category.name
emoji = cls.CATEGORY_EMOJI.get(category_code, '🔔')
logger.info(
f"[TelegramNotificationSender] Категория: {category_name} ({category_code}), emoji: {emoji}"
)
# Формируем текст сообщения
text = f"{emoji} {notification.title}\n\n"
text += f"{notification.message}\n\n"
text += f"Категория: {category_name}"
logger.info(
f"[TelegramNotificationSender] Текст сообщения сформирован (длина: {len(text)} символов)"
)
# Создаем кнопку действия если есть URL
keyboard = None
if notification.action_url:
action_text = notification.action_text or 'Посмотреть'
logger.info(
f"[TelegramNotificationSender] Формирование кнопки: action_url={notification.action_url}"
)
# Формируем полный URL
if site_url:
if notification.action_url.startswith('http'):
url = notification.action_url
else:
# Убираем trailing slash из site_url если есть
base_url = site_url.rstrip('/')
url = f"{base_url}{notification.action_url}"
else:
url = notification.action_url
# Telegram не принимает:
# 1. URL вида "http://site.com/" (только корень)
# 2. localhost URL в inline кнопках
url_without_trailing = url.rstrip('/')
base_without_trailing = (
site_url.rstrip('/') if site_url else ''
)
is_valid_url = (
url_without_trailing and
url_without_trailing != base_without_trailing and
'localhost' not in url.lower() and
'127.0.0.1' not in url
)
if is_valid_url:
keyboard = InlineKeyboardMarkup([
[InlineKeyboardButton(action_text, url=url)]
])
logger.info(
f"[TelegramNotificationSender] ✅ Inline кнопка создана: {url}"
)
elif 'localhost' in url.lower() or '127.0.0.1' in url:
# Добавляем URL в текст сообщения
text += f"\n\n🔗 Ссылка: {url}"
logger.info(
f"[TelegramNotificationSender] ⚠️ localhost URL добавлен в текст (inline кнопка невозможна)"
)
# Отправляем сообщение
logger.info(
f"[TelegramNotificationSender] ➡️ Отправка в Telegram API (chat_id={telegram_id})..."
)
await bot.send_message(
chat_id=telegram_id,
text=text,
parse_mode=ParseMode.HTML,
reply_markup=keyboard,
disable_web_page_preview=True,
)
logger.info(
f"[TelegramNotificationSender] ✅ Telegram API УСПЕШНО: "
f"notification_id={notification.id} -> chat_id={telegram_id}"
)
return True
except TelegramError as e:
error_msg = str(e)
logger.error(
f"[TelegramNotificationSender] ❌ TelegramError: "
f"notification_id={notification.id}, "
f"chat_id={telegram_id}, "
f"error={error_msg}"
)
# Обработка специфичных ошибок
if "chat not found" in error_msg.lower():
logger.warning(
f"[TelegramNotificationSender] ⚠️ Чат не найден: "
f"пользователь с Chat ID {telegram_id} не отправил /start боту"
)
elif "bot was blocked" in error_msg.lower() or "forbidden" in error_msg.lower():
logger.warning(
f"[TelegramNotificationSender] ⚠️ Бот заблокирован: "
f"пользователь с Chat ID {telegram_id} заблокировал бота"
)
else:
logger.error(
f"[TelegramNotificationSender] ❌ Необработанная ошибка Telegram: {e}",
exc_info=True
)
return False
except Exception as e:
logger.error(
f"Неожиданная ошибка отправки Telegram: {e}",
exc_info=True
)
return False
@classmethod
def send_notification(
cls,
notification,
chat_id: Optional[str] = None,
site_url: str = None
) -> bool:
"""
Синхронная обёртка для отправки уведомления
Args:
notification: объект Notification
chat_id: Telegram Chat ID получателя (строка или число)
site_url: базовый URL сайта
Returns:
True если отправлено успешно, False иначе
"""
logger.info(
f"[TelegramNotificationSender.send_notification] НАЧАЛО: "
f"notification_id={notification.id}, "
f"chat_id={chat_id}, "
f"site_url={site_url}"
)
# Преобразуем chat_id в int
if not chat_id:
logger.warning(
f"[TelegramNotificationSender] ❌ Chat ID не указан для {notification.recipient}"
)
return False
try:
telegram_id = int(chat_id)
logger.info(
f"[TelegramNotificationSender] Chat ID преобразован в int: {telegram_id}"
)
except (ValueError, TypeError):
logger.error(
f"[TelegramNotificationSender] ❌ Неверный формат Chat ID: {chat_id}"
)
return False
# Получить site_url если не передан
if not site_url:
site_url = getattr(settings, 'SITE_URL', 'http://localhost:9000')
logger.info(
f"[TelegramNotificationSender] Site URL из настроек: {site_url}"
)
# Запускаем асинхронную отправку
logger.info(
f"[TelegramNotificationSender] ➡️ Запуск асинхронной отправки..."
)
try:
loop = asyncio.get_event_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
result = loop.run_until_complete(
cls.send_notification_async(
telegram_id,
notification,
site_url
)
)
if result:
logger.info(
f"[TelegramNotificationSender] ✅ Успешно отправлено в Telegram (chat_id={telegram_id})"
)
else:
logger.warning(
f"[TelegramNotificationSender] ⚠️ Отправка в Telegram вернула False"
)
return result
@classmethod
def send_notification_by_username(
cls,
notification,
telegram_username: str,
site_url: str = None
) -> bool:
"""
Отправка уведомления по @username без привязки аккаунта
Args:
notification: объект Notification
telegram_username: @username или chat_id
site_url: базовый URL сайта
Returns:
True если отправлено успешно
"""
if not site_url:
site_url = getattr(settings, 'SITE_URL', 'http://localhost:9000')
# Убираем @ если есть
username = telegram_username.strip().lstrip('@')
# Пробуем отправить по username
try:
loop = asyncio.get_event_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
async def send_by_username():
bot = cls.get_bot()
if not bot:
return False
try:
# Формируем сообщение
category_code = notification.notification_type.category.code
emoji = cls.CATEGORY_EMOJI.get(category_code, '🔔')
text = f"{emoji} {notification.title}\n\n"
text += f"{notification.message}\n\n"
# Кнопка действия
keyboard = None
if notification.action_url:
action_text = notification.action_text or 'Посмотреть'
if notification.action_url.startswith('http'):
url = notification.action_url
else:
url = f"{site_url}{notification.action_url}"
keyboard = InlineKeyboardMarkup([
[InlineKeyboardButton(action_text, url=url)]
])
# Отправляем по @username
await bot.send_message(
chat_id=f"@{username}",
text=text,
parse_mode=ParseMode.HTML,
reply_markup=keyboard,
disable_web_page_preview=True,
)
logger.info(
f"Telegram отправлено @{username}: {notification.id}"
)
return True
except TelegramError as e:
error_msg = str(e)
if "chat not found" in error_msg.lower():
logger.warning(
f"Пользователь @{username} не начал диалог с ботом. "
f"Необходимо отправить /start боту."
)
elif "bot was blocked" in error_msg.lower():
logger.warning(
f"Пользователь @{username} заблокировал бота."
)
else:
logger.error(
f"Ошибка отправки @{username}: {e}",
exc_info=True
)
return False
return loop.run_until_complete(send_by_username())
@classmethod
async def send_message_async(
cls,
telegram_id: int,
text: str,
keyboard: Optional[InlineKeyboardMarkup] = None
) -> bool:
"""
Отправка произвольного сообщения
Args:
telegram_id: ID получателя
text: текст сообщения
keyboard: inline клавиатура (опционально)
Returns:
True если отправлено успешно
"""
bot = cls.get_bot()
if not bot:
return False
try:
await bot.send_message(
chat_id=telegram_id,
text=text,
parse_mode=ParseMode.HTML,
reply_markup=keyboard,
disable_web_page_preview=True,
)
return True
except TelegramError as e:
logger.error(f"Ошибка отправки сообщения в Telegram: {e}")
return False
@classmethod
def send_message(
cls,
telegram_id: int,
text: str,
keyboard: Optional[InlineKeyboardMarkup] = None
) -> bool:
"""Синхронная обёртка для send_message_async"""
try:
loop = asyncio.get_event_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
return loop.run_until_complete(
cls.send_message_async(telegram_id, text, keyboard)
)
@classmethod
def test_bot_connection(cls) -> bool:
"""
Тестирует подключение к Telegram боту
Returns:
True если бот доступен и настроен правильно
"""
bot = cls.get_bot()
if not bot:
return False
try:
loop = asyncio.get_event_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
async def test():
try:
me = await bot.get_me()
logger.info(
f"Бот подключен: @{me.username} ({me.first_name})"
)
return True
except TelegramError as e:
logger.error(f"Ошибка подключения к боту: {e}")
return False
return loop.run_until_complete(test())