Compare commits
No commits in common. "master" and "main" have entirely different histories.
2
.gitignore
vendored
|
|
@ -9,7 +9,5 @@ timetables/*.json
|
||||||
.timetracker
|
.timetracker
|
||||||
web/analytics/data.json
|
web/analytics/data.json
|
||||||
DEBUG
|
DEBUG
|
||||||
schedule_client.py
|
|
||||||
config.py
|
config.py
|
||||||
.env
|
.env
|
||||||
.venv
|
|
||||||
|
|
|
||||||
30
AGENTS.md
Normal file
|
|
@ -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???
|
||||||
30
README.md
|
|
@ -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 этажей содержат соответствующую группу `#Точки`.
|
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,15 @@ from telebot.handler_backends import ContinueHandling
|
||||||
|
|
||||||
from models.bot import bot, send
|
from models.bot import bot, send
|
||||||
from models.user import validate
|
from models.user import validate
|
||||||
|
from models import logger
|
||||||
|
|
||||||
|
# @bot.message_handler(content_types = ['text'])
|
||||||
|
# def handleAll(message):
|
||||||
|
# logger.log('Bot', f'{message.from_user.id}: {message.text}')
|
||||||
|
# return ContinueHandling()
|
||||||
|
|
||||||
|
|
||||||
#! Root Handlers
|
#! Root Handlers
|
||||||
from .debug import *
|
|
||||||
from .main.register_user import *
|
from .main.register_user import *
|
||||||
# @bot.message_handler(content_types = ['text'], func = lambda m: not validate(m))
|
# @bot.message_handler(content_types = ['text'], func = lambda m: not validate(m))
|
||||||
# def cantValidateHandler(message): ...
|
# def cantValidateHandler(message): ...
|
||||||
|
|
@ -16,24 +21,20 @@ from .main.register_user import *
|
||||||
|
|
||||||
# #? Another code
|
# #? Another code
|
||||||
from .analytics import *
|
from .analytics import *
|
||||||
from .navigator import *
|
|
||||||
from .iternal import *
|
from .iternal import *
|
||||||
from .admin_schedule import *
|
|
||||||
from .inline import *
|
from .inline import *
|
||||||
from .main.home import *
|
from .main.home import *
|
||||||
from .schedule.search import *
|
|
||||||
from .schedule.other_days import *
|
from .schedule.other_days import *
|
||||||
from .settings.notifications import *
|
from .settings.notifications import *
|
||||||
from .schedule.saved_groups import *
|
from .schedule.saved_groups import *
|
||||||
|
|
||||||
|
|
||||||
def run():
|
def run():
|
||||||
import logging
|
|
||||||
logger.success('Bot', 'Started')
|
logger.success('Bot', 'Started')
|
||||||
# try: bot.infinity_polling(logger_level=1)
|
# try: bot.infinity_polling(logger_level=1)
|
||||||
try:
|
try:
|
||||||
bot.infinity_polling(logger_level=logging.ERROR)
|
bot.infinity_polling(logger_level=1)
|
||||||
...
|
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
logger.error('Bot', 'Stopping...')
|
logger.error('Bot', 'Stopping...')
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -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, "База очищена")
|
|
||||||
32
bot/debug.py
|
|
@ -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)
|
|
||||||
174
bot/inline.py
|
|
@ -1,74 +1,138 @@
|
||||||
from telebot.types import InlineQuery
|
from telebot.types import (InlineQueryResultArticle, InputTextMessageContent, InlineQuery)
|
||||||
|
|
||||||
|
from models.bot import bot, Inlines
|
||||||
|
from models.user import validate
|
||||||
|
from models.schedule import generateDaySchedule, dayStrings
|
||||||
|
|
||||||
from models import logger
|
from models import logger
|
||||||
from models.bot import Inlines, bot
|
|
||||||
from models.schedule import dayStrings, generateDaySchedule, generateWeekSchedule
|
|
||||||
from models.user import validate
|
|
||||||
|
|
||||||
|
|
||||||
@bot.inline_handler(lambda query: True)
|
@bot.inline_handler(lambda query: True)
|
||||||
def default_query(query: InlineQuery):
|
def default_query(query: InlineQuery):
|
||||||
user = validate(query)
|
user = validate(query)
|
||||||
inline = Inlines(query.id)
|
inline = Inlines(query.id)
|
||||||
if not user.group:
|
if not user:
|
||||||
|
logger.warn('Bot', 'Inlinue from unknown user invoked')
|
||||||
inline.add(
|
inline.add(
|
||||||
"Расписание недоступно ❌",
|
'Расписание недоступно ❌',
|
||||||
"Запусти бота, чтобы выбрать группу",
|
'Запусти бота, чтобы использовать эту функцию',
|
||||||
"Не удалось получить расписание. Сначала запусти бота: https://t.me/zatups_bot",
|
'Не удалось получить расписание. Пользователь не запустил бота ❌ \n\nhttps://t.me/zatups_bot'
|
||||||
)
|
|
||||||
else:
|
|
||||||
schedule = user.schedule()
|
|
||||||
if not schedule:
|
|
||||||
inline.add(
|
|
||||||
"Расписание недоступно ❌",
|
|
||||||
"Сервер расписания сейчас недоступен",
|
|
||||||
"Не удалось получить расписание. Попробуй ещё раз позже.",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
week_type = "четная" if schedule.weekNumber % 2 == 0 else "нечетная"
|
|
||||||
description = dayStrings[schedule.weekDay]
|
|
||||||
inline.add(
|
|
||||||
"Расписание на сегодня 🔥",
|
|
||||||
f"{description}, {week_type} неделя",
|
|
||||||
generateDaySchedule(schedule, isToday=True),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
tomorrow = user.schedule()
|
|
||||||
if tomorrow:
|
|
||||||
if tomorrow.weekDay == 6:
|
|
||||||
tomorrow.weekDay = 0
|
|
||||||
tomorrow.weekNumber += 1
|
|
||||||
else:
|
else:
|
||||||
tomorrow.weekDay += 1
|
if 'today':
|
||||||
tomorrow_description = dayStrings[tomorrow.weekDay]
|
s = user.schedule()
|
||||||
tomorrow_week_type = (
|
weektype = 'четная' if s.weekNumber%2 == 0 else 'нечетная'
|
||||||
"четная" if tomorrow.weekNumber % 2 == 0 else "нечетная"
|
description = dayStrings[s.weekDay]
|
||||||
)
|
output = generateDaySchedule(s, isToday = True) if s.weekDay < 5 else f'<b>{description}</b>\nСегодня пар нету 🥰'
|
||||||
|
|
||||||
inline.add(
|
inline.add(
|
||||||
"Расписание на завтра 🍀",
|
'Расписание на сегодня 🔥',
|
||||||
f"{tomorrow_description}, {tomorrow_week_type} неделя",
|
f'{description}, {weektype.lower()} неделя',
|
||||||
generateDaySchedule(tomorrow),
|
output
|
||||||
)
|
)
|
||||||
|
|
||||||
week = user.schedule()
|
if 'tomorrow':
|
||||||
if week:
|
s = user.schedule()
|
||||||
title = "эту"
|
output = False
|
||||||
if week.weekDay == 6:
|
description = dayStrings[(s.weekDay+1)%len(dayStrings)]
|
||||||
week.weekNumber += 1
|
if s.weekDay in [4, 5]:
|
||||||
title = "следующую"
|
output = f'<b>{description}</b>\nЗавтра пар нету 🥰'
|
||||||
week_type = "четную" if week.weekNumber % 2 == 0 else "нечетную"
|
|
||||||
output = (
|
elif s.weekDay == 6:
|
||||||
f"<i>Расписание на </i><b>{title}</b><i> {week_type} неделю</i>\n"
|
s.weekDay = 0
|
||||||
f"{generateWeekSchedule(week)}"
|
s.weekNumber += 1
|
||||||
)
|
|
||||||
|
else:
|
||||||
|
s.weekDay += 1
|
||||||
|
|
||||||
|
if not output: output = generateDaySchedule(s)
|
||||||
|
|
||||||
|
weektype = 'четная' if s.weekNumber%2 == 0 else 'нечетная'
|
||||||
|
|
||||||
inline.add(
|
inline.add(
|
||||||
"Расписание на неделю 📅",
|
'Расписание на завтра 🍀',
|
||||||
f"{week_type.capitalize()} неделя",
|
f'{description}, {weektype.lower()} неделя',
|
||||||
output,
|
output
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.log("Bot", f"Inline from {user.id}")
|
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'
|
||||||
|
|
||||||
|
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'
|
||||||
|
|
||||||
|
for index, _ in enumerate(s.days):
|
||||||
|
s.weekDay = index
|
||||||
|
output += generateDaySchedule(s, False) +'\n\n'
|
||||||
|
|
||||||
|
inline.add(
|
||||||
|
'Расписание на неделю 📅',
|
||||||
|
description,
|
||||||
|
output
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.log('Bot', f'Inline from {user.id}')
|
||||||
try:
|
try:
|
||||||
return inline.send()
|
return inline.send()
|
||||||
except Exception as error:
|
except Exception as e:
|
||||||
logger.error("Bot", f"Inline.send() -> {error}")
|
logger.error('Bot', f'Inline.send() -> {e}')
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,12 +47,7 @@ def sendConfirm(message: Message):
|
||||||
'Нeт': {'callback_data': 'ignore'}
|
'Нeт': {'callback_data': 'ignore'}
|
||||||
}, row_width=1))
|
}, row_width=1))
|
||||||
|
|
||||||
@bot.message_handler(
|
@bot.message_handler(content_types = ['document', 'file'], func = lambda m: m.from_user.id in config.ADMINS)
|
||||||
content_types=['document', 'file'],
|
|
||||||
func=lambda message: message.from_user.id in config.ADMINS
|
|
||||||
and bool(message.document)
|
|
||||||
and (message.document.file_name or '').lower().endswith('.json'),
|
|
||||||
)
|
|
||||||
def massSendHandle(message: Message):
|
def massSendHandle(message: Message):
|
||||||
global REMEMBER_MESSAGE_DATA, REMEMBER_JSON_DATA, REMEMBER_PHOTO_ID
|
global REMEMBER_MESSAGE_DATA, REMEMBER_JSON_DATA, REMEMBER_PHOTO_ID
|
||||||
REMEMBER_PHOTO_ID = False
|
REMEMBER_PHOTO_ID = False
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,9 @@ import json
|
||||||
from telebot.types import Message, CallbackQuery, InputFile, ReplyKeyboardRemove
|
from telebot.types import Message, CallbackQuery, InputFile, ReplyKeyboardRemove
|
||||||
from telebot.util import quick_markup
|
from telebot.util import quick_markup
|
||||||
|
|
||||||
from models.bot import bot, Markup, Strings, send, Receive, Default
|
from models.bot import bot, Markup, send, Receive, Default
|
||||||
from models.user import validate
|
from models.user import validate
|
||||||
from models.schedule import dayStrings, generateDaySchedule, generateWeekSchedule
|
from models.schedule import generateDaySchedule, dayStrings
|
||||||
|
|
||||||
from utils.calendar import ICS
|
from utils.calendar import ICS
|
||||||
|
|
||||||
|
|
@ -15,13 +15,9 @@ from telebot.handler_backends import ContinueHandling
|
||||||
def sendBrokenMessage(message: Message):
|
def sendBrokenMessage(message: Message):
|
||||||
send(message, 'Твоя группа не была найдена в боте, пожалуйста перезапусти его через команду /start', ReplyKeyboardRemove())
|
send(message, 'Твоя группа не была найдена в боте, пожалуйста перезапусти его через команду /start', ReplyKeyboardRemove())
|
||||||
|
|
||||||
@bot.message_handler(content_types = ['text'], func = lambda m: m.text == Strings.back_to_home)
|
@bot.message_handler(content_types = ['text'], func = lambda m: m.text == 'На главную 🏠')
|
||||||
def returnToDefaultState(message: Message):
|
def returnToDefaultState(message: Message):
|
||||||
u = validate(message)
|
u = validate(message)
|
||||||
if not u.active_target and u.state != 'scheduleDeepLink':
|
|
||||||
from bot.schedule.search import open_search
|
|
||||||
|
|
||||||
return open_search(message, u)
|
|
||||||
u.setState('default')
|
u.setState('default')
|
||||||
return send(message, 'Главное меню 🏠', reply_markup = Markup.default(u))
|
return send(message, 'Главное меню 🏠', reply_markup = Markup.default(u))
|
||||||
|
|
||||||
|
|
@ -34,40 +30,24 @@ def forceReturnToDefaultState(query: CallbackQuery):
|
||||||
@bot.message_handler(content_types = ['text'], func = lambda m: validate(m).state == 'default')
|
@bot.message_handler(content_types = ['text'], func = lambda m: validate(m).state == 'default')
|
||||||
def defaultState(message: Message):
|
def defaultState(message: Message):
|
||||||
u = validate(message)
|
u = validate(message)
|
||||||
|
s = u.schedule()
|
||||||
|
# print(u.id, message.text)
|
||||||
|
|
||||||
|
|
||||||
if message.text[0] == '/': return ContinueHandling() # type: ignore
|
if message.text[0] == '/': return ContinueHandling() # type: ignore
|
||||||
|
|
||||||
if message.text == Strings.search:
|
if message.text == f'{u.group} 🔄':
|
||||||
from bot.schedule.search import open_search
|
|
||||||
|
|
||||||
return open_search(message, u)
|
|
||||||
|
|
||||||
if message.text == Strings.current_schedule_name_F.format(u.group):
|
|
||||||
u.setState('savedGroups')
|
u.setState('savedGroups')
|
||||||
return send(
|
return send(u.id, f'<b>Сохраненные группы</b>\nЗдесь список групп, которые ранее были использованы, чтобы быстро между ними переключаться. \n\n<i>Активная группа: {u.group}</i>', Markup.saved_groups(u))
|
||||||
u.id,
|
|
||||||
f'<b>Сохраненные расписания</b>\n\n<i>Активное расписание: {u.group}</i>',
|
|
||||||
Markup.saved_schedules(u),
|
|
||||||
)
|
|
||||||
|
|
||||||
schedule_actions = {
|
|
||||||
Strings.today,
|
|
||||||
Strings.tomorrow,
|
|
||||||
Strings.schedule_another_days,
|
|
||||||
'На неделю 📅',
|
|
||||||
}
|
|
||||||
s = u.schedule() if message.text in schedule_actions else None
|
|
||||||
if message.text in schedule_actions and not s:
|
|
||||||
return send(
|
|
||||||
u.id,
|
|
||||||
'Не удалось получить расписание. Попробуй ещё раз позже или перезапусти бота через /start.',
|
|
||||||
reply_markup=Markup.default(u),
|
|
||||||
)
|
|
||||||
|
|
||||||
match message.text:
|
match message.text:
|
||||||
case Strings.today:
|
case 'Сегодня 🔥':
|
||||||
|
if s.weekDay > 4:
|
||||||
|
return send(u.id, f'<b>{dayStrings[s.weekDay]}</b>\nСегодня пар нет 🥰')
|
||||||
|
|
||||||
return send(u.id, generateDaySchedule(s, isToday = True), reply_markup = Markup.default(u))
|
return send(u.id, generateDaySchedule(s, isToday = True), reply_markup = Markup.default(u))
|
||||||
|
|
||||||
case Strings.tomorrow:
|
case 'Завтра 🍀':
|
||||||
if s.weekDay in [4, 5]:
|
if s.weekDay in [4, 5]:
|
||||||
return send(u.id, f'<b>{dayStrings[s.weekDay+1]}</b>\nЗавтра пар нет 🥰')
|
return send(u.id, f'<b>{dayStrings[s.weekDay+1]}</b>\nЗавтра пар нет 🥰')
|
||||||
elif s.weekDay == 6:
|
elif s.weekDay == 6:
|
||||||
|
|
@ -85,21 +65,17 @@ def defaultState(message: Message):
|
||||||
s.weekNumber += 1
|
s.weekNumber += 1
|
||||||
resultString = f'<i>Расписание на </i><b>следующую</b><i> {"четную" if s.weekNumber%2==0 else "нечетную"} неделю</i>\n'
|
resultString = f'<i>Расписание на </i><b>следующую</b><i> {"четную" if s.weekNumber%2==0 else "нечетную"} неделю</i>\n'
|
||||||
|
|
||||||
resultString += generateWeekSchedule(s)
|
for index, _ in enumerate(s.days):
|
||||||
|
s.weekDay = index
|
||||||
|
resultString += generateDaySchedule(s, False) +'\n\n'
|
||||||
|
|
||||||
return send(u.id, resultString, reply_markup = Markup.default(u))
|
return send(u.id, resultString, reply_markup = Markup.default(u))
|
||||||
|
|
||||||
case Strings.schedule_another_days:
|
case 'Другие дни 📁':
|
||||||
u.setState('otherDays')
|
u.setState('otherDays')
|
||||||
u.view_target = u.active_target
|
return send(u.id, f'<b>Сегодня {dayStrings[s.weekDay].lower()}, {"четная" if s.weekNumber%2==0 else "нечентная"} неделя</b>\n\nВыбери нужный день/пункт', reply_markup = Markup.other())
|
||||||
u.save()
|
|
||||||
return send(
|
|
||||||
u.id,
|
|
||||||
f'<b>Сегодня {dayStrings[s.weekDay].lower()}, {"четная" if s.weekNumber%2==0 else "нечетная"} неделя</b>\n\nВыбери нужный день/пункт',
|
|
||||||
reply_markup=Markup.schedule(u.is_saved(u.active_target)),
|
|
||||||
)
|
|
||||||
|
|
||||||
case Strings.notifications:
|
case 'Уведомления ⏰':
|
||||||
u.setState('notifications')
|
u.setState('notifications')
|
||||||
return send(u.id, f'''\
|
return send(u.id, f'''\
|
||||||
<b>Перед парой ⌛</b> — <i>{"включено" if 'lesson' in u.notifications else "выключено"}</i>
|
<b>Перед парой ⌛</b> — <i>{"включено" if 'lesson' in u.notifications else "выключено"}</i>
|
||||||
|
|
@ -114,7 +90,7 @@ def defaultState(message: Message):
|
||||||
<i>Выбери нужную кнопку, чтобы включить/выключить уведомления</i>\
|
<i>Выбери нужную кнопку, чтобы включить/выключить уведомления</i>\
|
||||||
''', reply_markup = Markup.notifications())
|
''', reply_markup = Markup.notifications())
|
||||||
|
|
||||||
case Strings.support:
|
case 'Поддержать 💞':
|
||||||
supportJSON = json.load(open('support.json', 'r', encoding = 'utf-8'))
|
supportJSON = json.load(open('support.json', 'r', encoding = 'utf-8'))
|
||||||
return send(u.id, supportJSON['text'], reply_markup = quick_markup(supportJSON['quick_markup'], row_width = 1))
|
return send(u.id, supportJSON['text'], reply_markup = quick_markup(supportJSON['quick_markup'], row_width = 1))
|
||||||
|
|
||||||
|
|
@ -194,3 +170,4 @@ def sendHelp(message: Message):
|
||||||
[bot.forward_message(adminID, message.from_user.id, message.id) for adminID in ADMINS] # type: ignore
|
[bot.forward_message(adminID, message.from_user.id, message.id) for adminID in ADMINS] # type: ignore
|
||||||
|
|
||||||
send(message, '<b>Сообщение переслано разработчику 🔧</b>\nСпасибо за обращение', Markup.default(u))
|
send(message, '<b>Сообщение переслано разработчику 🔧</b>\nСпасибо за обращение', Markup.default(u))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,148 +1,97 @@
|
||||||
import json
|
import json
|
||||||
|
from telebot.types import Message
|
||||||
from telebot.types import Message, ReplyKeyboardRemove
|
|
||||||
from telebot.util import quick_markup
|
from telebot.util import quick_markup
|
||||||
|
|
||||||
import config
|
|
||||||
from models import logger
|
from models import logger
|
||||||
from models.bot import Markup_v1 as Markup, bot, send
|
from models.user import User, validate, getAllUsers
|
||||||
from models.schedule import (
|
from models.bot import (Markup_v1 as Markup, bot, send)
|
||||||
ScheduleAPIError,
|
from models.schedule import generateDaySchedule
|
||||||
find_main_groups,
|
|
||||||
group_exists,
|
|
||||||
group_target,
|
|
||||||
search_targets,
|
|
||||||
)
|
|
||||||
from models.user import getAllUsers, validate
|
|
||||||
from utils import convert
|
|
||||||
|
|
||||||
|
@bot.message_handler(commands = ['start'])
|
||||||
@bot.message_handler(commands=["start"])
|
|
||||||
def start(message: Message):
|
def start(message: Message):
|
||||||
user = validate(message)
|
u = validate(message)
|
||||||
message.text = str(message.text)
|
if u and 'migrate' in message.text: # type: ignore
|
||||||
|
u.setState('default')
|
||||||
|
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>
|
||||||
|
|
||||||
payload = message.text.split()[-1]
|
<i>Чтобы воспользоваться функцией отправки расписания в любом чате, напиши <code>@zatups_bot</code> в любом чате, выбери бота из списка, а после нажми на нужный день - сегодня, завтра, или на всю неделю.\nКстати - в следующий раз достаточно просто написать @ и телеграм сам выдаст затупсика в списке!</i>''')
|
||||||
if payload.startswith("_s"):
|
return
|
||||||
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
|
|
||||||
|
|
||||||
return open_schedule_viewer(message, results[0], from_search=False)
|
logger.log('Bot', f'[{u.id}] /start detected, Total users: {len(list(getAllUsers()))+1}')
|
||||||
|
|
||||||
|
|
||||||
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))
|
|
||||||
|
|
||||||
was_registered = bool(user.group)
|
|
||||||
logger.log("Bot", f"[{user.id}] /start detected, Total users: {len(list(getAllUsers()))}")
|
|
||||||
json.dump(
|
json.dump(
|
||||||
{
|
{
|
||||||
"state": "selectGroup",
|
'state': 'selectTopic',
|
||||||
"group": None,
|
|
||||||
"active_schedule": None,
|
'topic': '',
|
||||||
"saved_groups": user.saved_groups,
|
'course': '',
|
||||||
"saved_schedules": user.saved_schedules,
|
'group': '',
|
||||||
"view_schedule": None,
|
'saved_groups': u.saved_groups if u else [],
|
||||||
"notifications": [],
|
'notifyEvening': False,
|
||||||
"valid": True,
|
'notifyBeforeLesson': False,
|
||||||
"user": user.user,
|
|
||||||
"usage": user.usage,
|
|
||||||
"flags": user.flags,
|
|
||||||
},
|
},
|
||||||
open(f"./users/{user.id}.json", "w", encoding="utf-8"),
|
open(f'./users/{message.from_user.id}.json', 'w', encoding='utf-8'), # type: ignore
|
||||||
ensure_ascii = False,
|
ensure_ascii = False,
|
||||||
indent=4,
|
indent = 4
|
||||||
)
|
)
|
||||||
|
send(message, '''
|
||||||
|
<b>Бот на стадии разработки!</b>
|
||||||
|
- <i>Доступны не все функции, группы</i>
|
||||||
|
- <i>Возможны баги, фризы, затупы итд итп</i>
|
||||||
|
- <i>Бот изначально не планировался для публичного использования</i>
|
||||||
|
|
||||||
prefix = (
|
Просто имей это ввиду😥
|
||||||
"<i>Вы начали процедуру перезапуска бота. Уведомления отключены.</i>\n\n"
|
''')
|
||||||
if was_registered
|
|
||||||
else ""
|
#FIXME
|
||||||
)
|
send(message, 'Выбери свой факультет' if not u else '<i>Вы начали процедуру перезапуска бота. Все настройки и данные удалены, включая уведомления</i>\nВыбери новый факультет', reply_markup = Markup.selectTopic())
|
||||||
send(
|
|
||||||
message,
|
|
||||||
prefix + "Напиши название своей группы. Например: <code>АТ-501</code>",
|
|
||||||
reply_markup=ReplyKeyboardRemove(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@bot.message_handler(content_types=["text"], func=lambda message: "нету ⚠" in message.text)
|
|
||||||
|
@bot.message_handler(content_types = ['text'], func = lambda m: ' нету ⚠' in m.text)
|
||||||
def notFoundHandler(message: Message):
|
def notFoundHandler(message: Message):
|
||||||
bot.reply_to(
|
bot.reply_to(message, '''
|
||||||
message,
|
|
||||||
"""
|
|
||||||
<b>Не расстраивайся!</b>
|
<b>Не расстраивайся!</b>
|
||||||
<i>Напиши разработчику, если твоей группы нет в расписании.</i>
|
<i>Напиши разработчику, чтобы он добавил твой факультет/курс/группу</i>
|
||||||
""",
|
|
||||||
reply_markup=quick_markup(
|
<blockquote>
|
||||||
{
|
К сожалению, для поддержки работы бота нужно много мощностей и материальные вложения.
|
||||||
"Написать разработчику 💞": {"url": "https://t.me/m/Gg00FvJmZDM6"},
|
Бюджет, выделенный на разработку бота - <i>2 банки пива</i>, поэтому в целях экономии в боте пока доступны не все группы
|
||||||
"Перезапустить бота 🤖": {"url": config.TELEGRAM_URL + "?start=r"},
|
</blockquote>''', reply_markup = quick_markup({'Написать разработчику 💞': {'url': 'https://t.me/m/Gg00FvJmZDM6'}, 'Перезапустить бота 🤖': {'url': 'https://t.me/zatups_bot?start=r'}}, row_width = 1))
|
||||||
},
|
|
||||||
row_width=1,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _finish_registration(message: Message, group_name: str) -> None:
|
@bot.message_handler(content_types = ['text'], func = lambda m: validate(m).state == 'selectTopic')
|
||||||
user = validate(message)
|
def selectTopicState(message: Message):
|
||||||
user.save_schedule(group_target(group_name))
|
u = validate(message)
|
||||||
user.valid = True
|
u.topic = message.text
|
||||||
user.setState("default")
|
u.setState('selectCourse')
|
||||||
send(
|
|
||||||
message,
|
|
||||||
"""
|
|
||||||
<b>Приятного использования!</b>
|
|
||||||
Здесь можно посмотреть расписание и настроить уведомления о парах.
|
|
||||||
|
|
||||||
<i>Изменить группу можно через /start.</i>
|
send(message, 'Выбери свой курс', reply_markup = Markup.selectCourse(u.topic))
|
||||||
""",
|
|
||||||
reply_markup=Markup.default(user),
|
@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(
|
@bot.message_handler(content_types = ['text'], func = lambda m: validate(m).state == 'selectGroup')
|
||||||
content_types=["text"], func=lambda message: validate(message).state == "selectGroup"
|
|
||||||
)
|
|
||||||
def selectGroupState(message: Message):
|
def selectGroupState(message: Message):
|
||||||
query = (message.text or "").strip()
|
u = validate(message)
|
||||||
try:
|
u.group = message.text
|
||||||
matches = find_main_groups(query)
|
u.setState('default')
|
||||||
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(
|
send(message, '''
|
||||||
message,
|
<b>Приятного использования!</b>
|
||||||
"Выбери свою группу из самых похожих вариантов или введи название точнее.",
|
Здесь можно посмотреть расписание в более приятном виде, а также настроить уведомления о парах!
|
||||||
reply_markup=Markup.selectGroup(matches),
|
|
||||||
)
|
<i>Изменить факультет/курс/группу можно просто перезапустив бота</i>
|
||||||
|
''', reply_markup = Markup.default(u))
|
||||||
132
bot/navigator.py
|
|
@ -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)
|
|
||||||
|
|
@ -1,40 +1,25 @@
|
||||||
from telebot.types import Message
|
from telebot.types import Message
|
||||||
|
|
||||||
from models.bot import Markup, Strings, bot, send
|
from models.bot import bot, Markup, send
|
||||||
from models.user import validate
|
from models.user import validate
|
||||||
|
from models.schedule import generateDaySchedule
|
||||||
|
|
||||||
from .search import render_schedule_action
|
@bot.message_handler(content_types = ['text'], func = lambda m: validate(m).state == 'otherDays')
|
||||||
|
|
||||||
|
|
||||||
@bot.message_handler(
|
|
||||||
content_types=["text"], func=lambda message: validate(message).state == "otherDays"
|
|
||||||
)
|
|
||||||
def otherState(message: Message):
|
def otherState(message: Message):
|
||||||
user = validate(message)
|
u = validate(message)
|
||||||
target = user.view_target or user.active_target
|
possibleResponces = ['ПН', 'ВТ', 'СР', 'ЧТ', 'ПТ']
|
||||||
if not target:
|
if message.text not in possibleResponces and message.text not in ['Четная 📅', 'Нечетная 📅']: return
|
||||||
user.setState("default")
|
result: str = ''
|
||||||
return send(message, "Сначала выбери расписание через поиск", Markup.default(user))
|
s = u.schedule()
|
||||||
|
if message.text in possibleResponces:
|
||||||
|
s.weekDay = possibleResponces.index(message.text)
|
||||||
|
result = generateDaySchedule(s)
|
||||||
|
|
||||||
if message.text == Strings.save:
|
else:
|
||||||
user.save_schedule(target)
|
s.weekNumber = 2 if message.text == 'Четная 📅' else 1
|
||||||
return send(
|
result = f'<i>{message.text.split(" ")[0]} неделя</i>\n'
|
||||||
message,
|
for index, _ in enumerate(s.days):
|
||||||
f"Расписание <b>{target['name']}</b> сохранено",
|
s.weekDay = index
|
||||||
reply_markup=Markup.schedule(True),
|
result += generateDaySchedule(s, False) +'\n\n'
|
||||||
)
|
|
||||||
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 "")
|
return send(u.id, result, reply_markup = Markup.other())
|
||||||
if result:
|
|
||||||
return send(
|
|
||||||
message,
|
|
||||||
result,
|
|
||||||
reply_markup=Markup.schedule(user.is_saved(target)),
|
|
||||||
)
|
|
||||||
|
|
@ -1,50 +1,33 @@
|
||||||
from telebot.types import Message
|
from telebot.types import Message, CallbackQuery
|
||||||
|
from telebot.util import quick_markup
|
||||||
from models.bot import Markup, Strings, bot, send
|
from models.bot import bot, Markup, send
|
||||||
from models.user import validate
|
from models.user import validate
|
||||||
|
from models.schedule import generateDaySchedule
|
||||||
|
|
||||||
|
@bot.message_handler(content_types = ['text'], func = lambda m: validate(m).state == 'savedGroups')
|
||||||
@bot.message_handler(
|
|
||||||
content_types=["text"], func=lambda message: validate(message).state == "savedGroups"
|
|
||||||
)
|
|
||||||
def savedGroups(message: Message):
|
def savedGroups(message: Message):
|
||||||
user = validate(message)
|
u = validate(message)
|
||||||
|
if message.text == 'Добавить группу 🔆':
|
||||||
|
return send(message, 'Чтобы добавить группу используй команду /start')
|
||||||
|
|
||||||
if message.text == Strings.add_schedule:
|
elif message.text == 'Удалить группы 🗑':
|
||||||
from .search import open_search
|
return send(message, 'Это действие нельзя отменить, точно удалить все сохраненные группы?', quick_markup({
|
||||||
|
'Да, удалить все': {'callback_data': '$delsaved'}
|
||||||
|
}))
|
||||||
|
|
||||||
return open_search(message, user)
|
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))
|
||||||
|
|
||||||
if user.active_target and message.text == Strings.delete_active_schedule_F.format(user.group):
|
@bot.callback_query_handler(func=lambda call: '$delsaved' == call.data)
|
||||||
removed_name = user.group
|
def confirmDeleteAll(call: CallbackQuery):
|
||||||
user.remove_schedule(user.active_target)
|
u = validate(call)
|
||||||
if not user.active_target:
|
u.setState('default')
|
||||||
from .search import open_search
|
try:
|
||||||
|
u.saved_groups = [u.group]
|
||||||
send(message, f'Расписание <b>{removed_name}</b> удалено. Выбери новое расписание.')
|
u.save()
|
||||||
return open_search(message, user)
|
send(u.id, 'Сохраненные группы были удалены 🗑', Markup.default(u))
|
||||||
user.setState("savedGroups")
|
except:
|
||||||
return send(
|
send(u.id, 'Не удалось удалить группы', Markup.default(u))
|
||||||
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),
|
|
||||||
)
|
|
||||||
|
|
@ -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
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
# Telegram bot token
|
# Telegram bot token
|
||||||
TOKEN: str = '...'
|
TOKEN: str = '...'
|
||||||
|
|
||||||
# Full url to bot
|
|
||||||
TELEGRAM_URL: str = 'https://t.me/your_cool_bot'
|
|
||||||
|
|
||||||
# Telegram proxy url (None for no proxy)
|
# Telegram proxy url (None for no proxy)
|
||||||
TELEGRAM_PROXY: str | None = None
|
TELEGRAM_PROXY: str | None = None
|
||||||
|
|
||||||
|
|
@ -11,10 +8,7 @@ TELEGRAM_PROXY: str | None = None
|
||||||
WEB_BASE_URL: str = 'https://zatups.example.com'
|
WEB_BASE_URL: str = 'https://zatups.example.com'
|
||||||
|
|
||||||
# Base URL for the schedule (should be runned separately)
|
# Base URL for the schedule (should be runned separately)
|
||||||
SCHEDULE_BASE_URL: str = 'http://10.9.8.3:18822'
|
SCHEDULE_BASE_URL: str = 'http://schedule_backend:8000/'
|
||||||
|
|
||||||
# The navigator graph is embedded in web/map/data/navigator.json and is shared
|
|
||||||
# by the bot and the WebView.
|
|
||||||
|
|
||||||
# Telegram user IDs
|
# Telegram user IDs
|
||||||
ADMINS: list[int] = [5016590523, 5001115363]
|
ADMINS: list[int] = [5016590523, 5001115363]
|
||||||
|
|
@ -19,11 +19,18 @@ class Lesson(Event):
|
||||||
t = s.today()
|
t = s.today()
|
||||||
if t == []: continue
|
if t == []: continue
|
||||||
w = f'<b>Скоро начнется пара</b>\n'
|
w = f'<b>Скоро начнется пара</b>\n'
|
||||||
for lesson in t:
|
for lessonList in t:
|
||||||
if [lessonTime.hour, lessonTime.minute] == lesson.start:
|
if len(lessonList) == 1:
|
||||||
|
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)
|
w += lesson.text(isToday = True)
|
||||||
|
|
||||||
if len(w.split('\n')) > 2: send(user.id, w)
|
if len(w.split('\n')) > 2: send(user.id, w)
|
||||||
|
|
||||||
|
|
||||||
addEvent(Lesson)
|
addEvent(Lesson)
|
||||||
|
|
||||||
|
|
|
||||||
10
main.py
|
|
@ -4,13 +4,10 @@ if __name__ == '__main__':
|
||||||
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
import models.logger
|
import models.logger
|
||||||
# Reuse the interpreter that launched this process. Calling ``uv`` from
|
CMD = 'python3.13.exe' if sys.platform == 'win32' else 'python3'
|
||||||
# a child shell breaks when uv was installed outside PATH (and also makes
|
|
||||||
# ``uv run main.py`` recurse through a second environment).
|
|
||||||
CMD = 'python3.13.exe' if sys.platform == 'win32' else f'"{sys.executable}"'
|
|
||||||
PROCESSES = [
|
PROCESSES = [
|
||||||
Process(target=bot.run if sys.platform == 'win32' else os.system, args=() if sys.platform == 'win32' else (f'{CMD} -c "import bot; bot.run()"',)),
|
# Process(target=bot.run if sys.platform == 'win32' else os.system, args=() if sys.platform == 'win32' else (f'{CMD} -c "import bot; bot.run()"',)),
|
||||||
# Process(target=bot.run),
|
Process(target=bot.run),
|
||||||
Process(target=event.run),
|
Process(target=event.run),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -26,3 +23,4 @@ if __name__ == '__main__':
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
models.logger.error('Main', f'{e}')
|
models.logger.error('Main', f'{e}')
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,51 +1,111 @@
|
||||||
import datetime
|
import datetime
|
||||||
import html
|
|
||||||
import json
|
import json
|
||||||
from typing import Any
|
from typing import Any, Callable, Literal
|
||||||
|
|
||||||
|
from .schedule import getFacCourseByGroup
|
||||||
from .user import User
|
from .user import User
|
||||||
|
|
||||||
|
def I(condition: bool): return 1 if condition else 0
|
||||||
|
|
||||||
|
FEATURE_LIST: dict[str, Callable[[User], int]] = {
|
||||||
|
"accounts": lambda _: 1,
|
||||||
|
|
||||||
|
"today_accounts": lambda u: I(u.usage["date"] == datetime.datetime.now().strftime("%Y.%m.%d")),
|
||||||
|
"today_interactions": lambda u: (
|
||||||
|
max(u.usage["count"], 0)
|
||||||
|
if u.usage["date"] == datetime.datetime.now().strftime("%Y.%m.%d")
|
||||||
|
else 0
|
||||||
|
),
|
||||||
|
|
||||||
|
"feature_groups": lambda u: I(len(u.saved_groups) > 1),
|
||||||
|
"feature_notifications": lambda u: I(len(u.notifications) > 0),
|
||||||
|
"feature_extended_schedule": lambda u: I('extended_schedule' in u.flags),
|
||||||
|
"fetaure_v2_ui": lambda u: I('v2_ui' in u.flags)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class Analytics:
|
class Analytics:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.issued_at = datetime.datetime.now()
|
self.issued_at: datetime.datetime = datetime.datetime.now()
|
||||||
self.users = 0
|
self.custom: dict[str, Any] = {
|
||||||
self.groups: dict[str, int] = {}
|
"group_list": [],
|
||||||
|
}
|
||||||
|
self.total: dict[str, int] = {}
|
||||||
|
self.data: dict[
|
||||||
|
str, # faculty
|
||||||
|
dict[
|
||||||
|
str, # course
|
||||||
|
dict[str, int], # features
|
||||||
|
],
|
||||||
|
] = {}
|
||||||
|
|
||||||
def add(self, user: User) -> None:
|
def add(self, u: User):
|
||||||
self.users += 1
|
if u.state == "broken":
|
||||||
if not user.active_target or user.active_target["kind"] != "group":
|
|
||||||
return
|
return
|
||||||
group = user.active_target["name"]
|
|
||||||
self.groups[group] = self.groups.get(group, 0) + 1
|
|
||||||
|
|
||||||
def toDict(self) -> dict[str, Any]:
|
faculty, course = getFacCourseByGroup(u.group)
|
||||||
|
if not faculty:
|
||||||
|
return
|
||||||
|
|
||||||
|
faculty: str
|
||||||
|
course: str
|
||||||
|
|
||||||
|
# custom
|
||||||
|
[self.custom["group_list"].append(groupname) for groupname in u.saved_groups]
|
||||||
|
self.custom["group_list"] = list(set(self.custom["group_list"]))
|
||||||
|
|
||||||
|
# features
|
||||||
|
for feature_name, func in FEATURE_LIST.items():
|
||||||
|
result = func(u)
|
||||||
|
|
||||||
|
if feature_name not in self.total:
|
||||||
|
self.total[feature_name] = 0
|
||||||
|
self.total[feature_name] += result
|
||||||
|
|
||||||
|
if faculty not in self.data:
|
||||||
|
self.data[faculty] = {}
|
||||||
|
if course not in self.data[faculty]:
|
||||||
|
self.data[faculty][course] = {}
|
||||||
|
if feature_name not in self.data[faculty][course]:
|
||||||
|
self.data[faculty][course][feature_name] = 0
|
||||||
|
self.data[faculty][course][feature_name] += result
|
||||||
|
|
||||||
|
def toDict(self):
|
||||||
return {
|
return {
|
||||||
"version": 3,
|
"version": 2,
|
||||||
"issued_at": self.issued_at.isoformat(),
|
"issued_at": self.issued_at.isoformat(),
|
||||||
"users": self.users,
|
"total": self.total,
|
||||||
"groups": dict(sorted(self.groups.items())),
|
"data": self.data,
|
||||||
|
"custom": self.custom,
|
||||||
}
|
}
|
||||||
|
|
||||||
def append(self) -> None:
|
def append(self):
|
||||||
try:
|
try:
|
||||||
with open("./web/analytics/data-v2.json", "r", encoding="utf-8") as file:
|
w: list[dict[str, Any]] = json.load(
|
||||||
history: list[dict[str, Any]] = json.load(file)
|
open("./web/analytics/data-v2.json", "r", encoding="utf-8")
|
||||||
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
)
|
||||||
history = []
|
except:
|
||||||
|
w = []
|
||||||
history.append(self.toDict())
|
w.append(self.toDict())
|
||||||
with open("./web/analytics/data-v2.json", "w", encoding="utf-8") as file:
|
w = w[-30:]
|
||||||
json.dump(history[-30:], file, ensure_ascii=False)
|
json.dump(
|
||||||
|
w,
|
||||||
|
open("./web/analytics/data-v2.json", "w", encoding="utf-8"),
|
||||||
|
ensure_ascii=False,
|
||||||
|
# indent=0,
|
||||||
|
)
|
||||||
|
|
||||||
def text(self) -> str:
|
def text(self) -> str:
|
||||||
group_lines = [
|
|
||||||
f"{html.escape(group)}: <code>{count}</code>"
|
|
||||||
for group, count in sorted(self.groups.items())
|
|
||||||
]
|
|
||||||
groups = "\n".join(group_lines) if group_lines else "Нет зарегистрированных групп"
|
|
||||||
return (
|
return (
|
||||||
f'<b>Аналитика за {self.issued_at.strftime("%d.%m")} 📊</b>\n'
|
f"""
|
||||||
f"Пользователи: <code>{self.users}</code>\n\n"
|
<b>Аналитика за {self.issued_at.strftime("%m.%d")} 📊</b>"""
|
||||||
f"<b>Группы</b>\n{groups}"
|
+ "\n".join(
|
||||||
|
[
|
||||||
|
f"{feature.capitalize()}: <code>{number}</code>"
|
||||||
|
for feature, number in self.total.items()
|
||||||
|
]
|
||||||
|
)
|
||||||
|
+ """
|
||||||
|
<i><a href="https://zatups.sinya.ru/analytics">Сайт</a></i>
|
||||||
|
"""
|
||||||
)
|
)
|
||||||
|
|
|
||||||
113
models/bot.py
|
|
@ -2,11 +2,12 @@ import json
|
||||||
import random
|
import random
|
||||||
import telebot
|
import telebot
|
||||||
import datetime
|
import datetime
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
from telebot.types import ReplyKeyboardMarkup, InlineQueryResultArticle, InputTextMessageContent, Message, ReplyKeyboardRemove, WebAppInfo, KeyboardButton
|
from telebot.types import ReplyKeyboardMarkup, InlineQueryResultArticle, InputTextMessageContent, Message, ReplyKeyboardRemove, WebAppInfo, KeyboardButton
|
||||||
import config
|
|
||||||
from telebot import apihelper
|
from telebot import apihelper
|
||||||
|
import config
|
||||||
|
import utils.schedule_client as sc
|
||||||
|
|
||||||
|
if config.TELEGRAM_PROXY:
|
||||||
apihelper.proxy = {
|
apihelper.proxy = {
|
||||||
'http': config.TELEGRAM_PROXY,
|
'http': config.TELEGRAM_PROXY,
|
||||||
'https': config.TELEGRAM_PROXY
|
'https': config.TELEGRAM_PROXY
|
||||||
|
|
@ -61,19 +62,10 @@ class Strings:
|
||||||
# home
|
# home
|
||||||
map = 'Карта 🗺️'
|
map = 'Карта 🗺️'
|
||||||
schedule = 'Расписание 📅'
|
schedule = 'Расписание 📅'
|
||||||
schedule_emoji = '📅'
|
|
||||||
schedule_another_days = 'Другие дни 📁'
|
|
||||||
search = 'Поиск 🔎'
|
search = 'Поиск 🔎'
|
||||||
settings = '⚙️'
|
settings = 'Настройки ⚙️'
|
||||||
support = 'Поддержка 💞'
|
support = 'Поддержать 💞'
|
||||||
notifications = 'Уведомления ⏰'
|
|
||||||
current_schedule_name_F = '{} 🔄'
|
current_schedule_name_F = '{} 🔄'
|
||||||
groups = 'Группы'
|
|
||||||
rooms = 'Аудитории'
|
|
||||||
teachers = 'Преподаватели'
|
|
||||||
add_schedule = 'Добавить расписание 🔆'
|
|
||||||
delete_active_schedule_F = 'Удалить "{}" 🗑'
|
|
||||||
delete_other_schedules_F = 'Удалить все кроме "{}" 🗑'
|
|
||||||
|
|
||||||
# register
|
# register
|
||||||
register_not_found_T = 'Моего {} нету ⚠'
|
register_not_found_T = 'Моего {} нету ⚠'
|
||||||
|
|
@ -101,7 +93,7 @@ class Markup_v2:
|
||||||
markup.add(
|
markup.add(
|
||||||
KeyboardButton(
|
KeyboardButton(
|
||||||
text=Strings.map,
|
text=Strings.map,
|
||||||
web_app=WebAppInfo(f'{config.WEB_BASE_URL.rstrip("/")}/map/?v=15&mode=search')
|
web_app=WebAppInfo(f'{config.WEB_BASE_URL}/map/?v13')
|
||||||
),
|
),
|
||||||
Strings.schedule,
|
Strings.schedule,
|
||||||
row_width=2
|
row_width=2
|
||||||
|
|
@ -112,16 +104,12 @@ class Markup_v2:
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def schedule(is_added: bool, from_search: bool = False) -> ReplyKeyboardMarkup:
|
def schedule(is_added: bool, from_search: bool = False) -> ReplyKeyboardMarkup:
|
||||||
odd_even = datetime.datetime.now(ZoneInfo('Europe/Moscow')).isocalendar().week % 2 == 0
|
odd_even = datetime.datetime.now().isocalendar().week%2 == 0 #! ??????
|
||||||
markup = ReplyKeyboardMarkup(resize_keyboard=True)
|
markup = ReplyKeyboardMarkup(resize_keyboard=True)
|
||||||
markup.add(Strings.back_to_home, row_width=1) if not from_search else markup.add(Strings.back_to_search,Strings.back_to_home, row_width=2)
|
markup.add(Strings.back_to_home, row_width=1) if not from_search else markup.add(Strings.back_to_search,Strings.back_to_home, row_width=2)
|
||||||
markup.add(Strings.today, Strings.tomorrow, row_width=2)
|
markup.add(Strings.today, Strings.tomorrow, row_width=2)
|
||||||
markup.add(*Strings.weekdays, row_width=5)
|
markup.add(*Strings.weekdays, row_width=5)
|
||||||
markup.add(
|
markup.add(f'{"" if odd_even else "→"} {Strings.odd}', f'{"→" if odd_even else ""} {Strings.even}', row_width=2)
|
||||||
f'{"" if odd_even else "→ "}{Strings.odd}',
|
|
||||||
f'{"→ " if odd_even else ""}{Strings.even}',
|
|
||||||
row_width=2,
|
|
||||||
)
|
|
||||||
markup.add(Strings.delete if is_added else Strings.save, row_width=1)
|
markup.add(Strings.delete if is_added else Strings.save, row_width=1)
|
||||||
return markup
|
return markup
|
||||||
|
|
||||||
|
|
@ -137,59 +125,31 @@ class Markup_v1:
|
||||||
markup = ReplyKeyboardMarkup(resize_keyboard=True)
|
markup = ReplyKeyboardMarkup(resize_keyboard=True)
|
||||||
markup.add(
|
markup.add(
|
||||||
KeyboardButton(
|
KeyboardButton(
|
||||||
text=Strings.map,
|
text='Карта 🗺️',
|
||||||
web_app=WebAppInfo(f'{config.WEB_BASE_URL.rstrip("/")}/map/?v=15&mode=search')
|
web_app=WebAppInfo(f'{config.WEB_BASE_URL}/map/?v12')
|
||||||
),
|
), row_width=1
|
||||||
Strings.search,
|
|
||||||
row_width=2
|
|
||||||
)
|
)
|
||||||
markup.add(Strings.today, Strings.tomorrow, row_width=2)
|
markup.add('Сегодня 🔥', 'Завтра 🍀', row_width=2)
|
||||||
markup.add(Strings.schedule_another_days, Strings.current_schedule_name_F.format(user.group), row_width=2)
|
markup.add('На неделю 📅', 'Другие дни 📁', row_width=2)
|
||||||
markup.add(Strings.notifications, Strings.support, row_width=2)
|
# markup.add('Уведомления ⏰', 'Помощь 🆘', row_width=2)
|
||||||
|
markup.add('Уведомления ⏰', 'Поддержать 💞', f'{user.group} 🔄', row_width=3)
|
||||||
|
|
||||||
return markup
|
return markup
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def search_types() -> ReplyKeyboardMarkup:
|
def other() -> ReplyKeyboardMarkup:
|
||||||
markup = ReplyKeyboardMarkup(resize_keyboard=True)
|
markup = ReplyKeyboardMarkup(resize_keyboard=True)
|
||||||
markup.add(Strings.back_to_home, row_width=1)
|
markup.add('На главную 🏠', row_width=1)
|
||||||
markup.add(Strings.groups, row_width=1)
|
markup.add('ПН', 'ВТ', 'СР', 'ЧТ', 'ПТ', row_width=5)
|
||||||
markup.add(Strings.rooms, row_width=1)
|
markup.add('Нечетная 📅', 'Четная 📅', row_width=2)
|
||||||
markup.add(Strings.teachers, row_width=1)
|
# markup.add('ЧТ', 'ПТ', row_width=2)
|
||||||
return markup
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def search_results(results: list[str]) -> ReplyKeyboardMarkup:
|
|
||||||
markup = ReplyKeyboardMarkup(resize_keyboard=True)
|
|
||||||
markup.add(Strings.back_to_home, row_width=1)
|
|
||||||
for result in results[:10]:
|
|
||||||
markup.add(result, row_width=1)
|
|
||||||
return markup
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def schedule(
|
|
||||||
is_saved: bool, from_search: bool = False, include_today: bool = False
|
|
||||||
) -> ReplyKeyboardMarkup:
|
|
||||||
even_week = datetime.datetime.now(ZoneInfo('Europe/Moscow')).isocalendar().week % 2 == 0
|
|
||||||
markup = ReplyKeyboardMarkup(resize_keyboard=True)
|
|
||||||
markup.add(Strings.back_to_search if from_search else Strings.back_to_home, row_width=1)
|
|
||||||
if from_search:
|
|
||||||
markup.add(Strings.delete if is_saved else Strings.save, row_width=1)
|
|
||||||
if from_search or include_today:
|
|
||||||
markup.add(Strings.today, Strings.tomorrow, row_width=2)
|
|
||||||
markup.add(*Strings.weekdays, row_width=5)
|
|
||||||
markup.add(
|
|
||||||
f'{"" if even_week else "→ "}{Strings.odd}',
|
|
||||||
f'{"→ " if even_week else ""}{Strings.even}',
|
|
||||||
row_width=2,
|
|
||||||
)
|
|
||||||
if not from_search:
|
|
||||||
markup.add(Strings.delete if is_saved else Strings.save, row_width=1)
|
|
||||||
return markup
|
return markup
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def notifications() -> ReplyKeyboardMarkup:
|
def notifications() -> ReplyKeyboardMarkup:
|
||||||
markup = ReplyKeyboardMarkup(resize_keyboard=True)
|
markup = ReplyKeyboardMarkup(resize_keyboard=True)
|
||||||
markup.add(Strings.back_to_home, row_width=1)
|
markup.add('На главную 🏠', row_width=1)
|
||||||
markup.add('Перед парой ⌛', row_width=1)
|
markup.add('Перед парой ⌛', row_width=1)
|
||||||
markup.add('Вечером 🌆', row_width=1)
|
markup.add('Вечером 🌆', row_width=1)
|
||||||
markup.add('Файлом на неделю 📅', row_width=1)
|
markup.add('Файлом на неделю 📅', row_width=1)
|
||||||
|
|
@ -197,29 +157,28 @@ class Markup_v1:
|
||||||
return markup
|
return markup
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def saved_schedules(u: user.User) -> ReplyKeyboardMarkup:
|
def saved_groups(u: user.User) -> ReplyKeyboardMarkup:
|
||||||
markup = ReplyKeyboardMarkup(resize_keyboard=True)
|
markup = ReplyKeyboardMarkup(resize_keyboard=True)
|
||||||
markup.add(Strings.back_to_home, row_width=1)
|
markup.add('На главную 🏠', row_width=1)
|
||||||
markup.add(*(target['name'] for target in u.saved_schedules), row_width=4)
|
markup.add(*u.saved_groups, row_width=4)
|
||||||
markup.add(Strings.add_schedule, row_width=1)
|
markup.add('Добавить группу 🔆', 'Удалить группы 🗑', row_width=2)
|
||||||
if u.active_target:
|
|
||||||
markup.add(Strings.delete_active_schedule_F.format(u.group), row_width=1)
|
|
||||||
markup.add(Strings.delete_other_schedules_F.format(u.group), row_width=1)
|
|
||||||
return markup
|
|
||||||
|
|
||||||
saved_groups = saved_schedules
|
return markup
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def selectGroup(groups: list[str]) -> ReplyKeyboardMarkup:
|
def selectGroup(query: str) -> ReplyKeyboardMarkup:
|
||||||
|
#FIXME
|
||||||
|
schedule = sc.ScheduleDB(api_url=config.SCHEDULE_BASE_URL)
|
||||||
|
groupList = schedule.query(sc.Group).filter_by(name=query)
|
||||||
|
|
||||||
markup = ReplyKeyboardMarkup(resize_keyboard=True)
|
markup = ReplyKeyboardMarkup(resize_keyboard=True)
|
||||||
for group in groups:
|
for group in groupList:
|
||||||
markup.add(group, row_width=1)
|
markup.add(group, row_width=1)
|
||||||
markup.add('Моей группы нету ⚠', row_width=1)
|
|
||||||
|
markup.add('Моей группы нет ⚠', row_width=1)
|
||||||
|
|
||||||
return markup
|
return markup
|
||||||
|
|
||||||
|
|
||||||
Markup = Markup_v1
|
|
||||||
|
|
||||||
def Receive(state: str, text: str | None = None, strict: bool = True):
|
def Receive(state: str, text: str | None = None, strict: bool = True):
|
||||||
return bot.message_handler(
|
return bot.message_handler(
|
||||||
content_types = ['text'],
|
content_types = ['text'],
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1,363 +1,145 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import datetime
|
|
||||||
import difflib
|
|
||||||
import html
|
|
||||||
import json
|
import json
|
||||||
import re
|
import datetime
|
||||||
import threading
|
import config as _config
|
||||||
import time
|
|
||||||
from typing import Any, Literal, TypedDict
|
|
||||||
from urllib.error import HTTPError, URLError
|
|
||||||
from urllib.parse import quote
|
|
||||||
from urllib.request import Request, urlopen
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
import config
|
|
||||||
from utils import convert
|
|
||||||
|
|
||||||
|
|
||||||
TargetKind = Literal["group", "room", "teacher"]
|
|
||||||
|
|
||||||
|
|
||||||
class ScheduleTarget(TypedDict):
|
|
||||||
kind: TargetKind
|
|
||||||
id: str
|
|
||||||
name: str
|
|
||||||
|
|
||||||
|
|
||||||
dayStrings = [
|
|
||||||
"Понедельник",
|
|
||||||
"Вторник",
|
|
||||||
"Среда",
|
|
||||||
"Четверг",
|
|
||||||
"Пятница",
|
|
||||||
"Суббота",
|
|
||||||
"Воскресенье",
|
|
||||||
]
|
|
||||||
|
|
||||||
MOSCOW = ZoneInfo("Europe/Moscow")
|
|
||||||
_CACHE_TTL = 300.0
|
|
||||||
_REQUEST_TIMEOUT = 10.0
|
|
||||||
_FAILURE_RETRY_DELAY = 30.0
|
|
||||||
_cache: dict[str, tuple[float, list[dict[str, Any]]]] = {}
|
|
||||||
_failures: dict[str, tuple[float, str]] = {}
|
|
||||||
_cache_lock = threading.RLock()
|
|
||||||
|
|
||||||
_ENDPOINTS: dict[TargetKind, str] = {
|
|
||||||
"group": "groups",
|
|
||||||
"room": "rooms",
|
|
||||||
"teacher": "teachers",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class ScheduleAPIError(RuntimeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _api_url(path: str) -> str:
|
|
||||||
return f"{config.SCHEDULE_BASE_URL.rstrip('/')}/{path.lstrip('/')}"
|
|
||||||
|
|
||||||
|
|
||||||
def _load_result(path: str) -> list[dict[str, Any]]:
|
|
||||||
now = time.monotonic()
|
|
||||||
with _cache_lock:
|
|
||||||
cached = _cache.get(path)
|
|
||||||
if cached and now - cached[0] < _CACHE_TTL:
|
|
||||||
return cached[1]
|
|
||||||
failure = _failures.get(path)
|
|
||||||
if failure and now < failure[0]:
|
|
||||||
if cached:
|
|
||||||
return cached[1]
|
|
||||||
raise ScheduleAPIError(failure[1])
|
|
||||||
|
|
||||||
request = Request(_api_url(path), headers={"Accept": "application/json"})
|
|
||||||
try:
|
|
||||||
with urlopen(request, timeout=_REQUEST_TIMEOUT) as response:
|
|
||||||
payload = json.load(response)
|
|
||||||
except (HTTPError, URLError, TimeoutError, OSError, ValueError) as error:
|
|
||||||
message = f"Не удалось получить {path}: {error}"
|
|
||||||
with _cache_lock:
|
|
||||||
_failures[path] = (now + _FAILURE_RETRY_DELAY, message)
|
|
||||||
if cached:
|
|
||||||
return cached[1]
|
|
||||||
raise ScheduleAPIError(message) from error
|
|
||||||
|
|
||||||
if payload.get("ok") is not True or not isinstance(payload.get("result"), list):
|
|
||||||
if cached:
|
|
||||||
return cached[1]
|
|
||||||
raise ScheduleAPIError(f"Сервер вернул некорректный ответ для {path}")
|
|
||||||
|
|
||||||
result = payload["result"]
|
|
||||||
with _cache_lock:
|
|
||||||
_cache[path] = (now, result)
|
|
||||||
_failures.pop(path, None)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def clear_schedule_cache() -> None:
|
|
||||||
with _cache_lock:
|
|
||||||
_cache.clear()
|
|
||||||
_failures.clear()
|
|
||||||
|
|
||||||
|
|
||||||
def target_key(target: ScheduleTarget) -> str:
|
|
||||||
return f"{target['kind']}:{target['id']}"
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_target(value: Any) -> ScheduleTarget | None:
|
|
||||||
if not isinstance(value, dict):
|
|
||||||
return None
|
|
||||||
kind = value.get("kind")
|
|
||||||
target_id = value.get("id")
|
|
||||||
name = value.get("name")
|
|
||||||
if kind not in _ENDPOINTS or not all(isinstance(item, str) and item for item in (target_id, name)):
|
|
||||||
return None
|
|
||||||
return {"kind": kind, "id": target_id, "name": name}
|
|
||||||
|
|
||||||
|
|
||||||
def group_target(name: str) -> ScheduleTarget:
|
|
||||||
return {"kind": "group", "id": name, "name": name}
|
|
||||||
|
|
||||||
|
|
||||||
def get_main_groups() -> list[dict[str, Any]]:
|
|
||||||
groups = _load_result("groups")
|
|
||||||
if not groups:
|
|
||||||
raise ScheduleAPIError("Сервер расписания пока не содержит групп")
|
|
||||||
return sorted(
|
|
||||||
(group for group in groups if group.get("parent_group_id") is None),
|
|
||||||
key=lambda group: str(group.get("name", "")).casefold(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _compact(value: str) -> str:
|
|
||||||
return re.sub(r"[^0-9a-zа-яё]", "", value.casefold())
|
|
||||||
|
|
||||||
|
|
||||||
def _search_records(records: list[dict[str, Any]], query: str, limit: int) -> list[dict[str, Any]]:
|
|
||||||
compact_query = _compact(query)
|
|
||||||
if not compact_query:
|
|
||||||
return []
|
|
||||||
|
|
||||||
exact = [record for record in records if _compact(str(record.get("name", ""))) == compact_query]
|
|
||||||
if exact:
|
|
||||||
return exact[:limit]
|
|
||||||
|
|
||||||
def score(record: dict[str, Any]) -> tuple[float, str]:
|
|
||||||
name = str(record.get("name", ""))
|
|
||||||
compact_name = _compact(name)
|
|
||||||
similarity = difflib.SequenceMatcher(None, compact_query, compact_name).ratio()
|
|
||||||
return similarity, name.casefold()
|
|
||||||
|
|
||||||
contains = [
|
|
||||||
record for record in records if compact_query in _compact(str(record.get("name", "")))
|
|
||||||
]
|
|
||||||
if contains:
|
|
||||||
return sorted(contains, key=score, reverse=True)[:limit]
|
|
||||||
|
|
||||||
ranked = sorted(records, key=score, reverse=True)
|
|
||||||
return [record for record in ranked if score(record)[0] >= 0.45][:limit]
|
|
||||||
|
|
||||||
|
|
||||||
def search_targets(kind: TargetKind, query: str, limit: int = 10) -> list[ScheduleTarget]:
|
|
||||||
records = _load_result(_ENDPOINTS[kind])
|
|
||||||
if kind == "group":
|
|
||||||
records = [record for record in records if record.get("parent_group_id") is None]
|
|
||||||
return [
|
|
||||||
{"kind": kind, "id": str(record["id"]), "name": str(record["name"])}
|
|
||||||
for record in _search_records(records, query.strip(), limit)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def find_main_groups(query: str, limit: int = 8) -> list[str]:
|
|
||||||
return [target["name"] for target in search_targets("group", query, limit)]
|
|
||||||
|
|
||||||
|
|
||||||
def group_exists(group_name: str) -> bool:
|
|
||||||
target = _compact(group_name)
|
|
||||||
return any(_compact(str(group["name"])) == target for group in get_main_groups())
|
|
||||||
|
|
||||||
|
|
||||||
def _minutes_to_time(value: int | None) -> list[int]:
|
|
||||||
if value is None:
|
|
||||||
return [0, 0]
|
|
||||||
return [value // 60, value % 60]
|
|
||||||
|
|
||||||
|
dayStrings = ['Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота', 'Воскресенье']
|
||||||
|
|
||||||
class Lesson:
|
class Lesson:
|
||||||
def __init__(
|
#FIXME: фулл переделать потому-что формат поменялся сильно
|
||||||
self,
|
def __init__(self, start_end: str, data: dict) -> None:
|
||||||
data: dict[str, Any],
|
self.start: list[int] = [
|
||||||
target_kind: TargetKind,
|
int(start_end.split(' - ')[0].split(':')[0]),
|
||||||
group_names: list[str],
|
int(start_end.split(' - ')[0].split(':')[1])
|
||||||
) -> None:
|
]
|
||||||
self.start = _minutes_to_time(data.get("time_start"))
|
|
||||||
self.end = _minutes_to_time(data.get("time_end"))
|
self.end: list[int] = [
|
||||||
self.name = str(data.get("name") or "Без названия")
|
int(start_end.split(' - ')[1].split(':')[0]),
|
||||||
self.type = str(data.get("type") or "пара")
|
int(start_end.split(' - ')[1].split(':')[1])
|
||||||
self.teacher_ids = [str(value) for value in data.get("teacher_ids", [])]
|
]
|
||||||
self.room_ids = [str(value) for value in data.get("room_ids", [])]
|
self.strTime: str = start_end
|
||||||
self.target_kind = target_kind
|
|
||||||
self.group_names = group_names
|
self.raw: str = data['raw']
|
||||||
self.info = self.teacher_ids + [f"ауд. {room}" for room in self.room_ids]
|
self.ok: bool = data['ok']
|
||||||
self.is_odd_week = bool(data.get("is_odd_week"))
|
if not self.ok: return
|
||||||
self.is_even_week = bool(data.get("is_even_week"))
|
|
||||||
|
self.name: str = data['name']
|
||||||
|
self.type: str = data['type']
|
||||||
|
self.info: list[str] = data['info']
|
||||||
|
|
||||||
def text(self, isToday: bool = False) -> str:
|
def text(self, isToday: bool = False) -> str:
|
||||||
now = datetime.datetime.now(MOSCOW)
|
date = datetime.datetime.now()
|
||||||
lesson_start = now.replace(hour=self.start[0], minute=self.start[1], second=0, microsecond=0)
|
lessonStart = datetime.datetime(date.year, date.month, date.day, self.start[0], self.start[1], 10)
|
||||||
lesson_end = now.replace(hour=self.end[0], minute=self.end[1], second=0, microsecond=0)
|
|
||||||
lesson_type = html.escape(self.type)
|
|
||||||
date_text = f"{lesson_type.capitalize()} <i>в {self.start[0]}:{self.start[1]:02d}</i>"
|
|
||||||
|
|
||||||
if isToday and lesson_start > now:
|
|
||||||
minutes = int((lesson_start - now).total_seconds() // 60)
|
|
||||||
if minutes < 30:
|
|
||||||
date_text = f"{lesson_type.capitalize()} <i>начнется через {max(minutes, 1)} мин.</i>"
|
|
||||||
elif isToday and lesson_start <= now < lesson_end:
|
|
||||||
minutes = int((lesson_end - now).total_seconds() // 60)
|
|
||||||
date_text = f"<i>Сейчас идет</i> {lesson_type}, закончится через {max(minutes, 1)} мин."
|
|
||||||
|
|
||||||
def generate_url(kind, target):
|
|
||||||
return f'<a href="{config.TELEGRAM_URL}?start=_s{kind[:1]}{convert.encode(html.escape(target))}">{html.escape(target)}</a>'
|
|
||||||
|
|
||||||
teachers = [
|
if self.ok:
|
||||||
generate_url("teacher", teacher)
|
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>'
|
||||||
for teacher in self.teacher_ids
|
if isToday:
|
||||||
]
|
if lessonStart > date and (lessonStart-date).seconds < 30*60:
|
||||||
rooms = [
|
dateStr = f'{self.type.capitalize()} <i>начнется через {max((lessonStart-date).seconds//60, 1)} мин.</i>'
|
||||||
generate_url("room", room)
|
elif lessonStart < date and (date-lessonStart).seconds < 31*60:
|
||||||
for room in self.room_ids
|
dateStr = f'<i>Сейчас идет</i> {self.type}'
|
||||||
]
|
elif lessonStart < date and (date-lessonStart).seconds >= 31*60 and (date-lessonStart).seconds < 89*60:
|
||||||
groups = [
|
dateStr = f'{self.type.capitalize()} <i>закончится через {max(90-(date-lessonStart).seconds//60, 1)} мин.</i>'
|
||||||
generate_url("group", group)
|
wrb = ''
|
||||||
for group in self.group_names
|
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')
|
||||||
|
|
||||||
# FIXME: Не отображаются подгруппы
|
else: nd.append(data)
|
||||||
|
|
||||||
# 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 "Дополнительная информация отсутствует"
|
for data in nd:
|
||||||
return f"<blockquote><b>{html.escape(self.name)}</b>\n{date_text}\n{info}</blockquote>"
|
# print()
|
||||||
|
# print(data, wrb)
|
||||||
|
if ('\n' in data or '\n' in wrb[-2:]):
|
||||||
|
wrb += data
|
||||||
|
elif 'ауд' in data.lower():
|
||||||
|
audn = '.'.join(data.replace(' ', '').split('.')[1:])
|
||||||
|
wrb += ', '+f'<a href="https://rasp.pgups.ru/schedule/room?room={audn}">{data}</a>'
|
||||||
|
else:
|
||||||
|
wrb += ', '+data
|
||||||
|
|
||||||
|
|
||||||
|
return f'''\
|
||||||
|
<blockquote>\
|
||||||
|
<b>{self.name}</b>#FIXME
|
||||||
|
{dateStr}
|
||||||
|
{wrb[2:] if wrb else 'Дополнительная информация отсуствует'}</blockquote>'''
|
||||||
|
|
||||||
|
else:
|
||||||
|
return f'''\
|
||||||
|
<pre language="Error">\
|
||||||
|
Пара в {self.start[0]}:{self.start[1] if self.start[1] > 9 else '0'+str(self.start[1])}
|
||||||
|
{self.raw}</pre>'''
|
||||||
|
|
||||||
|
|
||||||
class Schedule:
|
class Schedule:
|
||||||
def __init__(
|
def __init__(self, responce: dict) -> None:
|
||||||
self,
|
self.weekNumber: int = responce['date']['weekNumber']
|
||||||
lessons: list[dict[str, Any]],
|
self.weekDay: int = responce['date']['weekDay']
|
||||||
target: ScheduleTarget,
|
|
||||||
groups: list[dict[str, Any]],
|
|
||||||
now: datetime.datetime | None = None,
|
|
||||||
) -> None:
|
|
||||||
current = now or datetime.datetime.now(MOSCOW)
|
|
||||||
self.weekNumber = current.isocalendar().week
|
|
||||||
self.weekDay = current.weekday()
|
|
||||||
self.days: list[list[Lesson]] = [[] for _ in range(5)]
|
|
||||||
|
|
||||||
groups_by_id = {
|
self.raw: dict = responce['schedule']
|
||||||
str(group["id"]): str(group["name"])
|
self.days: list[list[list[Lesson]]] = []
|
||||||
for group in groups
|
|
||||||
}
|
|
||||||
target_ids = {target["id"]}
|
|
||||||
child_names: dict[str, str] = {}
|
|
||||||
field = {"group": "group_ids", "room": "room_ids", "teacher": "teacher_ids"}[target["kind"]]
|
|
||||||
if target["kind"] == "group":
|
|
||||||
child_names = {
|
|
||||||
str(group["id"]): str(group["name"])
|
|
||||||
for group in groups
|
|
||||||
if group.get("parent_group_id") == target["id"]
|
|
||||||
}
|
|
||||||
target_ids.update(child_names)
|
|
||||||
|
|
||||||
for raw_lesson in lessons:
|
for dayInfo in self.raw.values():
|
||||||
weekday = raw_lesson.get("weekday")
|
currentDay = []
|
||||||
if not isinstance(weekday, int) or weekday not in range(1, 6):
|
for start_end, lessons in dayInfo.items():
|
||||||
|
if lessons == [None] or lessons == [None, None]:
|
||||||
continue
|
continue
|
||||||
matched_ids = target_ids.intersection(map(str, raw_lesson.get(field, [])))
|
|
||||||
if not matched_ids:
|
lessonList: list[Lesson | None] = []
|
||||||
continue
|
for lesson in lessons:
|
||||||
if target["kind"] == "group":
|
if lesson: lessonList.append(Lesson(start_end, lesson))
|
||||||
group_names = sorted(child_names[item] for item in matched_ids if item in child_names)
|
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'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
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(' ', '_')
|
||||||
|
|
||||||
|
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:
|
else:
|
||||||
group_names = sorted(
|
lesson = lessonList[1 if isWeekOdd else 0]
|
||||||
{
|
if lesson:
|
||||||
groups_by_id.get(str(group_id), str(group_id))
|
scheduleResult += lesson.text(isToday) + '\n'
|
||||||
for group_id in raw_lesson.get("group_ids", [])
|
lastLessonEnds = lesson.end
|
||||||
}
|
|
||||||
)
|
|
||||||
self.days[weekday - 1].append(Lesson(raw_lesson, target["kind"], group_names))
|
|
||||||
|
|
||||||
for day in self.days:
|
scheduleResult += f'Пары закончатся в <i>{lastLessonEnds[0]}:{lastLessonEnds[1] if lastLessonEnds[1] > 9 else "0"+str(lastLessonEnds[1])}</i>'
|
||||||
day.sort(key=lambda lesson: (lesson.start, lesson.name.casefold()))
|
return scheduleResult
|
||||||
|
|
||||||
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 "Пар на этой неделе нет 🥰"
|
|
||||||
168
models/user.py
|
|
@ -1,147 +1,96 @@
|
||||||
|
import datetime
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from typing import Any, Iterator
|
|
||||||
|
|
||||||
from telebot.types import CallbackQuery, InlineQuery, Message
|
from telebot.types import CallbackQuery, InlineQuery, Message, ReplyKeyboardRemove
|
||||||
|
|
||||||
from models import logger
|
from models import logger
|
||||||
|
|
||||||
from .schedule import (
|
from .schedule import Schedule, getSchedulePathByGroup
|
||||||
Schedule,
|
|
||||||
ScheduleAPIError,
|
|
||||||
ScheduleTarget,
|
|
||||||
get_schedule,
|
|
||||||
group_target,
|
|
||||||
normalize_target,
|
|
||||||
target_key,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class User:
|
class User:
|
||||||
def __init__(self, userID: int) -> None:
|
def __init__(self, userID: int) -> None:
|
||||||
self.id = userID
|
self.id: int = userID
|
||||||
try:
|
try:
|
||||||
with open(f"./users/{self.id}.json", "r", encoding="utf-8") as file:
|
self.data: dict[str, str] = json.load(
|
||||||
self.data: dict[str, Any] = json.load(file)
|
open(f"./users/{self.id}.json", "r", encoding="utf-8")
|
||||||
except FileNotFoundError:
|
)
|
||||||
self.data = {}
|
except:
|
||||||
except (json.JSONDecodeError, OSError):
|
|
||||||
logger.error("Bot", f'INVALID "{userID}" SAVED DATA! RESET!')
|
logger.error("Bot", f'INVALID "{userID}" SAVED DATA! RESET!')
|
||||||
self.data = {}
|
self.data = {}
|
||||||
|
|
||||||
self.state: str | bool | None = self.data.get("state")
|
self.state: str | bool | None = self.data.get("state", None)
|
||||||
legacy_group = self.data.get("group")
|
|
||||||
self.active_target = normalize_target(self.data.get("active_schedule"))
|
|
||||||
if self.active_target is None and isinstance(legacy_group, str) and legacy_group:
|
|
||||||
self.active_target = group_target(legacy_group)
|
|
||||||
|
|
||||||
saved = [normalize_target(value) for value in self.data.get("saved_schedules", [])]
|
# self.topic: str | None = self.data.get("topic", None)
|
||||||
self.saved_schedules: list[ScheduleTarget] = [value for value in saved if value]
|
# self.course: str | None = self.data.get("course", None)
|
||||||
for name in self.data.get("saved_groups", []):
|
self.group: str | None = self.data.get("group", None)
|
||||||
if isinstance(name, str) and name:
|
self.notifications: list[str] = self.data.get("notifications", []) # type: ignore
|
||||||
self._append_saved(group_target(name))
|
self.valid: bool = self.data.get("valid", True)
|
||||||
if self.active_target:
|
self.saved_groups: list[str] = self.data.get("saved_groups", [])
|
||||||
self._append_saved(self.active_target)
|
self.saved_groups.append(self.group)
|
||||||
|
self.saved_groups = [t if "-" in t else "{null}" for t in self.saved_groups]
|
||||||
|
self.saved_groups = list(set(self.saved_groups))
|
||||||
|
try: self.saved_groups.remove("{null}")
|
||||||
|
except: ...
|
||||||
|
|
||||||
self.view_target = normalize_target(self.data.get("view_schedule"))
|
self.user: dict = self.data.get("user", {})
|
||||||
self.search_kind = self.data.get("search_kind")
|
self.usage: dict = self.data.get(
|
||||||
results = [normalize_target(value) for value in self.data.get("search_results", [])]
|
|
||||||
self.search_results: list[ScheduleTarget] = [value for value in results if value]
|
|
||||||
|
|
||||||
self.notifications: list[str] = self.data.get("notifications", [])
|
|
||||||
self.valid = bool(self.data.get("valid", True))
|
|
||||||
self.user: dict[str, Any] = self.data.get("user", {})
|
|
||||||
self.usage: dict[str, Any] = self.data.get(
|
|
||||||
"usage", {"count": -1, "date": None, "time": None}
|
"usage", {"count": -1, "date": None, "time": None}
|
||||||
)
|
)
|
||||||
self.flags: list[str] = self.data.get("flags", [])
|
self.flags: list[str] = self.data.get('flags', [])
|
||||||
|
|
||||||
if self.data.get("notifyEvening") and "evening" not in self.notifications:
|
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")
|
self.notifications.append("evening")
|
||||||
if self.data.get("notifyBeforeLesson") and "lesson" not in self.notifications:
|
if self.data["notifyBeforeLesson"] and "lesson" not in self.notifications:
|
||||||
self.notifications.append("lesson")
|
self.notifications.append("lesson")
|
||||||
|
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
@property
|
def schedule(self) -> Schedule | None:
|
||||||
def group(self) -> str | None:
|
date = datetime.datetime.now()
|
||||||
return self.active_target["name"] if self.active_target else None
|
#FIXME
|
||||||
|
path = getSchedulePathByGroup(self.group)
|
||||||
|
if not path:
|
||||||
|
self.valid = False
|
||||||
|
self.notifications = []
|
||||||
|
self.setState("broken")
|
||||||
|
self.save()
|
||||||
|
return None
|
||||||
|
|
||||||
@group.setter
|
return Schedule(
|
||||||
def group(self, name: str | None) -> None:
|
{
|
||||||
self.active_target = group_target(name) if name else None
|
"date": {
|
||||||
|
"weekNumber": date.isocalendar().week,
|
||||||
@property
|
"weekDay": date.weekday(),
|
||||||
def saved_groups(self) -> list[str]:
|
},
|
||||||
return [target["name"] for target in self.saved_schedules if target["kind"] == "group"]
|
"schedule": json.load(open(path, "r", encoding="utf-8"))[self.group],
|
||||||
|
}
|
||||||
@saved_groups.setter
|
|
||||||
def saved_groups(self, names: list[str]) -> None:
|
|
||||||
non_groups = [target for target in self.saved_schedules if target["kind"] != "group"]
|
|
||||||
self.saved_schedules = non_groups + [group_target(name) for name in names if name]
|
|
||||||
|
|
||||||
def _append_saved(self, target: ScheduleTarget) -> None:
|
|
||||||
if not any(target_key(value) == target_key(target) for value in self.saved_schedules):
|
|
||||||
self.saved_schedules.append(target)
|
|
||||||
|
|
||||||
def is_saved(self, target: ScheduleTarget | None) -> bool:
|
|
||||||
return bool(
|
|
||||||
target
|
|
||||||
and any(target_key(value) == target_key(target) for value in self.saved_schedules)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def save_schedule(self, target: ScheduleTarget, make_active: bool = True) -> None:
|
|
||||||
self._append_saved(target)
|
|
||||||
if make_active:
|
|
||||||
self.active_target = target
|
|
||||||
self.save()
|
|
||||||
|
|
||||||
def remove_schedule(self, target: ScheduleTarget) -> None:
|
|
||||||
key = target_key(target)
|
|
||||||
self.saved_schedules = [value for value in self.saved_schedules if target_key(value) != key]
|
|
||||||
if self.active_target and target_key(self.active_target) == key:
|
|
||||||
self.active_target = self.saved_schedules[-1] if self.saved_schedules else None
|
|
||||||
self.save()
|
|
||||||
|
|
||||||
def keep_only_active(self) -> None:
|
|
||||||
self.saved_schedules = [self.active_target] if self.active_target else []
|
|
||||||
self.save()
|
|
||||||
|
|
||||||
def find_saved(self, name: str) -> ScheduleTarget | None:
|
|
||||||
return next((target for target in self.saved_schedules if target["name"] == name), None)
|
|
||||||
|
|
||||||
def schedule(self, target: ScheduleTarget | None = None) -> Schedule | None:
|
|
||||||
selected = target or self.active_target
|
|
||||||
if not selected:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return get_schedule(selected)
|
|
||||||
except (ValueError, ScheduleAPIError) as error:
|
|
||||||
logger.error("Schedule", f"[{self.id}] {error}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def setState(self, newState: str) -> None:
|
def setState(self, newState: str) -> None:
|
||||||
self.state = newState
|
self.state = newState
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
def save(self) -> None:
|
def save(self) -> None:
|
||||||
with open(f"./users/{self.id}.json", "w", encoding="utf-8") as file:
|
return json.dump(
|
||||||
json.dump(
|
|
||||||
{
|
{
|
||||||
"state": self.state,
|
"state": self.state,
|
||||||
|
"topic": self.topic,
|
||||||
|
"course": self.course,
|
||||||
"group": self.group,
|
"group": self.group,
|
||||||
"active_schedule": self.active_target,
|
|
||||||
"saved_groups": self.saved_groups,
|
|
||||||
"saved_schedules": self.saved_schedules,
|
|
||||||
"view_schedule": self.view_target,
|
|
||||||
"search_kind": self.search_kind,
|
|
||||||
"search_results": self.search_results,
|
|
||||||
"notifications": self.notifications,
|
"notifications": self.notifications,
|
||||||
"valid": self.valid,
|
"valid": self.valid,
|
||||||
|
"saved_groups": self.saved_groups,
|
||||||
"user": self.user,
|
"user": self.user,
|
||||||
"usage": self.usage,
|
"usage": self.usage,
|
||||||
"flags": self.flags,
|
"flags": self.flags,
|
||||||
},
|
},
|
||||||
file,
|
open(f"./users/{self.id}.json", "w", encoding="utf-8"),
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
indent=4,
|
indent=4,
|
||||||
)
|
)
|
||||||
|
|
@ -150,13 +99,14 @@ class User:
|
||||||
def validate(ctx: Message | InlineQuery | CallbackQuery) -> User:
|
def validate(ctx: Message | InlineQuery | CallbackQuery) -> User:
|
||||||
try:
|
try:
|
||||||
return User(ctx.from_user.id) # type: ignore
|
return User(ctx.from_user.id) # type: ignore
|
||||||
except Exception as error:
|
except Exception as e:
|
||||||
logger.error("Bot", f'INVALID "{ctx.from_user.id}" VALIDATION! {error}')
|
logger.error("Bot", f'INVALID "{ctx.from_user.id}" VALIDATION! {e}')
|
||||||
return False # type: ignore
|
return False # type: ignore
|
||||||
|
|
||||||
|
|
||||||
def getAllUsers() -> Iterator[User]:
|
def getAllUsers() -> list[User]: # type: ignore
|
||||||
for _, _, filenames in os.walk("./users"):
|
for _, _, filenames in os.walk("./users"):
|
||||||
for userFile in filenames:
|
for userFile in filenames:
|
||||||
if userFile.endswith(".json"):
|
if ".json" not in userFile:
|
||||||
yield User(int(userFile.removesuffix(".json")))
|
continue
|
||||||
|
yield User(int(userFile.replace(".json", ""))) # type: ignore
|
||||||
|
|
|
||||||
0
modules/__init__.py
Normal file
0
modules/ics_generator/bot.py
Normal file
0
modules/ics_generator/event.py
Normal file
|
|
@ -5,11 +5,9 @@ description = "Add your description here"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"colorama>=0.4.6",
|
|
||||||
"ics>=0.7.3",
|
"ics>=0.7.3",
|
||||||
"pytelegrambotapi>=4.36.1",
|
"pytelegrambotapi>=4.36.1",
|
||||||
"python-dotenv>=1.2.3",
|
"python-dotenv>=1.2.3",
|
||||||
|
"python-socks>=3.0.0",
|
||||||
"pytz>=2026.3.post1",
|
"pytz>=2026.3.post1",
|
||||||
"requests[socks]>=2.34.2",
|
|
||||||
"rich>=15.0.0",
|
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,10 @@
|
||||||
"text": "<b>Ищу разработчиков</b>\nНужны ребята для решения проблем с парсером в этом боте. Бюджет не больше банки пива\nСтек: py3(base), openpyxl, json, pyTelegramBotApi\n\n<b>Материальная поддержка</b>\nДля работы бота нужно поддерживать работу сервера, а это к сожалению платно(\nБуду рад любой копеечке, спасибо!",
|
"text": "<b>Ищу разработчиков</b>\nНужны ребята для решения проблем с парсером в этом боте. Бюджет не больше банки пива\nСтек: py3(base), openpyxl, json, pyTelegramBotApi\n\n<b>Материальная поддержка</b>\nДля работы бота нужно поддерживать работу сервера, а это к сожалению платно(\nБуду рад любой копеечке, спасибо!",
|
||||||
"quick_markup": {
|
"quick_markup": {
|
||||||
"Связь с разработчиком 💻": {
|
"Связь с разработчиком 💻": {
|
||||||
"url": "http://t.me/zatups?direct"
|
"url": "https://t.me/mwamev"
|
||||||
},
|
},
|
||||||
"Исходный код бота 💫" : {
|
"Исходный код парсера 💫" : {
|
||||||
"url": "https://git.snw.su/zatups/bot"
|
"url": "https://github.com/ktnk-dev/ZatupsParser"
|
||||||
},
|
},
|
||||||
"Скинуть на сервера и сигареты 💕": {
|
"Скинуть на сервера и сигареты 💕": {
|
||||||
"url": "https://www.tinkoff.ru/rm/r_SPKHLWrUDg.iHXMyujyfd/c1IGc13418"
|
"url": "https://www.tinkoff.ru/rm/r_SPKHLWrUDg.iHXMyujyfd/c1IGc13418"
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ class ICS:
|
||||||
else: # on this week
|
else: # on this week
|
||||||
self.start_week_date = timezone.localize(datetime.now()) - timedelta(days=schedule.weekDay)
|
self.start_week_date = timezone.localize(datetime.now()) - timedelta(days=schedule.weekDay)
|
||||||
|
|
||||||
|
self.isWeekOdd = schedule.weekNumber%2 == 0
|
||||||
self.calendar = Calendar()
|
self.calendar = Calendar()
|
||||||
|
|
||||||
for target in range(5):
|
for target in range(5):
|
||||||
|
|
@ -27,7 +28,10 @@ class ICS:
|
||||||
event_date = self.start_week_date + timedelta(days=target)
|
event_date = self.start_week_date + timedelta(days=target)
|
||||||
# print(f'\n\n--- {target} -> {event_date.strftime("%Y-%m-%d")} ---')
|
# print(f'\n\n--- {target} -> {event_date.strftime("%Y-%m-%d")} ---')
|
||||||
|
|
||||||
for lesson in schedule.today():
|
for lessonList in schedule.today():
|
||||||
|
lesson = lessonList[0] if len(lessonList) == 1 else lessonList[1 if self.isWeekOdd else 0]
|
||||||
|
if not lesson: continue
|
||||||
|
|
||||||
event = Event()
|
event = Event()
|
||||||
|
|
||||||
event.name = f'{lesson.name}'
|
event.name = f'{lesson.name}'
|
||||||
|
|
@ -55,3 +59,4 @@ class ICS:
|
||||||
path = f'./temp/{self.userID}.ics'
|
path = f'./temp/{self.userID}.ics'
|
||||||
with open(path, 'w', encoding='utf-8', newline='') as file: file.writelines(content)
|
with open(path, 'w', encoding='utf-8', newline='') as file: file.writelines(content)
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
@ -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
|
|
@ -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
|
|
@ -151,24 +151,20 @@ name = "bot-legacy"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "colorama" },
|
|
||||||
{ name = "ics" },
|
{ name = "ics" },
|
||||||
{ name = "pytelegrambotapi" },
|
{ name = "pytelegrambotapi" },
|
||||||
{ name = "python-dotenv" },
|
{ name = "python-dotenv" },
|
||||||
|
{ name = "python-socks" },
|
||||||
{ name = "pytz" },
|
{ name = "pytz" },
|
||||||
{ name = "requests", extra = ["socks"] },
|
|
||||||
{ name = "rich" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "colorama", specifier = ">=0.4.6" },
|
|
||||||
{ name = "ics", specifier = ">=0.7.3" },
|
{ name = "ics", specifier = ">=0.7.3" },
|
||||||
{ name = "pytelegrambotapi", specifier = ">=4.36.1" },
|
{ name = "pytelegrambotapi", specifier = ">=4.36.1" },
|
||||||
{ name = "python-dotenv", specifier = ">=1.2.3" },
|
{ name = "python-dotenv", specifier = ">=1.2.3" },
|
||||||
|
{ name = "python-socks", specifier = ">=3.0.0" },
|
||||||
{ name = "pytz", specifier = ">=2026.3.post1" },
|
{ name = "pytz", specifier = ">=2026.3.post1" },
|
||||||
{ name = "requests", extras = ["socks"], specifier = ">=2.34.2" },
|
|
||||||
{ name = "rich", specifier = ">=15.0.0" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -311,15 +307,6 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" },
|
{ url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "colorama"
|
|
||||||
version = "0.4.6"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "frozenlist"
|
name = "frozenlist"
|
||||||
version = "1.8.0"
|
version = "1.8.0"
|
||||||
|
|
@ -434,27 +421,6 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" },
|
{ url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "markdown-it-py"
|
|
||||||
version = "4.2.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "mdurl" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "mdurl"
|
|
||||||
version = "0.1.2"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "multidict"
|
name = "multidict"
|
||||||
version = "6.7.1"
|
version = "6.7.1"
|
||||||
|
|
@ -648,24 +614,6 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" },
|
{ url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pygments"
|
|
||||||
version = "2.21.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pysocks"
|
|
||||||
version = "1.7.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0", size = 284429, upload-time = "2019-09-20T02:07:35.714Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725, upload-time = "2019-09-20T02:06:22.938Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pytelegrambotapi"
|
name = "pytelegrambotapi"
|
||||||
version = "4.36.1"
|
version = "4.36.1"
|
||||||
|
|
@ -700,6 +648,15 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" },
|
{ url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-socks"
|
||||||
|
version = "3.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/6c/6b/bd9e15e67cf42b318970028a572750924adbe28776e8420fcdfe60838c61/python_socks-3.0.0.tar.gz", hash = "sha256:c20ddf978b5c90467bf242757a11dcd00d7c8bd339901de1839b492d5571ab7f", size = 232731, upload-time = "2026-08-11T11:17:09.464Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/e7/af080d0d2d5bbc3dbdf58a805812ee45175c5c32c6673f23a5480f9785c2/python_socks-3.0.0-py3-none-any.whl", hash = "sha256:e06a421d4d7f3fc17ef2740ab6c291640364f6a362b94a1ae87a136c29bd0067", size = 49519, upload-time = "2026-08-11T11:17:07.118Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pytz"
|
name = "pytz"
|
||||||
version = "2026.3.post1"
|
version = "2026.3.post1"
|
||||||
|
|
@ -724,24 +681,6 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
|
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.optional-dependencies]
|
|
||||||
socks = [
|
|
||||||
{ name = "pysocks" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rich"
|
|
||||||
version = "15.0.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "markdown-it-py" },
|
|
||||||
{ name = "pygments" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "six"
|
name = "six"
|
||||||
version = "1.17.0"
|
version = "1.17.0"
|
||||||
|
|
|
||||||
9
web/common/eruda.min.js
vendored
|
Before Width: | Height: | Size: 710 KiB After Width: | Height: | Size: 696 KiB |
3114
web/map/images/2.svg
|
Before Width: | Height: | Size: 751 KiB After Width: | Height: | Size: 723 KiB |
|
Before Width: | Height: | Size: 708 KiB After Width: | Height: | Size: 697 KiB |
|
Before Width: | Height: | Size: 678 KiB After Width: | Height: | Size: 668 KiB |
|
Before Width: | Height: | Size: 628 KiB After Width: | Height: | Size: 620 KiB |
|
|
@ -35,7 +35,6 @@
|
||||||
<g id="Коридоры" transform="matrix(1,0,0,1,280,0)">
|
<g id="Коридоры" transform="matrix(1,0,0,1,280,0)">
|
||||||
<path d="M3061.77,2336.07l-73.434,0l0.019,-169.434l73.415,0l0,8.709l320.728,0l0,-8.709l470.548,0l0,6.612l123.711,0l0,-114.104l38.594,0l0,115.621l109.572,0.024l-0.019,-114.826l49.546,-0.819l0,150.113l-1112.68,0l0,126.813Z" style="fill:#d4d4d4;"/>
|
<path d="M3061.77,2336.07l-73.434,0l0.019,-169.434l73.415,0l0,8.709l320.728,0l0,-8.709l470.548,0l0,6.612l123.711,0l0,-114.104l38.594,0l0,115.621l109.572,0.024l-0.019,-114.826l49.546,-0.819l0,150.113l-1112.68,0l0,126.813Z" style="fill:#d4d4d4;"/>
|
||||||
</g>
|
</g>
|
||||||
<g id="Точки"><circle cx="3898" cy="2188" r="2" fill="transparent" /> <circle cx="3945" cy="2188" r="2" fill="transparent" /> <circle cx="3979" cy="2188" r="2" fill="transparent" /> <circle cx="4029" cy="2185" r="2" fill="transparent" /> <circle cx="4067" cy="2188" r="2" fill="transparent" /> <circle cx="4105" cy="2188" r="2" fill="transparent" /> <circle cx="4142" cy="2188" r="2" fill="transparent" /> <circle cx="4183" cy="2188" r="2" fill="transparent" /> <circle cx="4221" cy="2191" r="2" fill="transparent" /> <circle cx="4268" cy="2191" r="2" fill="transparent" /> <circle cx="4268" cy="2144" r="2" fill="transparent" /> <circle cx="4268" cy="2097" r="2" fill="transparent" /> <circle cx="4318" cy="2197" r="2" fill="transparent" /> <circle cx="4365" cy="2197" r="2" fill="transparent" /> <circle cx="4397" cy="2191" r="2" fill="transparent" /> <circle cx="4434" cy="2188" r="2" fill="transparent" /> <circle cx="3854" cy="2178" r="2" fill="transparent" /> <circle cx="3804" cy="2188" r="2" fill="transparent" /> <circle cx="3747" cy="2185" r="2" fill="transparent" /> <circle cx="3700" cy="2185" r="2" fill="transparent" /> <circle cx="3647" cy="2185" r="2" fill="transparent" /> <circle cx="3593" cy="2191" r="2" fill="transparent" /> <circle cx="3546" cy="2191" r="2" fill="transparent" /> <circle cx="3502" cy="2191" r="2" fill="transparent" /> <circle cx="3455" cy="2188" r="2" fill="transparent" /> <circle cx="3418" cy="2182" r="2" fill="transparent" /> <circle cx="3377" cy="2185" r="2" fill="transparent" /> <circle cx="3342" cy="2185" r="2" fill="transparent" /> <circle cx="3302" cy="2185" r="2" fill="transparent" /> <circle cx="3308" cy="2213" r="2" fill="transparent" /></g>
|
|
||||||
<g id="Аудитории" transform="matrix(1,0,0,1,280,0)">
|
<g id="Аудитории" transform="matrix(1,0,0,1,280,0)">
|
||||||
<g id="_1-609" serif:id="1-609">
|
<g id="_1-609" serif:id="1-609">
|
||||||
<rect id="_1-6091" serif:id="1-609" x="3380.5" y="2061.24" width="158.619" height="105.394" style="fill:none;stroke:#000;stroke-width:6.9px;"/>
|
<rect id="_1-6091" serif:id="1-609" x="3380.5" y="2061.24" width="158.619" height="105.394" style="fill:none;stroke:#000;stroke-width:6.9px;"/>
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 538 KiB After Width: | Height: | Size: 536 KiB |
|
|
@ -1,86 +1,53 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="ru">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta name="theme-color" content="#ffffff">
|
|
||||||
<title>Карта</title>
|
<title>Карта</title>
|
||||||
<script src="../common/telegram.js"></script>
|
<script src="https://telegram.org/js/telegram-web-app.js?59"></script>
|
||||||
<script src="../common/eruda.min.js?v15"></script>
|
<link rel="stylesheet" href="./styles/main.css?v12">
|
||||||
<link rel="stylesheet" href="./styles/main.css?v16">
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<section id="block" aria-live="polite">
|
<section id="block">
|
||||||
<div id="loader"></div>
|
<div id="loader"></div>
|
||||||
<h3>Загрузка карты</h3>
|
<h3>Загрузка карты</h3>
|
||||||
<p>0/6 загружено...</p>
|
<p>0/6 загружено...</p>
|
||||||
|
<div id="mp"></div>
|
||||||
</section>
|
</section>
|
||||||
|
<section id="map">
|
||||||
<main id="app" aria-label="Карта университета">
|
|
||||||
<section id="map" aria-label="Карта здания">
|
|
||||||
<div id="controls">
|
<div id="controls">
|
||||||
<button id="floor-current" class="floor-current" type="button" aria-expanded="false">
|
<div class="current" onclick="floorsopen()">
|
||||||
<span>2 этаж</span>
|
<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>
|
<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>
|
||||||
</button>
|
</div>
|
||||||
<div id="floor-select" class="floor-select hidden" aria-label="Выбор этажа">
|
<div class="select hidden">
|
||||||
<button type="button" data-floor="1">1 этаж</button>
|
<span onclick="fswitch(1)">1 этаж</span>
|
||||||
<button type="button" data-floor="2" class="active">2 этаж</button>
|
<span onclick="fswitch(2)" class="active">2 этаж</span>
|
||||||
<button type="button" data-floor="3">3 этаж</button>
|
<span onclick="fswitch(3)">3 этаж</span>
|
||||||
<button type="button" data-floor="4">4 этаж</button>
|
<span onclick="fswitch(4)">4 этаж</span>
|
||||||
<button type="button" data-floor="5">5 этаж</button>
|
<span onclick="fswitch(5)">5 этаж</span>
|
||||||
<button type="button" data-floor="6">6 этаж</button>
|
<span onclick="fswitch(6)">6 этаж</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="wr" aria-live="polite"></div>
|
|
||||||
<div id="map-message" class="map-message hidden"></div>
|
<div id="wr"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="bottom-panel" aria-label="Поиск и навигация">
|
<footer >
|
||||||
<nav id="mode-switch" aria-label="Режим карты">
|
<!-- <div class="dragger"></div> -->
|
||||||
<button id="search-mode" type="button" class="active" aria-selected="true">Поиск</button>
|
<div class="hints">
|
||||||
<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>
|
||||||
<div id="search-hints" class="hints" aria-live="polite"></div>
|
<div class="i">
|
||||||
</section>
|
<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="Введи нужную аудиторию...">
|
||||||
<section id="navigator-panel" class="panel-card hidden" aria-labelledby="navigator-mode">
|
<!-- <button onclick="footerfind()">Найти</button> -->
|
||||||
<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>
|
||||||
<div id="route-start-hints" class="hints" aria-live="polite"></div>
|
</footer>
|
||||||
<div class="field-row">
|
<script src="./js/footer.js?v12"></script>
|
||||||
<svg aria-hidden="true" viewBox="0 -960 960 960"><path d="M480-80 160-400l56-56 224 224v-528h80v528l224-224 56 56L480-80Z"/></svg>
|
<script src="./js/map.js?v12"></script>
|
||||||
<input id="route-end" type="text" autocomplete="off" inputmode="search" placeholder="Аудитория которая тебе нужна..." aria-label="Конечная аудитория">
|
<script>mapload()</script>
|
||||||
</div>
|
<script>
|
||||||
<div id="route-end-hints" class="hints" aria-live="polite"></div>
|
window.Telegram.WebApp.expand()
|
||||||
</div>
|
</script>
|
||||||
<button id="route-submit" class="primary-button" type="button">Найти</button>
|
|
||||||
<p id="route-feedback" class="feedback" aria-live="polite"></p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="route-card" class="route-card hidden" aria-live="polite">
|
|
||||||
<div class="route-card-head">
|
|
||||||
<p id="route-summary" class="route-summary" aria-label="Маршрут"></p>
|
|
||||||
<button id="route-hide" type="button" aria-expanded="true">Скрыть</button>
|
|
||||||
</div>
|
|
||||||
<strong id="route-title" class="route-title">Маршрут</strong>
|
|
||||||
<div id="route-steps" class="route-steps"></div>
|
|
||||||
</section>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<script src="./js/map.js?v16"></script>
|
|
||||||
<script>window.Telegram.WebApp.expand(); window.ZatupsMap.init();</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
@ -2,38 +2,6 @@ const input = document.querySelector('footer > .i > input')
|
||||||
const hints = document.querySelector('.hints')
|
const hints = document.querySelector('.hints')
|
||||||
var inputed_room = ''
|
var inputed_room = ''
|
||||||
|
|
||||||
const initEruda = () => {
|
|
||||||
if (window.__zatupsErudaInitialized || window.__zatupsErudaLoading) return
|
|
||||||
|
|
||||||
const start = () => {
|
|
||||||
if (!window.eruda) {
|
|
||||||
hints.innerHTML = '<span>Консоль не загрузилась</span>'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
window.eruda.init()
|
|
||||||
window.__zatupsErudaInitialized = true
|
|
||||||
} catch (error) {
|
|
||||||
hints.innerHTML = '<span>Консоль не загрузилась</span>'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (window.eruda) return start()
|
|
||||||
|
|
||||||
window.__zatupsErudaLoading = true
|
|
||||||
const script = document.createElement('script')
|
|
||||||
script.src = '../common/eruda.min.js?v13'
|
|
||||||
script.onload = () => {
|
|
||||||
window.__zatupsErudaLoading = false
|
|
||||||
start()
|
|
||||||
}
|
|
||||||
script.onerror = () => {
|
|
||||||
window.__zatupsErudaLoading = false
|
|
||||||
hints.innerHTML = '<span>Консоль не загрузилась</span>'
|
|
||||||
}
|
|
||||||
document.head.appendChild(script)
|
|
||||||
}
|
|
||||||
|
|
||||||
input.addEventListener('focus', (e) => {
|
input.addEventListener('focus', (e) => {
|
||||||
const fixed = `height: ${map.clientHeight}px !important;`
|
const fixed = `height: ${map.clientHeight}px !important;`
|
||||||
document.querySelector('#map').style = fixed
|
document.querySelector('#map').style = fixed
|
||||||
|
|
@ -42,13 +10,7 @@ input.addEventListener('focus', (e) => {
|
||||||
|
|
||||||
input.addEventListener('input', (e) => {
|
input.addEventListener('input', (e) => {
|
||||||
console.log(e.target.value)
|
console.log(e.target.value)
|
||||||
inputed_room = e.target.value.trim().toLowerCase()
|
inputed_room = e.target.value.toLowerCase()
|
||||||
if (inputed_room === 'eruda') {
|
|
||||||
try {hints.classList.remove('a')} catch {}
|
|
||||||
hints.innerHTML = ''
|
|
||||||
initEruda()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (inputed_room.length > 0) {
|
if (inputed_room.length > 0) {
|
||||||
try {hints.classList.add('a')} catch {}
|
try {hints.classList.add('a')} catch {}
|
||||||
const out = fdata.results(inputed_room)
|
const out = fdata.results(inputed_room)
|
||||||
|
|
|
||||||
|
|
@ -1,839 +1,136 @@
|
||||||
/*
|
const fdata = {
|
||||||
* The map is deliberately self-contained. Telegram's theme variables are
|
|
||||||
* not used: the floor plans are white and the controls use one fixed palette
|
|
||||||
* both inside and outside Telegram.
|
|
||||||
*/
|
|
||||||
const FLOOR_COUNT = 6
|
|
||||||
const MAP_VERSION = '16'
|
|
||||||
|
|
||||||
const $ = (selector) => document.querySelector(selector)
|
|
||||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
||||||
|
|
||||||
const fdata = window.fdata = {
|
|
||||||
floor: 2,
|
floor: 2,
|
||||||
floors: Array(FLOOR_COUNT).fill(null),
|
floors: [null,null,null,null,null,null],
|
||||||
floors_rooms: Array.from({length: FLOOR_COUNT}, () => []),
|
floors_rooms: [[], [], [], [], [], []],
|
||||||
room_floors: Object.create(null),
|
|
||||||
inited: false,
|
inited: false,
|
||||||
results: (query, limit = 10) => roomResults(query, limit),
|
check: async () => {
|
||||||
|
var count = 0
|
||||||
|
fdata.floors.forEach(e => e == null ? count += 1 : null)
|
||||||
|
document.querySelector('#block > p').innerHTML = `${6-count}/6 загружено...`
|
||||||
|
if (count == 0) {
|
||||||
|
await sleep(100)
|
||||||
|
mapdisplay()
|
||||||
|
removeblock()
|
||||||
}
|
}
|
||||||
|
},
|
||||||
const state = {
|
results: (query) => {
|
||||||
initialized: false,
|
query = query.toLowerCase()
|
||||||
mode: 'search',
|
var found = []
|
||||||
highlightedRoom: '',
|
var foundrooms = []
|
||||||
route: null,
|
fdata.floors_rooms.forEach((rooms, floor) => {
|
||||||
routeStatus: null,
|
rooms.forEach(
|
||||||
routeStart: null,
|
room =>
|
||||||
routeEnd: null,
|
room && room.toLowerCase().includes(query) && !foundrooms.includes(room)
|
||||||
navigatorManifest: null,
|
? function() {found.push({r: room, f: floor+1}); foundrooms.push(room)} ()
|
||||||
mapHeight: null,
|
: null
|
||||||
routeView: false,
|
)
|
||||||
routeCollapsed: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
function haptic(type = 'light') {
|
|
||||||
try {
|
|
||||||
window.Telegram?.WebApp?.HapticFeedback?.impactOccurred(type)
|
|
||||||
} catch (_) {
|
|
||||||
// Haptic feedback is optional outside Telegram.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalize(value) {
|
|
||||||
return String(value || '').trim().toLocaleLowerCase('ru-RU')
|
|
||||||
}
|
|
||||||
|
|
||||||
function compact(value) {
|
|
||||||
return normalize(value).replace(/[^\p{L}\p{N}]/gu, '')
|
|
||||||
}
|
|
||||||
|
|
||||||
function roomResults(query, limit = 10) {
|
|
||||||
const needle = compact(query)
|
|
||||||
if (!needle) return []
|
|
||||||
|
|
||||||
const all = []
|
|
||||||
const seen = new Set()
|
|
||||||
fdata.floors_rooms.forEach((rooms, index) => rooms.forEach((name) => {
|
|
||||||
const key = normalize(name)
|
|
||||||
if (!key || seen.has(key)) return
|
|
||||||
seen.add(key)
|
|
||||||
all.push({name, floor: index + 1})
|
|
||||||
}))
|
|
||||||
|
|
||||||
const exact = all.filter((room) => compact(room.name) === needle)
|
|
||||||
if (exact.length) return exact.slice(0, limit)
|
|
||||||
|
|
||||||
const contains = all.filter((room) => compact(room.name).includes(needle))
|
|
||||||
if (contains.length) return contains.slice(0, limit)
|
|
||||||
|
|
||||||
// Keep the fallback useful for a mistyped room without pulling in a large
|
|
||||||
// fuzzy-search library.
|
|
||||||
const distance = (name) => {
|
|
||||||
const value = compact(name)
|
|
||||||
let previous = Array.from({length: needle.length + 1}, (_, i) => i)
|
|
||||||
for (let i = 0; i < value.length; i += 1) {
|
|
||||||
const current = [i + 1]
|
|
||||||
for (let j = 0; j < needle.length; j += 1) {
|
|
||||||
current.push(Math.min(
|
|
||||||
current[j] + 1,
|
|
||||||
previous[j + 1] + 1,
|
|
||||||
previous[j] + (value[i] === needle[j] ? 0 : 1),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
previous = current
|
|
||||||
}
|
|
||||||
return previous[needle.length]
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
})
|
})
|
||||||
|
return found
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearHints(...containers) {
|
const floorsopen = () => {
|
||||||
containers.forEach((container) => container?.replaceChildren())
|
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 removeBlock() {
|
const removeblock = () => {
|
||||||
$('#block')?.remove()
|
document.querySelector('#block').remove()
|
||||||
}
|
}
|
||||||
|
const highlight = (room) => {
|
||||||
function applyMapHeightLock() {
|
document.querySelectorAll('.highlight').forEach(e => e.classList.remove('highlight'))
|
||||||
const map = $('#map')
|
var lastfound = null
|
||||||
const wrapper = $('#wr')
|
document.querySelectorAll('#Аудитории > g').forEach(e => {
|
||||||
if (!map || !wrapper || !state.mapHeight) return
|
if (e.getAttribute('serif:id') == room) {
|
||||||
const height = `${state.mapHeight}px`
|
e.querySelector('*').classList.add('highlight')
|
||||||
map.style.height = height
|
lastfound = e
|
||||||
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
|
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)
|
||||||
|
|
||||||
async function loadNavigatorManifest() {
|
}
|
||||||
try {
|
const map = document.querySelector('#map > #wr')
|
||||||
const response = await fetch('./data/navigator.json', {cache: 'no-store'})
|
if (room.includes('-')) {
|
||||||
if (!response.ok) return
|
const building = room.split('-')[0]
|
||||||
const value = await response.json()
|
const values_to_scroll = {
|
||||||
if (value && typeof value === 'object') state.navigatorManifest = value
|
1: 1.1,
|
||||||
} catch (_) {
|
2: 1.1,
|
||||||
// The map itself remains usable if the embedded navigator data is unavailable.
|
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)}
|
||||||
|
|
||||||
|
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 mapload = async () => {
|
||||||
const response = await fetch(`./images/${index + 1}.svg?v=${MAP_VERSION}`)
|
|
||||||
if (!response.ok) throw new Error(`floor ${index + 1}: ${response.status}`)
|
|
||||||
const text = await response.text()
|
|
||||||
fdata.floors[index] = text
|
|
||||||
fdata.floors_rooms[index] = parseRooms(text, index + 1)
|
|
||||||
updateLoading()
|
|
||||||
}
|
|
||||||
|
|
||||||
function floorValue(value) {
|
for (let i = 0; i < 6; i++) {
|
||||||
const number = Number.parseInt(String(value), 10)
|
await fetch(`./images/${i+1}.svg`).then(
|
||||||
return Number.isInteger(number) && number >= 1 && number <= FLOOR_COUNT ? number : null
|
async e => {
|
||||||
}
|
const mp = document.createElement('div');
|
||||||
|
const svg = await e.text()
|
||||||
function setFloor(floor, {keepHighlight = false} = {}) {
|
fdata.floors[i] = svg
|
||||||
const next = floorValue(floor)
|
mp.innerHTML = svg
|
||||||
if (!next || !fdata.floors[next - 1]) return
|
mp.querySelectorAll('#Аудитории > g').forEach(e => {
|
||||||
fdata.floor = next
|
// console.log(e.getAttributeNS('serif:id'))
|
||||||
const current = $('#floor-current')
|
fdata.floors_rooms[i].push(e.getAttribute('serif:id'))
|
||||||
if (current) {
|
|
||||||
current.querySelector('span').textContent = `${next} этаж`
|
|
||||||
current.setAttribute('aria-expanded', 'false')
|
|
||||||
}
|
|
||||||
$('#floor-select')?.classList.add('hidden')
|
|
||||||
$('#floor-select')?.querySelectorAll('button').forEach((button) => {
|
|
||||||
button.classList.toggle('active', Number(button.dataset.floor) === next)
|
|
||||||
})
|
})
|
||||||
renderFloor()
|
await sleep(100)
|
||||||
if (!keepHighlight) state.highlightedRoom = ''
|
fdata.check()
|
||||||
haptic('light')
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleFloors() {
|
// [0,1,2,3,4,5].map(async (_, i) =>
|
||||||
const select = $('#floor-select')
|
// await fetch(`./images/${i+1}.svg`).then(async e => {fdata.floors[i] = await e.text(); fdata.check()}))
|
||||||
const current = $('#floor-current')
|
|
||||||
if (!select || !current) return
|
|
||||||
const opened = select.classList.toggle('hidden') === false
|
|
||||||
current.setAttribute('aria-expanded', String(opened))
|
|
||||||
haptic('light')
|
|
||||||
}
|
}
|
||||||
|
const mapdisplay = async () => {
|
||||||
function currentSvg() {
|
const map = document.querySelector('#map > #wr')
|
||||||
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]
|
const svg = fdata.floors[fdata.floor-1]
|
||||||
if (!map || !svg) return
|
|
||||||
map.innerHTML = svg
|
map.innerHTML = svg
|
||||||
if (!fdata.inited) {
|
if (!fdata.inited) {
|
||||||
// The source drawings are wide; starting near the centre gives a
|
const sc = (map.scrollWidth - map.clientWidth) / 1.1
|
||||||
// useful view while still allowing horizontal scrolling.
|
map.scrollLeft = sc;
|
||||||
map.scrollLeft = Math.max(0, (map.scrollWidth - map.clientWidth) * .9)
|
console.log(sc)
|
||||||
fdata.inited = true
|
fdata.inited = true
|
||||||
}
|
}
|
||||||
if (state.highlightedRoom && fdata.room_floors[state.highlightedRoom] === fdata.floor) highlight(state.highlightedRoom)
|
|
||||||
drawCurrentRoute()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateSearchHints() {
|
|
||||||
const input = $('#room-search')
|
|
||||||
const hints = $('#search-hints')
|
|
||||||
const title = $('#search-title')
|
|
||||||
if (!input || !hints) return
|
|
||||||
const query = input.value.trim()
|
|
||||||
if (normalize(query) === 'eruda') {
|
|
||||||
initEruda()
|
|
||||||
clearHints(hints)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!query) {
|
|
||||||
if (title) title.textContent = 'Напиши название аудитории ниже'
|
|
||||||
return clearHints(hints)
|
|
||||||
}
|
|
||||||
const results = roomResults(query)
|
|
||||||
if (title) title.textContent = results.some((result) => compact(result.name) === compact(query))
|
|
||||||
? 'Аудитория найдена'
|
|
||||||
: (results.length ? 'Похожие аудитории' : 'Аудитория не найдена')
|
|
||||||
showHints(hints, results, (result) => {
|
|
||||||
input.value = result.name
|
|
||||||
clearHints(hints)
|
|
||||||
setFloor(result.floor)
|
|
||||||
highlight(result.name)
|
|
||||||
haptic('success')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateNavigatorHints(which) {
|
|
||||||
const input = which === 'start' ? $('#route-start') : $('#route-end')
|
|
||||||
const hints = which === 'start' ? $('#route-start-hints') : $('#route-end-hints')
|
|
||||||
if (!input || !hints) return
|
|
||||||
const query = input.value.trim()
|
|
||||||
if (!query) return clearHints(hints)
|
|
||||||
showHints(hints, roomResults(query), (result) => {
|
|
||||||
input.value = result.name
|
|
||||||
clearHints(hints)
|
|
||||||
if (which === 'start') state.routeStart = result
|
|
||||||
else state.routeEnd = result
|
|
||||||
setFloor(result.floor, {keepHighlight: true})
|
|
||||||
highlight(result.name)
|
|
||||||
haptic('success')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function initEruda() {
|
|
||||||
if (window.__zatupsErudaInitialized || window.__zatupsErudaLoading) return
|
|
||||||
const start = () => {
|
|
||||||
if (!window.eruda) return
|
|
||||||
try {
|
|
||||||
window.eruda.init()
|
|
||||||
window.__zatupsErudaInitialized = true
|
|
||||||
} catch (_) {
|
|
||||||
// Dev-only console; never interrupt map use.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (window.eruda) return start()
|
|
||||||
window.__zatupsErudaLoading = true
|
|
||||||
const script = document.createElement('script')
|
|
||||||
script.src = `../common/eruda.min.js?v${MAP_VERSION}`
|
|
||||||
script.onload = () => { window.__zatupsErudaLoading = false; start() }
|
|
||||||
script.onerror = () => { window.__zatupsErudaLoading = false }
|
|
||||||
document.head.append(script)
|
|
||||||
}
|
|
||||||
|
|
||||||
function switchMode(mode) {
|
|
||||||
if (state.routeView) return
|
|
||||||
state.mode = mode === 'navigator' ? 'navigator' : 'search'
|
|
||||||
const searchActive = state.mode === 'search'
|
|
||||||
$('#search-mode')?.classList.toggle('active', searchActive)
|
|
||||||
$('#navigator-mode')?.classList.toggle('active', !searchActive)
|
|
||||||
$('#search-mode')?.setAttribute('aria-selected', String(searchActive))
|
|
||||||
$('#navigator-mode')?.setAttribute('aria-selected', String(!searchActive))
|
|
||||||
$('#search-panel')?.classList.toggle('hidden', !searchActive)
|
|
||||||
$('#navigator-panel')?.classList.toggle('hidden', searchActive || Boolean(state.routeStatus))
|
|
||||||
if (searchActive && !state.routeStatus) $('#route-card')?.classList.add('hidden')
|
|
||||||
if (!searchActive && !state.routeStatus) $('#route-card')?.classList.add('hidden')
|
|
||||||
haptic('light')
|
|
||||||
}
|
|
||||||
|
|
||||||
function randomRequestId() {
|
|
||||||
try {
|
|
||||||
if (crypto.randomUUID) return crypto.randomUUID()
|
|
||||||
} catch (_) {}
|
|
||||||
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function roomForRequest(input, remembered) {
|
|
||||||
const exact = exactRoom(input.value)
|
|
||||||
if (exact) return exact
|
|
||||||
return remembered && compact(remembered.name) === compact(input.value) ? remembered : null
|
|
||||||
}
|
|
||||||
|
|
||||||
function submitRoute() {
|
|
||||||
const startInput = $('#route-start')
|
|
||||||
const endInput = $('#route-end')
|
|
||||||
const feedback = $('#route-feedback')
|
|
||||||
if (!startInput || !endInput || !feedback) return
|
|
||||||
const start = roomForRequest(startInput, state.routeStart)
|
|
||||||
const end = roomForRequest(endInput, state.routeEnd)
|
|
||||||
state.routeStart = start
|
|
||||||
state.routeEnd = end
|
|
||||||
if (!start || !end) {
|
|
||||||
feedback.textContent = 'Выбери две существующие аудитории из подсказок.'
|
|
||||||
haptic('error')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (compact(start.name) === compact(end.name)) {
|
|
||||||
feedback.textContent = 'Начальная и конечная аудитории должны отличаться.'
|
|
||||||
haptic('error')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
v: 1,
|
|
||||||
type: 'route.request',
|
|
||||||
request_id: randomRequestId(),
|
|
||||||
start: {floor: String(start.floor), room: start.name},
|
|
||||||
end: {floor: String(end.floor), room: end.name},
|
|
||||||
}
|
|
||||||
window.__lastRouteRequest = payload
|
|
||||||
try {
|
|
||||||
const webApp = window.Telegram?.WebApp
|
|
||||||
if (typeof webApp?.sendData !== 'function') throw new Error('Telegram WebApp is unavailable')
|
|
||||||
webApp.sendData(JSON.stringify(payload))
|
|
||||||
feedback.textContent = 'Запрос отправлен в бот. Вернись в чат за результатом.'
|
|
||||||
haptic('success')
|
|
||||||
} catch (_) {
|
|
||||||
feedback.textContent = 'Открой карту из кнопки бота, чтобы отправить маршрут.'
|
|
||||||
haptic('error')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeRoute(value) {
|
|
||||||
try {
|
|
||||||
const normalized = value.replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((value.length + 3) % 4)
|
|
||||||
const bytes = Uint8Array.from(atob(normalized), (char) => char.charCodeAt(0))
|
|
||||||
return JSON.parse(new TextDecoder().decode(bytes))
|
|
||||||
} catch (_) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function encodeText(value) {
|
|
||||||
return String(value || '').trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
function createRouteStep(title, description) {
|
|
||||||
const element = document.createElement('div')
|
|
||||||
element.className = 'route-step route-step-message'
|
|
||||||
const heading = document.createElement('strong')
|
|
||||||
heading.textContent = title
|
|
||||||
const text = document.createElement('span')
|
|
||||||
text.textContent = description
|
|
||||||
element.append(heading, text)
|
|
||||||
return element
|
|
||||||
}
|
|
||||||
|
|
||||||
function createRouteIcon(kind, {descending = false} = {}) {
|
|
||||||
const wrapper = document.createElement('span')
|
|
||||||
wrapper.className = `route-step-icon route-step-icon-${kind}${descending ? ' descending' : ''}`
|
|
||||||
wrapper.setAttribute('aria-hidden', 'true')
|
|
||||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
|
||||||
svg.setAttribute('viewBox', '0 0 24 24')
|
|
||||||
svg.setAttribute('focusable', 'false')
|
|
||||||
const paths = {
|
|
||||||
pin: ['M12 22s7-6.2 7-13a7 7 0 1 0-14 0c0 6.8 7 13 7 13Z', 'M12 6.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5Z'],
|
|
||||||
turn: ['M5 4v8a5 5 0 0 0 5 5h8', 'm14 13 4 4-4 4'],
|
|
||||||
stairs: ['M3 20h5v-4h4v-4h4V8h5V4'],
|
|
||||||
}
|
|
||||||
;(paths[kind] || []).forEach((data) => {
|
|
||||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
|
|
||||||
path.setAttribute('d', data)
|
|
||||||
svg.append(path)
|
|
||||||
})
|
|
||||||
wrapper.append(svg)
|
|
||||||
return wrapper
|
|
||||||
}
|
|
||||||
|
|
||||||
function scrollToCurrentRoute() {
|
|
||||||
const map = $('#wr')
|
|
||||||
const svg = currentSvg()
|
|
||||||
const route = svg?.querySelector('#Линия')
|
|
||||||
if (!map || !svg || !route) return
|
|
||||||
try {
|
|
||||||
const bbox = route.getBBox()
|
|
||||||
const viewBox = svg.viewBox?.baseVal
|
|
||||||
const svgHeight = viewBox?.height || svg.getBoundingClientRect().height
|
|
||||||
const scale = svgHeight ? svg.clientHeight / svgHeight : 1
|
|
||||||
const center = (bbox.x + bbox.width / 2) * scale
|
|
||||||
const contentWidth = Math.max((viewBox?.width || svg.clientWidth) * scale, svg.clientWidth)
|
|
||||||
map.scrollTo({
|
|
||||||
left: Math.max(0, Math.min(contentWidth - map.clientWidth, center - map.clientWidth / 2)),
|
|
||||||
behavior: 'smooth',
|
|
||||||
})
|
|
||||||
} catch (_) {
|
|
||||||
// Older WebViews can fail to calculate an injected SVG group's bbox.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showRouteFloor(floor) {
|
|
||||||
setFloor(floor, {keepHighlight: true})
|
|
||||||
requestAnimationFrame(scrollToCurrentRoute)
|
|
||||||
}
|
|
||||||
|
|
||||||
function createRouteInstruction(label, icon, {floor = null, descending = false} = {}) {
|
|
||||||
const element = document.createElement('div')
|
|
||||||
element.className = 'route-step'
|
|
||||||
const row = document.createElement('div')
|
|
||||||
row.className = 'route-step-row'
|
|
||||||
const text = document.createElement('p')
|
|
||||||
text.className = 'route-step-label'
|
|
||||||
text.textContent = label
|
|
||||||
row.append(text, createRouteIcon(icon, {descending}))
|
|
||||||
element.append(row)
|
|
||||||
if (floor) {
|
|
||||||
const button = document.createElement('button')
|
|
||||||
button.type = 'button'
|
|
||||||
button.className = 'route-floor-button'
|
|
||||||
button.textContent = 'Показать этаж'
|
|
||||||
button.addEventListener('click', () => showRouteFloor(floor))
|
|
||||||
element.append(button)
|
|
||||||
}
|
|
||||||
return element
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderRouteInstructions(steps, segments) {
|
|
||||||
const routeSegments = segments
|
|
||||||
.map((segment) => ({segment, floor: segmentFloor(segment)}))
|
|
||||||
.filter(({floor}) => Boolean(floor))
|
|
||||||
if (!routeSegments.length) {
|
|
||||||
steps.append(createRouteStep('Маршрут пуст', 'Не удалось найти точки между аудиториями.'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const startFloor = routeSegments[0].floor
|
|
||||||
steps.append(createRouteInstruction(`Вы сейчас на ${startFloor} этаже`, 'pin', {floor: startFloor}))
|
|
||||||
if (routeSegments.length === 1) {
|
|
||||||
steps.append(createRouteInstruction('Пройдите до аудитории', 'pin'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
steps.append(createRouteInstruction('Пройдите до лестницы', 'turn'))
|
|
||||||
for (let index = 1; index < routeSegments.length; index += 1) {
|
|
||||||
const previousFloor = routeSegments[index - 1].floor
|
|
||||||
const floor = routeSegments[index].floor
|
|
||||||
if (floor !== previousFloor) {
|
|
||||||
const descending = floor < previousFloor
|
|
||||||
steps.append(createRouteInstruction(
|
|
||||||
`${descending ? 'Спуститесь' : 'Поднимитесь'} на ${floor} этаж`,
|
|
||||||
'stairs',
|
|
||||||
{floor, descending},
|
|
||||||
))
|
|
||||||
}
|
|
||||||
const last = index === routeSegments.length - 1
|
|
||||||
steps.append(createRouteInstruction(last ? 'Пройдите до аудитории' : 'Пройдите до лестницы', last ? 'pin' : 'turn'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderRouteSummary(summary, start, end) {
|
|
||||||
summary.replaceChildren()
|
|
||||||
if (!start || !end) return
|
|
||||||
const startLabel = document.createElement('span')
|
|
||||||
startLabel.textContent = start
|
|
||||||
const arrow = document.createElement('span')
|
|
||||||
arrow.className = 'route-arrow'
|
|
||||||
arrow.setAttribute('aria-hidden', 'true')
|
|
||||||
const endLabel = document.createElement('span')
|
|
||||||
endLabel.textContent = end
|
|
||||||
summary.append(startLabel, arrow, endLabel)
|
|
||||||
}
|
|
||||||
|
|
||||||
function enterRouteView() {
|
|
||||||
state.routeView = true
|
|
||||||
state.routeCollapsed = false
|
|
||||||
state.mode = 'navigator'
|
|
||||||
$('#app')?.classList.add('route-view')
|
|
||||||
$('#route-card')?.classList.remove('hidden', 'route-collapsed')
|
|
||||||
const hide = $('#route-hide')
|
|
||||||
if (hide) {
|
|
||||||
hide.textContent = 'Скрыть'
|
|
||||||
hide.setAttribute('aria-expanded', 'true')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleRouteCollapsed() {
|
|
||||||
const card = $('#route-card')
|
|
||||||
const button = $('#route-hide')
|
|
||||||
if (!card || !button || !state.routeView) return
|
|
||||||
state.routeCollapsed = !state.routeCollapsed
|
|
||||||
card.classList.toggle('route-collapsed', state.routeCollapsed)
|
|
||||||
button.textContent = state.routeCollapsed ? 'Показать' : 'Скрыть'
|
|
||||||
button.setAttribute('aria-expanded', String(!state.routeCollapsed))
|
|
||||||
haptic('light')
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderRouteCard() {
|
|
||||||
const card = $('#route-card')
|
|
||||||
const title = $('#route-title')
|
|
||||||
const summary = $('#route-summary')
|
|
||||||
const steps = $('#route-steps')
|
|
||||||
if (!card || !title || !summary || !steps) return
|
|
||||||
steps.replaceChildren()
|
|
||||||
const start = encodeText(state.routeStart?.name || new URLSearchParams(location.search).get('start'))
|
|
||||||
const end = encodeText(state.routeEnd?.name || new URLSearchParams(location.search).get('end'))
|
|
||||||
renderRouteSummary(summary, start, end)
|
|
||||||
const failed = state.routeStatus === 'failed' || !state.route
|
|
||||||
card.classList.toggle('route-failed', failed)
|
|
||||||
|
|
||||||
if (failed) {
|
|
||||||
title.textContent = 'Маршрут не построен'
|
|
||||||
steps.append(createRouteStep('Попробуй ещё раз', 'Не удалось найти путь между выбранными аудиториями.'))
|
|
||||||
} else {
|
|
||||||
title.textContent = 'Маршрут построен'
|
|
||||||
const segments = Array.isArray(state.route.segments) ? state.route.segments : []
|
|
||||||
renderRouteInstructions(steps, segments)
|
|
||||||
}
|
|
||||||
enterRouteView()
|
|
||||||
}
|
|
||||||
|
|
||||||
function initRouteFromQuery() {
|
|
||||||
const params = new URLSearchParams(location.search)
|
|
||||||
const start = params.get('start') || ''
|
|
||||||
const end = params.get('end') || ''
|
|
||||||
const startRoom = exactRoom(start)
|
|
||||||
const endRoom = exactRoom(end)
|
|
||||||
if (startRoom) {
|
|
||||||
state.routeStart = startRoom
|
|
||||||
$('#route-start').value = startRoom.name
|
|
||||||
} else if (start) $('#route-start').value = start
|
|
||||||
if (endRoom) {
|
|
||||||
state.routeEnd = endRoom
|
|
||||||
$('#route-end').value = endRoom.name
|
|
||||||
} else if (end) $('#route-end').value = end
|
|
||||||
|
|
||||||
const encoded = params.get('route')
|
|
||||||
state.route = encoded ? decodeRoute(encoded) : null
|
|
||||||
state.routeStatus = params.get('status') || (state.route ? 'built' : null)
|
|
||||||
if (start || end || state.route || state.routeStatus) switchMode('navigator')
|
|
||||||
if (state.route || state.routeStatus === 'failed') renderRouteCard()
|
|
||||||
|
|
||||||
const floor = floorValue(params.get('floor') || params.get('sf') || startRoom?.floor)
|
|
||||||
if (floor) fdata.floor = floor
|
|
||||||
}
|
|
||||||
|
|
||||||
function hydrateRouteEndpoints() {
|
|
||||||
const params = new URLSearchParams(location.search)
|
|
||||||
const start = state.routeStart || exactRoom(params.get('start') || '')
|
|
||||||
const end = state.routeEnd || exactRoom(params.get('end') || '')
|
|
||||||
if (start && !state.routeStart) {
|
|
||||||
state.routeStart = start
|
|
||||||
$('#route-start').value = start.name
|
|
||||||
}
|
|
||||||
if (end && !state.routeEnd) {
|
|
||||||
state.routeEnd = end
|
|
||||||
$('#route-end').value = end.name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function bindEvents() {
|
|
||||||
$('#floor-current')?.addEventListener('click', toggleFloors)
|
|
||||||
$('#floor-select')?.querySelectorAll('button[data-floor]').forEach((button) => {
|
|
||||||
button.addEventListener('click', () => setFloor(button.dataset.floor))
|
|
||||||
})
|
|
||||||
$('#search-mode')?.addEventListener('click', () => switchMode('search'))
|
|
||||||
$('#navigator-mode')?.addEventListener('click', () => switchMode('navigator'))
|
|
||||||
$('#room-search')?.addEventListener('input', updateSearchHints)
|
|
||||||
$('#route-start')?.addEventListener('input', () => updateNavigatorHints('start'))
|
|
||||||
$('#route-end')?.addEventListener('input', () => updateNavigatorHints('end'))
|
|
||||||
$('#route-submit')?.addEventListener('click', submitRoute)
|
|
||||||
$('#route-hide')?.addEventListener('click', toggleRouteCollapsed)
|
|
||||||
// Mobile WebViews resize their visual viewport when the keyboard opens.
|
|
||||||
// Reapply the captured size, but never measure the shrunken viewport.
|
|
||||||
document.addEventListener('focusin', (event) => {
|
|
||||||
if (event.target instanceof HTMLInputElement) applyMapHeightLock()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function mapload() {
|
|
||||||
const results = await Promise.allSettled(Array.from({length: FLOOR_COUNT}, (_, index) => loadFloor(index)))
|
|
||||||
const failed = results.filter((result) => result.status === 'rejected').length
|
|
||||||
if (failed === FLOOR_COUNT) {
|
|
||||||
setMapMessage('Не удалось загрузить карту')
|
|
||||||
const progress = $('#block > p')
|
|
||||||
if (progress) progress.textContent = 'Проверь соединение и попробуй снова'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
removeBlock()
|
|
||||||
hydrateRouteEndpoints()
|
|
||||||
renderFloor()
|
|
||||||
if (state.routeStart?.floor) setFloor(state.routeStart.floor, {keepHighlight: true})
|
|
||||||
if (state.routeStart?.name) highlight(state.routeStart.name)
|
|
||||||
drawCurrentRoute()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function init() {
|
|
||||||
if (state.initialized) return
|
|
||||||
state.initialized = true
|
|
||||||
bindEvents()
|
|
||||||
initRouteFromQuery()
|
|
||||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))
|
|
||||||
lockMapHeight()
|
|
||||||
await Promise.all([loadNavigatorManifest(), mapload()])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep the small global API used by older deep links and by the developer
|
|
||||||
// console while exposing the new state in one namespace.
|
|
||||||
window.floorsopen = toggleFloors
|
|
||||||
window.fswitch = setFloor
|
|
||||||
window.highlight = highlight
|
|
||||||
window.mapload = mapload
|
|
||||||
window.find = (room, floor) => {
|
|
||||||
const input = $('#room-search')
|
|
||||||
if (input) input.value = room
|
|
||||||
setFloor(floor)
|
|
||||||
highlight(room)
|
|
||||||
}
|
|
||||||
window.ZatupsMap = {init, state, fdata, setFloor, highlight, submitRoute}
|
|
||||||
|
|
|
||||||
|
|
@ -1,597 +1,250 @@
|
||||||
:root {
|
|
||||||
color-scheme: light;
|
|
||||||
--page: #ffffff;
|
|
||||||
--panel: #444444;
|
|
||||||
--panel-muted: #5e5e5e;
|
|
||||||
--panel-light: #707070;
|
|
||||||
--panel-text: #f6f6f6;
|
|
||||||
--panel-hint: #d4d4d4;
|
|
||||||
--white: #ffffff;
|
|
||||||
--accent: #2481cc;
|
|
||||||
--accent-pulse: #1767a6;
|
|
||||||
--line: #2481cc;
|
|
||||||
--route-end: #b01820;
|
|
||||||
--route-end-pulse: #7f1118;
|
|
||||||
}
|
|
||||||
|
|
||||||
@property --found-fill {
|
|
||||||
syntax: "<color>";
|
|
||||||
inherits: true;
|
|
||||||
initial-value: #2481cc;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
/* border: solid 1px #f00; */
|
||||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||||
|
touch-action: pan-x pan-y;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
}
|
|
||||||
|
|
||||||
html, body {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
overflow: hidden;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--panel-text);
|
background: var(--tg-theme-bg-color);
|
||||||
background: var(--page);
|
color: var(--tg-theme-text-color);
|
||||||
font-size: 1rem;
|
height: 100vh;
|
||||||
}
|
/* height: 100dvh; */
|
||||||
|
|
||||||
button, input {
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
border: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
input {
|
|
||||||
user-select: text;
|
|
||||||
-webkit-user-select: text;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
#app {
|
|
||||||
height: 100%;
|
|
||||||
min-height: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#map {
|
#map {
|
||||||
position: absolute;
|
width: calc(100vw - 1rem);
|
||||||
top: 0;
|
margin: 0.5rem;
|
||||||
right: 0;
|
background: #fff;
|
||||||
left: 0;
|
/* box-shadow: rgba(255,255,255,.1) 0 0 .5rem .1rem; */
|
||||||
bottom: auto;
|
border-radius: 1rem;
|
||||||
height: calc(100% - 12.2rem);
|
height: calc(100% - 7.5rem);
|
||||||
overflow: hidden;
|
overflow: auto;
|
||||||
background: var(--white);
|
scrollbar-width: none;
|
||||||
border-bottom: 4px solid #f6e7a2;
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
scroll-behavior: smooth;
|
||||||
}
|
}
|
||||||
|
#map > .controls {
|
||||||
#map.map-locked {
|
display: none;
|
||||||
height: var(--map-locked-height);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#wr {
|
#wr {
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: auto;
|
overflow-y: hidden;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
overscroll-behavior: contain;
|
|
||||||
scroll-behavior: smooth;
|
|
||||||
background: var(--white);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#wr::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#wr > svg {
|
#wr > svg {
|
||||||
display: block;
|
|
||||||
width: auto;
|
width: auto;
|
||||||
min-width: 100%;
|
/* transform: translateX(27%); */
|
||||||
height: 100%;
|
/* height: calc(100% - 5rem); */
|
||||||
background: var(--white);
|
/* scale: 1.1; */
|
||||||
}
|
}
|
||||||
|
footer {
|
||||||
#controls {
|
width: calc(100vw);
|
||||||
position: absolute;
|
background: var(--tg-theme-bg-color);
|
||||||
z-index: 5;
|
|
||||||
top: .65rem;
|
|
||||||
left: .65rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
gap: .35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.floor-current, .floor-select {
|
|
||||||
color: var(--panel-text);
|
|
||||||
background: rgba(68, 68, 68, .94);
|
|
||||||
border-radius: .75rem;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, .16);
|
|
||||||
}
|
|
||||||
|
|
||||||
.floor-current {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: .35rem;
|
|
||||||
padding: .35rem .55rem .35rem .75rem;
|
|
||||||
font-size: 1.05rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.floor-current svg {
|
|
||||||
width: 1.15rem;
|
|
||||||
height: 1.15rem;
|
|
||||||
fill: currentColor;
|
|
||||||
transition: transform .16s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.floor-current[aria-expanded="true"] svg {
|
|
||||||
transform: rotate(180deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.floor-select {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: .18rem;
|
|
||||||
padding: .32rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.floor-select button {
|
|
||||||
min-width: 4.8rem;
|
|
||||||
padding: .28rem .45rem;
|
|
||||||
color: var(--panel-text);
|
|
||||||
background: transparent;
|
|
||||||
border-radius: .48rem;
|
|
||||||
font-size: .98rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.floor-select button.active,
|
|
||||||
.floor-select button:active {
|
|
||||||
background: var(--panel-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.highlight > *,
|
|
||||||
.highlight > * > * {
|
|
||||||
fill: var(--found-fill) !important;
|
|
||||||
stroke: #171717 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.highlight {
|
|
||||||
--found-fill: var(--accent);
|
|
||||||
animation: found-fill 2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
#Линия {
|
|
||||||
--found-fill: var(--line);
|
|
||||||
animation: found-fill 2s ease-in-out infinite;
|
|
||||||
transform-box: fill-box;
|
|
||||||
transform-origin: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
#Линия line {
|
|
||||||
stroke: var(--found-fill) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
#Линия rect {
|
|
||||||
fill: var(--found-fill) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
#Линия .route-end-marker {
|
|
||||||
animation: route-end-fill 2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes found-fill {
|
|
||||||
0%, 100% { --found-fill: var(--accent); }
|
|
||||||
50% { --found-fill: var(--accent-pulse); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes route-end-fill {
|
|
||||||
0%, 100% { --found-fill: var(--route-end); }
|
|
||||||
50% { --found-fill: var(--route-end-pulse); }
|
|
||||||
}
|
|
||||||
|
|
||||||
#map-message {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 4;
|
|
||||||
left: 50%;
|
|
||||||
top: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
max-width: calc(100% - 2rem);
|
|
||||||
padding: .75rem 1rem;
|
|
||||||
color: #fff;
|
|
||||||
background: rgba(68, 68, 68, .94);
|
|
||||||
border-radius: .85rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
#bottom-panel {
|
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 10;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
/* border: solid 2px var(--tg-theme-bg-color); */
|
||||||
|
/* border-top: solid 2px var(--tg-theme-hint-color); */
|
||||||
|
box-shadow: var(--tg-theme-secondary-bg-color) 0 0rem .9rem .2rem;
|
||||||
|
|
||||||
|
border-radius: 1.5rem 1.5rem 0 0;
|
||||||
|
padding: .5rem 0 2.5rem;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: flex-end;
|
/* gap: .5rem; */
|
||||||
gap: .38rem;
|
|
||||||
min-height: 12.2rem;
|
|
||||||
padding: .42rem .62rem calc(.65rem + env(safe-area-inset-bottom));
|
|
||||||
background: transparent;
|
|
||||||
}
|
}
|
||||||
|
.dragger {
|
||||||
/* A route is a focused, read-only map view. The map itself remains usable,
|
height: .3rem;
|
||||||
* while floor controls, tabs, and both input panels stay out of the way. */
|
background: var(--tg-theme-hint-color);
|
||||||
#app.route-view #controls,
|
opacity: .2;
|
||||||
#app.route-view #mode-switch,
|
margin: 0 auto;
|
||||||
#app.route-view #search-panel,
|
border-radius: 100vw;
|
||||||
#app.route-view #navigator-panel {
|
width: 3rem;
|
||||||
display: none !important;
|
|
||||||
}
|
}
|
||||||
|
footer > .i {
|
||||||
#app.route-view #bottom-panel {
|
|
||||||
min-height: 0;
|
|
||||||
gap: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#mode-switch {
|
|
||||||
display: flex;
|
|
||||||
align-self: flex-start;
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: 1.2rem;
|
|
||||||
background: var(--panel);
|
|
||||||
}
|
|
||||||
|
|
||||||
#mode-switch button {
|
|
||||||
min-width: 5.4rem;
|
|
||||||
padding: .28rem .8rem;
|
|
||||||
color: #eee;
|
|
||||||
background: var(--panel);
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
#mode-switch button.active {
|
|
||||||
color: #fff;
|
|
||||||
background: #8d8d8d;
|
|
||||||
}
|
|
||||||
|
|
||||||
#mode-switch,
|
|
||||||
.panel-card,
|
|
||||||
.route-card {
|
|
||||||
box-shadow: 0 .7rem 2.2rem rgba(0, 0, 0, .16), 0 .12rem .45rem rgba(0, 0, 0, .12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-card, .route-card {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
padding: .62rem .68rem .68rem;
|
|
||||||
color: var(--panel-text);
|
|
||||||
background: var(--panel);
|
|
||||||
border-radius: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-title {
|
|
||||||
margin: 0 0 .48rem;
|
|
||||||
font-size: 1.02rem;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-row {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: .45rem;
|
background: var(--tg-theme-secondary-bg-color);
|
||||||
min-height: 2.35rem;
|
border-radius: 5rem;
|
||||||
padding: .1rem .58rem;
|
padding: 0 .5rem;
|
||||||
color: var(--panel-hint);
|
margin: 0 1rem;
|
||||||
background: var(--panel-muted);
|
gap: .5rem;
|
||||||
border-radius: .72rem;
|
margin-top: .5rem;
|
||||||
}
|
/* border: solid 1px var(--tg-theme-hint-color); */
|
||||||
|
|
||||||
.field-row svg {
|
padding-right: 0;
|
||||||
flex: 0 0 1.25rem;
|
|
||||||
width: 1.25rem;
|
|
||||||
height: 1.25rem;
|
|
||||||
fill: currentColor;
|
|
||||||
}
|
}
|
||||||
|
footer > .i > svg {
|
||||||
.field-row input {
|
fill: var(--tg-theme-text-color) !important;
|
||||||
min-width: 0;
|
}
|
||||||
width: 100%;
|
footer > .i > input {
|
||||||
padding: .36rem 0;
|
|
||||||
color: #fff;
|
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 0;
|
|
||||||
outline: none;
|
outline: none;
|
||||||
font-size: 1rem;
|
border: 0;
|
||||||
|
padding: .5rem 0;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: var(--tg-theme-text-color);
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
}
|
||||||
|
.i > input::placeholder {
|
||||||
|
color: var(--tg-theme-hint-color);
|
||||||
|
}
|
||||||
|
.i > button {
|
||||||
|
padding: .62rem .8rem;
|
||||||
|
/* font-weight: bold; */
|
||||||
|
/* height: 100%; */
|
||||||
|
border-radius: 100vw;
|
||||||
|
border: 0;
|
||||||
|
color: var(--tg-theme-bg-color);
|
||||||
|
background: var(--tg-theme-text-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-row input::placeholder {
|
|
||||||
color: #c6c6c6;
|
|
||||||
opacity: .8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hints {
|
.hints {
|
||||||
|
padding: 0 1rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: nowrap;
|
||||||
gap: .35rem;
|
gap: .5rem;
|
||||||
max-height: 4.5rem;
|
overflow-x: auto;
|
||||||
padding-top: .35rem;
|
|
||||||
overflow: auto;
|
|
||||||
scrollbar-width:none;
|
scrollbar-width:none;
|
||||||
}
|
}
|
||||||
|
.hints.a {
|
||||||
.hints:empty {
|
padding: .6rem 1rem .3rem;
|
||||||
display: none;
|
}
|
||||||
|
.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::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hints button, .hint-empty {
|
#controls {
|
||||||
padding: .28rem .68rem;
|
margin: .5rem;
|
||||||
color: #333;
|
|
||||||
background: #f8f8f8;
|
|
||||||
border-radius: 1rem;
|
|
||||||
font-size: .91rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hint-empty {
|
|
||||||
color: var(--panel-hint);
|
|
||||||
background: var(--panel-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.navigator-fields {
|
|
||||||
display: grid;
|
|
||||||
gap: .28rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navigator-fields .hints {
|
|
||||||
padding: 0 0 .08rem 1.7rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.primary-button {
|
|
||||||
min-height: 2.3rem;
|
|
||||||
padding: .34rem 1.1rem;
|
|
||||||
border-radius: .78rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.primary-button {
|
|
||||||
display: block;
|
|
||||||
margin: .5rem 0 0 auto;
|
|
||||||
color: #333;
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.primary-button:disabled {
|
|
||||||
color: #aaa;
|
|
||||||
background: #777;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feedback {
|
|
||||||
min-height: 0;
|
|
||||||
margin: .3rem 0 0;
|
|
||||||
color: #ffd1d1;
|
|
||||||
font-size: .85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.route-card {
|
|
||||||
max-height: none;
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.route-card-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: .7rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.route-title {
|
|
||||||
display: block;
|
|
||||||
margin: .42rem 0 .58rem;
|
|
||||||
font-size: 1.05rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.route-card:not(.route-failed) .route-title {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.route-card-head button {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
padding: .3rem .8rem;
|
|
||||||
color: #fff;
|
|
||||||
background: var(--panel-light);
|
|
||||||
border-radius: .7rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
#route-summary {
|
|
||||||
min-width: 0;
|
|
||||||
margin: 0;
|
|
||||||
color: var(--panel-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.route-summary {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: .62rem;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.route-summary > span:not(.route-arrow) {
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Keep the route direction independent from the active font. */
|
|
||||||
.route-arrow {
|
|
||||||
flex: 0 0 .56rem;
|
|
||||||
width: .56rem;
|
|
||||||
height: .56rem;
|
|
||||||
margin-right: .3rem;
|
|
||||||
color: var(--panel-hint);
|
|
||||||
border-top: .12rem solid currentColor;
|
|
||||||
border-right: .12rem solid currentColor;
|
|
||||||
transform: rotate(45deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.route-steps {
|
|
||||||
display: grid;
|
|
||||||
gap: .72rem;
|
|
||||||
margin-top: .72rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.route-step {
|
|
||||||
padding: .12rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.route-step-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: .9rem;
|
|
||||||
min-height: 1.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.route-step-label {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--panel-text);
|
|
||||||
font-size: 1.08rem;
|
|
||||||
line-height: 1.25;
|
|
||||||
}
|
|
||||||
|
|
||||||
.route-step-icon {
|
|
||||||
flex: 0 0 1.55rem;
|
|
||||||
width: 1.55rem;
|
|
||||||
height: 1.55rem;
|
|
||||||
color: var(--panel-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.route-step-icon svg {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
fill: none;
|
|
||||||
stroke: currentColor;
|
|
||||||
stroke-width: 2.25;
|
|
||||||
stroke-linecap: round;
|
|
||||||
stroke-linejoin: round;
|
|
||||||
}
|
|
||||||
|
|
||||||
.route-step-icon-stairs.descending {
|
|
||||||
transform: rotate(180deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.route-floor-button {
|
|
||||||
margin-top: .42rem;
|
|
||||||
padding: .38rem .72rem;
|
|
||||||
color: var(--panel-text);
|
|
||||||
background: var(--panel-light);
|
|
||||||
border-radius: .72rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.route-step-message {
|
|
||||||
padding: .55rem .62rem;
|
|
||||||
background: var(--panel-muted);
|
|
||||||
border-radius: .72rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.route-step-message strong {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: .12rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.route-step-message > span {
|
|
||||||
color: var(--panel-hint);
|
|
||||||
font-size: .9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
#app.route-view #route-card.route-collapsed {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
|
||||||
align-items: center;
|
|
||||||
gap: .65rem;
|
|
||||||
max-height: none;
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
#app.route-view #route-card.route-collapsed .route-card-head {
|
|
||||||
display: contents;
|
|
||||||
}
|
|
||||||
|
|
||||||
#app.route-view #route-card.route-collapsed #route-summary {
|
|
||||||
grid-column: 1;
|
|
||||||
grid-row: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#app.route-view #route-card.route-collapsed #route-hide {
|
|
||||||
grid-column: 2;
|
|
||||||
grid-row: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
#app.route-view #route-card.route-collapsed #route-title,
|
|
||||||
#app.route-view #route-card.route-collapsed #route-steps {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#block {
|
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 100;
|
|
||||||
inset: 0;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
gap: .3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#controls > .current {
|
||||||
|
background: var(--tg-theme-bg-color);
|
||||||
|
padding: .2rem .4rem .2rem;
|
||||||
|
padding-left: .8rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .4rem;
|
||||||
|
border-radius: 10rem;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
#controls > .current > svg {
|
||||||
|
transform: translateY(.12rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#controls > .select {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: .5rem;
|
||||||
|
gap: .4rem;
|
||||||
|
background: var(--tg-theme-bg-color);
|
||||||
|
border-radius: 1.4rem;
|
||||||
|
}
|
||||||
|
#controls > .select > span {
|
||||||
|
background: var(--tg-theme-secondary-bg-color);
|
||||||
|
padding: .2rem .05rem;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: .5rem;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
#controls > .select > span:nth-child(1) {
|
||||||
|
border-radius: 1rem 1rem .5rem .5rem;
|
||||||
|
}
|
||||||
|
#controls > .select > span:nth-child(6) {
|
||||||
|
border-radius: .5rem .5rem 1rem 1rem ;
|
||||||
|
}
|
||||||
|
#controls > .select > span.active {
|
||||||
|
/* color: var(--tg-theme-bg-color); */
|
||||||
|
background: var(--tg-theme-hint-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
#mp {
|
||||||
|
height: 10px;
|
||||||
|
width: 10px;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
#block {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 100;
|
||||||
|
height: 100vh;
|
||||||
|
/* height: 100dvh; */
|
||||||
|
width: 100vw;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--tg-theme-bg-color);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: #333;
|
|
||||||
background: var(--page);
|
|
||||||
}
|
}
|
||||||
|
#block > h3 {
|
||||||
#block h3 {
|
font-weight: normal;
|
||||||
margin: 1rem 0 .1rem;
|
margin-bottom: 0;
|
||||||
font-weight: 500;
|
}#block > p {
|
||||||
}
|
color: var(--tg-theme-hint-color);
|
||||||
|
|
||||||
#block p {
|
|
||||||
margin: 0;
|
|
||||||
color: #555;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#loader {
|
#loader {
|
||||||
width: 2rem;
|
|
||||||
height: 2rem;
|
height: 2rem;
|
||||||
border: 3px solid #444;
|
width: 2rem;
|
||||||
border-top-color: transparent;
|
border: solid 3px var(--tg-theme-text-color);
|
||||||
border-radius: 50%;
|
border-top: solid 3px transparent;
|
||||||
animation: spin .7s linear infinite;
|
border-radius: 100vw;
|
||||||
|
animation: anim .7s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes anim {
|
||||||
to { transform: rotate(360deg); }
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 600px) {
|
#controls > .select.hidden {
|
||||||
#bottom-panel {
|
display: none;
|
||||||
right: 50%;
|
}
|
||||||
width: min(34rem, 100%);
|
|
||||||
transform: translateX(50%);
|
#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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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>
|
|
||||||