import contextlib import json import logging import os import re import time from datetime import datetime, timedelta import redis from playwright.async_api import async_playwright from telegram import ChatMember, InlineKeyboardButton, InlineKeyboardMarkup, Update from telegram.constants import ChatType from telegram.ext import Application, CallbackQueryHandler, CommandHandler, ContextTypes # Logger logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) # Suppress HTTP request logs logging.getLogger('urllib3').setLevel(logging.WARNING) logging.getLogger('httpx').setLevel(logging.WARNING) logging.getLogger('telegram.ext._application').setLevel(logging.WARNING) # Load environment variables def _env(key, default=None): v = os.getenv(key, default) if isinstance(v, str) and len(v) >= 2 and ((v[0] == '"' and v[-1] == '"') or (v[0] == "'" and v[-1] == "'")): return v[1:-1] return v EDU_BASE = _env('EDU_URL_BASE', 'https://edu.edu.vn.ua') EDU_WEBINAR_PATH = _env('EDU_URL_WEBINAR', '/webinar/useractive') WEBINAR_URL = f'{EDU_BASE.rstrip("/")}/{EDU_WEBINAR_PATH.lstrip("/")}' DIARY_URL = f'{EDU_BASE.rstrip("/")}/user/diary' SCHEDULE_URL = f'{EDU_BASE.rstrip("/")}/lessons/table' WEBINAR_CHECK_INTERVAL = int(_env('WEBINAR_CHECK_INTERVAL', 60)) REDIS_HOST = _env('REDIS_HOST', 'redis') REDIS_PORT = int(_env('REDIS_PORT', 6379)) PLAYWRIGHT_WS = _env('PLAYWRIGHT_WS', 'ws://playwright-service:3000/ws') USER_AGENT = _env( 'USER_AGENT', 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36', ) WEBINAR_TELEGRAM_TOKEN = _env('WEBINAR_TELEGRAM_TOKEN') ADMIN_ID = int(_env('WEBINAR_ADMIN_ID', '0')) # Redis Keys KEY_WHITELIST = 'bot:whitelist' KEY_WHITELIST_ENABLED = 'bot:whitelist_enabled' KEY_SUBSCRIBERS = 'bot:subscribers' KEY_PHPSESSID = 'EDU_PHPSESSID' KEY_WEBINAR_HISTORY = 'bot:webinar_history' # Stores last 3 webinars # Initialize Redis try: redis_client = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, decode_responses=True) redis_client.ping() logger.info(f'Connected to Redis at {REDIS_HOST}:{REDIS_PORT}') except Exception as e: logger.error(f'Failed to connect to Redis: {e}') exit(1) # --- Translations --- TRANSLATIONS = { 'ru': { 'welcome': '👋 Привет, {name}!\n\nЯ бот-уведомитель о вебинарах. Я буду сообщать вам, когда появится новый вебинар.\nВы подписаны на уведомления.', 'welcome_admin': '\n\n👑 Режим администратора активен', 'access_denied': '⛔ Доступ запрещен. Вас нет в белом списке.', 'help_title': '🤖 Помощь по боту\n\n', 'help_commands': '/start - Подписаться на уведомления\n/stop - Отписаться от уведомлений\n/help - Показать это сообщение\n/language - Сменить язык', 'help_admin': '\nКоманды администратора:\n/adduser [user_id] - Добавить пользователя в белый список\n/removeuser [user_id] - Удалить пользователя из белого списка\nИли используйте панель ниже для управления настройками.', 'admin_only': '⛔ Только для администратора!', 'user_added': '✅ Пользователь {user_id} добавлен в белый список', 'user_removed': '✅ Пользователь {user_id} удален из белого списка', 'user_not_in_whitelist': '⚠️ Пользователь {user_id} не был в белом списке', 'cannot_remove_admin': '❌ Невозможно удалить администратора из белого списка', 'invalid_user_id': '❌ Неверный ID пользователя. Должно быть число.', 'usage_adduser': 'Использование: /adduser [user_id]', 'usage_removeuser': 'Использование: /removeuser [user_id]', 'whitelist_enabled': '✅ Белый список включен', 'whitelist_disabled': '✅ Белый список отключен', 'whitelist_title': '📋 Белый список:\n', 'subscribers_title': '👥 Подписчики:\n', 'empty': 'Пусто', 'force_check_running': '🔄 Запускаю проверку...', 'check_failed': '❌ Проверка не удалась. Смотрите логи.', 'check_completed_none': '✅ Проверка завершена. Вебинаров не найдено.', 'check_completed': '✅ Проверка завершена. Найдено {count} вебинар(ов)!', 'toggle_whitelist_disable': '🔒 Отключить белый список', 'toggle_whitelist_enable': '🔓 Включить белый список', 'view_whitelist': '📋 Посмотреть белый список', 'view_subscribers': '👥 Посмотреть подписчиков', 'force_check': '🔄 Принудительная проверка', 'webinar_found': '🎓 Новый вебинар!\n\n', 'webinar_item': '📌 {name}\n🔗 https://edu.edu.vn.ua{url}', 'select_language': '🌐 Выберите язык / Оберіть мову / Select language:', 'language_changed': '✅ Язык изменен на русский', 'flag_ru': '🇷🇺 Русский', 'flag_uk': '🇺🇦 Українська', 'flag_en': '🇬🇧 English', 'history_cleared': '✅ История вебинаров очищена', 'history_clear_failed': '❌ Ошибка при очистке истории', }, 'uk': { 'welcome': "👋 Привіт, {name}!\n\nЯ бот-сповіщувач про вебінари. Я повідомлятиму вас, коли з'явиться новий вебінар.\nВи підписані на сповіщення.", 'welcome_admin': '\n\n👑 Режим адміністратора активний', 'access_denied': '⛔ Доступ заборонено. Вас немає в білому списку.', 'help_title': '🤖 Довідка по боту\n\n', 'help_commands': '/start - Підписатися на сповіщення\n/stop - Відписатися від сповіщень\n/help - Показати це повідомлення\n/language - Змінити мову', 'help_admin': '\nКоманди адміністратора:\n/adduser [user_id] - Додати користувача до білого списку\n/removeuser [user_id] - Видалити користувача з білого списку\nАбо використовуйте панель нижче для керування налаштуваннями.', 'admin_only': '⛔ Тільки для адміністратора!', 'user_added': '✅ Користувач {user_id} доданий до білого списку', 'user_removed': '✅ Користувач {user_id} видалений з білого списку', 'user_not_in_whitelist': '⚠️ Користувач {user_id} не був у білому списку', 'cannot_remove_admin': '❌ Неможливо видалити адміністратора з білого списку', 'invalid_user_id': '❌ Невірний ID користувача. Має бути число.', 'usage_adduser': 'Використання: /adduser [user_id]', 'usage_removeuser': 'Використання: /removeuser [user_id]', 'whitelist_enabled': '✅ Білий список увімкнено', 'whitelist_disabled': '✅ Білий список вимкнено', 'whitelist_title': '📋 Білий список:\n', 'subscribers_title': '👥 Підписники:\n', 'empty': 'Порожньо', 'force_check_running': '🔄 Запускаю перевірку...', 'check_failed': '❌ Перевірка не вдалася. Дивіться логи.', 'check_completed_none': '✅ Перевірка завершена. Вебінарів не знайдено.', 'check_completed': '✅ Перевірка завершена. Знайдено {count} вебінар(ів)!', 'toggle_whitelist_disable': '🔒 Вимкнути білий список', 'toggle_whitelist_enable': '🔓 Увімкнути білий список', 'view_whitelist': '📋 Переглянути білий список', 'view_subscribers': '👥 Переглянути підписників', 'force_check': '🔄 Примусова перевірка', 'webinar_found': '🎓 Новий вебінар!\n\n', 'webinar_item': '📌 {name}\n🔗 https://edu.edu.vn.ua{url}', 'select_language': '🌐 Виберіть мову / Выберите язык / Select language:', 'language_changed': '✅ Мову змінено на українську', 'flag_ru': '🇷🇺 Русский', 'flag_uk': '🇺🇦 Українська', 'flag_en': '🇬🇧 English', 'history_cleared': '✅ Історія вебінарів очищена', 'history_clear_failed': '❌ Помилка при очищенні історії', }, 'en': { 'welcome': '👋 Hello, {name}!\n\nI am the Webinar Checker Bot. I will notify you when a new webinar appears.\nYou have been subscribed to notifications.', 'welcome_admin': '\n\n👑 Admin Mode Active', 'access_denied': '⛔ Access denied. You are not on the whitelist.', 'help_title': '🤖 Bot Help\n\n', 'help_commands': '/start - Subscribe to notifications\n/stop - Unsubscribe from notifications\n/help - Show this message\n/language - Change language', 'help_admin': '\nAdmin Commands:\n/adduser [user_id] - Add user to whitelist\n/removeuser [user_id] - Remove user from whitelist\nOr use the panel below to manage settings.', 'admin_only': '⛔ Admin only!', 'user_added': '✅ User {user_id} added to whitelist', 'user_removed': '✅ User {user_id} removed from whitelist', 'user_not_in_whitelist': '⚠️ User {user_id} was not in whitelist', 'cannot_remove_admin': '❌ Cannot remove admin from whitelist', 'invalid_user_id': '❌ Invalid user ID. Must be a number.', 'usage_adduser': 'Usage: /adduser [user_id]', 'usage_removeuser': 'Usage: /removeuser [user_id]', 'whitelist_enabled': '✅ Whitelist Enabled', 'whitelist_disabled': '✅ Whitelist Disabled', 'whitelist_title': '📋 Whitelist:\n', 'subscribers_title': '👥 Subscribers:\n', 'empty': 'Empty', 'force_check_running': '🔄 Running immediate check...', 'check_failed': '❌ Check failed. See logs for details.', 'check_completed_none': '✅ Check completed. No webinars found.', 'check_completed': '✅ Check completed. Found {count} webinar(s)!', 'toggle_whitelist_disable': '🔒 Disable Whitelist', 'toggle_whitelist_enable': '🔓 Enable Whitelist', 'view_whitelist': '📋 View Whitelist', 'view_subscribers': '👥 View Subscribers', 'force_check': '🔄 Force Check', 'webinar_found': '🎓 New webinar found!\n\n', 'webinar_item': '📌 {name}\n🔗 https://edu.edu.vn.ua{url}', 'select_language': '🌐 Select language / Виберіть мову / Выберите язык:', 'language_changed': '✅ Language changed to English', 'flag_ru': '🇷🇺 Русский', 'flag_uk': '🇺🇦 Українська', 'flag_en': '🇬🇧 English', 'history_cleared': '✅ Webinar history cleared', 'history_clear_failed': '❌ Error clearing history', }, } # --- Language Helper Functions --- def get_user_language(user_id: int) -> str: """Get user's preferred language from Redis. Default: Ukrainian.""" lang = redis_client.get(f'user:{user_id}:language') return lang if lang in ['ru', 'uk', 'en'] else 'uk' def set_user_language(user_id: int, lang: str): """Save user's language preference to Redis.""" if lang in ['ru', 'uk', 'en']: redis_client.set(f'user:{user_id}:language', lang) logger.info(f'User {user_id} language set to {lang}') def t(user_id: int, key: str, **kwargs) -> str: """Translate message for user with optional formatting.""" lang = get_user_language(user_id) message = TRANSLATIONS.get(lang, TRANSLATIONS['uk']).get(key, key) if kwargs: return message.format(**kwargs) return message def get_language_keyboard(): """Generate language selection keyboard.""" keyboard = [ [ InlineKeyboardButton('🇷🇺 Русский', callback_data='lang_ru'), InlineKeyboardButton('🇺🇦 Українська', callback_data='lang_uk'), ], [ InlineKeyboardButton('🇬🇧 English', callback_data='lang_en'), ], ] return InlineKeyboardMarkup(keyboard) # --- Helper Functions --- def is_whitelisted(user_id: int) -> bool: """Check if user is allowed to use the bot.""" if user_id == ADMIN_ID: return True enabled = redis_client.get(KEY_WHITELIST_ENABLED) if enabled == '0': # Whitelist disabled return True return redis_client.sismember(KEY_WHITELIST, str(user_id)) async def is_group_admin(update: Update, context: ContextTypes.DEFAULT_TYPE) -> bool: """Check if the user is an administrator in the group.""" user = update.effective_user chat = update.effective_chat if chat.type in [ChatType.PRIVATE, 'private']: return True try: member = await context.bot.get_chat_member(chat.id, user.id) return member.status in [ChatMember.OWNER, ChatMember.ADMINISTRATOR] except Exception as e: logger.error(f'Failed to check admin status: {e}') return False def get_admin_keyboard(user_id: int): """Generate admin panel keyboard.""" whitelist_enabled = redis_client.get(KEY_WHITELIST_ENABLED) != '0' toggle_text = t(user_id, 'toggle_whitelist_disable') if whitelist_enabled else t(user_id, 'toggle_whitelist_enable') keyboard = [ [InlineKeyboardButton(toggle_text, callback_data='toggle_whitelist')], [InlineKeyboardButton(t(user_id, 'view_whitelist'), callback_data='view_whitelist')], [InlineKeyboardButton(t(user_id, 'view_subscribers'), callback_data='view_subscribers')], [InlineKeyboardButton(t(user_id, 'force_check'), callback_data='force_check')], ] return InlineKeyboardMarkup(keyboard) # --- Diary Functions --- SCHEDULE_WEEKDAYS_FULL = ['Понеділок', 'Вівторок', 'Середа', 'Четвер', "П'ятниця", 'Субота', 'Неділя'] SCHEDULE_CACHE_TTL = 18000 # 5 hours DIARY_MONTH_NAMES = [ '', 'Січня', 'Лютого', 'Березня', 'Квітня', 'Травня', 'Червня', 'Липня', 'Серпня', 'Вересня', 'Жовтня', 'Листопада', 'Грудня', ] DIARY_WEEKDAYS_SHORT = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Нд'] def get_diary_keyboard(): today = datetime.now() keyboard = [ [ InlineKeyboardButton(f'📌 Сьогодні ({today.day}.{today.month:02d})', callback_data='diary_today'), InlineKeyboardButton('📌 Завтра', callback_data='diary_tomorrow'), ], [ InlineKeyboardButton('📅 Цей тиждень', callback_data='diary_week'), InlineKeyboardButton('📅 Весь місяць', callback_data='diary_month'), ], ] return InlineKeyboardMarkup(keyboard) def _parse_calendar_html(table_html: str) -> tuple: """Parse calendar HTML table into (month_text, {day_num: {weekday, events}}).""" days = {} weekdays = [] rows = re.findall(r'