Businka
@MariDesignWОтвечу в течение 30 минут
● Брендбук
Главная/Блог

how-to · бот для max мессенджер

Как сделать бота для MAX: API, poll и заявка

Дмитрий Businka · квиз

MAX — российский мессенджер с ботами, кнопками и Mini App. Эта статья — рабочий контур, а не «скопируй bot.py из Telegram». Ниже: токен, хост API 2026 года, GET /me, long poll /updates, webhook POST /subscriptions, исходящие сообщения, клавиатура, заявка в CRM Businka, файлы, антифлуд, хостинг. Примеры сверял с боевым кодом студии и с официальной докой dev.max.ru/docs-api.

Я Дима, Businka. Ботов Telegram и MAX держу на одном сервере, заявки складываю в одну CRM. Сценарий переносится. Коннектор — нет.

Код Telegram Bot API в MAX не вставляется. Другой хост, другой идентификатор человека, другие события, другая клавиатура.

Как это выглядит в работах студии, а не в абстрактном API: каталог Royal Coast в MAX, доставка Сувлак & Кебаб (меню, корзина, оплата), заявка в CRM. Кейс Mini App — разбор доставки. Связка бот → CRM — кейс заявок. Заказать контур — квиз.

Что здесь есть по разделам: чем MAX не Telegram; хост platform-api и platform-api2; токен и Authorization; GET /me; long poll и marker; webhook и секрет; исходящее POST /messages; нарезка длинных текстов; кнопки; контакт и hash; конечный автомат; CRM ingest; файлы через /uploads; PM2; грабли; когда заказывать.

Как это выглядит у нас

Не абстрактный bot.py. Каталог и доставка, которые можно открыть. На главной нет выдуманных отзывов — те же функции.

Первый экран каталога ROYAL COAST в мессенджере
Первый экран каталога ROYAL COAST в мессенджере

2025-2026

Каталог ROYAL COAST

  • MAX
  • Telegram @royalcoast_bot
  • 63 объекта в четырёх рынках
Проверить проект
Меню mini-app Сувлак и Кебаб
Меню mini-app Сувлак и Кебаб

2025-2026

Сувлак & Кебаб

  • Две точки продаж
  • Онлайн-оплата
  • Статусы заказа
Проверить проект

На главной: работаю с Telegram и MAX Bot API в проде — бот, mini-app, оплата, статусы, CRM. Срок простого бота с заявкой — от 3 дней после фиксации объёма, Mini App — от 5. Кейсы без API: Mini App доставки, бот + CRM. Ниже — хост, токен, poll и webhook.

Чем MAX отличается от Telegram

В Telegram бот живёт на api.telegram.org. Апдейты — getUpdates или webhook. Идентификатор чата — chat_id. Клавиатура — reply_markup.inline_keyboard. Секрет webhook — заголовок X-Telegram-Bot-Api-Secret-Token.

В MAX платформенный хост другой. На момент написания официальная документация просит слать HTTPS на https://platform-api2.max.ru. Боевые процессы Businka, которые уже крутятся, ходят на https://platform-api.max.ru — этот хост платформа объявляла как замену старому botapi.max.ru. Если новый хост отвечает 200, переключайте клиент. Если нет — не ломайте прод «по статье из интернета», проверьте кабинет и текущую док.

Исходящее сообщение в личку уходит POST /messages?user_id=. Входящие события — long poll GET /updates или webhook POST /subscriptions. Нажатие кнопки — message_callback, не callback_query. Старт диалога — bot_started.

Аудитория часто уже в MAX из-за белых списков операторов. Сценарий тот же: человек пишет боту → заявка не теряется → менеджер видит её в CRM. Кабинеты партнёра и Mini App живут у экосистемы VK/MAX, не у BotFather.

Что не обещаю: бот не заменяет отдел продаж. Он принимает заявку, квалифицирует по сценарию и не теряет контакт. ИИ как слой квалификации — отдельно, и только если диалог куда-то пишется.

Что нужно до первой строки кода

  1. Зарегистрировать бота в кабинете партнёра MAX (раздел чат-ботов / «MAX для бизнеса») и получить токен.
  2. Сервер, который крутится 24/7. Ноутбук с открытым терминалом — не хостинг.
  3. Куда класть заявку: таблица, CRM, рабочий чат админа. Без этого бот — переписка в никуда.
  4. Сценарий на бумаге: приветствие, 2–3 вопроса, кнопка «оставить заявку» или Mini App.
  5. Понимание, кто публикует бота. Правила кабинета менялись: публикация Mini App и ботов для бизнеса завязана на верификацию организации. Это не «техническая мелочь», это допуск. Сверяйте текущие правила на dev.max.ru, не эту статью через год.

Токен только в env. В git, в скриншоты, в чат клиенту и в бандл Mini App его не кладём.

bash
export MAX_BOT_TOKEN="ваш_токен_из_кабинета"
# не коммить, не печатать в pm2 logs целиком

Токен в кабинете: чат-боты → расширенные настройки. Либо команда в боте «MAX для бизнеса», если кабинет так устроен на момент вашей регистрации.

Хост API: не копируйте чужой гайд слепо

Документация MAX в 2026 году пишет: запросы на platform-api2.max.ru вместо platform-api.max.ru. Токен через query (?access_token=) больше не поддерживается — только заголовок Authorization. Метод GET /chats с июня 2026 снят; список групповых чатов — через подписки, не через этот GET.

В нашем коде заголовок — сам токен, без префикса Bearer. Так отвечают живые боты. Если кабинет когда-нибудь потребует Bearer , это будет видно по 401 на /me. Не гадайте заранее.

Обёртка, которую я ставлю в новые контуры: один клиент, хост из env.

js
const MAX_API = (
  process.env.MAX_API_BASE || "https://platform-api.max.ru"
).replace(/\/$/, "");

function maxHeaders() {
  return {
    Authorization: process.env.MAX_BOT_TOKEN,
    "Content-Type": "application/json",
  };
}

async function maxApi(method, path, body, timeoutMs = 15000) {
  const res = await fetch(MAX_API + path, {
    method,
    headers: maxHeaders(),
    body: body ? JSON.stringify(body) : undefined,
    signal: AbortSignal.timeout(timeoutMs),
  });
  const data = await res.json().catch(() => ({ raw: true, status: res.status }));
  if (res.status === 401) {
    throw new Error("MAX 401: token rejected");
  }
  if (res.status === 429) {
    throw new Error("MAX 429: slow down");
  }
  return data;
}

Для проверки, жив ли api2, достаточно GET /me на оба хоста на стейдже. На прод не прыгайте каждые две недели.

Платформа просит доверенный TLS, в том числе корневой сертификат Минцифры. На голом контейнере с урезанным ca-certificates запрос к MAX может падать на verify. Это не «бот молчит» — это Node не доверяет цепочке. На боевом Ubuntu с системными корнями у нас запросы ходят. Если поднимете бота в минимальном Docker — проверьте сертификаты, не вините сразу токен. Разбор с TLS Минцифры на Хабре: habr.com/ru/articles/1060586.

Документация также пишет лимит порядка 30 запросов в секунду на хост API. Для бота заявок это не проблема. Для рассылки «всем кто писал» — уже архитектура очереди, не for в цикле.

Проверка токена: GET /me

Перед циклом апдейтов спросите платформу «кто я». Если нет user_id — токен мёртвый, крутить poll бессмысленно.

Официальный пример ответа /me:

json
{
  "user_id": 1,
  "name": "My Bot",
  "username": "my_bot",
  "is_bot": true,
  "last_activity_time": 1737500130100
}

Код:

js
const me = await maxApi("GET", "/me", null);
if (!me.user_id) {
  throw new Error("MAX token rejected: " + JSON.stringify(me));
}
console.log("bot", me.name || me.username, me.user_id);

HTTP-коды, которые дока называет явно: 200 успех, 400 плохой запрос, 401 токен, 404 нет ресурса, 405 метод, 429 лимит, 503 платформа недоступна. На 503 poll должен подождать и повторить, не рестартовать процесс каждую секунду.

Команды бота настраиваются PATCH /me/commands. Это меню «/start» в клиенте, не замена сценария. Сценарий вы пишете сами.

Входящие события: long poll GET /updates

Long poll удобен на одном процессе PM2. Документация MAX прямо пишет: для production рекомендуют webhook, long poll ограничен по скорости и сроку хранения событий. Мы тем не менее держим long poll на внутренних ботах, потому что один процесс, один токен, нет публичного endpoint. Для клиентского бота, который должен пережить рестарт и не терять пачку, webhook честнее.

Правила /updates из доки:

  • timeout — 0–90 секунд, по умолчанию 30.
  • limit — 1–1000, по умолчанию 100.
  • marker — указатель на следующее обновление. Передали marker — предыдущее считается прочитанным.
  • Если marker не передать или передать null — получите только последнее обновление. Это не «всю историю».
  • types — фильтр, например message_created,message_callback.
  • Одновременно webhook и long poll использовать нельзя.

