import os import logging import redis import json from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup 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__) # Load environment variables WEBINAR_URL = os.getenv('WEBINAR_URL', 'https://edu.edu.vn.ua/webinar/useractive') WEBINAR_CHECK_INTERVAL = int(os.getenv('WEBINAR_CHECK_INTERVAL', 60)) REDIS_HOST = os.getenv('REDIS_HOST', 'redis') REDIS_PORT = int(os.getenv('REDIS_PORT', 6379)) PLAYWRIGHT_WS = os.getenv('PLAYWRIGHT_WS', 'ws://playwright-service:3000/ws') USER_AGENT = os.getenv('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 = os.getenv('WEBINAR_TELEGRAM_TOKEN') ADMIN_ID = int(os.getenv('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 5 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) # --- 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)) def get_admin_keyboard(): """Generate admin panel keyboard.""" whitelist_enabled = redis_client.get(KEY_WHITELIST_ENABLED) != "0" toggle_text = "🔒 Disable Whitelist" if whitelist_enabled else "🔓 Enable Whitelist" keyboard = [ [InlineKeyboardButton(toggle_text, callback_data="toggle_whitelist")], [InlineKeyboardButton("📋 View Whitelist", callback_data="view_whitelist")], [InlineKeyboardButton("👥 View Subscribers", callback_data="view_subscribers")], [InlineKeyboardButton("🔄 Force Check", callback_data="force_check")] ] return InlineKeyboardMarkup(keyboard) # --- Command Handlers --- async def start(update: Update, context: ContextTypes.DEFAULT_TYPE): """Handle /start command.""" user = update.effective_user logger.info(f"User {user.id} ({user.username}) started the bot.") if not is_whitelisted(user.id): await update.message.reply_text("⛔ Access denied. You are not on the whitelist.") return # Add to subscribers redis_client.sadd(KEY_SUBSCRIBERS, user.id) msg = ( f"👋 Hello {user.first_name}!\n\n" "I am the Webinar Checker Bot. I will notify you when a new webinar appears.\n" "You have been subscribed to notifications." ) if user.id == ADMIN_ID: msg += "\n\n👑 Admin Mode Active" await update.message.reply_text(msg, parse_mode='HTML', reply_markup=get_admin_keyboard()) else: await update.message.reply_text(msg, parse_mode='HTML') async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE): """Handle /help command.""" msg = ( "🤖 Bot Help\n\n" "/start - Subscribe to notifications\n" "/help - Show this message\n" ) if update.effective_user.id == ADMIN_ID: msg += ( "\nAdmin Commands:\n" "/adduser [user_id] - Add user to whitelist\n" "/removeuser [user_id] - Remove user from whitelist\n" "Or use the panel below to manage settings." ) await update.message.reply_text(msg, parse_mode='HTML', reply_markup=get_admin_keyboard()) else: await update.message.reply_text(msg, parse_mode='HTML') async def add_user(update: Update, context: ContextTypes.DEFAULT_TYPE): """Add user to whitelist (admin only).""" if update.effective_user.id != ADMIN_ID: await update.message.reply_text("⛔ Admin only!") return if not context.args: await update.message.reply_text("Usage: /adduser [user_id]") return try: user_id = int(context.args[0]) redis_client.sadd(KEY_WHITELIST, str(user_id)) await update.message.reply_text(f"✅ User {user_id} added to whitelist") logger.info(f"Admin added user {user_id} to whitelist") except ValueError: await update.message.reply_text("❌ Invalid user ID. Must be a number.") async def remove_user(update: Update, context: ContextTypes.DEFAULT_TYPE): """Remove user from whitelist (admin only).""" if update.effective_user.id != ADMIN_ID: await update.message.reply_text("⛔ Admin only!") return if not context.args: await update.message.reply_text("Usage: /removeuser [user_id]") return try: user_id = int(context.args[0]) if str(user_id) == str(ADMIN_ID): await update.message.reply_text("❌ Cannot remove admin from whitelist") return removed = redis_client.srem(KEY_WHITELIST, str(user_id)) if removed: await update.message.reply_text(f"✅ User {user_id} removed from whitelist") logger.info(f"Admin removed user {user_id} from whitelist") else: await update.message.reply_text(f"⚠️ User {user_id} was not in whitelist") except ValueError: await update.message.reply_text("❌ Invalid user ID. Must be a number.") # --- 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("⛔ 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 = "Disabled" if new_state == "0" else "Enabled" await query.edit_message_reply_markup(reply_markup=get_admin_keyboard()) await query.message.reply_text(f"✅ Whitelist {state_text}") elif data == "view_whitelist": members = redis_client.smembers(KEY_WHITELIST) msg = "📋 Whitelist:\n" + ("\n".join(members) if members else "Empty") await query.message.reply_text(msg, parse_mode='HTML') elif data == "view_subscribers": subs = redis_client.smembers(KEY_SUBSCRIBERS) msg = "👥 Subscribers:\n" + ("\n".join(subs) if subs else "Empty") await query.message.reply_text(msg, parse_mode='HTML') elif data == "force_check": await query.message.reply_text("🔄 Running immediate check...") result = await check_webinars_job(context) if result is None: await query.message.reply_text("❌ Check failed. See logs for details.") elif result == 0: await query.message.reply_text("✅ Check completed. No webinars found.") else: await query.message.reply_text(f"✅ Check completed. Found {result} webinar(s)!") # --- Webinar Checking Job --- def get_webinar_key(name: str, url: str) -> str: """Generate unique key for a webinar based on name and URL.""" return f"{name}|{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 5 most recent webinar keys in Redis.""" # Keep only last 5 webinar_keys = webinar_keys[-5:] 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['name'], 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: message = "🎓 Новий вебінар!\n\n" + "\n\n".join([ f"📌 {w['name']}\n🔗 https://edu.edu.vn.ua{w['url']}" for w in 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: 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("help", help_command)) app.add_handler(CommandHandler("adduser", add_user)) app.add_handler(CommandHandler("removeuser", remove_user)) 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()