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()