Мы подписываемся на три типа: message_created, message_callback, bot_started. Poll-таймаут клиента должен быть больше 30 секунд, иначе оборвёте ожидание раньше платформы. В Adelya-боте HTTP-таймаут poll — 50 секунд при timeout=30.

js
let marker = null;

async function poll() {
  while (true) {
    let path = "/updates?timeout=30&types=message_created,message_callback,bot_started";
    if (marker != null) path += "&marker=" + encodeURIComponent(String(marker));
    try {
      const data = await maxApi("GET", path, null, 50000);
      for (const update of data.updates || []) {
        await handleUpdate(update);
      }
      if (data.marker != null) marker = data.marker;
    } catch (err) {
      console.error("poll failed", err.message);
      await new Promise((r) => setTimeout(r, 3000));
    }
  }
}

handleUpdate смотрит update.update_type. Для текста — message_created. Для нажатия кнопки — message_callback. bot_started — человек только открыл бота: короткое приветствие, не анкета из десяти полей.

Поля вроде update.message.sender.user_id в примерах — ориентир. Реальный payload логируйте один раз на стейдже ключами объекта, не полным PII, и привяжите парсер к факту. API живой.

Два процесса poll на одном токене — гонка. Один воркер.

Webhook и poll вместе не живут. Если в кабинете или через POST /subscriptions висит подписка, /updates будет пустым или бесполезным. Снимите подписку, потом poll.

Webhook: POST /subscriptions

Это то, что дока называет основным механизмом для продакшена.

Условия:

  • URL только HTTPS, порт только 443, порт в URL не пишут.
  • Сертификат от доверенного ЦА или Минцифры. Самоподписанный не принимают.
  • CN/SAN совпадает с доменом.
  • Ответ HTTP 200 за 30 секунд. Иначе ошибка доставки.
  • Если указали secret — каждый запрос несёт заголовок X-Max-Bot-Api-Secret. Без совпадения — 401, не 200.
  • Повторы: до 10 попыток с интервалом 60с × 2.5. Если 8 часов нет успеха — платформа отписывает бота сама.

Пример из официальной доки:

bash
curl -X POST "https://platform-api2.max.ru/subscriptions" \
  -H "Authorization: {access_token}" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-domain.com/webhook",
    "update_types": ["message_created", "bot_started"],
    "secret": "your_secret"
  }'

secret — 5–256 символов, класс [A-Za-z0-9_-].

Приёмник в Next.js должен ответить сразу и унести работу в очередь. Если route думает 25 секунд на ingest, поймаете ретраи и двойные заявки.

js
import { createHmac, timingSafeEqual } from "node:crypto";

export async function POST(req) {
  const secret = process.env.MAX_WEBHOOK_SECRET || "";
  const got = req.headers.get("x-max-bot-api-secret") || "";
  if (!secret || got.length !== secret.length) {
    return new Response("forbidden", { status: 401 });
  }
  const a = Buffer.from(got);
  const b = Buffer.from(secret);
  if (!timingSafeEqual(a, b)) {
    return new Response("forbidden", { status: 401 });
  }
  const update = await req.json();
  queue.push(update);
  return Response.json({ ok: true });
}

queue в памяти умрёт при рестарте. Для заявок — таблица max_updates(id, payload, done). Идемпотентность по marker / id события: повтор webhook не должен плодить вторую карточку в CRM.

Список подписок — GET /subscriptions. Снять — соответствующий DELETE в текущей доке (имя метода сверяйте, не копируйте из кэша статьи).

nginx на 443 уже стоит перед Node. Не поднимайте второй nginx «для MAX». Location на приложение:

nginx
location /api/max/webhook {
  proxy_pass http://127.0.0.1:3004;
  proxy_set_header Host $host;
  proxy_set_header X-Forwarded-Proto https;
  proxy_set_header X-Max-Bot-Api-Secret $http_x_max_bot_api_secret;
}

Перед правкой конфига — копия, nginx -t, потом reload. Порт 443 на этом сервере боевой.

Исходящее сообщение: POST /messages

Идентификатор получателя в личке — user_id в query. Для чата или канала дока даёт chat_id. Как получить chat_id: из Update (bot_added, bot_started) или из Mini App initData. Не из выдуманного GET /chats.

js
async function sendMax(userId, text, extra = {}) {
  const path = "/messages?" + new URLSearchParams({ user_id: String(userId) });
  return maxApi("POST", path, { text, notify: true, ...extra });
}

