import asyncio import contextlib import json import logging import os import re import tempfile import threading import time from datetime import datetime, timedelta from html import escape from http.server import BaseHTTPRequestHandler, HTTPServer 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')) METRICS_PORT = int(_env('METRICS_PORT', '8000')) # --- Prometheus metrics (stdlib only, no extra deps) --- # Scraped by prometheus-stack via ServiceMonitor (edu_master/k8s/servicemonitor.yaml). # Critical alerts in edu_master/k8s/alerts.yaml fire to Telegram via Alertmanager. _METRICS_LOCK = threading.Lock() _METRICS = { 'last_run': 0.0, # Unix ts of last check start 'last_success': 0.0, # Unix ts of last successful check 'last_duration': 0.0, # Duration of last check in seconds 'success_total': 0, 'failure_total': 0, 'consecutive_failures': 0, 'phpsessid_present': 1, # 1 if EDU_PHPSESSID found in redis, else 0 } def _metric_check_start(): with _METRICS_LOCK: _METRICS['last_run'] = time.time() def _metric_check_ok(duration: float): now = time.time() with _METRICS_LOCK: _METRICS['last_success'] = now _METRICS['last_duration'] = duration _METRICS['success_total'] += 1 _METRICS['consecutive_failures'] = 0 _METRICS['phpsessid_present'] = 1 def _metric_check_fail(duration: float, phpsessid_missing: bool = False): with _METRICS_LOCK: _METRICS['last_duration'] = duration _METRICS['failure_total'] += 1 _METRICS['consecutive_failures'] += 1 _METRICS['phpsessid_present'] = 0 if phpsessid_missing else 1 def _metrics_render() -> bytes: with _METRICS_LOCK: m = dict(_METRICS) lines = [ '# HELP webinar_check_last_run_timestamp_seconds Unix timestamp of last webinar check start.', '# TYPE webinar_check_last_run_timestamp_seconds gauge', f'webinar_check_last_run_timestamp_seconds {m["last_run"]}', '# HELP webinar_check_last_success_timestamp_seconds Unix timestamp of last successful webinar check.', '# TYPE webinar_check_last_success_timestamp_seconds gauge', f'webinar_check_last_success_timestamp_seconds {m["last_success"]}', '# HELP webinar_check_last_duration_seconds Duration of last webinar check in seconds.', '# TYPE webinar_check_last_duration_seconds gauge', f'webinar_check_last_duration_seconds {m["last_duration"]}', '# HELP webinar_check_success_total Total successful webinar checks.', '# TYPE webinar_check_success_total counter', f'webinar_check_success_total {m["success_total"]}', '# HELP webinar_check_failure_total Total failed webinar checks (timeout, playwright error, page error).', '# TYPE webinar_check_failure_total counter', f'webinar_check_failure_total {m["failure_total"]}', '# HELP webinar_check_consecutive_failures Consecutive failed webinar checks (reset on success).', '# TYPE webinar_check_consecutive_failures gauge', f'webinar_check_consecutive_failures {m["consecutive_failures"]}', '# HELP edu_phpsessid_present 1 if EDU_PHPSESSID exists in redis, 0 otherwise.', '# TYPE edu_phpsessid_present gauge', f'edu_phpsessid_present {m["phpsessid_present"]}', ] return ('\n'.join(lines) + '\n').encode() class _MetricsHandler(BaseHTTPRequestHandler): def do_GET(self): if self.path == '/metrics': body = _metrics_render() self.send_response(200) self.send_header('Content-Type', 'text/plain; version=0.0.4') self.send_header('Content-Length', str(len(body))) self.end_headers() self.wfile.write(body) elif self.path in ('/healthz', '/health'): body = b'ok\n' self.send_response(200) self.send_header('Content-Type', 'text/plain') self.send_header('Content-Length', str(len(body))) self.end_headers() self.wfile.write(body) else: self.send_response(404) self.end_headers() def log_message(self, *args): pass # keep bot logs clean def start_metrics_server(port: int = METRICS_PORT): server = HTTPServer(('0.0.0.0', port), _MetricsHandler) # noqa: S104 - k8s ServiceMonitor scrapes pod IP thread = threading.Thread(target=server.serve_forever, name='metrics-server', daemon=True) thread.start() logger.info(f'Metrics server listening on :{port}/metrics') return server # 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👑 Режим администратора активен', 'access_denied': '⛔ Доступ запрещен. Вас нет в белом списке.', 'help_title': '🤖 Помощь по боту\n\n', 'help_commands': '/start - Подписаться на уведомления\n/stop - Отписаться от уведомлений\n/help - Показать это сообщение\n/language - Сменить язык', 'help_admin': '\nКоманды администратора:\n/adduser [user_id] - Добавить пользователя в белый список\n/removeuser [user_id] - Удалить пользователя из белого списка\nИли используйте панель ниже для управления настройками.', 'admin_only': '⛔ Только для администратора!', 'user_added': '✅ Пользователь {user_id} добавлен в белый список', 'user_removed': '✅ Пользователь {user_id} удален из белого списка', 'user_not_in_whitelist': '⚠️ Пользователь {user_id} не был в белом списке', 'cannot_remove_admin': '❌ Невозможно удалить администратора из белого списка', 'invalid_user_id': '❌ Неверный ID пользователя. Должно быть число.', 'usage_adduser': 'Использование: /adduser [user_id]', 'usage_removeuser': 'Использование: /removeuser [user_id]', 'whitelist_enabled': '✅ Белый список включен', 'whitelist_disabled': '🚫 Белый список отключен', 'whitelist_title': '📋 Белый список:\n', 'subscribers_title': '👥 Подписчики:\n', 'empty': 'Пусто', 'force_check_running': '🔄 Запускаю проверку...', 'check_failed': '❌ Проверка не удалась. Смотрите логи.', 'check_completed_none': '✅ Проверка завершена. Вебинаров не найдено.', 'check_completed': '✅ Проверка завершена. Найдено {count} вебинар(ов)!', 'toggle_whitelist_disable': '🔒 Отключить белый список', 'toggle_whitelist_enable': '🔓 Включить белый список', 'view_whitelist': '📋 Посмотреть белый список', 'view_subscribers': '👥 Посмотреть подписчиков', 'force_check': '🔄 Принудительная проверка', 'webinar_found': '🎓 Новый вебинар!\n\n', 'webinar_item': '📌 {name}\n🔗 https://edu.edu.vn.ua{url}', 'select_language': '🌐 Выберите язык / Оберіть мову / Select language:', 'language_changed': '✅ Язык изменен на {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Ви підписані на сповіщення.", 'welcome_admin': '\n\n👑 Режим адміністратора активний', 'access_denied': '⛔ Доступ заборонено. Вас немає в білому списку.', 'help_title': '🤖 Довідка по боту\n\n', 'help_commands': '/start - Підписатися на сповіщення\n/stop - Відписатися від сповіщень\n/help - Показати це повідомлення\n/language - Змінити мову', 'help_admin': '\nКоманди адміністратора:\n/adduser [user_id] - Додати користувача до білого списку\n/removeuser [user_id] - Видалити користувача з білого списку\nАбо використовуйте панель нижче для керування налаштуваннями.', 'admin_only': '⛔ Тільки для адміністратора!', 'user_added': '✅ Користувач {user_id} доданий до білого списку', 'user_removed': '✅ Користувач {user_id} видалений з білого списку', 'user_not_in_whitelist': '⚠️ Користувач {user_id} не був у білому списку', 'cannot_remove_admin': '❌ Неможливо видалити адміністратора з білого списку', 'invalid_user_id': '❌ Невірний ID користувача. Має бути число.', 'usage_adduser': 'Використання: /adduser [user_id]', 'usage_removeuser': 'Використання: /removeuser [user_id]', 'whitelist_enabled': '✅ Білий список увімкнено', 'whitelist_disabled': '🚫 Білий список вимкнено', 'whitelist_title': '📋 Білий список:\n', 'subscribers_title': '👥 Підписники:\n', 'empty': 'Порожньо', 'force_check_running': '🔄 Запускаю перевірку...', 'check_failed': '❌ Перевірка не вдалася. Дивіться логи.', 'check_completed_none': '✅ Перевірка завершена. Вебінарів не знайдено.', 'check_completed': '✅ Перевірка завершена. Знайдено {count} вебінар(ів)!', 'toggle_whitelist_disable': '🔒 Вимкнути білий список', 'toggle_whitelist_enable': '🔓 Увімкнути білий список', 'view_whitelist': '📋 Переглянути білий список', 'view_subscribers': '👥 Переглянути підписників', 'force_check': '🔄 Примусова перевірка', 'webinar_found': '🎓 Новий вебінар!\n\n', 'webinar_item': '📌 {name}\n🔗 https://edu.edu.vn.ua{url}', 'select_language': '🌐 Виберіть мову / Выберите язык / Select language:', 'language_changed': '✅ Мову змінено на {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.', 'welcome_admin': '\n\n👑 Admin Mode Active', 'access_denied': '⛔ Access denied. You are not on the whitelist.', 'help_title': '🤖 Bot Help\n\n', 'help_commands': '/start - Subscribe to notifications\n/stop - Unsubscribe from notifications\n/help - Show this message\n/language - Change language', 'help_admin': '\nAdmin Commands:\n/adduser [user_id] - Add user to whitelist\n/removeuser [user_id] - Remove user from whitelist\nOr use the panel below to manage settings.', 'admin_only': '⛔ Admin only!', 'user_added': '✅ User {user_id} added to whitelist', 'user_removed': '✅ User {user_id} removed from whitelist', 'user_not_in_whitelist': '⚠️ User {user_id} was not in whitelist', 'cannot_remove_admin': '❌ Cannot remove admin from whitelist', 'invalid_user_id': '❌ Invalid user ID. Must be a number.', 'usage_adduser': 'Usage: /adduser [user_id]', 'usage_removeuser': 'Usage: /removeuser [user_id]', 'whitelist_enabled': '✅ Whitelist Enabled', 'whitelist_disabled': '🚫 Whitelist Disabled', 'whitelist_title': '📋 Whitelist:\n', 'subscribers_title': '👥 Subscribers:\n', 'empty': 'Empty', 'force_check_running': '🔄 Running immediate check...', 'check_failed': '❌ Check failed. See logs for details.', 'check_completed_none': '✅ Check completed. No webinars found.', 'check_completed': '✅ Check completed. Found {count} webinar(s)!', 'toggle_whitelist_disable': '🔒 Disable Whitelist', 'toggle_whitelist_enable': '🔓 Enable Whitelist', 'view_whitelist': '📋 View Whitelist', 'view_subscribers': '👥 View Subscribers', 'force_check': '🔄 Force Check', 'webinar_found': '🎓 New webinar found!\n\n', 'webinar_item': '📌 {name}\n🔗 https://edu.edu.vn.ua{url}', 'select_language': '🌐 Select language / Виберіть мову / Выберите язык:', 'language_changed': '✅ Language changed to {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: 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'