MAX-olymp

Мини-приложение и чат-бот MAX

Назначение продукта

Продукт создан специально для национального мессенджера MAX: чат-бот и мини-приложение.

Сейчас массовых решений для отслеживания олимпиад нет: есть только календари, куда данные приходится вносить вручную. По результатам нашего опроса, более 75% учащихся пропускали этапы олимпиад из-за сложности отслеживания сроков, и примерно столько же хотели бы иметь для этого удобное мини-приложение.

Целевая аудитория продукта — учащиеся 5–11 классов, участвующие или желающие участвовать в олимпиадах.

Продукт автоматически отслеживает изменения в данных об олимпиадах (название, уровень, даты и др.), которые берутся с портала Olimpiada.ru, составляет индивидуальный календарь, помогающий отслеживать сроки олимпиад, а также присылает напоминания о ближайших этапах. Пользователю достаточно указать класс и отметить олимпиады, в которых он участвует.

Так олимпиадники не пропустят этапы из-за забытых сроков: вся информация и напоминания приходят в привычный мессенджер.

Основной пользовательский сценарий

Открыть мини-приложение в MAX –> выбрать класс обучения (его можно позже изменить в настройках при необходимости) –> просмотреть каталог олимпиад (олимпиады отсортированы по предметам) с фильтрами (по наличию информации о датах, по уровню), воспользоваться поиском –> выбрать для участия интересующие олимпиады нажатием кнопки “Буду участвовать”. После этого выбранные олимпиады автоматически переносятся в “Мои олимпиады” и “Календарь” –> в персонализированном календаре ознакомление со сроками, сравнивание сроков, выбор точных дат для написания –> получение напоминаний в push-уведомлениях о приближающемся окончании этапа или об участии в заранее выбранную дату –> успешная и своевременная регистрация в выбранных олимпиадах, участие и отслеживание своего статуса.

Состав и архитектура

В решении три части: бэкенд на FastAPI, мини-приложение на JavaScript без сборки и база Postgres. Бэкенд в одном процессе обслуживает API мини-приложения, вебхук бота и ежедневную рассылку. Всё запускается в Docker и работает по HTTPS на одном домене:

пользователь в MAX
        │ HTTPS
        ▼
