Legan Studio
Все статьи
~ 6 мин чтения

Бот для онлайн-курсов и LMS в MAX: уроки, тесты, прогресс, тьюторы

Как сделать бот в MAX для онлайн-курсов: выдача уроков, тесты с автопроверкой, прогресс-бар, тьюторы, домашние задания, геймификация и интеграция с LMS.

  • MAX
  • EdTech
  • вертикали

Онлайн-курсы в мессенджере — формат, который активно растёт в РФ: вместо тяжёлой LMS-платформы пользователь получает уроки прямо в чате, делает тест из 5 вопросов на телефоне в метро, получает фидбек. Удержание — на 30–50% выше, чем у классических LMS. В этой статье разберём, как спроектировать бот для онлайн-курсов в MAX: модель данных уроков и прогресса, выдача контента (текст, видео, файлы), тесты с автопроверкой, домашние задания с проверкой тьютором, геймификация (стрики, бейджи, рейтинги), интеграция с GetCourse / Skillbox-style LMS.

Архитектура

Бот в MAX = тонкий «фронтенд» учебного процесса. LMS (своя или внешняя) — источник контента и регистратор прогресса. Минимум:

[Bot MAX] ↔ [Lesson API] ↔ [LMS / Postgres]
                ↑
        [Tutor Web Cabinet]
                ↑
            [Admin Course Builder]

Если внешней LMS нет — пишем свою на Postgres + S3 для медиа.

Модель данных

CREATE TABLE courses (
    id BIGSERIAL PRIMARY KEY,
    title TEXT, description TEXT,
    is_published BOOLEAN DEFAULT false
);

CREATE TABLE modules (
    id BIGSERIAL PRIMARY KEY,
    course_id BIGINT REFERENCES courses(id),
    title TEXT, position INT
);

CREATE TABLE lessons (
    id BIGSERIAL PRIMARY KEY,
    module_id BIGINT REFERENCES modules(id),
    title TEXT,
    content_md TEXT,            -- markdown с inline-картинками
    video_url TEXT,
    files JSONB,                -- [{url, name, size}]
    quiz JSONB,                 -- [{q, options, correct}]
    homework_required BOOLEAN DEFAULT false,
    position INT,
    duration_min INT
);

CREATE TABLE enrollments (
    id BIGSERIAL PRIMARY KEY,
    user_id BIGINT,
    course_id BIGINT REFERENCES courses(id),
    started_at TIMESTAMPTZ DEFAULT now(),
    completed_at TIMESTAMPTZ,
    UNIQUE (user_id, course_id)
);

CREATE TABLE progress (
    user_id BIGINT,
    lesson_id BIGINT REFERENCES lessons(id),
    status TEXT,                -- opened, finished, passed
    score INT,                  -- для тестов
    finished_at TIMESTAMPTZ,
    PRIMARY KEY (user_id, lesson_id)
);

CREATE TABLE homework (
    id BIGSERIAL PRIMARY KEY,
    user_id BIGINT,
    lesson_id BIGINT REFERENCES lessons(id),
    text TEXT,
    files JSONB,
    submitted_at TIMESTAMPTZ DEFAULT now(),
    reviewed_at TIMESTAMPTZ,
    tutor_id BIGINT,
    score INT,
    feedback TEXT
);

Выдача урока

async def deliver_lesson(user_id: int, lesson_id: int):
    lesson = await db.fetch_one("SELECT * FROM lessons WHERE id = $1", lesson_id)
    text = f"*Урок {lesson.position}: {lesson.title}*\n\n{lesson.content_md[:3500]}"
    if len(lesson.content_md) > 3500:
        text += "\n\n_(продолжение ниже)_"
    await bot.send_message(user_id, text, parse_mode="Markdown")
    if len(lesson.content_md) > 3500:
        await bot.send_message(user_id, lesson.content_md[3500:7000], parse_mode="Markdown")
    if lesson.video_url:
        await bot.send_video(user_id, video=lesson.video_url, caption="Видео урока")
    for f in lesson.files or []:
        await bot.send_document(user_id, document=f["url"], filename=f["name"])
    
    # клавиатура с действиями
    kb_rows = []
    if lesson.quiz:
        kb_rows.append([InlineKeyboardButton("📝 Пройти тест", callback_data=f"quiz:{lesson.id}")])
    if lesson.homework_required:
        kb_rows.append([InlineKeyboardButton("📤 Сдать д/з", callback_data=f"hw:submit:{lesson.id}")])
    kb_rows.append([InlineKeyboardButton("✅ Урок пройден", callback_data=f"lesson:done:{lesson.id}")])
    await bot.send_message(user_id, "Когда закончите изучение:", reply_markup=InlineKeyboardMarkup(kb_rows))
    await mark_progress(user_id, lesson_id, "opened")

