Skip to content

Repository files navigation

Scheduler

Telegram-бот автоматически загружает актуальные снимки расписаний:

Данные сохраняются в PostgreSQL, после чего бот выдаёт расписание выбранной группы. Для ИГЭУ номер вводится в формате 1-40, для ИГХТУ — 3/147.

В проект также входят публичный сайт и отдельная веб-админка Scheduler Control Room. Сайт знакомит с проектом и показывает живое число подключённых вузов, пользователей, групп и занятий. Админка показывает состояние источников, историю проходов, группы, занятия и пользователей, позволяет менять интервал и безопасно запускать синхронизацию вручную.

Состояние текущей версии

Текущая версия включает три способа подключения расписаний: встроенные управляемые парсеры, получение готового JSON-снимка и внешний API с подписью Ed25519. Для интеграций доступны SDK для Go и Python, командная строка, JSON-схема, описание OpenAPI и административный мастер подключения.

Рабочие таблицы содержат один полный актуальный снимок расписания вуза; история версий и прошлых семестров сохраняется в JSON снимков. Удаление источника в админке выполнено как обратимое архивирование: настройки, снимки, диагностика и последнее опубликованное расписание не уничтожаются. Коннекторы защищены от повторной отправки и повторного воспроизведения запросов, а размер и состав принимаемых данных строго ограничены. Автоматические проверки проекта включают тесты с детектором гонок, анализ уязвимостей, сборку веб-интерфейсов, браузерные сценарии, чистый запуск миграций, восстановление резервной копии, CodeQL, формирование SBOM и проверяемые сведения о сборке.

Запуск

  1. Заполните .env по образцу .env.example. Быстро заменить все placeholder-секреты случайными локальными значениями можно командой ./scripts/rotate-local-secrets.ps1; Telegram-токен скрипт намеренно не меняет. Для запуска Go-процессов на хосте оставьте DATABASE_HOST=127.0.0.1 и укажите опубликованный порт PostgreSQL. Значения DATABASE_USER и DATABASE_PASSWORD предназначены только для запуска одного процесса на хосте: скопируйте в них пару нужной роли, например DATABASE_BOT_*. В Docker Compose каждый сервис получает собственную пару автоматически.

  2. Подготовьте PostgreSQL, роли и схему:

    docker compose up -d postgres postgres-bootstrap migrator
  3. При необходимости выполните первый импорт отдельно:

    go run ./cmd/sync

    Команда без аргументов обновляет оба вуза. Один источник можно запустить отдельно: go run ./cmd/sync ispu-main или go run ./cmd/sync isuct-main.

  4. Запустите бота:

    go run ./cmd/bot
  5. В другом терминале запустите админку:

    go run ./cmd/admin

    Основной вход выполняется из Telegram Mini App. Для кратковременного локального первоначального входа включите ADMIN_ACCESS_LOGIN_ENABLED=true, откройте http://localhost:18080 и используйте ADMIN_ACCESS_TOKEN. После назначения Telegram-администратора снова выключите этот флаг.

  6. Публичный сайт запускается независимо от админки:

    go run ./cmd/site

    Он доступен на http://localhost:18081. Ссылки на репозиторий и Telegram-бота задаются через PROJECT_URL и BOT_PUBLIC_URL.

В Compose миграции применяет только одноразовый сервис migrator. Бот, админка, сайт и ручная синхронизация при старте лишь сверяют версию и контрольные суммы схемы; при незавершённой миграции они завершаются с понятной ошибкой. Фоновый обработчик запускает проверку сразу при старте, затем каждые пять минут проверяет, пришло ли время обновить источник. ИГХТУ и ИГЭУ обновляются раз в час; неуспешный проход повторяется на следующей проверке. Несколько вузов обрабатываются с ограниченным параллелизмом и отдельным 30-минутным таймаутом на источник, поэтому один медленный сайт не блокирует остальные. Полный первичный импорт занимает несколько минут.

Подписки и уведомления