await sendMax(userId, "Опишите задачу в двух предложениях. Я отвечу сам.");

Длинный ответ режьте. В наших ботах порог нарезки — около 3900 символов, режем по последнему переводу строки после 1500, пауза 500 мс между частями. Платформа и клиент не любят простыни.

js
function splitText(text, max = 3900) {
  const parts = [];
  let remaining = String(text || "").trim();
  while (remaining.length > 0) {
    if (remaining.length <= max) {
      parts.push(remaining);
      break;
    }
    const chunk = remaining.slice(0, max);
    const lastNl = chunk.lastIndexOf("\n");
    const cut = lastNl > 1500 ? lastNl : max;
    parts.push(remaining.slice(0, cut).trim());
    remaining = remaining.slice(cut).trim();
  }
  return parts;
}

async function sendMaxSafe(userId, text) {
  for (const part of splitText(text)) {
    const r = await sendMax(userId, part);
    if (r && r.code === "dialog.not.found") return r;
    await new Promise((x) => setTimeout(x, 400));
  }
}

dialog.not.found — человек ещё не открыл бота, или диалога нет. Писать в пустоту бессмысленно. В Adelya мы не шлём исходящие неизвестным user_id, пока не было bot_started.

notify: true — пуш. Не ставьте его на служебный флуд.

Кнопки: attachments.inline_keyboard

Кнопки — не «сайт внутри чата». Это короткие действия. Дока: до 210 кнопок, до 30 рядов, до 7 в ряду (до 3, если тип link, open_app, request_geo_location, request_contact). Пересланное сообщение кнопки не тащит.

Типы, которые дока перечисляет: callback, link, request_contact, request_geo_location, open_app, message, clipboard.

Нажатие callback приходит как message_callback, если вы подписаны на этот тип.

Пример из официальной доки — кнопка-ссылка:

json
{
  "text": "Это сообщение с кнопкой-ссылкой",
  "attachments": [
    {
      "type": "inline_keyboard",
      "payload": {
        "buttons": [
          [
            {
              "type": "link",
              "text": "Откройте сайт",
              "url": "https://businka-it.ru/sochi/boty"
            }
          ]
        ]
      }
    }
  ]
}

Для заявки удобнее callback:

js
async function sendLeadKeyboard(userId) {
  return sendMax(userId, "Что нужно сделать?", {
    attachments: [
      {
        type: "inline_keyboard",
        payload: {
          buttons: [
            [
              { type: "callback", text: "Сайт", payload: "need:site" },
              { type: "callback", text: "Бот", payload: "need:bot" },
            ],
            [{ type: "callback", text: "CRM", payload: "need:crm" }],
          ],
        },
      },
    ],
  });
}

Имена полей payload у кнопки сверяйте с текущей докой и с тем, что реально приходит в message_callback. Если кабинет ждёт payload строкой — не пихайте туда JSON на 2 КБ.

request_contact отдаёт контакт с полем hash, если номер совпадает с аккаунтом MAX. Дока прямо говорит: контакт из скрепки или пересланный из книги hash не содержит — это нельзя считать доказательством «номер принадлежит этому аккаунту». Данные с request_contact можно использовать только для этого бота (регистрация, статус заказа), не как базу для чужой рассылки.

Конечный автомат заявки

Без состояния бот каждый раз отвечает одно и то же. Минимальный автомат: start → brief → contact → done.

js
const sessions = new Map();

function step(userId) {
  return sessions.get(String(userId)) || { stage: "start" };
}

async function handleUpdate(update) {
  const type = update.update_type;
  if (type === "bot_started") {
    const userId = update.user_id || update.user?.user_id;
    if (!userId) return;
    sessions.set(String(userId), { stage: "brief" });
    await sendLeadKeyboard(userId);
    return;
  }

  if (type === "message_callback") {
    const userId = update.callback?.user?.user_id || update.user_id;
    const payload = String(update.callback?.payload || update.payload || "");
    if (!userId) return;
    sessions.set(String(userId), { stage: "contact", need: payload.replace("need:", "") });
    await sendMax(userId, "Оставьте телефон или @username — отвечу в рабочее время.");
    return;
  }

  if (type === "message_created") {
    const userId = update.message?.sender?.user_id;
    const text = String(update.message?.body?.text || "").trim();
    if (!userId || !text) return;
    const s = step(userId);
    if (s.stage === "brief") {
      sessions.set(String(userId), { stage: "contact", brief: text, need: s.need });
      await sendMax(userId, "Оставьте телефон или @username.");
      return;
    }
    if (s.stage === "contact") {
      await saveLead({ userId, brief: s.brief || s.need, contact: text });
      sessions.delete(String(userId));
      await sendMax(userId, "Заявку получил. Не пишите сюда пароли и токены.");
    }
  }
}