https://my-olymp.ru — docker-compose.prod.yml

  nginx     :443 TLS (Let's Encrypt), :80 → редирект на HTTPS
    ├── /                          статика мини-приложения
    └── /api/*, /webhook, /health
          │
          ▼
  backend   FastAPI, наружу не открыт ──► db  Postgres 16
          │
          └──► platform-api2.max.ru: сообщения бота

  certbot   продлевает сертификат

Локально docker-compose.yml поднимает те же контейнеры, но без TLS: всё открывается на http://localhost:8080.

Что есть в приложении

В мини-приложении четыре вкладки и окно настроек:

Экран Что там API
Поиск предметы с числом олимпиад для класса пользователя, список олимпиад по предмету, карточка олимпиады с этапами и кнопкой «Буду писать» GET /api/catalog/subjects, GET /api/catalog/olympiads, GET /api/catalog/olympiads/{id}
Новости события по сохранённым олимпиадам: «Появились даты», «Срочно», «Скоро», «Позже», «Ожидает ответа от тебя» GET /api/me/news
Мои сохранённые олимпиады и ответы «прошёл / не прошёл» по этапам GET /api/me/olympiads, GET, POST и DELETE /api/me/olympiads/{id}, PUT /api/me/stages/{id}/result
Календарь этапы на сетке месяца и план — какой этап в какой день писать GET /api/me/calendar, PUT и DELETE /api/me/stages/{id}/plan
Настройки класс, уведомления, режим для дальтоников GET и PATCH /api/me/settings

Правила механики проверяет сервер, в personal/rules.py:

Если действие нарушает правило, сервер отвечает 409 или 422 с кодом ошибки, и фронтенд выбирает реакцию по коду. Все эндпоинты, поля и коды описаны в docs/api-for-frontend.md; там есть и те, что интерфейс пока не вызывает: события одного дня календаря и отмена ответа по этапу.

Бот делает три вещи: на запуск присылает приветствие с кнопкой открытия приложения, на любое сообщение отвечает той же кнопкой и раз в день присылает сводку по сохранённым олимпиадам (см. «Утренняя сводка» ниже).

Файлы

.
├── docker-compose.yml        запуск всех локальных компонентов одной командой
├── docker-compose.prod.yml   запуск на сервере my-olymp.ru
├── .dockerignore             контекст образа фронтенда: только статика и nginx
├── .env.example              шаблон переменных окружения, без секретов
├── certs/                    сертификаты Минцифры (сами файлы в git не хранятся)
├── deploy/
│   ├── nginx/                local.conf — локально, my-olymp.ru.conf — TLS на 443,
│   │                         proxy.conf — общие настройки прокси
│   ├── Caddyfile             TLS, статика и прокси для сервера без Docker (Windows)
│   └── diagnose.sh / .ps1    снимок состояния сервера, ничего не меняет
├── docs/
│   ├── api-for-frontend.md   эндпоинты, поля и коды ошибок
│   ├── deploy-my-olymp.md    сервер с нуля: домен, сертификат, вебхук
│   ├── deploy-without-docker.md  то же на Windows без Docker
│   ├── launch-in-max.md      как привязать приложение к боту в MAX
│   └── frontend-todo.md      что осталось поправить во фронтенде
├── tools/                    парсер расписания olimpiada.ru и его тесты
├── frontend/                 мини-приложение, отдаётся с того же домена
│   ├── index.html            разметка и подключение MAX Bridge
│   ├── app.js                экраны, вкладки, календарь
│   ├── api.js                клиент бэкенда: Api и ApiError
│   ├── style.css
│   └── Dockerfile            nginx со статикой и прокси в бэкенд
└── backend/
    ├── Dockerfile            два образа: runtime и dev
    ├── .dockerignore         контекст образа бэкенда
    ├── requirements.txt      зависимости времени выполнения
    ├── requirements-dev.txt  + pytest
    ├── app/
    │   ├── main.py           сборка приложения, запуск и остановка
    │   ├── config.py         настройки из переменных окружения
    │   ├── clock.py          «сегодня» в часовом поясе приложения
    │   ├── security/
    │   │   ├── launch_data.py   проверка подписи данных запуска
    │   │   ├── deps.py          аутентификация запросов мини-приложения
    │   │   └── testing.py       генератор подписанных данных для отладки
    │   ├── max_api/
    │   │   ├── client.py        HTTP-клиент Bot API MAX
    │   │   └── rate_limiter.py  очередь под лимит 2 сообщения в секунду
    │   ├── bot/
    │   │   ├── router.py        приём событий на /webhook
    │   │   ├── handlers.py      обработка событий
    │   │   ├── keyboards.py     кнопка открытия мини-приложения из чата
    │   │   └── subscription.py  проверка подписки на события при старте
    │   ├── catalog/
    │   │   ├── models.py        олимпиады, этапы, предметы, категории
    │   │   ├── dates.py         разбор дат этапов с точностью
    │   │   ├── subject_importer.py   импорт каталога по предметам
    │   │   ├── schedule_importer.py  импорт расписания с olimpiada.ru
    │   │   ├── importer.py      импорт выгрузки postupi.online
    │   │   └── presentation.py  статусы этапов и таймеры для интерфейса
    │   ├── personal/
    │   │   ├── models.py        мои олимпиады, ответы, планы, настройки
    │   │   ├── rules.py         правила механики: лимит, блокировки, окна
    │   │   ├── queries.py       выборки по пользователю, общие для API и бота
    │   │   ├── news.py          лента «Новости»
    │   │   ├── reminders.py     текст утренней сводки
    │   │   └── reminder_service.py  ежедневная рассылка сводки
    │   ├── api/
    │   │   ├── router.py        профиль пользователя
    │   │   ├── catalog.py       предметы, список и карточки олимпиад
    │   │   └── personal.py      мои олимпиады, календарь, новости, настройки
    │   └── db/                  модели пользователя и подключение к базе
    ├── alembic.ini
    ├── migrations/           версии схемы базы данных
    ├── data/                 выгрузки источников, из них наполняется каталог
    ├── scripts/              связь с MAX, вебхук, импорт, рассылка, сквозная проверка;
    │                         seed.py наполняет пустой каталог при старте
    └── tests/                335 тестов

Два контура аутентификации

Контур Кто обращается Как подтверждает себя
POST /webhook платформа MAX заголовок X-Max-Bot-Api-Secret
/api/* мини-приложение подпись данных запуска в Authorization

Секрет вебхука бэкенд передаёт MAX сам, при подписке на события (scripts.setup_webhook). Проверка подписи для мини-приложения описана ниже.

Почему бот и API в одном сервисе

MAX принимает вебхук только по HTTPS на порту 443 и с сертификатом доверенного центра. Если разнести бота и API по разным сервисам, понадобятся два публичных адреса и два сертификата. Поэтому сервис один, а bot/ и api/ разделены только в коде: при необходимости их можно развести, не переписывая логику.

Мини-приложение отдаётся с того же домена, что и API. Запросы идут на свой origin, поэтому в проде нет ни preflight, ни CORS. HTTPS обслуживает контейнер nginx с конфигом из deploy/nginx/, сертификат Let’s Encrypt выпускает и продлевает контейнер certbot. Бэкенд наружу не открыт, до него доходят только запросы через nginx.

Проверка данных запуска

MAX передаёт мини-приложению подписанную строку запуска, а фронтенд отправляет её на сервер в заголовке Authorization: tma <initData>. Подпись считается от токена бота, поэтому проверить её может только сервер.

Алгоритм взят из документации MAX, реализация — в launch_data.py:

  1. строка разбирается на пары, каждый ключ должен встречаться ровно один раз (иначе через дубликат можно подсунуть значение в обход подписи);
  2. hash откладывается отдельно и в подписываемые данные не входит;
  3. значения URL-декодируются, пары сортируются по ключу и склеиваются через \n;
  4. secret_key = HMAC-SHA256(key="WebAppData", msg=BOT_TOKEN);
  5. hash = hex(HMAC-SHA256(key=secret_key, msg=launch_params));
  6. результат сравнивается с присланным hash в постоянном времени.

Ещё проверяется auth_date: строка старше LAUNCH_DATA_TTL отклоняется, чтобы перехваченная строка не работала вечно. initDataUnsafe сервер не использует, его можно подделать. Идентификатор пользователя берётся только из проверенной строки и никогда из тела запроса.

Олимпиада по предмету

Уровень по перечню РСОШ зависит от предмета: Московская олимпиада школьников по астрономии — I уровня, по истории — II. В текущем каталоге 34 олимпиады проходят сразу по нескольким предметам, и у 5 из них уровень разный.

Поэтому одна запись в каталоге — это пара «олимпиада + предмет». Описание, сайт, классы и организаторы у всех предметов одной олимпиады одинаковые и просто дублируются. «Ломоносов» встречается в каталоге 13 раз, олимпиада СПбГУ — 12, и в «Мои олимпиады» они тоже добавляются по предмету.

Миграции

Схему ведёт Alembic. Бэкенд применяет миграции сам при старте, до запуска HTTP-сервера, так что одна команда поднимает и пустую базу, и существующую. Потом scripts.seed заполняет каталог из backend/data/, если он пустой, а непустой не трогает.

После изменения моделей:

docker compose run --rm backend alembic revision --autogenerate -m "что меняем"

Файл появится в backend/migrations/versions/. Его нужно просмотреть и закоммитить, применится он при следующем старте.

Точность дат

Приложение считает «до этапа N дней», рисует календарь по дням и присылает сводку о событиях дня. Для этого нужен точный день, а он известен не всегда. Расписание с olimpiada.ru даёт дни, а в выгрузке postupi.online у 32 этапов из 97 есть только месяц и год («мар 2027»). Если считать по такой дате дни, получится правдоподобное, но выдуманное число.

Поэтому у каждой даты хранится её точность, и API отдаёт её явно:

Поле Смысл
start_precision day — известен день, month — только месяц
days_until_start число дней или null, если точного дня нет
plannable false — этап нельзя поставить в календарь
raw_date_range исходная строка источника, например «мар 2027»

Если days_until_start равен null, фронтенд не показывает таймер и выводит raw_date_range как есть. Как этап рисуется в календаре (полоса, кружок или дедлайн), описано в разделе «Форма сроков этапа».

Тип этапа (kind: регистрация, отборочный, финал) определяется по названию, в источнике его нет. Если определить не получилось, ставится other; в расписании таких 16 этапов из 147.

Лента «Новости»

Ленту сервер не хранит, а считает при каждом запросе из дат этапов и ответов пользователя (personal/news.py). Так она не может разойтись с календарём, когда даты в источнике меняются.

Категория Что в ней
Срочно этап начинается или заканчивается сегодня
Скоро начинается или заканчивается в ближайшие 7 дней
Позже всё остальное впереди
Ожидает ответа от тебя этап закончился, а ответа «прошёл / не прошёл» нет
Появились даты у этапа появились даты, пока олимпиада была в «Моих»

Каждый этап попадает ровно в одну из первых четырёх категорий (ещё есть «Завершённые», их интерфейс пока не показывает). «Появились даты» идёт поверх: этап с новыми датами остаётся и в своей категории. В «Срочно» и «Скоро» попадают только этапы с точным днём.

Утренняя сводка

Раз в день, в REMINDER_TIME, бот присылает одно сообщение по всем олимпиадам, за которыми следит пользователь. Одно, а не по сообщению на событие: в сезон у школьника с пятью олимпиадами бывает по нескольку событий в день, и десяток уведомлений он просто выключит.

В сводку попадают только события с точным днём, у одного этапа — не больше одной строки:

Блок Что в нём
Сегодня запланированный на сегодня этап, начало этапа, последний день
Завтра то же про завтра
Через 3 дня последний день этапа, чтобы успеть доделать работу
Как прошло? этап закончился вчера, а ответа «прошёл / не прошёл» нет

Если сказать нечего, сообщения не будет: пустые сводки приучают не открывать сообщения бота.

Сводку получают те, кто запустил бота (иначе MAX не даст ему писать), не выключил уведомления в настройках и сохранил хотя бы одну олимпиаду.

Время этапов в источнике не указано, только даты. Поэтому напоминания «за час до начала» нет, его заменяет сводка накануне и утром в день события.

Чтобы не написать человеку дважды, перед отправкой в reminder_log вставляется строка «пользователь + день», и первичный ключ не даёт вставить её второй раз. Бэкенд можно перезапускать: если он поднялся в 9:40 после сбоя, то допишет тем, кому не успел, а остальных не тронет. Если MAX недоступен, проход прерывается и повторяется через 10 минут. Ошибка по одному человеку (например, он удалил диалог) на остальных не влияет. После 21:00 рассылка не начинается.

Проверить на себе, не дожидаясь утра:

python -m scripts.send_reminders --users                     # найти свой id
python -m scripts.send_reminders --dry-run                   # что уйдёт сегодня, без отправки
python -m scripts.send_reminders --dry-run --date 2026-10-14 # как будто сегодня 14 октября
python -m scripts.send_reminders --user <id> --date 2026-10-14 --force

Если сводка кому-то не приходит, --user <id> покажет причину: от человека не было событий бота, уведомления выключены или нет сохранённых олимпиад. С --dry-run текст сводки выводится и в этом случае. Что бот уже отправил, записано в reminder_log.text.

О том, что человек запустил бота, сервер узнаёт только из событий MAX, а они приходят только при подписке на вебхук. При старте бэкенд проверяет подписку и пишет в лог «Подписка на события MAX: …», если всё в порядке, или «MAX не присылает события на …», если нужно запустить python -m scripts.setup_webhook. Событие bot_started приходит один раз, поэтому тех, кто запустил бота до подписки, сервер отметит по первому их сообщению боту.


Запуск

Что нужно заранее

Одна команда

docker compose up --build

Поднимаются три сервиса:

Сервис Что это Адрес
db Postgres 16 127.0.0.1:5432, только для клиента БД
backend API и бот; при старте применяет миграции и наполняет пустой каталог http://localhost:8000
web nginx: мини-приложение и прокси /api, /webhook, /health в бэкенд http://localhost:8080

Каталог олимпиад наполняется сам при первом старте из выгрузок в backend/data/ — после up приложение сразу с данными, импортировать руками ничего не нужно.

.env для запуска не обязателен: без него поднимается всё, только бот не знает своего токена. Для работы с MAX скопируйте шаблон и заполните BOT_TOKEN:

cp .env.example .env

В PowerShell:

Copy-Item .env.example .env

Подробнее о запуске в разделе “Пошаговый сценарий проверки”

Docker-конфигурация

Файл Назначение
backend/Dockerfile образ бэкенда: runtime — рабочий, dev — с pytest для тестов
frontend/Dockerfile образ фронтенда: nginx со статикой мини-приложения
docker-compose.yml все локальные компоненты одной командой
docker-compose.prod.yml сервер: те же образы плюс TLS и certbot
.dockerignore, backend/.dockerignore в контекст сборки не попадают .env, certs/, .git и прочее лишнее
.env.example шаблон переменных, токены и пароли пустые

Сборка обоих образов с нуля занимает меньше минуты (замер — 16–30 секунд без учёта загрузки базовых образов): зависимости ставятся готовыми колёсами, ничего не компилируется, слой с зависимостями кэшируется отдельно от кода.

Остановка и повторный запуск

docker compose down

Данные Postgres лежат в именованном томе и переживают остановку — следующий docker compose up поднимет то же состояние.

Удалить данные вместе с контейнерами:

docker compose down -v

Перезапустить один сервис, не трогая базу:

docker compose restart backend

Пересобрать после изменения кода или зависимостей:

docker compose up --build

Запуск на сервере

Локальный docker-compose.yml TLS не делает и слушает обычный HTTP. Для сервера есть отдельный файл — он собирает те же образы, но nginx получает сертификат Let’s Encrypt и слушает 80 и 443, а порты бэкенда и базы с хоста убраны:

docker compose -f docker-compose.prod.yml up -d --build

Первый выпуск сертификата, перерегистрация вебхука и привязка адреса к боту — docs/deploy-my-olymp.md.


Переменные окружения

Полный список с комментариями — в .env.example. Обязательные для рабочего запуска отмечены звёздочкой.

Переменная По умолчанию Назначение
BOT_TOKEN * — токен чат-бота, даёт полный доступ к управлению
BOT_USERNAME — ник бота для диплинка https://max.ru/<botName>
MAX_API_BASE_URL https://platform-api2.max.ru домен API
MAX_API_TIMEOUT 15 таймаут запросов к MAX, секунды
PUBLIC_BASE_URL https://my-olymp.ru публичный HTTPS-адрес бэкенда
WEBHOOK_PATH /webhook путь endpoint’а вебхука
WEBHOOK_SECRET * — значение заголовка X-Max-Bot-Api-Secret
LAUNCH_DATA_TTL 86400 предельный возраст auth_date, секунды
CORS_ORIGINS https://my-olymp.ru origin фронтенда через запятую
DATABASE_URL задаётся в compose строка подключения к Postgres
POSTGRES_PASSWORD локально app пароль базы; на сервере обязателен
EXTRA_CA_CERTS_DIR certs каталог с сертификатами Минцифры, путь от рабочего
APP_ENV development в production скрывается /docs
APP_TIMEZONE Europe/Moscow в каком поясе считается «сегодня»
REMINDERS_ENABLED true рассылать ли утреннюю сводку
REMINDER_TIME 09:00 во сколько, в поясе APP_TIMEZONE
LOG_LEVEL INFO уровень логирования

Секретов в репозитории нет: .env и certs/* перечислены в .gitignore. Если токен хоть раз попал в историю коммитов, считайте его скомпрометированным и перевыпустите.

Порты

Порт Сервис Переменная Примечание
8080 web WEB_PORT локально: мини-приложение и API на одном адресе
8000 backend BACKEND_PORT локально: бэкенд в обход nginx; в проде на хост не публикуется
5432 Postgres POSTGRES_PORT локально, только 127.0.0.1: для клиента БД
80 nginx — подтверждение домена и редирект на HTTPS (прод)
443 nginx — HTTPS: мини-приложение и API (прод)

Наружу MAX ходит по 443 — его обеспечивает nginx, а не приложение. Локально тот же nginx слушает обычный HTTP на 8080.

Зависимости

Версии зафиксированы точно в requirements.txt (время выполнения) и requirements-dev.txt (тесты).

FastAPI и uvicorn, pydantic и pydantic-settings, httpx и certifi, SQLAlchemy с asyncpg. Образ бэкенда собирается из python:3.12-slim, фронтенда — из nginx:1.30-alpine; база — готовый postgres:16-alpine, certbot на сервере — certbot/certbot:v5.8.0.

Внешние сервисы и интеграции

Их нельзя воспроизвести внутри Docker — для проверки решения нужны условия ниже.

Сервис Назначение Что нужно для проверки
Платформа MAX доставка событий бота, отправка сообщений действующий BOT_TOKEN
Сертификаты Минцифры TLS-соединение с platform-api2.max.ru файлы в certs/, скачиваются с Госуслуг
Домен my-olymp.ru публичный адрес приложения и вебхука на 443 A-запись на сервер и сертификат Let’s Encrypt
CDN MAX библиотека MAX Bridge для фронтенда доступ в интернет из браузера

Публичный адрес нужен потому, что MAX доставляет события только на HTTPS-адрес с сертификатом доверенного центра; самоподписанные не принимаются.

Раньше эту роль играл туннель ngrok, и его адрес приходилось помнить и перерегистрировать. Теперь адрес постоянный, и туннель нужен только для отладки: тогда его адрес подставляется в PUBLIC_BASE_URL, а подписка оформляется заново.

Как поднять сервер с нуля — docs/deploy-my-olymp.md.

Работа с данными

Хранятся только те данные, что приходят из проверенных данных запуска и событий бота:

Каталог олимпиад:

Данные пользователя:

Хранятся на сервере, а не в браузере: напоминания отправляет бот, и он должен знать, за чем следит пользователь, даже когда мини-приложение закрыто. Вкладка «Новости» отдельно не хранится — она вычисляется из дат этапов и ответов пользователя. Единственное, что для неё запоминается, — stages.dates_added_at: момент, когда импорт расписания увидел у этапа даты, которых раньше не было. Из него строится блок «Появились даты»: этапы обновляются на месте, и без отметки прошлое состояние не узнать.

Идентификатор пользователя записывается только после проверки подписи. Таблицы создаются автоматически при старте приложения, каталог при первом старте наполняется из backend/data/.

Персональные данные из внешних систем не обрабатываются.

Источники данных

Источник Что даёт Чего нет
data/subjects.sample.json 166 записей: предметы, уровни, классы, описания, организаторы этапов и дат
data/olympiads_schedule.json 147 этапов с датами у 61 олимпиады всего остального
data/olympiads.sample.json 97 этапов у 20 олимпиад, другой источник предмета и уровня

Первый файл наполняет каталог, второй доставляет в него даты. До появления второго «Поиск» работал на каталоге, а «Календарь» и «Новости» — на третьем файле, чьи записи предмету не принадлежат и потому в интерфейсе не видны. Теперь даты приходят прямо в каталожные олимпиады, и обе вкладки работают на тех же записях, что и поиск.

Расписание снимается с olimpiada.ru отдельным парсером (tools/olympiad_schedule_parser.py) и кладётся в базу командой python -m scripts.import_schedule. Связать его с каталогом напрямую нечем: парсер знает олимпиады по activity_id olimpiada.ru, а база — по id из каталога. Соответствие берётся из data/subjects.olimpiada.json — это тот же каталог, размеченный идентификаторами olimpiada.ru. Импортируется он не как каталог, а только как таблица соответствия.

Года в расписании на сайте нет — парсер угадывает его от даты запуска, и у диапазона год считается один раз, по концу: иначе «20 авг…22 сен», разобранный в сентябре, получал начало на год позже конца. Разбор дат проверяют тесты рядом с парсером (cd tools && python -m pytest), а файлы, снятые до исправления, импорт выправляет сам.

Расписание есть не у всех: на olimpiada.ru его нет у 85 олимпиад из 146. Записи разных источников различаются полем source и друг другу не мешают.

Форма сроков этапа

Парсер размечает сроки четырьмя способами, и каждый рисуется в календаре по-своему. В базе для этого нет отдельного поля: всё выражено тем, какие из дат заполнены, а обратно значение вычисляет presentation.date_precision и отдаёт в API полем date_precision.

Значение starts_on ends_on Как показывать
range начало конец непрерывная полоса цвета предмета
exact день — кружок в цвет олимпиады
until — дедлайн полоса, набирающая непрозрачность к последнему дню
unknown — — в календаре не показывать

Таблица — о том, как даты лежат в базе. Наружу их отдаёт presentation.stage_bounds в полях парсера: у exact конец равен началу, поэтому карточка этапа и календарь показывают одни и те же даты.

Вычисление, а не хранение, выбрано намеренно: значение не может разойтись с датами, по которым посчитано. Дата с точностью до месяца попадает в unknown — конкретного дня у неё нет, а календарь размечает дни; сама точность при этом остаётся в start_precision и raw_date_range.

Порядок работы с тестовыми данными

Все выгрузки лежат в backend/data/ и едут в образе — проверка решения не требует ручного подкладывания файлов. При первом старте бэкенд сам импортирует их в пустой каталог (scripts.seed). Обновить данные в уже наполненной базе — те же импорты по отдельности:

docker compose run --rm backend python -m scripts.import_subjects
docker compose run --rm backend python -m scripts.import_schedule
docker compose run --rm backend python -m scripts.import_catalog

import_schedule не пропускайте: без него у олимпиад каталога нет дат, и «Календарь» с «Новостями» пусты.

Импорт идемпотентен: повторный запуск обновляет записи на месте, а не дублирует их. Обновление на месте принципиально — прогресс пользователя по этапам ссылается на строки таблицы stages, и пересоздание обнуляло бы его при каждом обновлении данных.

Данные собраны парсером с postupi.online и являются срезом на момент выгрузки. Дата последней проверки записи отдаётся в поле source_checked_at.


Пошаговый сценарий проверки

1. Проверка интерфейса локально без MAX

  1. Скопируйте .env.example в .env. Укажите тестовое значение:

BOT_TOKEN=local-test-token-not-for-max Это не действующий токен MAX. Он используется только для подписи и проверки локальных тестовых данных.

  1. Запустите приложение:
docker compose up -d --build
  1. Сгенерируйте данные авторизации:
docker compose run --rm backend python -m scripts.make_launch_data --curl
  1. Откройте http://localhost:8080. В консоли разработчика браузера выполните:

Выполните js:

sessionStorage.setItem( “localInitData”, “СТРОКА_ИЗ_КОМАНДЫ_ОТ_auth_date=_ДО_КОНЦА_hash” ); location.reload();

Скопируйте строку без префикса Authorization: tma , сохранив URL-кодирование.

Интерфейс работает от имени тестового пользователя. Доступны локальные функции приложения; настоящие сообщения бота и интеграция с MAX проверяются в действующем мини-приложении.

2. Живость сервиса

curl http://localhost:8080/health

Тот же ответ и напрямую, в обход nginx: http://localhost:8000/health. Мини-приложение открывается на http://localhost:8080/ — вне MAX у него нет строки запуска, поэтому данные в нём не загрузятся; API с подписью проверяется на шаге 6.

Ожидается:

{"status":"ok","env":"development","bot_configured":true,"webhook_secret_set":true}

3. Тесты

docker compose --profile test run --rm tests

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

4. Каталог олимпиад

Наполняется сам при первом старте — в логе бэкенда:

docker compose logs backend | grep data/

Ожидается по выгрузкам: subjects.sample.json — subjects_created: 14, olympiads_created: 166; olympiads_schedule.json — 150 этапов у 62 олимпиад; olympiads.sample.json — olympiads_created: 20, stages_created: 97. Дальше список доступен по GET /api/catalog/olympiads, детали — по GET /api/catalog/olympiads/{id}; оба требуют того же заголовка Authorization: tma <initData>. Без расписания GET /api/me/calendar отдаёт пустой entries при любых сохранённых олимпиадах.

5. Пользовательский сценарий целиком

docker compose run --rm backend python -m scripts.smoke_test

Скрипт проходит механику от лица тестового пользователя: «Буду писать», планы в календаре, лимит трёх олимпиад в день, ответ «не прошёл» с блокировкой следующих этапов, новости, настройки и удаление. Ожидается провалено 0. Данные тестового пользователя удаляются после прогона.

6. Вебхук

В PUBLIC_BASE_URL уже стоит https://my-olymp.ru. Если проверяете на своей машине, подставьте туда адрес туннеля — MAX не доставит события на localhost. Туннель направляйте на порт 8080: там на одном адресе и вебхук, и мини-приложение. Затем:

docker compose run --rm backend python -m scripts.setup_webhook

Напишите боту в MAX — в логах docker compose logs -f backend появится событие, а бот ответит в чат.

Примеры ожидаемого поведения

Действие Ответ
GET /api/me с корректной строкой запуска 200 и профиль пользователя
GET /api/me без заголовка Authorization 401, «Нет заголовка Authorization с данными запуска»
GET /api/me с изменённым user.id 401, {"code": "bad_signature"}
GET /api/me со строкой старше LAUNCH_DATA_TTL 401, {"code": "expired"} — можно переоткрыть приложение
POST /webhook с неверным секретом 403, событие не обрабатывается
POST /webhook с корректным секретом 200 сразу, обработка — после ответа
Пользователь запустил бота бот присылает приветствие с кнопкой открытия приложения
Отправка нескольких сообщений одному адресату очередь выдерживает паузу ≥ 0,5 с между отправками

Проверить сценарии с ошибками:

# просроченные данные запуска
docker compose run --rm backend python -m scripts.make_launch_data --age 999999 --curl

# запрос без авторизации
curl -i http://localhost:8000/api/me

Известные ограничения

Особенности платформы MAX

То, на чём мы уже спотыкались: