Night Sniper
@nightsniper01Отвечу в течение 30 минут
● hi@night-sniper.ruБрендбук
Главная/Блог

how-to · разработка telegram бота

Разработка Telegram-бота под ключ

Дмитрий Night Sniper · квиз

Telegram-бот под ключ — это не «файл bot.py на фрилансе». Это сценарий, процесс на сервере, приём заявки и место, куда заявка падает. Ниже — рабочий контур на Bot API: токен, long poll и webhook, клавиатура, конечный автомат, Mini App и HMAC initData, связка с CRM Night Sniper. Без копипасты MAX: хост и идентификаторы другие.

Я Дима, Night Sniper. Держу Telegram и MAX. Сценарий общий, два коннектора. Срок простого бота с заявкой — от 3 дней после фиксации объёма. Mini App — от 5. Вилки «от 25 840 ₽ по Сочи» из сетевых лендингов не использую: объём сценария разный.

Официальная справка методов: core.telegram.org/bots/api. Mini App: core.telegram.org/bots/webapps. Если эта страница и дока разошлись — верьте доке.

Что внутри: токен и getMe; getUpdates и offset; почему poll молчит; setWebhook / deleteWebhook / secret_token; nginx; сообщение и кнопки; callback; автомат заявки; очередь и идемпотентность; ingest в CRM; Mini App HMAC; группы; parse_mode; хостинг; два мессенджера; грабли.

Что вы получаете, когда заказываете бота

  • Сценарий: приветствие, квалификация, заявка или Mini App.
  • Админка или CRM: кто видит лид, какой статус.
  • Хостинг: процесс живёт после закрытия ноутбука.
  • Исходники: код ваш, не подписка на конструктор ботов.

Бот не заменяет отдел продаж. Он принимает заявку и не теряет её. ИИ как слой квалификации — отдельно.

Каталог Royal Coast в Telegram — отдельный контур: карточки, заявка, связь с CRM. Перенос «скопировал bot.py» не работает.

Живые контуры Night Sniper: каталог Royal Coast, Сувлак & Кебаб, RICH GROUP. Как устроена доставка в Mini App — Mini App доставки. Бот + CRM — бот + CRM. Задача на разработку — квиз.

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

Кадры с витрины, не «макет бота в Figma». На главной те же функции, без цитат.

Каталог RICH GROUP с подборками по Крыму и Сочи
Каталог RICH GROUP с подборками по Крыму и Сочи

2025

RICH GROUP

  • Подбор по региону
  • Карточки объектов
  • Заявка внутри Telegram
Проверить проект
Первый экран каталога ROYAL COAST в мессенджере
Первый экран каталога ROYAL COAST в мессенджере

2025-2026

Каталог ROYAL COAST

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

2025-2026

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

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

На главной: MAX + Telegram в проде, исходники ваши, бот от 3 дней после фиксации объёма. Ниже — токен, poll, webhook и CRM. Это не второй каталог API, это рабочие грабли.

Токен и безопасность

Токен выдаёт @BotFather. Он равен полному доступу к боту. Env, не git, не Mini App на клиенте, не «файл для ознакомления» клиенту.

bash
export TG_BOT_TOKEN="123456:ABC..."
export TG_WEBHOOK_SECRET="latin_and_dash_only"

Проверка, что токен живой — getMe. Хост всегда https://api.telegram.org/bot<token>/METHOD.

js
const token = process.env.TG_BOT_TOKEN;

async function tg(method, payload = {}, timeoutMs = 15000) {
  const res = await fetch("https://api.telegram.org/bot" + token + "/" + method, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(payload),
    signal: AbortSignal.timeout(timeoutMs),
  });
  const data = await res.json();
  if (!data.ok) throw new Error(method + " " + JSON.stringify(data));
  return data.result;
}

const me = await tg("getMe", {});
console.log(me.username, me.id);

Не кладите токен в бандл Next. Route Handler и воркер — да. Браузер — нет.

Если токен светился в чате или в репозитории — считайте его скомпрометированным: Revoke в BotFather, новый в env, рестарт процесса.

Long poll: getUpdates

Для одного процесса на VPS достаточно long poll. Telegram отдаёт update_id. Следующий запрос должен звать offset = last + 1, иначе дубли. Дока: метод не работает, пока висит исходящий webhook.

Параметры из Bot API:

  • offset — первый апдейт, который хотите получить. Должен быть на единицу больше максимального уже полученного update_id.
  • limit — 1–100, по умолчанию 100.
  • timeout — секунды long poll. 0 — короткий poll, для теста. Для боя — положительный, у нас 30.
  • allowed_updates — фильтр типов. Пустой список — почти все, кроме chat_member и реакций.

Таймаут HTTP-клиента должен быть больше timeout, иначе оборвёте ожидание раньше Telegram. Ставьте 40–50 секунд на 30-секундный poll.

js
let offset = 0;

async function poll() {
  while (true) {
    try {
      const updates = await tg(
        "getUpdates",
        {
          offset,
          timeout: 30,
          allowed_updates: ["message", "callback_query"],
        },
        50000
      );
      for (const update of updates) {
        offset = update.update_id + 1;
        await handleUpdate(update);
      }
    } catch (err) {
      console.error("poll", err.message);
      await new Promise((r) => setTimeout(r, 3000));
    }
  }
}

Не поднимайте два poll на одном токене. Второй украдёт апдейты у первого.

Отрицательный offset в доке есть: «взять с хвоста очереди и забыть предыдущее». Для продакшена заявок я его не использую — можно молча потерять лид, который пришёл, пока бот лежал.

Почему poll молчит

Самая частая причина — не снят webhook. Дока прямо: getUpdates не работает, пока webhook установлен.

js
const info = await tg("getWebhookInfo", {});
console.log(info.url, info.pending_update_count, info.last_error_message);

await tg("deleteWebhook", { drop_pending_updates: false });

drop_pending_updates: true сотрёт очередь. Для отладки удобно, для боя — способ потерять заявки, которые пришли за ночь.

Вторая причина — бот в группе без права читать сообщения. Третья — фильтр allowed_updates, в котором нет callback_query, а вы ждёте кнопки.

Webhook: setWebhook

Webhook нужен, когда не хотите держать открытый GET в цикле: HTTPS endpoint, сертификат, один инстанс приёма.

Дока: Telegram шлёт HTTPS POST с JSON Update. Если ответ не 2xx — ретраит «разумное число раз». Порты webhook: 443, 80, 88, 8443. Для прод-сайта разумный только 443 за nginx.

secret_token — 1–256 символов класса [A-Za-z0-9_-]. Если задан, каждый запрос несёт заголовок X-Telegram-Bot-Api-Secret-Token.

js
await tg("setWebhook", {
  url: "https://night-sniper.ru/api/telegram/webhook",
  secret_token: process.env.TG_WEBHOOK_SECRET,
  allowed_updates: ["message", "callback_query"],
  drop_pending_updates: false,
  max_connections: 40,
});

max_connections — 1–100, по умолчанию 40. Для бота заявок 8–16 достаточно, меньше нагрузка на Node.

ip_address — если хотите, чтобы Telegram не ходил по DNS, а бил в фиксированный IP. На нашем контуре обычно не нужен.

Самоподписанный сертификат в certificate дока ещё умеет. Я его не использую: Let’s Encrypt / обычный ЦА, nginx на 443.

Снять webhook перед возвратом к poll:

js
await tg("deleteWebhook", { drop_pending_updates: false });

Иначе getUpdates молчит.

Проверка:

js
const info = await tg("getWebhookInfo", {});
if (info.last_error_message) {
  console.error("webhook last error", info.last_error_date, info.last_error_message);
}

Приём webhook: быстрый 200, очередь, секрет

Route, который думает 20 секунд, получит ретраи и двойные заявки.

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

export async function POST(req) {
  const secret = process.env.TG_WEBHOOK_SECRET || "";
  const got = req.headers.get("x-telegram-bot-api-secret-token") || "";
  if (!secret || got.length !== secret.length || !timingSafeEqual(Buffer.from(got), Buffer.from(secret))) {
    return new Response("forbidden", { status: 401 });
  }
  const update = await req.json();
  enqueue(update); // таблица, не массив в памяти
  return Response.json({ ok: true });
}

Чужой POST без секрета — 401, не 200 с пустым телом: иначе Telegram решит, что webhook живой.

enqueue в памяти процесса умрёт при рестарте. Для заявок:

js
import Database from "better-sqlite3";

const db = new Database("data/tg-updates.db");
db.exec(`
  CREATE TABLE IF NOT EXISTS tg_updates (
    update_id INTEGER PRIMARY KEY,
    payload TEXT NOT NULL,
    done INTEGER NOT NULL DEFAULT 0,
    created_at INTEGER NOT NULL
  )
`);

function enqueue(update) {
  db.prepare(
    `INSERT OR IGNORE INTO tg_updates(update_id, payload, created_at) VALUES(?,?,?)`
  ).run(update.update_id, JSON.stringify(update), Date.now());
}

function takeBatch(limit = 20) {
  return db.prepare(`SELECT update_id, payload FROM tg_updates WHERE done=0 ORDER BY update_id LIMIT ?`).all(limit);
}

function markDone(id) {
  db.prepare(`UPDATE tg_updates SET done=1 WHERE update_id=?`).run(id);
}

INSERT OR IGNORE по update_id — идемпотентность. Повтор webhook не плодит вторую карточку, если обработчик сначала пишет в эту таблицу, а CRM вызывается один раз после markDone только при успехе. Если ingest упал — не помечайте done.

nginx для webhook

Node не должен торчать в интернет на 3000. Терминация TLS — на nginx.

nginx
location /api/telegram/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-Telegram-Bot-Api-Secret-Token $http_x_telegram_bot_api_secret_token;
}

На нашем контуре корень уже проксируется в night-sniper-app. Отдельный location не обязателен, если путь живёт внутри Next. Перед правкой конфига — копия, nginx -t, reload. Порт 443 боевой.

Fail2ban и UFW: входящий 443/22, 22 с allowlist. Порт Node наружу не открываем.

Сообщение и кнопки

chat_id — чат, не user_id MAX. Для лички с человеком это его id, для группы — отрицательный. Храните как целое, не как 32-битный int: у супергрупп id не влезает в int32. В JS Number безопасен для telegram id (до 52 бит), в других языках берите int64.

js
async function sendLeadPrompt(chatId) {
  return tg("sendMessage", {
    chat_id: chatId,
    text: "Что нужно сделать?",
    reply_markup: {
      inline_keyboard: [
        [{ text: "Сайт", callback_data: "need:site" }],
        [{ text: "Бот", callback_data: "need:bot" }],
        [{ text: "CRM", callback_data: "need:crm" }],
      ],
    },
  });
}

callback_data — короткий, лимит Bot API 64 байта. Не пихайте туда JSON заказа.

Нажатие приходит callback_query. Подтвердите answerCallbackQuery, иначе часики на кнопке крутятся вечно.

js
async function onCallback(q) {
  await tg("answerCallbackQuery", { callback_query_id: q.id });
  const chatId = q.message.chat.id;
  const need = String(q.data || "").replace("need:", "");
  sessions.set(String(chatId), { stage: "contact", need });
  await tg("sendMessage", {
    chat_id: chatId,
    text: "Оставьте телефон или @username.",
  });
}

Reply-клавиатура (keyboard) закрывает поле ввода и живёт, пока не снимете. Для заявки я чаще беру inline: меньше мусора в следующем сообщении человека.

request_contact в reply-клавиатуре даёт контакт. Это удобно. Это всё ещё ПДн.

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

Минимальный автомат: start → need → contact → done.

js
const sessions = new Map();

async function handleUpdate(update) {
  if (update.callback_query) {
    await onCallback(update.callback_query);
    return;
  }
  const msg = update.message;
  if (!msg || !msg.chat) return;
  const chatId = msg.chat.id;
  const text = String(msg.text || "").trim();

  if (msg.contact && msg.contact.phone_number) {
    const s = sessions.get(String(chatId)) || {};
    await saveLead({
      chatId,
      need: s.need || "contact-button",
      contact: msg.contact.phone_number,
      name: msg.contact.first_name || null,
    });
    sessions.delete(String(chatId));
    await tg("sendMessage", { chat_id: chatId, text: "Заявку получил. Пароли сюда не присылайте." });
    return;
  }

  if (text === "/start") {
    sessions.set(String(chatId), { stage: "start" });
    await sendLeadPrompt(chatId);
    return;
  }
  const s = sessions.get(String(chatId));
  if (s && s.stage === "contact" && text) {
    await saveLead({ chatId, need: s.need, contact: text });
    sessions.delete(String(chatId));
    await tg("sendMessage", {
      chat_id: chatId,
      text: "Заявку получил. Напишите, если уточните задачу — не присылайте пароли.",
    });
  }
}

Map сбросится при рестарте. Для заявок приемлемо. Для корзины — SQLite, как в статье про MAX.

Команды /start недостаточно, если человек пришёл по диплинку ?start=from-sochi. Тогда text будет /start from-sochi или /start@bot from-sochi. Разберите payload и положите в source_detail.

js
function startPayload(text) {
  const m = String(text || "").match(/^\/start(?:@\w+)?(?:\s+(.+))?$/);
  return m && m[1] ? m[1].trim().slice(0, 64) : "";
}