Профиль пользователя хранится в PostgreSQL и не сбрасывается после перезапуска бота или контейнера. /start восстанавливает основную группу автоматически.

  • выбор новой группы добавляет подписку, не удаляя прежние;
  • компактное меню содержит только частые действия: «Сегодня», «Завтра», «Неделя», «Выбрать дату», «Поиск», «Мои группы» и «Ещё». В обычных разделах оно остаётся доступным, при показе расписания скрывается, чтобы освободить экран, и возвращается кнопкой «Главное меню» или командой /menu;
  • /settings, /subscriptions и кнопка «Мои группы» показывают подписки страницами по семь групп. Нажатие на группу открывает отдельное меню, а удаление требует подтверждения;
  • в «Добавить группу» отправьте вуз и группу одним сообщением: ИГХТУ 4/147, ИГХТУ 4 147 или ИГХТУ 4 курс 147 группа. Для ИГЭУ, например: ИГЭУ 1-ЭЭ-В. Поиск ограничивается указанным вузом; добавление не меняет основную группу. Если аббревиатуры вузов совпадают, бот попросит указать уникальный код источника;
  • команды /today, /tomorrow, /date, /week и /twoweeks используют основную группу. /date без аргументов открывает календарь, а /date 01.09.2026 сразу показывает выбранный день;
  • под расписанием доступны переходы на предыдущий и следующий день, текущую дату, неделю, календарь и настройки выбранной группы. При переходах бот редактирует текущее сообщение, если оно помещается в лимит Telegram;
  • одинаковая навигация доступна для основной группы, дополнительных подписок, результата поиска группы, выбранной даты и расписания преподавателя. Перелистывание, календарь и экспорт сохраняют открытую группу или преподавателя. Просмотр через поиск сам по себе не создаёт подписку;
  • кнопка «Скачать расписание» открывает PNG, JSON, CSV и календарь ICS за показанный день, неделю или две недели. ICS содержит отдельные события с часовым поясом вуза и текущим фильтром подгруппы. Это файл для импорта, не автоматически обновляемая подписка;
  • /reminders включает личные напоминания перед занятиями основной группы. Готовые интервалы доступны кнопками, произвольный задаётся командой /reminders 45; допустимый диапазон — от 5 до 180 минут;
  • в личном чате число от -7 до 7 открывает расписание на соответствующее количество дней назад или вперёд от сегодня. Также можно отправить дату 08.01.2026, название дня Понедельник или Пн, а также Сегодня, Завтра, Неделя или Две недели;
  • преподавателя можно искать по фамилии, части фамилии, имени и отчеству либо инициалам: Константинов, Константинов Е.С. или Поиск Конст. При нескольких совпадениях бот предлагает кнопки выбора, а формат результата настраивается в «Ещё» → «Формат расписания из поиска». По умолчанию используется визуальная таблица;
  • группа без названия вуза (4/185, 4-185, 4 185, 4 курс 185 группа) ищется внутри текущего основного вуза. Квалифицированный запрос ИГЭУ 1-ЭЭ-В одновременно меняет основной вуз и группу, если оба активны;
  • при реальном изменении итогового расписания бот уведомляет всех подписчиков группы. Простая повторная загрузка тех же данных уведомление не создаёт;
  • изменения из редактора также создают уведомления после успешной фиксации в БД;
  • доставки хранятся в PostgreSQL, повторяются после временных ошибок Telegram и не теряются при перезапуске. После отключения уведомлений об изменениях или удаления подписки соответствующие ожидающие сообщения отменяются. Напоминания управляются независимо через /reminders.

Количество «подписок» в админке — это число групп, изменения которых отслеживает пользователь. Это не число входов, сообщений или Telegram-каналов.

Групповые чаты

Бота можно добавить в обычную группу или супергруппу Telegram. У группового чата собственная настройка расписания: она не связана с личными подписками участников и сохраняется в PostgreSQL после перезапуска.

Администратор чата один раз выбирает группу:

/set_chat_group isuct 3/147
/set_chat_group ispu 1-40

После этого всем участникам доступны /today, /tomorrow, /date, /week и /twoweeks. Текущая привязка показывается командой /chat_settings, а /unset_chat_group удаляет её. Telegram показывает разные меню команд в личных и групповых чатах; изменять привязку могут только администраторы соответствующего чата. В групповых чатах постоянная клавиатура не используется: /chat_settings открывает компактную встроенную панель, а удаление привязки из неё требует подтверждения.

Редактор расписания

Раздел «Редактор» показывает итоговое расписание выбранной группы по дням недели. Администратор может добавлять занятия, менять данные существующих записей, скрывать ошибочные занятия и восстанавливать версию источника. Ручные изменения хранятся отдельно от снимка парсера, поэтому не теряются после следующего автоматического обновления. Бот читает итоговое представление расписания и видит изменения сразу после сохранения.

