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