Онлайн-курсы в мессенджере — формат, который активно растёт в РФ: вместо тяжёлой 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
- Лекция в одном сообщении на 8000 знаков — обрезается, выглядит криво.
- Видео > 50 МБ — MAX/Telegram-style режут размер, лучше URL на CDN/Kinescope.
- Тест без обратной связи на ошибку — студент не понимает, где ошибся.
- Д/з падает в общую очередь без приоритета — топовые студенты ждут так же, как пробные.
- 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 раза выше у тех, кто завершил первый).