Группа выбирается поиском по всему справочнику, а не коротким списком вариантов. Если расписание содержит занятия по чётным и нечётным неделям, режим «Все» выводит две отдельные недельные доски; общие занятия присутствуют в обеих. Благодаря этому пары с одинаковым временем не выглядят как несколько одновременных занятий одной недели. Закрытые перечисления — вуз, тип занятия, подгруппа, статус и вид недели — остаются обычными селекторами. Произвольный интервал обновления источника вводится числом от 5 до 10 080 минут.

Жизненный цикл учебных групп

Раздел «Справочники» → «Группы» показывает активные, неактивные или все группы выбранного вуза. Список можно упорядочить по названию, от новых к старым или от старых к новым; дата «Появилась» означает момент первого обнаружения группы Scheduler и не меняется при следующих обновлениях. Для каждой записи отдельно отображаются наличие в последнем опубликованном снимке, ручное отключение, количество занятий, подписок, основных групп пользователей, групповых чатов и ручных правок.

  • ручное отключение скрывает группу из пользовательского поиска, расписания и напоминаний, но сохраняет занятия, подписки и настройки; последующий парсинг не включает её самостоятельно;
  • включить вручную отключённую группу можно, пока она присутствует в актуальном снимке источника;
  • группа, исчезнувшая из актуального снимка, сохраняется неактивной для проверки и истории;
  • окончательно удалить можно только группу, которой уже нет в текущем снимке. Перед удалением админка показывает все затрагиваемые связи; подписки удаляются, основные группы пользователей сбрасываются, настройки групповых чатов и рабочие занятия очищаются. Исторические снимки и аудит остаются доступными.

Изменения состояния и удаления выполняются под той же блокировкой публикации, что и обновление расписания, поэтому ручное действие не может пересечься с транзакцией парсера.

Горячая линия

Команда /hotline и кнопка «Горячая линия» в главном меню позволяют пользователю выбрать один из двух сценариев: сообщить об изменении уже подключённого расписания или предложить новое учебное заведение. Бот выдаёт подходящий шаблон и сохраняет заполненное обращение в PostgreSQL.

  • одновременно у пользователя может быть не более трёх открытых обращений;
  • все действующие администраторы получают уведомление, а бывшие администраторы исключаются из доставки непосредственно перед отправкой;
  • заявки рассматриваются в разделе «Обращения» админки: их можно принять в работу или отклонить;
  • результат и комментарий администратора сохраняются и доставляются пользователю через надёжную очередь с повторными попытками после временных ошибок Telegram;
  • решение фиксируется в административном журнале.

Подключение новых расписаний

Scheduler поддерживает три независимых режима. Во всех случаях данные проходят одинаковую валидацию, сравнение с последней доверенной версией, карантин и визуальную проверку.

  1. Управляемый парсер — основной путь. Разработчик реализует небольшой контракт из parser/v1 и отправляет код в проект. После проверки кода Scheduler сам запускает парсер, ограничивает параллелизм, повторяет временные ошибки и ведёт историю снимков. Сервер и Docker автору парсера не нужны.
  2. Получение готового JSON. Если источник уже отдаёт готовый снимок расписания версии 1, в мастере достаточно указать публичный HTTPS URL. Scheduler безопасно забирает JSON по расписанию; локальные и приватные сетевые адреса блокируются.
  3. Внешний коннектор с отправкой данных. Организация запускает интеграцию самостоятельно и отправляет снимки через API внешних коннекторов с подписью Ed25519. Этот режим нужен, когда код нельзя передать проекту или источник доступен только владельцу.

Рабочий пример основного пути — integrations/ivgpu. Он реализует только получение групп и занятий из официального API ИВГПУ; общий обработчик парсеров выполняет запуск, снимки, карантин, публикацию и наблюдаемость внутри обычного контейнера бота. Отдельный контейнер, ключ внешнего API и постоянная машина автора для ИВГПУ не требуются.

Пользовательская точка входа находится на публичном сайте в разделе «Разработчикам», а в Telegram — в меню «Ещё» → «Подключить своё расписание» или по команде /connect_source.

Структура веб-интерфейсов