Заявка в CRM

Тот же ingest, что у сайта. Меняется source. Для Telegram-бота валидное значение — bot_tg. Строка telegram API отвергнет: 400 Invalid source. Это не мелочь: в раннем черновике этой статьи я как раз написал бы неверный source, и бот «работал», а CRM молчала.

source_detail — до 64 символов. Ключ бота не должен совпадать с ключом формы на /sochi/boty.

Если задан LEADS_HUB_SECRET, нужен HMAC тела в x-lead-signature.

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

async function saveLead({ chatId, need, contact, name = null }) {
  const body = JSON.stringify({
    source: "bot_tg",
    source_detail: "tg-bot:lead",
    name,
    phone: /^\+?\d[\d\s()-]+$/.test(contact) ? contact : null,
    tg: contact.startsWith("@") ? contact.slice(1) : String(chatId),
    message: "Нужно: " + (need || ""),
    meta: { tg_chat_id: String(chatId) },
  });
  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://night-sniper.ru/api/leads/ingest", {
    method: "POST",
    headers,
    body,
  });
  if (!res.ok) throw new Error("ingest " + res.status + " " + (await res.text()));
  return res.json();
}

Уведомление себе в рабочий чат — дополнительно. Мы шлём служебные сообщения через sendMessage с parse_mode: HTML и таймаутом 8 секунд. Для клиентского бота лучше простой текст: Markdown легко сломать символом _ в телефоне.

js
await tg("sendMessage", {
  chat_id: process.env.TG_ADMIN_CHAT_ID,
  text: "Лид из бота: " + String(chatId),
  disable_web_page_preview: true,
});

Пустой лид (нет телефона, tg, email, имени, message) ingest не примет. Не зовите API «для галочки».

Mini App: кнопка и проверка initData

Web App открывается кнопкой web_app: { url }. Это не бот-команда. Нужны HTTPS, валидный домен, проверка initData на сервере. Без проверки любой подделает user id.

Документация Telegram: initData — query string. Считаете HMAC-SHA-256.

  • secret_key = HMAC_SHA256(bot_token, key="WebAppData")
  • data_check_string — все поля кроме hash, отсортированные по ключу, формат key=value, разделитель перевод строки.
  • Сравниваете hex HMAC data_check_string с полем hash.
  • Дополнительно смотрите auth_date: протухший timestamp отбрасываете.
js
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyTelegramInitData(initData, botToken, maxAgeSec = 86400) {
  const params = new URLSearchParams(initData);
  const hash = params.get("hash") || "";
  params.delete("hash");
  const pairs = [...params.entries()].map(([k, v]) => k + "=" + v).sort();
  const dataCheckString = pairs.join("\n");
  const secretKey = createHmac("sha256", "WebAppData").update(botToken).digest();
  const digest = createHmac("sha256", secretKey).update(dataCheckString).digest("hex");
  const a = Buffer.from(digest, "hex");
  const b = Buffer.from(hash, "hex");
  if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
  const authDate = Number(params.get("auth_date") || 0);
  if (!authDate || Date.now() / 1000 - authDate > maxAgeSec) return null;
  const userRaw = params.get("user");
  return userRaw ? JSON.parse(userRaw) : {};
}

Есть отдельная схема для third-party с Ed25519 и bot_id — если отдаёте initData третьей стороне без токена. Для своего бэкенда достаточно HMAC выше.

Кнопка:

js
await tg("sendMessage", {
  chat_id: chatId,
  text: "Открыть каталог",
  reply_markup: {
    inline_keyboard: [[{ text: "Каталог", web_app: { url: "https://example.com/mini" } }]],
  },
});

Токен бота в бандле Next нельзя. Проверка только в Route Handler.

Группы и каналы

В группе бот не читает чужие сообщения, пока это не явно включено (privacy mode BotFather / право администратора). Для заявки личка проще группы.

Канал — бот как администратор может постить. Это уже контент-движок, не эта статья. Не смешивайте канал-постинг и бот заявок на одном токене без нужды: инцидент в одном контуре ляжет на второй.

parse_mode и прочие грабли текста

parse_mode: Markdown и номер +7-900-... ломают разметку из-за _. HTML чуть предсказуемее, но тоже требует экранирования <, &. Для клиентского бота я чаще шлю plain text.

Не логируйте полный текст с паспортами. update_id, chat_id, тип события — да.

