Corp/backend/notifications/telegram_bot.py
Игорь Надеин d7c025f120 feat: Add department-based document distribution with department picker in UI
- Added departments ManyToMany field to Document model
- Implemented department selection in document create/edit modals
- Added automatic notification creation for all department employees
- Fixed document filtering to include uploaded documents in 'My Documents'
- Fixed acknowledgements API to include department recipients
- Added bulk notification support with configurable threshold
- Removed acknowledge button, added visual highlighting for unread documents
- Implemented server-side acknowledgment flow with redirect to file
- Fixed mobile responsive layout for document actions
- Removed temporary test files and documentation
2025-11-21 12:46:43 +03:00

516 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Telegram bot handlers для обработки команд пользователей
"""
import logging
from typing import Optional
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup, KeyboardButton, ReplyKeyboardMarkup, ReplyKeyboardRemove
from telegram.ext import (
Application,
CommandHandler,
ContextTypes,
MessageHandler,
filters,
ConversationHandler,
)
from telegram.constants import ParseMode
from telegram.error import TelegramError
from django.conf import settings
from django.utils import timezone
from .telegram_models import TelegramUser
from employees.models import Employee
logger = logging.getLogger(__name__)
# Состояния для ConversationHandler
WAITING_FOR_CONTACT = 1
class TelegramBotHandlers:
"""
Обработчики команд Telegram бота
"""
@staticmethod
async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""
Команда /start - приветствие и инструкции
"""
user = update.effective_user
# Проверяем, привязан ли аккаунт
try:
tg_user = TelegramUser.objects.get(
telegram_id=user.id,
is_active=True
)
# Аккаунт уже привязан
employee = tg_user.user
await update.message.reply_text(
f"👋 Привет, {employee.first_name}!\n\n"
f"Ваш аккаунт уже привязан к EUSRR.\n\n"
f"Доступные команды:\n"
f"/settings - Настройки уведомлений\n"
f"/unlink - Отвязать аккаунт\n"
f"/help - Справка",
parse_mode=ParseMode.HTML
)
# Обновляем время взаимодействия
tg_user.update_last_interaction()
except TelegramUser.DoesNotExist:
# Аккаунт не привязан - показываем инструкцию
await update.message.reply_text(
"👋 Добро пожаловать в бот EUSRR!\n\n"
"Этот бот будет отправлять вам уведомления из системы EUSRR.\n\n"
"🔗 <b>Для привязки аккаунта:</b>\n"
"1. Войдите в личный кабинет EUSRR\n"
"2. Перейдите в настройки уведомлений\n"
"3. Нажмите 'Привязать Telegram'\n"
"4. Получите код привязки\n"
"5. Отправьте команду: /link ВАШ_КОД\n\n"
"❓ Помощь: /help",
parse_mode=ParseMode.HTML
)
@staticmethod
async def link_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""
Команда /link CODE - запрашивает код и контакт для привязки
"""
user = update.effective_user
# Проверяем что код указан
if not context.args or len(context.args) == 0:
await update.message.reply_text(
"❌ Укажите код привязки.\n\n"
"Использование: /link ВАШ_КОД\n\n"
"Код можно получить в настройках уведомлений EUSRR.",
parse_mode=ParseMode.HTML
)
return
link_code = context.args[0].upper()
# Проверяем что аккаунт еще не привязан
if TelegramUser.objects.filter(
telegram_id=user.id,
is_active=True
).exists():
await update.message.reply_text(
"⚠️ Ваш Telegram уже привязан к аккаунту EUSRR.\n\n"
"Если хотите привязать другой аккаунт, "
"сначала используйте /unlink",
parse_mode=ParseMode.HTML
)
return
# Ищем пользователя с таким кодом
try:
tg_user = TelegramUser.objects.select_related('user').get(
link_code=link_code,
telegram_id__isnull=True
)
# Проверяем срок действия кода
if not tg_user.is_link_code_valid():
await update.message.reply_text(
"❌ Код привязки истёк.\n\n"
"Пожалуйста, получите новый код в настройках EUSRR.",
parse_mode=ParseMode.HTML
)
return
# Сохраняем код в контексте для проверки контакта
context.user_data['pending_link_code'] = link_code
context.user_data['pending_tg_user_id'] = tg_user.id
# Создаем кнопку "Поделиться контактом"
from telegram import KeyboardButton, ReplyKeyboardMarkup
keyboard = ReplyKeyboardMarkup(
[[KeyboardButton("📱 Поделиться контактом",
request_contact=True)]],
one_time_keyboard=True,
resize_keyboard=True
)
await update.message.reply_text(
"🔐 <b>Подтверждение личности</b>\n\n"
"Для безопасности нажмите кнопку ниже, "
"чтобы поделиться вашим контактом.\n\n"
"Мы проверим что он совпадает с данными, "
"указанными при регистрации в EUSRR.",
parse_mode=ParseMode.HTML,
reply_markup=keyboard
)
except TelegramUser.DoesNotExist:
await update.message.reply_text(
"❌ Неверный код привязки.\n\n"
"Проверьте код и попробуйте снова.\n"
"Если код истёк, получите новый в настройках EUSRR.",
parse_mode=ParseMode.HTML
)
@staticmethod
async def contact_received(
update: Update,
context: ContextTypes.DEFAULT_TYPE
):
"""
Обработка полученного контакта для верификации
"""
from telegram import ReplyKeyboardRemove
user = update.effective_user
contact = update.message.contact
# Проверяем что контакт от самого пользователя
if contact.user_id != user.id:
await update.message.reply_text(
"❌ Пожалуйста, поделитесь <b>своим</b> контактом, "
"а не контактом другого пользователя.",
parse_mode=ParseMode.HTML,
reply_markup=ReplyKeyboardRemove()
)
return
# Проверяем что есть pending привязка
pending_code = context.user_data.get('pending_link_code')
pending_tg_user_id = context.user_data.get('pending_tg_user_id')
if not pending_code or not pending_tg_user_id:
await update.message.reply_text(
"❌ Нет активной попытки привязки.\n\n"
"Сначала используйте команду /link КОД",
parse_mode=ParseMode.HTML,
reply_markup=ReplyKeyboardRemove()
)
return
try:
tg_user = TelegramUser.objects.select_related('user').get(
id=pending_tg_user_id,
link_code=pending_code
)
employee = tg_user.user
# Проверяем совпадение данных
telegram_field = employee.telegram.strip().lower()
# Получаем username и phone из контакта
contact_username = f"@{contact.username}".lower() \
if contact.username else None
contact_phone = contact.phone_number
# Проверка совпадения
match_found = False
match_type = ""
if telegram_field:
# Проверка по username
if contact_username and telegram_field == contact_username:
match_found = True
match_type = f"username ({contact_username})"
# Проверка по номеру телефона
elif contact_phone:
# Нормализуем номера (убираем + и пробелы)
normalized_contact = contact_phone.replace('+', '') \
.replace(' ', '').replace('-', '')
normalized_field = telegram_field.replace('+', '') \
.replace(' ', '').replace('-', '')
if normalized_contact == normalized_field:
match_found = True
match_type = f"номер телефона ({contact_phone})"
if not match_found:
await update.message.reply_text(
"❌ <b>Данные не совпадают</b>\n\n"
f"Ваш контакт:\n"
f"Username: {contact_username or 'не указан'}\n"
f"Телефон: {contact_phone or 'не указан'}\n\n"
f"В профиле EUSRR указано: {telegram_field or 'не указано'}\n\n"
"Обновите поле 'Telegram' в вашем профиле EUSRR "
"и попробуйте снова.",
parse_mode=ParseMode.HTML,
reply_markup=ReplyKeyboardRemove()
)
# Очищаем pending данные
context.user_data.clear()
return
# ✅ Данные совпали - привязываем аккаунт
tg_user.telegram_id = user.id
tg_user.telegram_username = user.username or ''
tg_user.first_name = user.first_name or ''
tg_user.last_name = user.last_name or ''
tg_user.is_active = True
tg_user.is_blocked = False
tg_user.clear_link_code()
tg_user.update_last_interaction()
tg_user.save()
await update.message.reply_text(
f"✅ <b>Аккаунт успешно привязан!</b>\n\n"
f"Подтверждено через: {match_type}\n\n"
f"👤 Привязан к: {employee.get_full_name()}\n"
f"📧 Email: {employee.email}\n\n"
f"Теперь вы будете получать уведомления EUSRR в Telegram.\n\n"
f"Доступные команды:\n"
f"/settings - Настройки уведомлений\n"
f"/unlink - Отвязать аккаунт\n"
f"/help - Справка",
parse_mode=ParseMode.HTML,
reply_markup=ReplyKeyboardRemove()
)
logger.info(
f"Telegram привязан через {match_type}: "
f"{user.id} -> {employee.email}"
)
# Очищаем pending данные
context.user_data.clear()
except TelegramUser.DoesNotExist:
await update.message.reply_text(
"❌ Код привязки истёк или недействителен.\n\n"
"Получите новый код в настройках EUSRR.",
parse_mode=ParseMode.HTML,
reply_markup=ReplyKeyboardRemove()
)
context.user_data.clear()
@staticmethod
async def unlink_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""
Команда /unlink - отвязка аккаунта
"""
user = update.effective_user
try:
tg_user = TelegramUser.objects.get(
telegram_id=user.id,
is_active=True
)
employee_name = tg_user.user.get_full_name()
# Отвязываем аккаунт
tg_user.is_active = False
tg_user.save()
await update.message.reply_text(
f"✅ <b>Аккаунт отвязан</b>\n\n"
f"Связь с {employee_name} удалена.\n"
f"Вы больше не будете получать уведомления.\n\n"
f"Для повторной привязки используйте /link",
parse_mode=ParseMode.HTML
)
logger.info(f"Telegram аккаунт отвязан: {user.id}")
except TelegramUser.DoesNotExist:
await update.message.reply_text(
"⚠️ Ваш аккаунт не привязан к EUSRR.\n\n"
"Используйте /start для инструкций по привязке.",
parse_mode=ParseMode.HTML
)
@staticmethod
async def settings_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""
Команда /settings - показать текущие настройки
"""
user = update.effective_user
try:
tg_user = TelegramUser.objects.get(
telegram_id=user.id,
is_active=True
)
employee = tg_user.user
# Получаем настройки уведомлений
from .models import UserNotificationSettings, NotificationCategory
settings_text = f"⚙️ <b>Настройки уведомлений</b>\n\n"
settings_text += f"👤 Пользователь: {employee.get_full_name()}\n"
settings_text += f"📧 Email: {employee.email}\n"
settings_text += f"🔗 Telegram: @{tg_user.telegram_username or 'не указан'}\n\n"
settings_text += "<b>Категории уведомлений:</b>\n\n"
categories = NotificationCategory.objects.all()
for category in categories:
try:
user_settings = UserNotificationSettings.objects.get(
user=employee,
category=category
)
# Эмодзи для категорий
emoji_map = {
'communications': '💬',
'documents': '📄',
'requests': '📋',
'calendar': '📅',
'department': '👥',
'profile': '👤',
'feed': '📰',
'system': '⚙️',
}
emoji = emoji_map.get(category.code, '🔔')
status = "✅" if user_settings.send_telegram else "❌"
settings_text += f"{emoji} {category.name}: {status}\n"
except UserNotificationSettings.DoesNotExist:
pass
# Создаем кнопку для перехода в настройки
site_url = getattr(settings, 'SITE_URL', 'http://localhost:9000')
keyboard = InlineKeyboardMarkup([
[InlineKeyboardButton(
"🔧 Изменить настройки",
url=f"{site_url}/notifications/settings/"
)]
])
await update.message.reply_text(
settings_text,
parse_mode=ParseMode.HTML,
reply_markup=keyboard
)
tg_user.update_last_interaction()
except TelegramUser.DoesNotExist:
await update.message.reply_text(
"⚠️ Ваш аккаунт не привязан к EUSRR.\n\n"
"Используйте /start для инструкций по привязке.",
parse_mode=ParseMode.HTML
)
@staticmethod
async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""
Команда /help - справка
"""
await update.message.reply_text(
"📖 <b>Справка по командам бота EUSRR</b>\n\n"
"<b>Основные команды:</b>\n\n"
"/start - Приветствие и инструкции\n"
"/link КОД - Привязать аккаунт EUSRR\n"
"/unlink - Отвязать аккаунт\n"
"/settings - Показать текущие настройки\n"
"/help - Эта справка\n\n"
"<b>Как привязать аккаунт:</b>\n"
"1. Войдите в личный кабинет EUSRR\n"
"2. Откройте настройки уведомлений\n"
"3. Нажмите 'Привязать Telegram'\n"
"4. Скопируйте код привязки\n"
"5. Отправьте: /link ВАШ_КОД\n\n"
"<b>О боте:</b>\n"
"Этот бот отправляет уведомления из системы EUSRR "
"о новых сообщениях, документах, заявках и других событиях.\n\n"
"Настроить какие уведомления получать можно в личном кабинете EUSRR.",
parse_mode=ParseMode.HTML
)
@staticmethod
async def unknown_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""
Обработчик неизвестных команд и текстовых сообщений
"""
await update.message.reply_text(
"❓ Неизвестная команда.\n\n"
"Используйте /help для списка доступных команд.",
parse_mode=ParseMode.HTML
)
def create_telegram_bot_application() -> Optional[Application]:
"""
Создает и настраивает приложение Telegram бота
Returns:
Настроенное приложение или None если токен не настроен
"""
token = getattr(settings, 'TELEGRAM_BOT_TOKEN', None)
if not token:
logger.error("TELEGRAM_BOT_TOKEN не настроен в settings")
return None
# Создаем приложение
application = Application.builder().token(token).build()
# Регистрируем handlers
handlers = TelegramBotHandlers
application.add_handler(CommandHandler("start", handlers.start_command))
application.add_handler(CommandHandler("link", handlers.link_command))
application.add_handler(CommandHandler("unlink", handlers.unlink_command))
application.add_handler(
CommandHandler("settings", handlers.settings_command)
)
application.add_handler(CommandHandler("help", handlers.help_command))
# Обработчик контактов (для верификации при привязке)
application.add_handler(
MessageHandler(filters.CONTACT, handlers.contact_received)
)
# Обработчик неизвестных команд и текстовых сообщений
application.add_handler(
MessageHandler(
filters.COMMAND | filters.TEXT,
handlers.unknown_command
)
)
logger.info("Telegram bot application создан")
return application
async def run_telegram_bot():
"""
Запускает Telegram бота в режиме polling
"""
application = create_telegram_bot_application()
if not application:
logger.error("Не удалось создать Telegram bot application")
return
logger.info("Запуск Telegram бота...")
try:
# Инициализация
await application.initialize()
await application.start()
# Запуск polling
await application.updater.start_polling(
allowed_updates=Update.ALL_TYPES
)
logger.info("Telegram бот запущен и слушает обновления")
# Ожидание остановки
await application.updater.stop()
await application.stop()
await application.shutdown()
except Exception as e:
logger.error(f"Ошибка при запуске Telegram бота: {e}", exc_info=True)