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' 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 --- 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']*>(.*?)', table_html, re.DOTALL) month_text = '' for r_idx, row in enumerate(rows): cells = re.findall(r']*>(.*?)', row, re.DOTALL) if r_idx == 0: # Month navigation row: extract "Травень 2026" from nav text raw = re.sub(r'<[^>]+>', ' ', row).strip() raw = re.sub(r'\s+', ' ', raw) m = re.search(r'([А-Яа-яіїєґ\']+\s*:?\s*\d{4})', raw) month_text = m.group(1).replace(' : ', ' ').strip() if m else raw elif r_idx == 1: # Day names row for cell in cells: name = re.sub(r'<[^>]+>', '', cell).strip() if name: weekdays.append(name) else: # Data rows: each cell = a day for col_idx, cell in enumerate(cells): # Extract day number — first number in the cell text text = re.sub(r'<[^>]+>', ' ', cell).strip() text = re.sub(r'\s+', ' ', text) dm = re.match(r'(\d+)', text) if not dm: continue day_num = dm.group(1) # Extract events: title attribute (full name) of ALL tags inside the cell events = [] for a_match in re.finditer(r']*>(.*?)', cell, re.DOTALL): a_tag = a_match.group(0) # Prefer the title attribute (contains full name, not truncated) title_m = re.search(r'title\s*=\s*"([^"]*)"', a_tag) et = title_m.group(1).strip() if title_m else re.sub(r'<[^>]+>', '', a_match.group(1)).strip() if et: events.append(et) weekday = weekdays[col_idx] if col_idx < len(weekdays) else '' days[day_num] = {'weekday': weekday, 'events': events} return month_text, days async def fetch_diary_data(phpsessid: str) -> dict | None: logger.info('Fetching diary data via Playwright...') try: async with async_playwright() as p: browser = await p.chromium.connect(PLAYWRIGHT_WS) try: context_browser = await browser.new_context(user_agent=USER_AGENT) await context_browser.add_cookies( [{'name': 'PHPSESSID', 'value': phpsessid, 'domain': 'edu.edu.vn.ua', 'path': '/'}] ) page = await context_browser.new_page() try: await page.goto(DIARY_URL, wait_until='domcontentloaded') await page.wait_for_selector('table.calendar', timeout=10000) await page.wait_for_timeout(1500) table_html = await page.evaluate(""" () => { const t = document.querySelector('table.calendar'); return t ? t.outerHTML : null; } """) if not table_html: logger.error('table.calendar not found in DOM') return None # Debug: save HTML for troubleshooting with contextlib.suppress(Exception), open('/tmp/diary_debug.html', 'w', encoding='utf-8') as f: # noqa: S108 f.write(table_html) month_text, days = _parse_calendar_html(table_html) logger.info( f'Diary parsed: month={month_text!r}, days_with_events={sum(1 for d in days.values() if d["events"])}/{len(days)}' ) return {'monthFullText': month_text, 'days': days} except Exception as e: logger.error(f'Error parsing diary: {e}') return None finally: await page.close() await context_browser.close() finally: await browser.close() except Exception as e: logger.error(f'Playwright error in diary fetch: {e}') return None def _parse_diary_month(text: str) -> str: match = re.search(r'([А-Яа-яіїєґ\']+\s*:\s*\d{4})', text) if match: return match.group(1).replace(' : ', ' ').strip() return text.strip() def format_diary_day(data: dict, day_num: int) -> str: days = data.get('days', {}) month_str = _parse_diary_month(data.get('monthFullText', '')) day_data = days.get(str(day_num)) lines = [f'📅 {day_num} {month_str}', '─' * 18] if not day_data or not day_data.get('events'): lines.append('Немає подій') else: for e in day_data['events']: lines.append(f'📌 {e}') lines.append(f'\n🔗 {DIARY_URL}') return '\n'.join(lines) def format_diary_week(data: dict, today: datetime) -> str: days = data.get('days', {}) _parse_diary_month(data.get('monthFullText', '')) monday = today - timedelta(days=today.weekday()) sunday = monday + timedelta(days=6) lines = [f'📅 Тиждень {monday.day}.{monday.month} – {sunday.day}.{sunday.month}\n'] for i in range(7): d = monday + timedelta(days=i) day_data = days.get(str(d.day)) lines.append(f'─ {DIARY_WEEKDAYS_SHORT[i]} {d.day}.{d.month} ─') if not day_data or not day_data.get('events'): lines.append('Немає подій\n') else: for e in day_data['events']: lines.append(f'📌 {e}') lines.append('') lines.append(f'🔗 {DIARY_URL}') return '\n'.join(lines) def format_diary_month(data: dict) -> str: days = data.get('days', {}) month_str = _parse_diary_month(data.get('monthFullText', '')) lines = [f'📅 {month_str}\n'] for day_num in sorted(days.keys(), key=int): day_data = days[day_num] events = day_data.get('events', []) weekday = day_data.get('weekday', '') lines.append(f'─ {weekday} {day_num} ─') if not events: lines.append('Немає подій\n') else: for e in events: lines.append(f'📌 {e}') lines.append('') lines.append(f'🔗 {DIARY_URL}') return '\n'.join(lines) async def _get_diary_data(context: ContextTypes.DEFAULT_TYPE) -> dict | None: cached = context.user_data.get('diary_cache') now_ts = time.time() if cached and (now_ts - cached.get('timestamp', 0)) < 300: return cached['data'] phpsessid = redis_client.get(KEY_PHPSESSID) if not phpsessid: return None data = await fetch_diary_data(phpsessid) if data: context.user_data['diary_cache'] = {'data': data, 'timestamp': now_ts} return data # --- Command Handlers --- async def start(update: Update, _context: ContextTypes.DEFAULT_TYPE): """Handle /start command.""" user = update.effective_user chat = update.effective_chat logger.info(f'User {user.id} ({user.username}) started the bot in chat {chat.id} ({chat.type}).') # Check whitelist - MUST be the user executing the command if not is_whitelisted(user.id): await update.message.reply_text(t(user.id, 'access_denied')) return # Add to subscribers (Chat ID!) redis_client.sadd(KEY_SUBSCRIBERS, chat.id) msg = t(chat.id, 'welcome', name=user.first_name) if user.id == ADMIN_ID and chat.type == 'private': msg += t(chat.id, 'welcome_admin') await update.message.reply_text(msg, parse_mode='HTML', reply_markup=get_admin_keyboard(user.id)) else: await update.message.reply_text(msg, parse_mode='HTML') async def help_command(update: Update, _context: ContextTypes.DEFAULT_TYPE): """Handle /help command.""" user_id = update.effective_user.id chat_id = update.effective_chat.id msg = t(chat_id, 'help_title') + t(chat_id, 'help_commands') if user_id == ADMIN_ID and update.effective_chat.type == 'private': msg += t(chat_id, 'help_admin') await update.message.reply_text(msg, parse_mode='HTML', reply_markup=get_admin_keyboard(user_id)) else: await update.message.reply_text(msg, parse_mode='HTML') async def stop_command(update: Update, context: ContextTypes.DEFAULT_TYPE): """Handle /stop command (unsubscribe).""" user = update.effective_user chat = update.effective_chat # Permission check: Whitelisted user OR Group Admin if not (is_whitelisted(user.id) or await is_group_admin(update, context)): await update.message.reply_text(t(chat.id, 'access_denied')) # Or specific "admin only" message return redis_client.srem(KEY_SUBSCRIBERS, chat.id) await update.message.reply_text( t(chat.id, 'whitelist_disabled') .replace(' whitelist', ' notifications') .replace('Білий список', 'Сповіщення') .replace('Белый список', 'Уведомления') if chat.id else 'Unsubscribed' ) async def language_command(update: Update, context: ContextTypes.DEFAULT_TYPE): """Handle /language command.""" user = update.effective_user chat = update.effective_chat # Permission check for groups if not (is_whitelisted(user.id) or await is_group_admin(update, context)): return await update.message.reply_text( t(chat.id, 'select_language'), parse_mode='HTML', reply_markup=get_language_keyboard() ) async def add_user(update: Update, context: ContextTypes.DEFAULT_TYPE): """Add user to whitelist (admin only).""" admin_id = update.effective_user.id if admin_id != ADMIN_ID: await update.message.reply_text(t(admin_id, 'admin_only')) return if not context.args: await update.message.reply_text(t(admin_id, 'usage_adduser')) return try: user_id = int(context.args[0]) redis_client.sadd(KEY_WHITELIST, str(user_id)) await update.message.reply_text(t(admin_id, 'user_added', user_id=user_id)) logger.info(f'Admin added user {user_id} to whitelist') except ValueError: await update.message.reply_text(t(admin_id, 'invalid_user_id')) async def remove_user(update: Update, context: ContextTypes.DEFAULT_TYPE): """Remove user from whitelist (admin only).""" admin_id = update.effective_user.id if admin_id != ADMIN_ID: await update.message.reply_text(t(admin_id, 'admin_only')) return if not context.args: await update.message.reply_text(t(admin_id, 'usage_removeuser')) return try: user_id = int(context.args[0]) if str(user_id) == str(ADMIN_ID): await update.message.reply_text(t(admin_id, 'cannot_remove_admin')) return removed = redis_client.srem(KEY_WHITELIST, str(user_id)) if removed: await update.message.reply_text(t(admin_id, 'user_removed', user_id=user_id)) logger.info(f'Admin removed user {user_id} from whitelist') else: await update.message.reply_text(t(admin_id, 'user_not_in_whitelist', user_id=user_id)) except ValueError: await update.message.reply_text(t(admin_id, 'invalid_user_id')) async def clear_history(update: Update, _context: ContextTypes.DEFAULT_TYPE): """Clear webinar history (admin only).""" admin_id = update.effective_user.id if admin_id != ADMIN_ID: await update.message.reply_text(t(admin_id, 'admin_only')) return try: redis_client.delete(KEY_WEBINAR_HISTORY) await update.message.reply_text(t(admin_id, 'history_cleared')) logger.info('Admin cleared webinar history') except Exception as e: logger.error(f'Failed to clear history: {e}') await update.message.reply_text(t(admin_id, 'history_clear_failed')) async def diary_command(update: Update, _context: ContextTypes.DEFAULT_TYPE): user = update.effective_user if not is_whitelisted(user.id): await update.message.reply_text(t(user.id, 'access_denied')) return await update.message.reply_text( '📅 Щоденник — виберіть період:', parse_mode='HTML', reply_markup=get_diary_keyboard() ) async def diary_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): query = update.callback_query user_id = query.from_user.id await query.answer() if user_id != ADMIN_ID and not is_whitelisted(user_id): await query.edit_message_text('⛔ Доступ заборонено.') return data = query.data if data == 'diary_refresh': context.user_data.pop('diary_cache', None) await query.edit_message_text('🔄 Завантажую щоденник...') diary_data = await _get_diary_data(context) if not diary_data: await query.edit_message_text('❌ Не вдалося завантажити щоденник. Немає сесії або помилка.') return await query.edit_message_text( '📅 Щоденник — виберіть період:', parse_mode='HTML', reply_markup=get_diary_keyboard() ) return await query.edit_message_text('🔄 Завантажую щоденник...') diary_data = await _get_diary_data(context) if not diary_data: await query.edit_message_text('❌ Не вдалося завантажити щоденник.') return today = datetime.now() if data == 'diary_today': text = format_diary_day(diary_data, today.day) elif data == 'diary_tomorrow': tomorrow = today + timedelta(days=1) if tomorrow.day < today.day: text = '❌ Дані за наступний місяць недоступні. Перейдіть на сайт.' else: text = format_diary_day(diary_data, tomorrow.day) elif data == 'diary_week': text = format_diary_week(diary_data, today) elif data == 'diary_month': text = format_diary_month(diary_data) else: return if len(text) > 4096: text = text[:4090] + '\n\n✂️ ...(обрізано)' await query.edit_message_text(text, parse_mode='HTML', reply_markup=get_diary_keyboard()) # --- Admin Callbacks --- async def admin_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): """Handle admin panel button clicks.""" query = update.callback_query user_id = query.from_user.id if user_id != ADMIN_ID: await query.answer(t(user_id, 'admin_only'), show_alert=True) return await query.answer() data = query.data if data == 'toggle_whitelist': current = redis_client.get(KEY_WHITELIST_ENABLED) new_state = '0' if current != '0' else '1' redis_client.set(KEY_WHITELIST_ENABLED, new_state) state_text = t(user_id, 'whitelist_disabled') if new_state == '0' else t(user_id, 'whitelist_enabled') await query.edit_message_reply_markup(reply_markup=get_admin_keyboard(user_id)) await query.message.reply_text(state_text) elif data == 'view_whitelist': members = redis_client.smembers(KEY_WHITELIST) msg = t(user_id, 'whitelist_title') + ('\n'.join(members) if members else t(user_id, 'empty')) await query.message.reply_text(msg, parse_mode='HTML') elif data == 'view_subscribers': subs = redis_client.smembers(KEY_SUBSCRIBERS) msg = t(user_id, 'subscribers_title') + ('\n'.join(subs) if subs else t(user_id, 'empty')) await query.message.reply_text(msg, parse_mode='HTML') elif data == 'force_check': await query.message.reply_text(t(user_id, 'force_check_running')) result = await check_webinars_job(context) if result is None: await query.message.reply_text(t(user_id, 'check_failed')) elif result == 0: await query.message.reply_text(t(user_id, 'check_completed_none')) else: await query.message.reply_text(t(user_id, 'check_completed', count=result)) async def language_callback(update: Update, _context: ContextTypes.DEFAULT_TYPE): """Handle language selection button clicks.""" query = update.callback_query user_id = query.from_user.id data = query.data if data.startswith('lang_'): lang = data.split('_')[1] set_user_language(user_id, lang) await query.answer() await query.edit_message_text(t(user_id, 'language_changed'), parse_mode='HTML') # --- Webinar Checking Job --- def get_webinar_key(url: str) -> str: """Generate unique key for a webinar based on URL.""" return url def get_stored_webinars() -> list: """Get list of stored webinar keys from Redis.""" data = redis_client.get(KEY_WEBINAR_HISTORY) if data: try: return json.loads(data) except Exception as e: logger.error(f'Failed to parse webinar history: {e}') return [] def store_webinars(webinar_keys: list): """Store up to 3 most recent webinar keys in Redis.""" # Keep only last 3 webinar_keys = webinar_keys[-3:] try: redis_client.set(KEY_WEBINAR_HISTORY, json.dumps(webinar_keys)) logger.info(f'Stored {len(webinar_keys)} webinar(s) in history') except Exception as e: logger.error(f'Failed to store webinar history: {e}') async def check_webinars_job(context: ContextTypes.DEFAULT_TYPE): """Background job to check for webinars using Async Playwright. Returns: int: Number of webinars found, or None if check failed """ logger.info('Running webinar check...') phpsessid = redis_client.get(KEY_PHPSESSID) if not phpsessid: logger.warning('PHPSESSID missing. Skipping check.') # --- DEBUG LOGGING --- try: with open('phpsessid_missing.log', 'a') as f: f.write(f'[{os.getcwd()}] PHPSESSID missing at {context.job.last_run: %Y-%m-%d %H:%M:%S}\n') except Exception as e: logger.error(f'Failed to write PHPSESSID debug log: {e}') # --------------------- return None current_webinars = [] # List of dicts with name, url, and formatted text content = '' try: async with async_playwright() as p: # Connect to remote Playwright service browser = await p.chromium.connect(PLAYWRIGHT_WS) try: # Create browser context with user agent context_browser = await browser.new_context(user_agent=USER_AGENT) # Add PHPSESSID cookie await context_browser.add_cookies( [{'name': 'PHPSESSID', 'value': phpsessid, 'domain': 'edu.edu.vn.ua', 'path': '/'}] ) # Create new page page = await context_browser.new_page() try: # Navigate to webinar page await page.goto(WEBINAR_URL, wait_until='domcontentloaded') # Wait for the table to load await page.wait_for_selector('#meetings table', timeout=10000) await page.wait_for_timeout(2000) # Get page content content = await page.content() # Check if "no webinar" message is present if 'Жодного онлайн уроку зараз' not in content: logger.info('!!! WEBINAR FOUND !!!') # Extract webinar details from table rows rows = page.locator('#meetings table tbody tr') count = await rows.count() for i in range(count): row = rows.nth(i) text = await row.inner_text() if 'Жодного онлайн уроку зараз' not in text: # Extract name (topic) from first column name_elem = row.locator('td').nth(0) name = await name_elem.inner_text() name = name.strip() # Extract join URL from fourth column url_elem = row.locator('td').nth(3).locator('a[href*="/webinar/join/"]').first url = await url_elem.get_attribute('href') if name and url: current_webinars.append({'name': name, 'url': url, 'text': text.strip()}) logger.info(f'Found webinar: {name} -> {url}') else: logger.info('No webinars found (expected message present)') except Exception as e: logger.error(f'Error checking page: {e}. Saving content for debug.') # If page content is available, save it on error with contextlib.suppress(Exception): if page and not content: content = await page.content() return None finally: await page.close() await context_browser.close() finally: await browser.close() except Exception as e: logger.error(f'Playwright error: {e}') return None # --- DEBUG LOGGING (Saving last response content) --- if not current_webinars and content: # if no webinars found, save the page content try: with open('response.html', 'w', encoding='utf-8') as f: f.write(content) logger.info('Saved page content to response.html for debug.') except Exception as e: logger.error(f'Failed to write debug HTML: {e}') # ----------------------------------------------------- # Check for NEW webinars and notify if current_webinars: # Get stored webinar history stored_keys = get_stored_webinars() logger.info(f'Stored webinar keys: {stored_keys}') # Find new webinars (not in history) new_webinars = [] current_keys = [] for webinar in current_webinars: key = get_webinar_key(webinar['url']) current_keys.append(key) if key not in stored_keys: new_webinars.append(webinar) logger.info(f'NEW webinar detected: {webinar["name"]}') # Update stored history with current webinars # Merge old and new, keeping only last 5 updated_keys = stored_keys + [k for k in current_keys if k not in stored_keys] store_webinars(updated_keys) # Notify subscribers ONLY about NEW webinars if new_webinars: subscribers = redis_client.smembers(KEY_SUBSCRIBERS) logger.info( f'Sending notification about {len(new_webinars)} new webinar(s) to {len(subscribers)} subscriber(s)' ) for sub_id in subscribers: try: # Build message in user's language # sub_id comes from redis set as string, convert to int for translation lookup webinar_items = '\n\n'.join( [t(int(sub_id), 'webinar_item', name=w['name'], url=w['url']) for w in new_webinars] ) message = t(int(sub_id), 'webinar_found') + webinar_items await context.bot.send_message(chat_id=sub_id, text=message, parse_mode='HTML') logger.info(f'Notification sent to {sub_id}') except Exception as e: logger.error(f'Failed to send to {sub_id}: {e}') else: logger.info(f'Found {len(current_webinars)} webinar(s), but all are already known') return len(current_webinars) # --- Main --- def main(): if not WEBINAR_TELEGRAM_TOKEN: logger.error('WEBINAR_TELEGRAM_TOKEN is missing!') return # Set default whitelist state if not set if not redis_client.exists(KEY_WHITELIST_ENABLED): redis_client.set(KEY_WHITELIST_ENABLED, '1') # Enabled by default # Add admin to whitelist if ADMIN_ID: redis_client.sadd(KEY_WHITELIST, str(ADMIN_ID)) app = Application.builder().token(WEBINAR_TELEGRAM_TOKEN).build() # Handlers app.add_handler(CommandHandler('start', start)) app.add_handler(CommandHandler('stop', stop_command)) app.add_handler(CommandHandler('help', help_command)) app.add_handler(CommandHandler('language', language_command)) app.add_handler(CommandHandler('adduser', add_user)) app.add_handler(CommandHandler('removeuser', remove_user)) app.add_handler(CommandHandler('clearhistory', clear_history)) app.add_handler(CommandHandler('diary', diary_command)) # Callback handlers - diary first, then language selection, then admin panel app.add_handler(CallbackQueryHandler(diary_callback, pattern='^diary_')) app.add_handler(CallbackQueryHandler(language_callback, pattern='^lang_')) app.add_handler(CallbackQueryHandler(admin_callback)) # Job Queue job_queue = app.job_queue job_queue.run_repeating(check_webinars_job, interval=WEBINAR_CHECK_INTERVAL, first=10) logger.info('Bot started polling...') app.run_polling() if __name__ == '__main__': main()