Compare commits

...

No commits in common. "master" and "main" have entirely different histories.
master ... main

45 changed files with 3016 additions and 23628 deletions

4
.gitignore vendored
View file

@ -9,7 +9,5 @@ timetables/*.json
.timetracker .timetracker
web/analytics/data.json web/analytics/data.json
DEBUG DEBUG
schedule_client.py
config.py config.py
.env .env
.venv

30
AGENTS.md Normal file
View file

@ -0,0 +1,30 @@
# About
This project: Telegram bot for displaying schedule for groups of students
Validate and run project via `uv`
### Commit message rules:
`{fix|add|impl|bot|event|web|any other...}: {message}`
# Folders:
## `./events/`
Separate event system implementation, i.e.: notifications about upcoming lessons
## `./bot/`
Main interactions with bot
## `./models/`
Main implementations
## `./web/`
Folder for static web content
## `./utils/schedule_client.py`
Main schedule database client to communicate with. Autogenerated from private repo, don't modify
# How to run
Copy `./config.example.py` to `./config.py`
Fill all variables in `./config.py`
Run via `uv run main.py`
...anything else???

View file

@ -1,32 +1,2 @@
# Исходный код затупса # Исходный код затупса
> Прошлая история была удалена > Прошлая история была удалена
# Запуск
```sh
cp config.example.py config.py
```
Далее заполнить конфиг
```
uv run main.py
```
Запускает два процесса:
- `eventloop` (`./event/__init__.py:run`) для отправки уведомлений и различных cron-like задач
- `mainloop` (`./bot/__init__.py:run`) сам бот
## Решение проблем
- Если не работает рассылка с json то надо создать папку `./temp` руками
- Оно вообще не запустится без бекенда расписания, при желании его можно зареверсить из этого исходного кода, либо я когда-нибудь сделаю версию без парсеров
## Карта и навигатор
Карта использует статичную светлую тему и не зависит от Telegram theme
переменных. Вкладка «Навигатор» отправляет в бот проверенный JSON-запрос
`route.request` через `Telegram.WebApp.sendData`. Обработчик находится в
`bot/navigator.py`, а протокол — в `models/navigator.py`.
Скомпилированный граф встроен в `web/map/data/navigator.json`: этот файл
одновременно читают бот и WebView. `calculate_route` строит путь без сетевых
запросов; внешний API навигатора используется только как эталон при проверке.
UI получает номера этажей и индексы точек и рисует маршрут по координатам из
того же manifest. SVG этажей содержат соответствующую группу `#Точки`.

View file

@ -4,10 +4,15 @@ from telebot.handler_backends import ContinueHandling
from models.bot import bot, send from models.bot import bot, send
from models.user import validate from models.user import validate
from models import logger
# @bot.message_handler(content_types = ['text'])
# def handleAll(message):
# logger.log('Bot', f'{message.from_user.id}: {message.text}')
# return ContinueHandling()
#! Root Handlers #! Root Handlers
from .debug import *
from .main.register_user import * from .main.register_user import *
# @bot.message_handler(content_types = ['text'], func = lambda m: not validate(m)) # @bot.message_handler(content_types = ['text'], func = lambda m: not validate(m))
# def cantValidateHandler(message): ... # def cantValidateHandler(message): ...
@ -16,26 +21,22 @@ from .main.register_user import *
# #? Another code # #? Another code
from .analytics import * from .analytics import *
from .navigator import *
from .iternal import * from .iternal import *
from .admin_schedule 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 *
def run(): def run():
import logging
logger.success('Bot', 'Started') logger.success('Bot', 'Started')
# try: bot.infinity_polling(logger_level=1) # try: bot.infinity_polling(logger_level=1)
try: try:
bot.infinity_polling(logger_level=logging.ERROR) bot.infinity_polling(logger_level=1)
...
except KeyboardInterrupt: except KeyboardInterrupt:
logger.error('Bot', 'Stopping...') logger.error('Bot', 'Stopping...')
return return
except Exception as e: except Exception as e:
logger.error('Bot', f'{e}') logger.error('Bot', f'{e}')

View file

@ -1,178 +0,0 @@
from __future__ import annotations
import html
import json
import secrets
import tempfile
from dataclasses import dataclass
from io import BytesIO
from pathlib import Path
from typing import Any
import requests
from telebot.types import CallbackQuery, InputFile, Message
from telebot.util import quick_markup
import config
from models import logger
from models.bot import bot, send
from models.schedule import clear_schedule_cache
@dataclass
class PendingArchive:
admin_id: int
filename: str
path: Path
PENDING_ARCHIVES: dict[str, PendingArchive] = {}
UPLOAD_CALLBACK_PREFIX = "$schedule_upload:"
CANCEL_CALLBACK_PREFIX = "$schedule_cancel:"
def _is_admin(user_id: int) -> bool:
return user_id in config.ADMINS
def _api_url(path: str) -> str:
return f"{config.SCHEDULE_BASE_URL.rstrip('/')}/{path.lstrip('/')}"
def _download_archive(message: Message) -> PendingArchive:
file_info = bot.get_file(message.document.file_id)
content = bot.download_file(file_info.file_path)
filename = message.document.file_name or "schedule.zip"
with tempfile.NamedTemporaryFile(prefix="zatups-schedule-", suffix=".zip", delete=False) as file:
file.write(content)
path = Path(file.name)
return PendingArchive(message.from_user.id, filename, path)
def _upload_archive(archive: PendingArchive) -> dict[str, Any]:
with archive.path.open("rb") as file:
response = requests.post(
_api_url("schedule/default/upload"),
files={"file": (archive.filename, file, "application/zip")},
timeout=(10, 300),
)
response.raise_for_status()
return response.json()
def _clear_database() -> dict[str, Any]:
response = requests.delete(
_api_url("admin/database"),
params={"confirm": "DELETE_ALL_DATA"},
timeout=30,
)
response.raise_for_status()
return response.json()
def _send_upload_result(admin_id: int, payload: dict[str, Any]):
content = json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8")
caption = "База применена" if payload.get("ok") else "Архив обработан с ошибками"
return bot.send_document(
admin_id,
InputFile(BytesIO(content), "schedule-upload-result.json"),
caption=caption,
)
def _format_request_error(error: requests.RequestException) -> str:
response = error.response
if response is None:
return str(error)
try:
detail = response.json()
except ValueError:
detail = response.text
return json.dumps(detail, ensure_ascii=False) if isinstance(detail, dict) else str(detail)
@bot.message_handler(
content_types=["document", "file"],
func=lambda message: _is_admin(message.from_user.id)
and bool(message.document)
and (message.document.file_name or "").lower().endswith(".zip"),
)
def schedule_archive(message: Message):
try:
archive = _download_archive(message)
except Exception as error:
logger.error("Schedule", f"Archive download failed: {error}")
return send(message, f"Не удалось скачать архив: <code>{html.escape(str(error))}</code>")
token = secrets.token_urlsafe(8)
PENDING_ARCHIVES[token] = archive
return send(
message,
"Вы уверены, что хотите отправить архив в API?\n"
"Сбросить БД в API можно командой /reset_schedule_db",
reply_markup=quick_markup(
{
"Отправить": {"callback_data": UPLOAD_CALLBACK_PREFIX + token},
"Отменить": {"callback_data": CANCEL_CALLBACK_PREFIX + token},
},
row_width=1,
),
)
@bot.callback_query_handler(func=lambda call: str(call.data).startswith(CANCEL_CALLBACK_PREFIX))
def cancel_schedule_upload(call: CallbackQuery):
if not _is_admin(call.from_user.id):
return
token = str(call.data).removeprefix(CANCEL_CALLBACK_PREFIX)
archive = PENDING_ARCHIVES.get(token)
if archive and archive.admin_id == call.from_user.id:
PENDING_ARCHIVES.pop(token, None)
archive.path.unlink(missing_ok=True)
bot.answer_callback_query(call.id, "Отменено")
@bot.callback_query_handler(func=lambda call: str(call.data).startswith(UPLOAD_CALLBACK_PREFIX))
def upload_schedule_archive(call: CallbackQuery):
if not _is_admin(call.from_user.id):
return
token = str(call.data).removeprefix(UPLOAD_CALLBACK_PREFIX)
archive = PENDING_ARCHIVES.get(token)
if not archive or archive.admin_id != call.from_user.id:
return bot.answer_callback_query(call.id, "Архив не найден или уже отправлен")
bot.answer_callback_query(call.id, "Отправляю архив")
try:
payload = _upload_archive(archive)
except requests.RequestException as error:
logger.error("Schedule", f"Archive upload failed: {error}")
return send(
call.from_user.id,
f"API отклонил архив: <code>{html.escape(_format_request_error(error))}</code>",
)
except (OSError, ValueError) as error:
logger.error("Schedule", f"Archive upload failed: {error}")
return send(call.from_user.id, f"Не удалось отправить архив: <code>{html.escape(str(error))}</code>")
PENDING_ARCHIVES.pop(token, None)
archive.path.unlink(missing_ok=True)
clear_schedule_cache()
return _send_upload_result(call.from_user.id, payload)
@bot.message_handler(commands=["reset_schedule_db"], func=lambda message: _is_admin(message.from_user.id))
def reset_schedule_database(message: Message):
try:
_clear_database()
except requests.RequestException as error:
logger.error("Schedule", f"Database reset failed: {error}")
return send(
message,
f"Не удалось очистить базу: <code>{html.escape(_format_request_error(error))}</code>",
)
except ValueError as error:
logger.error("Schedule", f"Database reset failed: {error}")
return send(message, f"API вернул некорректный ответ: <code>{html.escape(str(error))}</code>")
clear_schedule_cache()
return send(message, "База очищена")

View file

@ -1,32 +0,0 @@
import os
import config
from rich import print as pprint
from telebot.handler_backends import ContinueHandling
from models.bot import bot, Markup, Strings, send, WebAppInfo, KeyboardButton, ReplyKeyboardMarkup, Message
from models import logger
if os.getenv('ZATUPS_DEBUG'):
@bot.message_handler(content_types=['text', 'audio', 'document', 'animation', 'game', 'photo', 'sticker', 'video', 'video_note', 'voice', 'location', 'contact', 'venue', 'dice', 'new_chat_members', 'left_chat_member', 'new_chat_title', 'new_chat_photo', 'delete_chat_photo', 'group_chat_created', 'supergroup_chat_created', 'channel_chat_created', 'migrate_to_chat_id', 'migrate_from_chat_id', 'pinned_message', 'invoice', 'successful_payment', 'connected_website', 'poll', 'passport_data', 'proximity_alert_triggered', 'video_chat_scheduled', 'video_chat_started', 'video_chat_ended', 'video_chat_participants_invited', 'web_app_data', 'message_auto_delete_timer_changed', 'forum_topic_created', 'forum_topic_closed', 'forum_topic_reopened', 'forum_topic_edited', 'general_forum_topic_hidden', 'general_forum_topic_unhidden', 'write_access_allowed', 'user_shared', 'chat_shared', 'story'])
def handleAll(message: Message):
logger.log('Input', f'[{message.from_user.id if message.from_user else "??"}]: {message.content_type}')
print(logger.color.reset(), end='')
pprint(message.json)
return ContinueHandling()
@bot.message_handler(
content_types = ['text'],
func = lambda m: '/web_' in m.text
)
def web(message: Message):
message.text = str(message.text) #! typechecker error fix
markup = ReplyKeyboardMarkup(resize_keyboard=True)
markup.add(
Strings.back_to_home,
KeyboardButton(
text=message.text,
web_app=WebAppInfo(url=config.WEB_BASE_URL+message.text)
),
row_width=1
)
send(message, message.text, markup)

View file

@ -1,74 +1,138 @@
from telebot.types import InlineQuery from telebot.types import (InlineQueryResultArticle, InputTextMessageContent, InlineQuery)
from models.bot import bot, Inlines
from models.user import validate
from models.schedule import generateDaySchedule, dayStrings
from models import logger from models import logger
from models.bot import Inlines, bot
from models.schedule import dayStrings, generateDaySchedule, generateWeekSchedule
from models.user import validate
@bot.inline_handler(lambda query: True) @bot.inline_handler(lambda query: True)
def default_query(query: InlineQuery): def default_query(query: InlineQuery):
user = validate(query) user = validate(query)
inline = Inlines(query.id) inline = Inlines(query.id)
if not user.group: if not user:
logger.warn('Bot', 'Inlinue from unknown user invoked')
inline.add( inline.add(
"Расписание недоступно ❌", 'Расписание недоступно ❌',
"Запусти бота, чтобы выбрать группу", 'Запусти бота, чтобы использовать эту функцию',
"Не удалось получить расписание. Сначала запусти бота: https://t.me/zatups_bot", 'Не удалось получить расписание. Пользователь не запустил бота ❌ \n\nhttps://t.me/zatups_bot'
) )
else:
schedule = user.schedule()
if not schedule: else:
if 'today':
s = user.schedule()
weektype = 'четная' if s.weekNumber%2 == 0 else 'нечетная'
description = dayStrings[s.weekDay]
output = generateDaySchedule(s, isToday = True) if s.weekDay < 5 else f'<b>{description}</b>\nСегодня пар нету 🥰'
inline.add( inline.add(
"Расписание недоступно ❌", 'Расписание на сегодня 🔥',
"Сервер расписания сейчас недоступен", f'{description}, {weektype.lower()} неделя',
"Не удалось получить расписание. Попробуй ещё раз позже.", output
) )
else:
week_type = "четная" if schedule.weekNumber % 2 == 0 else "нечетная" if 'tomorrow':
description = dayStrings[schedule.weekDay] s = user.schedule()
output = False
description = dayStrings[(s.weekDay+1)%len(dayStrings)]
if s.weekDay in [4, 5]:
output = f'<b>{description}</b>\nЗавтра пар нету 🥰'
elif s.weekDay == 6:
s.weekDay = 0
s.weekNumber += 1
else:
s.weekDay += 1
if not output: output = generateDaySchedule(s)
weektype = 'четная' if s.weekNumber%2 == 0 else 'нечетная'
inline.add( inline.add(
"Расписание на сегодня 🔥", 'Расписание на завтра 🍀',
f"{description}, {week_type} неделя", f'{description}, {weektype.lower()} неделя',
generateDaySchedule(schedule, isToday=True), output
) )
tomorrow = user.schedule() if 'week':
if tomorrow: s = user.schedule()
if tomorrow.weekDay == 6: description = f'Сейчас {"четная" if s.weekNumber%2==0 else "нечетная"} неделя'
tomorrow.weekDay = 0 output = f'<i>Расписание на </i><b>эту</b><i> {"четную" if s.weekNumber%2==0 else "нечетную"} неделю</i>\n'
tomorrow.weekNumber += 1
else:
tomorrow.weekDay += 1
tomorrow_description = dayStrings[tomorrow.weekDay]
tomorrow_week_type = (
"четная" if tomorrow.weekNumber % 2 == 0 else "нечетная"
)
inline.add(
"Расписание на завтра 🍀",
f"{tomorrow_description}, {tomorrow_week_type} неделя",
generateDaySchedule(tomorrow),
)
week = user.schedule() if s.weekDay == 6:
if week: s.weekDay = 0
title = "эту" s.weekNumber += 1
if week.weekDay == 6: description = f'Следующая {"четная" if s.weekNumber%2==0 else "нечетная"} неделя'
week.weekNumber += 1 output = f'<i>Расписание на </i><b>следующую</b><i> {"четную" if s.weekNumber%2==0 else "нечетную"} неделю</i>\n'
title = "следующую"
week_type = "четную" if week.weekNumber % 2 == 0 else "нечетную"
output = (
f"<i>Расписание на </i><b>{title}</b><i> {week_type} неделю</i>\n"
f"{generateWeekSchedule(week)}"
)
inline.add(
"Расписание на неделю 📅",
f"{week_type.capitalize()} неделя",
output,
)
logger.log("Bot", f"Inline from {user.id}") for index, _ in enumerate(s.days):
s.weekDay = index
output += generateDaySchedule(s, False) +'\n\n'
inline.add(
'Расписание на неделю 📅',
description,
output
)
logger.log('Bot', f'Inline from {user.id}')
try: try:
return inline.send() return inline.send()
except Exception as error: except Exception as e:
logger.error("Bot", f"Inline.send() -> {error}") logger.error('Bot', f'Inline.send() -> {e}')

View file

@ -47,12 +47,7 @@ def sendConfirm(message: Message):
'Н': {'callback_data': 'ignore'} 'Н': {'callback_data': 'ignore'}
}, row_width=1)) }, row_width=1))
@bot.message_handler( @bot.message_handler(content_types = ['document', 'file'], func = lambda m: m.from_user.id in config.ADMINS)
content_types=['document', 'file'],
func=lambda message: message.from_user.id in config.ADMINS
and bool(message.document)
and (message.document.file_name or '').lower().endswith('.json'),
)
def massSendHandle(message: Message): def massSendHandle(message: Message):
global REMEMBER_MESSAGE_DATA, REMEMBER_JSON_DATA, REMEMBER_PHOTO_ID global REMEMBER_MESSAGE_DATA, REMEMBER_JSON_DATA, REMEMBER_PHOTO_ID
REMEMBER_PHOTO_ID = False REMEMBER_PHOTO_ID = False

View file

@ -2,9 +2,9 @@ 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, Strings, send, Receive, Default from models.bot import bot, Markup, send, Receive, Default
from models.user import validate from models.user import validate
from models.schedule import dayStrings, generateDaySchedule, generateWeekSchedule from models.schedule import generateDaySchedule, dayStrings
from utils.calendar import ICS from utils.calendar import ICS
@ -15,13 +15,9 @@ 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 == Strings.back_to_home) @bot.message_handler(content_types = ['text'], func = lambda m: m.text == 'На главную 🏠')
def returnToDefaultState(message: Message): def returnToDefaultState(message: Message):
u = validate(message) u = validate(message)
if not u.active_target and u.state != 'scheduleDeepLink':
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))
@ -34,40 +30,24 @@ def forceReturnToDefaultState(query: CallbackQuery):
@bot.message_handler(content_types = ['text'], func = lambda m: validate(m).state == 'default') @bot.message_handler(content_types = ['text'], func = lambda m: validate(m).state == 'default')
def defaultState(message: Message): def defaultState(message: Message):
u = validate(message) u = validate(message)
s = u.schedule()
# print(u.id, message.text)
if message.text[0] == '/': return ContinueHandling() # type: ignore if message.text[0] == '/': return ContinueHandling() # type: ignore
if message.text == Strings.search: if message.text == f'{u.group} 🔄':
from bot.schedule.search import open_search
return open_search(message, u)
if message.text == Strings.current_schedule_name_F.format(u.group):
u.setState('savedGroups') u.setState('savedGroups')
return send( return send(u.id, f'<b>Сохраненные группы</b>\nЗдесь список групп, которые ранее были использованы, чтобы быстро между ними переключаться. \n\n<i>Активная группа: {u.group}</i>', Markup.saved_groups(u))
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
if message.text in schedule_actions and not s:
return send(
u.id,
'Не удалось получить расписание. Попробуй ещё раз позже или перезапусти бота через /start.',
reply_markup=Markup.default(u),
)
match message.text: match message.text:
case Strings.today: case 'Сегодня 🔥':
if s.weekDay > 4:
return send(u.id, f'<b>{dayStrings[s.weekDay]}</b>\nСегодня пар нет 🥰')
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 Strings.tomorrow: case 'Завтра 🍀':
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:
@ -84,22 +64,18 @@ def defaultState(message: Message):
s.weekDay = 0 s.weekDay = 0
s.weekNumber += 1 s.weekNumber += 1
resultString = f'<i>Расписание на </i><b>следующую</b><i> {"четную" if s.weekNumber%2==0 else "нечетную"} неделю</i>\n' resultString = f'<i>Расписание на </i><b>следующую</b><i> {"четную" if s.weekNumber%2==0 else "нечетную"} неделю</i>\n'
resultString += generateWeekSchedule(s) for index, _ in enumerate(s.days):
s.weekDay = index
resultString += generateDaySchedule(s, False) +'\n\n'
return send(u.id, resultString, reply_markup = Markup.default(u)) return send(u.id, resultString, reply_markup = Markup.default(u))
case Strings.schedule_another_days: case 'Другие дни 📁':
u.setState('otherDays') u.setState('otherDays')
u.view_target = u.active_target return send(u.id, f'<b>Сегодня {dayStrings[s.weekDay].lower()}, {"четная" if s.weekNumber%2==0 else "нечентная"} неделя</b>\n\nВыбери нужный день/пункт', reply_markup = Markup.other())
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 Strings.notifications: case 'Уведомления ⏰':
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>
@ -114,7 +90,7 @@ def defaultState(message: Message):
<i>Выбери нужную кнопку, чтобы включить/выключить уведомления</i>\ <i>Выбери нужную кнопку, чтобы включить/выключить уведомления</i>\
''', reply_markup = Markup.notifications()) ''', reply_markup = Markup.notifications())
case Strings.support: case 'Поддержать 💞':
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))
@ -194,3 +170,4 @@ def sendHelp(message: Message):
[bot.forward_message(adminID, message.from_user.id, message.id) for adminID in ADMINS] # type: ignore [bot.forward_message(adminID, message.from_user.id, message.id) for adminID in ADMINS] # type: ignore
send(message, '<b>Сообщение переслано разработчику 🔧</b>\nСпасибо за обращение', Markup.default(u)) send(message, '<b>Сообщение переслано разработчику 🔧</b>\nСпасибо за обращение', Markup.default(u))

View file

@ -1,148 +1,97 @@
import json import json
from telebot.types import Message
from telebot.types import Message, ReplyKeyboardRemove
from telebot.util import quick_markup from telebot.util import quick_markup
import config
from models import logger from models import logger
from models.bot import Markup_v1 as Markup, bot, send from models.user import User, validate, getAllUsers
from models.schedule import ( from models.bot import (Markup_v1 as Markup, bot, send)
ScheduleAPIError, from models.schedule import generateDaySchedule
find_main_groups,
group_exists,
group_target,
search_targets,
)
from models.user import getAllUsers, validate
from utils import convert
@bot.message_handler(commands = ['start'])
@bot.message_handler(commands=["start"])
def start(message: Message): def start(message: Message):
user = validate(message) u = validate(message)
message.text = str(message.text) if u and 'migrate' in message.text: # type: ignore
u.setState('default')
payload = message.text.split()[-1] logger.log('Bot', f'{message.from_user.id} migrated') # type: ignore
if payload.startswith("_s"): send(message, 'Добро пожаловать снова 💞', reply_markup = Markup.default(u))
query = payload send(message, f'''<b>Что нового</b>
search_target_kind = ({ <blockquote>1. Теперь доступны все факультеты, курсы и группы
't': 'teacher', 2. Затупсика можно упомянуть в любом чате, даже в личке и группе, и он отправит актуальное расписание!
'r': 'room', <i>Для этого напиши <code>@zatups_bot</code> в любом чате</i>
'g': 'group' 3. Теперь расписания отображаются корректно всегда, даже с подгруппами</blockquote>
}).get(query[2:3])
if not search_target_kind:
return send(message, "Не удалось определить тип расписания по ссылке.")
search_query = convert.decode(query[3:])
try:
results = search_targets(search_target_kind, search_query, limit=10)
except ScheduleAPIError as error:
logger.error("Schedule", f"[{user.id}] {error}")
return send(message, "Сервер расписания сейчас недоступен. Попробуй открыть ссылку позже.")
if not results:
return send(message, "Это расписание больше не найдено на сервере.")
from bot.schedule.search import open_schedule_viewer
return open_schedule_viewer(message, results[0], from_search=False) <i>Чтобы воспользоваться функцией отправки расписания в любом чате, напиши <code>@zatups_bot</code> в любом чате, выбери бота из списка, а после нажми на нужный день - сегодня, завтра, или на всю неделю.\nКстати - в следующий раз достаточно просто написать @ и телеграм сам выдаст затупсика в списке!</i>''')
return
logger.log('Bot', f'[{u.id}] /start detected, Total users: {len(list(getAllUsers()))+1}')
if user.group and "migrate" in message.text: json.dump(
user.setState("default") {
logger.log("Bot", f"{user.id} migrated") 'state': 'selectTopic',
return send(message, "Добро пожаловать снова 💞", reply_markup=Markup.default(user))
was_registered = bool(user.group) 'topic': '',
logger.log("Bot", f"[{user.id}] /start detected, Total users: {len(list(getAllUsers()))}") 'course': '',
json.dump( 'group': '',
{ 'saved_groups': u.saved_groups if u else [],
"state": "selectGroup", 'notifyEvening': False,
"group": None, 'notifyBeforeLesson': False,
"active_schedule": None,
"saved_groups": user.saved_groups,
"saved_schedules": user.saved_schedules,
"view_schedule": None,
"notifications": [],
"valid": True,
"user": user.user,
"usage": user.usage,
"flags": user.flags,
},
open(f"./users/{user.id}.json", "w", encoding="utf-8"),
ensure_ascii=False,
indent=4,
)
prefix = (
"<i>Вы начали процедуру перезапуска бота. Уведомления отключены.</i>\n\n"
if was_registered
else ""
)
send(
message,
prefix + "Напиши название своей группы. Например: <code>АТ-501</code>",
reply_markup=ReplyKeyboardRemove(),
)
@bot.message_handler(content_types=["text"], func=lambda message: "нету ⚠" in message.text)
def notFoundHandler(message: Message):
bot.reply_to(
message,
"""
<b>Не расстраивайся!</b>
<i>Напиши разработчику, если твоей группы нет в расписании.</i>
""",
reply_markup=quick_markup(
{
"Написать разработчику 💞": {"url": "https://t.me/m/Gg00FvJmZDM6"},
"Перезапустить бота 🤖": {"url": config.TELEGRAM_URL + "?start=r"},
}, },
row_width=1, open(f'./users/{message.from_user.id}.json', 'w', encoding='utf-8'), # type: ignore
), ensure_ascii = False,
) indent = 4
)
send(message, '''
<b>Бот на стадии разработки!</b>
- <i>Доступны не все функции, группы</i>
- <i>Возможны баги, фризы, затупы итд итп</i>
- <i>Бот изначально не планировался для публичного использования</i>
Просто имей это ввиду😥
''')
#FIXME
send(message, 'Выбери свой факультет' if not u else '<i>Вы начали процедуру перезапуска бота. Все настройки и данные удалены, включая уведомления</i>\nВыбери новый факультет', reply_markup = Markup.selectTopic())
def _finish_registration(message: Message, group_name: str) -> None:
user = validate(message) @bot.message_handler(content_types = ['text'], func = lambda m: ' нету ⚠' in m.text)
user.save_schedule(group_target(group_name)) def notFoundHandler(message: Message):
user.valid = True bot.reply_to(message, '''
user.setState("default") <b>Не расстраивайся!</b>
send( <i>Напиши разработчику, чтобы он добавил твой факультет/курс/группу</i>
message,
""" <blockquote>
К сожалению, для поддержки работы бота нужно много мощностей и материальные вложения.
Бюджет, выделенный на разработку бота - <i>2 банки пива</i>, поэтому в целях экономии в боте пока доступны не все группы
</blockquote>''', reply_markup = quick_markup({'Написать разработчику 💞': {'url': 'https://t.me/m/Gg00FvJmZDM6'}, 'Перезапустить бота 🤖': {'url': 'https://t.me/zatups_bot?start=r'}}, row_width = 1))
@bot.message_handler(content_types = ['text'], func = lambda m: validate(m).state == 'selectTopic')
def selectTopicState(message: Message):
u = validate(message)
u.topic = message.text
u.setState('selectCourse')
send(message, 'Выбери свой курс', reply_markup = Markup.selectCourse(u.topic))
@bot.message_handler(content_types = ['text'], func = lambda m: validate(m).state == 'selectCourse')
def selectCourseState(message: Message):
u = validate(message)
u.course = message.text
u.setState('selectGroup')
send(message, 'Выбери свою группу', reply_markup = Markup.selectGroup(u.topic, u.course))
@bot.message_handler(content_types = ['text'], func = lambda m: validate(m).state == 'selectGroup')
def selectGroupState(message: Message):
u = validate(message)
u.group = message.text
u.setState('default')
send(message, '''
<b>Приятного использования!</b> <b>Приятного использования!</b>
Здесь можно посмотреть расписание и настроить уведомления о парах. Здесь можно посмотреть расписание в более приятном виде, а также настроить уведомления о парах!
<i>Изменить группу можно через /start.</i> <i>Изменить факультет/курс/группу можно просто перезапустив бота</i>
""", ''', reply_markup = Markup.default(u))
reply_markup=Markup.default(user),
)
@bot.message_handler(
content_types=["text"], func=lambda message: validate(message).state == "selectGroup"
)
def selectGroupState(message: Message):
query = (message.text or "").strip()
try:
matches = find_main_groups(query)
if len(matches) == 1 and group_exists(query):
return _finish_registration(message, matches[0])
except ScheduleAPIError as error:
logger.error("Schedule", f"[{message.from_user.id}] {error}")
return send(
message,
"Сервер расписания сейчас недоступен. Попробуй отправить название группы позже.",
)
if not matches:
return send(
message,
"Похожих групп не нашлось. Проверь название и попробуй ещё раз.",
reply_markup=Markup.selectGroup([]),
)
send(
message,
"Выбери свою группу из самых похожих вариантов или введи название точнее.",
reply_markup=Markup.selectGroup(matches),
)

View file

@ -1,132 +0,0 @@
from __future__ import annotations
import html
import re
import time
from pathlib import Path
from urllib.parse import urlencode
import config
from telebot.types import InlineKeyboardButton, InlineKeyboardMarkup, Message, WebAppInfo
from models import logger
from models.bot import bot, send
from models.navigator import (
RouteRequest,
calculate_route,
encode_route,
parse_route_request,
)
_SEEN_REQUESTS: dict[tuple[int, str], float] = {}
_SEEN_TTL = 300.0
_ROOMS_BY_FLOOR: dict[str, set[str]] | None = None
def _map_rooms() -> dict[str, set[str]]:
"""Read the room IDs from the same static SVGs used by the WebApp."""
global _ROOMS_BY_FLOOR
if _ROOMS_BY_FLOOR is not None:
return _ROOMS_BY_FLOOR
rooms: dict[str, set[str]] = {}
pattern = re.compile(r'serif:id="([^"]+)"')
root = Path(__file__).resolve().parents[1] / "web" / "map" / "images"
for floor in range(1, 7):
path = root / f"{floor}.svg"
try:
rooms[str(floor)] = set(pattern.findall(path.read_text(encoding="utf-8")))
except OSError:
rooms[str(floor)] = set()
_ROOMS_BY_FLOOR = rooms
return rooms
def _validate_map_rooms(request: RouteRequest) -> bool:
rooms = _map_rooms()
# A missing bundle should not make the bot unusable during deployment;
# the actual navigator adapter will perform its own manifest validation.
if not any(rooms.values()):
return True
for endpoint in (request.start, request.end):
if endpoint.floor and endpoint.room not in rooms.get(endpoint.floor, set()):
return False
if not endpoint.floor and not any(endpoint.room in values for values in rooms.values()):
return False
return True
def _remember_request(user_id: int, request_id: str) -> bool:
now = time.monotonic()
expired = [key for key, timestamp in _SEEN_REQUESTS.items() if now - timestamp > _SEEN_TTL]
for key in expired:
_SEEN_REQUESTS.pop(key, None)
key = (user_id, request_id)
if key in _SEEN_REQUESTS:
return False
_SEEN_REQUESTS[key] = now
return True
def _map_url(request: RouteRequest, route: dict | None) -> str:
base = f"{config.WEB_BASE_URL.rstrip('/')}/map/"
params = {
"v": "15",
"mode": "navigator",
"start": request.start.room,
"end": request.end.room,
"status": "built" if route else "failed",
}
if request.start.floor:
params["sf"] = request.start.floor
if request.end.floor:
params["ef"] = request.end.floor
if route:
params["route"] = encode_route(route)
return f"{base}?{urlencode(params)}"
def _result_markup(url: str) -> InlineKeyboardMarkup:
markup = InlineKeyboardMarkup()
markup.add(InlineKeyboardButton("Показать маршрут", web_app=WebAppInfo(url)))
return markup
def _send_route_result(message: Message, request: RouteRequest, route: dict | None):
start = html.escape(request.start.room)
end = html.escape(request.end.room)
if route:
text = f"<b>Маршрут построен</b>\n\n{start} - {end}"
else:
text = (
f"<b>Маршрут не построен</b>\n\n{start} - {end}\n"
"Не удалось найти путь между выбранными аудиториями."
)
markup = _result_markup(_map_url(request, route)) if route else None
return send(message, text, reply_markup=markup)
@bot.message_handler(content_types=["web_app_data"])
def route_request_from_webapp(message: Message):
"""Receive ``Telegram.WebApp.sendData`` from the navigator tab."""
data = getattr(getattr(message, "web_app_data", None), "data", "")
try:
request = parse_route_request(data)
except (TypeError, ValueError, UnicodeError) as error:
logger.error("Navigator", f"[{message.from_user.id}] invalid request: {error}")
return send(message, "Не удалось распознать запрос маршрута. Открой карту заново.")
if not _remember_request(message.from_user.id, request.request_id):
return
if not _validate_map_rooms(request):
return send(message, "Одной из аудиторий нет на карте. Выбери аудитории из подсказок.")
try:
route = calculate_route(request)
except Exception as error: # keep a future adapter from breaking polling
logger.error("Navigator", f"[{message.from_user.id}] route calculation failed: {error}")
route = None
return _send_route_result(message, request, route)

View file

@ -1,40 +1,25 @@
from telebot.types import Message from telebot.types import Message
from models.bot import Markup, Strings, bot, send from models.bot import bot, Markup, send
from models.user import validate from models.user import validate
from models.schedule import generateDaySchedule
from .search import render_schedule_action @bot.message_handler(content_types = ['text'], func = lambda m: validate(m).state == 'otherDays')
def otherState(message: Message):
u = validate(message)
possibleResponces = ['ПН', 'ВТ', 'СР', 'ЧТ', 'ПТ']
if message.text not in possibleResponces and message.text not in ['Четная 📅', 'Нечетная 📅']: return
result: str = ''
s = u.schedule()
if message.text in possibleResponces:
s.weekDay = possibleResponces.index(message.text)
result = generateDaySchedule(s)
else:
s.weekNumber = 2 if message.text == 'Четная 📅' else 1
result = f'<i>{message.text.split(" ")[0]} неделя</i>\n'
for index, _ in enumerate(s.days):
s.weekDay = index
result += generateDaySchedule(s, False) +'\n\n'
return send(u.id, result, reply_markup = Markup.other())
@bot.message_handler(
content_types=["text"], func=lambda message: validate(message).state == "otherDays"
)
def otherState(message: Message):
user = validate(message)
target = user.view_target or user.active_target
if not target:
user.setState("default")
return send(message, "Сначала выбери расписание через поиск", Markup.default(user))
if message.text == Strings.save:
user.save_schedule(target)
return send(
message,
f"Расписание <b>{target['name']}</b> сохранено",
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),
)
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,50 +1,33 @@
from telebot.types import Message from telebot.types import Message, CallbackQuery
from telebot.util import quick_markup
from models.bot import Markup, Strings, bot, send from models.bot import bot, Markup, 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')
def savedGroups(message: Message):
u = validate(message)
if message.text == 'Добавить группу 🔆':
return send(message, 'Чтобы добавить группу используй команду /start')
elif message.text == 'Удалить группы 🗑':
return send(message, 'Это действие нельзя отменить, точно удалить все сохраненные группы?', quick_markup({
'Да, удалить все': {'callback_data': '$delsaved'}
}))
elif message.text in u.saved_groups:
changed = message.text != u.group
if changed: u.group = message.text
u.setState('default')
send(message, 'Группа изменена ✨' if changed else 'Ничего не поменялось ❌', Markup.default(u))
@bot.message_handler( @bot.callback_query_handler(func=lambda call: '$delsaved' == call.data)
content_types=["text"], func=lambda message: validate(message).state == "savedGroups" def confirmDeleteAll(call: CallbackQuery):
) u = validate(call)
def savedGroups(message: Message): u.setState('default')
user = validate(message) try:
u.saved_groups = [u.group]
if message.text == Strings.add_schedule: u.save()
from .search import open_search send(u.id, 'Сохраненные группы были удалены 🗑', Markup.default(u))
except:
return open_search(message, user) send(u.id, 'Не удалось удалить группы', Markup.default(u))
if user.active_target and message.text == Strings.delete_active_schedule_F.format(user.group):
removed_name = user.group
user.remove_schedule(user.active_target)
if not user.active_target:
from .search import open_search
send(message, f'Расписание <b>{removed_name}</b> удалено. Выбери новое расписание.')
return open_search(message, user)
user.setState("savedGroups")
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),
)

View file

@ -1,168 +0,0 @@
from telebot.types import Message
from models import logger
from models.bot import Markup, Strings, bot, send
from models.schedule import (
ScheduleAPIError,
ScheduleTarget,
generateDaySchedule,
generateWeekSchedule,
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, from_search: bool = True
) -> None:
user = validate(message)
user.view_target = target
user.setState("scheduleSearch" if from_search else "scheduleDeepLink")
send(
message,
f"<b>{target['name']}</b>\nВыбери нужный день или неделю",
reply_markup=Markup.schedule(
user.is_saved(target), from_search=from_search, include_today=not from_search
),
)
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
return f"<i>{action.split()[0]} неделя</i>\n{generateWeekSchedule(schedule)}"
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 in {"scheduleSearch", "scheduleDeepLink"},
)
def search_schedule_viewer(message: Message):
user = validate(message)
from_search = user.state == "scheduleSearch"
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=from_search, include_today=not from_search),
)
if message.text == Strings.delete:
user.remove_schedule(target)
return send(
message,
f"Расписание <b>{target['name']}</b> удалено из сохраненных",
reply_markup=Markup.schedule(False, from_search=from_search, include_today=not from_search),
)
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=from_search, include_today=not from_search
),
)

View file

@ -1,9 +1,6 @@
# Telegram bot token # Telegram bot token
TOKEN: str = '...' TOKEN: str = '...'
# Full url to bot
TELEGRAM_URL: str = 'https://t.me/your_cool_bot'
# Telegram proxy url (None for no proxy) # Telegram proxy url (None for no proxy)
TELEGRAM_PROXY: str | None = None TELEGRAM_PROXY: str | None = None
@ -11,10 +8,7 @@ TELEGRAM_PROXY: str | None = None
WEB_BASE_URL: str = 'https://zatups.example.com' WEB_BASE_URL: str = 'https://zatups.example.com'
# Base URL for the schedule (should be runned separately) # Base URL for the schedule (should be runned separately)
SCHEDULE_BASE_URL: str = 'http://10.9.8.3:18822' SCHEDULE_BASE_URL: str = 'http://schedule_backend:8000/'
# The navigator graph is embedded in web/map/data/navigator.json and is shared
# by the bot and the WebView.
# Telegram user IDs # Telegram user IDs
ADMINS: list[int] = [5016590523, 5001115363] ADMINS: list[int] = [5016590523, 5001115363]

View file

@ -19,11 +19,18 @@ class Lesson(Event):
t = s.today() t = s.today()
if t == []: continue if t == []: continue
w = f'<b>Скоро начнется пара</b>\n' w = f'<b>Скоро начнется пара</b>\n'
for lesson in t: for lessonList in t:
if [lessonTime.hour, lessonTime.minute] == lesson.start: if len(lessonList) == 1:
w += lesson.text(isToday=True) if [lessonTime.hour, lessonTime.minute] == lessonList[0].start:
w += lessonList[0].text(isToday = True)
else:
lesson = lessonList[1 if s.weekNumber%2 == 0 else 0]
if lesson and [lessonTime.hour, lessonTime.minute] == lesson.start:
w += lesson.text(isToday = True)
if len(w.split('\n')) > 2: send(user.id, w) if len(w.split('\n')) > 2: send(user.id, w)
addEvent(Lesson) addEvent(Lesson)

10
main.py
View file

@ -4,13 +4,10 @@ if __name__ == '__main__':
sys.path.append(os.path.dirname(os.path.abspath(__file__))) sys.path.append(os.path.dirname(os.path.abspath(__file__)))
import models.logger import models.logger
# Reuse the interpreter that launched this process. Calling ``uv`` from CMD = 'python3.13.exe' if sys.platform == 'win32' else 'python3'
# a child shell breaks when uv was installed outside PATH (and also makes
# ``uv run main.py`` recurse through a second environment).
CMD = 'python3.13.exe' if sys.platform == 'win32' else f'"{sys.executable}"'
PROCESSES = [ PROCESSES = [
Process(target=bot.run if sys.platform == 'win32' else os.system, args=() if sys.platform == 'win32' else (f'{CMD} -c "import bot; bot.run()"',)), # Process(target=bot.run if sys.platform == 'win32' else os.system, args=() if sys.platform == 'win32' else (f'{CMD} -c "import bot; bot.run()"',)),
# Process(target=bot.run), Process(target=bot.run),
Process(target=event.run), Process(target=event.run),
] ]
@ -26,3 +23,4 @@ if __name__ == '__main__':
except Exception as e: except Exception as e:
models.logger.error('Main', f'{e}') models.logger.error('Main', f'{e}')

View file

@ -1,51 +1,111 @@
import datetime import datetime
import html
import json import json
from typing import Any from typing import Any, Callable, Literal
from .schedule import getFacCourseByGroup
from .user import User from .user import User
def I(condition: bool): return 1 if condition else 0
FEATURE_LIST: dict[str, Callable[[User], int]] = {
"accounts": lambda _: 1,
"today_accounts": lambda u: I(u.usage["date"] == datetime.datetime.now().strftime("%Y.%m.%d")),
"today_interactions": lambda u: (
max(u.usage["count"], 0)
if u.usage["date"] == datetime.datetime.now().strftime("%Y.%m.%d")
else 0
),
"feature_groups": lambda u: I(len(u.saved_groups) > 1),
"feature_notifications": lambda u: I(len(u.notifications) > 0),
"feature_extended_schedule": lambda u: I('extended_schedule' in u.flags),
"fetaure_v2_ui": lambda u: I('v2_ui' in u.flags)
}
class Analytics: class Analytics:
def __init__(self): def __init__(self):
self.issued_at = datetime.datetime.now() self.issued_at: datetime.datetime = datetime.datetime.now()
self.users = 0 self.custom: dict[str, Any] = {
self.groups: dict[str, int] = {} "group_list": [],
}
self.total: dict[str, int] = {}
self.data: dict[
str, # faculty
dict[
str, # course
dict[str, int], # features
],
] = {}
def add(self, user: User) -> None: def add(self, u: User):
self.users += 1 if u.state == "broken":
if not user.active_target or user.active_target["kind"] != "group":
return return
group = user.active_target["name"]
self.groups[group] = self.groups.get(group, 0) + 1
def toDict(self) -> dict[str, Any]: faculty, course = getFacCourseByGroup(u.group)
if not faculty:
return
faculty: str
course: str
# custom
[self.custom["group_list"].append(groupname) for groupname in u.saved_groups]
self.custom["group_list"] = list(set(self.custom["group_list"]))
# features
for feature_name, func in FEATURE_LIST.items():
result = func(u)
if feature_name not in self.total:
self.total[feature_name] = 0
self.total[feature_name] += result
if faculty not in self.data:
self.data[faculty] = {}
if course not in self.data[faculty]:
self.data[faculty][course] = {}
if feature_name not in self.data[faculty][course]:
self.data[faculty][course][feature_name] = 0
self.data[faculty][course][feature_name] += result
def toDict(self):
return { return {
"version": 3, "version": 2,
"issued_at": self.issued_at.isoformat(), "issued_at": self.issued_at.isoformat(),
"users": self.users, "total": self.total,
"groups": dict(sorted(self.groups.items())), "data": self.data,
"custom": self.custom,
} }
def append(self) -> None: def append(self):
try: try:
with open("./web/analytics/data-v2.json", "r", encoding="utf-8") as file: w: list[dict[str, Any]] = json.load(
history: list[dict[str, Any]] = json.load(file) open("./web/analytics/data-v2.json", "r", encoding="utf-8")
except (FileNotFoundError, json.JSONDecodeError, OSError): )
history = [] except:
w = []
history.append(self.toDict()) w.append(self.toDict())
with open("./web/analytics/data-v2.json", "w", encoding="utf-8") as file: w = w[-30:]
json.dump(history[-30:], file, ensure_ascii=False) json.dump(
w,
open("./web/analytics/data-v2.json", "w", encoding="utf-8"),
ensure_ascii=False,
# indent=0,
)
def text(self) -> str: def text(self) -> str:
group_lines = [
f"{html.escape(group)}: <code>{count}</code>"
for group, count in sorted(self.groups.items())
]
groups = "\n".join(group_lines) if group_lines else "Нет зарегистрированных групп"
return ( return (
f'<b>Аналитика за {self.issued_at.strftime("%d.%m")} 📊</b>\n' f"""
f"Пользователи: <code>{self.users}</code>\n\n" <b>Аналитика за {self.issued_at.strftime("%m.%d")} 📊</b>"""
f"<b>Группы</b>\n{groups}" + "\n".join(
[
f"{feature.capitalize()}: <code>{number}</code>"
for feature, number in self.total.items()
]
)
+ """
<i><a href="https://zatups.sinya.ru/analytics">Сайт</a></i>
"""
) )

View file

@ -2,15 +2,16 @@ 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
from telebot import apihelper from telebot import apihelper
import config
import utils.schedule_client as sc
apihelper.proxy = { if config.TELEGRAM_PROXY:
'http': config.TELEGRAM_PROXY, apihelper.proxy = {
'https': config.TELEGRAM_PROXY 'http': config.TELEGRAM_PROXY,
} 'https': config.TELEGRAM_PROXY
}
bot = telebot.TeleBot(config.TOKEN, parse_mode='html') bot = telebot.TeleBot(config.TOKEN, parse_mode='html')
@ -61,19 +62,10 @@ class Strings:
# home # home
map = 'Карта 🗺️' map = 'Карта 🗺️'
schedule = 'Расписание 📅' schedule = 'Расписание 📅'
schedule_emoji = '📅'
schedule_another_days = 'Другие дни 📁'
search = 'Поиск 🔎' search = 'Поиск 🔎'
settings = '⚙️' settings = 'Настройки ⚙️'
support = 'Поддержка 💞' support = 'Поддержать 💞'
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 = 'Моего {} нету ⚠'
@ -101,7 +93,7 @@ class Markup_v2:
markup.add( markup.add(
KeyboardButton( KeyboardButton(
text=Strings.map, text=Strings.map,
web_app=WebAppInfo(f'{config.WEB_BASE_URL.rstrip("/")}/map/?v=15&mode=search') web_app=WebAppInfo(f'{config.WEB_BASE_URL}/map/?v13')
), ),
Strings.schedule, Strings.schedule,
row_width=2 row_width=2
@ -112,16 +104,12 @@ 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(ZoneInfo('Europe/Moscow')).isocalendar().week % 2 == 0 odd_even = datetime.datetime.now().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( markup.add(f'{"" if odd_even else ""} {Strings.odd}', f'{"" if odd_even else ""} {Strings.even}', row_width=2)
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
@ -137,59 +125,31 @@ class Markup_v1:
markup = ReplyKeyboardMarkup(resize_keyboard=True) markup = ReplyKeyboardMarkup(resize_keyboard=True)
markup.add( markup.add(
KeyboardButton( KeyboardButton(
text=Strings.map, text='Карта 🗺️',
web_app=WebAppInfo(f'{config.WEB_BASE_URL.rstrip("/")}/map/?v=15&mode=search') web_app=WebAppInfo(f'{config.WEB_BASE_URL}/map/?v12')
), ), row_width=1
Strings.search,
row_width=2
) )
markup.add(Strings.today, Strings.tomorrow, row_width=2) markup.add('Сегодня 🔥', 'Завтра 🍀', row_width=2)
markup.add(Strings.schedule_another_days, Strings.current_schedule_name_F.format(user.group), row_width=2) markup.add('На неделю 📅', 'Другие дни 📁', row_width=2)
markup.add(Strings.notifications, Strings.support, row_width=2) # markup.add('Уведомления ⏰', 'Помощь 🆘', row_width=2)
markup.add('Уведомления ⏰', 'Поддержать 💞', f'{user.group} 🔄', row_width=3)
return markup return markup
@staticmethod @staticmethod
def search_types() -> ReplyKeyboardMarkup: def other() -> ReplyKeyboardMarkup:
markup = ReplyKeyboardMarkup(resize_keyboard=True) markup = ReplyKeyboardMarkup(resize_keyboard=True)
markup.add(Strings.back_to_home, row_width=1) markup.add('На главную 🏠', row_width=1)
markup.add(Strings.groups, row_width=1) markup.add('ПН', 'ВТ', 'СР', 'ЧТ', 'ПТ', row_width=5)
markup.add(Strings.rooms, row_width=1) markup.add('Нечетная 📅', 'Четная 📅', row_width=2)
markup.add(Strings.teachers, row_width=1) # markup.add('ЧТ', 'ПТ', row_width=2)
return markup
@staticmethod
def search_results(results: list[str]) -> ReplyKeyboardMarkup:
markup = ReplyKeyboardMarkup(resize_keyboard=True)
markup.add(Strings.back_to_home, row_width=1)
for result in results[:10]:
markup.add(result, row_width=1)
return markup
@staticmethod
def schedule(
is_saved: bool, from_search: bool = False, include_today: 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)
if from_search or include_today:
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(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) markup.add('Файлом на неделю 📅', row_width=1)
@ -197,29 +157,28 @@ class Markup_v1:
return markup return markup
@staticmethod @staticmethod
def saved_schedules(u: user.User) -> ReplyKeyboardMarkup: def saved_groups(u: user.User) -> ReplyKeyboardMarkup:
markup = ReplyKeyboardMarkup(resize_keyboard=True) markup = ReplyKeyboardMarkup(resize_keyboard=True)
markup.add(Strings.back_to_home, row_width=1) markup.add('На главную 🏠', row_width=1)
markup.add(*(target['name'] for target in u.saved_schedules), row_width=4) markup.add(*u.saved_groups, row_width=4)
markup.add(Strings.add_schedule, row_width=1) markup.add('Добавить группу 🔆', 'Удалить группы 🗑', row_width=2)
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(query: str) -> ReplyKeyboardMarkup:
#FIXME
schedule = sc.ScheduleDB(api_url=config.SCHEDULE_BASE_URL)
groupList = schedule.query(sc.Group).filter_by(name=query)
markup = ReplyKeyboardMarkup(resize_keyboard=True) markup = ReplyKeyboardMarkup(resize_keyboard=True)
for group in groups: for group in groupList:
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
Markup = Markup_v1
def Receive(state: str, text: str | None = None, strict: bool = True): def Receive(state: str, text: str | None = None, strict: bool = True):
return bot.message_handler( return bot.message_handler(
content_types = ['text'], content_types = ['text'],
@ -229,4 +188,4 @@ def Receive(state: str, text: str | None = None, strict: bool = True):
) )
def Default(text: str, strict: bool = True): def Default(text: str, strict: bool = True):
return Receive('default', text, strict) return Receive('default', text, strict)

View file

@ -1,294 +0,0 @@
"""Validated WebApp protocol and local pathfinder implementation."""
from __future__ import annotations
import base64
import binascii
import json
import math
import re
from collections import deque
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from typing import Any
MAX_ROUTE_DATA = 4096
MAX_REQUEST_ID = 80
MAX_ENDPOINT_TEXT = 80
_REQUEST_ID = re.compile(r"^[A-Za-z0-9_.:-]{1,80}$")
class NavigatorRequestError(ValueError):
"""The WebApp sent an unsupported or unsafe route request."""
@dataclass(frozen=True)
class RouteEndpoint:
room: str
floor: str | None = None
@dataclass(frozen=True)
class RouteRequest:
version: int
request_id: str
start: RouteEndpoint
end: RouteEndpoint
@dataclass(frozen=True)
class CompiledFloor:
name: str
floor_id: str
neighbors: tuple[tuple[str, ...], ...]
graph: tuple[tuple[int, ...], ...]
positions: tuple[tuple[float, float], ...]
_MANIFEST_PATH = Path(__file__).resolve().parents[1] / "web" / "map" / "data" / "navigator.json"
def _text(value: Any, field: str, limit: int = MAX_ENDPOINT_TEXT) -> str:
if not isinstance(value, str):
raise NavigatorRequestError(f"{field} должен быть строкой")
value = value.strip()
if not value or len(value) > limit or any(char in value for char in "<>\r\n"):
raise NavigatorRequestError(f"Некорректное поле {field}")
return value
def _endpoint(value: Any, field: str) -> RouteEndpoint:
if not isinstance(value, dict):
raise NavigatorRequestError(f"{field} должен быть объектом")
room = _text(value.get("room"), f"{field}.room")
floor_value = value.get("floor")
floor = None if floor_value is None else _text(floor_value, f"{field}.floor", 16)
return RouteEndpoint(room=room, floor=floor)
def parse_route_request(raw: str | bytes) -> RouteRequest:
if isinstance(raw, bytes):
if len(raw) > MAX_ROUTE_DATA:
raise NavigatorRequestError("Слишком большой запрос маршрута")
raw = raw.decode("utf-8", errors="strict")
elif not isinstance(raw, str) or len(raw.encode("utf-8")) > MAX_ROUTE_DATA:
raise NavigatorRequestError("Слишком большой запрос маршрута")
try:
payload = json.loads(raw)
except (UnicodeError, json.JSONDecodeError) as error:
raise NavigatorRequestError("Запрос маршрута не является JSON") from error
if not isinstance(payload, dict) or payload.get("v") != 1 or payload.get("type") != "route.request":
raise NavigatorRequestError("Неподдерживаемый формат запроса маршрута")
request_id = _text(payload.get("request_id"), "request_id", MAX_REQUEST_ID)
if not _REQUEST_ID.fullmatch(request_id):
raise NavigatorRequestError("Некорректный request_id")
start = _endpoint(payload.get("start"), "start")
end = _endpoint(payload.get("end"), "end")
if start.room.casefold() == end.room.casefold() and start.floor == end.floor:
raise NavigatorRequestError("Начальная и конечная аудитории совпадают")
return RouteRequest(version=1, request_id=request_id, start=start, end=end)
def _decode_pathfinder(value: Any) -> dict[str, Any] | None:
for _ in range(2):
if not isinstance(value, str):
break
try:
value = json.loads(value)
except json.JSONDecodeError:
return None
return value if isinstance(value, dict) else None
@lru_cache(maxsize=4)
def _load_floors(mtime_ns: int) -> dict[str, CompiledFloor]:
del mtime_ns # cache key; contents are read again whenever the file changes
manifest = json.loads(_MANIFEST_PATH.read_text(encoding="utf-8"))
raw_floors = manifest.get("floors") if isinstance(manifest, dict) else None
if not isinstance(raw_floors, dict):
return {}
floors: dict[str, CompiledFloor] = {}
for key, raw_floor in raw_floors.items():
if not isinstance(raw_floor, dict):
continue
name = str(raw_floor.get("floor_name", key)).strip()
pathfinder = _decode_pathfinder(raw_floor.get("pathfinder_data", raw_floor))
if not pathfinder:
continue
raw_neighbors = pathfinder.get("neighbors")
raw_graph = pathfinder.get("graph")
raw_positions = raw_floor.get("positions", pathfinder.get("positions"))
if not all(isinstance(value, list) for value in (raw_neighbors, raw_graph, raw_positions)):
continue
if not (len(raw_neighbors) == len(raw_graph) == len(raw_positions)):
continue
try:
neighbors = tuple(tuple(str(item) for item in items) for items in raw_neighbors)
graph = tuple(tuple(int(item) for item in items) for items in raw_graph)
positions = tuple((float(item[0]), float(item[1])) for item in raw_positions)
except (TypeError, ValueError, IndexError):
continue
node_count = len(graph)
if any(node < 0 or node >= node_count for edges in graph for node in edges):
continue
floors[name] = CompiledFloor(
name=name,
floor_id=str(raw_floor.get("id", name)),
neighbors=neighbors,
graph=graph,
positions=positions,
)
return floors
def _floors() -> dict[str, CompiledFloor]:
try:
return _load_floors(_MANIFEST_PATH.stat().st_mtime_ns)
except (OSError, ValueError, TypeError, json.JSONDecodeError):
return {}
def _contains_room(floor: CompiledFloor, room: str) -> bool:
return any(room in names for names in floor.neighbors)
def _resolve_floor(floors: dict[str, CompiledFloor], endpoint: RouteEndpoint) -> CompiledFloor | None:
if endpoint.floor and endpoint.floor in floors:
floor = floors[endpoint.floor]
return floor if _contains_room(floor, endpoint.room) else None
matches = [floor for floor in floors.values() if _contains_room(floor, endpoint.room)]
return matches[0] if len(matches) == 1 else None
def _bfs(floor: CompiledFloor, start: str | int, end: str | int) -> list[int] | None:
if isinstance(start, int):
start_index = start if 0 <= start < len(floor.graph) else None
else:
start_index = next((index for index, names in enumerate(floor.neighbors) if start in names), None)
if start_index is None:
return None
def reached(index: int) -> bool:
return index == end if isinstance(end, int) else end in floor.neighbors[index]
if reached(start_index):
return [start_index]
queue = deque([start_index])
visited = {start_index}
parents: dict[int, int] = {}
while queue:
node = queue.popleft()
for neighbor in floor.graph[node]:
if neighbor in visited:
continue
parents[neighbor] = node
if reached(neighbor):
path = [neighbor]
while path[-1] != start_index:
path.append(parents[path[-1]])
return path[::-1]
visited.add(neighbor)
queue.append(neighbor)
return None
def _xy_search(floor: CompiledFloor, x: float, y: float, *, safe: bool = True) -> int | None:
closest_index: int | None = None
closest_distance = math.inf
for index, (point_x, point_y) in enumerate(floor.positions):
if safe and "лест" not in ",".join(floor.neighbors[index]).casefold():
continue
distance = math.hypot(point_x - x, point_y - y)
if distance < closest_distance:
closest_distance = distance
closest_index = index
return closest_index if closest_index is not None and closest_distance < 2000 else None
def _nearest_node(floor: CompiledFloor, position: tuple[float, float]) -> int | None:
safe_index = _xy_search(floor, *position, safe=True)
return safe_index if safe_index is not None else _xy_search(floor, *position, safe=False)
def _segment(floor: CompiledFloor, path: list[int]) -> dict[str, Any]:
# The WebView only needs the display floor number. Keeping the UUID out of
# the payload also makes route URLs shorter.
return {"array": path, "floor_id": floor.name, "floor_name": floor.name}
def calculate_route(request: RouteRequest) -> dict[str, Any] | None:
"""Calculate a route from the pathfinder manifest shared with the WebView."""
floors = _floors()
start_floor = _resolve_floor(floors, request.start)
end_floor = _resolve_floor(floors, request.end)
if not start_floor or not end_floor:
return None
if start_floor.name == end_floor.name:
path = _bfs(start_floor, request.start.room, request.end.room)
return {"v": 1, "segments": [_segment(start_floor, path)]} if path else None
end_path = _bfs(end_floor, request.end.room, "Лестница")
if not end_path:
return None
if start_floor.name == "2":
destination = _nearest_node(start_floor, end_floor.positions[end_path[-1]])
start_path = _bfs(start_floor, request.start.room, destination) if destination is not None else None
if not start_path:
return None
segments = [_segment(start_floor, start_path), _segment(end_floor, end_path)]
return {"v": 1, "segments": segments}
start_path = _bfs(start_floor, request.start.room, "Лестница")
second_floor = floors.get("2")
if not start_path or not second_floor:
return None
second_start = _nearest_node(second_floor, start_floor.positions[start_path[-1]])
if second_start is None:
return None
if end_floor.name == "2":
second_path = _bfs(second_floor, second_start, request.end.room)
if not second_path:
return None
return {"v": 1, "segments": [_segment(start_floor, start_path), _segment(second_floor, second_path)]}
second_end = _nearest_node(second_floor, end_floor.positions[end_path[-1]])
if second_end is None:
return None
second_path = _bfs(second_floor, second_start, second_end)
if not second_path:
return None
segments = [
_segment(start_floor, start_path),
_segment(second_floor, second_path),
_segment(end_floor, end_path),
]
return {"v": 1, "segments": segments}
def encode_route(route: dict[str, Any]) -> str:
"""Encode a route for a URL without putting credentials in the URL."""
data = json.dumps(route, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
def decode_route(value: str) -> dict[str, Any] | None:
"""Decode a route in tests/tools; the WebApp performs the same operation."""
try:
padded = value + "=" * ((4 - len(value) % 4) % 4)
result = json.loads(base64.urlsafe_b64decode(padded).decode("utf-8"))
except (ValueError, TypeError, UnicodeError, binascii.Error, json.JSONDecodeError):
return None
return result if isinstance(result, dict) else None

View file

@ -1,363 +1,145 @@
from __future__ import annotations
import datetime
import difflib
import html
import json import json
import re import datetime
import threading import config as _config
import time
from typing import Any, Literal, TypedDict
from urllib.error import HTTPError, URLError
from urllib.parse import quote
from urllib.request import Request, urlopen
from zoneinfo import ZoneInfo
import config
from utils import convert
TargetKind = Literal["group", "room", "teacher"]
class ScheduleTarget(TypedDict):
kind: TargetKind
id: str
name: str
dayStrings = [
"Понедельник",
"Вторник",
"Среда",
"Четверг",
"Пятница",
"Суббота",
"Воскресенье",
]
MOSCOW = ZoneInfo("Europe/Moscow")
_CACHE_TTL = 300.0
_REQUEST_TIMEOUT = 10.0
_FAILURE_RETRY_DELAY = 30.0
_cache: dict[str, tuple[float, list[dict[str, Any]]]] = {}
_failures: dict[str, tuple[float, str]] = {}
_cache_lock = threading.RLock()
_ENDPOINTS: dict[TargetKind, str] = {
"group": "groups",
"room": "rooms",
"teacher": "teachers",
}
class ScheduleAPIError(RuntimeError):
pass
def _api_url(path: str) -> str:
return f"{config.SCHEDULE_BASE_URL.rstrip('/')}/{path.lstrip('/')}"
def _load_result(path: str) -> list[dict[str, Any]]:
now = time.monotonic()
with _cache_lock:
cached = _cache.get(path)
if cached and now - cached[0] < _CACHE_TTL:
return cached[1]
failure = _failures.get(path)
if failure and now < failure[0]:
if cached:
return cached[1]
raise ScheduleAPIError(failure[1])
request = Request(_api_url(path), headers={"Accept": "application/json"})
try:
with urlopen(request, timeout=_REQUEST_TIMEOUT) as response:
payload = json.load(response)
except (HTTPError, URLError, TimeoutError, OSError, ValueError) as error:
message = f"Не удалось получить {path}: {error}"
with _cache_lock:
_failures[path] = (now + _FAILURE_RETRY_DELAY, message)
if cached:
return cached[1]
raise ScheduleAPIError(message) from error
if payload.get("ok") is not True or not isinstance(payload.get("result"), list):
if cached:
return cached[1]
raise ScheduleAPIError(f"Сервер вернул некорректный ответ для {path}")
result = payload["result"]
with _cache_lock:
_cache[path] = (now, result)
_failures.pop(path, None)
return result
def clear_schedule_cache() -> None:
with _cache_lock:
_cache.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]]:
groups = _load_result("groups")
if not groups:
raise ScheduleAPIError("Сервер расписания пока не содержит групп")
return sorted(
(group for group in groups if group.get("parent_group_id") is None),
key=lambda group: str(group.get("name", "")).casefold(),
)
def _compact(value: str) -> str:
return re.sub(r"[^0-9a-zа-яё]", "", value.casefold())
def _search_records(records: list[dict[str, Any]], query: str, limit: int) -> list[dict[str, Any]]:
compact_query = _compact(query)
if not compact_query:
return []
exact = [record for record in records if _compact(str(record.get("name", ""))) == compact_query]
if exact:
return exact[:limit]
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:
return sorted(contains, key=score, reverse=True)[:limit]
ranked = sorted(records, key=score, reverse=True)
return [record for record in ranked if score(record)[0] >= 0.45][:limit]
def search_targets(kind: TargetKind, query: str, limit: int = 10) -> list[ScheduleTarget]:
records = _load_result(_ENDPOINTS[kind])
if kind == "group":
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:
target = _compact(group_name)
return any(_compact(str(group["name"])) == target for group in get_main_groups())
def _minutes_to_time(value: int | None) -> list[int]:
if value is None:
return [0, 0]
return [value // 60, value % 60]
dayStrings = ['Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота', 'Воскресенье']
class Lesson: class Lesson:
def __init__( #FIXME: фулл переделать потому-что формат поменялся сильно
self, def __init__(self, start_end: str, data: dict) -> None:
data: dict[str, Any], self.start: list[int] = [
target_kind: TargetKind, int(start_end.split(' - ')[0].split(':')[0]),
group_names: list[str], int(start_end.split(' - ')[0].split(':')[1])
) -> None: ]
self.start = _minutes_to_time(data.get("time_start"))
self.end = _minutes_to_time(data.get("time_end")) self.end: list[int] = [
self.name = str(data.get("name") or "Без названия") int(start_end.split(' - ')[1].split(':')[0]),
self.type = str(data.get("type") or "пара") int(start_end.split(' - ')[1].split(':')[1])
self.teacher_ids = [str(value) for value in data.get("teacher_ids", [])] ]
self.room_ids = [str(value) for value in data.get("room_ids", [])] self.strTime: str = start_end
self.target_kind = target_kind
self.group_names = group_names self.raw: str = data['raw']
self.info = self.teacher_ids + [f"ауд. {room}" for room in self.room_ids] self.ok: bool = data['ok']
self.is_odd_week = bool(data.get("is_odd_week")) if not self.ok: return
self.is_even_week = bool(data.get("is_even_week"))
self.name: str = data['name']
self.type: str = data['type']
self.info: list[str] = data['info']
def text(self, isToday: bool = False) -> str: def text(self, isToday: bool = False) -> str:
now = datetime.datetime.now(MOSCOW) date = datetime.datetime.now()
lesson_start = now.replace(hour=self.start[0], minute=self.start[1], second=0, microsecond=0) lessonStart = datetime.datetime(date.year, date.month, date.day, self.start[0], self.start[1], 10)
lesson_end = now.replace(hour=self.end[0], minute=self.end[1], second=0, microsecond=0)
lesson_type = html.escape(self.type)
date_text = f"{lesson_type.capitalize()} <i>в {self.start[0]}:{self.start[1]:02d}</i>"
if isToday and lesson_start > now:
minutes = int((lesson_start - now).total_seconds() // 60)
if minutes < 30:
date_text = f"{lesson_type.capitalize()} <i>начнется через {max(minutes, 1)} мин.</i>"
elif isToday and lesson_start <= now < lesson_end:
minutes = int((lesson_end - now).total_seconds() // 60)
date_text = f"<i>Сейчас идет</i> {lesson_type}, закончится через {max(minutes, 1)} мин."
def generate_url(kind, target):
return f'<a href="{config.TELEGRAM_URL}?start=_s{kind[:1]}{convert.encode(html.escape(target))}">{html.escape(target)}</a>'
teachers = [
generate_url("teacher", teacher)
for teacher in self.teacher_ids
]
rooms = [
generate_url("room", room)
for room in self.room_ids
]
groups = [
generate_url("group", group)
for group in self.group_names
]
# FIXME: Не отображаются подгруппы
# if self.target_kind == "teacher":
# details = rooms + groups
# elif self.target_kind == "room":
# details = groups + teachers
# elif self.target_kind == "group":
# details = teachers + rooms
# else:
if True:
details = teachers + rooms + groups
info = ", ".join(details) if details else "Дополнительная информация отсутствует"
return f"<blockquote><b>{html.escape(self.name)}</b>\n{date_text}\n{info}</blockquote>" if self.ok:
dateStr: str = f'{self.type.capitalize()} <i>в {self.start[0]}:{self.start[1] if self.start[1] > 9 else "0"+str(self.start[1])}</i>'
if isToday:
if lessonStart > date and (lessonStart-date).seconds < 30*60:
dateStr = f'{self.type.capitalize()} <i>начнется через {max((lessonStart-date).seconds//60, 1)} мин.</i>'
elif lessonStart < date and (date-lessonStart).seconds < 31*60:
dateStr = f'<i>Сейчас идет</i> {self.type}'
elif lessonStart < date and (date-lessonStart).seconds >= 31*60 and (date-lessonStart).seconds < 89*60:
dateStr = f'{self.type.capitalize()} <i>закончится через {max(90-(date-lessonStart).seconds//60, 1)} мин.</i>'
wrb = ''
if self.info:
nd: list[str] = []
for data in self.info:
if data in self.type: continue
if 'подгр' in data:
nd.append(data.split('подгр')[0])
nd.append('\n<b>подгр'+data.split('подгр')[1]+f'</b>\n')
else: nd.append(data)
for data in nd:
# print()
# print(data, wrb)
if ('\n' in data or '\n' in wrb[-2:]):
wrb += data
elif 'ауд' in data.lower():
audn = '.'.join(data.replace(' ', '').split('.')[1:])
wrb += ', '+f'<a href="https://rasp.pgups.ru/schedule/room?room={audn}">{data}</a>'
else:
wrb += ', '+data
return f'''\
<blockquote>\
<b>{self.name}</b>#FIXME
{dateStr}
{wrb[2:] if wrb else 'Дополнительная информация отсуствует'}</blockquote>'''
else:
return f'''\
<pre language="Error">\
Пара в {self.start[0]}:{self.start[1] if self.start[1] > 9 else '0'+str(self.start[1])}
{self.raw}</pre>'''
class Schedule: class Schedule:
def __init__( def __init__(self, responce: dict) -> None:
self, self.weekNumber: int = responce['date']['weekNumber']
lessons: list[dict[str, Any]], self.weekDay: int = responce['date']['weekDay']
target: ScheduleTarget,
groups: list[dict[str, Any]],
now: datetime.datetime | None = None,
) -> None:
current = now or datetime.datetime.now(MOSCOW)
self.weekNumber = current.isocalendar().week
self.weekDay = current.weekday()
self.days: list[list[Lesson]] = [[] for _ in range(5)]
groups_by_id = { self.raw: dict = responce['schedule']
str(group["id"]): str(group["name"]) self.days: list[list[list[Lesson]]] = []
for group in groups
}
target_ids = {target["id"]}
child_names: dict[str, str] = {}
field = {"group": "group_ids", "room": "room_ids", "teacher": "teacher_ids"}[target["kind"]]
if target["kind"] == "group":
child_names = {
str(group["id"]): str(group["name"])
for group in groups
if group.get("parent_group_id") == target["id"]
}
target_ids.update(child_names)
for raw_lesson in lessons: for dayInfo in self.raw.values():
weekday = raw_lesson.get("weekday") currentDay = []
if not isinstance(weekday, int) or weekday not in range(1, 6): for start_end, lessons in dayInfo.items():
continue if lessons == [None] or lessons == [None, None]:
matched_ids = target_ids.intersection(map(str, raw_lesson.get(field, []))) continue
if not matched_ids:
continue lessonList: list[Lesson | None] = []
if target["kind"] == "group": for lesson in lessons:
group_names = sorted(child_names[item] for item in matched_ids if item in child_names) if lesson: lessonList.append(Lesson(start_end, lesson))
else: else: lessonList.append(None)
group_names = sorted(
{ currentDay.append(lessonList)
groups_by_id.get(str(group_id), str(group_id))
for group_id in raw_lesson.get("group_ids", []) self.days.append(currentDay)
}
def today(self) -> list[list[Lesson]]:
return self.days[self.weekDay] if self.weekDay < 5 else []
def getFacCourseByGroup(group: str) -> tuple[str | None, str | None]:
#FIXME
for faculty, courses in _config.LOAD.items():
for course in courses:
path = f'./timetables/{faculty} {course}.json'.replace(' ', '_')
data: dict[str, dict] = json.load(
open(
path,
'r',
encoding = 'utf-8'
) )
self.days[weekday - 1].append(Lesson(raw_lesson, target["kind"], group_names)) )
if group.lower() in [_.lower() for _ in data.keys()]:
return faculty, course
return None, None
def getSchedulePathByGroup(group: str) -> str | None:
#FIXME
faculty, course = getFacCourseByGroup(group)
return None if not faculty else f'./timetables/{faculty} {course}.json'.replace(' ', '_')
for day in self.days: def generateDaySchedule(schedule: Schedule, showOddWeekText: bool = True, isToday: bool = False) -> str:
day.sort(key=lambda lesson: (lesson.start, lesson.name.casefold())) isWeekOdd = schedule.weekNumber%2 == 0
weekDay = schedule.weekDay
def today(self) -> list[Lesson]: schedule_today = schedule.today()
if self.weekDay not in range(5): lastLessonEnds = [0,0]
return [] scheduleResult = f'<b>{dayStrings[weekDay]}, {"четная" if isWeekOdd else "нечетная"} неделя</b>\n' if showOddWeekText else f'<b>{dayStrings[weekDay]}</b>'
even_week = self.weekNumber % 2 == 0 for lessonList in schedule_today:
return [ if len(lessonList) == 1:
lesson scheduleResult += lessonList[0].text(isToday) + '\n'
for lesson in self.days[self.weekDay] lastLessonEnds = lessonList[0].end
if (lesson.is_even_week if even_week else lesson.is_odd_week) else:
] lesson = lessonList[1 if isWeekOdd else 0]
if lesson:
scheduleResult += lesson.text(isToday) + '\n'
def get_schedule(target: ScheduleTarget) -> Schedule: lastLessonEnds = lesson.end
groups = _load_result("groups")
lessons = _load_result("schedule/default") scheduleResult += f'Пары закончатся в <i>{lastLessonEnds[0]}:{lastLessonEnds[1] if lastLessonEnds[1] > 9 else "0"+str(lastLessonEnds[1])}</i>'
return Schedule(lessons, target, groups) return scheduleResult
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(
schedule: Schedule, showOddWeekText: bool = True, isToday: bool = False
) -> str:
even_week = schedule.weekNumber % 2 == 0
weekday = schedule.weekDay
header = (
f'<b>{dayStrings[weekday]}, {"четная" if even_week else "нечетная"} неделя</b>\n'
if showOddWeekText
else f"<b>{dayStrings[weekday]}</b>\n"
)
lessons = schedule.today()
if not lessons:
return header + "Пар нет 🥰"
result = header + "\n".join(lesson.text(isToday) for lesson in lessons)
last_end = max(lesson.end for lesson in lessons)
return result + f"\nПары закончатся в <i>{last_end[0]}:{last_end[1]:02d}</i>"
def generateWeekSchedule(schedule: Schedule) -> str:
original_weekday = schedule.weekDay
days = []
try:
for weekday in range(len(schedule.days)):
schedule.weekDay = weekday
if schedule.today():
days.append(generateDaySchedule(schedule, False))
finally:
schedule.weekDay = original_weekday
return "\n\n".join(days) if days else "Пар на этой неделе нет 🥰"

View file

@ -1,162 +1,112 @@
import datetime
import hashlib
import json import json
import os import os
from typing import Any, Iterator
from telebot.types import CallbackQuery, InlineQuery, Message from telebot.types import CallbackQuery, InlineQuery, Message, ReplyKeyboardRemove
from models import logger from models import logger
from .schedule import ( from .schedule import Schedule, getSchedulePathByGroup
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 = userID self.id: int = userID
try: try:
with open(f"./users/{self.id}.json", "r", encoding="utf-8") as file: self.data: dict[str, str] = json.load(
self.data: dict[str, Any] = json.load(file) open(f"./users/{self.id}.json", "r", encoding="utf-8")
except FileNotFoundError: )
self.data = {} except:
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") self.state: str | bool | None = self.data.get("state", None)
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)
saved = [normalize_target(value) for value in self.data.get("saved_schedules", [])] # self.topic: str | None = self.data.get("topic", None)
self.saved_schedules: list[ScheduleTarget] = [value for value in saved if value] # self.course: str | None = self.data.get("course", None)
for name in self.data.get("saved_groups", []): self.group: str | None = self.data.get("group", None)
if isinstance(name, str) and name: self.notifications: list[str] = self.data.get("notifications", []) # type: ignore
self._append_saved(group_target(name)) self.valid: bool = self.data.get("valid", True)
if self.active_target: self.saved_groups: list[str] = self.data.get("saved_groups", [])
self._append_saved(self.active_target) self.saved_groups.append(self.group)
self.saved_groups = [t if "-" in t else "{null}" for t in self.saved_groups]
self.saved_groups = list(set(self.saved_groups))
try: self.saved_groups.remove("{null}")
except: ...
self.view_target = normalize_target(self.data.get("view_schedule")) self.user: dict = self.data.get("user", {})
self.search_kind = self.data.get("search_kind") self.usage: dict = self.data.get(
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():
self.notifyEvening: str = self.data["notifyEvening"]
self.notifyBeforeLesson: str = self.data["notifyBeforeLesson"]
if self.data["notifyEvening"] and "evening" not in self.notifications:
self.notifications.append("evening")
if self.data["notifyBeforeLesson"] and "lesson" not in self.notifications:
self.notifications.append("lesson")
if self.data.get("notifyEvening") and "evening" not in self.notifications:
self.notifications.append("evening")
if self.data.get("notifyBeforeLesson") and "lesson" not in self.notifications:
self.notifications.append("lesson")
self.save() self.save()
@property def schedule(self) -> Schedule | None:
def group(self) -> str | None: date = datetime.datetime.now()
return self.active_target["name"] if self.active_target else None #FIXME
path = getSchedulePathByGroup(self.group)
if not path:
self.valid = False
self.notifications = []
self.setState("broken")
self.save()
return None
@group.setter return Schedule(
def group(self, name: str | None) -> None: {
self.active_target = group_target(name) if name else None "date": {
"weekNumber": date.isocalendar().week,
@property "weekDay": date.weekday(),
def saved_groups(self) -> list[str]: },
return [target["name"] for target in self.saved_schedules if target["kind"] == "group"] "schedule": json.load(open(path, "r", encoding="utf-8"))[self.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
try:
return get_schedule(selected)
except (ValueError, ScheduleAPIError) as error:
logger.error("Schedule", f"[{self.id}] {error}")
return None
def setState(self, newState: str) -> None: def setState(self, newState: str) -> None:
self.state = newState self.state = newState
self.save() self.save()
def save(self) -> None: def save(self) -> None:
with open(f"./users/{self.id}.json", "w", encoding="utf-8") as file: return json.dump(
json.dump( {
{ "state": self.state,
"state": self.state, "topic": self.topic,
"group": self.group, "course": self.course,
"active_schedule": self.active_target, "group": self.group,
"saved_groups": self.saved_groups, "notifications": self.notifications,
"saved_schedules": self.saved_schedules, "valid": self.valid,
"view_schedule": self.view_target, "saved_groups": self.saved_groups,
"search_kind": self.search_kind, "user": self.user,
"search_results": self.search_results, "usage": self.usage,
"notifications": self.notifications, "flags": self.flags,
"valid": self.valid, },
"user": self.user, open(f"./users/{self.id}.json", "w", encoding="utf-8"),
"usage": self.usage, ensure_ascii=False,
"flags": self.flags, indent=4,
}, )
file,
ensure_ascii=False,
indent=4,
)
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 error: except Exception as e:
logger.error("Bot", f'INVALID "{ctx.from_user.id}" VALIDATION! {error}') logger.error("Bot", f'INVALID "{ctx.from_user.id}" VALIDATION! {e}')
return False # type: ignore return False # type: ignore
def getAllUsers() -> Iterator[User]: def getAllUsers() -> list[User]: # type: ignore
for _, _, filenames in os.walk("./users"): for _, _, filenames in os.walk("./users"):
for userFile in filenames: for userFile in filenames:
if userFile.endswith(".json"): if ".json" not in userFile:
yield User(int(userFile.removesuffix(".json"))) continue
yield User(int(userFile.replace(".json", ""))) # type: ignore

0
modules/__init__.py Normal file
View file

View file

View file

View file

@ -5,11 +5,9 @@ description = "Add your description here"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"colorama>=0.4.6",
"ics>=0.7.3", "ics>=0.7.3",
"pytelegrambotapi>=4.36.1", "pytelegrambotapi>=4.36.1",
"python-dotenv>=1.2.3", "python-dotenv>=1.2.3",
"python-socks>=3.0.0",
"pytz>=2026.3.post1", "pytz>=2026.3.post1",
"requests[socks]>=2.34.2",
"rich>=15.0.0",
] ]

View file

@ -2,10 +2,10 @@
"text": "<b>Ищу разработчиков</b>\nНужны ребята для решения проблем с парсером в этом боте. Бюджет не больше банки пива\nСтек: py3(base), openpyxl, json, pyTelegramBotApi\n\n<b>Материальная поддержка</b>\nДля работы бота нужно поддерживать работу сервера, а это к сожалению платно(\nБуду рад любой копеечке, спасибо!", "text": "<b>Ищу разработчиков</b>\nНужны ребята для решения проблем с парсером в этом боте. Бюджет не больше банки пива\nСтек: py3(base), openpyxl, json, pyTelegramBotApi\n\n<b>Материальная поддержка</b>\nДля работы бота нужно поддерживать работу сервера, а это к сожалению платно(\nБуду рад любой копеечке, спасибо!",
"quick_markup": { "quick_markup": {
"Связь с разработчиком 💻": { "Связь с разработчиком 💻": {
"url": "http://t.me/zatups?direct" "url": "https://t.me/mwamev"
}, },
"Исходный код бота 💫" : { "Исходный код парсера 💫" : {
"url": "https://git.snw.su/zatups/bot" "url": "https://github.com/ktnk-dev/ZatupsParser"
}, },
"Скинуть на сервера и сигареты 💕": { "Скинуть на сервера и сигареты 💕": {
"url": "https://www.tinkoff.ru/rm/r_SPKHLWrUDg.iHXMyujyfd/c1IGc13418" "url": "https://www.tinkoff.ru/rm/r_SPKHLWrUDg.iHXMyujyfd/c1IGc13418"

View file

@ -20,6 +20,7 @@ class ICS:
else: # on this week else: # on this week
self.start_week_date = timezone.localize(datetime.now()) - timedelta(days=schedule.weekDay) self.start_week_date = timezone.localize(datetime.now()) - timedelta(days=schedule.weekDay)
self.isWeekOdd = schedule.weekNumber%2 == 0
self.calendar = Calendar() self.calendar = Calendar()
for target in range(5): for target in range(5):
@ -27,7 +28,10 @@ class ICS:
event_date = self.start_week_date + timedelta(days=target) event_date = self.start_week_date + timedelta(days=target)
# print(f'\n\n--- {target} -> {event_date.strftime("%Y-%m-%d")} ---') # print(f'\n\n--- {target} -> {event_date.strftime("%Y-%m-%d")} ---')
for lesson in schedule.today(): for lessonList in schedule.today():
lesson = lessonList[0] if len(lessonList) == 1 else lessonList[1 if self.isWeekOdd else 0]
if not lesson: continue
event = Event() event = Event()
event.name = f'{lesson.name}' event.name = f'{lesson.name}'
@ -43,7 +47,7 @@ class ICS:
# break # break
# break # break
def save(self) -> str: def save(self) -> str:
"""returns path to file""" """returns path to file"""
content = self.calendar.serialize() content = self.calendar.serialize()
@ -55,3 +59,4 @@ class ICS:
path = f'./temp/{self.userID}.ics' path = f'./temp/{self.userID}.ics'
with open(path, 'w', encoding='utf-8', newline='') as file: file.writelines(content) with open(path, 'w', encoding='utf-8', newline='') as file: file.writelines(content)
return path return path

View file

@ -1,14 +0,0 @@
TABLE = 'ёйцукенгшщзхъфывапролджэячсмитьбюЁЙЦУКЕНГШЩЗХЪФЫВАПРОЛДЖЭЯЧСМИТЬБЮ1234567890-_. '
NUMBER = lambda n: str(n) if n > 9 else '0'+str(n)
def encode(text: str) -> str:
return ''.join([
NUMBER(TABLE.index(char)) if char in TABLE else char
for char in text
])
def decode(text: str) -> str:
return ''.join([
TABLE[int(text[index:index+2])] if text[index:index+2].isdigit() else text[index:index+2]
for index in range(0, len(text), 2)
])

644
utils/schedule_client.py Normal file
View file

@ -0,0 +1,644 @@
"""Однофайловый клиент для SheetScheduleParser API.
Файл можно просто скопировать в другой проект. Внешние зависимости не нужны.
Пример::
import schedule_client as schedule
schedule.configure("http://schedule.example.com", cache_ttl=300)
db = schedule.ScheduleDB()
lessons = (
db.query(schedule.ScheduleEntry)
.filter_by(schedule_type="default", group_ids="АТ-501", weekday=1)
.order_by(schedule.Schedule.time_start)
.all()
)
"""
from __future__ import annotations
import json
import os
import threading
import time
from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
from dataclasses import dataclass, field
from typing import Any, Generic, TypeVar
from urllib.error import HTTPError, URLError
from urllib.parse import quote
from urllib.request import Request, urlopen
# Эти значения можно изменить прямо в скопированном файле или через configure().
API_URL = os.getenv("SCHEDULE_API_URL", "http://127.0.0.1:8000")
# Должно в точности совпадать с app.version.API_VERSION на сервере.
API_VERSION = "1"
CACHE_TTL = float(os.getenv("SCHEDULE_CACHE_TTL", "300"))
REQUEST_TIMEOUT = float(os.getenv("SCHEDULE_REQUEST_TIMEOUT", "15"))
FAILURE_RETRY_DELAY = float(os.getenv("SCHEDULE_FAILURE_RETRY_DELAY", "15"))
class ScheduleClientError(RuntimeError):
"""Базовая ошибка клиента."""
class APIError(ScheduleClientError):
"""API недоступно или вернуло некорректный ответ."""
class IncompatibleAPIVersionError(ScheduleClientError):
"""Версия контракта API не поддерживается этим файлом клиента."""
class NoResultFound(ScheduleClientError):
"""Query.one() не нашёл ни одной записи."""
class MultipleResultsFound(ScheduleClientError):
"""Query.one() нашёл больше одной записи."""
class Expression:
def __init__(self, predicate: Callable[["Model"], bool], label: str = "") -> None:
self._predicate = predicate
self._label = label
def __call__(self, item: "Model") -> bool:
return self._predicate(item)
def __and__(self, other: "Expression") -> "Expression":
return Expression(lambda item: self(item) and other(item))
def __or__(self, other: "Expression") -> "Expression":
return Expression(lambda item: self(item) or other(item))
def __invert__(self) -> "Expression":
return Expression(lambda item: not self(item))
def __repr__(self) -> str:
return self._label or "Expression(...)"
@dataclass(frozen=True)
class SortExpression:
name: str
reverse: bool = False
class Field:
"""Дескриптор поля модели и строитель выражений для Query.filter()."""
def __init__(self) -> None:
self.name = ""
def __set_name__(self, owner: type["Model"], name: str) -> None:
self.name = name
def __get__(self, instance: "Model | None", owner: type["Model"]) -> Any:
if instance is None:
return self
return instance._data.get(self.name)
def _compare(self, operation: Callable[[Any, Any], bool], value: Any, symbol: str) -> Expression:
return Expression(
lambda item: operation(item._data.get(self.name), value),
f"{self.name} {symbol} {value!r}",
)
def __eq__(self, value: Any) -> Expression: # type: ignore[override]
return self._compare(lambda actual, expected: actual == expected, value, "==")
def __ne__(self, value: Any) -> Expression: # type: ignore[override]
return self._compare(lambda actual, expected: actual != expected, value, "!=")
def __lt__(self, value: Any) -> Expression:
return self._compare(_safe_lt, value, "<")
def __le__(self, value: Any) -> Expression:
return self._compare(_safe_le, value, "<=")
def __gt__(self, value: Any) -> Expression:
return self._compare(_safe_gt, value, ">")
def __ge__(self, value: Any) -> Expression:
return self._compare(_safe_ge, value, ">=")
def contains(self, value: Any) -> Expression:
return Expression(
lambda item: _contains(item._data.get(self.name), value),
f"{self.name}.contains({value!r})",
)
def in_(self, values: Iterable[Any]) -> Expression:
choices = tuple(values)
return Expression(
lambda item: item._data.get(self.name) in choices,
f"{self.name}.in_({choices!r})",
)
def is_(self, value: Any) -> Expression:
return Expression(lambda item: item._data.get(self.name) is value)
def startswith(self, value: str) -> Expression:
return Expression(
lambda item: str(item._data.get(self.name) or "").startswith(value)
)
def asc(self) -> SortExpression:
return SortExpression(self.name)
def desc(self) -> SortExpression:
return SortExpression(self.name, reverse=True)
class Model:
"""Неизменяемая по соглашению запись из API с доступом через атрибуты."""
def __init__(self, data: Mapping[str, Any]) -> None:
self._data = dict(data)
def as_dict(self) -> dict[str, Any]:
return dict(self._data)
def __getitem__(self, key: str) -> Any:
return self._data[key]
def __repr__(self) -> str:
identity = self._data.get("id", self._data.get("name", "?"))
return f"{type(self).__name__}(id={identity!r})"
class Schedule(Model):
id = Field()
schedule_type = Field()
name = Field()
type = Field()
weekday = Field()
is_odd_week = Field()
is_even_week = Field()
time_start = Field()
time_end = Field()
datetime = Field()
group_ids = Field()
room_ids = Field()
teacher_ids = Field()
teacher_assignments = Field()
created_at = Field()
parsed_at = Field()
# Более явное имя для импорта; Schedule оставлено как короткий вариант.
ScheduleEntry = Schedule
class Group(Model):
id = Field()
name = Field()
faculty = Field()
course = Field()
parent_group_id = Field()
created_at = Field()
updated_at = Field()
last_seen_at = Field()
class Teacher(Model):
id = Field()
name = Field()
positions = Field()
created_at = Field()
updated_at = Field()
last_seen_at = Field()
class Room(Model):
id = Field()
name = Field()
created_at = Field()
updated_at = Field()
last_seen_at = Field()
class GroupHistory(Model):
faculty = Field()
course = Field()
parent_group_id = Field()
valid_from = Field()
valid_to = Field()
M = TypeVar("M", bound=Model)
class Query(Generic[M]):
"""Небольшое неизменяемое подмножество привычного SQLAlchemy Query API."""
def __init__(
self,
client: "ScheduleDB",
dataset: str,
model: type[M],
predicates: tuple[Expression, ...] = (),
sort_expressions: tuple[SortExpression, ...] = (),
offset_value: int = 0,
limit_value: int | None = None,
) -> None:
self._client = client
self._dataset = dataset
self._model = model
self._predicates = predicates
self._sort_expressions = sort_expressions
self._offset = offset_value
self._limit = limit_value
def _clone(self, **changes: Any) -> "Query[M]":
values = {
"client": self._client,
"dataset": self._dataset,
"model": self._model,
"predicates": self._predicates,
"sort_expressions": self._sort_expressions,
"offset_value": self._offset,
"limit_value": self._limit,
}
values.update(changes)
return Query(**values)
def filter(self, *criteria: Expression | Callable[[M], bool]) -> "Query[M]":
expressions: list[Expression] = []
for criterion in criteria:
if isinstance(criterion, Expression):
expressions.append(criterion)
elif callable(criterion):
expressions.append(Expression(criterion))
else:
raise TypeError("filter() принимает выражения полей или callable")
return self._clone(predicates=self._predicates + tuple(expressions))
def filter_by(self, **values: Any) -> "Query[M]":
expressions: list[Expression] = []
for name, expected in values.items():
if not hasattr(self._model, name):
raise AttributeError(f"У {self._model.__name__} нет поля {name!r}")
expressions.append(
Expression(
lambda item, key=name, value=expected: _filter_by_match(
item._data.get(key), value
),
f"{name}={expected!r}",
)
)
return self._clone(predicates=self._predicates + tuple(expressions))
def order_by(self, *fields: Field | SortExpression | str) -> "Query[M]":
expressions: list[SortExpression] = []
for value in fields:
if isinstance(value, Field):
expressions.append(value.asc())
elif isinstance(value, SortExpression):
expressions.append(value)
elif isinstance(value, str):
expressions.append(SortExpression(value))
else:
raise TypeError("order_by() принимает поле, field.asc()/desc() или имя поля")
return self._clone(sort_expressions=tuple(expressions))
def limit(self, value: int) -> "Query[M]":
if value < 0:
raise ValueError("limit не может быть отрицательным")
return self._clone(limit_value=value)
def offset(self, value: int) -> "Query[M]":
if value < 0:
raise ValueError("offset не может быть отрицательным")
return self._clone(offset_value=value)
def all(self) -> list[M]:
records = list(self._client._records(self._dataset))
for predicate in self._predicates:
records = [item for item in records if predicate(item)]
for expression in reversed(self._sort_expressions):
records.sort(
key=lambda item, name=expression.name: _sort_key(item._data.get(name)),
reverse=expression.reverse,
)
end = None if self._limit is None else self._offset + self._limit
return records[self._offset : end]
def first(self) -> M | None:
records = self.limit(1).all()
return records[0] if records else None
def one_or_none(self) -> M | None:
records = self.limit(2).all()
if len(records) > 1:
raise MultipleResultsFound("Ожидалась максимум одна запись")
return records[0] if records else None
def one(self) -> M:
result = self.one_or_none()
if result is None:
raise NoResultFound("Запись не найдена")
return result
def count(self) -> int:
return len(self.all())
def get(self, entity_id: str) -> M | None:
return self.filter_by(id=entity_id).first()
def __iter__(self) -> Iterator[M]:
return iter(self.all())
@dataclass
class _CacheState:
lock: threading.RLock = field(default_factory=threading.RLock)
datasets: dict[str, tuple[Model, ...]] = field(default_factory=dict)
refreshed_monotonic: float = 0.0
refreshed_at: str | None = None
retry_after: float = 0.0
last_error: str | None = None
_CACHE_LOCK = threading.Lock()
_GLOBAL_CACHE: dict[str, _CacheState] = {}
def configure(
api_url: str,
*,
cache_ttl: float | None = None,
request_timeout: float | None = None,
) -> None:
"""Задаёт глобальные настройки для последующих ScheduleDB()."""
global API_URL, CACHE_TTL, REQUEST_TIMEOUT
API_URL = api_url.rstrip("/")
if cache_ttl is not None:
CACHE_TTL = float(cache_ttl)
if request_timeout is not None:
REQUEST_TIMEOUT = float(request_timeout)
def clear_cache(api_url: str | None = None) -> None:
"""Очищает один глобальный снимок или весь кеш процесса."""
with _CACHE_LOCK:
if api_url is None:
states = list(_GLOBAL_CACHE.values())
_GLOBAL_CACHE.clear()
else:
state = _GLOBAL_CACHE.pop(api_url.rstrip("/"), None)
states = [state] if state is not None else []
# Сбрасываем и уже выданные клиентам state, а не только запись в словаре.
for state in states:
with state.lock:
state.datasets.clear()
state.refreshed_monotonic = 0.0
state.refreshed_at = None
state.retry_after = 0.0
state.last_error = None
class ScheduleDB:
"""Точка входа к кешированной копии всех основных справочников API."""
def __init__(
self,
api_url: str | None = None,
*,
cache_ttl: float | None = None,
request_timeout: float | None = None,
eager: bool = True,
) -> None:
self.api_url = (api_url or API_URL).rstrip("/")
self.cache_ttl = CACHE_TTL if cache_ttl is None else float(cache_ttl)
self.request_timeout = (
REQUEST_TIMEOUT if request_timeout is None else float(request_timeout)
)
with _CACHE_LOCK:
self._state = _GLOBAL_CACHE.setdefault(self.api_url, _CacheState())
# Проверяется при каждом создании клиента, даже если общий кеш уже заполнен.
self._check_compatibility()
if eager:
self._ensure_fresh(require_data=True, compatibility_checked=True)
@property
def schedule(self) -> Query[Schedule]:
return Query(self, "schedule", Schedule)
@property
def default(self) -> Query[Schedule]:
return Query(self, "default", Schedule)
@property
def exam(self) -> Query[Schedule]:
return Query(self, "exam", Schedule)
@property
def groups(self) -> Query[Group]:
return Query(self, "groups", Group)
@property
def teachers(self) -> Query[Teacher]:
return Query(self, "teachers", Teacher)
@property
def rooms(self) -> Query[Room]:
return Query(self, "rooms", Room)
def query(self, model: type[M]) -> Query[M]:
"""SQLAlchemy-подобная форма: db.query(ScheduleEntry).filter(...)."""
datasets: dict[type[Model], str] = {
Schedule: "schedule",
Group: "groups",
Teacher: "teachers",
Room: "rooms",
}
dataset = datasets.get(model)
if dataset is None:
raise TypeError(
"query() поддерживает ScheduleEntry, Group, Teacher и Room; "
"для истории используйте group_history(group_id)"
)
return Query(self, dataset, model)
def group_history(self, group_id: str) -> Query[GroupHistory]:
"""Загружает историю группы лениво и дальше хранит в общем кеше."""
dataset = f"group_history:{group_id}"
return Query(self, dataset, GroupHistory)
def refresh(self) -> None:
"""Принудительно и атомарно обновляет все основные наборы или бросает APIError."""
with self._state.lock:
try:
self._refresh_locked()
except APIError as error:
self._state.last_error = str(error)
self._state.retry_after = time.monotonic() + FAILURE_RETRY_DELAY
raise
def cache_info(self) -> dict[str, Any]:
with self._state.lock:
return {
"api_url": self.api_url,
"refreshed_at": self._state.refreshed_at,
"last_error": self._state.last_error,
"counts": {
name: len(items)
for name, items in self._state.datasets.items()
if not name.startswith("group_history:")
},
}
def _records(self, dataset: str) -> Sequence[Model]:
self._ensure_fresh(require_data=True)
with self._state.lock:
if dataset.startswith("group_history:") and dataset not in self._state.datasets:
group_id = dataset.partition(":")[2]
response = self._fetch(f"/groups/{quote(group_id, safe='')}/history")
self._state.datasets[dataset] = tuple(
GroupHistory(item) for item in response["result"]
)
return self._state.datasets.get(dataset, ())
def _ensure_fresh(
self,
*,
require_data: bool,
compatibility_checked: bool = False,
) -> None:
with self._state.lock:
now = time.monotonic()
has_data = bool(self._state.datasets)
stale = not has_data or now - self._state.refreshed_monotonic >= self.cache_ttl
if not stale or (has_data and now < self._state.retry_after):
return
try:
self._refresh_locked(check_compatibility=not compatibility_checked)
except APIError as error:
self._state.last_error = str(error)
self._state.retry_after = time.monotonic() + FAILURE_RETRY_DELAY
if require_data and not has_data:
raise
def _refresh_locked(self, *, check_compatibility: bool = True) -> None:
if check_compatibility:
self._check_compatibility()
endpoints = {
"default": ("/schedule/default", Schedule),
"exam": ("/schedule/exam", Schedule),
"groups": ("/groups", Group),
"teachers": ("/teachers", Teacher),
"rooms": ("/rooms", Room),
}
fresh: dict[str, tuple[Model, ...]] = {}
for name, (path, model) in endpoints.items():
response = self._fetch(path)
records: list[Model] = []
for raw_item in response["result"]:
item = dict(raw_item)
if name in {"default", "exam"}:
item.setdefault("schedule_type", name)
records.append(model(item))
fresh[name] = tuple(records)
fresh["schedule"] = fresh["default"] + fresh["exam"]
# Истории групп инвалидируются: после обновления основной базы они могли измениться.
self._state.datasets = fresh
self._state.refreshed_monotonic = time.monotonic()
self._state.refreshed_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
self._state.retry_after = 0.0
self._state.last_error = None
def _fetch(self, path: str) -> dict[str, Any]:
return _request_json(f"{self.api_url}{path}", self.request_timeout)
def _check_compatibility(self) -> None:
response = self._fetch("/version")
actual = response.get("api_version")
if actual != API_VERSION:
raise IncompatibleAPIVersionError(
f"Несовместимая версия API: сервер={actual!r}, "
f"клиент ожидает {API_VERSION!r}"
)
def _request_json(url: str, timeout: float) -> dict[str, Any]:
request = Request(url, headers={"Accept": "application/json"})
try:
with urlopen(request, timeout=timeout) as response:
payload = json.load(response)
except HTTPError as error:
raise APIError(f"API вернуло HTTP {error.code} для {url}") from error
except (URLError, TimeoutError, OSError) as error:
raise APIError(f"Не удалось обратиться к {url}: {error}") from error
except (UnicodeDecodeError, json.JSONDecodeError) as error:
raise APIError(f"API вернуло невалидный JSON для {url}") from error
if not isinstance(payload, dict) or payload.get("ok") is not True:
raise APIError(f"Некорректный ответ API для {url}")
return payload
def _filter_by_match(actual: Any, expected: Any) -> bool:
# Самый частый запрос — filter_by(group_ids="АТ-501"). Для списковых полей
# одиночное значение означает contains, а коллекция — точное сравнение.
if isinstance(actual, (list, tuple, set)) and not isinstance(
expected, (list, tuple, set)
):
return expected in actual
return actual == expected
def _contains(container: Any, value: Any) -> bool:
try:
return value in container
except (TypeError, AttributeError):
return False
def _sort_key(value: Any) -> tuple[bool, Any]:
return value is None, value
def _safe_lt(left: Any, right: Any) -> bool:
return left is not None and right is not None and left < right
def _safe_le(left: Any, right: Any) -> bool:
return left is not None and right is not None and left <= right
def _safe_gt(left: Any, right: Any) -> bool:
return left is not None and right is not None and left > right
def _safe_ge(left: Any, right: Any) -> bool:
return left is not None and right is not None and left >= right
__all__ = [
"API_URL",
"API_VERSION",
"CACHE_TTL",
"APIError",
"IncompatibleAPIVersionError",
"NoResultFound",
"MultipleResultsFound",
"ScheduleDB",
"Schedule",
"ScheduleEntry",
"Group",
"Teacher",
"Room",
"GroupHistory",
"Query",
"configure",
"clear_cache",
]

83
uv.lock generated
View file

@ -151,24 +151,20 @@ name = "bot-legacy"
version = "0.1.0" version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "colorama" },
{ name = "ics" }, { name = "ics" },
{ name = "pytelegrambotapi" }, { name = "pytelegrambotapi" },
{ name = "python-dotenv" }, { name = "python-dotenv" },
{ name = "python-socks" },
{ name = "pytz" }, { name = "pytz" },
{ name = "requests", extra = ["socks"] },
{ name = "rich" },
] ]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "colorama", specifier = ">=0.4.6" },
{ name = "ics", specifier = ">=0.7.3" }, { name = "ics", specifier = ">=0.7.3" },
{ name = "pytelegrambotapi", specifier = ">=4.36.1" }, { name = "pytelegrambotapi", specifier = ">=4.36.1" },
{ name = "python-dotenv", specifier = ">=1.2.3" }, { name = "python-dotenv", specifier = ">=1.2.3" },
{ name = "python-socks", specifier = ">=3.0.0" },
{ name = "pytz", specifier = ">=2026.3.post1" }, { name = "pytz", specifier = ">=2026.3.post1" },
{ name = "requests", extras = ["socks"], specifier = ">=2.34.2" },
{ name = "rich", specifier = ">=15.0.0" },
] ]
[[package]] [[package]]
@ -311,15 +307,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" }, { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" },
] ]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]] [[package]]
name = "frozenlist" name = "frozenlist"
version = "1.8.0" version = "1.8.0"
@ -434,27 +421,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" },
] ]
[[package]]
name = "markdown-it-py"
version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]] [[package]]
name = "multidict" name = "multidict"
version = "6.7.1" version = "6.7.1"
@ -648,24 +614,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" },
] ]
[[package]]
name = "pygments"
version = "2.21.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
]
[[package]]
name = "pysocks"
version = "1.7.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0", size = 284429, upload-time = "2019-09-20T02:07:35.714Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725, upload-time = "2019-09-20T02:06:22.938Z" },
]
[[package]] [[package]]
name = "pytelegrambotapi" name = "pytelegrambotapi"
version = "4.36.1" version = "4.36.1"
@ -700,6 +648,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" },
] ]
[[package]]
name = "python-socks"
version = "3.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6c/6b/bd9e15e67cf42b318970028a572750924adbe28776e8420fcdfe60838c61/python_socks-3.0.0.tar.gz", hash = "sha256:c20ddf978b5c90467bf242757a11dcd00d7c8bd339901de1839b492d5571ab7f", size = 232731, upload-time = "2026-08-11T11:17:09.464Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/e7/af080d0d2d5bbc3dbdf58a805812ee45175c5c32c6673f23a5480f9785c2/python_socks-3.0.0-py3-none-any.whl", hash = "sha256:e06a421d4d7f3fc17ef2740ab6c291640364f6a362b94a1ae87a136c29bd0067", size = 49519, upload-time = "2026-08-11T11:17:07.118Z" },
]
[[package]] [[package]]
name = "pytz" name = "pytz"
version = "2026.3.post1" version = "2026.3.post1"
@ -724,24 +681,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
] ]
[package.optional-dependencies]
socks = [
{ name = "pysocks" },
]
[[package]]
name = "rich"
version = "15.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
]
[[package]] [[package]]
name = "six" name = "six"
version = "1.17.0" version = "1.17.0"

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 710 KiB

After

Width:  |  Height:  |  Size: 696 KiB

File diff suppressed because it is too large Load diff

Before

Width:  |  Height:  |  Size: 751 KiB

After

Width:  |  Height:  |  Size: 723 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 708 KiB

After

Width:  |  Height:  |  Size: 697 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 678 KiB

After

Width:  |  Height:  |  Size: 668 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 628 KiB

After

Width:  |  Height:  |  Size: 620 KiB

View file

@ -35,7 +35,6 @@
<g id="Коридоры" transform="matrix(1,0,0,1,280,0)"> <g id="Коридоры" transform="matrix(1,0,0,1,280,0)">
<path d="M3061.77,2336.07l-73.434,0l0.019,-169.434l73.415,0l0,8.709l320.728,0l0,-8.709l470.548,0l0,6.612l123.711,0l0,-114.104l38.594,0l0,115.621l109.572,0.024l-0.019,-114.826l49.546,-0.819l0,150.113l-1112.68,0l0,126.813Z" style="fill:#d4d4d4;"/> <path d="M3061.77,2336.07l-73.434,0l0.019,-169.434l73.415,0l0,8.709l320.728,0l0,-8.709l470.548,0l0,6.612l123.711,0l0,-114.104l38.594,0l0,115.621l109.572,0.024l-0.019,-114.826l49.546,-0.819l0,150.113l-1112.68,0l0,126.813Z" style="fill:#d4d4d4;"/>
</g> </g>
<g id="Точки"><circle cx="3898" cy="2188" r="2" fill="transparent" /> <circle cx="3945" cy="2188" r="2" fill="transparent" /> <circle cx="3979" cy="2188" r="2" fill="transparent" /> <circle cx="4029" cy="2185" r="2" fill="transparent" /> <circle cx="4067" cy="2188" r="2" fill="transparent" /> <circle cx="4105" cy="2188" r="2" fill="transparent" /> <circle cx="4142" cy="2188" r="2" fill="transparent" /> <circle cx="4183" cy="2188" r="2" fill="transparent" /> <circle cx="4221" cy="2191" r="2" fill="transparent" /> <circle cx="4268" cy="2191" r="2" fill="transparent" /> <circle cx="4268" cy="2144" r="2" fill="transparent" /> <circle cx="4268" cy="2097" r="2" fill="transparent" /> <circle cx="4318" cy="2197" r="2" fill="transparent" /> <circle cx="4365" cy="2197" r="2" fill="transparent" /> <circle cx="4397" cy="2191" r="2" fill="transparent" /> <circle cx="4434" cy="2188" r="2" fill="transparent" /> <circle cx="3854" cy="2178" r="2" fill="transparent" /> <circle cx="3804" cy="2188" r="2" fill="transparent" /> <circle cx="3747" cy="2185" r="2" fill="transparent" /> <circle cx="3700" cy="2185" r="2" fill="transparent" /> <circle cx="3647" cy="2185" r="2" fill="transparent" /> <circle cx="3593" cy="2191" r="2" fill="transparent" /> <circle cx="3546" cy="2191" r="2" fill="transparent" /> <circle cx="3502" cy="2191" r="2" fill="transparent" /> <circle cx="3455" cy="2188" r="2" fill="transparent" /> <circle cx="3418" cy="2182" r="2" fill="transparent" /> <circle cx="3377" cy="2185" r="2" fill="transparent" /> <circle cx="3342" cy="2185" r="2" fill="transparent" /> <circle cx="3302" cy="2185" r="2" fill="transparent" /> <circle cx="3308" cy="2213" r="2" fill="transparent" /></g>
<g id="Аудитории" transform="matrix(1,0,0,1,280,0)"> <g id="Аудитории" transform="matrix(1,0,0,1,280,0)">
<g id="_1-609" serif:id="1-609"> <g id="_1-609" serif:id="1-609">
<rect id="_1-6091" serif:id="1-609" x="3380.5" y="2061.24" width="158.619" height="105.394" style="fill:none;stroke:#000;stroke-width:6.9px;"/> <rect id="_1-6091" serif:id="1-609" x="3380.5" y="2061.24" width="158.619" height="105.394" style="fill:none;stroke:#000;stroke-width:6.9px;"/>

Before

Width:  |  Height:  |  Size: 538 KiB

After

Width:  |  Height:  |  Size: 536 KiB

View file

@ -1,86 +1,53 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="ru"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#ffffff">
<title>Карта</title> <title>Карта</title>
<script src="../common/telegram.js"></script> <script src="https://telegram.org/js/telegram-web-app.js?59"></script>
<script src="../common/eruda.min.js?v15"></script> <link rel="stylesheet" href="./styles/main.css?v12">
<link rel="stylesheet" href="./styles/main.css?v16">
</head> </head>
<body> <body>
<section id="block" aria-live="polite"> <section id="block">
<div id="loader"></div> <div id="loader"></div>
<h3>Загрузка карты</h3> <h3>Загрузка карты</h3>
<p>0/6 загружено...</p> <p>0/6 загружено...</p>
<div id="mp"></div>
</section>
<section id="map">
<div id="controls">
<div class="current" onclick="floorsopen()">
<span>2 этаж</span>
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e3e3e3"><path d="M480-344 240-584l56-56 184 184 184-184 56 56-240 240Z"/></svg>
</div>
<div class="select hidden">
<span onclick="fswitch(1)">1 этаж</span>
<span onclick="fswitch(2)" class="active">2 этаж</span>
<span onclick="fswitch(3)">3 этаж</span>
<span onclick="fswitch(4)">4 этаж</span>
<span onclick="fswitch(5)">5 этаж</span>
<span onclick="fswitch(6)">6 этаж</span>
</div>
</div>
<div id="wr"></div>
</section> </section>
<main id="app" aria-label="Карта университета"> <footer >
<section id="map" aria-label="Карта здания"> <!-- <div class="dragger"></div> -->
<div id="controls"> <div class="hints">
<button id="floor-current" class="floor-current" type="button" aria-expanded="false"> </div>
<span>2 этаж</span> <div class="i">
<svg aria-hidden="true" viewBox="0 -960 960 960"><path d="M480-344 240-584l56-56 184 184 184-184 56 56-240 240Z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e3e3e3"><path d="M480-480q33 0 56.5-23.5T560-560q0-33-23.5-56.5T480-640q-33 0-56.5 23.5T400-560q0 33 23.5 56.5T480-480Zm0 294q122-112 181-203.5T720-552q0-109-69.5-178.5T480-800q-101 0-170.5 69.5T240-552q0 71 59 162.5T480-186Zm0 106Q319-217 239.5-334.5T160-552q0-150 96.5-239T480-880q127 0 223.5 89T800-552q0 100-79.5 217.5T480-80Zm0-480Z"/></svg>
</button> <input type="text" placeholder="Введи нужную аудиторию...">
<div id="floor-select" class="floor-select hidden" aria-label="Выбор этажа"> <!-- <button onclick="footerfind()">Найти</button> -->
<button type="button" data-floor="1">1 этаж</button> </div>
<button type="button" data-floor="2" class="active">2 этаж</button> </footer>
<button type="button" data-floor="3">3 этаж</button> <script src="./js/footer.js?v12"></script>
<button type="button" data-floor="4">4 этаж</button> <script src="./js/map.js?v12"></script>
<button type="button" data-floor="5">5 этаж</button> <script>mapload()</script>
<button type="button" data-floor="6">6 этаж</button> <script>
</div> window.Telegram.WebApp.expand()
</div> </script>
<div id="wr" aria-live="polite"></div>
<div id="map-message" class="map-message hidden"></div>
</section>
<section id="bottom-panel" aria-label="Поиск и навигация">
<nav id="mode-switch" aria-label="Режим карты">
<button id="search-mode" type="button" class="active" aria-selected="true">Поиск</button>
<button id="navigator-mode" type="button" aria-selected="false">Навигатор</button>
</nav>
<section id="search-panel" class="panel-card" aria-labelledby="search-mode">
<p id="search-title" class="panel-title">Напиши название аудитории ниже</p>
<div class="field-row">
<svg aria-hidden="true" viewBox="0 -960 960 960"><path d="M480-480q33 0 56.5-23.5T560-560q0-33-23.5-56.5T480-640q-33 0-56.5 23.5T400-560q0 33 23.5 56.5T480-480Zm0 294q122-112 181-203.5T720-552q0-109-69.5-178.5T480-800q-101 0-170.5 69.5T240-552q0 71 59 162.5T480-186Zm0 106Q319-217 239.5-334.5T160-552q0-150 96.5-239T480-880q127 0 223.5 89T800-552q0 100-79.5 217.5T480-80Zm0-480Z"/></svg>
<input id="room-search" type="text" autocomplete="off" inputmode="search" placeholder="Писать сюда..." aria-label="Поиск аудитории">
</div>
<div id="search-hints" class="hints" aria-live="polite"></div>
</section>
<section id="navigator-panel" class="panel-card hidden" aria-labelledby="navigator-mode">
<p class="panel-title">Построй маршрут до аудитории</p>
<div class="navigator-fields">
<div class="field-row">
<svg aria-hidden="true" viewBox="0 -960 960 960"><path d="m120-120 80-80h560l80 80H120Zm80-160v-160h560v160H200Zm0-240v-160h560v160H200Zm0-240v-80h560v80H200Z"/></svg>
<input id="route-start" type="text" autocomplete="off" inputmode="search" placeholder="Аудитория возле тебя..." aria-label="Начальная аудитория">
</div>
<div id="route-start-hints" class="hints" aria-live="polite"></div>
<div class="field-row">
<svg aria-hidden="true" viewBox="0 -960 960 960"><path d="M480-80 160-400l56-56 224 224v-528h80v528l224-224 56 56L480-80Z"/></svg>
<input id="route-end" type="text" autocomplete="off" inputmode="search" placeholder="Аудитория которая тебе нужна..." aria-label="Конечная аудитория">
</div>
<div id="route-end-hints" class="hints" aria-live="polite"></div>
</div>
<button id="route-submit" class="primary-button" type="button">Найти</button>
<p id="route-feedback" class="feedback" aria-live="polite"></p>
</section>
<section id="route-card" class="route-card hidden" aria-live="polite">
<div class="route-card-head">
<p id="route-summary" class="route-summary" aria-label="Маршрут"></p>
<button id="route-hide" type="button" aria-expanded="true">Скрыть</button>
</div>
<strong id="route-title" class="route-title">Маршрут</strong>
<div id="route-steps" class="route-steps"></div>
</section>
</section>
</main>
<script src="./js/map.js?v16"></script>
<script>window.Telegram.WebApp.expand(); window.ZatupsMap.init();</script>
</body> </body>
</html> </html>

View file

@ -2,38 +2,6 @@ const input = document.querySelector('footer > .i > input')
const hints = document.querySelector('.hints') const hints = document.querySelector('.hints')
var inputed_room = '' var inputed_room = ''
const initEruda = () => {
if (window.__zatupsErudaInitialized || window.__zatupsErudaLoading) return
const start = () => {
if (!window.eruda) {
hints.innerHTML = '<span>Консоль не загрузилась</span>'
return
}
try {
window.eruda.init()
window.__zatupsErudaInitialized = true
} catch (error) {
hints.innerHTML = '<span>Консоль не загрузилась</span>'
}
}
if (window.eruda) return start()
window.__zatupsErudaLoading = true
const script = document.createElement('script')
script.src = '../common/eruda.min.js?v13'
script.onload = () => {
window.__zatupsErudaLoading = false
start()
}
script.onerror = () => {
window.__zatupsErudaLoading = false
hints.innerHTML = '<span>Консоль не загрузилась</span>'
}
document.head.appendChild(script)
}
input.addEventListener('focus', (e) => { input.addEventListener('focus', (e) => {
const fixed = `height: ${map.clientHeight}px !important;` const fixed = `height: ${map.clientHeight}px !important;`
document.querySelector('#map').style = fixed document.querySelector('#map').style = fixed
@ -42,13 +10,7 @@ input.addEventListener('focus', (e) => {
input.addEventListener('input', (e) => { input.addEventListener('input', (e) => {
console.log(e.target.value) console.log(e.target.value)
inputed_room = e.target.value.trim().toLowerCase() inputed_room = e.target.value.toLowerCase()
if (inputed_room === 'eruda') {
try {hints.classList.remove('a')} catch {}
hints.innerHTML = ''
initEruda()
return
}
if (inputed_room.length > 0) { if (inputed_room.length > 0) {
try {hints.classList.add('a')} catch {} try {hints.classList.add('a')} catch {}
const out = fdata.results(inputed_room) const out = fdata.results(inputed_room)
@ -79,4 +41,4 @@ const find = async (room_name, floor) => {
} }
Telegram.WebApp.HapticFeedback.notificationOccurred('success') Telegram.WebApp.HapticFeedback.notificationOccurred('success')
highlight(room_name) highlight(room_name)
} }

View file

@ -1,839 +1,136 @@
/* const fdata = {
* The map is deliberately self-contained. Telegram's theme variables are
* not used: the floor plans are white and the controls use one fixed palette
* both inside and outside Telegram.
*/
const FLOOR_COUNT = 6
const MAP_VERSION = '16'
const $ = (selector) => document.querySelector(selector)
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const fdata = window.fdata = {
floor: 2, floor: 2,
floors: Array(FLOOR_COUNT).fill(null), floors: [null,null,null,null,null,null],
floors_rooms: Array.from({length: FLOOR_COUNT}, () => []), floors_rooms: [[], [], [], [], [], []],
room_floors: Object.create(null),
inited: false, inited: false,
results: (query, limit = 10) => roomResults(query, limit), check: async () => {
} var count = 0
fdata.floors.forEach(e => e == null ? count += 1 : null)
const state = { document.querySelector('#block > p').innerHTML = `${6-count}/6 загружено...`
initialized: false, if (count == 0) {
mode: 'search', await sleep(100)
highlightedRoom: '', mapdisplay()
route: null, removeblock()
routeStatus: null,
routeStart: null,
routeEnd: null,
navigatorManifest: null,
mapHeight: null,
routeView: false,
routeCollapsed: false,
}
function haptic(type = 'light') {
try {
window.Telegram?.WebApp?.HapticFeedback?.impactOccurred(type)
} catch (_) {
// Haptic feedback is optional outside Telegram.
}
}
function normalize(value) {
return String(value || '').trim().toLocaleLowerCase('ru-RU')
}
function compact(value) {
return normalize(value).replace(/[^\p{L}\p{N}]/gu, '')
}
function roomResults(query, limit = 10) {
const needle = compact(query)
if (!needle) return []
const all = []
const seen = new Set()
fdata.floors_rooms.forEach((rooms, index) => rooms.forEach((name) => {
const key = normalize(name)
if (!key || seen.has(key)) return
seen.add(key)
all.push({name, floor: index + 1})
}))
const exact = all.filter((room) => compact(room.name) === needle)
if (exact.length) return exact.slice(0, limit)
const contains = all.filter((room) => compact(room.name).includes(needle))
if (contains.length) return contains.slice(0, limit)
// Keep the fallback useful for a mistyped room without pulling in a large
// fuzzy-search library.
const distance = (name) => {
const value = compact(name)
let previous = Array.from({length: needle.length + 1}, (_, i) => i)
for (let i = 0; i < value.length; i += 1) {
const current = [i + 1]
for (let j = 0; j < needle.length; j += 1) {
current.push(Math.min(
current[j] + 1,
previous[j + 1] + 1,
previous[j] + (value[i] === needle[j] ? 0 : 1),
))
}
previous = current
} }
return previous[needle.length] },
results: (query) => {
query = query.toLowerCase()
var found = []
var foundrooms = []
fdata.floors_rooms.forEach((rooms, floor) => {
rooms.forEach(
room =>
room && room.toLowerCase().includes(query) && !foundrooms.includes(room)
? function() {found.push({r: room, f: floor+1}); foundrooms.push(room)} ()
: null
)
})
return found
} }
return all
.map((room) => ({room, score: distance(room.name)}))
.filter(({room, score}) => score <= Math.max(2, Math.ceil(compact(room.name).length * .45)))
.sort((a, b) => a.score - b.score || a.room.name.localeCompare(b.room.name, 'ru'))
.slice(0, limit)
.map(({room}) => room)
} }
function exactRoom(value) { const floorsopen = () => {
const key = compact(value) document.querySelector('#controls > .select').classList.toggle('hidden')
if (!key) return null Telegram.WebApp.HapticFeedback.impactOccurred('light')
return roomResults(value, 100).find((room) => compact(room.name) === key) || null }
const fswitch = floor => {
if (floor == fdata.floor) return
fdata.floor = floor
if (!document.querySelector('#controls > .select').classList.contains('hidden')) document.querySelector('#controls > .select').classList.add('hidden')
//
document.querySelector('#controls > .select > .active').classList.remove('active')
document.querySelector('#controls > .current > span').innerHTML = `${floor} этаж`
document.querySelectorAll('#controls > .select > span')[floor-1].classList.add('active')
input.placeholder = 'Введи нужную аудиторию...'
Telegram.WebApp.HapticFeedback.impactOccurred('light')
mapdisplay()
} }
function showHints(container, results, onSelect) { const removeblock = () => {
container.replaceChildren() document.querySelector('#block').remove()
if (!results.length) { }
const empty = document.createElement('span') const highlight = (room) => {
empty.className = 'hint-empty' document.querySelectorAll('.highlight').forEach(e => e.classList.remove('highlight'))
empty.textContent = 'Аудитория не найдена' var lastfound = null
container.append(empty) document.querySelectorAll('#Аудитории > g').forEach(e => {
return if (e.getAttribute('serif:id') == room) {
} e.querySelector('*').classList.add('highlight')
results.forEach((result) => { lastfound = e
const button = document.createElement('button') }
button.type = 'button'
button.textContent = result.name
button.addEventListener('click', () => onSelect(result))
container.append(button)
}) })
} const scrollauto = (room) => {
const bbox = room.getBBox()
const svg = document.querySelector('#map > #wr > svg')
const roomCenterX = (bbox.x%svg.viewBox.baseVal.width) + bbox.width / 2
const scale = svg.clientHeight / svg.viewBox.baseVal.height
const centerX = roomCenterX * scale
const sc = centerX - map.clientWidth / 2
map.scroll({left: sc, behavior: 'smooth'})
console.log(sc, bbox,roomCenterX, centerX, scale, map.clientWidth)
function clearHints(...containers) { }
containers.forEach((container) => container?.replaceChildren()) const map = document.querySelector('#map > #wr')
} if (room.includes('-')) {
const building = room.split('-')[0]
const values_to_scroll = {
1: 1.1,
2: 1.1,
3: 1.1,
4: 1.1,
5: 1.1,
6: 1.1,
7: null,
9: 10000,
8: 3.9,
10: 1.9
}
const val = values_to_scroll[building]
if (!val) {return scrollauto(lastfound)}
function removeBlock() { const sc = (map.scrollWidth - map.clientWidth) / val
$('#block')?.remove() map.scroll({left: sc, behavior: 'smooth'})
}
} else if (room.includes('Туале')) {
function applyMapHeightLock() { const sc = (map.scrollWidth - map.clientWidth) / 1.1
const map = $('#map') map.scroll({left: sc, behavior: 'smooth'})
const wrapper = $('#wr') } else {
if (!map || !wrapper || !state.mapHeight) return return scrollauto(lastfound)
const height = `${state.mapHeight}px`
map.style.height = height
wrapper.style.height = height
}
function lockMapHeight() {
const map = $('#map')
const wrapper = $('#wr')
if (!map || !wrapper || state.mapHeight) return
const measured = Math.round(map.getBoundingClientRect().height)
const panel = $('#bottom-panel')
const fallback = Math.round(window.innerHeight - (panel?.getBoundingClientRect().height || 0))
state.mapHeight = Math.max(1, measured || fallback)
document.documentElement.style.setProperty('--map-locked-height', `${state.mapHeight}px`)
map.classList.add('map-locked')
applyMapHeightLock()
}
function setMapMessage(text = '') {
const element = $('#map-message')
if (!element) return
element.textContent = text
element.classList.toggle('hidden', !text)
}
function updateLoading() {
const loaded = fdata.floors.filter(Boolean).length
const progress = $('#block > p')
if (progress) progress.textContent = `${loaded}/${FLOOR_COUNT} загружено...`
}
function roomIdFromElement(element) {
return element.getAttribute('serif:id') || element.getAttribute('id') || ''
}
function parseRooms(svgText, floor) {
const doc = new DOMParser().parseFromString(svgText, 'image/svg+xml')
const root = doc.querySelector('#Аудитории')
if (!root) return []
const names = []
const seen = new Set()
Array.from(root.children).filter((element) => element.tagName?.toLowerCase() === 'g').forEach((element) => {
const name = roomIdFromElement(element).trim()
if (!name || seen.has(normalize(name))) return
seen.add(normalize(name))
names.push(name)
if (!fdata.room_floors[name]) fdata.room_floors[name] = floor
})
return names
}
async function loadNavigatorManifest() {
try {
const response = await fetch('./data/navigator.json', {cache: 'no-store'})
if (!response.ok) return
const value = await response.json()
if (value && typeof value === 'object') state.navigatorManifest = value
} catch (_) {
// The map itself remains usable if the embedded navigator data is unavailable.
} }
} }
async function loadFloor(index) { const mapload = async () => {
const response = await fetch(`./images/${index + 1}.svg?v=${MAP_VERSION}`)
if (!response.ok) throw new Error(`floor ${index + 1}: ${response.status}`)
const text = await response.text()
fdata.floors[index] = text
fdata.floors_rooms[index] = parseRooms(text, index + 1)
updateLoading()
}
function floorValue(value) { for (let i = 0; i < 6; i++) {
const number = Number.parseInt(String(value), 10) await fetch(`./images/${i+1}.svg`).then(
return Number.isInteger(number) && number >= 1 && number <= FLOOR_COUNT ? number : null async e => {
} const mp = document.createElement('div');
const svg = await e.text()
function setFloor(floor, {keepHighlight = false} = {}) { fdata.floors[i] = svg
const next = floorValue(floor) mp.innerHTML = svg
if (!next || !fdata.floors[next - 1]) return mp.querySelectorAll('#Аудитории > g').forEach(e => {
fdata.floor = next // console.log(e.getAttributeNS('serif:id'))
const current = $('#floor-current') fdata.floors_rooms[i].push(e.getAttribute('serif:id'))
if (current) { })
current.querySelector('span').textContent = `${next} этаж` await sleep(100)
current.setAttribute('aria-expanded', 'false') fdata.check()
}
)
} }
$('#floor-select')?.classList.add('hidden')
$('#floor-select')?.querySelectorAll('button').forEach((button) => { // [0,1,2,3,4,5].map(async (_, i) =>
button.classList.toggle('active', Number(button.dataset.floor) === next) // await fetch(`./images/${i+1}.svg`).then(async e => {fdata.floors[i] = await e.text(); fdata.check()}))
})
renderFloor()
if (!keepHighlight) state.highlightedRoom = ''
haptic('light')
} }
const mapdisplay = async () => {
function toggleFloors() { const map = document.querySelector('#map > #wr')
const select = $('#floor-select') const svg = fdata.floors[fdata.floor-1]
const current = $('#floor-current')
if (!select || !current) return
const opened = select.classList.toggle('hidden') === false
current.setAttribute('aria-expanded', String(opened))
haptic('light')
}
function currentSvg() {
return $('#wr > svg')
}
function scrollToRoom(element) {
const map = $('#wr')
const svg = currentSvg()
if (!svg || !map || !element) return
const maxScroll = Math.max(0, map.scrollWidth - map.clientWidth)
const room = roomIdFromElement(element)
const building = room.includes('-') ? room.split('-')[0] : ''
// The source drawings place buildings at different horizontal offsets.
// These are the calibrated offsets used by the previous map version.
const buildingScrollFactors = {
'1': 1.1,
'2': 1.1,
'3': 1.1,
'4': 1.1,
'5': 1.1,
'6': 1.1,
'7': null,
'8': 3.9,
'9': 10000,
'10': 1.9,
}
if (Object.prototype.hasOwnProperty.call(buildingScrollFactors, building) && buildingScrollFactors[building]) {
map.scrollTo({left: maxScroll / buildingScrollFactors[building], behavior: 'smooth'})
return
}
if (room.includes('Туале')) {
map.scrollTo({left: maxScroll / 1.1, behavior: 'smooth'})
return
}
try {
const bbox = element.getBBox()
const viewBox = svg.viewBox?.baseVal
const svgWidth = viewBox?.width || svg.getBoundingClientRect().width
const svgHeight = viewBox?.height || svg.getBoundingClientRect().height
const scale = svgHeight ? svg.clientHeight / svgHeight : 1
const center = (bbox.x + bbox.width / 2) * scale
const contentWidth = Math.max(svgWidth * scale, svg.clientWidth)
map.scrollTo({left: Math.max(0, Math.min(contentWidth - map.clientWidth, center - map.clientWidth / 2)), behavior: 'smooth'})
} catch (_) {
// Some SVG elements do not expose a bounding box in older WebViews.
}
}
function roomElement(room) {
const svg = currentSvg()
if (!svg) return null
return Array.from(svg.querySelectorAll('#Аудитории > g')).find((element) => roomIdFromElement(element) === room) || null
}
function highlight(room) {
currentSvg()?.querySelectorAll('.highlight').forEach((element) => element.classList.remove('highlight'))
const element = roomElement(room)
if (!element) return false
element.classList.add('highlight')
element.querySelectorAll('*').forEach((child) => child.classList.add('highlight'))
state.highlightedRoom = room
scrollToRoom(element)
return true
}
function nodePoint(node) {
if (Array.isArray(node) && node.length >= 2) return {x: Number(node[0]), y: Number(node[1])}
if (!node || typeof node !== 'object') return null
const x = Number(node.x ?? node.cx ?? node[0])
const y = Number(node.y ?? node.cy ?? node[1])
return Number.isFinite(x) && Number.isFinite(y) ? {x, y} : null
}
function manifestPoints(floorId) {
const manifest = state.navigatorManifest
if (!manifest) return []
const floors = manifest.floors || manifest
const value = floors?.[String(floorId)] || floors?.[Number(floorId)]
if (Array.isArray(value)) return value
if (Array.isArray(value?.points)) return value.points
if (Array.isArray(value?.positions)) return value.positions
if (Array.isArray(value?.pathfinder_data?.positions)) return value.pathfinder_data.positions
return []
}
function segmentFloor(segment) {
const direct = floorValue(segment?.floor_name) || floorValue(segment?.floor_id)
if (direct) return direct
const floors = state.navigatorManifest?.floors || state.navigatorManifest
if (!floors || typeof floors !== 'object') return null
for (const [number, value] of Object.entries(floors)) {
if (value?.id != null && String(value.id) === String(segment?.floor_id)) return floorValue(number)
}
return null
}
function routePoints(segment) {
if (Array.isArray(segment?.points)) return segment.points.map(nodePoint).filter(Boolean)
const explicit = manifestPoints(segment?.floor_id)
const fallback = explicit.length ? explicit : manifestPoints(segmentFloor(segment))
const nodes = Array.isArray(segment?.node_ids) ? segment.node_ids : segment?.array
if (Array.isArray(nodes) && fallback.length) return nodes.map((node) => nodePoint(fallback[node] ?? node)).filter(Boolean)
const svg = currentSvg()
const pointsGroup = svg?.querySelector('#Точки')
if (!pointsGroup || !Array.isArray(nodes)) return []
const pointNodes = Array.from(pointsGroup.children)
return nodes.map((index) => {
const point = pointNodes[Number(index)]
if (!point) return null
return nodePoint({x: point.getAttribute('cx'), y: point.getAttribute('cy')})
}).filter(Boolean)
}
function drawRoute(segment) {
const svg = currentSvg()
if (!svg) return false
svg.querySelector('#Линия')?.remove()
const points = routePoints(segment)
if (points.length < 2) return false
const group = document.createElementNS('http://www.w3.org/2000/svg', 'g')
group.id = 'Линия'
const viewBoxWidth = svg.viewBox?.baseVal?.width || 1000
const strokeWidth = Math.max(4, viewBoxWidth * .006)
for (let index = 1; index < points.length; index += 1) {
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line')
line.setAttribute('x1', String(points[index - 1].x))
line.setAttribute('y1', String(points[index - 1].y))
line.setAttribute('x2', String(points[index].x))
line.setAttribute('y2', String(points[index].y))
line.setAttribute('stroke', 'var(--line)')
line.setAttribute('stroke-width', String(strokeWidth))
line.setAttribute('stroke-linecap', 'round')
line.setAttribute('stroke-linejoin', 'round')
line.setAttribute('vector-effect', 'non-scaling-stroke')
group.append(line)
}
const markerSize = Math.max(strokeWidth * 2.5, 12)
;[points[0], points[points.length - 1]].forEach((point, index) => {
const marker = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
marker.classList.add(index === 0 ? 'route-start-marker' : 'route-end-marker')
marker.setAttribute('x', String(point.x - markerSize / 2))
marker.setAttribute('y', String(point.y - markerSize / 2))
marker.setAttribute('width', String(markerSize))
marker.setAttribute('height', String(markerSize))
marker.setAttribute('fill', 'var(--line)')
marker.setAttribute('stroke', '#ffffff')
marker.setAttribute('stroke-width', String(Math.max(1, strokeWidth * .25)))
marker.setAttribute('vector-effect', 'non-scaling-stroke')
group.append(marker)
})
svg.append(group)
return true
}
function findSegment(floor) {
const segments = state.route?.segments
if (!Array.isArray(segments)) return null
return segments.find((segment) => segmentFloor(segment) === Number(floor)) || null
}
function drawCurrentRoute() {
if (!state.route) return
const segment = findSegment(fdata.floor)
if (!segment) {
currentSvg()?.querySelector('#Линия')?.remove()
return
}
if (!drawRoute(segment)) setMapMessage('В этой версии карты нет скомпилированных точек маршрута.')
else setMapMessage('')
}
function renderFloor() {
const map = $('#wr')
const svg = fdata.floors[fdata.floor - 1]
if (!map || !svg) return
map.innerHTML = svg map.innerHTML = svg
if (!fdata.inited) { if (!fdata.inited) {
// The source drawings are wide; starting near the centre gives a const sc = (map.scrollWidth - map.clientWidth) / 1.1
// useful view while still allowing horizontal scrolling. map.scrollLeft = sc;
map.scrollLeft = Math.max(0, (map.scrollWidth - map.clientWidth) * .9) console.log(sc)
fdata.inited = true fdata.inited = true
} }
if (state.highlightedRoom && fdata.room_floors[state.highlightedRoom] === fdata.floor) highlight(state.highlightedRoom)
drawCurrentRoute()
} }
function updateSearchHints() {
const input = $('#room-search')
const hints = $('#search-hints')
const title = $('#search-title')
if (!input || !hints) return
const query = input.value.trim()
if (normalize(query) === 'eruda') {
initEruda()
clearHints(hints)
return
}
if (!query) {
if (title) title.textContent = 'Напиши название аудитории ниже'
return clearHints(hints)
}
const results = roomResults(query)
if (title) title.textContent = results.some((result) => compact(result.name) === compact(query))
? 'Аудитория найдена'
: (results.length ? 'Похожие аудитории' : 'Аудитория не найдена')
showHints(hints, results, (result) => {
input.value = result.name
clearHints(hints)
setFloor(result.floor)
highlight(result.name)
haptic('success')
})
}
function updateNavigatorHints(which) {
const input = which === 'start' ? $('#route-start') : $('#route-end')
const hints = which === 'start' ? $('#route-start-hints') : $('#route-end-hints')
if (!input || !hints) return
const query = input.value.trim()
if (!query) return clearHints(hints)
showHints(hints, roomResults(query), (result) => {
input.value = result.name
clearHints(hints)
if (which === 'start') state.routeStart = result
else state.routeEnd = result
setFloor(result.floor, {keepHighlight: true})
highlight(result.name)
haptic('success')
})
}
function initEruda() {
if (window.__zatupsErudaInitialized || window.__zatupsErudaLoading) return
const start = () => {
if (!window.eruda) return
try {
window.eruda.init()
window.__zatupsErudaInitialized = true
} catch (_) {
// Dev-only console; never interrupt map use.
}
}
if (window.eruda) return start()
window.__zatupsErudaLoading = true
const script = document.createElement('script')
script.src = `../common/eruda.min.js?v${MAP_VERSION}`
script.onload = () => { window.__zatupsErudaLoading = false; start() }
script.onerror = () => { window.__zatupsErudaLoading = false }
document.head.append(script)
}
function switchMode(mode) {
if (state.routeView) return
state.mode = mode === 'navigator' ? 'navigator' : 'search'
const searchActive = state.mode === 'search'
$('#search-mode')?.classList.toggle('active', searchActive)
$('#navigator-mode')?.classList.toggle('active', !searchActive)
$('#search-mode')?.setAttribute('aria-selected', String(searchActive))
$('#navigator-mode')?.setAttribute('aria-selected', String(!searchActive))
$('#search-panel')?.classList.toggle('hidden', !searchActive)
$('#navigator-panel')?.classList.toggle('hidden', searchActive || Boolean(state.routeStatus))
if (searchActive && !state.routeStatus) $('#route-card')?.classList.add('hidden')
if (!searchActive && !state.routeStatus) $('#route-card')?.classList.add('hidden')
haptic('light')
}
function randomRequestId() {
try {
if (crypto.randomUUID) return crypto.randomUUID()
} catch (_) {}
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
}
function roomForRequest(input, remembered) {
const exact = exactRoom(input.value)
if (exact) return exact
return remembered && compact(remembered.name) === compact(input.value) ? remembered : null
}
function submitRoute() {
const startInput = $('#route-start')
const endInput = $('#route-end')
const feedback = $('#route-feedback')
if (!startInput || !endInput || !feedback) return
const start = roomForRequest(startInput, state.routeStart)
const end = roomForRequest(endInput, state.routeEnd)
state.routeStart = start
state.routeEnd = end
if (!start || !end) {
feedback.textContent = 'Выбери две существующие аудитории из подсказок.'
haptic('error')
return
}
if (compact(start.name) === compact(end.name)) {
feedback.textContent = 'Начальная и конечная аудитории должны отличаться.'
haptic('error')
return
}
const payload = {
v: 1,
type: 'route.request',
request_id: randomRequestId(),
start: {floor: String(start.floor), room: start.name},
end: {floor: String(end.floor), room: end.name},
}
window.__lastRouteRequest = payload
try {
const webApp = window.Telegram?.WebApp
if (typeof webApp?.sendData !== 'function') throw new Error('Telegram WebApp is unavailable')
webApp.sendData(JSON.stringify(payload))
feedback.textContent = 'Запрос отправлен в бот. Вернись в чат за результатом.'
haptic('success')
} catch (_) {
feedback.textContent = 'Открой карту из кнопки бота, чтобы отправить маршрут.'
haptic('error')
}
}
function decodeRoute(value) {
try {
const normalized = value.replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((value.length + 3) % 4)
const bytes = Uint8Array.from(atob(normalized), (char) => char.charCodeAt(0))
return JSON.parse(new TextDecoder().decode(bytes))
} catch (_) {
return null
}
}
function encodeText(value) {
return String(value || '').trim()
}
function createRouteStep(title, description) {
const element = document.createElement('div')
element.className = 'route-step route-step-message'
const heading = document.createElement('strong')
heading.textContent = title
const text = document.createElement('span')
text.textContent = description
element.append(heading, text)
return element
}
function createRouteIcon(kind, {descending = false} = {}) {
const wrapper = document.createElement('span')
wrapper.className = `route-step-icon route-step-icon-${kind}${descending ? ' descending' : ''}`
wrapper.setAttribute('aria-hidden', 'true')
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
svg.setAttribute('viewBox', '0 0 24 24')
svg.setAttribute('focusable', 'false')
const paths = {
pin: ['M12 22s7-6.2 7-13a7 7 0 1 0-14 0c0 6.8 7 13 7 13Z', 'M12 6.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5Z'],
turn: ['M5 4v8a5 5 0 0 0 5 5h8', 'm14 13 4 4-4 4'],
stairs: ['M3 20h5v-4h4v-4h4V8h5V4'],
}
;(paths[kind] || []).forEach((data) => {
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
path.setAttribute('d', data)
svg.append(path)
})
wrapper.append(svg)
return wrapper
}
function scrollToCurrentRoute() {
const map = $('#wr')
const svg = currentSvg()
const route = svg?.querySelector('#Линия')
if (!map || !svg || !route) return
try {
const bbox = route.getBBox()
const viewBox = svg.viewBox?.baseVal
const svgHeight = viewBox?.height || svg.getBoundingClientRect().height
const scale = svgHeight ? svg.clientHeight / svgHeight : 1
const center = (bbox.x + bbox.width / 2) * scale
const contentWidth = Math.max((viewBox?.width || svg.clientWidth) * scale, svg.clientWidth)
map.scrollTo({
left: Math.max(0, Math.min(contentWidth - map.clientWidth, center - map.clientWidth / 2)),
behavior: 'smooth',
})
} catch (_) {
// Older WebViews can fail to calculate an injected SVG group's bbox.
}
}
function showRouteFloor(floor) {
setFloor(floor, {keepHighlight: true})
requestAnimationFrame(scrollToCurrentRoute)
}
function createRouteInstruction(label, icon, {floor = null, descending = false} = {}) {
const element = document.createElement('div')
element.className = 'route-step'
const row = document.createElement('div')
row.className = 'route-step-row'
const text = document.createElement('p')
text.className = 'route-step-label'
text.textContent = label
row.append(text, createRouteIcon(icon, {descending}))
element.append(row)
if (floor) {
const button = document.createElement('button')
button.type = 'button'
button.className = 'route-floor-button'
button.textContent = 'Показать этаж'
button.addEventListener('click', () => showRouteFloor(floor))
element.append(button)
}
return element
}
function renderRouteInstructions(steps, segments) {
const routeSegments = segments
.map((segment) => ({segment, floor: segmentFloor(segment)}))
.filter(({floor}) => Boolean(floor))
if (!routeSegments.length) {
steps.append(createRouteStep('Маршрут пуст', 'Не удалось найти точки между аудиториями.'))
return
}
const startFloor = routeSegments[0].floor
steps.append(createRouteInstruction(`Вы сейчас на ${startFloor} этаже`, 'pin', {floor: startFloor}))
if (routeSegments.length === 1) {
steps.append(createRouteInstruction('Пройдите до аудитории', 'pin'))
return
}
steps.append(createRouteInstruction('Пройдите до лестницы', 'turn'))
for (let index = 1; index < routeSegments.length; index += 1) {
const previousFloor = routeSegments[index - 1].floor
const floor = routeSegments[index].floor
if (floor !== previousFloor) {
const descending = floor < previousFloor
steps.append(createRouteInstruction(
`${descending ? 'Спуститесь' : 'Поднимитесь'} на ${floor} этаж`,
'stairs',
{floor, descending},
))
}
const last = index === routeSegments.length - 1
steps.append(createRouteInstruction(last ? 'Пройдите до аудитории' : 'Пройдите до лестницы', last ? 'pin' : 'turn'))
}
}
function renderRouteSummary(summary, start, end) {
summary.replaceChildren()
if (!start || !end) return
const startLabel = document.createElement('span')
startLabel.textContent = start
const arrow = document.createElement('span')
arrow.className = 'route-arrow'
arrow.setAttribute('aria-hidden', 'true')
const endLabel = document.createElement('span')
endLabel.textContent = end
summary.append(startLabel, arrow, endLabel)
}
function enterRouteView() {
state.routeView = true
state.routeCollapsed = false
state.mode = 'navigator'
$('#app')?.classList.add('route-view')
$('#route-card')?.classList.remove('hidden', 'route-collapsed')
const hide = $('#route-hide')
if (hide) {
hide.textContent = 'Скрыть'
hide.setAttribute('aria-expanded', 'true')
}
}
function toggleRouteCollapsed() {
const card = $('#route-card')
const button = $('#route-hide')
if (!card || !button || !state.routeView) return
state.routeCollapsed = !state.routeCollapsed
card.classList.toggle('route-collapsed', state.routeCollapsed)
button.textContent = state.routeCollapsed ? 'Показать' : 'Скрыть'
button.setAttribute('aria-expanded', String(!state.routeCollapsed))
haptic('light')
}
function renderRouteCard() {
const card = $('#route-card')
const title = $('#route-title')
const summary = $('#route-summary')
const steps = $('#route-steps')
if (!card || !title || !summary || !steps) return
steps.replaceChildren()
const start = encodeText(state.routeStart?.name || new URLSearchParams(location.search).get('start'))
const end = encodeText(state.routeEnd?.name || new URLSearchParams(location.search).get('end'))
renderRouteSummary(summary, start, end)
const failed = state.routeStatus === 'failed' || !state.route
card.classList.toggle('route-failed', failed)
if (failed) {
title.textContent = 'Маршрут не построен'
steps.append(createRouteStep('Попробуй ещё раз', 'Не удалось найти путь между выбранными аудиториями.'))
} else {
title.textContent = 'Маршрут построен'
const segments = Array.isArray(state.route.segments) ? state.route.segments : []
renderRouteInstructions(steps, segments)
}
enterRouteView()
}
function initRouteFromQuery() {
const params = new URLSearchParams(location.search)
const start = params.get('start') || ''
const end = params.get('end') || ''
const startRoom = exactRoom(start)
const endRoom = exactRoom(end)
if (startRoom) {
state.routeStart = startRoom
$('#route-start').value = startRoom.name
} else if (start) $('#route-start').value = start
if (endRoom) {
state.routeEnd = endRoom
$('#route-end').value = endRoom.name
} else if (end) $('#route-end').value = end
const encoded = params.get('route')
state.route = encoded ? decodeRoute(encoded) : null
state.routeStatus = params.get('status') || (state.route ? 'built' : null)
if (start || end || state.route || state.routeStatus) switchMode('navigator')
if (state.route || state.routeStatus === 'failed') renderRouteCard()
const floor = floorValue(params.get('floor') || params.get('sf') || startRoom?.floor)
if (floor) fdata.floor = floor
}
function hydrateRouteEndpoints() {
const params = new URLSearchParams(location.search)
const start = state.routeStart || exactRoom(params.get('start') || '')
const end = state.routeEnd || exactRoom(params.get('end') || '')
if (start && !state.routeStart) {
state.routeStart = start
$('#route-start').value = start.name
}
if (end && !state.routeEnd) {
state.routeEnd = end
$('#route-end').value = end.name
}
}
function bindEvents() {
$('#floor-current')?.addEventListener('click', toggleFloors)
$('#floor-select')?.querySelectorAll('button[data-floor]').forEach((button) => {
button.addEventListener('click', () => setFloor(button.dataset.floor))
})
$('#search-mode')?.addEventListener('click', () => switchMode('search'))
$('#navigator-mode')?.addEventListener('click', () => switchMode('navigator'))
$('#room-search')?.addEventListener('input', updateSearchHints)
$('#route-start')?.addEventListener('input', () => updateNavigatorHints('start'))
$('#route-end')?.addEventListener('input', () => updateNavigatorHints('end'))
$('#route-submit')?.addEventListener('click', submitRoute)
$('#route-hide')?.addEventListener('click', toggleRouteCollapsed)
// Mobile WebViews resize their visual viewport when the keyboard opens.
// Reapply the captured size, but never measure the shrunken viewport.
document.addEventListener('focusin', (event) => {
if (event.target instanceof HTMLInputElement) applyMapHeightLock()
})
}
async function mapload() {
const results = await Promise.allSettled(Array.from({length: FLOOR_COUNT}, (_, index) => loadFloor(index)))
const failed = results.filter((result) => result.status === 'rejected').length
if (failed === FLOOR_COUNT) {
setMapMessage('Не удалось загрузить карту')
const progress = $('#block > p')
if (progress) progress.textContent = 'Проверь соединение и попробуй снова'
return
}
removeBlock()
hydrateRouteEndpoints()
renderFloor()
if (state.routeStart?.floor) setFloor(state.routeStart.floor, {keepHighlight: true})
if (state.routeStart?.name) highlight(state.routeStart.name)
drawCurrentRoute()
}
async function init() {
if (state.initialized) return
state.initialized = true
bindEvents()
initRouteFromQuery()
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))
lockMapHeight()
await Promise.all([loadNavigatorManifest(), mapload()])
}
// Keep the small global API used by older deep links and by the developer
// console while exposing the new state in one namespace.
window.floorsopen = toggleFloors
window.fswitch = setFloor
window.highlight = highlight
window.mapload = mapload
window.find = (room, floor) => {
const input = $('#room-search')
if (input) input.value = room
setFloor(floor)
highlight(room)
}
window.ZatupsMap = {init, state, fdata, setFloor, highlight, submitRoute}

