Перейти к содержимому
MAX

Как создать чат бота для мессенджера Max

Как создать чат бота для мессенджера Max

В этой статье я не буду рассказывать, как зарегистрироваться на платформе и получить токен доступа - об этом уже написано +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:

env
BOT_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-файл — совсем маленькие проекты

Как отправить изображение?

typescript
const image = await ctx.api.uploadImage({ source: '/path/to/image.jpg' });
await ctx.reply('Вот пример нашей работы:', {
  attachments: [image.toJson()],
});

Или по ссылке:

typescript
const image = await ctx.api.uploadImage({
  url: 'https://example.com/photo.jpg'
});

Как добавить кнопку «Назад»?

Добавьте в массив кнопок шага кнопку с отдельным payload, например action:back, и обработайте её через bot.action('action:back', ...) — восстановите предыдущий шаг из состояния.


Что дальше

  • Подключить CRM (AmoCRM, Bitrix24) через их API — создавать сделки прямо из бота
  • Добавить фото работ на шаге выбора типа мебели
  • Сохранить состояния в Redis для стабильности
  • Добавить админ-панель: команду /stats для подсчёта заявок за день

Удачи с ботом! 🚀