В этой статье я не буду рассказывать, как зарегистрироваться на платформе и получить токен доступа - об этом уже написано +100500 статей. Предположу, что токен у вас уже есть, и мы сразу перейдём к коду.
Сделаем лид-бота для мебельной компании на заказ: он соберёт тип мебели, размеры, бюджет и контакты клиента - и отправит заявку менеджеру.
Подготовка проекта
Создаём папку, устанавливаем зависимости:
Инициализируем Node.js проект (создаёт package.json):
npm init -yУстанавливаем библиотеку MAX Bot API и dotenv для работы с .env файлом:
npm install @maxhub/max-bot-api dotenvУстанавливаем TypeScript и типы для Node.js как dev-зависимости:
npm install -D typescript @types/nodeСоздаём базовый tsconfig.json:
npx tsc --initСтруктура проекта:
mebel-bot/
├── src/
│ ├── bot.ts # основная логика
│ └── types.ts # типы
├── .env
├── package.json
└── tsconfig.json
Настроим package.json — добавим "type": "module" и скрипты:
json{ "type": "module", "scripts": { "build": "tsc", "start": "npm run build && node dist/bot.js", "dev": "tsc --watch --preserveWatchOutput" }, "dependencies": { "@maxhub/max-bot-api": "^0.2.2", "dotenv": "^17.4.2" }, "devDependencies": { "@types/node": "^26.0.0", "typescript": "^6.0.3" } }
tsconfig.json — важно настроить ESM для Node.js:
json{ "compilerOptions": { "rootDir": "./src", "outDir": "./dist", "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "types": ["node"], "strict": true, "verbatimModuleSyntax": true, "esModuleInterop": true, "skipLibCheck": true, "sourceMap": true }, "include": ["src/**/*.ts"] }
⚠️ При
module: NodeNextв TypeScript все относительные импорты должны использовать расширение.js, даже если файл физически.ts. TypeScript сам разберётся при компиляции.
Файл .env:
envBOT_TOKEN=ваш_токен_здесь MANAGER_ID=123456789
MANAGER_ID — это ваш user_id в MAX. Узнать его можно командой /myid в готовом боте (добавим её ниже).
Типы данных
Создаём src/types.ts — опишем состояние пользователя в воронке и вспомогательные типы:
typescript// src/types.ts // Шаги воронки сбора заявки export type Step = | "furniture_type" // выбор типа мебели | "dimensions" // ввод размеров | "material" // выбор материала | "budget" // выбор бюджета | "contacts" // ввод контактов | "done"; // заявка отправлена // Состояние одного пользователя export interface UserState { step: Step; furnitureType?: string; dimensions?: string; material?: string; budget?: string; contacts?: string; } // Кнопка с текстом и payload export interface ButtonItem { label: string; payload: string; } // Упрощённый контекст (до появления официальных типов от maxhub) export interface BotContext { user?: { user_id: number; name?: string; username?: string; }; message?: { body?: { text?: string; mid?: string; }; }; match?: RegExpMatchArray; reply: (text: string, options?: ReplyOptions) => Promise<unknown>; } export interface ReplyOptions { format?: "markdown" | "html"; attachments?: unknown[]; }
Основной файл бота
Теперь вся магия — src/bot.ts. Разберём по частям.
Инициализация
typescript// src/bot.ts import "dotenv/config"; import { Bot, Keyboard } from "@maxhub/max-bot-api"; import type { BotContext, ButtonItem, UserState } from "./types.js"; const botToken = process.env.BOT_TOKEN; if (!botToken) throw new Error("BOT_TOKEN не задан в .env"); const MANAGER_ID = Number(process.env.MANAGER_ID) || 0; const bot = new Bot(botToken); // Хранилище состояний: user_id → состояние воронки // В продакшене замените на Redis или базу данных const userStates = new Map<number, UserState>();
💡
Map<number, UserState>хранит состояния в памяти процесса. Это нормально для старта, но при перезапуске бота все состояния сбросятся. Для продакшена подключите Redis или SQLite.
Данные для кнопок
typescript// Типы мебели const FURNITURE_TYPES: ButtonItem[] = [ { label: "🛋 Диван / кресло", payload: "action:type:Диван или кресло" }, { label: "🪑 Стол / стулья", payload: "action:type:Стол или стулья" }, { label: "🛏 Кровать / спальня", payload: "action:type:Кровать или спальня" }, { label: "📦 Шкаф / гардероб", payload: "action:type:Шкаф или гардероб" }, { label: "🍳 Кухонный гарнитур", payload: "action:type:Кухонный гарнитур" }, { label: "🏢 Офисная мебель", payload: "action:type:Офисная мебель" }, { label: "🤔 Что-то другое", payload: "action:type:Другое" }, ]; // Материалы const MATERIALS: ButtonItem[] = [ { label: "🌲 Массив дерева", payload: "action:material:Массив дерева" }, { label: "🪵 МДФ / ДСП", payload: "action:material:МДФ или ДСП" }, { label: "🔩 Металл", payload: "action:material:Металл" }, { label: "💎 Комбинированный", payload: "action:material:Комбинированный" }, { label: "❓ Подскажите сами", payload: "action:material:На ваш выбор" }, ]; // Бюджеты const BUDGETS: ButtonItem[] = [ { label: "До 50 000 ₽", payload: "action:budget:До 50 000 ₽" }, { label: "50 000 – 150 000 ₽", payload: "action:budget:50 000 – 150 000 ₽" }, { label: "150 000 – 400 000 ₽", payload: "action:budget:150 000 – 400 000 ₽" }, { label: "Более 400 000 ₽", payload: "action:budget:Более 400 000 ₽" }, { label: "Пока не определился", payload: "action:budget:Не определился" }, ];
Вспомогательные функции
typescript// Получить user_id из контекста function getUserId(ctx: BotContext): number { return ctx.user?.user_id ?? 0; } // Красивое имя для уведомления менеджеру function getUserName(ctx: BotContext): string { return ( [ctx.user?.name, ctx.user?.username].filter(Boolean).join(" / ") || "Неизвестно" ); } // Построить inline-клавиатуру из массива кнопок // В @maxhub/max-bot-api клавиатура передаётся как attachment, не как отдельное поле function buildKeyboard(items: ButtonItem[]) { return Keyboard.inlineKeyboard( items.map((item) => [ Keyboard.button.callback(item.label, item.payload), ]) ); }
Функции каждого шага воронки
Каждый шаг — отдельная функция. Это упрощает поддержку и позволяет легко менять тексты.
typescript// Шаг 1: Выбор типа мебели async function askFurnitureType(ctx: BotContext): Promise<void> { await ctx.reply( "👋 Добро пожаловать в **МебельПроф**!\n\n" + "Мы создаём мебель на заказ под любые размеры и стиль.\n\n" + "Что вы хотите заказать?", { format: "markdown", attachments: [buildKeyboard(FURNITURE_TYPES)], } ); } // Шаг 2: Запрос размеров async function askDimensions(ctx: BotContext, type: string): Promise<void> { await ctx.reply( `Отличный выбор — **${type}** ✅\n\n` + "Укажите примерные размеры или особенности помещения.\n\n" + "_Например: «Ширина 2 м, высота 2.4 м, угловой вариант» или «Не знаю, нужен замер»_", { format: "markdown" } ); } // Шаг 3: Выбор материала async function askMaterial(ctx: BotContext): Promise<void> { await ctx.reply( "🪚 Из какого материала сделать мебель?", { attachments: [buildKeyboard(MATERIALS)] } ); } // Шаг 4: Выбор бюджета async function askBudget(ctx: BotContext): Promise<void> { await ctx.reply( "💰 Какой бюджет вы рассматриваете?", { attachments: [buildKeyboard(BUDGETS)] } ); } // Шаг 5: Запрос контактов async function askContacts(ctx: BotContext): Promise<void> { await ctx.reply( "📞 Последний шаг! Укажите ваше **имя и телефон** — наш замерщик свяжется с вами, чтобы уточнить детали и назначить бесплатный выезд.\n\n" + "_Например: «Алексей, +7 912 345-67-89»_", { format: "markdown" } ); } // Финал: подтверждение клиенту + уведомление менеджеру async function finishAndNotify( ctx: BotContext, state: UserState ): Promise<void> { const userId = getUserId(ctx); const userName = getUserName(ctx); // Подтверждение клиенту await ctx.reply( "🎉 Заявка принята!\n\n" + "Наш менеджер свяжется с вами в течение 30 минут в рабочее время.\n\n" + "Если хотите оставить ещё одну заявку — напишите /start", { format: "markdown" } ); // Уведомление менеджеру if (MANAGER_ID) { const text = `📥 **Новая заявка с бота МебельПроф**\n\n` + `👤 Клиент: ${userName} (ID: ${userId})\n` + `🛋 Мебель: ${state.furnitureType}\n` + `📐 Размеры: ${state.dimensions}\n` + `🪵 Материал: ${state.material}\n` + `💰 Бюджет: ${state.budget}\n` + `📞 Контакты: ${state.contacts}`; await bot.api.sendMessageToUser(MANAGER_ID, text, { format: "markdown", }); } }
Обработчики событий
Теперь подключаем всё к событиям бота:
typescript// ── Старт ────────────────────────────────────────────────────────────────── // Событие при первом запуске бота (кнопка «Начать» в MAX) bot.on("bot_started", async (ctx) => { const userId = getUserId(ctx as BotContext); userStates.set(userId, { step: "furniture_type" }); await askFurnitureType(ctx as BotContext); }); // Команда /start — перезапуск воронки bot.command("start", async (ctx) => { const userId = getUserId(ctx as BotContext); userStates.set(userId, { step: "furniture_type" }); await askFurnitureType(ctx as BotContext); }); // Команда /myid — удобно для получения своего ID (чтобы задать MANAGER_ID) bot.command("myid", (ctx) => { const userId = getUserId(ctx as BotContext); ctx.reply(`Ваш user_id: **${userId}**`, { format: "markdown" }); }); // ── Callback-кнопки: тип мебели ──────────────────────────────────────────── bot.action(/^action:type:(.+)/, async (ctx) => { const userId = getUserId(ctx as BotContext); const furnitureType = ctx.match?.[1] ?? ""; userStates.set(userId, { step: "dimensions", furnitureType }); await askDimensions(ctx as BotContext, furnitureType); }); // ── Callback-кнопки: материал ────────────────────────────────────────────── bot.action(/^action:material:(.+)/, async (ctx) => { const userId = getUserId(ctx as BotContext); const state = userStates.get(userId); if (!state || state.step !== "material") { await ctx.reply("Пожалуйста, начните сначала: /start"); return; } state.material = ctx.match?.[1] ?? ""; state.step = "budget"; userStates.set(userId, state); await askBudget(ctx as BotContext); }); // ── Callback-кнопки: бюджет ──────────────────────────────────────────────── bot.action(/^action:budget:(.+)/, async (ctx) => { const userId = getUserId(ctx as BotContext); const state = userStates.get(userId); if (!state || state.step !== "budget") { await ctx.reply("Пожалуйста, начните сначала: /start"); return; } state.budget = ctx.match?.[1] ?? ""; state.step = "contacts"; userStates.set(userId, state); await askContacts(ctx as BotContext); }); // ── Текстовые сообщения ──────────────────────────────────────────────────── bot.on("message_created", async (ctx) => { const userId = getUserId(ctx as BotContext); const state = userStates.get(userId); const text = (ctx as BotContext).message?.body?.text?.trim(); if (!text) return; // Пользователь ещё не начал воронку if (!state) { await ctx.reply("Напишите /start, чтобы оставить заявку 👋"); return; } // Шаг: ввод размеров (текстом) if (state.step === "dimensions") { state.dimensions = text; state.step = "material"; userStates.set(userId, state); await askMaterial(ctx as BotContext); return; } // Шаг: ввод контактов (текстом) if (state.step === "contacts") { state.contacts = text; state.step = "done"; userStates.set(userId, state); await finishAndNotify(ctx as BotContext, state); return; } // Заявка уже отправлена if (state.step === "done") { await ctx.reply( "Ваша заявка уже принята 🙌\n\nХотите оставить новую? Напишите /start" ); return; } // На других шагах просим выбрать из кнопок await ctx.reply("Пожалуйста, выберите вариант из кнопок выше 👆"); }); // ── Запуск ───────────────────────────────────────────────────────────────── console.log("МебельПроф бот запущен!"); void bot.start().catch((error: unknown) => { console.error("Ошибка запуска:", error); process.exitCode = 1; });
Запуск
# Сборка и запуск
npm startВ консоли должно появиться:
МебельПроф бот запущен!
Откройте бота в MAX и нажмите «Начать» — воронка стартует.
Как работает воронка
/start или «Начать»
│
▼
[Кнопки] Тип мебели
│
▼
[Текст] Размеры и особенности
│
▼
[Кнопки] Материал
│
▼
[Кнопки] Бюджет
│
▼
[Текст] Имя и телефон
│
▼
✅ Заявка принята → Уведомление менеджеру
Каждый шаг хранится в userStates. Если пользователь нажимает кнопку не на своём шаге — бот просит начать заново.
Частые вопросы
Кнопки не отображаются
В @maxhub/max-bot-api клавиатура передаётся как attachment, а не отдельным полем keyboard:
typescript// ✅ Правильно await ctx.reply("Выберите:", { attachments: [buildKeyboard(FURNITURE_TYPES)], }); // ❌ Неправильно — поля keyboard не существует await ctx.reply("Выберите:", { keyboard: buildKeyboard(FURNITURE_TYPES), });
Состояния сбрасываются при перезапуске
Это нормально для Map в памяти. Для сохранения состояний между перезапусками используйте:
- Redis — для высоконагруженных ботов
- SQLite (через
better-sqlite3) — для простых случаев - JSON-файл — совсем маленькие проекты
Как отправить изображение?
typescriptconst image = await ctx.api.uploadImage({ source: '/path/to/image.jpg' }); await ctx.reply('Вот пример нашей работы:', { attachments: [image.toJson()], });
Или по ссылке:
typescriptconst image = await ctx.api.uploadImage({ url: 'https://example.com/photo.jpg' });
Как добавить кнопку «Назад»?
Добавьте в массив кнопок шага кнопку с отдельным payload, например action:back, и обработайте её через bot.action('action:back', ...) — восстановите предыдущий шаг из состояния.
Что дальше
- Подключить CRM (AmoCRM, Bitrix24) через их API — создавать сделки прямо из бота
- Добавить фото работ на шаге выбора типа мебели
- Сохранить состояния в Redis для стабильности
- Добавить админ-панель: команду
/statsдля подсчёта заявок за день
Удачи с ботом! 🚀