Админка и публичный сайт являются независимыми React/Vite-проектами. Исходный код админки находится в web/admin: страницы — в src/pages, общие компоненты — в src/components, хуки — в src/hooks, слой API — в src/api. Каталог internal/adminui/dist содержит только результат рабочей сборки и маленький Go-адаптер embed.go. Публичный SPA находится в web/site: точка сборки приложения — в src/app, главная страница и её секции — в src/pages/home, шапка и подвал — в src/widgets, загрузка живой статистики — в src/features/public-info, а HTTP-клиент, конфигурация, переиспользуемые элементы и модульные стили — в src/shared. Сайт собирается независимо и обслуживается отдельным Go-процессом cmd/site; его рабочая сборка встраивается через internal/siteui. Ни исходники, ни API публичного сайта не являются частью админки.

Админка в Telegram

Назначение роли is_admin само по себе не подключает Mini App — оно только разрешает пользователю пройти серверную проверку при запуске из Telegram.

Этот Mini App предназначен только для администраторов. Обычным пользователям доступны команды бота и публичный сайт с описанием проекта, статистикой и инструкцией по подключению источников. Отдельного пользовательского веб-кабинета с расписанием пока нет.

Для подключения:

  1. Опубликуйте админку по доступному из интернета HTTPS-адресу.
  2. Запишите этот адрес в ADMIN_PUBLIC_URL и перезапустите сервисы.
  3. Откройте диалог с ботом и отправьте /start или /admin. Бот автоматически назначит администраторам персональную кнопку меню с адресом ADMIN_PUBLIC_URL; если Telegram временно недоступен, настройка будет повторяться каждые 30 секунд.

После /start или /help бот обновляет персональное меню команд: обычному пользователю показываются только основные команды, а администратору дополнительно — /admin и /metrics. Права при выполнении этих команд всё равно заново проверяются по данным PostgreSQL. Состояние кнопки Mini App сохраняется в профиле: массовая синхронизация повторяет только необработанные изменения роли или адреса админки. Блокировка бота отдельным пользователем не запускает бесконечный повтор полного списка.

Сервер проверяет подпись initData, срок её действия и флаг users.is_admin; пользователь с ролью администратора входит без ADMIN_ACCESS_TOKEN.

Первого Telegram-администратора можно назначить напрямую в PostgreSQL:

UPDATE users
SET is_admin = TRUE,
    admin_role = 'owner',
    telegram_menu_fingerprint = ''
WHERE id = '<telegram_id>';

Последующие роли меняются в разделе «Пользователи». Все административные изменения пишутся в отдельный журнал аудита. Ручной запуск и фоновый обработчик защищены консультативной блокировкой PostgreSQL, поэтому один источник не парсится одновременно из двух процессов.

Для полного запуска через Docker:

docker-compose up -d --build

Compose публикует PostgreSQL только локально на 127.0.0.1:55432, админку — на http://localhost:18080, а публичный сайт — на http://localhost:18081. Эти порты настраиваются через POSTGRES_HOST_PORT, ADMIN_HOST_PORT и SITE_HOST_PORT; внутренние контейнерные порты остаются изолированными. Адреса привязки админки и сайта по умолчанию равны 127.0.0.1 и при необходимости явно меняются через ADMIN_BIND_ADDRESS и SITE_BIND_ADDRESS.

Compose не передаёт контейнерам общий .env: bootstrap создаёт отдельные роли scheduler_migrator, scheduler_bot, scheduler_admin, scheduler_site, scheduler_backup и scheduler_restore. Публичный сайт и резервное копирование имеют только чтение, приложение не может менять схему, а CREATEDB доступно лишь временной роли проверки восстановления. Поэтому компрометация одного контейнера не раскрывает остальные секреты и не даёт ему полномочия всего стека. При использовании внешнего PostgreSQL bootstrap подключается к DATABASE_CONTAINER_HOST:DATABASE_CONTAINER_PORT с режимом DATABASE_CONTAINER_SSLMODE; TLS-файлы передаются как read-only mounts по путям PGSSLROOTCERT, PGSSLCERT и PGSSLKEY.

Защита данных и наблюдаемость

В разделе «Источники» администратор может временно отключить автоматическое и ручное обновление конкретного источника, а затем включить его обратно. При повторном включении обработчик заберёт источник на ближайшей проверке. Архивирование источника требует отдельного подтверждения и недоступно во время парсинга. Оно останавливает обновления и убирает источник из рабочего списка, но сохраняет настройки, историю запусков, диагностику, снимки и уже опубликованное расписание.

