add schedule search and viewer

This commit is contained in:
Server 2026-08-30 13:04:57 +00:00
parent f65ad275bc
commit f5febefa92
10 changed files with 559 additions and 226 deletions

View file

@ -24,6 +24,7 @@ from .analytics import *
from .iternal import * from .iternal import *
from .inline import * from .inline import *
from .main.home import * from .main.home import *
from .schedule.search import *
from .schedule.other_days import * from .schedule.other_days import *
from .settings.notifications import * from .settings.notifications import *
from .schedule.saved_groups import * from .schedule.saved_groups import *

View file

@ -2,7 +2,7 @@ import json
from telebot.types import Message, CallbackQuery, InputFile, ReplyKeyboardRemove from telebot.types import Message, CallbackQuery, InputFile, ReplyKeyboardRemove
from telebot.util import quick_markup from telebot.util import quick_markup
from models.bot import bot, Markup, send, Receive, Default from models.bot import bot, Markup, Strings, send, Receive, Default
from models.user import validate from models.user import validate
from models.schedule import generateDaySchedule, dayStrings from models.schedule import generateDaySchedule, dayStrings
@ -15,9 +15,13 @@ from telebot.handler_backends import ContinueHandling
def sendBrokenMessage(message: Message): def sendBrokenMessage(message: Message):
send(message, 'Твоя группа не была найдена в боте, пожалуйста перезапусти его через команду /start', ReplyKeyboardRemove()) send(message, 'Твоя группа не была найдена в боте, пожалуйста перезапусти его через команду /start', ReplyKeyboardRemove())
@bot.message_handler(content_types = ['text'], func = lambda m: m.text == 'На главную 🏠') @bot.message_handler(content_types = ['text'], func = lambda m: m.text == Strings.back_to_home)
def returnToDefaultState(message: Message): def returnToDefaultState(message: Message):
u = validate(message) u = validate(message)
if not u.active_target:
from bot.schedule.search import open_search
return open_search(message, u)
u.setState('default') u.setState('default')
return send(message, 'Главное меню 🏠', reply_markup = Markup.default(u)) return send(message, 'Главное меню 🏠', reply_markup = Markup.default(u))
@ -32,11 +36,25 @@ def defaultState(message: Message):
u = validate(message) u = validate(message)
if message.text[0] == '/': return ContinueHandling() # type: ignore if message.text[0] == '/': return ContinueHandling() # type: ignore
if message.text == f'{u.group} 🔄': if message.text == Strings.search:
u.setState('savedGroups') from bot.schedule.search import open_search
return send(u.id, f'<b>Сохраненные группы</b>\nЗдесь список групп, которые ранее были использованы, чтобы быстро между ними переключаться. \n\n<i>Активная группа: {u.group}</i>', Markup.saved_groups(u))
schedule_actions = {'Сегодня 🔥', 'Завтра 🍀', 'На неделю 📅', 'Другие дни 📁'} return open_search(message, u)
if message.text == Strings.current_schedule_name_F.format(u.group):
u.setState('savedGroups')
return send(
u.id,
f'<b>Сохраненные расписания</b>\n\n<i>Активное расписание: {u.group}</i>',
Markup.saved_schedules(u),
)
schedule_actions = {
Strings.today,
Strings.tomorrow,
Strings.schedule_another_days,
'На неделю 📅',
}
s = u.schedule() if message.text in schedule_actions else None s = u.schedule() if message.text in schedule_actions else None
if message.text in schedule_actions and not s: if message.text in schedule_actions and not s:
return send( return send(
@ -46,10 +64,10 @@ def defaultState(message: Message):
) )
match message.text: match message.text:
case 'Сегодня 🔥': case Strings.today:
return send(u.id, generateDaySchedule(s, isToday = True), reply_markup = Markup.default(u)) return send(u.id, generateDaySchedule(s, isToday = True), reply_markup = Markup.default(u))
case 'Завтра 🍀': case Strings.tomorrow:
if s.weekDay in [4, 5]: if s.weekDay in [4, 5]:
return send(u.id, f'<b>{dayStrings[s.weekDay+1]}</b>\nЗавтра пар нет 🥰') return send(u.id, f'<b>{dayStrings[s.weekDay+1]}</b>\nЗавтра пар нет 🥰')
elif s.weekDay == 6: elif s.weekDay == 6:
@ -73,11 +91,17 @@ def defaultState(message: Message):
return send(u.id, resultString, reply_markup = Markup.default(u)) return send(u.id, resultString, reply_markup = Markup.default(u))
case 'Другие дни 📁': case Strings.schedule_another_days:
u.setState('otherDays') u.setState('otherDays')
return send(u.id, f'<b>Сегодня {dayStrings[s.weekDay].lower()}, {"четная" if s.weekNumber%2==0 else "нечетная"} неделя</b>\n\nВыбери нужный день/пункт', reply_markup = Markup.other()) u.view_target = u.active_target
u.save()
return send(
u.id,
f'<b>Сегодня {dayStrings[s.weekDay].lower()}, {"четная" if s.weekNumber%2==0 else "нечетная"} неделя</b>\n\nВыбери нужный день/пункт',
reply_markup=Markup.schedule(u.is_saved(u.active_target)),
)
case 'Уведомления ⏰': case Strings.notifications:
u.setState('notifications') u.setState('notifications')
return send(u.id, f'''\ return send(u.id, f'''\
<b>Перед парой </b> <i>{"включено" if 'lesson' in u.notifications else "выключено"}</i> <b>Перед парой </b> <i>{"включено" if 'lesson' in u.notifications else "выключено"}</i>
@ -92,7 +116,7 @@ def defaultState(message: Message):
<i>Выбери нужную кнопку, чтобы включить/выключить уведомления</i>\ <i>Выбери нужную кнопку, чтобы включить/выключить уведомления</i>\
''', reply_markup = Markup.notifications()) ''', reply_markup = Markup.notifications())
case 'Поддержать 💞': case Strings.support:
supportJSON = json.load(open('support.json', 'r', encoding = 'utf-8')) supportJSON = json.load(open('support.json', 'r', encoding = 'utf-8'))
return send(u.id, supportJSON['text'], reply_markup = quick_markup(supportJSON['quick_markup'], row_width = 1)) return send(u.id, supportJSON['text'], reply_markup = quick_markup(supportJSON['quick_markup'], row_width = 1))