Ещё раз: точные пути к user_id в callback логируйте на стейдже. Пример выше — каркас, не SDK.

Map живёт, пока жив процесс. После рестарта PM2 человек начнёт сценарий заново — для заявки это нормально. Для корзины — SQLite.

js
import Database from "better-sqlite3";

const db = new Database("data/max-sessions.db");
db.exec(`
  CREATE TABLE IF NOT EXISTS max_sessions (
    user_id TEXT PRIMARY KEY,
    stage TEXT NOT NULL,
    payload TEXT NOT NULL DEFAULT '{}',
    updated_at INTEGER NOT NULL
  )
`);

function loadSession(userId) {
  const row = db.prepare("SELECT stage, payload FROM max_sessions WHERE user_id=?").get(String(userId));
  if (!row) return { stage: "start" };
  return { stage: row.stage, ...JSON.parse(row.payload) };
}

function saveSession(userId, state) {
  const { stage, ...rest } = state;
  db.prepare(
    `INSERT INTO max_sessions(user_id, stage, payload, updated_at)
     VALUES(?,?,?,?)
     ON CONFLICT(user_id) DO UPDATE SET stage=excluded.stage, payload=excluded.payload, updated_at=excluded.updated_at`
  ).run(String(userId), stage, JSON.stringify(rest), Date.now());
}

На живой adel-events.db / crm.db такую таблицу не создавайте без бэкапа. Для сессий бота — отдельный файл.

Заявка в CRM, не в чат разработчика

Бот, который пишет вам в личку «пришёл лид» и больше ничего не делает, сломается в отпуск. Нормальный контур: бот → HTTP ingest → CRM.

На businka-it.ru публичный приём — POST /api/leads/ingest. Это не выдумка для статьи: тот же route принимает формы сайта, квиз и ботов.

Важные факты контракта, из кода:

  • source — строгий список. Для MAX-бота это bot_max, не max и не telegram. Для сайта — site. Для Telegram-бота — bot_tg. Иначе API ответит Invalid source 400.
  • source_detail обрезается до 64 символов. Ключ должен отличаться на каждой посадочной: форма Сочи и бот — разные строки.
  • Пустая заявка (нет имени, телефона, tg, email, message, raw_text) — 400 Empty lead.
  • Если задан LEADS_HUB_SECRET, нужен заголовок x-lead-signature: hex HMAC-SHA256 тела. Без него — 401.
  • Дубли по отпечатку (телефон или tg/email + source_detail + день) не плодят вторую карточку как новый лид.
js
import { createHmac } from "node:crypto";

async function saveLead({ userId, brief, contact }) {
  const body = JSON.stringify({
    source: "bot_max",
    source_detail: "max-bot:lead",
    name: null,
    phone: contact.startsWith("+") || /^\d[\d\s()-]{9,}$/.test(contact) ? contact : null,
    tg: contact.startsWith("@") ? contact.slice(1) : null,
    email: null,
    message: brief || "",
    meta: { max_user_id: String(userId) },
  });
  const headers = { "Content-Type": "application/json" };
  const secret = process.env.LEADS_HUB_SECRET;
  if (secret) {
    headers["x-lead-signature"] = createHmac("sha256", secret).update(body).digest("hex");
  }
  const res = await fetch("https://businka-it.ru/api/leads/ingest", {
    method: "POST",
    headers,
    body,
  });
  if (!res.ok) throw new Error("ingest " + res.status + " " + (await res.text()));
  return res.json();
}

На гео-странице ботов форма сайта пишет night-sniper:sochi-boty. Бот пусть пишет свой ключ, не чужой.

Не кладите в сообщение токен MAX, ключи CRM и персональные данные третьих лиц.

Уведомление себе в рабочий чат — дополнительно, не вместо CRM. Иначе лид живёт в истории мессенджера и теряется в поиске.

Файлы: POST /uploads, потом вложение

Исходящий файл — не multipart сразу в /messages. Сначала платформа выдаёт URL загрузки.

Каркас из наших ботов (упрощённо):

