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
web/analytics/data.json
DEBUG
schedule_client.py
config.py
.env
.venv
.env

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.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
from .debug import *
from .main.register_user import *
# @bot.message_handler(content_types = ['text'], func = lambda m: not validate(m))
# def cantValidateHandler(message): ...
@ -16,26 +21,22 @@ from .main.register_user import *
# #? Another code
from .analytics import *
from .navigator import *
from .iternal import *
from .admin_schedule import *
from .inline import *
from .main.home import *
from .schedule.search import *
from .schedule.other_days import *
from .settings.notifications import *
from .schedule.saved_groups import *
def run():
import logging
logger.success('Bot', 'Started')
# try: bot.infinity_polling(logger_level=1)
try:
bot.infinity_polling(logger_level=logging.ERROR)
...
bot.infinity_polling(logger_level=1)
except KeyboardInterrupt:
logger.error('Bot', 'Stopping...')
return
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.bot import Inlines, bot
from models.schedule import dayStrings, generateDaySchedule, generateWeekSchedule
from models.user import validate
@bot.inline_handler(lambda query: True)
def default_query(query: InlineQuery):
user = validate(query)
inline = Inlines(query.id)
if not user.group:
if not user:
logger.warn('Bot', 'Inlinue from unknown user invoked')
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(
"Расписание недоступно ❌",
"Сервер расписания сейчас недоступен",
"Не удалось получить расписание. Попробуй ещё раз позже.",
'Расписание на сегодня 🔥',
f'{description}, {weektype.lower()} неделя',
output
)
else:
week_type = "четная" if schedule.weekNumber % 2 == 0 else "нечетная"
description = dayStrings[schedule.weekDay]
if 'tomorrow':
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(
"Расписание на сегодня 🔥",
f"{description}, {week_type} неделя",
generateDaySchedule(schedule, isToday=True),
'Расписание на завтра 🍀',
f'{description}, {weektype.lower()} неделя',
output
)
tomorrow = user.schedule()
if tomorrow:
if tomorrow.weekDay == 6:
tomorrow.weekDay = 0
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),
)
if 'week':
s = user.schedule()
description = f'Сейчас {"четная" if s.weekNumber%2==0 else "нечетная"} неделя'
output = f'<i>Расписание на </i><b>эту</b><i> {"четную" if s.weekNumber%2==0 else "нечетную"} неделю</i>\n'
week = user.schedule()
if week:
title = "эту"
if week.weekDay == 6:
week.weekNumber += 1
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,
)
if s.weekDay == 6:
s.weekDay = 0
s.weekNumber += 1
description = f'Следующая {"четная" if s.weekNumber%2==0 else "нечетная"} неделя'
output = f'<i>Расписание на </i><b>следующую</b><i> {"четную" if s.weekNumber%2==0 else "нечетную"} неделю</i>\n'
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:
return inline.send()
except Exception as error:
logger.error("Bot", f"Inline.send() -> {error}")
except Exception as e:
logger.error('Bot', f'Inline.send() -> {e}')

View file

@ -47,12 +47,7 @@ def sendConfirm(message: Message):
'Н': {'callback_data': 'ignore'}
}, row_width=1))
@bot.message_handler(
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'),
)
@bot.message_handler(content_types = ['document', 'file'], func = lambda m: m.from_user.id in config.ADMINS)
def massSendHandle(message: Message):
global REMEMBER_MESSAGE_DATA, REMEMBER_JSON_DATA, REMEMBER_PHOTO_ID
REMEMBER_PHOTO_ID = False

View file

@ -2,9 +2,9 @@ import json
from telebot.types import Message, CallbackQuery, InputFile, ReplyKeyboardRemove
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.schedule import dayStrings, generateDaySchedule, generateWeekSchedule
from models.schedule import generateDaySchedule, dayStrings
from utils.calendar import ICS
@ -15,13 +15,9 @@ from telebot.handler_backends import ContinueHandling
def sendBrokenMessage(message: Message):
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):
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')
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')
def defaultState(message: Message):
u = validate(message)
s = u.schedule()
# print(u.id, message.text)
if message.text[0] == '/': return ContinueHandling() # type: ignore
if message.text == Strings.search:
from bot.schedule.search import open_search
return open_search(message, u)
if message.text == Strings.current_schedule_name_F.format(u.group):
if message.text == f'{u.group} 🔄':
u.setState('savedGroups')
return send(
u.id,
f'<b>Сохраненные расписания</b>\n\n<i>Активное расписание: {u.group}</i>',
Markup.saved_schedules(u),
)
schedule_actions = {
Strings.today,
Strings.tomorrow,
Strings.schedule_another_days,
'На неделю 📅',
}
s = u.schedule() if message.text in schedule_actions else None
if message.text in schedule_actions and not s:
return send(
u.id,
'Не удалось получить расписание. Попробуй ещё раз позже или перезапусти бота через /start.',
reply_markup=Markup.default(u),
)
return send(u.id, f'<b>Сохраненные группы</b>\nЗдесь список групп, которые ранее были использованы, чтобы быстро между ними переключаться. \n\n<i>Активная группа: {u.group}</i>', Markup.saved_groups(u))
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))
case Strings.tomorrow:
case 'Завтра 🍀':
if s.weekDay in [4, 5]:
return send(u.id, f'<b>{dayStrings[s.weekDay+1]}</b>\nЗавтра пар нет 🥰')
elif s.weekDay == 6:
@ -84,22 +64,18 @@ def defaultState(message: Message):
s.weekDay = 0
s.weekNumber += 1
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))
case Strings.schedule_another_days:
case 'Другие дни 📁':
u.setState('otherDays')
u.view_target = u.active_target
u.save()
return send(
u.id,
f'<b>Сегодня {dayStrings[s.weekDay].lower()}, {"четная" if s.weekNumber%2==0 else "нечетная"} неделя</b>\n\nВыбери нужный день/пункт',
reply_markup=Markup.schedule(u.is_saved(u.active_target)),
)
return send(u.id, f'<b>Сегодня {dayStrings[s.weekDay].lower()}, {"четная" if s.weekNumber%2==0 else "нечентная"} неделя</b>\n\nВыбери нужный день/пункт', reply_markup = Markup.other())
case Strings.notifications:
case 'Уведомления ⏰':
u.setState('notifications')
return send(u.id, f'''\
<b>Перед парой </b> <i>{"включено" if 'lesson' in u.notifications else "выключено"}</i>
@ -114,7 +90,7 @@ def defaultState(message: Message):
<i>Выбери нужную кнопку, чтобы включить/выключить уведомления</i>\
''', reply_markup = Markup.notifications())
case Strings.support:
case 'Поддержать 💞':
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))
@ -194,3 +170,4 @@ def sendHelp(message: Message):
[bot.forward_message(adminID, message.from_user.id, message.id) for adminID in ADMINS] # type: ignore
send(message, '<b>Сообщение переслано разработчику 🔧</b>\nСпасибо за обращение', Markup.default(u))