View file

@ -5,7 +5,7 @@ from telebot.util import quick_markup
from models import logger from models import logger
from models.bot import Markup_v1 as Markup, bot, send from models.bot import Markup_v1 as Markup, bot, send
from models.schedule import ScheduleAPIError, find_main_groups, group_exists from models.schedule import ScheduleAPIError, find_main_groups, group_exists, group_target
from models.user import getAllUsers, validate from models.user import getAllUsers, validate
@ -23,7 +23,10 @@ def start(message: Message):
{ {
"state": "selectGroup", "state": "selectGroup",
"group": None, "group": None,
"active_schedule": None,
"saved_groups": user.saved_groups, "saved_groups": user.saved_groups,
"saved_schedules": user.saved_schedules,
"view_schedule": None,
"notifications": [], "notifications": [],
"valid": True, "valid": True,
"user": user.user, "user": user.user,
@ -67,9 +70,7 @@ def notFoundHandler(message: Message):
def _finish_registration(message: Message, group_name: str) -> None: def _finish_registration(message: Message, group_name: str) -> None:
user = validate(message) user = validate(message)
user.group = group_name user.save_schedule(group_target(group_name))
if group_name not in user.saved_groups:
user.saved_groups.append(group_name)
user.valid = True user.valid = True
user.setState("default") user.setState("default")
send( send(

View file

@ -1,27 +1,40 @@
from telebot.types import Message from telebot.types import Message
from models.bot import bot, Markup, send from models.bot import Markup, Strings, bot, send
from models.user import validate from models.user import validate
from models.schedule import generateDaySchedule
@bot.message_handler(content_types = ['text'], func = lambda m: validate(m).state == 'otherDays') from .search import render_schedule_action
@bot.message_handler(
content_types=["text"], func=lambda message: validate(message).state == "otherDays"
)
def otherState(message: Message): def otherState(message: Message):
u = validate(message) user = validate(message)
possibleResponces = ['ПН', 'ВТ', 'СР', 'ЧТ', 'ПТ'] target = user.view_target or user.active_target
if message.text not in possibleResponces and message.text not in ['Четная 📅', 'Нечетная 📅']: return if not target:
result: str = '' user.setState("default")
s = u.schedule() return send(message, "Сначала выбери расписание через поиск", Markup.default(user))
if not s:
return send(message, 'Не удалось получить расписание. Попробуй ещё раз позже.')
if message.text in possibleResponces:
s.weekDay = possibleResponces.index(message.text)
result = generateDaySchedule(s)
else: if message.text == Strings.save:
s.weekNumber = 2 if message.text == 'Четная 📅' else 1 user.save_schedule(target)
result = f'<i>{message.text.split(" ")[0]} неделя</i>\n' return send(
for index, _ in enumerate(s.days): message,
s.weekDay = index f"Расписание <b>{target['name']}</b> сохранено",
result += generateDaySchedule(s, False) +'\n\n' reply_markup=Markup.schedule(True),
)
if message.text == Strings.delete:
user.remove_schedule(target)
return send(
message,
f"Расписание <b>{target['name']}</b> удалено из сохраненных",
reply_markup=Markup.schedule(False),
)
return send(u.id, result, reply_markup = Markup.other()) result = render_schedule_action(user, target, message.text or "")
if result:
return send(
message,
result,
reply_markup=Markup.schedule(user.is_saved(target)),
)

View file

@ -1,33 +1,50 @@
from telebot.types import Message, CallbackQuery from telebot.types import Message
from telebot.util import quick_markup
from models.bot import bot, Markup, send from models.bot import Markup, Strings, bot, send
from models.user import validate from models.user import validate
from models.schedule import generateDaySchedule
@bot.message_handler(content_types = ['text'], func = lambda m: validate(m).state == 'savedGroups')
@bot.message_handler(
content_types=["text"], func=lambda message: validate(message).state == "savedGroups"
)
def savedGroups(message: Message): def savedGroups(message: Message):
u = validate(message) user = validate(message)
if message.text == 'Добавить группу 🔆':
return send(message, 'Чтобы добавить группу используй команду /start')
elif message.text == 'Удалить группы 🗑': if message.text == Strings.add_schedule:
return send(message, 'Это действие нельзя отменить, точно удалить все сохраненные группы?', quick_markup({ from .search import open_search
'Да, удалить все': {'callback_data': '$delsaved'}
}))
elif message.text in u.saved_groups: return open_search(message, user)
changed = message.text != u.group
if changed: u.group = message.text
u.setState('default')
send(message, 'Группа изменена ✨' if changed else 'Ничего не поменялось ❌', Markup.default(u))
@bot.callback_query_handler(func=lambda call: '$delsaved' == call.data) if user.active_target and message.text == Strings.delete_active_schedule_F.format(user.group):
def confirmDeleteAll(call: CallbackQuery): removed_name = user.group
u = validate(call) user.remove_schedule(user.active_target)
u.setState('default') if not user.active_target:
try: from .search import open_search
u.saved_groups = [u.group]
u.save() send(message, f'Расписание <b>{removed_name}</b> удалено. Выбери новое расписание.')
send(u.id, 'Сохраненные группы были удалены 🗑', Markup.default(u)) return open_search(message, user)
except: user.setState("savedGroups")
send(u.id, 'Не удалось удалить группы', Markup.default(u)) return send(
message,
f'Расписание <b>{removed_name}</b> удалено. Активно: <b>{user.group}</b>',
Markup.saved_schedules(user),
)
if user.active_target and message.text == Strings.delete_other_schedules_F.format(user.group):
user.keep_only_active()
return send(
message,
f'Оставлено только расписание <b>{user.group}</b>',
Markup.saved_schedules(user),
)
selected = user.find_saved(message.text or "")
if selected:
changed = selected != user.active_target
user.active_target = selected
user.setState("default")
return send(
message,
"Расписание изменено ✨" if changed else "Ничего не поменялось",
Markup.default(user),
)

158
bot/schedule/search.py Normal file
View file

@ -0,0 +1,158 @@
from telebot.types import Message
from models import logger
from models.bot import Markup, Strings, bot, send
from models.schedule import ScheduleAPIError, ScheduleTarget, generateDaySchedule, search_targets
from models.user import User, validate
SEARCH_KINDS = {
Strings.groups: "group",
Strings.rooms: "room",
Strings.teachers: "teacher",
}
SEARCH_PROMPTS = {
"group": "Введи название группы",
"room": "Введи номер или название аудитории",
"teacher": "Введи фамилию или имя преподавателя",
}
def open_search(message: Message, user: User | None = None) -> None:
user = user or validate(message)
user.search_kind = None
user.search_results = []
user.view_target = None
user.setState("searchType")
send(
message,
"Выберите, для кого или чего вы хотите найти расписание",
reply_markup=Markup.search_types(),
)
def open_schedule_viewer(message: Message, target: ScheduleTarget) -> None:
user = validate(message)
user.view_target = target
user.setState("scheduleSearch")
send(
message,
f"<b>{target['name']}</b>\nВыбери нужный день или неделю",
reply_markup=Markup.schedule(user.is_saved(target), from_search=True),
)
def render_schedule_action(user: User, target: ScheduleTarget, text: str) -> str | None:
schedule = user.schedule(target)
if not schedule:
return None
action = text.replace("", "").strip()
if action == Strings.today:
return generateDaySchedule(schedule, isToday=True)
if action == Strings.tomorrow:
if schedule.weekDay == 6:
schedule.weekDay = 0
schedule.weekNumber += 1
else:
schedule.weekDay += 1
return generateDaySchedule(schedule)
if action in Strings.weekdays:
schedule.weekDay = Strings.weekdays.index(action)
return generateDaySchedule(schedule)
if action in (Strings.odd, Strings.even):
schedule.weekNumber = 2 if action == Strings.even else 1
result = f"<i>{action.split()[0]} неделя</i>\n"
for weekday in range(len(schedule.days)):
schedule.weekDay = weekday
result += generateDaySchedule(schedule, False) + "\n\n"
return result.rstrip()
return None
@bot.message_handler(content_types=["text"], func=lambda message: message.text == Strings.back_to_search)
def back_to_search(message: Message):
open_search(message)
@bot.message_handler(content_types=["text"], func=lambda message: validate(message).state == "searchType")
def select_search_type(message: Message):
kind = SEARCH_KINDS.get(message.text)
if not kind:
return
user = validate(message)
user.search_kind = kind
user.search_results = []
user.setState(f"search:{kind}")
send(message, SEARCH_PROMPTS[kind], reply_markup=Markup.search_results([]))
@bot.message_handler(
content_types=["text"],
func=lambda message: str(validate(message).state).startswith("search:"),
)
def search_query(message: Message):
user = validate(message)
selected = next(
(target for target in user.search_results if target["name"] == message.text),
None,
)
if selected:
return open_schedule_viewer(message, selected)
kind = str(user.state).partition(":")[2]
try:
results = search_targets(kind, message.text or "", limit=10) # type: ignore
except ScheduleAPIError as error:
logger.error("Schedule", f"[{user.id}] {error}")
return send(message, "Сервер расписания сейчас недоступен. Попробуй ещё раз позже.")
user.search_results = results
user.save()
if len(results) == 1:
return open_schedule_viewer(message, results[0])
if not results:
return send(
message,
"Ничего похожего не нашлось. Попробуй другой запрос.",
reply_markup=Markup.search_results([]),
)
send(
message,
"Выбери подходящее расписание",
reply_markup=Markup.search_results([target["name"] for target in results]),
)
@bot.message_handler(
content_types=["text"], func=lambda message: validate(message).state == "scheduleSearch"
)
def search_schedule_viewer(message: Message):
user = validate(message)
target = user.view_target
if not target:
return open_search(message, user)
if message.text == Strings.save:
user.save_schedule(target)
return send(
message,
f"Расписание <b>{target['name']}</b> сохранено",
reply_markup=Markup.schedule(True, from_search=True),
)
if message.text == Strings.delete:
user.remove_schedule(target)
return send(
message,
f"Расписание <b>{target['name']}</b> удалено из сохраненных",
reply_markup=Markup.schedule(False, from_search=True),
)
result = render_schedule_action(user, target, message.text or "")
if result:
return send(
message,
result,
reply_markup=Markup.schedule(user.is_saved(target), from_search=True),
)

View file

@ -14,9 +14,10 @@ class Analytics:
def add(self, user: User) -> None: def add(self, user: User) -> None:
self.users += 1 self.users += 1
if not user.group: if not user.active_target or user.active_target["kind"] != "group":
return return
self.groups[user.group] = self.groups.get(user.group, 0) + 1 group = user.active_target["name"]
self.groups[group] = self.groups.get(group, 0) + 1
def toDict(self) -> dict[str, Any]: def toDict(self) -> dict[str, Any]:
return { return {

View file

@ -2,6 +2,7 @@ import json
import random import random
import telebot import telebot
import datetime import datetime
from zoneinfo import ZoneInfo
from telebot.types import ReplyKeyboardMarkup, InlineQueryResultArticle, InputTextMessageContent, Message, ReplyKeyboardRemove, WebAppInfo, KeyboardButton from telebot.types import ReplyKeyboardMarkup, InlineQueryResultArticle, InputTextMessageContent, Message, ReplyKeyboardRemove, WebAppInfo, KeyboardButton
import config import config
from telebot import apihelper from telebot import apihelper
@ -67,6 +68,12 @@ class Strings:
support = 'Поддержка 💞' support = 'Поддержка 💞'
notifications = 'Уведомления ⏰' notifications = 'Уведомления ⏰'
current_schedule_name_F = '{} 🔄' current_schedule_name_F = '{} 🔄'
groups = 'Группы'
rooms = 'Аудитории'
teachers = 'Преподаватели'
add_schedule = 'Добавить расписание 🔆'
delete_active_schedule_F = 'Удалить "{}" 🗑'
delete_other_schedules_F = 'Удалить все кроме "{}" 🗑'
# register # register
register_not_found_T = 'Моего {} нету ⚠' register_not_found_T = 'Моего {} нету ⚠'
@ -105,12 +112,16 @@ class Markup_v2:
@staticmethod @staticmethod
def schedule(is_added: bool, from_search: bool = False) -> ReplyKeyboardMarkup: def schedule(is_added: bool, from_search: bool = False) -> ReplyKeyboardMarkup:
odd_even = datetime.datetime.now().isocalendar().week%2 == 0 #! ?????? odd_even = datetime.datetime.now(ZoneInfo('Europe/Moscow')).isocalendar().week % 2 == 0
markup = ReplyKeyboardMarkup(resize_keyboard=True) markup = ReplyKeyboardMarkup(resize_keyboard=True)
markup.add(Strings.back_to_home, row_width=1) if not from_search else markup.add(Strings.back_to_search,Strings.back_to_home, row_width=2) markup.add(Strings.back_to_home, row_width=1) if not from_search else markup.add(Strings.back_to_search,Strings.back_to_home, row_width=2)
markup.add(Strings.today, Strings.tomorrow, row_width=2) markup.add(Strings.today, Strings.tomorrow, row_width=2)
markup.add(*Strings.weekdays, row_width=5) markup.add(*Strings.weekdays, row_width=5)
markup.add(f'{"" if odd_even else ""} {Strings.odd}', f'{"" if odd_even else ""} {Strings.even}', row_width=2) markup.add(
f'{"" if odd_even else ""}{Strings.odd}',
f'{"" if odd_even else ""}{Strings.even}',
row_width=2,
)
markup.add(Strings.delete if is_added else Strings.save, row_width=1) markup.add(Strings.delete if is_added else Strings.save, row_width=1)
return markup return markup
@ -123,17 +134,6 @@ class Markup_v1:
@staticmethod @staticmethod
def default(user: user.User) -> ReplyKeyboardMarkup: def default(user: user.User) -> ReplyKeyboardMarkup:
markup = ReplyKeyboardMarkup(resize_keyboard=True)
# markup.add(
# KeyboardButton(
# text='Карта 🗺️',
# web_app=WebAppInfo(f'{config.WEB_BASE_URL}/map/?v12')
# ), row_width=1
# )
# markup.add('Сегодня 🔥', 'Завтра 🍀', row_width=2)
# markup.add('На неделю 📅', 'Другие дни 📁', row_width=2)
# markup.add('Уведомления ⏰', 'Поддержать 💞', f'{user.group} 🔄', row_width=3)
markup = ReplyKeyboardMarkup(resize_keyboard=True) markup = ReplyKeyboardMarkup(resize_keyboard=True)
markup.add( markup.add(
KeyboardButton( KeyboardButton(
@ -148,23 +148,45 @@ class Markup_v1:
markup.add(Strings.notifications, Strings.support, row_width=2) markup.add(Strings.notifications, Strings.support, row_width=2)
return markup return markup
@staticmethod
def search_types() -> ReplyKeyboardMarkup:
markup = ReplyKeyboardMarkup(resize_keyboard=True)
markup.add(Strings.back_to_home, row_width=1)
markup.add(Strings.groups, row_width=1)
markup.add(Strings.rooms, row_width=1)
markup.add(Strings.teachers, row_width=1)
return markup return markup
@staticmethod @staticmethod
def other() -> ReplyKeyboardMarkup: def search_results(results: list[str]) -> ReplyKeyboardMarkup:
markup = ReplyKeyboardMarkup(resize_keyboard=True) markup = ReplyKeyboardMarkup(resize_keyboard=True)
markup.add('На главную 🏠', row_width=1) markup.add(Strings.back_to_home, row_width=1)
markup.add('ПН', 'ВТ', 'СР', 'ЧТ', 'ПТ', row_width=5) for result in results[:10]:
markup.add('Нечетная 📅', 'Четная 📅', row_width=2) markup.add(result, row_width=1)
# markup.add('ЧТ', 'ПТ', row_width=2) return markup
@staticmethod
def schedule(is_saved: bool, from_search: bool = False) -> ReplyKeyboardMarkup:
even_week = datetime.datetime.now(ZoneInfo('Europe/Moscow')).isocalendar().week % 2 == 0
markup = ReplyKeyboardMarkup(resize_keyboard=True)
markup.add(Strings.back_to_search if from_search else Strings.back_to_home, row_width=1)
if from_search:
markup.add(Strings.delete if is_saved else Strings.save, row_width=1)
markup.add(Strings.today, Strings.tomorrow, row_width=2)
markup.add(*Strings.weekdays, row_width=5)
markup.add(
f'{"" if even_week else ""}{Strings.odd}',
f'{"" if even_week else ""}{Strings.even}',
row_width=2,
)
if not from_search:
markup.add(Strings.delete if is_saved else Strings.save, row_width=1)
return markup return markup
@staticmethod @staticmethod
def notifications() -> ReplyKeyboardMarkup: def notifications() -> ReplyKeyboardMarkup:
markup = ReplyKeyboardMarkup(resize_keyboard=True) markup = ReplyKeyboardMarkup(resize_keyboard=True)
markup.add('На главную 🏠', row_width=1) markup.add(Strings.back_to_home, row_width=1)
markup.add('Перед парой ⌛', row_width=1) markup.add('Перед парой ⌛', row_width=1)
markup.add('Вечером 🌆', row_width=1) markup.add('Вечером 🌆', row_width=1)
markup.add('Файлом на неделю 📅', row_width=1) markup.add('Файлом на неделю 📅', row_width=1)
@ -172,22 +194,24 @@ class Markup_v1:
return markup return markup
@staticmethod @staticmethod
def saved_groups(u: user.User) -> ReplyKeyboardMarkup: def saved_schedules(u: user.User) -> ReplyKeyboardMarkup:
markup = ReplyKeyboardMarkup(resize_keyboard=True) markup = ReplyKeyboardMarkup(resize_keyboard=True)
markup.add('На главную 🏠', row_width=1) markup.add(Strings.back_to_home, row_width=1)
markup.add(*u.saved_groups, row_width=4) markup.add(*(target['name'] for target in u.saved_schedules), row_width=4)
markup.add('Добавить группу 🔆', 'Удалить группы 🗑', row_width=2) markup.add(Strings.add_schedule, row_width=1)
if u.active_target:
markup.add(Strings.delete_active_schedule_F.format(u.group), row_width=1)
markup.add(Strings.delete_other_schedules_F.format(u.group), row_width=1)
return markup return markup
saved_groups = saved_schedules
@staticmethod @staticmethod
def selectGroup(groups: list[str]) -> ReplyKeyboardMarkup: def selectGroup(groups: list[str]) -> ReplyKeyboardMarkup:
markup = ReplyKeyboardMarkup(resize_keyboard=True) markup = ReplyKeyboardMarkup(resize_keyboard=True)
for group in groups: for group in groups:
markup.add(group, row_width=1) markup.add(group, row_width=1)
markup.add('Моей группы нету ⚠', row_width=1) markup.add('Моей группы нету ⚠', row_width=1)
return markup return markup

View file

@ -7,7 +7,7 @@ import json
import re import re
import threading import threading
import time import time
from typing import Any from typing import Any, Literal, TypedDict
from urllib.error import HTTPError, URLError from urllib.error import HTTPError, URLError
from urllib.parse import quote from urllib.parse import quote
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
@ -16,6 +16,15 @@ from zoneinfo import ZoneInfo
import config import config
TargetKind = Literal["group", "room", "teacher"]
class ScheduleTarget(TypedDict):
kind: TargetKind
id: str
name: str
dayStrings = [ dayStrings = [
"Понедельник", "Понедельник",
"Вторник", "Вторник",
@ -34,6 +43,12 @@ _cache: dict[str, tuple[float, list[dict[str, Any]]]] = {}
_failures: dict[str, tuple[float, str]] = {} _failures: dict[str, tuple[float, str]] = {}
_cache_lock = threading.RLock() _cache_lock = threading.RLock()
_ENDPOINTS: dict[TargetKind, str] = {
"group": "groups",
"room": "rooms",
"teacher": "teachers",
}
class ScheduleAPIError(RuntimeError): class ScheduleAPIError(RuntimeError):
pass pass
@ -85,6 +100,25 @@ def clear_schedule_cache() -> None:
_failures.clear() _failures.clear()
def target_key(target: ScheduleTarget) -> str:
return f"{target['kind']}:{target['id']}"
def normalize_target(value: Any) -> ScheduleTarget | None:
if not isinstance(value, dict):
return None
kind = value.get("kind")
target_id = value.get("id")
name = value.get("name")
if kind not in _ENDPOINTS or not all(isinstance(item, str) and item for item in (target_id, name)):
return None
return {"kind": kind, "id": target_id, "name": name}
def group_target(name: str) -> ScheduleTarget:
return {"kind": "group", "id": name, "name": name}
def get_main_groups() -> list[dict[str, Any]]: def get_main_groups() -> list[dict[str, Any]]:
groups = _load_result("groups") groups = _load_result("groups")
if not groups: if not groups:
@ -95,40 +129,52 @@ def get_main_groups() -> list[dict[str, Any]]:
) )
def _compact_group_name(value: str) -> str: def _compact(value: str) -> str:
return re.sub(r"[^0-9a-zа-яё]", "", value.casefold()) return re.sub(r"[^0-9a-zа-яё]", "", value.casefold())
def find_main_groups(query: str, limit: int = 8) -> list[str]: def _search_records(records: list[dict[str, Any]], query: str, limit: int) -> list[dict[str, Any]]:
query = query.strip() compact_query = _compact(query)
if not query: if not compact_query:
return [] return []
groups = [str(group["name"]) for group in get_main_groups()] exact = [record for record in records if _compact(str(record.get("name", ""))) == compact_query]
compact_query = _compact_group_name(query)
exact = [name for name in groups if _compact_group_name(name) == compact_query]
if exact: if exact:
return exact[:1] return exact[:limit]
contains = [name for name in groups if compact_query in _compact_group_name(name)] def score(record: dict[str, Any]) -> tuple[float, str]:
name = str(record.get("name", ""))
compact_name = _compact(name)
similarity = difflib.SequenceMatcher(None, compact_query, compact_name).ratio()
return similarity, name.casefold()
contains = [
record for record in records if compact_query in _compact(str(record.get("name", "")))
]
if contains: if contains:
return contains[:limit] return sorted(contains, key=score, reverse=True)[:limit]
scored = sorted( ranked = sorted(records, key=score, reverse=True)
( return [record for record in ranked if score(record)[0] >= 0.45][:limit]
difflib.SequenceMatcher(None, compact_query, _compact_group_name(name)).ratio(),
name,
) def search_targets(kind: TargetKind, query: str, limit: int = 10) -> list[ScheduleTarget]:
for name in groups records = _load_result(_ENDPOINTS[kind])
) if kind == "group":
return [name for score, name in reversed(scored) if score >= 0.45][:limit] records = [record for record in records if record.get("parent_group_id") is None]
return [
{"kind": kind, "id": str(record["id"]), "name": str(record["name"])}
for record in _search_records(records, query.strip(), limit)
]
def find_main_groups(query: str, limit: int = 8) -> list[str]:
return [target["name"] for target in search_targets("group", query, limit)]
def group_exists(group_name: str) -> bool: def group_exists(group_name: str) -> bool:
target = _compact_group_name(group_name) target = _compact(group_name)
return any( return any(_compact(str(group["name"])) == target for group in get_main_groups())
_compact_group_name(str(group["name"])) == target for group in get_main_groups()
)
def _minutes_to_time(value: int | None) -> list[int]: def _minutes_to_time(value: int | None) -> list[int]:
@ -141,7 +187,6 @@ class Lesson:
def __init__(self, data: dict[str, Any], subgroup_names: list[str]) -> None: def __init__(self, data: dict[str, Any], subgroup_names: list[str]) -> None:
self.start = _minutes_to_time(data.get("time_start")) self.start = _minutes_to_time(data.get("time_start"))
self.end = _minutes_to_time(data.get("time_end")) self.end = _minutes_to_time(data.get("time_end"))
self.strTime = f"{self.start[0]:02d}:{self.start[1]:02d} - {self.end[0]:02d}:{self.end[1]:02d}"
self.name = str(data.get("name") or "Без названия") self.name = str(data.get("name") or "Без названия")
self.type = str(data.get("type") or "пара") self.type = str(data.get("type") or "пара")
self.teacher_ids = [str(value) for value in data.get("teacher_ids", [])] self.teacher_ids = [str(value) for value in data.get("teacher_ids", [])]
@ -153,15 +198,11 @@ class Lesson:
def text(self, isToday: bool = False) -> str: def text(self, isToday: bool = False) -> str:
now = datetime.datetime.now(MOSCOW) now = datetime.datetime.now(MOSCOW)
lesson_start = now.replace( lesson_start = now.replace(hour=self.start[0], minute=self.start[1], second=0, microsecond=0)
hour=self.start[0], minute=self.start[1], second=0, microsecond=0 lesson_end = now.replace(hour=self.end[0], minute=self.end[1], second=0, microsecond=0)
)
lesson_end = now.replace(
hour=self.end[0], minute=self.end[1], second=0, microsecond=0
)
lesson_type = html.escape(self.type) lesson_type = html.escape(self.type)
date_text = f"{lesson_type.capitalize()} <i>в {self.start[0]}:{self.start[1]:02d}</i>" date_text = f"{lesson_type.capitalize()} <i>в {self.start[0]}:{self.start[1]:02d}</i>"
if isToday and lesson_start > now: if isToday and lesson_start > now:
minutes = int((lesson_start - now).total_seconds() // 60) minutes = int((lesson_start - now).total_seconds() // 60)
if minutes < 30: if minutes < 30:
@ -170,8 +211,7 @@ class Lesson:
minutes = int((lesson_end - now).total_seconds() // 60) minutes = int((lesson_end - now).total_seconds() // 60)
date_text = f"<i>Сейчас идет</i> {lesson_type}, закончится через {max(minutes, 1)} мин." date_text = f"<i>Сейчас идет</i> {lesson_type}, закончится через {max(minutes, 1)} мин."
details: list[str] = [] details = [html.escape(teacher) for teacher in self.teacher_ids]
details.extend(html.escape(teacher) for teacher in self.teacher_ids)
details.extend( details.extend(
f'<a href="https://rasp.pgups.ru/schedule/room?room={quote(room)}">ауд. {html.escape(room)}</a>' f'<a href="https://rasp.pgups.ru/schedule/room?room={quote(room)}">ауд. {html.escape(room)}</a>'
for room in self.room_ids for room in self.room_ids
@ -180,57 +220,42 @@ class Lesson:
details.append(html.escape(", ".join(self.subgroup_names))) details.append(html.escape(", ".join(self.subgroup_names)))
info = ", ".join(details) if details else "Дополнительная информация отсутствует" info = ", ".join(details) if details else "Дополнительная информация отсутствует"
return ( return f"<blockquote><b>{html.escape(self.name)}</b>\n{date_text}\n{info}</blockquote>"
f"<blockquote><b>{html.escape(self.name)}</b>\n"
f"{date_text}\n{info}</blockquote>"
)
class Schedule: class Schedule:
def __init__( def __init__(
self, self,
lessons: list[dict[str, Any]], lessons: list[dict[str, Any]],
group_name: str, target: ScheduleTarget,
groups: list[dict[str, Any]], groups: list[dict[str, Any]],
now: datetime.datetime | None = None, now: datetime.datetime | None = None,
) -> None: ) -> None:
current = now or datetime.datetime.now(MOSCOW) current = now or datetime.datetime.now(MOSCOW)
self.weekNumber = current.isocalendar().week self.weekNumber = current.isocalendar().week
self.weekDay = current.weekday() self.weekDay = current.weekday()
self.days: list[list[Lesson]] = [[] for _ in range(5)]
base_group = next( target_ids = {target["id"]}
( child_names: dict[str, str] = {}
group field = {"group": "group_ids", "room": "room_ids", "teacher": "teacher_ids"}[target["kind"]]
for group in groups if target["kind"] == "group":
if _compact_group_name(str(group.get("name", "")))
== _compact_group_name(group_name)
and group.get("parent_group_id") is None
),
None,
)
if base_group is None:
raise ValueError(f"Группа {group_name!r} не найдена")
base_id = str(base_group["id"])
child_names = { child_names = {
str(group["id"]): str(group["name"]) str(group["id"]): str(group["name"])
for group in groups for group in groups
if group.get("parent_group_id") == base_id if group.get("parent_group_id") == target["id"]
} }
target_ids = {base_id, *child_names} target_ids.update(child_names)
self.days: list[list[Lesson]] = [[] for _ in range(5)]
for raw_lesson in lessons: for raw_lesson in lessons:
weekday = raw_lesson.get("weekday") weekday = raw_lesson.get("weekday")
if not isinstance(weekday, int) or weekday not in range(1, 6): if not isinstance(weekday, int) or weekday not in range(1, 6):
continue continue
matched_ids = target_ids.intersection(map(str, raw_lesson.get("group_ids", []))) matched_ids = target_ids.intersection(map(str, raw_lesson.get(field, [])))
if not matched_ids: if not matched_ids:
continue continue
subgroup_names = [ subgroup_names = sorted(child_names[item] for item in matched_ids if item in child_names)
child_names[group_id] for group_id in matched_ids if group_id in child_names self.days[weekday - 1].append(Lesson(raw_lesson, subgroup_names))
]
self.days[weekday - 1].append(Lesson(raw_lesson, sorted(subgroup_names)))
for day in self.days: for day in self.days:
day.sort(key=lambda lesson: (lesson.start, lesson.name.casefold())) day.sort(key=lambda lesson: (lesson.start, lesson.name.casefold()))
@ -246,12 +271,24 @@ class Schedule:
] ]
def get_group_schedule(group_name: str) -> Schedule: def get_schedule(target: ScheduleTarget) -> Schedule:
groups = _load_result("groups") groups = _load_result("groups")
if not groups:
raise ScheduleAPIError("Сервер расписания пока не содержит групп")
lessons = _load_result("schedule/default") lessons = _load_result("schedule/default")
return Schedule(lessons, group_name, groups) return Schedule(lessons, target, groups)
def get_group_schedule(group_name: str) -> Schedule:
target = next(
(
{"kind": "group", "id": str(group["id"]), "name": str(group["name"])}
for group in get_main_groups()
if _compact(str(group["name"])) == _compact(group_name)
),
None,
)
if target is None:
raise ValueError(f"Группа {group_name!r} не найдена")
return get_schedule(target)
def generateDaySchedule( def generateDaySchedule(
@ -259,11 +296,10 @@ def generateDaySchedule(
) -> str: ) -> str:
even_week = schedule.weekNumber % 2 == 0 even_week = schedule.weekNumber % 2 == 0
weekday = schedule.weekDay weekday = schedule.weekDay
day_name = dayStrings[weekday]
header = ( header = (
f'<b>{day_name}, {"четная" if even_week else "нечетная"} неделя</b>\n' f'<b>{dayStrings[weekday]}, {"четная" if even_week else "нечетная"} неделя</b>\n'
if showOddWeekText if showOddWeekText
else f"<b>{day_name}</b>\n" else f"<b>{dayStrings[weekday]}</b>\n"
) )
lessons = schedule.today() lessons = schedule.today()
if not lessons: if not lessons:

View file

@ -6,63 +6,116 @@ from telebot.types import CallbackQuery, InlineQuery, Message
from models import logger from models import logger
from .schedule import Schedule, ScheduleAPIError, get_group_schedule from .schedule import (
Schedule,
ScheduleAPIError,
ScheduleTarget,
get_schedule,
group_target,
normalize_target,
target_key,
)
class User: class User:
def __init__(self, userID: int) -> None: def __init__(self, userID: int) -> None:
self.id: int = userID self.id = userID
try: try:
self.data: dict[str, Any] = json.load( with open(f"./users/{self.id}.json", "r", encoding="utf-8") as file:
open(f"./users/{self.id}.json", "r", encoding="utf-8") self.data: dict[str, Any] = json.load(file)
)
except FileNotFoundError: except FileNotFoundError:
self.data = {} self.data = {}
except (json.JSONDecodeError, OSError): except (json.JSONDecodeError, OSError):
logger.error("Bot", f'INVALID "{userID}" SAVED DATA! RESET!') logger.error("Bot", f'INVALID "{userID}" SAVED DATA! RESET!')
self.data = {} self.data = {}
self.state: str | bool | None = self.data.get("state", None) self.state: str | bool | None = self.data.get("state")
legacy_group = self.data.get("group")
self.active_target = normalize_target(self.data.get("active_schedule"))
if self.active_target is None and isinstance(legacy_group, str) and legacy_group:
self.active_target = group_target(legacy_group)
self.group: str | None = self.data.get("group", None) saved = [normalize_target(value) for value in self.data.get("saved_schedules", [])]
self.notifications: list[str] = self.data.get("notifications", []) # type: ignore self.saved_schedules: list[ScheduleTarget] = [value for value in saved if value]
self.valid: bool = self.data.get("valid", True) for name in self.data.get("saved_groups", []):
saved_groups = self.data.get("saved_groups", []) if isinstance(name, str) and name:
self.saved_groups = [ self._append_saved(group_target(name))
value for value in saved_groups if isinstance(value, str) and value.strip() if self.active_target:
] self._append_saved(self.active_target)
if self.group and self.group not in self.saved_groups:
self.saved_groups.append(self.group)
self.saved_groups = list(dict.fromkeys(self.saved_groups))
self.user: dict = self.data.get("user", {}) self.view_target = normalize_target(self.data.get("view_schedule"))
self.usage: dict = self.data.get( self.search_kind = self.data.get("search_kind")
results = [normalize_target(value) for value in self.data.get("search_results", [])]
self.search_results: list[ScheduleTarget] = [value for value in results if value]
self.notifications: list[str] = self.data.get("notifications", [])
self.valid = bool(self.data.get("valid", True))
self.user: dict[str, Any] = self.data.get("user", {})
self.usage: dict[str, Any] = self.data.get(
"usage", {"count": -1, "date": None, "time": None} "usage", {"count": -1, "date": None, "time": None}
) )
self.flags: list[str] = self.data.get('flags', []) self.flags: list[str] = self.data.get("flags", [])
if "notifyEvening" in self.data.keys(): if self.data.get("notifyEvening") and "evening" not in self.notifications:
if self.data["notifyEvening"] and "evening" not in self.notifications:
self.notifications.append("evening") self.notifications.append("evening")
if self.data.get("notifyBeforeLesson") and "lesson" not in self.notifications: if self.data.get("notifyBeforeLesson") and "lesson" not in self.notifications:
self.notifications.append("lesson") self.notifications.append("lesson")
self.save() self.save()
def schedule(self) -> Schedule | None: @property
if not self.group: def group(self) -> str | None:
self.valid = False return self.active_target["name"] if self.active_target else None
self.notifications = []
self.setState("broken") @group.setter
def group(self, name: str | None) -> None:
self.active_target = group_target(name) if name else None
@property
def saved_groups(self) -> list[str]:
return [target["name"] for target in self.saved_schedules if target["kind"] == "group"]
@saved_groups.setter
def saved_groups(self, names: list[str]) -> None:
non_groups = [target for target in self.saved_schedules if target["kind"] != "group"]
self.saved_schedules = non_groups + [group_target(name) for name in names if name]
def _append_saved(self, target: ScheduleTarget) -> None:
if not any(target_key(value) == target_key(target) for value in self.saved_schedules):
self.saved_schedules.append(target)
def is_saved(self, target: ScheduleTarget | None) -> bool:
return bool(
target
and any(target_key(value) == target_key(target) for value in self.saved_schedules)
)
def save_schedule(self, target: ScheduleTarget, make_active: bool = True) -> None:
self._append_saved(target)
if make_active:
self.active_target = target
self.save()
def remove_schedule(self, target: ScheduleTarget) -> None:
key = target_key(target)
self.saved_schedules = [value for value in self.saved_schedules if target_key(value) != key]
if self.active_target and target_key(self.active_target) == key:
self.active_target = self.saved_schedules[-1] if self.saved_schedules else None
self.save()
def keep_only_active(self) -> None:
self.saved_schedules = [self.active_target] if self.active_target else []
self.save()
def find_saved(self, name: str) -> ScheduleTarget | None:
return next((target for target in self.saved_schedules if target["name"] == name), None)
def schedule(self, target: ScheduleTarget | None = None) -> Schedule | None:
selected = target or self.active_target
if not selected:
return None return None
try: try:
return get_group_schedule(self.group) return get_schedule(selected)
except ValueError: except (ValueError, ScheduleAPIError) as error:
self.valid = False
self.notifications = []
self.setState("broken")
return None
except ScheduleAPIError as error:
logger.error("Schedule", f"[{self.id}] {error}") logger.error("Schedule", f"[{self.id}] {error}")
return None return None
@ -71,18 +124,24 @@ class User:
self.save() self.save()
def save(self) -> None: def save(self) -> None:
return json.dump( with open(f"./users/{self.id}.json", "w", encoding="utf-8") as file:
json.dump(
{ {
"state": self.state, "state": self.state,
"group": self.group, "group": self.group,
"active_schedule": self.active_target,
"saved_groups": self.saved_groups,
"saved_schedules": self.saved_schedules,
"view_schedule": self.view_target,
"search_kind": self.search_kind,
"search_results": self.search_results,
"notifications": self.notifications, "notifications": self.notifications,
"valid": self.valid, "valid": self.valid,
"saved_groups": self.saved_groups,
"user": self.user, "user": self.user,
"usage": self.usage, "usage": self.usage,
"flags": self.flags, "flags": self.flags,
}, },
open(f"./users/{self.id}.json", "w", encoding="utf-8"), file,
ensure_ascii=False, ensure_ascii=False,
indent=4, indent=4,
) )
@ -91,14 +150,13 @@ class User:
def validate(ctx: Message | InlineQuery | CallbackQuery) -> User: def validate(ctx: Message | InlineQuery | CallbackQuery) -> User:
try: try:
return User(ctx.from_user.id) # type: ignore return User(ctx.from_user.id) # type: ignore
except Exception as e: except Exception as error:
logger.error("Bot", f'INVALID "{ctx.from_user.id}" VALIDATION! {e}') logger.error("Bot", f'INVALID "{ctx.from_user.id}" VALIDATION! {error}')
return False # type: ignore return False # type: ignore
def getAllUsers() -> Iterator[User]: def getAllUsers() -> Iterator[User]:
for _, _, filenames in os.walk("./users"): for _, _, filenames in os.walk("./users"):
for userFile in filenames: for userFile in filenames:
if not userFile.endswith(".json"): if userFile.endswith(".json"):
continue
yield User(int(userFile.removesuffix(".json"))) yield User(int(userFile.removesuffix(".json")))