js
async function sendFile(userId, fileBuffer, filename) {
  const uploadType = /\.(mp3|wav|ogg)$/i.test(filename) ? "audio" : "file";
  const uploadInfo = await maxApi("POST", "/uploads?type=" + uploadType, null);
  if (!uploadInfo.url) throw new Error("no upload url");

  const form = new FormData();
  form.append("data", new Blob([fileBuffer]), filename);
  const uploaded = await fetch(uploadInfo.url, { method: "POST", body: form }).then((r) => r.json());
  const token = uploadInfo.token || uploaded.token || uploaded.fileId;
  if (!token) throw new Error("no file token");

  const att = { type: uploadType, payload: { token } };
  if (uploadType === "file") att.payload.filename = filename;

  const sendResult = await maxApi("POST", "/messages?user_id=" + userId, {
    text: filename,
    attachments: [att],
  });
  if (sendResult.code === "attachment.not.ready") {
    await new Promise((r) => setTimeout(r, 3000));
    await maxApi("POST", "/messages?user_id=" + userId, {
      text: filename,
      attachments: [att],
    });
  }
}

Точную форму multipart сверяйте с докой загрузок. attachment.not.ready у нас реально встречается: платформа ещё не прожевала файл, повтор через несколько секунд.

Входящие вложения обрабатывайте отдельной веткой handleUpdate. Не ждите, что body.text всегда есть.

Mini App

Mini App нужен, когда в чате тесно: каталог, корзина, запись, фильтры. Для одной кнопки «оставить телефон» Mini App не обязателен. Кнопка типа open_app открывает его из клавиатуры.

Каталог недвижимости Royal Coast и доставка Сувлак & Кебаб у нас живут как Mini App + бот, не как простыня из 40 сообщений. Отдельного публичного «скопируй этот JSON и получи магазин» у MAX нет в том виде, в каком его рисуют инфоцыгане. Сборку витрины заказываете как продукт: экран, корзина, оплата, статусы.

Если переносите Telegram Mini App в MAX — Bridge API другой. Переезжает продуктовая логика (товары, заказ), не HTML «как было».

chat_id для Mini App дока разрешает брать с клиента через window.WebApp.initData библиотеки MAX Bridge. Без серверной проверки initData это дырка: любой нарисует чужой id.

Оплата внутри Mini App — касса, оферта, фискализация. Не обещайте «кнопку оплаты за вечер», если нет юрконтура.

Хостинг

Процесс должен пережить закрытие ноутбука. У нас боты сидят под PM2 на том же контуре, что и сайт. Минимальный unit:

bash
pm2 start max-lead-bot.js --name max-lead-bot
pm2 save

Новый воркер, если это часть night-sniper, должен попасть в ecosystem.clean.cjs и pm2 save. Иначе после ребута сервера бот не встанет.

Рядом: ротация логов, рестарт на ошибке, env с токеном не в репозитории. Два инстанса poll на одном токене — гонка апдейтов.

Health на localhost, не в интернет:

js
import { createServer } from "node:http";

createServer((_req, res) => {
  res.writeHead(200, { "Content-Type": "text/plain" });
  res.end("ok");
}).listen(8091, "127.0.0.1");

SSL для long poll к API — исходящий. Входящий HTTPS нужен, если перейдёте на webhook.

Не кладите Node на 443. 443 — nginx, сайты.

Что логировать

Один раз на стейдже распечатайте ключи объекта, не весь PII:

js
function summarize(update) {
  return {
    type: update.update_type,
    keys: Object.keys(update || {}),
    hasMessage: Boolean(update.message),
    hasCallback: Boolean(update.callback),
  };
}

В прод-лог: тип события, усечённый id, ошибка API. Не токен, не полный текст с паспортами. Ротация обязательна: бот болтливый.

Антиспам и флуд

Без паузы человек забьёт CRM повторами. Простой ограничитель: не чаще одной заявки с user_id за 10 минут. Серверное, не «кнопка disabled в UI».

js
const lastLeadAt = new Map();

function allowLead(userId) {
  const now = Date.now();
  const prev = lastLeadAt.get(String(userId)) || 0;
  if (now - prev < 10 * 60 * 1000) return false;
  lastLeadAt.set(String(userId), now);
  return true;
}

CRM со своей стороны ещё дедупит по отпечатку. Два слоя лучше, чем вера в один.

Не отвечайте ботом на бота. Если пришёл служебный тип, которого вы не ждали — лог и return, не падение процесса.

Не делайте рассылку всем, кто когда-либо написал, «потому что токен позволяет». Это уже закон о рекламе и правила платформы, не «ещё один cron».

