418 lines
16 KiB
Python
418 lines
16 KiB
Python
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👑 <b>Admin Mode Active</b>"
|
|
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 = (
|
|
"🤖 <b>Bot Help</b>\n\n"
|
|
"/start - Subscribe to notifications\n"
|
|
"/help - Show this message\n"
|
|
)
|
|
if update.effective_user.id == ADMIN_ID:
|
|
msg += (
|
|
"\n<b>Admin Commands:</b>\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 = "📋 <b>Whitelist:</b>\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 = "👥 <b>Subscribers:</b>\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 = "🎓 <b>Новий вебінар!</b>\n\n" + "\n\n".join([
|
|
f"📌 <b>{w['name']}</b>\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() |