how-to · бот для max мессенджер
Как сделать бота для MAX: API, poll и заявка
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. Каталог и доставка, которые можно открыть. На главной нет выдуманных отзывов — те же функции.


На главной: работаю с 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.
Что не обещаю: бот не заменяет отдел продаж. Он принимает заявку, квалифицирует по сценарию и не теряет контакт. ИИ как слой квалификации — отдельно, и только если диалог куда-то пишется.
Что нужно до первой строки кода
- Зарегистрировать бота в кабинете партнёра MAX (раздел чат-ботов / «MAX для бизнеса») и получить токен.
- Сервер, который крутится 24/7. Ноутбук с открытым терминалом — не хостинг.
- Куда класть заявку: таблица, CRM, рабочий чат админа. Без этого бот — переписка в никуда.
- Сценарий на бумаге: приветствие, 2–3 вопроса, кнопка «оставить заявку» или Mini App.
- Понимание, кто публикует бота. Правила кабинета менялись: публикация Mini App и ботов для бизнеса завязана на верификацию организации. Это не «техническая мелочь», это допуск. Сверяйте текущие правила на dev.max.ru, не эту статью через год.
Токен только в env. В git, в скриншоты, в чат клиенту и в бандл Mini App его не кладём.
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.
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:
{
"user_id": 1,
"name": "My Bot",
"username": "my_bot",
"is_bot": true,
"last_activity_time": 1737500130100
}Код:
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.
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 часов нет успеха — платформа отписывает бота сама.
Пример из официальной доки:
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, поймаете ретраи и двойные заявки.
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 на приложение:
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.
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 мс между частями. Платформа и клиент не любят простыни.
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, если вы подписаны на этот тип.
Пример из официальной доки — кнопка-ссылка:
{
"text": "Это сообщение с кнопкой-ссылкой",
"attachments": [
{
"type": "inline_keyboard",
"payload": {
"buttons": [
[
{
"type": "link",
"text": "Откройте сайт",
"url": "https://businka-it.ru/sochi/boty"
}
]
]
}
}
]
}Для заявки удобнее callback:
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.
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.
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 source400.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 + день) не плодят вторую карточку как новый лид.
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 загрузки.
Каркас из наших ботов (упрощённо):
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:
pm2 start max-lead-bot.js --name max-lead-bot
pm2 saveНовый воркер, если это часть night-sniper, должен попасть в ecosystem.clean.cjs и pm2 save. Иначе после ребута сервера бот не встанет.
Рядом: ротация логов, рестарт на ошибке, env с токеном не в репозитории. Два инстанса poll на одном токене — гонка апдейтов.
Health на localhost, не в интернет:
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:
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».
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. Два тонких адаптера.
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 в модуль.
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_idTelegram иuser_idMAX — сообщения уходят в никуда, 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 — опишите задачу. Квиз