Лимит сообщения MAX/Telegram-style — 4096 символов. Для длинного контента — несколько сообщений.

Тесты с автопроверкой

@bot.callback_query_handler(lambda c: c.data.startswith("quiz:"))
async def start_quiz(call):
    lesson_id = int(call.data.split(":")[1])
    quiz = await get_quiz(lesson_id)
    await set_state(call.from_user.id, f"quiz:{lesson_id}:0", payload={"score": 0, "answers": []})
    await ask_question(call.message.chat.id, quiz[0], 0, len(quiz))

async def ask_question(chat_id: int, q: dict, idx: int, total: int):
    text = f"*Вопрос {idx+1}/{total}*\n\n{q['q']}"
    rows = [[InlineKeyboardButton(opt, callback_data=f"ans:{idx}:{i}")] for i, opt in enumerate(q["options"])]
    await bot.send_message(chat_id, text, reply_markup=InlineKeyboardMarkup(rows), parse_mode="Markdown")

@bot.callback_query_handler(lambda c: c.data.startswith("ans:"))
async def on_answer(call):
    _, idx, choice = call.data.split(":")
    state = await get_state(call.from_user.id)
    lesson_id = int(state.name.split(":")[1])
    quiz = await get_quiz(lesson_id)
    correct = int(choice) == quiz[int(idx)]["correct"]
    state.payload["answers"].append({"q": int(idx), "choice": int(choice), "correct": correct})
    if correct:
        state.payload["score"] += 1
    
    next_idx = int(idx) + 1
    if next_idx < len(quiz):
        await save_state(call.from_user.id, state)
        await ask_question(call.message.chat.id, quiz[next_idx], next_idx, len(quiz))
    else:
        score = state.payload["score"]
        passed = score / len(quiz) >= 0.7
        await mark_progress(call.from_user.id, lesson_id, "passed" if passed else "finished", score=score)
        text = f"Результат: *{score}/{len(quiz)}* ({'✅ зачёт' if passed else '❌ не зачёт, повторите'})"
        await bot.send_message(call.message.chat.id, text, parse_mode="Markdown")

Домашние задания

После клика «Сдать д/з» бот переводит в состояние ожидания файлов / текста:

@bot.message_handler(func=lambda m: state(m).name.startswith("hw:wait:"))
async def on_homework(msg):
    lesson_id = int(state(msg).name.split(":")[2])
    files = []
    if msg.document:
        files.append({"url": await save_to_s3(msg.document), "name": msg.document.file_name})
    if msg.photo:
        files.append({"url": await save_to_s3(msg.photo[-1]), "name": "photo.jpg"})
    await db.execute("""
        INSERT INTO homework (user_id, lesson_id, text, files) VALUES ($1, $2, $3, $4)
    """, msg.from_user.id, lesson_id, msg.text or msg.caption, json.dumps(files))
    await assign_to_tutor(lesson_id)
    await bot.send_message(msg.chat.id, "✅ Принято. Тьютор проверит в течение 24 часов.")
    await clear_state(msg.from_user.id)

Тьютор работает в отдельном веб-кабинете: видит очередь д/з, открывает, ставит оценку и пишет feedback. После проверки бот возвращает результат студенту:

async def deliver_review(homework_id: int):
    hw = await db.fetch_one("SELECT * FROM homework WHERE id = $1", homework_id)
    text = (
        f"📝 Проверено домашнее задание по уроку «{hw.lesson_title}»\n\n"
        f"Оценка: *{hw.score}/10*\n\n"
        f"Комментарий тьютора:\n_{hw.feedback}_"
    )
    await bot.send_message(hw.user_id, text, parse_mode="Markdown")