Не обещайте оплату внутри Telegram без юрконтура — это касса и оферта, не «добавим кнопочку».

Бот в группе без права читать сообщения выглядит «мёртвым».

Webhook не снят — poll пустой.

Два poll — гонка.

Два мессенджера

MAX и Telegram в одном продукте:

  • Общий сценарий и общая CRM.
  • Два тонких адаптера: sendMax(userId) и tg("sendMessage", { chat_id }).
  • Не скармливайте MAX поле chat_id.
  • В ingest: bot_max и bot_tg, не одно поле messenger.

Подробности MAX: как сделать бота для MAX. Заказ с офисом в Сочи: боты Telegram и MAX.

js
export async function notifyUser(channel, id, text) {
  if (channel === "max") return sendMax(id, text);
  if (channel === "tg") return tg("sendMessage", { chat_id: id, text });
  throw new Error("unknown channel");
}

Хостинг

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

Для webhook процесс Next уже слушает localhost. Не открывайте Node в интернет на случайном порту.

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

Health на 127.0.0.1, как в статье про MAX.

Не делайте pm2 restart all. Только адресный рестарт своего процесса.

Python, если команда не на Node

Тот же poll. Не тащите гигантский фреймворк ради трёх хендлеров.

py
import os, time, json, urllib.request

TOKEN = os.environ["TG_BOT_TOKEN"]
API = "https://api.telegram.org/bot" + TOKEN + "/"
offset = 0

def tg(method, payload):
    req = urllib.request.Request(
        API + method,
        data=json.dumps(payload).encode(),
        headers={"Content-Type": "application/json"},
        method="POST",
    )
    with urllib.request.urlopen(req, timeout=50) as res:
        data = json.loads(res.read().decode())
    if not data.get("ok"):
        raise RuntimeError(method + " " + json.dumps(data))
    return data["result"]

while True:
    try:
        updates = tg("getUpdates", {"offset": offset, "timeout": 30, "allowed_updates": ["message", "callback_query"]})
        for u in updates:
            offset = u["update_id"] + 1
            print(u.get("message", {}).get("text") or u.get("callback_query"))
    except Exception as e:
        print("poll", e)
        time.sleep(3)

Сценарий и ingest всё равно лучше держать в одном языке с сайтом, если сайт на Next. Два рантайма — два деплоя.

Полный каркас Node на poll

js
const token = process.env.TG_BOT_TOKEN;
if (!token) throw new Error("TG_BOT_TOKEN required");

async function tg(method, payload = {}, timeoutMs = 15000) {
  const res = await fetch("https://api.telegram.org/bot" + token + "/" + method, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(payload),
    signal: AbortSignal.timeout(timeoutMs),
  });
  const data = await res.json();
  if (!data.ok) throw new Error(method + " " + JSON.stringify(data));
  return data.result;
}

const sessions = new Map();
let offset = 0;

async function handleUpdate(update) {
  if (update.callback_query) {
    const q = update.callback_query;
    await tg("answerCallbackQuery", { callback_query_id: q.id });
    const chatId = q.message.chat.id;
    sessions.set(String(chatId), { stage: "contact", need: String(q.data || "") });
    await tg("sendMessage", { chat_id: chatId, text: "Оставьте телефон или @username." });
    return;
  }
  const msg = update.message;
  if (!msg) return;
  const chatId = msg.chat.id;
  const text = String(msg.text || "").trim();
  if (text.startsWith("/start")) {
    await tg("sendMessage", {
      chat_id: chatId,
      text: "Что нужно?",
      reply_markup: {
        inline_keyboard: [
          [{ text: "Сайт", callback_data: "need:site" }],
          [{ text: "Бот", callback_data: "need:bot" }],
        ],
      },
    });
    return;
  }
  const s = sessions.get(String(chatId));
  if (s && s.stage === "contact" && text) {
    await saveLead({ chatId, need: s.need, contact: text });
    sessions.delete(String(chatId));
    await tg("sendMessage", { chat_id: chatId, text: "Заявку получил." });
  }
}

async function main() {
  await tg("deleteWebhook", { drop_pending_updates: false }).catch(() => {});
  const me = await tg("getMe");
  console.log(me.username, me.id);
  while (true) {
    try {
      const updates = await tg("getUpdates", { offset, timeout: 30, allowed_updates: ["message", "callback_query"] }, 50000);
      for (const u of updates) {
        offset = u.update_id + 1;
        await handleUpdate(u);
      }
    } catch (e) {
      console.error(e.message);
      await new Promise((r) => setTimeout(r, 3000));
    }
  }
}