Парсер сначала собирает полный снимок источника, проверяет структуру и сравнивает количество групп и занятий с последним опубликованным снимком. Подозрительные данные не изменяют рабочее расписание: они попадают в карантин админки, где их можно принять или отклонить. Для опубликованного снимка доступен откат.

Для активного источника ручное подтверждение сразу публикует снимок. Для тестового или ожидающего проверки источника оно только присваивает статус approved: пользователи по-прежнему видят прежнюю версию. Новый источник и его одобренный снимок применяются одной транзакцией при активации; в неё же входят приостановка прежнего источника, обновление метаданных и постановка уведомлений в outbox. После публикации снимок становится новой доверенной базовой версией. Следующие результаты сравниваются с его сохранённым JSON, а не с рабочими таблицами. Семантически идентичный результат больше не требует повторного подтверждения, даже если источник изменил порядок элементов или технические идентификаторы записей. Для каждого вуза в один момент может быть активен только один источник. Ограничение закреплено в PostgreSQL, а публикации снимков дополнительно сериализуются по идентификатору вуза. После смены источника прежний источник уже не может дописать результат начатого ранее прохода.

Для Connector API чётность регулярных занятий закрепляется явным двухнедельным циклом от начала семестра. Встроенные сайты, которые отдают в valid_from первую фактическую дату конкретной пары, сохраняют эту опору. Одна и та же нормализованная модель затем используется выдачей бота, поиском, групповыми чатами и напоминаниями.

Массовый одинаковый сбой источника также не приводит к полному обходу всех групп. После трёх одинаковых диагностических ответов проход останавливается, рабочий снимок сохраняется, а автоматические повторы выполняются с растущей задержкой: 5, 10, 20, 40, 80 минут и далее не чаще одного раза в 6 часов. Успешная публикация сбрасывает счётчик и возвращает обычный интервал источника.

В parser_diagnostics отдельно хранятся ограниченные технические сведения об ответе: категория, HTTP-статус, тип, размер, SHA-256 и безопасный фрагмент до 1024 символов. Заголовки, cookie и данные авторизации туда не записываются. Основная карточка источника показывает только короткую сводку, число ошибок подряд и время следующей попытки; диагностика раскрывается отдельно.

  • GET /api/health — состояние HTTP-процесса;
  • GET /api/ready — готовность процесса, PostgreSQL и успешность последних проходов воркеров;
  • GET /metrics — метрики источников, очередей и обработчика напоминаний в формате Prometheus; адрес требует Authorization: Bearer <ADMIN_METRICS_TOKEN>;
  • /metrics — краткие операционные метрики в Telegram, доступные только действующим администраторам;
  • /sources — публичная информация об источниках расписания;
  • /quiet_hours 22:00-07:00 — пользовательский интервал без уведомлений;
  • /privacy, /my_data, /delete_me — сведения о данных пользователя, экспорт и удаление профиля в Telegram.

После включения встроенного режима в BotFather расписанием можно поделиться в любом диалоге без добавления бота в чат: @имя_бота сегодня, @имя_бота завтра или @имя_бота 08.08.2026.

Оба интерфейса собираются отдельно, а готовые файлы встраиваются в соответствующие Go-бинарники, поэтому Node.js-сервер в продакшене не нужен.

Безопасность

Исправления безопасности выпускаются для последней опубликованной версии и текущей ветки master. Не публикуйте в обычных задачах GitHub токены Telegram, закрытые ключи коннекторов, дампы базы, персональные данные или работающий пример эксплуатации уязвимости. Используйте приватные сообщения об уязвимостях GitHub для этого репозитория и укажите затронутую версию, влияние, безопасные шаги воспроизведения и, если возможно, минимальный пример. Полный отчёт должен быть подтверждён сопровождающим проекта в течение семи дней; раскрытие выполняется после подготовки исправления и предоставления пользователям разумного времени на обновление.

