import os import re import logging import redis import json import time from datetime import datetime, timedelta from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup, ChatMember from telegram.constants import ChatType from telegram.ext import Application, CommandHandler, CallbackQueryHandler, ContextTypes from playwright.async_api import async_playwright # 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) if m: month_text = m.group(1).replace(' : ', ' ').strip() else: month_text = 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) if title_m: et = title_m.group(1).strip() else: et = 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 try: with open('/tmp/diary_debug.html', 'w', encoding='utf-8') as f: f.write(table_html) except Exception: pass 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', {}) month_str = _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 chat = update.effective_chat 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: if 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 try: if page and not content: content = await page.content() except Exception: pass # Ignore error during content retrieval on check error 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()