View file

@ -1,597 +1,250 @@
:root {
color-scheme: light;
--page: #ffffff;
--panel: #444444;
--panel-muted: #5e5e5e;
--panel-light: #707070;
--panel-text: #f6f6f6;
--panel-hint: #d4d4d4;
--white: #ffffff;
--accent: #2481cc;
--accent-pulse: #1767a6;
--line: #2481cc;
--route-end: #b01820;
--route-end-pulse: #7f1118;
}
@property --found-fill {
syntax: "<color>";
inherits: true;
initial-value: #2481cc;
}
* { * {
box-sizing: border-box; /* border: solid 1px #f00; */
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
touch-action: pan-x pan-y;
user-select: none; user-select: none;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
}
html, body {
width: 100%;
height: 100%;
overflow: hidden;
}
html {
font-size: 16px;
} }
body { body {
overflow: hidden;
margin: 0; margin: 0;
color: var(--panel-text); background: var(--tg-theme-bg-color);
background: var(--page); color: var(--tg-theme-text-color);
font-size: 1rem; height: 100vh;
} /* height: 100dvh; */
button, input {
font: inherit;
}
button {
border: 0;
cursor: pointer;
}
input {
user-select: text;
-webkit-user-select: text;
}
.hidden {
display: none !important;
}
#app {
height: 100%;
min-height: 100%;
} }
#map { #map {
position: absolute; width: calc(100vw - 1rem);
top: 0; margin: 0.5rem;
right: 0; background: #fff;
left: 0; /* box-shadow: rgba(255,255,255,.1) 0 0 .5rem .1rem; */
bottom: auto; border-radius: 1rem;
height: calc(100% - 12.2rem); height: calc(100% - 7.5rem);
overflow: hidden; overflow: auto;
background: var(--white); scrollbar-width: none;
border-bottom: 4px solid #f6e7a2; padding: 0;
display: flex;
scroll-behavior: smooth;
} }
#map > .controls {
#map.map-locked { display: none;
height: var(--map-locked-height);
} }
#wr { #wr {
width: 100%; height: 100%;
height: 100%; overflow-y: hidden;
overflow: auto;
scrollbar-width: none; scrollbar-width: none;
overscroll-behavior: contain;
scroll-behavior: smooth;
background: var(--white);
} }
#wr::-webkit-scrollbar {
display: none;
}
#wr > svg { #wr > svg {
display: block;
width: auto; width: auto;
min-width: 100%; /* transform: translateX(27%); */
height: 100%; /* height: calc(100% - 5rem); */
background: var(--white); /* scale: 1.1; */
} }
footer {
#controls { width: calc(100vw);
position: absolute; background: var(--tg-theme-bg-color);
z-index: 5;
top: .65rem;
left: .65rem;
display: flex;
flex-direction: column;
align-items: stretch;
gap: .35rem;
}
.floor-current, .floor-select {
color: var(--panel-text);
background: rgba(68, 68, 68, .94);
border-radius: .75rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, .16);
}
.floor-current {
display: flex;
align-items: center;
gap: .35rem;
padding: .35rem .55rem .35rem .75rem;
font-size: 1.05rem;
white-space: nowrap;
}
.floor-current svg {
width: 1.15rem;
height: 1.15rem;
fill: currentColor;
transition: transform .16s ease;
}
.floor-current[aria-expanded="true"] svg {
transform: rotate(180deg);
}
.floor-select {
display: flex;
flex-direction: column;
gap: .18rem;
padding: .32rem;
}
.floor-select button {
min-width: 4.8rem;
padding: .28rem .45rem;
color: var(--panel-text);
background: transparent;
border-radius: .48rem;
font-size: .98rem;
}
.floor-select button.active,
.floor-select button:active {
background: var(--panel-light);
}
.highlight > *,
.highlight > * > * {
fill: var(--found-fill) !important;
stroke: #171717 !important;
}
.highlight {
--found-fill: var(--accent);
animation: found-fill 2s ease-in-out infinite;
}
#Линия {
--found-fill: var(--line);
animation: found-fill 2s ease-in-out infinite;
transform-box: fill-box;
transform-origin: center;
}
#Линия line {
stroke: var(--found-fill) !important;
}
#Линия rect {
fill: var(--found-fill) !important;
}
#Линия .route-end-marker {
animation: route-end-fill 2s ease-in-out infinite;
}
@keyframes found-fill {
0%, 100% { --found-fill: var(--accent); }
50% { --found-fill: var(--accent-pulse); }
}
@keyframes route-end-fill {
0%, 100% { --found-fill: var(--route-end); }
50% { --found-fill: var(--route-end-pulse); }
}
#map-message {
position: absolute;
z-index: 4;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
max-width: calc(100% - 2rem);
padding: .75rem 1rem;
color: #fff;
background: rgba(68, 68, 68, .94);
border-radius: .85rem;
text-align: center;
}
#bottom-panel {
position: fixed; position: fixed;
z-index: 10;
right: 0;
bottom: 0; bottom: 0;
left: 0;
display: flex; display: flex;
/* border: solid 2px var(--tg-theme-bg-color); */
/* border-top: solid 2px var(--tg-theme-hint-color); */
box-shadow: var(--tg-theme-secondary-bg-color) 0 0rem .9rem .2rem;
border-radius: 1.5rem 1.5rem 0 0;
padding: .5rem 0 2.5rem;
flex-direction: column; flex-direction: column;
justify-content: flex-end; /* gap: .5rem; */
gap: .38rem;
min-height: 12.2rem;
padding: .42rem .62rem calc(.65rem + env(safe-area-inset-bottom));
background: transparent;
} }
.dragger {
/* A route is a focused, read-only map view. The map itself remains usable, height: .3rem;
* while floor controls, tabs, and both input panels stay out of the way. */ background: var(--tg-theme-hint-color);
#app.route-view #controls, opacity: .2;
#app.route-view #mode-switch, margin: 0 auto;
#app.route-view #search-panel, border-radius: 100vw;
#app.route-view #navigator-panel { width: 3rem;
display: none !important;
} }
footer > .i {
#app.route-view #bottom-panel {
min-height: 0;
gap: 0;
}
#mode-switch {
display: flex;
align-self: flex-start;
overflow: hidden;
border-radius: 1.2rem;
background: var(--panel);
}
#mode-switch button {
min-width: 5.4rem;
padding: .28rem .8rem;
color: #eee;
background: var(--panel);
font-size: 1rem;
}
#mode-switch button.active {
color: #fff;
background: #8d8d8d;
}
#mode-switch,
.panel-card,
.route-card {
box-shadow: 0 .7rem 2.2rem rgba(0, 0, 0, .16), 0 .12rem .45rem rgba(0, 0, 0, .12);
}
.panel-card, .route-card {
position: relative;
width: 100%;
padding: .62rem .68rem .68rem;
color: var(--panel-text);
background: var(--panel);
border-radius: 1rem;
}
.panel-title {
margin: 0 0 .48rem;
font-size: 1.02rem;
line-height: 1.2;
}
.field-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: .45rem; background: var(--tg-theme-secondary-bg-color);
min-height: 2.35rem; border-radius: 5rem;
padding: .1rem .58rem; padding: 0 .5rem;
color: var(--panel-hint); margin: 0 1rem;
background: var(--panel-muted); gap: .5rem;
border-radius: .72rem; margin-top: .5rem;
} /* border: solid 1px var(--tg-theme-hint-color); */
.field-row svg { padding-right: 0;
flex: 0 0 1.25rem;
width: 1.25rem;
height: 1.25rem;
fill: currentColor;
} }
footer > .i > svg {
.field-row input { fill: var(--tg-theme-text-color) !important;
min-width: 0; }
width: 100%; footer > .i > input {
padding: .36rem 0;
color: #fff;
background: transparent; background: transparent;
border: 0;
outline: none; outline: none;
font-size: 1rem; border: 0;
padding: .5rem 0;
width: 100%;
font-size: 1.2rem;
color: var(--tg-theme-text-color);
font-family: Arial, Helvetica, sans-serif;
}
.i > input::placeholder {
color: var(--tg-theme-hint-color);
}
.i > button {
padding: .62rem .8rem;
/* font-weight: bold; */
/* height: 100%; */
border-radius: 100vw;
border: 0;
color: var(--tg-theme-bg-color);
background: var(--tg-theme-text-color);
} }
.field-row input::placeholder {
color: #c6c6c6;
opacity: .8;
}
.hints { .hints {
padding: 0 1rem;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: nowrap;
gap: .35rem; gap: .5rem;
max-height: 4.5rem; overflow-x: auto;
padding-top: .35rem; scrollbar-width:none;
overflow: auto; }
scrollbar-width: none; .hints.a {
padding: .6rem 1rem .3rem;
}
.hints > span {
background: var(--tg-theme-secondary-bg-color);
padding: .3rem .7rem;
border-radius: 100vw;
border: solid 1px var(--tg-theme-hint-color);
/* color: var(--tg-theme-hint-color); */
text-wrap-mode: nowrap;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
} }
.hints:empty {
display: none;
}
.hints::-webkit-scrollbar { #controls {
display: none; margin: .5rem;
}
.hints button, .hint-empty {
padding: .28rem .68rem;
color: #333;
background: #f8f8f8;
border-radius: 1rem;
font-size: .91rem;
white-space: nowrap;
}
.hint-empty {
color: var(--panel-hint);
background: var(--panel-light);
}
.navigator-fields {
display: grid;
gap: .28rem;
}
.navigator-fields .hints {
padding: 0 0 .08rem 1.7rem;
}
.primary-button {
min-height: 2.3rem;
padding: .34rem 1.1rem;
border-radius: .78rem;
font-size: 1rem;
}
.primary-button {
display: block;
margin: .5rem 0 0 auto;
color: #333;
background: #fff;
}
.primary-button:disabled {
color: #aaa;
background: #777;
cursor: not-allowed;
}
.feedback {
min-height: 0;
margin: .3rem 0 0;
color: #ffd1d1;
font-size: .85rem;
}
.route-card {
max-height: none;
overflow: visible;
}
.route-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: .7rem;
}
.route-title {
display: block;
margin: .42rem 0 .58rem;
font-size: 1.05rem;
}
.route-card:not(.route-failed) .route-title {
display: none;
}
.route-card-head button {
flex: 0 0 auto;
padding: .3rem .8rem;
color: #fff;
background: var(--panel-light);
border-radius: .7rem;
}
#route-summary {
min-width: 0;
margin: 0;
color: var(--panel-text);
}
.route-summary {
display: flex;
align-items: center;
gap: .62rem;
line-height: 1.2;
}
.route-summary > span:not(.route-arrow) {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Keep the route direction independent from the active font. */
.route-arrow {
flex: 0 0 .56rem;
width: .56rem;
height: .56rem;
margin-right: .3rem;
color: var(--panel-hint);
border-top: .12rem solid currentColor;
border-right: .12rem solid currentColor;
transform: rotate(45deg);
}
.route-steps {
display: grid;
gap: .72rem;
margin-top: .72rem;
}
.route-step {
padding: .12rem 0;
}
.route-step-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: .9rem;
min-height: 1.8rem;
}
.route-step-label {
margin: 0;
color: var(--panel-text);
font-size: 1.08rem;
line-height: 1.25;
}
.route-step-icon {
flex: 0 0 1.55rem;
width: 1.55rem;
height: 1.55rem;
color: var(--panel-text);
}
.route-step-icon svg {
display: block;
width: 100%;
height: 100%;
fill: none;
stroke: currentColor;
stroke-width: 2.25;
stroke-linecap: round;
stroke-linejoin: round;
}
.route-step-icon-stairs.descending {
transform: rotate(180deg);
}
.route-floor-button {
margin-top: .42rem;
padding: .38rem .72rem;
color: var(--panel-text);
background: var(--panel-light);
border-radius: .72rem;
font-size: 1rem;
}
.route-step-message {
padding: .55rem .62rem;
background: var(--panel-muted);
border-radius: .72rem;
}
.route-step-message strong {
display: block;
margin-bottom: .12rem;
}
.route-step-message > span {
color: var(--panel-hint);
font-size: .9rem;
}
#app.route-view #route-card.route-collapsed {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: .65rem;
max-height: none;
overflow: visible;
}
#app.route-view #route-card.route-collapsed .route-card-head {
display: contents;
}
#app.route-view #route-card.route-collapsed #route-summary {
grid-column: 1;
grid-row: 1;
}
#app.route-view #route-card.route-collapsed #route-hide {
grid-column: 2;
grid-row: 1;
}
#app.route-view #route-card.route-collapsed #route-title,
#app.route-view #route-card.route-collapsed #route-steps {
display: none;
}
#block {
position: fixed; position: fixed;
z-index: 100;
inset: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: .3rem;
}
#controls > .current {
background: var(--tg-theme-bg-color);
padding: .2rem .4rem .2rem;
padding-left: .8rem;
display: flex;
align-items: center;
gap: .4rem;
border-radius: 10rem;
font-size: 1.2rem;
cursor: pointer;
width: fit-content;
}
#controls > .current > svg {
transform: translateY(.12rem);
}
#controls > .select {
display: flex;
flex-direction: column;
padding: .5rem;
gap: .4rem;
background: var(--tg-theme-bg-color);
border-radius: 1.4rem;
}
#controls > .select > span {
background: var(--tg-theme-secondary-bg-color);
padding: .2rem .05rem;
text-align: center;
border-radius: .5rem;
font-size: 1.3rem;
}
#controls > .select > span:nth-child(1) {
border-radius: 1rem 1rem .5rem .5rem;
}
#controls > .select > span:nth-child(6) {
border-radius: .5rem .5rem 1rem 1rem ;
}
#controls > .select > span.active {
/* color: var(--tg-theme-bg-color); */
background: var(--tg-theme-hint-color);
}
#mp {
height: 10px;
width: 10px;
opacity: 0;
}
#block {
position: fixed;
top: 0;
left: 0;
z-index: 100;
height: 100vh;
/* height: 100dvh; */
width: 100vw;
display: flex;
flex-direction: column;
background: var(--tg-theme-bg-color);
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: #333;
background: var(--page);
} }
#block > h3 {
#block h3 { font-weight: normal;
margin: 1rem 0 .1rem; margin-bottom: 0;
font-weight: 500; }#block > p {
} color: var(--tg-theme-hint-color);
#block p {
margin: 0;
color: #555;
} }
#loader { #loader {
width: 2rem;
height: 2rem; height: 2rem;
border: 3px solid #444; width: 2rem;
border-top-color: transparent; border: solid 3px var(--tg-theme-text-color);
border-radius: 50%; border-top: solid 3px transparent;
animation: spin .7s linear infinite; border-radius: 100vw;
animation: anim .7s linear infinite;
} }
@keyframes spin { @keyframes anim {
to { transform: rotate(360deg); } to {
} transform: rotate(360deg);
@media (min-width: 600px) {
#bottom-panel {
right: 50%;
width: min(34rem, 100%);
transform: translateX(50%);
} }
} }
#controls > .select.hidden {
display: none;
}
#controls > .select > span {
cursor: pointer;
}
.highlight, .highlight > *, .highlight > * > * {
fill: var(--tg-theme-destructive-text-color) !important;
/* animation: highlight 2s linear infinite; */
}
@keyframes highlight {
0% {
fill: var(--tg-theme-destructive-text-color) !important;
}
50% {
fill: var(--tg-theme-bg-color) !important;
}
100% {
fill: var(--tg-theme-destructive-text-color) !important;
}
}

View file

@ -1,21 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script src="../common/telegram.js"></script>
</head>
<body style="filter: invert(1)">
<h1>/web_callback_test</h1>
<textarea id="data" style="width: 100%; height: 200px"></textarea>
<button style="font-size: 20px;" onclick="sendData()">Send Data</button>
<script>
const tg = window.Telegram.WebApp
const sendData = () => {
tg.sendData(document.getElementById('data').value)
}
</script>
</body>
</html>