Пример Docker Compose публикует PostgreSQL и админку только на локальном интерфейсе. Оператор развёртывания отвечает за HTTPS, ротацию секретов и сетевые ограничения. Для production необходимо подключить внешнее хранилище резервных копий и включить BACKUP_REQUIRE_OFFSITE=true; копии шифруются аутентифицированным форматом age по публичному X25519 recipient, а закрытый identity используется только при проверке восстановления. Оператор также отвечает за обновление хоста с контейнерной средой. Закрытый ключ внешнего коннектора показывается один раз и должен храниться вне Scheduler в хранилище секретов. Перед production-развёртыванием выполните чек-лист ротации, резервного копирования, RPO/RTO, retention и восстановления из раздела «Эксплуатация». Release workflow публикует образ только для commit SHA, на котором уже завершился полный CI.

Участие в разработке

Для новой пользовательской функции или несовместимого изменения контракта коннекторов сначала создайте задачу GitHub. Небольшое исправление, восстановление парсера или уточнение документации можно сразу оформить запросом на слияние. Никогда не добавляйте в Git .env, резервные копии, учётные данные коннекторов, Telegram-токены и настоящие пользовательские данные.

Новый управляемый парсер должен реализовывать parser/v1, располагаться в integrations/<parser-id>, не импортировать internal и иметь регрессионные тесты на локальных примерах ответов. Изменение внешнего контракта требует синхронно обновить Go- и Python-валидацию, JSON-схему, описание OpenAPI и сведения о совместимости. Миграции базы добавляются только вперёд и не должны уничтожать существующие данные. В описании запроса на слияние укажите видимое поведение, влияние на схему, способ отката и выполненные проверки.

Обсуждение должно оставаться уважительным, конструктивным и предметным. Не допускаются травля, дискриминация, угрозы, публикация частных сведений и намеренно небезопасные изменения. Техническая критика должна относиться к поведению и проверяемым фактам, а не к личности участника. О нарушениях следует сообщать сопровождающему проекта приватно; он может удалить опасные материалы и ограничить участие для защиты сообщества и пользователей.

Проверка

Для production есть отдельный Compose-профиль, предварительная проверка конфигурации и готовые правила оповещений. Порядок запуска, TLS, права БД и подключение monitoring описаны в руководстве по эксплуатации. Локальные настройки не нужно выдавать за production-конфигурацию.

Поиск групп предлагает варианты при одной опечатке, не меняя подписку автоматически. Пробелы и регистр нормализуются; подгруппы 1–100 доступны на страницах настроек, общие занятия сохраняются. Команда /twoweeks есть в меню личного и группового чата, а /unset_chat_group требует подтверждения.

В .gitleaksignore сохранено одно точное исключение для исторического ложного срабатывания на синтетический идентификатор группы в тесте. Оно не исключает файл или правило целиком. Настоящие ключи нельзя добавлять в этот список вместо отзыва и удаления из истории.

npm ci --prefix web/admin
npm run build --prefix web/admin
npm ci --prefix web/site
npm run build --prefix web/site
go test -race ./cmd/... ./connector/... ./parser/... ./integrations/... ./internal/... ./migration/...
go vet ./cmd/... ./connector/... ./parser/... ./integrations/... ./internal/... ./migration/...
npm run lint --prefix web/admin
npm run test:e2e --prefix web/admin
npm run lint --prefix web/site

Рабочая эксплуатация, резервное копирование, восстановление и настройка обратного прокси описаны в docs/operations.md, полный порядок выпуска — в docs/release-checklist.md, а подключение собственного парсера — в руководствах по управляемым парсерам и внешнему API коннекторов.

Проверка на настоящем сайте (полный список групп и расписание контрольной группы):

$env:ISUCT_INTEGRATION_TEST='1'
go test ./internal/scraper/isuct -run TestLiveISUCTAdapter -v -count=1

Живой тест ИГЭУ:

$env:ISPU_INTEGRATION_TEST='1'
go test ./internal/scraper/ispu -run TestLiveISPUAdapter -v -count=1

Сайт отдаёт не обычную HTML-форму: автодополнение работает только через POST и ограничивает ответ 15 группами, а расписание приходит в формате Drupal AJAX JSON. Адаптер обходит ограничение автодополнения, повторяет временно повреждённые ответы и публикует данные группы в БД транзакционно.

ИГЭУ использует ASP.NET WebForms. Его адаптер обходит все опубликованные виды расписания, факультеты, курсы, группы и подгруппы, а экзамены, зачёты и сессии сохраняет по точным календарным датам.

About

Platform for convenient scheduling using a Telegram bot

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages