В итоге проект получился заметно шире, чем я изначально думал. Снаружи это выглядит просто: пользователь нажал /start, дал номер, подтвердил подписку, выбрал тариф, оплатил, получил доступ, дальше общается с ботом и запускает генерации. Но внутри это сразу превращается в набор отдельных подсистем, каждая из которых должна быть предсказуемой: MAX Bot API, backend, база, платежи, лимиты, тарифы, очереди задач, обработка входящих апдейтов, загрузка и отправка медиа, web UI, интеграции с внешними AI API.
С чего я вообще начал
Я выбрал стек на JavaScript, потому что для такого типа проекта это очень прагматичный вариант. Нужно быстро собирать API, обрабатывать вебхуки, работать с JSON, интегрироваться с внешними сервисами, отправлять HTTP-запросы, держать и бот, и web-приложение рядом. Node.js для этого подходит отлично.
Базовая архитектура у меня в итоге выглядела так:
Node.js как серверная среда
Express как HTTP backend
MAX bot library для работы с ботом и событиями
SQLite как база для пользователей, подписок, платежей и лимитов
YooKassa для оплаты тарифов
fal.ai для генерации изображений, видео и музыки
Vite + frontend для web UI
немного аккуратной ручной логики поверх SDK, потому что в реальном проекте без этого почти никогда не обходится
То есть идея была такая: бот это не отдельный кусок, а часть общей системы. Пользователь может взаимодействовать и из чата, и из мини-приложения, а состояние у него одно.
Библиотека для MAX: первое впечатление
Для работы с MAX я использовал библиотеку @MaxHub/max-bot-api.
Если говорить честно, первое впечатление у меня было хорошее. Базовые вещи поднимаются быстро. Подключение бота, обработка входящих апдейтов, отправка сообщений, клавиатуры, команды, callback-кнопки, работа с чатами и upload API ложатся в довольно понятную модель.
Простейший старт выглядел примерно так:
import { Bot } from '@maxhub/max-bot-api';
const bot = new Bot(process.env.BOT_TOKEN);
bot.command('start', async (ctx) => {
await ctx.reply('Привет! Я помогу тебе пройти онбординг и открыть доступ.');
});
bot.start();
Это тот случай, когда порог входа реально невысокий. Если человек уже работал с ботами в других экосистемах, то войти в MAX довольно легко.
Но довольно быстро я ушел от совсем “игрушечной” схемы, потому что в реальном проекте мне нужно было:
держать бизнес-логику вне обработчиков
централизовать работу с пользователем
делать fallback на прямые HTTP-вызовы MAX API
логировать спорные кейсы
повторять отправку медиа, если вложение еще не обработано платформой
То есть библиотека хорошо закрывает типовой happy path, но когда проект становится продуктом, вокруг неё все равно вырастает свой слой.
Как я построил структуру проекта
Я довольно быстро пришел к разделению на несколько модулей:
index.js для старта сервера
src/bot.js для логики MAX-бота
src/db.js для базы и подписок
src/yookassa.js для платежей
src/fal.js для генераций
frontend отдельно через Vite
И это решение сильно упростило жизнь. Самая частая проблема в ботах на ранней стадии в том, что всё оказывается в одном файле. Сначала это кажется быстрым, потом на 800 строке становится не смешно.
У меня логика входящих сообщений в какой-то момент уже делилась примерно так:
онбординг
текстовый чат
медиа-команды
платежи
служебные действия
профиль и тарифы
То есть обычное сообщение пользователя уже шло не “напрямую в if-else”, а через маршрутизацию.
async function routeTextMessage(ctx, user, text) {
if (!user.phone_number || !user.channel_confirmed_at) {
return handleOnboardingText(ctx, user, text);
}
if (isMediaPrompt(text)) {
return processMediaMessage(ctx, user, text);
}
if (isProfileCommand(text)) {
return showProfile(ctx, user);
}
return handleAiChat(ctx, user, text);
}
Это вроде бы простая вещь, но именно она потом спасает проект от хаоса.
Что мне понравилось в MAX Bot API
Самое приятное в MAX для меня было то, что там можно собрать вполне нормальный сценарий онбординга без ощущения, что тебя душит платформа.
пользователь нажимает /start
бот показывает приветствие
просит отправить контакт
дает ссылку на канал
проверяет подписку
завершает онбординг
открывает доступ к функциям
То есть это уже не просто “получи update и ответь текстом”, а последовательность состояний.
Кнопки при этом выглядели вполне нормально. Например, приветственное сообщение я собирал с клавиатурой такого типа:
function buildOnboardingKeyboard() {
return {
buttons: [
[
{
type: 'request_contact',
text: '📱 Отправить контакт'
}
],
[
{
type: 'link',
text: '📢 Подписаться на канал',
url: 'https://max.ru/id2543196416_biz'
}
],
[
{
type: 'callback',
text: '🔄 Проверить доступ',
payload: 'onboard:confirm_channel'
}
]
]
};
}
Тут у меня как раз и было первое ощущение, что с MAX можно делать нормальные сценарии без лишней боли. Не идеальные, не магические, но вполне рабочие.
Понравилось еще то, что API достаточно прямолинейное. Если что-то не покрывает библиотека, можно спокойно уйти в обычный HTTP и доделать руками.
Например, для некоторых вещей я делал прямые вызовы MAX API через fetch или axios:
async function maxApiGet(pathname, params = {}) {
const url = new URL(`https://botapi.max.ru${pathname}`);
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
url.searchParams.set(key, String(value));
}
});
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${process.env.BOT_TOKEN}`
}
});
if (!response.ok) {
const raw = await response.text();
throw new Error(`MAX API error ${response.status}: ${raw}`);
}
return response.json();
}
И это, кстати, очень полезный момент. Я люблю библиотеки, которые не мешают мне при необходимости выйти на уровень ниже. MAX в этом смысле оказался вполне нормальным.
Где пришлось повозиться
Без сложностей не обошлось. Самый неприятный участок был не текстовый чат, а медиа.
С текстом всё почти всегда проще. Получил update, обработал, отправил ответ. А вот как только начинаются файлы, аудио, видео, загрузки, асинхронная обработка вложений, тут уже начинаются реальные боевые условия.
Конкретно у меня больше всего времени съели три зоны:
1. Проверка подписки на канал
На бумаге задача выглядит просто: пользователь подписан или нет. На практике нужно:
понять, какой именно канал проверять
получить chatId
сопоставить пользователя
проверить membership
обработать кейс, когда у бота недостаточно прав
Примерно так выглядела логика:
async function checkUserChannelMembership(userId, channelChatId) {
const response = await maxApiGet(`/chats/${channelChatId}/members`, {
user_ids: userId
});
return Array.isArray(response.members) && response.members.length > 0;
}
Но реальность была чуть веселее. Если у бота нет нужных прав в канале, можно получить 403 Not enough permissions. То есть бот добавлен, но этого недостаточно. Пришлось делать дополнительную диагностику, логировать поиск канала, чат, user id, ответы API, и только после этого стало понятно, что именно ломается.
Это, кстати, хороший пример того, как выглядит “настоящая” разработка бота. Обычно проблема не в том, что ты не умеешь написать 10 строк кода. Проблема в том, что платформенные ограничения проявляются только в реальном сценарии.
2. Контакт пользователя
Вторая зона, где пришлось быть внимательным, это прием номера телефона через кнопку “Передать контакт”.
Я изначально думал, что контакт всегда будет приходить в одной и той же форме. На практике лучше сразу делать извлечение аккуратно и не надеяться на один путь в payload.
В итоге я делал нечто вроде такого:
function extractPhoneFromMessage(message) {
return (
message?.contact?.phone_number ||
message?.contact?.phone ||
message?.body?.contact?.phone_number ||
message?.body?.contact?.phone ||
''
);
}
После этого номер сохранялся в базу:
async function saveUserPhone(maxId, phone) {
await db.run(
`UPDATE users
SET phone_number = ?, phone_shared_at = CURRENT_TIMESTAMP
WHERE max_id = ?`,
[phone, maxId]
);
}
С виду ерунда, но именно такие вещи влияют на ощущение “бот работает как часы” или “бот какой-то кривой”.
3. Медиа и MAX upload flow
Самая нервная часть была с отправкой музыки и видео обратно в MAX.
Генерация в fal.ai уже отработала, файл есть, URL есть, скачивание прошло, upload в MAX прошёл, а само сообщение не отправляется, потому что вложение ещё не обработано на стороне платформы.
Именно на таких вещах начинаешь уважать нормальные ретраи.
Примерно так выглядела отправка с повтором:
async function sendAttachmentOnlyWithRetry(chatId, attachment, retries = 10) {
for (let attempt = 1; attempt <= retries; attempt++) {
try {
return await postMaxMessageRaw(chatId, {
text: '',
attachments: [attachment],
notify: true
});
} catch (error) {
const code = error?.response?.code || error?.code;
if (code !== 'attachment.not.ready' || attempt === retries) {
throw error;
}
const delayMs = 1500 * attempt;
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
}
И вот тут я скажу честно: сама библиотека для бота помогает, но на медиа-цепочке ты уже начинаешь жить API-шной жизнью. И это нормально. Просто важно понимать это заранее.
Какие библиотеки я использовал кроме MAX
Express
express я использовал как основной backend. Никаких сюрпризов. Для таких задач он до сих пор очень удобен:
вебхуки
REST API
маршруты для фронта
обработка callback от платежей
служебные ручки для профиля, тарифов, генераций
Обычный маршрут, например, выглядел так:
app.post('/api/generate/music', async (req, res) => {
try {
const { prompt, modelId } = req.body;
const user = await requireAuthorizedUser(req);
const result = await generateMusicWithFal({
user,
modelId,
prompt
});
res.json({ ok: true, result });
} catch (error) {
res.status(500).json({
ok: false,
error: error.message
});
}
});
Express в таких проектах хорош тем, что не навязывает архитектуру. Если любишь всё контролировать сам, он не мешает.
SQLite
Для базы я использовал SQLite. И для подобного проекта это вообще прекрасный выбор на старте и на первой реальной нагрузке.
пользователей
номера телефонов
тарифы
даты подписки
лимиты и квоты
платежи
онбординг-статусы
То есть это не highload банковская система. Для одного проекта с ботом SQLite закрывает очень много задач.
Таблица пользователей у меня была примерно такой:
CREATE TABLE IF NOT EXISTS users (
max_id TEXT PRIMARY KEY,
username TEXT DEFAULT '',
first_name TEXT DEFAULT '',
last_name TEXT DEFAULT '',
name TEXT DEFAULT '',
subscription_tier TEXT DEFAULT 'Free',
subscription_started_at TEXT DEFAULT '',
subscription_expires_at TEXT DEFAULT '',
phone_number TEXT DEFAULT '',
phone_shared_at TEXT DEFAULT '',
channel_confirmed_at TEXT DEFAULT '',
onboarding_completed_at TEXT DEFAULT '',
weekly_usage_json TEXT DEFAULT '{}',
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
Мне нравится SQLite за то, что ты не тратишь полдня на инфраструктуру, когда тебе надо просто сделать продукт и проверить гипотезу.
YooKassa
Для оплаты тарифов я использовал YooKassa. Здесь уже пришлось быть внимательным, потому что платеж сам по себе проходит несложно, а вот чек и receipt надо формировать аккуратно.
Например, создание платежа у меня выглядело так:
async function createYooKassaPayment({ amountRub, description, phone, email }) {
const payload = {
amount: {
value: amountRub.toFixed(2),
currency: 'RUB'
},
capture: true,
confirmation: {
type: 'redirect',
return_url: process.env.YOOKASSA_RETURN_URL
},
description,
receipt: {
customer: {
email: email || process.env.YOOKASSA_RECEIPT_EMAIL
},
items: [
{
description,
quantity: '1.00',
amount: {
value: amountRub.toFixed(2),
currency: 'RUB'
},
vat_code: 1,
payment_mode: 'full_payment',
payment_subject: 'service'
}
]
}
};
const response = await requestYooKassa('/payments', payload);
return response;
}
Если не собрать receipt как надо, можно легко поймать ошибку вроде Receipt is missing or illegal.
Это, кстати, один из тех участков, где я быстро понял простую вещь: если хочешь контролируемое поведение, иногда лучше не зависеть от старой абстракции пакета, а работать через понятный ручной HTTP-клиент.
fal.ai я использовал для генерации:
И вот здесь уже начинается интересный продакшеновый опыт.
На демо всё выглядит красиво: вызвал модель, получил URL, отправил пользователю. На практике для музыки и видео обязательно нужно продумывать:
Для long-running задач я в итоге опирался на queue flow:
const submitted = await fal.queue.submit(endpoint, { input });
let status;
do {
await new Promise(resolve => setTimeout(resolve, 4000));
status = await fal.queue.status(endpoint, {
requestId: submitted.request_id
});
} while (status.status !== 'COMPLETED');
const result = await fal.queue.result(endpoint, {
requestId: submitted.request_id
});
И вот тут fal мне понравился тем, что система достаточно прозрачная. Можно понять:
Это лучше, чем когда у тебя всё спрятано в чёрный ящик.
Как я сделал тарифы и лимиты
Одна из вещей, которую я хотел сделать аккуратно, это тарифы без ручного хаоса по всему коду.
Я собрал конфигурацию тарифов в одном месте:
export function getTierConfig(tierId = 'Free') {
const tiers = {
Free: {
id: 'Free',
title: 'Free',
priceRub: 0,
weeklyImageGenerations: 3,
weeklyVideoGenerations: 1,
weeklyMusicGenerations: 1
},
Basic: {
id: 'Basic',
title: 'Basic',
priceRub: 399,
weeklyImageGenerations: 25,
weeklyVideoGenerations: 5,
weeklyMusicGenerations: 5
},
Business: {
id: 'Business',
title: 'Business',
priceRub: 2990,
weeklyImageGenerations: 250,
weeklyVideoGenerations: 60,
weeklyMusicGenerations: 30
}
};
return tiers[tierId] || tiers.Free;
}
Потом уже и профиль, и бот, и web UI, и списание лимитов опирались именно на эту функцию.
Это очень важный момент. Если цены и лимиты размазаны по пяти файлам, проблема гарантирована. Где-нибудь обязательно останется старая цифра.
Как я строил онбординг
Для меня было важно, чтобы первый вход выглядел нормально. Не сухо, не тупо текстом из документации, а как в живом продукте.
при первом /start создается пользователь
если он не прошёл онбординг, показывается приветствие
дальше бот ведёт человека по шагам
если онбординг уже пройден, открывается обычный интерфейс
async function handleStart(ctx, user) {
if (!user.onboarding_completed_at) {
return ctx.reply(
[
'Привет! Добро пожаловать.',
'',
'Чтобы открыть доступ, нужно пройти три шага:',
'1. Отправить контакт',
'2. Подписаться на канал',
'3. Нажать "Проверить доступ"'
].join('\n'),
{
keyboard: buildOnboardingKeyboard()
}
);
}
return showMainMenu(ctx, user);
}
Мне кажется, именно такие мелочи формируют ощущение качества. Пользователь не должен думать, что он попал в отладочный чат разработчика.
Что я думаю про удобство библиотеки для MAX
Если говорить без лишнего пафоса, то впечатление у меня в целом положительное.
Что удобно:
быстрый старт
понятные базовые сущности
нормальная работа с командами и сообщениями
можно быстро поднять живого бота
можно не упираться в библиотеку и пойти в прямой API
Что хотелось бы лучше:
побольше предсказуемости в кейсах с вложениями
более гладкий путь для production-сценариев вокруг медиа
местами хочется чуть более богатых оберток над низкоуровневыми API
диагностика некоторых кейсов могла бы быть еще дружелюбнее
Но если отвечать на вопрос “комфортно ли кодить с этой библиотекой”, то мой ответ да, комфортно. Не в смысле “вообще не пришлось думать”, а в смысле “она не мешает делать серьезный проект”.
Для меня это главный критерий. Я нормально отношусь к тому, что в проде все равно нужно писать свою логику поверх SDK. Это нормально. Главное, чтобы библиотека не вставала поперек дороги. Здесь этого ощущения не было.
Самый полезный вывод после всей работы
Самый важный вывод у меня такой: бот в MAX вполне реально довести до состояния продукта, если изначально относиться к нему как к системе, а не как к “скрипту на коленке”.
вот тут один обработчик
вот тут два if
вот тут платеж
вот тут музыка
а там потом разберусь
Как только я сам начал думать о проекте именно так, всё стало намного спокойнее.
Что бы я посоветовал тем, кто будет делать своего бота в MAX
Первое. Сразу разводить код по модулям. Не держать всё в одном файле.
Второе. Не полагаться на happy path. Особенно в:
подписках
оплатах
медиа
загрузках файлов
Третье. Делать нормальные логи. Не “что-то пошло не так”, а нормальные, с контекстом:
userId
chatId
payload
этап
код ответа API
Четвертое. Не дублировать тарифы и лимиты по всему проекту. Один источник правды.
Пятое. Сразу продумывать, как бот ведёт себя при первом входе. Это сильнее влияет на продукт, чем может показаться.
Итог
Если коротко, то разработка бота в MAX у меня оставила хорошее впечатление. Не потому что всё было идеально гладко, а потому что стек оказался рабочим. Я смог собрать не просто чат-бота, а связанный сервис с:
Да, кое-где пришлось копаться глубже, чем хотелось бы. Да, медиа и асинхронная обработка вложений заставили повозиться. Да, некоторые вещи лучше сразу логировать, а не пытаться угадать по симптомам. Но это и есть нормальная разработка.
Если бы меня спросили, стал бы я ещё раз делать проект на MAX с этой библиотекой, мой ответ был бы да. Потому что по итогу получилось не “победить SDK”, а реально построить рабочий продукт.