Прогресс-бар и навигация по курсу

@bot.message_handler(commands=["progress"])
async def on_progress(msg):
    enrollment = await get_active_enrollment(msg.from_user.id)
    total = await db.fetch_val("""
        SELECT COUNT(*) FROM lessons l JOIN modules m ON m.id = l.module_id WHERE m.course_id = $1
    """, enrollment.course_id)
    done = await db.fetch_val("""
        SELECT COUNT(*) FROM progress p JOIN lessons l ON l.id = p.lesson_id 
        JOIN modules m ON m.id = l.module_id 
        WHERE p.user_id = $1 AND m.course_id = $2 AND p.status IN ('finished','passed')
    """, msg.from_user.id, enrollment.course_id)
    pct = round(done / total * 100)
    bar = "▰" * (pct // 10) + "▱" * (10 - pct // 10)
    text = f"Курс «{enrollment.course_title}»\n\nПрогресс: {bar} {pct}%\n{done}/{total} уроков"
    await bot.send_message(msg.chat.id, text)

Геймификация

МеханикаРеализация
Стрик (дней подряд)счётчик в users.streak_days, обнуляется при пропуске суток
Бейджипри достижении (10 уроков, 100% теста, идеальная д/з) — push с PNG
Рейтингтоп-10 студентов курса по очкам
Очки XPза урок 10, тест 5–20, д/з 30, идеальный — 50

Подробнее — в статье «Геймификация в боте MAX».

Расписание и drip-уроки

Часто курс выдаётся не «всё сразу», а по графику: понедельник, среда, пятница в 19:00. Cron:

async def cron_drip():
    today = datetime.utcnow().date()
    pending = await db.fetch_all("""
        SELECT e.id AS enrollment_id, e.user_id, l.id AS lesson_id, l.position
        FROM enrollments e
        JOIN lessons l ON true
        WHERE e.scheduled_lessons @> jsonb_build_array(jsonb_build_object('date', $1::text, 'lesson_id', l.id))
          AND NOT EXISTS (SELECT 1 FROM progress p WHERE p.user_id = e.user_id AND p.lesson_id = l.id)
    """, today.isoformat())
    for r in pending:
        await deliver_lesson(r.user_id, r.lesson_id)

Интеграция с GetCourse / другими LMS

Внешняя LMS (GetCourse, AntiTrening, Bizon) даёт готовый сценарий админки и платежей. Бот выступает «оболочкой» для UX в MAX:

  • При покупке курса в LMS — webhook создаёт enrollment в Postgres бота и отправляет приветствие.
  • Прогресс из бота — POST в API LMS.
  • Тестовые результаты — двусторонняя синхронизация.
@app.post("/webhook/getcourse")
async def on_getcourse(payload: dict, x_signature: str = Header(None)):
    if not verify_signature(payload, x_signature):
        raise HTTPException(401)
    if payload["event"] == "purchase":
        user_id = await link_or_create_user(payload["email"])
        await create_enrollment(user_id, payload["course_id"])
        await bot.send_message(user_id, "Добро пожаловать на курс! Первый урок завтра в 19:00.")

Common pitfalls

  1. Лекция в одном сообщении на 8000 знаков — обрезается, выглядит криво.
  2. Видео > 50 МБ — MAX/Telegram-style режут размер, лучше URL на CDN/Kinescope.
  3. Тест без обратной связи на ошибку — студент не понимает, где ошибся.
  4. Д/з падает в общую очередь без приоритета — топовые студенты ждут так же, как пробные.
  5. Reset progress — при ребейсе курса студенты теряют прогресс, скандал.

Итого

Бот для онлайн-курсов в MAX строится на стеке: Postgres с courses/modules/lessons/enrollments/progress/homework, Object Storage для видео и материалов, FSM-логика тестов с автопроверкой и прогресс-баром, тьюторский веб-кабинет для проверки д/з, drip-выдача через cron, геймификация (стрики, бейджи, XP, рейтинги). Интегрируется с внешними LMS (GetCourse, AntiTrening) или работает автономно. MVP — 6–8 недель и 1–2 млн ₽; полная LMS-платформа с тьюторами, кабинетом, аналитикой по студенту, drip и геймификацией — 12–18 недель и 3–6 млн ₽. Удержание (course completion rate) у формата «уроки в мессенджере» — на 30–50% выше классических LMS, что напрямую конвертируется в LTV и сарафан.

Частые вопросы

Как давать урок длиннее 4096 символов в боте MAX?

Лимит одного сообщения в мессенджерах семейства MAX/Telegram-style — около 4096 символов. Длинные уроки разбивайте на 2–3 последовательных сообщения с маркерами «(1/3)», «(2/3)», «(3/3)» в начале. Лучше — реструктурировать урок на блоки по 1500–2500 символов с интерактивными вставками между ними (вопрос, задание, опрос). Для очень длинных материалов давайте PDF в attached файле + краткую выжимку в тексте. Видео — отдельным сообщением через send_video или ссылкой на CDN/Kinescope.

Как реализовать тесты с автопроверкой?

Тест хранится в JSONB поле lesson.quiz: массив объектов {q, options, correct}. Прохождение — FSM по индексу вопроса: state=quiz:{lesson_id}:{idx}, payload={score, answers}. Каждый вопрос отдельным сообщением с inline-кнопками вариантов. На клик — сохраняем ответ, увеличиваем индекс, переходим к следующему. После последнего — подсчёт балла, статус passed (≥ 70%) / finished, отображение результата с разбором ошибок (опционально). Результат логируется в progress.

Как принимать домашние задания в боте?

После клика «Сдать д/з» бот переводит в state hw:wait:{lesson_id}, ждёт сообщение от пользователя. Принимает: текст, документы (через msg.document), фото (msg.photo), голос (msg.voice). Файлы сохраняет в Object Storage (Yandex S3) и пишет в homework.files. Уведомляет тьютора через web-кабинет (или отдельный групповой чат). После проверки — push студенту с оценкой и комментарием. Лимит файлов — 5 штук, размер каждого до 20 МБ; для большего — внешние ссылки на Google Drive / Я.Диск.

Как организовать кабинет тьютора?

Веб-приложение (React/Next.js + JWT) с авторизацией через MAX deep link. Тьютор видит очередь нерасмотренных д/з с приоритетами (срок, оценка студента, вес курса), открывает каждое: текст и файлы студента, поле для оценки 1–10, поле для feedback (минимум 100 символов), кнопка «Опубликовать». Дополнительно — фильтры по курсу, студенту, дате; статистика по тьютору (среднее время проверки, количество за неделю); чат со студентом для уточняющих вопросов прямо из кабинета.

Как сделать drip-выдачу уроков по графику?

При создании enrollment рассчитывайте даты выдачи по правилам курса: «уроки 1, 2, 3 — в первый день; уроки 4, 5 — через 3 дня; и т.д.» — записываете в поле scheduled_lessons (массив {date, lesson_id}). Cron каждый час проверяет, какие уроки должны быть выданы сегодня и не выдавались, отправляет их пользователю. Учитывайте часовой пояс пользователя — выдача в 19:00 МСК для жителей Владивостока — это 4 утра. Спрашивайте часовой пояс при первом контакте.

Какие метрики важны для онлайн-курса?

Course completion rate (% дошедших до конца — целевые 30–60% для платных, 5–15% для бесплатных), Average lesson finish time, тестовые баллы по урокам (выявляет «слабые» уроки, которые надо переписать), процент сдавших д/з, среднее время проверки тьютором, NPS курса. Для геймификации — DAU, средний стрик, доля «вернувшихся через 7 дней». Дашборд в Metabase / Grafana, фильтры по когорте регистрации.

Сколько стоит и сколько занимает разработка?

MVP с курсами, drip-уроками, тестами, базовым прогрессом — 6–8 недель и 1–2 млн ₽. Полная LMS с тьюторами, домашками, кабинетом проверки, геймификацией, аналитикой, интеграцией GetCourse/AntiTrening — 12–18 недель и 3–6 млн ₽. Поддержка инфры — 15–40 тыс. ₽/мес. Окупаемость для школы 500+ платных студентов — 3–9 месяцев за счёт повышения course completion на 30–50%, удержания и роста LTV (повторные покупки следующих курсов в 2–3 раза выше у тех, кто завершил первый).