@@ -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 ' : ' 📋 <b>Белый список:</b> \n ' ,
' subscribers_title ' : ' 👥 <b>Подписчики:</b> \n ' ,
' empty ' : ' Пусто ' ,
@@ -99,12 +103,44 @@ TRANSLATIONS = {
' 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 ' : ' ✅ Язык изменен на русский ' ,
' 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 ' : ' 📅 <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 Ви підписані на сповіщення. " ,
@@ -122,7 +158,7 @@ TRANSLATIONS = {
' usage_adduser ' : ' Використання: /adduser [user_id] ' ,
' usage_removeuser ' : ' Використання: /removeuser [user_id] ' ,
' whitelist_enabled ' : ' ✅ Білий список увімкнено ' ,
' whitelist_disabled ' : ' ✅ Білий список вимкнено' ,
' whitelist_disabled ' : ' 🚫 Білий список вимкнено' ,
' whitelist_title ' : ' 📋 <b>Білий список:</b> \n ' ,
' subscribers_title ' : ' 👥 <b>Підписники:</b> \n ' ,
' empty ' : ' Порожньо ' ,
@@ -138,12 +174,44 @@ TRANSLATIONS = {
' 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 ' : ' ✅ Мову змінено на українську ' ,
' 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 ' : ' 📅 <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 \n I am the Webinar Checker Bot. I will notify you when a new webinar appears. \n You 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 ' : ' 📋 <b>Whitelist:</b> \n ' ,
' subscribers_title ' : ' 👥 <b>Subscribers:</b> \n ' ,
' empty ' : ' Empty ' ,
@@ -177,22 +245,57 @@ TRANSLATIONS = {
' 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 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 ' : ' 📅 <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: 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 ' 📅 <b> { day_num } { month_str } </b> ' , ' ─ ' * 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 ' 📅 <b>Тиждень { monday . day } . { monday . month } – { sunday . day } . { sunday . month } </b> \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 ' ─ <b> { DIARY_WEEKDAYS_SHORT [ i ] } { d . day } . { d . month } </b> ─ ' )
lines . append ( f ' ─ <b> { short [ i ] } { d . day } . { d . month } </b> ─ ' )
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 ' 📅 <b> { month_str } </b> \n ' ]
@@ -460,10 +643,10 @@ def format_diary_month(data: dict) -> str:
weekday = day_data . get ( ' weekday ' , ' ' )
lines . append ( f ' ─ <b> { weekday } { day_num } </b> ─ ' )
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,51 @@ 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 )
@@ -514,7 +722,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 +761,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 +780,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 ' <tr([^>]*)>(.*?)</tr> ' , table_html , re . DOTALL )
current_day = None
@@ -578,6 +795,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 +872,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 +904,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 ' 📅 <b> { weekday } — { class_num } клас</b> ' , ' ─ ' * 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 +933,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 ' 📌 <b> { lesson [ " subject " ] } </b> { note_part } ' )
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 ' { lesson [ " teacher " ] } ' )
lines . append ( f ' { escape ( lesson [ " teacher " ] ) } ' )
if i < len ( lessons ) - 1 :
lines . append ( ' ── ' )
@@ -734,10 +964,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 +976,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 +992,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 +1011,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 +1084,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 (
' 📅 <b>Щоденник</b> — виберіть період: ' , 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 +1094,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 (
' 📅 <b>Щоденник</b> — виберіть період: ' , 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 +1146,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 (
' 🎒 <b>Оберіть клас</b> — збережеться і більше не питатиметься: ' ,
t ( user_id , ' select_class ' ) ,
parse_mode = ' HTML ' ,
reply_markup = get_schedule_class_keyboard ( classes ) ,
)
@@ -960,19 +1186,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 +1216,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 ' ✅ Клас <b> { class_num } </b> збережено. ' )
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 +1231,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 +1333,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 +1442,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 +1453,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 +1533,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 } ' )