68630eb773
ci / lint-prettier (push) Successful in 9s
ci / lint-ruff (push) Successful in 6s
ci / lint-yaml (push) Successful in 10s
ci / lint-dockerfiles (push) Successful in 7s
ci / validate (push) Successful in 6s
ci / build (push) Has been skipped
ci / deploy-userbot-panel (push) Has been skipped
_collect_event_times re-clicked every a.event-link and waited ~3s per event for a visible span.data, but the calendar embeds all times in div.event-full-info[data-event-full-info-id] span.data already. The old loop took ~109s for 25 events and collected 0 (original divs stay sf-hidden), effectively hanging /diary. Now a single evaluate reads all times (~3.7s), parsing HH:MM from p.date span.data.
1689 lines
72 KiB
Python
1689 lines
72 KiB
Python
import contextlib
|
||
import json
|
||
import logging
|
||
import os
|
||
import re
|
||
import tempfile
|
||
import time
|
||
from datetime import datetime, timedelta
|
||
from html import escape
|
||
|
||
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'
|
||
SCHEDULE_URL = f'{EDU_BASE.rstrip("/")}/lessons/table'
|
||
|
||
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
|
||
|
||
# Marker shown by the site when there are no active online lessons
|
||
NO_WEBINAR_MARKER = 'Жодного онлайн уроку зараз'
|
||
|
||
# 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👑 <b>Режим администратора активен</b>',
|
||
'access_denied': '⛔ Доступ запрещен. Вас нет в белом списке.',
|
||
'help_title': '🤖 <b>Помощь по боту</b>\n\n',
|
||
'help_commands': '/start - Подписаться на уведомления\n/stop - Отписаться от уведомлений\n/help - Показать это сообщение\n/language - Сменить язык',
|
||
'help_admin': '\n<b>Команды администратора:</b>\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': '📋 <b>Белый список:</b>\n',
|
||
'subscribers_title': '👥 <b>Подписчики:</b>\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': '🎓 <b>Новый вебинар!</b>\n\n',
|
||
'webinar_item': '📌 <b>{name}</b>\n🔗 https://edu.edu.vn.ua{url}',
|
||
'select_language': '🌐 <b>Выберите язык / Оберіть мову / Select language:</b>',
|
||
'language_changed': '✅ Язык изменен на {lang}',
|
||
'flag_ru': '🇷🇺 Русский',
|
||
'flag_uk': '🇺🇦 Українська',
|
||
'flag_en': '🇬🇧 English',
|
||
'history_cleared': '✅ История вебинаров очищена',
|
||
'history_clear_failed': '❌ Ошибка при очистке истории',
|
||
'today': '📌 Сегодня',
|
||
'tomorrow': '📌 Завтра',
|
||
'week': '📅 Эта неделя',
|
||
'month': '📅 Весь месяц',
|
||
'no_events': 'Нет событий',
|
||
'no_lessons': 'Нет уроков',
|
||
'time_unknown': 'неизвестно',
|
||
'free_period': 'Свободно',
|
||
'unsubscribed': '🔕 Вы отписались от уведомлений.',
|
||
'diary_title': '📅 <b>Дневник</b> — выберите период:',
|
||
'diary_week_title': '📅 <b>Неделя {start} – {end}</b>',
|
||
'schedule_title': '📅 <b>{weekday} — {class_num} класс</b>',
|
||
'loading_diary': '🔄 Загружаю дневник...',
|
||
'diary_load_failed': '❌ Не удалось загрузить дневник.',
|
||
'diary_no_session': '❌ Не удалось загрузить дневник. Нет сессии или ошибка.',
|
||
'diary_next_month': '❌ Данные за следующий месяц недоступны. Перейдите на сайт.',
|
||
'schedule_load_failed': '❌ Не удалось загрузить расписание.',
|
||
'class_not_set': '❌ Класс не настроен. Используйте /setclass.',
|
||
'class_not_found': '❌ Классы не найдены в расписании.',
|
||
'select_class': '🎒 <b>Выберите класс</b> — сохранится и больше не спросится:',
|
||
'admin_class_not_set': '⚠️ Администратор еще не настроил класс для этой группы. Используйте /setclass 11',
|
||
'loading_schedule': '🔄 Загружаю расписание...',
|
||
'invalid_class_num': '❌ Неверный номер класса.',
|
||
'class_saved': '✅ Класс <b>{class_num}</b> сохранен.',
|
||
'admin_only_msg': '⚠️ Только администратор может настроить класс для группы.',
|
||
'truncation': '\n\n✂️ ...(обрезано)',
|
||
'day_mon': 'Пн',
|
||
'day_tue': 'Вт',
|
||
'day_wed': 'Ср',
|
||
'day_thu': 'Чт',
|
||
'day_fri': 'Пт',
|
||
'day_sat': 'Сб',
|
||
'day_sun': 'Вс',
|
||
},
|
||
'uk': {
|
||
'welcome': "👋 Привіт, {name}!\n\nЯ бот-сповіщувач про вебінари. Я повідомлятиму вас, коли з'явиться новий вебінар.\nВи підписані на сповіщення.",
|
||
'welcome_admin': '\n\n👑 <b>Режим адміністратора активний</b>',
|
||
'access_denied': '⛔ Доступ заборонено. Вас немає в білому списку.',
|
||
'help_title': '🤖 <b>Довідка по боту</b>\n\n',
|
||
'help_commands': '/start - Підписатися на сповіщення\n/stop - Відписатися від сповіщень\n/help - Показати це повідомлення\n/language - Змінити мову',
|
||
'help_admin': '\n<b>Команди адміністратора:</b>\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': '📋 <b>Білий список:</b>\n',
|
||
'subscribers_title': '👥 <b>Підписники:</b>\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': '🎓 <b>Новий вебінар!</b>\n\n',
|
||
'webinar_item': '📌 <b>{name}</b>\n🔗 https://edu.edu.vn.ua{url}',
|
||
'select_language': '🌐 <b>Виберіть мову / Выберите язык / Select language:</b>',
|
||
'language_changed': '✅ Мову змінено на {lang}',
|
||
'flag_ru': '🇷🇺 Русский',
|
||
'flag_uk': '🇺🇦 Українська',
|
||
'flag_en': '🇬🇧 English',
|
||
'history_cleared': '✅ Історія вебінарів очищена',
|
||
'history_clear_failed': '❌ Помилка при очищенні історії',
|
||
'today': '📌 Сьогодні',
|
||
'tomorrow': '📌 Завтра',
|
||
'week': '📅 Цей тиждень',
|
||
'month': '📅 Весь місяць',
|
||
'no_events': 'Немає подій',
|
||
'no_lessons': 'Немає уроків',
|
||
'time_unknown': 'невідомо',
|
||
'free_period': 'Вільно',
|
||
'unsubscribed': '🔕 Ви відписалися від сповіщень.',
|
||
'diary_title': '📅 <b>Щоденник</b> — виберіть період:',
|
||
'diary_week_title': '📅 <b>Тиждень {start} – {end}</b>',
|
||
'schedule_title': '📅 <b>{weekday} — {class_num} клас</b>',
|
||
'loading_diary': '🔄 Завантажую щоденник...',
|
||
'diary_load_failed': '❌ Не вдалося завантажити щоденник.',
|
||
'diary_no_session': '❌ Не вдалося завантажити щоденник. Немає сесії або помилка.',
|
||
'diary_next_month': '❌ Дані за наступний місяць недоступні. Перейдіть на сайт.',
|
||
'schedule_load_failed': '❌ Не вдалося завантажити розклад.',
|
||
'class_not_set': '❌ Клас не налаштовано. Використайте /setclass.',
|
||
'class_not_found': '❌ Класи не знайдені в розкладі.',
|
||
'select_class': '🎒 <b>Оберіть клас</b> — збережеться і більше не питатиметься:',
|
||
'admin_class_not_set': '⚠️ Адміністратор ще не налаштував клас для цієї групи. Використайте /setclass 11',
|
||
'loading_schedule': '🔄 Завантажую розклад...',
|
||
'invalid_class_num': '❌ Невірний номер класу.',
|
||
'class_saved': '✅ Клас <b>{class_num}</b> збережено.',
|
||
'admin_only_msg': '⚠️ Тільки адміністратор може налаштувати клас для групи.',
|
||
'truncation': '\n\n✂️ ...(обрізано)',
|
||
'day_mon': 'Пн',
|
||
'day_tue': 'Вт',
|
||
'day_wed': 'Ср',
|
||
'day_thu': 'Чт',
|
||
'day_fri': 'Пт',
|
||
'day_sat': 'Сб',
|
||
'day_sun': 'Нд',
|
||
},
|
||
'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👑 <b>Admin Mode Active</b>',
|
||
'access_denied': '⛔ Access denied. You are not on the whitelist.',
|
||
'help_title': '🤖 <b>Bot Help</b>\n\n',
|
||
'help_commands': '/start - Subscribe to notifications\n/stop - Unsubscribe from notifications\n/help - Show this message\n/language - Change language',
|
||
'help_admin': '\n<b>Admin Commands:</b>\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': '📋 <b>Whitelist:</b>\n',
|
||
'subscribers_title': '👥 <b>Subscribers:</b>\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': '🎓 <b>New webinar found!</b>\n\n',
|
||
'webinar_item': '📌 <b>{name}</b>\n🔗 https://edu.edu.vn.ua{url}',
|
||
'select_language': '🌐 <b>Select language / Виберіть мову / Выберите язык:</b>',
|
||
'language_changed': '✅ Language changed to {lang}',
|
||
'flag_ru': '🇷🇺 Русский',
|
||
'flag_uk': '🇺🇦 Українська',
|
||
'flag_en': '🇬🇧 English',
|
||
'history_cleared': '✅ Webinar history cleared',
|
||
'history_clear_failed': '❌ Error clearing history',
|
||
'today': '📌 Today',
|
||
'tomorrow': '📌 Tomorrow',
|
||
'week': '📅 This week',
|
||
'month': '📅 Whole month',
|
||
'no_events': 'No events',
|
||
'no_lessons': 'No lessons',
|
||
'time_unknown': 'unknown',
|
||
'free_period': 'Free',
|
||
'unsubscribed': '🔕 You have unsubscribed from notifications.',
|
||
'diary_title': '📅 <b>Diary</b> — choose a period:',
|
||
'diary_week_title': '📅 <b>Week {start} – {end}</b>',
|
||
'schedule_title': '📅 <b>{weekday} — {class_num} class</b>',
|
||
'loading_diary': '🔄 Loading diary...',
|
||
'diary_load_failed': '❌ Failed to load diary.',
|
||
'diary_no_session': '❌ Failed to load diary. No session or error.',
|
||
'diary_next_month': '❌ Next month data is not available. Please visit the website.',
|
||
'schedule_load_failed': '❌ Failed to load schedule.',
|
||
'class_not_set': '❌ Class is not set. Use /setclass.',
|
||
'class_not_found': '❌ No classes found in the schedule.',
|
||
'select_class': '🎒 <b>Choose a class</b> — it will be saved and not asked again:',
|
||
'admin_class_not_set': '⚠️ Admin has not set a class for this group yet. Use /setclass 11',
|
||
'loading_schedule': '🔄 Loading schedule...',
|
||
'invalid_class_num': '❌ Invalid class number.',
|
||
'class_saved': '✅ Class <b>{class_num}</b> saved.',
|
||
'admin_only_msg': '⚠️ Only an admin can set the class for this group.',
|
||
'truncation': '\n\n✂️ ...(truncated)',
|
||
'day_mon': 'Mon',
|
||
'day_tue': 'Tue',
|
||
'day_wed': 'Wed',
|
||
'day_thu': 'Thu',
|
||
'day_fri': 'Fri',
|
||
'day_sat': 'Sat',
|
||
'day_sun': 'Sun',
|
||
},
|
||
}
|
||
|
||
# --- Language Helper Functions ---
|
||
|
||
|
||
LANG_NAME_MAP = {'ru': 'Русский', 'uk': 'Українська', 'en': 'English'}
|
||
|
||
|
||
def get_user_language(user_id: int) -> str:
|
||
"""Get user's preferred language from Redis. Default: English."""
|
||
lang = redis_client.get(f'user:{user_id}:language')
|
||
return lang if lang in ['ru', 'uk', 'en'] else 'en'
|
||
|
||
|
||
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.
|
||
|
||
Fallback chain: user lang → en → uk → ru, otherwise return the key.
|
||
"""
|
||
lang = get_user_language(user_id)
|
||
message = None
|
||
for fallback in (lang, 'en', 'uk', 'ru'):
|
||
message = TRANSLATIONS.get(fallback, {}).get(key)
|
||
if message is not None:
|
||
break
|
||
if message is None:
|
||
return key
|
||
if kwargs:
|
||
return message.format(**kwargs)
|
||
return message
|
||
|
||
|
||
def _tr(lang: str, key: str, **kwargs) -> str:
|
||
"""Translate by explicit lang code (for format_* helpers).
|
||
|
||
Fallback chain: lang → en → uk → ru, otherwise return the key.
|
||
"""
|
||
message = None
|
||
for fallback in (lang, 'en', 'uk', 'ru'):
|
||
message = TRANSLATIONS.get(fallback, {}).get(key)
|
||
if message is not None:
|
||
break
|
||
if message is None:
|
||
return key
|
||
if kwargs:
|
||
return message.format(**kwargs)
|
||
return message
|
||
|
||
|
||
def get_chat_language(chat_id: int) -> str:
|
||
"""Get group chat language from Redis. Default: Ukrainian."""
|
||
lang = redis_client.get(f'chat:{chat_id}:language')
|
||
return lang if lang in ['ru', 'uk', 'en'] else 'uk'
|
||
|
||
|
||
def set_chat_language(chat_id: int, lang: str):
|
||
"""Save group chat language preference to Redis."""
|
||
if lang in ['ru', 'uk', 'en']:
|
||
redis_client.set(f'chat:{chat_id}:language', lang)
|
||
logger.info(f'Chat {chat_id} language set to {lang}')
|
||
|
||
|
||
def resolve_lang(chat, user_id=None) -> str:
|
||
"""Resolve effective language: private → user lang, groups → chat lang (default 'uk')."""
|
||
chat_id = getattr(chat, 'id', chat)
|
||
chat_type = getattr(chat, 'type', None)
|
||
if chat_type is None:
|
||
try:
|
||
is_private = int(chat_id) > 0
|
||
except (TypeError, ValueError):
|
||
is_private = True
|
||
if is_private:
|
||
uid = user_id if user_id is not None else chat_id
|
||
return get_user_language(int(uid))
|
||
return get_chat_language(int(chat_id))
|
||
if chat_type in (ChatType.PRIVATE, 'private'):
|
||
uid = user_id if user_id is not None else chat_id
|
||
return get_user_language(int(uid))
|
||
return get_chat_language(chat_id)
|
||
|
||
|
||
def t_chat(chat, user_id, key: str, **kwargs) -> str:
|
||
"""Translate using chat-resolved language (private → user, groups → chat 'uk' default)."""
|
||
return _tr(resolve_lang(chat, user_id), key, **kwargs)
|
||
|
||
|
||
def get_language_keyboard(user_id: int | None = None):
|
||
"""Generate language selection keyboard."""
|
||
lang = get_user_language(user_id) if user_id is not None else 'en'
|
||
keyboard = [
|
||
[
|
||
InlineKeyboardButton(_tr(lang, 'flag_ru'), callback_data='lang_ru'),
|
||
InlineKeyboardButton(_tr(lang, 'flag_uk'), callback_data='lang_uk'),
|
||
],
|
||
[
|
||
InlineKeyboardButton(_tr(lang, 'flag_en'), 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 ---
|
||
|
||
SCHEDULE_WEEKDAYS_FULL = {
|
||
'uk': ['Понеділок', 'Вівторок', 'Середа', 'Четвер', "П'ятниця", 'Субота', 'Неділя'],
|
||
'ru': ['Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота', 'Воскресенье'],
|
||
'en': ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'],
|
||
}
|
||
# Canonical weekday names used in callback_data and schedule lookup (site language).
|
||
SCHEDULE_WEEKDAYS_CANONICAL = SCHEDULE_WEEKDAYS_FULL['uk']
|
||
SCHEDULE_CACHE_TTL = 18000 # 5 hours
|
||
|
||
|
||
def _norm_day(s):
|
||
"""Normalize weekday name for tolerant matching (case, spaces, apostrophes)."""
|
||
if not isinstance(s, str):
|
||
return ''
|
||
s = s.strip().casefold()
|
||
for _q in ('\u2019', '\u2018', '\u02bc', '`'):
|
||
s = s.replace(_q, "'")
|
||
s = re.sub(r'\s+', ' ', s)
|
||
return s.strip()
|
||
|
||
|
||
_SCHEDULE_WEEKDAYS_CANONICAL_NORM = [_norm_day(d) for d in SCHEDULE_WEEKDAYS_CANONICAL]
|
||
|
||
DIARY_WEEKDAYS_SHORT = {
|
||
'uk': ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Нд'],
|
||
'ru': ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'],
|
||
'en': ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
|
||
}
|
||
|
||
|
||
def get_diary_keyboard(user_id: int | None = None):
|
||
today = datetime.now()
|
||
lang = get_user_language(user_id) if user_id is not None else 'en'
|
||
today_label = _tr(lang, 'today')
|
||
tomorrow_label = _tr(lang, 'tomorrow')
|
||
week_label = _tr(lang, 'week')
|
||
month_label = _tr(lang, 'month')
|
||
keyboard = [
|
||
[
|
||
InlineKeyboardButton(f'{today_label} ({today.day}.{today.month:02d})', callback_data='diary_today'),
|
||
InlineKeyboardButton(tomorrow_label, callback_data='diary_tomorrow'),
|
||
],
|
||
[
|
||
InlineKeyboardButton(week_label, callback_data='diary_week'),
|
||
InlineKeyboardButton(month_label, 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}}).
|
||
|
||
Each event is a dict: {'title': str, 'id': site event id | None, 'time': 'HH:MM' | None}.
|
||
"""
|
||
days = {}
|
||
weekdays = []
|
||
rows = re.findall(r'<tr[^>]*>(.*?)</tr>', table_html, re.DOTALL)
|
||
|
||
month_text = ''
|
||
for r_idx, row in enumerate(rows):
|
||
cells = re.findall(r'<t[dh][^>]*>(.*?)</t[dh]>', 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 <a> tags inside the cell.
|
||
# Each event keeps its site data-event-id and a 'time' slot (filled later
|
||
# from the AJAX popup by fetch_diary_data). 'time' is 'HH:MM' or None.
|
||
events = []
|
||
for a_match in re.finditer(r'<a[^>]*>(.*?)</a>', 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 not et:
|
||
continue
|
||
id_m = re.search(r'data-event-id\s*=\s*"?(\d+)"?', a_tag)
|
||
events.append(
|
||
{
|
||
'title': et,
|
||
'id': id_m.group(1) if id_m else None,
|
||
'time': None,
|
||
}
|
||
)
|
||
|
||
weekday = weekdays[col_idx] if col_idx < len(weekdays) else ''
|
||
days[day_num] = {'weekday': weekday, 'weekday_idx': col_idx, 'events': events}
|
||
|
||
return month_text, days
|
||
|
||
|
||
async def _collect_event_times(page) -> dict:
|
||
"""Read event times straight from the rendered calendar DOM.
|
||
|
||
The diary page embeds `div.event-full-info[data-event-full-info-id]`
|
||
containing `span.data` (e.g. "2026-09-02 16:30:00") for every event, so no
|
||
AJAX popup clicks are needed. Returns {event_id: 'HH:MM'} for events that
|
||
have a date; events without one are simply skipped.
|
||
"""
|
||
times_by_id: dict[str, str] = {}
|
||
try:
|
||
raw_times = await page.evaluate(
|
||
"""() => {
|
||
const out = {};
|
||
for (const div of document.querySelectorAll(
|
||
'div.event-full-info[data-event-full-info-id]'
|
||
)) {
|
||
const id = div.getAttribute('data-event-full-info-id');
|
||
const date_span = div.querySelector('p.date span.data');
|
||
if (id && date_span) {
|
||
out[id] = date_span.textContent.trim();
|
||
}
|
||
}
|
||
return out;
|
||
}"""
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f'Failed to read diary event times from DOM: {e}')
|
||
return times_by_id
|
||
|
||
for event_id, time_text in (raw_times or {}).items():
|
||
if not time_text:
|
||
continue
|
||
m = re.search(r'(\d{1,2}:\d{2})', time_text)
|
||
if m:
|
||
times_by_id[event_id] = m.group(1)
|
||
|
||
logger.info(f'Diary event times collected for {len(times_by_id)} events')
|
||
return times_by_id
|
||
|
||
|
||
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)
|
||
|
||
# Read event times by opening each event's AJAX popup.
|
||
times_by_id = await _collect_event_times(page)
|
||
if times_by_id:
|
||
for day_data in days.values():
|
||
for ev in day_data.get('events', []):
|
||
eid = ev.get('id')
|
||
if eid and eid in times_by_id:
|
||
ev['time'] = times_by_id[eid]
|
||
|
||
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_event_line(event, lang: str) -> str:
|
||
"""Render one diary event line: 📌 title, with time in parens when known.
|
||
|
||
'08:00' is the site's placeholder for "no time specified" — show a localized
|
||
'time_unknown' mark instead. Unknown/absent time renders as before, no parens.
|
||
"""
|
||
if isinstance(event, dict):
|
||
title = escape(str(event.get('title') or ''))
|
||
tme = event.get('time')
|
||
else:
|
||
title = escape(str(event))
|
||
tme = None
|
||
if tme == '08:00':
|
||
return f'📌 {title} ({_tr(lang, "time_unknown")})'
|
||
if tme:
|
||
return f'📌 {title} ({escape(str(tme))})'
|
||
return f'📌 {title}'
|
||
|
||
|
||
def format_diary_day(data: dict, day_num: int, lang: str = 'en') -> str:
|
||
days = data.get('days', {})
|
||
month_str = _parse_diary_month(data.get('monthFullText', ''))
|
||
day_data = days.get(str(day_num))
|
||
lines = [f'📅 <b>{day_num} {month_str}</b>', '─' * 18]
|
||
if not day_data or not day_data.get('events'):
|
||
lines.append(_tr(lang, 'no_events'))
|
||
else:
|
||
for e in day_data['events']:
|
||
lines.append(_format_event_line(e, lang))
|
||
lines.append(f'\n🔗 {DIARY_URL}')
|
||
return '\n'.join(lines)
|
||
|
||
|
||
def format_diary_week(data: dict, today: datetime, lang: str = 'en') -> str:
|
||
days = data.get('days', {})
|
||
_parse_diary_month(data.get('monthFullText', ''))
|
||
monday = today - timedelta(days=today.weekday())
|
||
friday = monday + timedelta(days=4)
|
||
start = f'{monday.day}.{monday.month}'
|
||
end = f'{friday.day}.{friday.month}'
|
||
short = DIARY_WEEKDAYS_SHORT.get(lang, DIARY_WEEKDAYS_SHORT['en'])
|
||
lines = [_tr(lang, 'diary_week_title', start=start, end=end) + '\n']
|
||
for i in range(5):
|
||
d = monday + timedelta(days=i)
|
||
day_data = days.get(str(d.day))
|
||
lines.append(f'─ <b>{short[i]} {d.day}.{d.month}</b> ─')
|
||
if not day_data or not day_data.get('events'):
|
||
lines.append(_tr(lang, 'no_events') + '\n')
|
||
else:
|
||
for e in day_data['events']:
|
||
lines.append(_format_event_line(e, lang))
|
||
lines.append('')
|
||
lines.append(f'🔗 {DIARY_URL}')
|
||
return '\n'.join(lines)
|
||
|
||
|
||
def format_diary_month(data: dict, lang: str = 'en') -> str:
|
||
days = data.get('days', {})
|
||
month_str = _parse_diary_month(data.get('monthFullText', ''))
|
||
lines = [f'📅 <b>{month_str}</b>\n']
|
||
for day_num in sorted(days.keys(), key=int):
|
||
day_data = days[day_num]
|
||
if day_data.get('weekday_idx', 0) >= 5:
|
||
continue
|
||
if day_data.get('weekday', '').strip().lower() in (
|
||
'субота',
|
||
'суббота',
|
||
'saturday',
|
||
'неділя',
|
||
'воскресенье',
|
||
'sunday',
|
||
):
|
||
continue
|
||
events = day_data.get('events', [])
|
||
weekday = day_data.get('weekday', '')
|
||
lines.append(f'─ <b>{weekday} {day_num}</b> ─')
|
||
if not events:
|
||
lines.append(_tr(lang, 'no_events') + '\n')
|
||
else:
|
||
for e in events:
|
||
lines.append(_format_event_line(e, lang))
|
||
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
|
||
|
||
|
||
# --- Schedule Functions ---
|
||
|
||
|
||
def get_schedule_day_keyboard(user_id: int | None = None):
|
||
lang = get_user_language(user_id) if user_id is not None else 'en'
|
||
full = SCHEDULE_WEEKDAYS_FULL.get(lang, SCHEDULE_WEEKDAYS_FULL['en'])
|
||
today = datetime.now()
|
||
tomorrow = today + timedelta(days=1)
|
||
today_label = _tr(lang, 'today')
|
||
tomorrow_label = _tr(lang, 'tomorrow')
|
||
short_keys = ['day_mon', 'day_tue', 'day_wed', 'day_thu', 'day_fri']
|
||
keyboard = [
|
||
[
|
||
InlineKeyboardButton(
|
||
f'{today_label} ({full[today.weekday()]})',
|
||
callback_data=f'schedule_day_{SCHEDULE_WEEKDAYS_CANONICAL[today.weekday()]}',
|
||
),
|
||
InlineKeyboardButton(
|
||
f'{tomorrow_label} ({full[tomorrow.weekday()]})',
|
||
callback_data=f'schedule_day_{SCHEDULE_WEEKDAYS_CANONICAL[tomorrow.weekday()]}',
|
||
),
|
||
],
|
||
[
|
||
InlineKeyboardButton(
|
||
_tr(lang, short_keys[0]), callback_data=f'schedule_day_{SCHEDULE_WEEKDAYS_CANONICAL[0]}'
|
||
),
|
||
InlineKeyboardButton(
|
||
_tr(lang, short_keys[1]), callback_data=f'schedule_day_{SCHEDULE_WEEKDAYS_CANONICAL[1]}'
|
||
),
|
||
InlineKeyboardButton(
|
||
_tr(lang, short_keys[2]), callback_data=f'schedule_day_{SCHEDULE_WEEKDAYS_CANONICAL[2]}'
|
||
),
|
||
],
|
||
[
|
||
InlineKeyboardButton(
|
||
_tr(lang, short_keys[3]), callback_data=f'schedule_day_{SCHEDULE_WEEKDAYS_CANONICAL[3]}'
|
||
),
|
||
InlineKeyboardButton(
|
||
_tr(lang, short_keys[4]), callback_data=f'schedule_day_{SCHEDULE_WEEKDAYS_CANONICAL[4]}'
|
||
),
|
||
],
|
||
]
|
||
return InlineKeyboardMarkup(keyboard)
|
||
|
||
|
||
def get_schedule_class_keyboard(classes: list):
|
||
rows = []
|
||
for i in range(0, len(classes), 4):
|
||
row = [InlineKeyboardButton(str(c), callback_data=f'schedule_class_{c}') for c in classes[i : i + 4]]
|
||
rows.append(row)
|
||
return InlineKeyboardMarkup(rows)
|
||
|
||
|
||
def _get_chat_class(chat, user_id) -> str | None:
|
||
"""Get stored schedule class for a private user or a group chat."""
|
||
if chat.type == 'private':
|
||
return redis_client.get(f'user:{user_id}:schedule_class')
|
||
return redis_client.get(f'chat:{chat.id}:schedule_class')
|
||
|
||
|
||
def _set_chat_class(chat, user_id, class_num) -> None:
|
||
"""Save schedule class for a private user or a group chat."""
|
||
if chat.type == 'private':
|
||
redis_client.set(f'user:{user_id}:schedule_class', str(class_num))
|
||
else:
|
||
redis_client.set(f'chat:{chat.id}:schedule_class', str(class_num))
|
||
|
||
|
||
def _parse_schedule_cell(cell_html: str) -> list:
|
||
"""Parse a single schedule cell, returning list of {subject, note, teacher}."""
|
||
lessons = []
|
||
parts = re.split(r'<hr\s*/?>', cell_html, flags=re.IGNORECASE)
|
||
for part in parts:
|
||
part = re.sub(r'<!--.*?-->', '', part, flags=re.DOTALL)
|
||
part = re.sub(r'<!--', '', part)
|
||
text = re.sub(r'<[^>]+>', '\n', part)
|
||
text = re.sub(r' ', ' ', text)
|
||
text = text.strip()
|
||
if not text:
|
||
continue
|
||
lines = [ln.strip() for ln in text.split('\n') if ln.strip()]
|
||
if not lines:
|
||
continue
|
||
subject = lines[0] if lines else ''
|
||
note = ''
|
||
teacher = ''
|
||
for line in lines[1:]:
|
||
if (
|
||
line in ('вебінар', 'онлайн', 'асинхрон', 'асинхронно')
|
||
or 'Вебінар' in line
|
||
or 'Асинхронн' in line
|
||
or 'вебінар' in line.lower()
|
||
or 'онлайн' in line.lower()
|
||
or 'асинхрон' in line.lower()
|
||
or 'А/С урок' in line
|
||
or 'Онлайн' in line
|
||
):
|
||
note = line
|
||
else:
|
||
teacher = line
|
||
lessons.append({'subject': subject, 'note': note, 'teacher': teacher})
|
||
return lessons
|
||
|
||
|
||
def _parse_schedule_html(table_html: str) -> dict:
|
||
"""Parse schedule HTML table into {classes: [...], weekdays: {day: [...]}}."""
|
||
result = {'classes': [], 'weekdays': {}, 'weekday_norm': {}}
|
||
row_matches = re.finditer(r'<tr([^>]*)>(.*?)</tr>', table_html, re.DOTALL)
|
||
|
||
current_day = None
|
||
classes_header = []
|
||
|
||
for match in row_matches:
|
||
tr_attrs, row = match.group(1), match.group(2)
|
||
|
||
if 'first-row' in tr_attrs:
|
||
cells = re.findall(r'<td[^>]*>(.*?)</td>', row, re.DOTALL)
|
||
if cells:
|
||
day_text = re.sub(r'<[^>]+>', '', cells[0]).strip()
|
||
current_day = day_text
|
||
result['weekdays'][current_day] = []
|
||
result['weekday_norm'][_norm_day(day_text)] = current_day
|
||
classes_header = []
|
||
for cell in cells[1:]:
|
||
cls = re.sub(r'<[^>]+>', '', cell).strip()
|
||
if cls:
|
||
try:
|
||
classes_header.append(int(cls))
|
||
except ValueError:
|
||
classes_header.append(cls)
|
||
result['classes'] = classes_header
|
||
continue
|
||
|
||
if 'second-row' in tr_attrs:
|
||
continue
|
||
|
||
if current_day is None:
|
||
continue
|
||
|
||
cells = re.findall(r'<td[^>]*>(.*?)</td>', row, re.DOTALL)
|
||
if len(cells) < 3:
|
||
continue
|
||
|
||
first_cell_text = re.sub(r'<[^>]+>', '', cells[0]).strip()
|
||
if not re.match(r'^\d+\.', first_cell_text) and (
|
||
'Позакласне' in first_cell_text or re.search(r'colspan\s*=\s*"?(\d+)"?', row)
|
||
):
|
||
continue
|
||
|
||
lesson_num_match = re.match(r'^(\d+)\.', first_cell_text)
|
||
lesson_num = lesson_num_match.group(1) if lesson_num_match else ''
|
||
time_text = re.sub(r'<[^>]+>', '', cells[1]).strip()
|
||
|
||
entry = {'lesson_num': lesson_num, 'time': time_text, 'classes': {}}
|
||
|
||
data_cells = cells[2:]
|
||
for idx, cell in enumerate(data_cells):
|
||
cls = classes_header[idx] if idx < len(classes_header) else idx
|
||
lessons = _parse_schedule_cell(cell)
|
||
entry['classes'][cls] = lessons
|
||
|
||
result['weekdays'][current_day].append(entry)
|
||
|
||
return result
|
||
|
||
|
||
async def fetch_schedule_data(phpsessid: str) -> dict | None:
|
||
logger.info('Fetching schedule 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(SCHEDULE_URL, wait_until='domcontentloaded')
|
||
await page.wait_for_selector('table.schedule-table', timeout=10000)
|
||
await page.wait_for_timeout(1500)
|
||
|
||
table_html = await page.evaluate("""
|
||
() => {
|
||
const t = document.querySelector('table.schedule-table');
|
||
return t ? t.outerHTML : null;
|
||
}
|
||
""")
|
||
if not table_html:
|
||
logger.error('table.schedule-table not found in DOM')
|
||
return None
|
||
|
||
debug_path = os.path.join(tempfile.gettempdir(), 'schedule_debug.html')
|
||
with contextlib.suppress(Exception), open(debug_path, 'w', encoding='utf-8') as f:
|
||
f.write(table_html)
|
||
|
||
data = _parse_schedule_html(table_html)
|
||
logger.info(list(data['weekdays'].keys()))
|
||
logger.info(f'Schedule parsed: {len(data["weekdays"])} days, classes={data["classes"]}')
|
||
|
||
return data
|
||
|
||
except Exception as e:
|
||
logger.error(f'Error parsing schedule: {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 schedule fetch: {e}')
|
||
return None
|
||
|
||
|
||
async def _get_schedule_data(context: ContextTypes.DEFAULT_TYPE) -> dict | None:
|
||
cached = context.user_data.get('schedule_cache')
|
||
now_ts = time.time()
|
||
if cached and (now_ts - cached.get('timestamp', 0)) < SCHEDULE_CACHE_TTL:
|
||
return cached['data']
|
||
phpsessid = redis_client.get(KEY_PHPSESSID)
|
||
if not phpsessid:
|
||
return None
|
||
data = await fetch_schedule_data(phpsessid)
|
||
if data:
|
||
context.user_data['schedule_cache'] = {'data': data, 'timestamp': now_ts}
|
||
return data
|
||
|
||
|
||
def format_schedule_day(data: dict, weekday: str, class_num, lang: str = 'en') -> str:
|
||
days = data.get('weekdays', {})
|
||
norm_map = data.get('weekday_norm') or {_norm_day(k): k for k in days}
|
||
norm = _norm_day(weekday)
|
||
day_key = norm_map.get(norm)
|
||
if day_key is None:
|
||
logger.warning(f'Schedule weekday not matched: {weekday!r}')
|
||
entries = days.get(day_key, []) if day_key is not None else []
|
||
try:
|
||
class_key = int(class_num)
|
||
except (TypeError, ValueError):
|
||
class_key = class_num
|
||
try:
|
||
idx = _SCHEDULE_WEEKDAYS_CANONICAL_NORM.index(norm)
|
||
display_weekday = SCHEDULE_WEEKDAYS_FULL.get(lang, SCHEDULE_WEEKDAYS_FULL['en'])[idx]
|
||
except ValueError:
|
||
logger.warning(f'Schedule weekday not in canonical list: {weekday!r}')
|
||
display_weekday = weekday
|
||
lines = [_tr(lang, 'schedule_title', weekday=display_weekday, class_num=class_num), '─' * 20]
|
||
|
||
if not entries:
|
||
lines.append(_tr(lang, 'no_lessons'))
|
||
else:
|
||
for entry in entries:
|
||
time_str = entry.get('time', '').replace(' ', '–')
|
||
lines.append(f'\n<b>{entry["lesson_num"]}. </b>({time_str})')
|
||
|
||
lessons = entry.get('classes', {}).get(class_key, [])
|
||
if not lessons:
|
||
lines.append(f' {_tr(lang, "free_period")}')
|
||
else:
|
||
for i, lesson in enumerate(lessons):
|
||
note_part = f' ({escape(lesson["note"])})' if lesson.get('note') else ''
|
||
lines.append(f' 📌 <b>{escape(lesson["subject"])}</b>{note_part}')
|
||
if lesson.get('teacher'):
|
||
lines.append(f' {escape(lesson["teacher"])}')
|
||
if i < len(lessons) - 1:
|
||
lines.append(' ──')
|
||
|
||
lines.append(f'\n🔗 {SCHEDULE_URL}')
|
||
return '\n'.join(lines)
|
||
|
||
|
||
# --- 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(chat, user.id, 'welcome', name=escape(user.first_name))
|
||
|
||
if user.id == ADMIN_ID and chat.type == 'private':
|
||
msg += t_chat(chat, user.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 = update.effective_chat
|
||
msg = t_chat(chat, user_id, 'help_title') + t_chat(chat, user_id, 'help_commands')
|
||
|
||
if user_id == ADMIN_ID and update.effective_chat.type == 'private':
|
||
msg += t_chat(chat, user_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
|
||
lang = resolve_lang(chat, user.id)
|
||
if not (is_whitelisted(user.id) or await is_group_admin(update, context)):
|
||
await update.message.reply_text(_tr(lang, 'access_denied')) # Or specific "admin only" message
|
||
return
|
||
|
||
redis_client.srem(KEY_SUBSCRIBERS, chat.id)
|
||
await update.message.reply_text(_tr(lang, '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(chat, user.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(
|
||
t(user.id, 'diary_title'), parse_mode='HTML', reply_markup=get_diary_keyboard(user.id)
|
||
)
|
||
|
||
|
||
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(t(user_id, 'access_denied'))
|
||
return
|
||
|
||
data = query.data
|
||
if data == 'diary_refresh':
|
||
context.user_data.pop('diary_cache', None)
|
||
await query.edit_message_text(t(user_id, 'loading_diary'))
|
||
diary_data = await _get_diary_data(context)
|
||
if not diary_data:
|
||
await query.edit_message_text(t(user_id, 'diary_no_session'))
|
||
return
|
||
await query.edit_message_text(
|
||
t(user_id, 'diary_title'), parse_mode='HTML', reply_markup=get_diary_keyboard(user_id)
|
||
)
|
||
return
|
||
|
||
await query.edit_message_text(t(user_id, 'loading_diary'))
|
||
diary_data = await _get_diary_data(context)
|
||
if not diary_data:
|
||
await query.edit_message_text(t(user_id, 'diary_load_failed'))
|
||
return
|
||
|
||
today = datetime.now()
|
||
lang = get_user_language(user_id)
|
||
if data == 'diary_today':
|
||
text = format_diary_day(diary_data, today.day, lang=lang)
|
||
elif data == 'diary_tomorrow':
|
||
tomorrow = today + timedelta(days=1)
|
||
if tomorrow.day < today.day:
|
||
text = t(user_id, 'diary_next_month')
|
||
else:
|
||
text = format_diary_day(diary_data, tomorrow.day, lang=lang)
|
||
elif data == 'diary_week':
|
||
text = format_diary_week(diary_data, today, lang=lang)
|
||
elif data == 'diary_month':
|
||
text = format_diary_month(diary_data, lang=lang)
|
||
else:
|
||
return
|
||
|
||
if len(text) > 4096:
|
||
text = text[:4090] + t(user_id, 'truncation')
|
||
|
||
await query.edit_message_text(text, parse_mode='HTML', reply_markup=get_diary_keyboard(user_id))
|
||
|
||
|
||
# --- Schedule Command Handlers ---
|
||
|
||
|
||
async def _schedule_text_for(context, chat, user_id, weekday) -> tuple:
|
||
"""Load schedule data and render one weekday for the stored class."""
|
||
schedule_data = await _get_schedule_data(context)
|
||
if not schedule_data:
|
||
return None, t(user_id, 'schedule_load_failed')
|
||
class_num = _get_chat_class(chat, user_id)
|
||
if not class_num:
|
||
return None, t(user_id, 'class_not_set')
|
||
text = format_schedule_day(schedule_data, weekday, class_num, lang=get_user_language(user_id))
|
||
if len(text) > 4096:
|
||
text = text[:4090] + t(user_id, 'truncation')
|
||
return text, None
|
||
|
||
|
||
async def _show_class_picker(update, context) -> bool:
|
||
"""Fetch classes from schedule and render the class-picker keyboard."""
|
||
user_id = update.effective_user.id
|
||
schedule_data = await _get_schedule_data(context)
|
||
if not schedule_data:
|
||
await update.message.reply_text(t(user_id, 'schedule_load_failed'))
|
||
return False
|
||
classes = schedule_data.get('classes', [])
|
||
if not classes:
|
||
await update.message.reply_text(t(user_id, 'class_not_found'))
|
||
return False
|
||
await update.message.reply_text(
|
||
t(user_id, 'select_class'),
|
||
parse_mode='HTML',
|
||
reply_markup=get_schedule_class_keyboard(classes),
|
||
)
|
||
return True
|
||
|
||
|
||
async def schedule_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
|
||
|
||
chat = update.effective_chat
|
||
stored_class = _get_chat_class(chat, user.id)
|
||
|
||
if not stored_class:
|
||
if chat.type != 'private' and not await is_group_admin(update, context):
|
||
await update.message.reply_text(t(user.id, 'admin_class_not_set'))
|
||
return
|
||
await _show_class_picker(update, context)
|
||
return
|
||
|
||
await update.message.reply_text(t(user.id, 'loading_schedule'))
|
||
today = datetime.now()
|
||
weekday = SCHEDULE_WEEKDAYS_CANONICAL[today.weekday()]
|
||
text, err = await _schedule_text_for(context, chat, user.id, weekday)
|
||
if err:
|
||
await update.message.reply_text(err)
|
||
return
|
||
await update.message.reply_text(text, parse_mode='HTML', reply_markup=get_schedule_day_keyboard(user.id))
|
||
|
||
|
||
async def setclass_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
|
||
|
||
chat = update.effective_chat
|
||
if chat.type != 'private' and not await is_group_admin(update, context):
|
||
await update.message.reply_text(t(user.id, 'admin_only'))
|
||
return
|
||
|
||
if context.args:
|
||
try:
|
||
class_num = int(context.args[0])
|
||
except ValueError:
|
||
await update.message.reply_text(t(user.id, 'invalid_class_num'))
|
||
return
|
||
_set_chat_class(chat, user.id, class_num)
|
||
await update.message.reply_text(t(user.id, 'class_saved', class_num=class_num), parse_mode='HTML')
|
||
return
|
||
|
||
await _show_class_picker(update, context)
|
||
|
||
|
||
async def schedule_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(t(user_id, 'access_denied'))
|
||
return
|
||
|
||
data = query.data
|
||
|
||
if data.startswith('schedule_class_'):
|
||
class_num_str = data[len('schedule_class_') :]
|
||
try:
|
||
class_num = int(class_num_str)
|
||
except ValueError:
|
||
await query.edit_message_text(t(user_id, 'invalid_class_num'))
|
||
return
|
||
|
||
chat = update.effective_chat
|
||
if chat.type != 'private' and not await is_group_admin(update, context):
|
||
await query.edit_message_text(t(user_id, 'admin_only_msg'))
|
||
return
|
||
|
||
_set_chat_class(chat, user_id, class_num)
|
||
await query.edit_message_text(t(user_id, 'loading_schedule'))
|
||
|
||
today = datetime.now()
|
||
weekday = SCHEDULE_WEEKDAYS_CANONICAL[today.weekday()]
|
||
text, err = await _schedule_text_for(context, chat, user_id, weekday)
|
||
if err:
|
||
await query.edit_message_text(err)
|
||
return
|
||
await query.edit_message_text(text, parse_mode='HTML', reply_markup=get_schedule_day_keyboard(user_id))
|
||
return
|
||
|
||
if data.startswith('schedule_day_'):
|
||
weekday = data[len('schedule_day_') :]
|
||
try:
|
||
_SCHEDULE_WEEKDAYS_CANONICAL_NORM.index(_norm_day(weekday))
|
||
except ValueError:
|
||
logger.warning(f'Schedule callback weekday not in canonical list: {weekday!r}')
|
||
await query.edit_message_text(t(user_id, 'loading_schedule'))
|
||
text, err = await _schedule_text_for(context, query.message.chat, user_id, weekday)
|
||
if err:
|
||
await query.edit_message_text(err)
|
||
return
|
||
await query.edit_message_text(text, parse_mode='HTML', reply_markup=get_schedule_day_keyboard(user_id))
|
||
return
|
||
|
||
if data == 'schedule_refresh':
|
||
context.user_data.pop('schedule_cache', None)
|
||
await query.edit_message_text(t(user_id, 'loading_schedule'))
|
||
today = datetime.now()
|
||
weekday = SCHEDULE_WEEKDAYS_CANONICAL[today.weekday()]
|
||
text, err = await _schedule_text_for(context, query.message.chat, user_id, weekday)
|
||
if err:
|
||
await query.edit_message_text(err)
|
||
return
|
||
await query.edit_message_text(text, parse_mode='HTML', reply_markup=get_schedule_day_keyboard(user_id))
|
||
return
|
||
|
||
|
||
# --- 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
|
||
chat = update.effective_chat
|
||
data = query.data
|
||
|
||
if data.startswith('lang_'):
|
||
lang = data.split('_')[1]
|
||
is_private = chat is None or getattr(chat, 'type', 'private') in (ChatType.PRIVATE, 'private')
|
||
if is_private:
|
||
set_user_language(user_id, lang)
|
||
await query.answer()
|
||
await query.edit_message_text(
|
||
t(user_id, 'language_changed', lang=LANG_NAME_MAP.get(lang, lang)), parse_mode='HTML'
|
||
)
|
||
else:
|
||
if not await is_group_admin(update, context):
|
||
await query.answer(t_chat(chat, user_id, 'admin_only'), show_alert=True)
|
||
return
|
||
set_chat_language(chat.id, lang)
|
||
await query.answer()
|
||
await query.edit_message_text(
|
||
_tr(lang, 'language_changed', lang=LANG_NAME_MAP.get(lang, lang)), 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 NO_WEBINAR_MARKER 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 NO_WEBINAR_MARKER 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_chat(
|
||
int(sub_id), int(sub_id), 'webinar_item', name=escape(w['name']), url=escape(w['url'])
|
||
)
|
||
for w in new_webinars
|
||
]
|
||
)
|
||
message = t_chat(int(sub_id), 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))
|
||
app.add_handler(CommandHandler('schedule', schedule_command))
|
||
app.add_handler(CommandHandler('setclass', setclass_command))
|
||
|
||
# Callback handlers - diary/schedule first, then language selection, then admin panel
|
||
app.add_handler(CallbackQueryHandler(diary_callback, pattern='^diary_'))
|
||
app.add_handler(CallbackQueryHandler(schedule_callback, pattern='^schedule_'))
|
||
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()
|