main();

saveLead — блок из раздела про CRM.

Сочи как гео, не как спам

Если заказчик в Сочи — это адрес студии и страница /sochi/boty. В тексте бота не нужно десять раз повторять город. Человек уже в чате.

Национальный how-to (эта статья) закрывает «разработка telegram бота». Гео-страница закрывает тех, кто ищет подрядчика в городе. Это разные URL, не дубль.

editMessageText, часики, «печатает»

После callback часто нужно заменить текст сообщения с кнопками, а не слать третье.

js
await tg("editMessageText", {
  chat_id: chatId,
  message_id: q.message.message_id,
  text: "Выбрали: бот. Оставьте телефон.",
});

Если сообщение с фото — это уже editMessageCaption. Не ловите 400 «there is no text».

sendChatAction с action: "typing" уместен, если реально думаете дольше секунды (ingest, Mini App). На пустой задержке это цирк.

Лимиты Bot API, которые ломают рассылки

Telegram режет слишком частые sendMessage в один чат. Для бота заявок (один ответ на событие) вы не упрётесь. Для «уведомить 400 менеджеров» — очередь с паузой, не цикл for без sleep.

Ошибка 429 приходит с retry_after. Почтите его, не молотите.

js
async function tgRetry(method, payload) {
  for (let i = 0; i < 4; i++) {
    const res = await fetch("https://api.telegram.org/bot" + token + "/" + method, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(payload),
    });
    const data = await res.json();
    if (data.ok) return data.result;
    const wait = Number(data.parameters?.retry_after || 0);
    if (res.status === 429 && wait) {
      await new Promise((r) => setTimeout(r, (wait + 1) * 1000));
      continue;
    }
    throw new Error(method + " " + JSON.stringify(data));
  }
}

Не используйте этот ретрай на getUpdates без ума: на сетевой ошибке — пауза 3 секунды, как в каркасе выше.

getWebhookInfo как диагностика, не как декорация

Поля, которые реально смотрят, когда «бот молчит»:

js
const info = await tg("getWebhookInfo");
console.log({
  url: info.url,
  pending: info.pending_update_count,
  lastError: info.last_error_message,
  lastErrorAt: info.last_error_date,
  allowed: info.allowed_updates,
});

Пустой url и ненулевой pending при poll — очередь ещё не забрана, увеличьте timeout или проверьте, что deleteWebhook прошёл. Непустой url при poll — вы сами себе противоречите.

Идемпотентность заявки на стороне CRM

Даже с INSERT OR IGNORE по update_id человек может нажать кнопку дважды с разными update_id. Тогда спасёт отпечаток ingest: телефон + source_detail + день. Не отключайте его «чтобы все клики были видны» — получите пять карточек одного человека.

Антифлуд на chat_id (10 минут) — третий слой, не вместо CRM.

js
const lastLeadAt = new Map();

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

Когда заказывать, а не собирать ночью

Собрать echo — вечер. Собрать заявку, которая не теряется, плюс Mini App, плюс два мессенджера, плюс оплата — продукт. Опишите задачу в квизе или на странице ботов. Офис: Сочи, ул. Несербская, 6.

Вопросы

Почему poll молчит?
Часто не снят webhook: getUpdates не работает, пока висит setWebhook. deleteWebhook, потом poll. И не запускайте два poll на одном токене.
Какой source в CRM?
bot_tg. Строка telegram на ingest даст 400. source_detail — свой ключ, до 64 символов.
Куда девать заявку?
В CRM через HTTP ingest, не только себе в личку. Иначе лид теряется в истории чата.
Как проверить Mini App?
initData на сервере HMAC-SHA256 с ключом WebAppData. Без проверки любой подделает user id. Токен бота в бандл Next не класть.
Webhook: какой заголовок секрета?
X-Telegram-Bot-Api-Secret-Token. Несовпадение — 401, не 200. Ответ route — быстрый 2xx, тяжёлую работу в очередь.
Срок?
Простой бот с заявкой — от 3 дней после фиксации объёма. Mini App от 5.
Нужна ли группа?
Для заявки личка проще. В группе бот часто не читает сообщения из-за privacy mode.
Можно ли один бот на Telegram и MAX?
Один сценарий и одна CRM — да. Два коннектора. Код Telegram в MAX не вставляется.

Нужен Telegram-бот — опишите сценарий. Квиз

Ещё в блоге

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

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

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

NS
Night Sniper
Разработчик, отвечаю лично

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

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

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

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

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

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