View file

@ -1,148 +1,97 @@
import json
from telebot.types import Message, ReplyKeyboardRemove
from telebot.types import Message
from telebot.util import quick_markup
import config
from models import logger
from models.bot import Markup_v1 as Markup, bot, send
from models.schedule import (
ScheduleAPIError,
find_main_groups,
group_exists,
group_target,
search_targets,
)
from models.user import getAllUsers, validate
from utils import convert
from models.user import User, validate, getAllUsers
from models.bot import (Markup_v1 as Markup, bot, send)
from models.schedule import generateDaySchedule
@bot.message_handler(commands=["start"])
@bot.message_handler(commands = ['start'])
def start(message: Message):
user = validate(message)
message.text = str(message.text)
payload = message.text.split()[-1]
if payload.startswith("_s"):
query = payload
search_target_kind = ({
't': 'teacher',
'r': 'room',
'g': 'group'
}).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
u = validate(message)
if u and 'migrate' in message.text: # type: ignore
u.setState('default')
logger.log('Bot', f'{message.from_user.id} migrated') # type: ignore
send(message, 'Добро пожаловать снова 💞', reply_markup = Markup.default(u))
send(message, f'''<b>Что нового</b>
<blockquote>1. Теперь доступны все факультеты, курсы и группы
2. Затупсика можно упомянуть в любом чате, даже в личке и группе, и он отправит актуальное расписание!
<i>Для этого напиши <code>@zatups_bot</code> в любом чате</i>
3. Теперь расписания отображаются корректно всегда, даже с подгруппами</blockquote>
return open_schedule_viewer(message, results[0], from_search=False)
<i>Чтобы воспользоваться функцией отправки расписания в любом чате, напиши <code>@zatups_bot</code> в любом чате, выбери бота из списка, а после нажми на нужный день - сегодня, завтра, или на всю неделю.\nКстати - в следующий раз достаточно просто написать @ и телеграм сам выдаст затупсика в списке!</i>''')
return
if user.group and "migrate" in message.text:
user.setState("default")
logger.log("Bot", f"{user.id} migrated")
return send(message, "Добро пожаловать снова 💞", reply_markup=Markup.default(user))
logger.log('Bot', f'[{u.id}] /start detected, Total users: {len(list(getAllUsers()))+1}')
json.dump(
{
'state': 'selectTopic',
was_registered = bool(user.group)
logger.log("Bot", f"[{user.id}] /start detected, Total users: {len(list(getAllUsers()))}")
json.dump(
{
"state": "selectGroup",
"group": None,
"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"},
'topic': '',
'course': '',
'group': '',
'saved_groups': u.saved_groups if u else [],
'notifyEvening': False,
'notifyBeforeLesson': False,
},
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)
user.save_schedule(group_target(group_name))
user.valid = True
user.setState("default")
send(
message,
"""
@bot.message_handler(content_types = ['text'], func = lambda m: ' нету ⚠' in m.text)
def notFoundHandler(message: Message):
bot.reply_to(message, '''
<b>Не расстраивайся!</b>
<i>Напиши разработчику, чтобы он добавил твой факультет/курс/группу</i>
<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>
Здесь можно посмотреть расписание и настроить уведомления о парах.
Здесь можно посмотреть расписание в более приятном виде, а также настроить уведомления о парах!
<i>Изменить группу можно через /start.</i>
""",
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),
)
<i>Изменить факультет/курс/группу можно просто перезапустив бота</i>
''', reply_markup = Markup.default(u))

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 models.bot import Markup, Strings, bot, send
from models.bot import bot, Markup, send
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'
@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)),
)
return send(u.id, result, reply_markup = Markup.other())

View file

@ -1,50 +1,33 @@
from telebot.types import Message
from models.bot import Markup, Strings, bot, send
from telebot.types import Message, CallbackQuery
from telebot.util import quick_markup
from models.bot import bot, Markup, send
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(
content_types=["text"], func=lambda message: validate(message).state == "savedGroups"
)
def savedGroups(message: Message):
user = validate(message)
if message.text == Strings.add_schedule:
from .search import open_search
return open_search(message, user)
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),
)
@bot.callback_query_handler(func=lambda call: '$delsaved' == call.data)
def confirmDeleteAll(call: CallbackQuery):
u = validate(call)
u.setState('default')
try:
u.saved_groups = [u.group]
u.save()
send(u.id, 'Сохраненные группы были удалены 🗑', Markup.default(u))
except:
send(u.id, 'Не удалось удалить группы', Markup.default(u))

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
TOKEN: str = '...'
# Full url to bot
TELEGRAM_URL: str = 'https://t.me/your_cool_bot'
# Telegram proxy url (None for no proxy)
TELEGRAM_PROXY: str | None = None
@ -11,10 +8,7 @@ TELEGRAM_PROXY: str | None = None
WEB_BASE_URL: str = 'https://zatups.example.com'
# Base URL for the schedule (should be runned separately)
SCHEDULE_BASE_URL: str = 'http://10.9.8.3:18822'
# The navigator graph is embedded in web/map/data/navigator.json and is shared
# by the bot and the WebView.
SCHEDULE_BASE_URL: str = 'http://schedule_backend:8000/'
# 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()
if t == []: continue
w = f'<b>Скоро начнется пара</b>\n'
for lesson in t:
if [lessonTime.hour, lessonTime.minute] == lesson.start:
w += lesson.text(isToday=True)
for lessonList in t:
if len(lessonList) == 1:
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)
addEvent(Lesson)

10
main.py
View file

@ -4,13 +4,10 @@ if __name__ == '__main__':
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
import models.logger
# Reuse the interpreter that launched this process. Calling ``uv`` from
# 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}"'
CMD = 'python3.13.exe' if sys.platform == 'win32' else 'python3'
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),
# 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=event.run),
]
@ -26,3 +23,4 @@ if __name__ == '__main__':
except Exception as e:
models.logger.error('Main', f'{e}')

View file

@ -1,51 +1,111 @@
import datetime
import html
import json
from typing import Any
from typing import Any, Callable, Literal
from .schedule import getFacCourseByGroup
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:
def __init__(self):
self.issued_at = datetime.datetime.now()
self.users = 0
self.groups: dict[str, int] = {}
self.issued_at: datetime.datetime = datetime.datetime.now()
self.custom: dict[str, Any] = {
"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:
self.users += 1
if not user.active_target or user.active_target["kind"] != "group":
def add(self, u: User):
if u.state == "broken":
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 {
"version": 3,
"version": 2,
"issued_at": self.issued_at.isoformat(),
"users": self.users,
"groups": dict(sorted(self.groups.items())),
"total": self.total,
"data": self.data,
"custom": self.custom,
}
def append(self) -> None:
def append(self):
try:
with open("./web/analytics/data-v2.json", "r", encoding="utf-8") as file:
history: list[dict[str, Any]] = json.load(file)
except (FileNotFoundError, json.JSONDecodeError, OSError):
history = []
history.append(self.toDict())
with open("./web/analytics/data-v2.json", "w", encoding="utf-8") as file:
json.dump(history[-30:], file, ensure_ascii=False)
w: list[dict[str, Any]] = json.load(
open("./web/analytics/data-v2.json", "r", encoding="utf-8")
)
except:
w = []
w.append(self.toDict())
w = w[-30:]
json.dump(
w,
open("./web/analytics/data-v2.json", "w", encoding="utf-8"),
ensure_ascii=False,
# indent=0,
)
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 (
f'<b>Аналитика за {self.issued_at.strftime("%d.%m")} 📊</b>\n'
f"Пользователи: <code>{self.users}</code>\n\n"
f"<b>Группы</b>\n{groups}"
f"""
<b>Аналитика за {self.issued_at.strftime("%m.%d")} 📊</b>"""
+ "\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 telebot
import datetime
from zoneinfo import ZoneInfo
from telebot.types import ReplyKeyboardMarkup, InlineQueryResultArticle, InputTextMessageContent, Message, ReplyKeyboardRemove, WebAppInfo, KeyboardButton
import config
from telebot import apihelper
import config
import utils.schedule_client as sc
apihelper.proxy = {
'http': config.TELEGRAM_PROXY,
'https': config.TELEGRAM_PROXY
}
if config.TELEGRAM_PROXY:
apihelper.proxy = {
'http': config.TELEGRAM_PROXY,
'https': config.TELEGRAM_PROXY
}
bot = telebot.TeleBot(config.TOKEN, parse_mode='html')
@ -61,19 +62,10 @@ class Strings:
# home
map = 'Карта 🗺️'
schedule = 'Расписание 📅'
schedule_emoji = '📅'
schedule_another_days = 'Другие дни 📁'
search = 'Поиск 🔎'
settings = '⚙️'
support = 'Поддержка 💞'
notifications = 'Уведомления ⏰'
settings = 'Настройки ⚙️'
support = 'Поддержать 💞'
current_schedule_name_F = '{} 🔄'
groups = 'Группы'
rooms = 'Аудитории'
teachers = 'Преподаватели'
add_schedule = 'Добавить расписание 🔆'
delete_active_schedule_F = 'Удалить "{}" 🗑'
delete_other_schedules_F = 'Удалить все кроме "{}" 🗑'
# register
register_not_found_T = 'Моего {} нету ⚠'
@ -101,7 +93,7 @@ class Markup_v2:
markup.add(
KeyboardButton(
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,
row_width=2
@ -112,16 +104,12 @@ class Markup_v2:
@staticmethod
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.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.weekdays, row_width=5)
markup.add(
f'{"" if odd_even else ""}{Strings.odd}',
f'{"" if odd_even else ""}{Strings.even}',
row_width=2,
)
markup.add(f'{"" if odd_even else ""} {Strings.odd}', f'{"" if odd_even else ""} {Strings.even}', row_width=2)
markup.add(Strings.delete if is_added else Strings.save, row_width=1)
return markup
@ -137,59 +125,31 @@ class Markup_v1:
markup = ReplyKeyboardMarkup(resize_keyboard=True)
markup.add(
KeyboardButton(
text=Strings.map,
web_app=WebAppInfo(f'{config.WEB_BASE_URL.rstrip("/")}/map/?v=15&mode=search')
),
Strings.search,
row_width=2
text='Карта 🗺️',
web_app=WebAppInfo(f'{config.WEB_BASE_URL}/map/?v12')
), row_width=1
)
markup.add(Strings.today, Strings.tomorrow, row_width=2)
markup.add(Strings.schedule_another_days, Strings.current_schedule_name_F.format(user.group), row_width=2)
markup.add(Strings.notifications, Strings.support, row_width=2)
markup.add('Сегодня 🔥', 'Завтра 🍀', row_width=2)
markup.add('На неделю 📅', 'Другие дни 📁', row_width=2)
# markup.add('Уведомления ⏰', 'Помощь 🆘', row_width=2)
markup.add('Уведомления ⏰', 'Поддержать 💞', f'{user.group} 🔄', row_width=3)
return markup
@staticmethod
def search_types() -> ReplyKeyboardMarkup:
def other() -> ReplyKeyboardMarkup:
markup = ReplyKeyboardMarkup(resize_keyboard=True)
markup.add(Strings.back_to_home, row_width=1)
markup.add(Strings.groups, row_width=1)
markup.add(Strings.rooms, row_width=1)
markup.add(Strings.teachers, row_width=1)
return markup
markup.add('На главную 🏠', row_width=1)
markup.add('ПН', 'ВТ', 'СР', 'ЧТ', 'ПТ', row_width=5)
markup.add('Нечетная 📅', 'Четная 📅', row_width=2)
# markup.add('ЧТ', 'ПТ', row_width=2)
@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
@staticmethod
def notifications() -> ReplyKeyboardMarkup:
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)
@ -197,29 +157,28 @@ class Markup_v1:
return markup
@staticmethod
def saved_schedules(u: user.User) -> ReplyKeyboardMarkup:
def saved_groups(u: user.User) -> ReplyKeyboardMarkup:
markup = ReplyKeyboardMarkup(resize_keyboard=True)
markup.add(Strings.back_to_home, row_width=1)
markup.add(*(target['name'] for target in u.saved_schedules), row_width=4)
markup.add(Strings.add_schedule, row_width=1)
if u.active_target:
markup.add(Strings.delete_active_schedule_F.format(u.group), row_width=1)
markup.add(Strings.delete_other_schedules_F.format(u.group), row_width=1)
markup.add('На главную 🏠', row_width=1)
markup.add(*u.saved_groups, row_width=4)
markup.add('Добавить группу 🔆', 'Удалить группы 🗑', row_width=2)
return markup
saved_groups = saved_schedules
@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)
for group in groups:
for group in groupList:
markup.add(group, row_width=1)
markup.add('Моей группы нету ⚠', row_width=1)
markup.add('Моей группы нет ⚠', row_width=1)
return markup
Markup = Markup_v1
def Receive(state: str, text: str | None = None, strict: bool = True):
return bot.message_handler(
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):
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 re
import threading
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]
import datetime
import config as _config
dayStrings = ['Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота', 'Воскресенье']
class Lesson:
def __init__(
self,
data: dict[str, Any],
target_kind: TargetKind,
group_names: list[str],
) -> None:
self.start = _minutes_to_time(data.get("time_start"))
self.end = _minutes_to_time(data.get("time_end"))
self.name = str(data.get("name") or "Без названия")
self.type = str(data.get("type") or "пара")
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.target_kind = target_kind
self.group_names = group_names
self.info = self.teacher_ids + [f"ауд. {room}" for room in self.room_ids]
self.is_odd_week = bool(data.get("is_odd_week"))
self.is_even_week = bool(data.get("is_even_week"))
#FIXME: фулл переделать потому-что формат поменялся сильно
def __init__(self, start_end: str, data: dict) -> None:
self.start: list[int] = [
int(start_end.split(' - ')[0].split(':')[0]),
int(start_end.split(' - ')[0].split(':')[1])
]
self.end: list[int] = [
int(start_end.split(' - ')[1].split(':')[0]),
int(start_end.split(' - ')[1].split(':')[1])
]
self.strTime: str = start_end
self.raw: str = data['raw']
self.ok: bool = data['ok']
if not self.ok: return
self.name: str = data['name']
self.type: str = data['type']
self.info: list[str] = data['info']
def text(self, isToday: bool = False) -> str:
now = datetime.datetime.now(MOSCOW)
lesson_start = now.replace(hour=self.start[0], minute=self.start[1], second=0, microsecond=0)
lesson_end = now.replace(hour=self.end[0], minute=self.end[1], second=0, microsecond=0)
lesson_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: Не отображаются подгруппы
date = datetime.datetime.now()
lessonStart = datetime.datetime(date.year, date.month, date.day, self.start[0], self.start[1], 10)
# 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:
def __init__(
self,
lessons: list[dict[str, Any]],
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)]
def __init__(self, responce: dict) -> None:
self.weekNumber: int = responce['date']['weekNumber']
self.weekDay: int = responce['date']['weekDay']
groups_by_id = {
str(group["id"]): str(group["name"])
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)
self.raw: dict = responce['schedule']
self.days: list[list[list[Lesson]]] = []
for raw_lesson in lessons:
weekday = raw_lesson.get("weekday")
if not isinstance(weekday, int) or weekday not in range(1, 6):
continue
matched_ids = target_ids.intersection(map(str, raw_lesson.get(field, [])))
if not matched_ids:
continue
if target["kind"] == "group":
group_names = sorted(child_names[item] for item in matched_ids if item in child_names)
else:
group_names = sorted(
{
groups_by_id.get(str(group_id), str(group_id))
for group_id in raw_lesson.get("group_ids", [])
}
for dayInfo in self.raw.values():
currentDay = []
for start_end, lessons in dayInfo.items():
if lessons == [None] or lessons == [None, None]:
continue
lessonList: list[Lesson | None] = []
for lesson in lessons:
if lesson: lessonList.append(Lesson(start_end, lesson))
else: lessonList.append(None)
currentDay.append(lessonList)
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:
day.sort(key=lambda lesson: (lesson.start, lesson.name.casefold()))
def today(self) -> list[Lesson]:
if self.weekDay not in range(5):
return []
even_week = self.weekNumber % 2 == 0
return [
lesson
for lesson in self.days[self.weekDay]
if (lesson.is_even_week if even_week else lesson.is_odd_week)
]
def get_schedule(target: ScheduleTarget) -> Schedule:
groups = _load_result("groups")
lessons = _load_result("schedule/default")
return Schedule(lessons, target, groups)
def get_group_schedule(group_name: str) -> Schedule:
target = next(
(
{"kind": "group", "id": str(group["id"]), "name": str(group["name"])}
for group in get_main_groups()
if _compact(str(group["name"])) == _compact(group_name)
),
None,
)
if target is None:
raise ValueError(f"Группа {group_name!r} не найдена")
return get_schedule(target)
def generateDaySchedule(
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 "Пар на этой неделе нет 🥰"
def generateDaySchedule(schedule: Schedule, showOddWeekText: bool = True, isToday: bool = False) -> str:
isWeekOdd = schedule.weekNumber%2 == 0
weekDay = schedule.weekDay
schedule_today = schedule.today()
lastLessonEnds = [0,0]
scheduleResult = f'<b>{dayStrings[weekDay]}, {"четная" if isWeekOdd else "нечетная"} неделя</b>\n' if showOddWeekText else f'<b>{dayStrings[weekDay]}</b>'
for lessonList in schedule_today:
if len(lessonList) == 1:
scheduleResult += lessonList[0].text(isToday) + '\n'
lastLessonEnds = lessonList[0].end
else:
lesson = lessonList[1 if isWeekOdd else 0]
if lesson:
scheduleResult += lesson.text(isToday) + '\n'
lastLessonEnds = lesson.end
scheduleResult += f'Пары закончатся в <i>{lastLessonEnds[0]}:{lastLessonEnds[1] if lastLessonEnds[1] > 9 else "0"+str(lastLessonEnds[1])}</i>'
return scheduleResult

View file

@ -1,162 +1,112 @@
import datetime
import hashlib
import json
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 .schedule import (
Schedule,
ScheduleAPIError,
ScheduleTarget,
get_schedule,
group_target,
normalize_target,
target_key,
)
from .schedule import Schedule, getSchedulePathByGroup
class User:
def __init__(self, userID: int) -> None:
self.id = userID
self.id: int = userID
try:
with open(f"./users/{self.id}.json", "r", encoding="utf-8") as file:
self.data: dict[str, Any] = json.load(file)
except FileNotFoundError:
self.data = {}
except (json.JSONDecodeError, OSError):
self.data: dict[str, str] = json.load(
open(f"./users/{self.id}.json", "r", encoding="utf-8")
)
except:
logger.error("Bot", f'INVALID "{userID}" SAVED DATA! RESET!')
self.data = {}
self.state: str | bool | None = self.data.get("state")
legacy_group = self.data.get("group")
self.active_target = normalize_target(self.data.get("active_schedule"))
if self.active_target is None and isinstance(legacy_group, str) and legacy_group:
self.active_target = group_target(legacy_group)
self.state: str | bool | None = self.data.get("state", None)
saved = [normalize_target(value) for value in self.data.get("saved_schedules", [])]
self.saved_schedules: list[ScheduleTarget] = [value for value in saved if value]
for name in self.data.get("saved_groups", []):
if isinstance(name, str) and name:
self._append_saved(group_target(name))
if self.active_target:
self._append_saved(self.active_target)
# self.topic: str | None = self.data.get("topic", None)
# self.course: str | None = self.data.get("course", None)
self.group: str | None = self.data.get("group", None)
self.notifications: list[str] = self.data.get("notifications", []) # type: ignore
self.valid: bool = self.data.get("valid", True)
self.saved_groups: list[str] = self.data.get("saved_groups", [])
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.search_kind = self.data.get("search_kind")
results = [normalize_target(value) for value in self.data.get("search_results", [])]
self.search_results: list[ScheduleTarget] = [value for value in results if value]
self.notifications: list[str] = self.data.get("notifications", [])
self.valid = bool(self.data.get("valid", True))
self.user: dict[str, Any] = self.data.get("user", {})
self.usage: dict[str, Any] = self.data.get(
self.user: dict = self.data.get("user", {})
self.usage: dict = self.data.get(
"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()
@property
def group(self) -> str | None:
return self.active_target["name"] if self.active_target else None
def schedule(self) -> Schedule | None:
date = datetime.datetime.now()
#FIXME
path = getSchedulePathByGroup(self.group)
if not path:
self.valid = False
self.notifications = []
self.setState("broken")
self.save()
return None
@group.setter
def group(self, name: str | None) -> None:
self.active_target = group_target(name) if name else None
@property
def saved_groups(self) -> list[str]:
return [target["name"] for target in self.saved_schedules if target["kind"] == "group"]
@saved_groups.setter
def saved_groups(self, names: list[str]) -> None:
non_groups = [target for target in self.saved_schedules if target["kind"] != "group"]
self.saved_schedules = non_groups + [group_target(name) for name in names if name]
def _append_saved(self, target: ScheduleTarget) -> None:
if not any(target_key(value) == target_key(target) for value in self.saved_schedules):
self.saved_schedules.append(target)
def is_saved(self, target: ScheduleTarget | None) -> bool:
return bool(
target
and any(target_key(value) == target_key(target) for value in self.saved_schedules)
return Schedule(
{
"date": {
"weekNumber": date.isocalendar().week,
"weekDay": date.weekday(),
},
"schedule": json.load(open(path, "r", encoding="utf-8"))[self.group],
}
)
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:
self.state = newState
self.save()
def save(self) -> None:
with open(f"./users/{self.id}.json", "w", encoding="utf-8") as file:
json.dump(
{
"state": self.state,
"group": self.group,
"active_schedule": self.active_target,
"saved_groups": self.saved_groups,
"saved_schedules": self.saved_schedules,
"view_schedule": self.view_target,
"search_kind": self.search_kind,
"search_results": self.search_results,
"notifications": self.notifications,
"valid": self.valid,
"user": self.user,
"usage": self.usage,
"flags": self.flags,
},
file,
ensure_ascii=False,
indent=4,
)
return json.dump(
{
"state": self.state,
"topic": self.topic,
"course": self.course,
"group": self.group,
"notifications": self.notifications,
"valid": self.valid,
"saved_groups": self.saved_groups,
"user": self.user,
"usage": self.usage,
"flags": self.flags,
},
open(f"./users/{self.id}.json", "w", encoding="utf-8"),
ensure_ascii=False,
indent=4,
)
def validate(ctx: Message | InlineQuery | CallbackQuery) -> User:
try:
return User(ctx.from_user.id) # type: ignore
except Exception as error:
logger.error("Bot", f'INVALID "{ctx.from_user.id}" VALIDATION! {error}')
except Exception as e:
logger.error("Bot", f'INVALID "{ctx.from_user.id}" VALIDATION! {e}')
return False # type: ignore
def getAllUsers() -> Iterator[User]:
def getAllUsers() -> list[User]: # type: ignore
for _, _, filenames in os.walk("./users"):
for userFile in filenames:
if userFile.endswith(".json"):
yield User(int(userFile.removesuffix(".json")))
if ".json" not in userFile:
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"
requires-python = ">=3.12"
dependencies = [
"colorama>=0.4.6",
"ics>=0.7.3",
"pytelegrambotapi>=4.36.1",
"python-dotenv>=1.2.3",
"python-socks>=3.0.0",
"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Буду рад любой копеечке, спасибо!",
"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"

View file

@ -20,6 +20,7 @@ class ICS:
else: # on this week
self.start_week_date = timezone.localize(datetime.now()) - timedelta(days=schedule.weekDay)
self.isWeekOdd = schedule.weekNumber%2 == 0
self.calendar = Calendar()
for target in range(5):
@ -27,7 +28,10 @@ class ICS:
event_date = self.start_week_date + timedelta(days=target)
# 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.name = f'{lesson.name}'
@ -43,7 +47,7 @@ class ICS:
# break
# break
def save(self) -> str:
"""returns path to file"""
content = self.calendar.serialize()
@ -55,3 +59,4 @@ class ICS:
path = f'./temp/{self.userID}.ics'
with open(path, 'w', encoding='utf-8', newline='') as file: file.writelines(content)
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"
source = { virtual = "." }
dependencies = [
{ name = "colorama" },
{ name = "ics" },
{ name = "pytelegrambotapi" },
{ name = "python-dotenv" },
{ name = "python-socks" },
{ name = "pytz" },
{ name = "requests", extra = ["socks"] },
{ name = "rich" },
]
[package.metadata]
requires-dist = [
{ name = "colorama", specifier = ">=0.4.6" },
{ name = "ics", specifier = ">=0.7.3" },
{ name = "pytelegrambotapi", specifier = ">=4.36.1" },
{ name = "python-dotenv", specifier = ">=1.2.3" },
{ name = "python-socks", specifier = ">=3.0.0" },
{ name = "pytz", specifier = ">=2026.3.post1" },
{ name = "requests", extras = ["socks"], specifier = ">=2.34.2" },
{ name = "rich", specifier = ">=15.0.0" },
]
[[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" },
]
[[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]]
name = "frozenlist"
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" },
]
[[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]]
name = "multidict"
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" },
]
[[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]]
name = "pytelegrambotapi"
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" },
]
[[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]]
name = "pytz"
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" },
]
[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]]
name = "six"
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)">
<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 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="_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;"/>

Before

Width:  |  Height:  |  Size: 538 KiB

After

Width:  |  Height:  |  Size: 536 KiB

View file

@ -1,86 +1,53 @@
<!DOCTYPE html>
<html lang="ru">
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#ffffff">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Карта</title>
<script src="../common/telegram.js"></script>
<script src="../common/eruda.min.js?v15"></script>
<link rel="stylesheet" href="./styles/main.css?v16">
<script src="https://telegram.org/js/telegram-web-app.js?59"></script>
<link rel="stylesheet" href="./styles/main.css?v12">
</head>
<body>
<section id="block" aria-live="polite">
<section id="block">
<div id="loader"></div>
<h3>Загрузка карты</h3>
<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>
<main id="app" aria-label="Карта университета">
<section id="map" aria-label="Карта здания">
<div id="controls">
<button id="floor-current" class="floor-current" type="button" aria-expanded="false">
<span>2 этаж</span>
<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>
</button>
<div id="floor-select" class="floor-select hidden" aria-label="Выбор этажа">
<button type="button" data-floor="1">1 этаж</button>
<button type="button" data-floor="2" class="active">2 этаж</button>
<button type="button" data-floor="3">3 этаж</button>
<button type="button" data-floor="4">4 этаж</button>
<button type="button" data-floor="5">5 этаж</button>
<button type="button" data-floor="6">6 этаж</button>
</div>
</div>
<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>
<footer >
<!-- <div class="dragger"></div> -->
<div class="hints">
</div>
<div class="i">
<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>
<input type="text" placeholder="Введи нужную аудиторию...">
<!-- <button onclick="footerfind()">Найти</button> -->
</div>
</footer>
<script src="./js/footer.js?v12"></script>
<script src="./js/map.js?v12"></script>
<script>mapload()</script>
<script>
window.Telegram.WebApp.expand()
</script>
</body>
</html>
</html>

View file

@ -2,38 +2,6 @@ const input = document.querySelector('footer > .i > input')
const hints = document.querySelector('.hints')
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) => {
const fixed = `height: ${map.clientHeight}px !important;`
document.querySelector('#map').style = fixed
@ -42,13 +10,7 @@ input.addEventListener('focus', (e) => {
input.addEventListener('input', (e) => {
console.log(e.target.value)
inputed_room = e.target.value.trim().toLowerCase()
if (inputed_room === 'eruda') {
try {hints.classList.remove('a')} catch {}
hints.innerHTML = ''
initEruda()
return
}
inputed_room = e.target.value.toLowerCase()
if (inputed_room.length > 0) {
try {hints.classList.add('a')} catch {}
const out = fdata.results(inputed_room)
@ -79,4 +41,4 @@ const find = async (room_name, floor) => {
}
Telegram.WebApp.HapticFeedback.notificationOccurred('success')
highlight(room_name)
}
}

View file

@ -1,839 +1,136 @@
/*
* 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 = {
const fdata = {
floor: 2,
floors: Array(FLOOR_COUNT).fill(null),
floors_rooms: Array.from({length: FLOOR_COUNT}, () => []),
room_floors: Object.create(null),
floors: [null,null,null,null,null,null],
floors_rooms: [[], [], [], [], [], []],
inited: false,
results: (query, limit = 10) => roomResults(query, limit),
}
const state = {
initialized: false,
mode: 'search',
highlightedRoom: '',
route: null,
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
check: async () => {
var count = 0
fdata.floors.forEach(e => e == null ? count += 1 : null)
document.querySelector('#block > p').innerHTML = `${6-count}/6 загружено...`
if (count == 0) {
await sleep(100)
mapdisplay()
removeblock()
}
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 key = compact(value)
if (!key) return null
return roomResults(value, 100).find((room) => compact(room.name) === key) || null
const floorsopen = () => {
document.querySelector('#controls > .select').classList.toggle('hidden')
Telegram.WebApp.HapticFeedback.impactOccurred('light')
}
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) {
container.replaceChildren()
if (!results.length) {
const empty = document.createElement('span')
empty.className = 'hint-empty'
empty.textContent = 'Аудитория не найдена'
container.append(empty)
return
}
results.forEach((result) => {
const button = document.createElement('button')
button.type = 'button'
button.textContent = result.name
button.addEventListener('click', () => onSelect(result))
container.append(button)
const removeblock = () => {
document.querySelector('#block').remove()
}
const highlight = (room) => {
document.querySelectorAll('.highlight').forEach(e => e.classList.remove('highlight'))
var lastfound = null
document.querySelectorAll('#Аудитории > g').forEach(e => {
if (e.getAttribute('serif:id') == room) {
e.querySelector('*').classList.add('highlight')
lastfound = e
}
})
}
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() {
$('#block')?.remove()
}
function applyMapHeightLock() {
const map = $('#map')
const wrapper = $('#wr')
if (!map || !wrapper || !state.mapHeight) return
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.
const sc = (map.scrollWidth - map.clientWidth) / val
map.scroll({left: sc, behavior: 'smooth'})
} else if (room.includes('Туале')) {
const sc = (map.scrollWidth - map.clientWidth) / 1.1
map.scroll({left: sc, behavior: 'smooth'})
} else {
return scrollauto(lastfound)
}
}
async function loadFloor(index) {
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()
}
const mapload = async () => {
function floorValue(value) {
const number = Number.parseInt(String(value), 10)
return Number.isInteger(number) && number >= 1 && number <= FLOOR_COUNT ? number : null
}
function setFloor(floor, {keepHighlight = false} = {}) {
const next = floorValue(floor)
if (!next || !fdata.floors[next - 1]) return
fdata.floor = next
const current = $('#floor-current')
if (current) {
current.querySelector('span').textContent = `${next} этаж`
current.setAttribute('aria-expanded', 'false')
for (let i = 0; i < 6; i++) {
await fetch(`./images/${i+1}.svg`).then(
async e => {
const mp = document.createElement('div');
const svg = await e.text()
fdata.floors[i] = svg
mp.innerHTML = svg
mp.querySelectorAll('#Аудитории > g').forEach(e => {
// console.log(e.getAttributeNS('serif:id'))
fdata.floors_rooms[i].push(e.getAttribute('serif:id'))
})
await sleep(100)
fdata.check()
}
)
}
$('#floor-select')?.classList.add('hidden')
$('#floor-select')?.querySelectorAll('button').forEach((button) => {
button.classList.toggle('active', Number(button.dataset.floor) === next)
})
renderFloor()
if (!keepHighlight) state.highlightedRoom = ''
haptic('light')
// [0,1,2,3,4,5].map(async (_, i) =>
// await fetch(`./images/${i+1}.svg`).then(async e => {fdata.floors[i] = await e.text(); fdata.check()}))
}
function toggleFloors() {
const select = $('#floor-select')
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
const mapdisplay = async () => {
const map = document.querySelector('#map > #wr')
const svg = fdata.floors[fdata.floor-1]
map.innerHTML = svg
if (!fdata.inited) {
// The source drawings are wide; starting near the centre gives a
// useful view while still allowing horizontal scrolling.
map.scrollLeft = Math.max(0, (map.scrollWidth - map.clientWidth) * .9)
const sc = (map.scrollWidth - map.clientWidth) / 1.1
map.scrollLeft = sc;
console.log(sc)
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;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
/* border: solid 1px #f00; */
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;
-webkit-tap-highlight-color: transparent;
}
html, body {
width: 100%;
height: 100%;
overflow: hidden;
}
html {
font-size: 16px;
}
body {
overflow: hidden;
margin: 0;
color: var(--panel-text);
background: var(--page);
font-size: 1rem;
}
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%;
background: var(--tg-theme-bg-color);
color: var(--tg-theme-text-color);
height: 100vh;
/* height: 100dvh; */
}
#map {
position: absolute;
top: 0;
right: 0;
left: 0;
bottom: auto;
height: calc(100% - 12.2rem);
overflow: hidden;
background: var(--white);
border-bottom: 4px solid #f6e7a2;
width: calc(100vw - 1rem);
margin: 0.5rem;
background: #fff;
/* box-shadow: rgba(255,255,255,.1) 0 0 .5rem .1rem; */
border-radius: 1rem;
height: calc(100% - 7.5rem);
overflow: auto;
scrollbar-width: none;
padding: 0;
display: flex;
scroll-behavior: smooth;
}
#map.map-locked {
height: var(--map-locked-height);
#map > .controls {
display: none;
}
#wr {
width: 100%;
height: 100%;
overflow: auto;
height: 100%;
overflow-y: hidden;
scrollbar-width: none;
overscroll-behavior: contain;
scroll-behavior: smooth;
background: var(--white);
}
#wr::-webkit-scrollbar {
display: none;
}
#wr > svg {
display: block;
width: auto;
min-width: 100%;
height: 100%;
background: var(--white);
/* transform: translateX(27%); */
/* height: calc(100% - 5rem); */
/* scale: 1.1; */
}
#controls {
position: absolute;
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 {
footer {
width: calc(100vw);
background: var(--tg-theme-bg-color);
position: fixed;
z-index: 10;
right: 0;
bottom: 0;
left: 0;
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;
justify-content: flex-end;
gap: .38rem;
min-height: 12.2rem;
padding: .42rem .62rem calc(.65rem + env(safe-area-inset-bottom));
background: transparent;
/* gap: .5rem; */
}
/* A route is a focused, read-only map view. The map itself remains usable,
* while floor controls, tabs, and both input panels stay out of the way. */
#app.route-view #controls,
#app.route-view #mode-switch,
#app.route-view #search-panel,
#app.route-view #navigator-panel {
display: none !important;
.dragger {
height: .3rem;
background: var(--tg-theme-hint-color);
opacity: .2;
margin: 0 auto;
border-radius: 100vw;
width: 3rem;
}
#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 {
footer > .i {
display: flex;
align-items: center;
gap: .45rem;
min-height: 2.35rem;
padding: .1rem .58rem;
color: var(--panel-hint);
background: var(--panel-muted);
border-radius: .72rem;
}
background: var(--tg-theme-secondary-bg-color);
border-radius: 5rem;
padding: 0 .5rem;
margin: 0 1rem;
gap: .5rem;
margin-top: .5rem;
/* border: solid 1px var(--tg-theme-hint-color); */
.field-row svg {
flex: 0 0 1.25rem;
width: 1.25rem;
height: 1.25rem;
fill: currentColor;
padding-right: 0;
}
.field-row input {
min-width: 0;
width: 100%;
padding: .36rem 0;
color: #fff;
footer > .i > svg {
fill: var(--tg-theme-text-color) !important;
}
footer > .i > input {
background: transparent;
border: 0;
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 {
padding: 0 1rem;
display: flex;
flex-wrap: wrap;
gap: .35rem;
max-height: 4.5rem;
padding-top: .35rem;
overflow: auto;
scrollbar-width: none;
flex-wrap: nowrap;
gap: .5rem;
overflow-x: 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 {
display: none;
}
.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 {
#controls {
margin: .5rem;
position: fixed;
z-index: 100;
inset: 0;
display: flex;
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;
justify-content: center;
color: #333;
background: var(--page);
}
#block h3 {
margin: 1rem 0 .1rem;
font-weight: 500;
}
#block p {
margin: 0;
color: #555;
#block > h3 {
font-weight: normal;
margin-bottom: 0;
}#block > p {
color: var(--tg-theme-hint-color);
}
#loader {
width: 2rem;
height: 2rem;
border: 3px solid #444;
border-top-color: transparent;
border-radius: 50%;
animation: spin .7s linear infinite;
width: 2rem;
border: solid 3px var(--tg-theme-text-color);
border-top: solid 3px transparent;
border-radius: 100vw;
animation: anim .7s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@media (min-width: 600px) {
#bottom-panel {
right: 50%;
width: min(34rem, 100%);
transform: translateX(50%);
@keyframes anim {
to {
transform: rotate(360deg);
}
}
#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>