Совместимость с Telegram в одном продукте

Общий слой сценария и общая CRM. Два тонких адаптера.

js
export async function notifyUser(channel, id, text) {
  if (channel === "max") return sendMax(id, text);
  if (channel === "tg") {
    return fetch("https://api.telegram.org/bot" + process.env.TG_BOT_TOKEN + "/sendMessage", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ chat_id: id, text }),
    });
  }
  throw new Error("unknown channel");
}

CRM хранит max_user_id и tg_chat_id отдельно. Склеивать их в одно поле messenger_id — способ потерять человека при переезде канала.

Как устроен Telegram-коннектор рядом: разработка Telegram-бота под ключ. Заказ с офисом в Сочи: боты Telegram и MAX.

Каркас процесса целиком

Ниже — один файл, которого хватает, чтобы вечером получить заявку. Это не фреймворк. Когда появятся вложения, очереди и несколько ботов — выносите maxApi в модуль.

js
import { createHmac } from "node:crypto";

const MAX_API = (process.env.MAX_API_BASE || "https://platform-api.max.ru").replace(/\/$/, "");
const TOKEN = process.env.MAX_BOT_TOKEN;
if (!TOKEN) throw new Error("MAX_BOT_TOKEN required");

async function maxApi(method, path, body, timeoutMs = 15000) {
  const res = await fetch(MAX_API + path, {
    method,
    headers: { Authorization: TOKEN, "Content-Type": "application/json" },
    body: body ? JSON.stringify(body) : undefined,
    signal: AbortSignal.timeout(timeoutMs),
  });
  return res.json();
}

async function sendMax(userId, text) {
  const path = "/messages?" + new URLSearchParams({ user_id: String(userId) });
  return maxApi("POST", path, { text, notify: true });
}

const sessions = new Map();
let marker = null;

async function saveLead({ userId, brief, contact }) {
  const body = JSON.stringify({
    source: "bot_max",
    source_detail: "max-bot:lead",
    phone: /^\+?\d/.test(contact) ? contact : null,
    tg: contact.startsWith("@") ? contact.slice(1) : null,
    message: String(brief || ""),
    meta: { max_user_id: String(userId) },
  });
  const headers = { "Content-Type": "application/json" };
  const secret = process.env.LEADS_HUB_SECRET;
  if (secret) headers["x-lead-signature"] = createHmac("sha256", secret).update(body).digest("hex");
  const res = await fetch("https://businka-it.ru/api/leads/ingest", { method: "POST", headers, body });
  if (!res.ok) throw new Error("ingest " + res.status);
}

async function handleUpdate(update) {
  if (update.update_type === "bot_started") {
    const userId = update.user_id;
    sessions.set(String(userId), { stage: "brief" });
    await sendMax(userId, "Напишите, что нужно: сайт, бот или CRM.");
    return;
  }
  if (update.update_type !== "message_created") return;
  const userId = update.message?.sender?.user_id;
  const text = String(update.message?.body?.text || "").trim();
  if (!userId || !text) return;
  const s = sessions.get(String(userId)) || { stage: "start" };
  if (s.stage === "brief") {
    sessions.set(String(userId), { stage: "contact", brief: text });
    await sendMax(userId, "Оставьте телефон или @username.");
    return;
  }
  if (s.stage === "contact") {
    await saveLead({ userId, brief: s.brief, contact: text });
    sessions.delete(String(userId));
    await sendMax(userId, "Заявку получил.");
  }
}

async function main() {
  const me = await maxApi("GET", "/me", null);
  if (!me.user_id) throw new Error("token rejected");
  console.log("bot", me.username || me.name, me.user_id);
  while (true) {
    let path = "/updates?timeout=30&types=message_created,message_callback,bot_started";
    if (marker != null) path += "&marker=" + encodeURIComponent(String(marker));
    try {
      const data = await maxApi("GET", path, null, 50000);
      for (const u of data.updates || []) await handleUpdate(u);
      if (data.marker != null) marker = data.marker;
    } catch (e) {
      console.error(e.message);
      await new Promise((r) => setTimeout(r, 3000));
    }
  }
}

main();

Собрать echo по этому тексту можно за вечер. Боевой контур — сценарий, админка, хостинг, CRM, иногда Mini App и оплата — это уже продукт.

