diff --git a/edu_master/.env.example b/edu_master/.env.example index e0c4a1e..ec9c514 100644 --- a/edu_master/.env.example +++ b/edu_master/.env.example @@ -9,5 +9,6 @@ WEBINAR_CHECK_INTERVAL=60 REDIS_HOST=redis REDIS_PORT=6379 PLAYWRIGHT_WS=ws://playwright-service:3000/ws +TZ=Europe/Kyiv WEBINAR_TELEGRAM_TOKEN=your_telegram_bot_token_here WEBINAR_ADMIN_ID=123456789 diff --git a/edu_master/k8s/active b/edu_master/k8s/active new file mode 100644 index 0000000..e69de29 diff --git a/edu_master/k8s/restore-seed-job.yaml.example b/edu_master/k8s/restore-seed-job.yaml.example index 08de33d..c85b078 100644 --- a/edu_master/k8s/restore-seed-job.yaml.example +++ b/edu_master/k8s/restore-seed-job.yaml.example @@ -1,17 +1,17 @@ -# Разовый Job для переноса состояния redis из docker compose в k8s (maintenance window). -# Файл .example -> deploy-пайплайн его не применяет (маска *.example.yaml). +# One-time Job to migrate redis state from docker compose to k8s (maintenance window). +# The .example file is not applied by the deploy pipeline (mask *.example.yaml). # # Runbook: -# 1. docker compose -f /edu_master/compose.yaml stop # SIGTERM -> redis сбросит dump.rdb +# 1. docker compose -f /edu_master/compose.yaml stop # SIGTERM -> redis will flush dump.rdb # 2. docker run --rm -v edu_master_redis-data:/data \ # -v /tmp/edu-master-backup:/backup \ # redis:alpine sh -c "cp /data/dump.rdb /backup/ && ls -la /backup" # 3. kubectl apply -f edu_master/k8s/namespace.yaml -# 4. kubectl apply -f <только PVC из redis.yaml> # seed строго ДО старта redis-пода +# 4. kubectl apply -f # seed must come BEFORE redis pod starts # 5. kubectl apply -f edu_master/k8s/restore-seed-job.yaml.example # kubectl wait --for=condition=complete job/redis-restore-seed -n edu-master --timeout=120s # 6. kubectl delete job redis-restore-seed -n edu-master -# 7. kubectl apply -f edu_master/k8s/ -R # остальные манифесты +# 7. kubectl apply -f edu_master/k8s/ -R # apply remaining manifests apiVersion: batch/v1 kind: Job metadata: @@ -47,4 +47,4 @@ spec: - name: backup hostPath: path: /tmp/edu-master-backup - type: DirectoryOrCreate \ No newline at end of file + type: DirectoryOrCreate diff --git a/edu_master/k8s/secrets.yaml.example b/edu_master/k8s/secrets.yaml.example index 5887bbe..ac30818 100644 --- a/edu_master/k8s/secrets.yaml.example +++ b/edu_master/k8s/secrets.yaml.example @@ -5,7 +5,7 @@ metadata: namespace: edu-master type: Opaque stringData: - # PHPSESSID keeper + # Session keeper credentials KEEPER_LOGIN: "" KEEPER_PASSWORD: "" KEEPER_INTERVAL: "10" @@ -23,4 +23,5 @@ stringData: WEBINAR_CHECK_INTERVAL: "60" # Database REDIS_HOST: "redis" - REDIS_PORT: "6379" \ No newline at end of file + REDIS_PORT: "6379" + TZ: "Europe/Kyiv" diff --git a/edu_master/k8s/webinar-checker.yaml b/edu_master/k8s/webinar-checker.yaml index 0813cc3..7d1324f 100644 --- a/edu_master/k8s/webinar-checker.yaml +++ b/edu_master/k8s/webinar-checker.yaml @@ -15,9 +15,9 @@ spec: labels: app: edu-master-webinar-checker spec: + # Enforces dependency order like compose depends_on: + # redis healthy -> session-keeper healthy (EXISTS EDU_PHPSESSID) -> playwright started initContainers: - # Держит порядок зависимостей как в compose depends_on: - # redis healthy -> session-keeper healthy (EXISTS EDU_PHPSESSID) -> playwright started - name: wait-deps image: redis:alpine command: @@ -50,6 +50,9 @@ spec: envFrom: - secretRef: name: edu-master-secrets + env: + - name: TZ + value: "Europe/Kyiv" resources: requests: cpu: 25m diff --git a/edu_master/webinar-checker/checker.py b/edu_master/webinar-checker/checker.py index e32904f..dd2db88 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,45 @@ 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': 'Нет уроков', + 'time_unknown': 'неизвестно', + '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 +159,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 +175,45 @@ 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': 'Немає уроків', + 'time_unknown': 'невідомо', + '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 +231,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 +247,58 @@ 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', + 'time_unknown': 'unknown', + '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 +309,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,45 +442,61 @@ 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) def _parse_calendar_html(table_html: str) -> tuple: - """Parse calendar HTML table into (month_text, {day_num: {weekday, events}}).""" + """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']*>(.*?)', table_html, re.DOTALL) @@ -342,22 +528,72 @@ def _parse_calendar_html(table_html: str) -> tuple: continue day_num = dm.group(1) - # Extract events: title attribute (full name) of ALL tags inside the cell + # Extract events: title attribute (full name) of ALL 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']*>(.*?)', 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) + 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, 'events': events} + 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: @@ -390,6 +626,16 @@ async def fetch_diary_data(phpsessid: str) -> dict | None: 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)}' ) @@ -416,54 +662,87 @@ def _parse_diary_month(text: str) -> str: return text.strip() -def format_diary_day(data: dict, day_num: int) -> str: +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'📅 {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(_format_event_line(e, lang)) 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'] - for i in range(7): + 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'─ {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(_format_event_line(e, lang)) 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'] 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'─ {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(_format_event_line(e, lang)) lines.append('') lines.append(f'🔗 {DIARY_URL}') return '\n'.join(lines) @@ -486,26 +765,43 @@ 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'] 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('Нд', callback_data=f'schedule_day_{SCHEDULE_WEEKDAYS_FULL[6]}'), + 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) @@ -514,7 +810,7 @@ def get_schedule_day_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]] + row = [InlineKeyboardButton(str(c), callback_data=f'schedule_class_{c}') for c in classes[i : i + 4]] rows.append(row) return InlineKeyboardMarkup(rows) @@ -553,7 +849,16 @@ def _parse_schedule_cell(cell_html: str) -> list: 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: + 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 @@ -563,7 +868,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']*)>(.*?)', table_html, re.DOTALL) current_day = None @@ -578,6 +883,7 @@ def _parse_schedule_html(table_html: str) -> dict: 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() @@ -654,6 +960,7 @@ async def fetch_schedule_data(phpsessid: str) -> dict | None: 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 @@ -685,17 +992,28 @@ async def _get_schedule_data(context: ContextTypes.DEFAULT_TYPE) -> dict | None: return data -def format_schedule_day(data: dict, weekday: str, class_num) -> str: +def format_schedule_day(data: dict, weekday: str, class_num, lang: str = 'en') -> str: days = data.get('weekdays', {}) - entries = days.get(weekday, []) + 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 - lines = [f'📅 {weekday} — {class_num} клас', '─' * 20] + 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('Немає уроків') + lines.append(_tr(lang, 'no_lessons')) else: for entry in entries: time_str = entry.get('time', '').replace(' ', '–') @@ -703,13 +1021,13 @@ def format_schedule_day(data: dict, weekday: str, class_num) -> str: lessons = entry.get('classes', {}).get(class_key, []) if not lessons: - lines.append(' Вільно') + lines.append(f' {_tr(lang, "free_period")}') else: for i, lesson in enumerate(lessons): - note_part = f' ({lesson["note"]})' if lesson.get('note') else '' - lines.append(f' 📌 {lesson["subject"]}{note_part}') + note_part = f' ({escape(lesson["note"])})' if lesson.get('note') else '' + lines.append(f' 📌 {escape(lesson["subject"])}{note_part}') if lesson.get('teacher'): - lines.append(f' {lesson["teacher"]}') + lines.append(f' {escape(lesson["teacher"])}') if i < len(lessons) - 1: lines.append(' ──') @@ -734,10 +1052,10 @@ async def start(update: Update, _context: ContextTypes.DEFAULT_TYPE): # Add to subscribers (Chat ID!) redis_client.sadd(KEY_SUBSCRIBERS, chat.id) - msg = t(chat.id, 'welcome', name=user.first_name) + msg = t_chat(chat, user.id, 'welcome', name=escape(user.first_name)) if user.id == ADMIN_ID and chat.type == 'private': - msg += t(chat.id, 'welcome_admin') + 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') @@ -746,11 +1064,11 @@ async def start(update: Update, _context: ContextTypes.DEFAULT_TYPE): 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') + 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_id, 'help_admin') + 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') @@ -762,19 +1080,13 @@ async def stop_command(update: Update, context: ContextTypes.DEFAULT_TYPE): 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(t(chat.id, 'access_denied')) # Or specific "admin only" message + 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( - t(chat.id, 'whitelist_disabled') - .replace(' whitelist', ' notifications') - .replace('Білий список', 'Сповіщення') - .replace('Белый список', 'Уведомления') - if chat.id - else 'Unsubscribed' - ) + await update.message.reply_text(_tr(lang, 'unsubscribed')) async def language_command(update: Update, context: ContextTypes.DEFAULT_TYPE): @@ -787,7 +1099,7 @@ async def language_command(update: Update, context: ContextTypes.DEFAULT_TYPE): return await update.message.reply_text( - t(chat.id, 'select_language'), parse_mode='HTML', reply_markup=get_language_keyboard() + t_chat(chat, user.id, 'select_language'), parse_mode='HTML', reply_markup=get_language_keyboard() ) @@ -860,7 +1172,7 @@ async def diary_command(update: Update, _context: ContextTypes.DEFAULT_TYPE): await update.message.reply_text(t(user.id, 'access_denied')) return await update.message.reply_text( - '📅 Щоденник — виберіть період:', parse_mode='HTML', reply_markup=get_diary_keyboard() + t(user.id, 'diary_title'), parse_mode='HTML', reply_markup=get_diary_keyboard(user.id) ) @@ -870,48 +1182,49 @@ async def diary_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): await query.answer() if user_id != ADMIN_ID and not is_whitelisted(user_id): - await query.edit_message_text('⛔ Доступ заборонено.') + 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('🔄 Завантажую щоденник...') + 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('❌ Не вдалося завантажити щоденник. Немає сесії або помилка.') + await query.edit_message_text(t(user_id, 'diary_no_session')) return await query.edit_message_text( - '📅 Щоденник — виберіть період:', parse_mode='HTML', reply_markup=get_diary_keyboard() + t(user_id, 'diary_title'), parse_mode='HTML', reply_markup=get_diary_keyboard(user_id) ) return - await query.edit_message_text('🔄 Завантажую щоденник...') + 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('❌ Не вдалося завантажити щоденник.') + 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) + 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 = '❌ Дані за наступний місяць недоступні. Перейдіть на сайт.' + text = t(user_id, 'diary_next_month') else: - text = format_diary_day(diary_data, tomorrow.day) + text = format_diary_day(diary_data, tomorrow.day, lang=lang) elif data == 'diary_week': - text = format_diary_week(diary_data, today) + text = format_diary_week(diary_data, today, lang=lang) elif data == 'diary_month': - text = format_diary_month(diary_data) + text = format_diary_month(diary_data, lang=lang) else: return if len(text) > 4096: - text = text[:4090] + '\n\n✂️ ...(обрізано)' + text = text[:4090] + t(user_id, 'truncation') - await query.edit_message_text(text, parse_mode='HTML', reply_markup=get_diary_keyboard()) + await query.edit_message_text(text, parse_mode='HTML', reply_markup=get_diary_keyboard(user_id)) # --- Schedule Command Handlers --- @@ -921,28 +1234,29 @@ 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, '❌ Не вдалося завантажити розклад.' + return None, t(user_id, 'schedule_load_failed') class_num = _get_chat_class(chat, user_id) if not class_num: - return None, '❌ Клас не налаштовано. Використайте /setclass.' - text = format_schedule_day(schedule_data, weekday, 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] + '\n\n✂️ ...(обрізано)' + 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('❌ Не вдалося завантажити розклад.') + 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('❌ Класи не знайдені в розкладі.') + 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), ) @@ -960,19 +1274,19 @@ async def schedule_command(update: Update, context: ContextTypes.DEFAULT_TYPE): if not stored_class: if chat.type != 'private' and not await is_group_admin(update, context): - await update.message.reply_text('⚠️ Адміністратор ще не налаштував клас для цієї групи. Використайте /setclass 11') + 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('🔄 Завантажую розклад...') + await update.message.reply_text(t(user.id, 'loading_schedule')) today = datetime.now() - weekday = SCHEDULE_WEEKDAYS_FULL[today.weekday()] + 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()) + 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): @@ -990,10 +1304,10 @@ async def setclass_command(update: Update, context: ContextTypes.DEFAULT_TYPE): try: class_num = int(context.args[0]) except ValueError: - await update.message.reply_text('❌ Невірний номер класу.') + 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(f'✅ Клас {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) @@ -1005,56 +1319,60 @@ async def schedule_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): await query.answer() if user_id != ADMIN_ID and not is_whitelisted(user_id): - await query.edit_message_text('⛔ Доступ заборонено.') + 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_'):] + class_num_str = data[len('schedule_class_') :] try: class_num = int(class_num_str) except ValueError: - await query.edit_message_text('❌ Невірний номер класу.') + 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('⚠️ Тільки адміністратор може налаштувати клас для групи.') + 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('🔄 Завантажую розклад...') + await query.edit_message_text(t(user_id, 'loading_schedule')) today = datetime.now() - weekday = SCHEDULE_WEEKDAYS_FULL[today.weekday()] + 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()) + 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_'):] - await query.edit_message_text('🔄 Завантажую розклад...') + 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()) + 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('🔄 Завантажую розклад...') + await query.edit_message_text(t(user_id, 'loading_schedule')) today = datetime.now() - weekday = SCHEDULE_WEEKDAYS_FULL[today.weekday()] + 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()) + await query.edit_message_text(text, parse_mode='HTML', reply_markup=get_schedule_day_keyboard(user_id)) return @@ -1103,17 +1421,31 @@ async def admin_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): await query.message.reply_text(t(user_id, 'check_completed', count=result)) -async def language_callback(update: Update, _context: ContextTypes.DEFAULT_TYPE): +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] - set_user_language(user_id, lang) - await query.answer() - await query.edit_message_text(t(user_id, 'language_changed'), parse_mode='HTML') + 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 --- @@ -1198,7 +1530,7 @@ async def check_webinars_job(context: ContextTypes.DEFAULT_TYPE): content = await page.content() # Check if "no webinar" message is present - if 'Жодного онлайн уроку зараз' not in content: + if NO_WEBINAR_MARKER not in content: logger.info('!!! WEBINAR FOUND !!!') # Extract webinar details from table rows @@ -1209,7 +1541,7 @@ async def check_webinars_job(context: ContextTypes.DEFAULT_TYPE): row = rows.nth(i) text = await row.inner_text() - if 'Жодного онлайн уроку зараз' not in 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() @@ -1289,9 +1621,14 @@ async def check_webinars_job(context: ContextTypes.DEFAULT_TYPE): # 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] + [ + 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(int(sub_id), 'webinar_found') + webinar_items + 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}')