diff --git a/edu_master/webinar-checker/checker.py b/edu_master/webinar-checker/checker.py index e32904f..80b3aca 100644 --- a/edu_master/webinar-checker/checker.py +++ b/edu_master/webinar-checker/checker.py @@ -6,6 +6,7 @@ import re import tempfile import time from datetime import datetime, timedelta +from html import escape import redis from playwright.async_api import async_playwright @@ -55,6 +56,9 @@ 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) @@ -83,7 +87,7 @@ TRANSLATIONS = { 'usage_adduser': 'Использование: /adduser [user_id]', 'usage_removeuser': 'Использование: /removeuser [user_id]', 'whitelist_enabled': '✅ Белый список включен', - 'whitelist_disabled': '✅ Белый список отключен', + 'whitelist_disabled': '🚫 Белый список отключен', 'whitelist_title': '📋 Белый список:\n', 'subscribers_title': '👥 Подписчики:\n', 'empty': 'Пусто', @@ -99,12 +103,44 @@ TRANSLATIONS = { 'webinar_found': '🎓 Новый вебинар!\n\n', 'webinar_item': '📌 {name}\n🔗 https://edu.edu.vn.ua{url}', 'select_language': '🌐 Выберите язык / Оберіть мову / Select language:', - 'language_changed': '✅ Язык изменен на русский', + 'language_changed': '✅ Язык изменен на {lang}', 'flag_ru': '🇷🇺 Русский', 'flag_uk': '🇺🇦 Українська', 'flag_en': '🇬🇧 English', 'history_cleared': '✅ История вебинаров очищена', 'history_clear_failed': '❌ Ошибка при очистке истории', + 'today': '📌 Сегодня', + 'tomorrow': '📌 Завтра', + 'week': '📅 Эта неделя', + 'month': '📅 Весь месяц', + 'no_events': 'Нет событий', + 'no_lessons': 'Нет уроков', + 'free_period': 'Свободно', + 'unsubscribed': '🔕 Вы отписались от уведомлений.', + 'diary_title': '📅 Дневник — выберите период:', + 'diary_week_title': '📅 Неделя {start} – {end}', + 'schedule_title': '📅 {weekday} — {class_num} класс', + 'loading_diary': '🔄 Загружаю дневник...', + 'diary_load_failed': '❌ Не удалось загрузить дневник.', + 'diary_no_session': '❌ Не удалось загрузить дневник. Нет сессии или ошибка.', + 'diary_next_month': '❌ Данные за следующий месяц недоступны. Перейдите на сайт.', + 'schedule_load_failed': '❌ Не удалось загрузить расписание.', + 'class_not_set': '❌ Класс не настроен. Используйте /setclass.', + 'class_not_found': '❌ Классы не найдены в расписании.', + 'select_class': '🎒 Выберите класс — сохранится и больше не спросится:', + 'admin_class_not_set': '⚠️ Администратор еще не настроил класс для этой группы. Используйте /setclass 11', + 'loading_schedule': '🔄 Загружаю расписание...', + 'invalid_class_num': '❌ Неверный номер класса.', + 'class_saved': '✅ Класс {class_num} сохранен.', + '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Ви підписані на сповіщення.", @@ -122,7 +158,7 @@ TRANSLATIONS = { 'usage_adduser': 'Використання: /adduser [user_id]', 'usage_removeuser': 'Використання: /removeuser [user_id]', 'whitelist_enabled': '✅ Білий список увімкнено', - 'whitelist_disabled': '✅ Білий список вимкнено', + 'whitelist_disabled': '🚫 Білий список вимкнено', 'whitelist_title': '📋 Білий список:\n', 'subscribers_title': '👥 Підписники:\n', 'empty': 'Порожньо', @@ -138,12 +174,44 @@ TRANSLATIONS = { 'webinar_found': '🎓 Новий вебінар!\n\n', 'webinar_item': '📌 {name}\n🔗 https://edu.edu.vn.ua{url}', 'select_language': '🌐 Виберіть мову / Выберите язык / Select language:', - 'language_changed': '✅ Мову змінено на українську', + 'language_changed': '✅ Мову змінено на {lang}', 'flag_ru': '🇷🇺 Русский', 'flag_uk': '🇺🇦 Українська', 'flag_en': '🇬🇧 English', 'history_cleared': '✅ Історія вебінарів очищена', 'history_clear_failed': '❌ Помилка при очищенні історії', + 'today': '📌 Сьогодні', + 'tomorrow': '📌 Завтра', + 'week': '📅 Цей тиждень', + 'month': '📅 Весь місяць', + 'no_events': 'Немає подій', + 'no_lessons': 'Немає уроків', + 'free_period': 'Вільно', + 'unsubscribed': '🔕 Ви відписалися від сповіщень.', + 'diary_title': '📅 Щоденник — виберіть період:', + 'diary_week_title': '📅 Тиждень {start} – {end}', + 'schedule_title': '📅 {weekday} — {class_num} клас', + 'loading_diary': '🔄 Завантажую щоденник...', + 'diary_load_failed': '❌ Не вдалося завантажити щоденник.', + 'diary_no_session': '❌ Не вдалося завантажити щоденник. Немає сесії або помилка.', + 'diary_next_month': '❌ Дані за наступний місяць недоступні. Перейдіть на сайт.', + 'schedule_load_failed': '❌ Не вдалося завантажити розклад.', + 'class_not_set': '❌ Клас не налаштовано. Використайте /setclass.', + 'class_not_found': '❌ Класи не знайдені в розкладі.', + 'select_class': '🎒 Оберіть клас — збережеться і більше не питатиметься:', + 'admin_class_not_set': '⚠️ Адміністратор ще не налаштував клас для цієї групи. Використайте /setclass 11', + 'loading_schedule': '🔄 Завантажую розклад...', + 'invalid_class_num': '❌ Невірний номер класу.', + 'class_saved': '✅ Клас {class_num} збережено.', + '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.', @@ -161,7 +229,7 @@ TRANSLATIONS = { 'usage_adduser': 'Usage: /adduser [user_id]', 'usage_removeuser': 'Usage: /removeuser [user_id]', 'whitelist_enabled': '✅ Whitelist Enabled', - 'whitelist_disabled': '✅ Whitelist Disabled', + 'whitelist_disabled': '🚫 Whitelist Disabled', 'whitelist_title': '📋 Whitelist:\n', 'subscribers_title': '👥 Subscribers:\n', 'empty': 'Empty', @@ -177,22 +245,57 @@ TRANSLATIONS = { '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', + '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', + 'free_period': 'Free', + 'unsubscribed': '🔕 You have unsubscribed from notifications.', + 'diary_title': '📅 Diary — choose a period:', + 'diary_week_title': '📅 Week {start} – {end}', + 'schedule_title': '📅 {weekday} — {class_num} class', + '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': '🎒 Choose a class — 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 {class_num} 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: Ukrainian.""" + """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 'uk' + return lang if lang in ['ru', 'uk', 'en'] else 'en' def set_user_language(user_id: int, lang: str): @@ -203,23 +306,87 @@ def set_user_language(user_id: int, lang: str): def t(user_id: int, key: str, **kwargs) -> str: - """Translate message for user with optional formatting.""" + """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 = TRANSLATIONS.get(lang, TRANSLATIONS['uk']).get(key, 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_language_keyboard(): +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('🇷🇺 Русский', callback_data='lang_ru'), - InlineKeyboardButton('🇺🇦 Українська', callback_data='lang_uk'), + InlineKeyboardButton(_tr(lang, 'flag_ru'), callback_data='lang_ru'), + InlineKeyboardButton(_tr(lang, 'flag_uk'), callback_data='lang_uk'), ], [ - InlineKeyboardButton('🇬🇧 English', callback_data='lang_en'), + InlineKeyboardButton(_tr(lang, 'flag_en'), callback_data='lang_en'), ], ] return InlineKeyboardMarkup(keyboard) @@ -272,38 +439,51 @@ def get_admin_keyboard(user_id: int): # --- Diary Functions --- -SCHEDULE_WEEKDAYS_FULL = ['Понеділок', 'Вівторок', 'Середа', 'Четвер', "П'ятниця", 'Субота', 'Неділя'] +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 -DIARY_MONTH_NAMES = [ - '', - 'Січня', - 'Лютого', - 'Березня', - 'Квітня', - 'Травня', - 'Червня', - 'Липня', - 'Серпня', - 'Вересня', - 'Жовтня', - 'Листопада', - 'Грудня', -] -DIARY_WEEKDAYS_SHORT = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Нд'] +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() -def get_diary_keyboard(): +_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.day}.{today.month:02d})', callback_data='diary_today'), - InlineKeyboardButton('📌 Завтра', callback_data='diary_tomorrow'), + InlineKeyboardButton(f'{today_label} ({today.day}.{today.month:02d})', callback_data='diary_today'), + InlineKeyboardButton(tomorrow_label, callback_data='diary_tomorrow'), ], [ - InlineKeyboardButton('📅 Цей тиждень', callback_data='diary_week'), - InlineKeyboardButton('📅 Весь місяць', callback_data='diary_month'), + InlineKeyboardButton(week_label, callback_data='diary_week'), + InlineKeyboardButton(month_label, callback_data='diary_month'), ], ] return InlineKeyboardMarkup(keyboard) @@ -416,41 +596,44 @@ def _parse_diary_month(text: str) -> str: return text.strip() -def format_diary_day(data: dict, day_num: int) -> str: +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'📅 {day_num} {month_str}', '─' * 18] if not day_data or not day_data.get('events'): - lines.append('Немає подій') + lines.append(_tr(lang, 'no_events')) else: for e in day_data['events']: - lines.append(f'📌 {e}') + lines.append(f'📌 {escape(e)}') lines.append(f'\n🔗 {DIARY_URL}') return '\n'.join(lines) -def format_diary_week(data: dict, today: datetime) -> str: +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()) sunday = monday + timedelta(days=6) - lines = [f'📅 Тиждень {monday.day}.{monday.month} – {sunday.day}.{sunday.month}\n'] + start = f'{monday.day}.{monday.month}' + end = f'{sunday.day}.{sunday.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(7): d = monday + timedelta(days=i) day_data = days.get(str(d.day)) - lines.append(f'─ {DIARY_WEEKDAYS_SHORT[i]} {d.day}.{d.month} ─') + lines.append(f'─ {short[i]} {d.day}.{d.month} ─') if not day_data or not day_data.get('events'): - lines.append('Немає подій\n') + lines.append(_tr(lang, 'no_events') + '\n') else: for e in day_data['events']: - lines.append(f'📌 {e}') + lines.append(f'📌 {escape(e)}') lines.append('') lines.append(f'🔗 {DIARY_URL}') return '\n'.join(lines) -def format_diary_month(data: dict) -> str: +def format_diary_month(data: dict, lang: str = 'en') -> str: days = data.get('days', {}) month_str = _parse_diary_month(data.get('monthFullText', '')) lines = [f'📅 {month_str}\n'] @@ -460,10 +643,10 @@ def format_diary_month(data: dict) -> str: weekday = day_data.get('weekday', '') lines.append(f'─ {weekday} {day_num} ─') if not events: - lines.append('Немає подій\n') + lines.append(_tr(lang, 'no_events') + '\n') else: for e in events: - lines.append(f'📌 {e}') + lines.append(f'📌 {escape(e)}') lines.append('') lines.append(f'🔗 {DIARY_URL}') return '\n'.join(lines) @@ -486,26 +669,37 @@ async def _get_diary_data(context: ContextTypes.DEFAULT_TYPE) -> dict | None: # --- Schedule Functions --- -def get_schedule_day_keyboard(): +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', 'day_sat', 'day_sun'] keyboard = [ [ - InlineKeyboardButton(f'📌 Сьогодні ({SCHEDULE_WEEKDAYS_FULL[today.weekday()]})', callback_data=f'schedule_day_{SCHEDULE_WEEKDAYS_FULL[today.weekday()]}'), - InlineKeyboardButton(f'📌 Завтра ({SCHEDULE_WEEKDAYS_FULL[tomorrow.weekday()]})', callback_data=f'schedule_day_{SCHEDULE_WEEKDAYS_FULL[tomorrow.weekday()]}'), + 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('Пн', callback_data=f'schedule_day_{SCHEDULE_WEEKDAYS_FULL[0]}'), - InlineKeyboardButton('Вт', callback_data=f'schedule_day_{SCHEDULE_WEEKDAYS_FULL[1]}'), - InlineKeyboardButton('Ср', callback_data=f'schedule_day_{SCHEDULE_WEEKDAYS_FULL[2]}'), + 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('Чт', callback_data=f'schedule_day_{SCHEDULE_WEEKDAYS_FULL[3]}'), - InlineKeyboardButton('Пт', callback_data=f'schedule_day_{SCHEDULE_WEEKDAYS_FULL[4]}'), - InlineKeyboardButton('Сб', callback_data=f'schedule_day_{SCHEDULE_WEEKDAYS_FULL[5]}'), + 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]}'), + InlineKeyboardButton(_tr(lang, short_keys[5]), callback_data=f'schedule_day_{SCHEDULE_WEEKDAYS_CANONICAL[5]}'), ], [ - InlineKeyboardButton('Нд', callback_data=f'schedule_day_{SCHEDULE_WEEKDAYS_FULL[6]}'), + InlineKeyboardButton(_tr(lang, short_keys[6]), callback_data=f'schedule_day_{SCHEDULE_WEEKDAYS_CANONICAL[6]}'), ], ] return InlineKeyboardMarkup(keyboard) @@ -563,7 +757,7 @@ def _parse_schedule_cell(cell_html: str) -> list: def _parse_schedule_html(table_html: str) -> dict: """Parse schedule HTML table into {classes: [...], weekdays: {day: [...]}}.""" - result = {'classes': [], 'weekdays': {}} + result = {'classes': [], 'weekdays': {}, 'weekday_norm': {}} row_matches = re.finditer(r'