Типичные грабли

  • Смешали chat_id Telegram и user_id MAX — сообщения уходят в никуда, API отвечает ошибкой.
  • Не сохраняете marker — получите только последнее событие или дыры.
  • Висит webhook, а вы крутите poll — лента пустая.
  • Два poll на одном токене.
  • Сценарий из 15 шагов без «назад» — человек бросает на третьем вопросе.
  • Бот на shared hosting с засыпающим PHP — long poll умрёт.
  • Токен в query, как в старых гайдах — 401.
  • GET /chats — метода больше нет.
  • Самоподписанный сертификат на webhook — события не доедут, через 8 часов подписка снимется.
  • Обещали «ИИ заменит менеджера» — получите разочарование.
  • source: "max" в ingest — 400. Нужен bot_max.

Юридическое, коротко

Бот собирает телефон — это ПДн. Нужны политика и согласие в сценарии («нажимая кнопку, вы соглашаетесь…») плюс ссылка. Рассылки в MAX без запроса — отдельный разговор и отдельный риск. Эта статья про заявку, не про спам-канал.

Оплата внутри Mini App — касса, оферта, фискализация.

Я не юрист и не заменяю оферту кабинета MAX. Перед публикацией бота в каталог читайте текущие правила платформы.

Когда статья не заменяет заказ

Здесь достаточно, чтобы разработчик собрал контур заявки. Недостаточно, чтобы заменить продуктовую работу: админка статусов, Mini App каталога, две кассы, SLA. Это уже смета после брифа, не README.

Срок простого бота с заявкой у нас — от 3 дней после фиксации объёма. Mini App — от 5. Вилки «от N рублей по Сочи» из сетевых лендингов не использую.

Задача — в квизе или на странице ботов. Офис: Сочи, ул. Несербская, 6.

Документация, с которой сверял факты API: обзор MAX API, GET /updates, POST /messages, POST /subscriptions. Если дока и эта страница разошлись — верьте кабинету и доке, пришлите скрин, поправлю статью.

Вопросы

Можно ли перенести Telegram-бота в MAX одним файлом?
Сценарий и CRM — да. Код Bot API Telegram в MAX не вставляется: другой хост, user_id вместо chat_id, другие события.
Какой хост API: platform-api или platform-api2?
Документация MAX в 2026 просит platform-api2.max.ru. Боевые боты Businka ходят на platform-api.max.ru. Хост проверяйте GET /me на стейдже, токен — только заголовок Authorization.
Long poll или webhook?
Дока MAX для прода рекомендует webhook POST /subscriptions (HTTPS :443, секрет X-Max-Bot-Api-Secret). Long poll /updates удобен на одном процессе PM2. Одновременно оба нельзя.
Какой source писать в CRM?
bot_max. Строки max и telegram ingest отвергнет. source_detail — до 64 символов, свой на каждого бота.
Нужен ли Mini App?
Для каталога, корзины, записи — да. Для одной заявки хватает бота с кнопками callback или request_contact.
Бот заменит отдел продаж?
Нет. Он принимает заявку и не теряет её.
Почему бот молчит после /updates?
Часто висит webhook-подписка, не сохраняется marker, или два poll на одном токене. Ещё 401 — токен в query вместо Authorization.
Срок заказа?
Простой бот с заявкой — от 3 дней после фиксации объёма. Mini App — от 5. Смета после брифа.

Нужен бот в MAX — опишите задачу. Квиз

Ещё в блоге

  • Парсер для бизнеса: открытые страницы, не взлом
  • Лендинг под ключ: состав работ, не подписка конструктора
  • Создание сайта в Сочи без конструктора
  • Разработка Telegram-бота под ключ
Direct contact / no managers

Обсудим задачу и сроки

Опишите задачу. Я разберу объём, предложу следующий шаг и дам предварительную оценку. Без менеджеров и пересказов.

BK
Businka
Разработчик, отвечаю лично

Обычно отвечаю в течение 30 минут в рабочее время.

Businka
Businka
✛Разработка, которая попадает в цель

Закрываю весь цикл сама: исследование, дизайн, код, запуск и поддержка. Работаю по договору и NDA, исходники отдаю, инфраструктуру документирую.

Связь
Сочи, ул. Несербская, д. 6Telegram — @MariDesignW
Услуги
001.Сайты002.Игры и WebGL003.Боты и Mini Apps004.CRM005.AI006.Парсеры007.СкраперыБлогСочи
Есть задача?

Опишите её в двух предложениях. Я отвечу планом, сроком и предварительной сметой. Отвечаю быстро, без менеджеров.

Обсудить проект
© 2026 BUSINKA - Powerful code for influential business
БлогПолитика конфиденциальностиБрендбукBusinka OS