Для AI-агентов: markdown этой страницы — /docs-content/recipes/telegram-bot.md индекс документации — /llms.txt
Telegram-бот для CRM
Сложность: средний | Скоупы: crm | Стек: cURL / JavaScript (Node.js, grammy)
Бот следит за новыми сделками в воронке и присылает менеджеру карточку сделки в Telegram: название, сумму, имя контакта. Под сообщением стоят кнопки, которые переводят сделку на другую стадию — менеджер отвечает на заявку, не открывая портал.
Что понадобится
- API-ключ Вайбкод со скоупом
crm - Токен бота Telegram — выдаёт
@BotFather - Идентификатор чата, куда бот пишет
- Node.js 18 и новее, пакеты
grammyиnode-cron
Во всех примерах $VIBE_URL — базовый адрес https://vibecode.bitrix24.tech, $VIBE_API_KEY — ваш API-ключ.
Как устроено решение
- Запоминаем идентификатор самой свежей сделки — это стартовое положение курсора.
- Раз в две минуты запрашиваем сделки с идентификатором больше сохранённого.
- По каждой найденной сделке подтягиваем имя контакта и отправляем сообщение в Telegram.
- Кнопка под сообщением переводит сделку на выбранную стадию.
- Курсор храним в файле рядом со скриптом, чтобы перезапуск не пропустил сделки и не прислал их повторно.
Примеры шагов показывают отдельные вызовы. Готовый к запуску скрипт — в разделе «Полный код».
Шаг 1. Стартовое положение курсора
При первом запуске бот не должен присылать все сделки портала. POST /v1/deals/search с сортировкой по убыванию идентификатора и limit: 1 отдаёт последнюю созданную сделку — её идентификатор становится курсором.
cURL
curl -s -X POST -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
"$VIBE_URL/v1/deals/search" \
-d '{
"filter": { "categoryId": 0 },
"select": ["id"],
"sort": { "id": "desc" },
"limit": 1
}'
JavaScript
const res = await fetch(`${VIBE_URL}/v1/deals/search`, {
method: 'POST',
headers: { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({
filter: { categoryId: 0 },
select: ['id'],
sort: { id: 'desc' },
limit: 1,
}),
})
const { data } = await res.json()
const cursor = data[0]?.id ?? 0
{
"success": true,
"data": [{ "id": 7999 }],
"meta": { "total": 1218, "hasMore": true, "durationMs": 1614 }
}
Поле meta.total — количество сделок, попавших под фильтр, а не длина массива data. Для курсора нужен только data[0].id.
Шаг 2. Новые сделки
Условие "id": { "$gt": <курсор> } отбирает сделки, созданные после последнего опроса. Сортировка по возрастанию идентификатора нужна, чтобы сообщения уходили в порядке появления сделок, а курсор двигался вперёд.
cURL
curl -s -X POST -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
"$VIBE_URL/v1/deals/search" \
-d '{
"filter": { "id": { "$gt": 7995 }, "categoryId": 0 },
"select": ["id", "title", "amount", "currency", "contactId", "assignedById", "createdAt"],
"sort": { "id": "asc" },
"limit": 50
}'
JavaScript
const res = await fetch(`${VIBE_URL}/v1/deals/search`, {
method: 'POST',
headers: { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({
filter: { id: { $gt: cursor }, categoryId: 0 },
select: ['id', 'title', 'amount', 'currency', 'contactId', 'assignedById', 'createdAt'],
sort: { id: 'asc' },
limit: 50,
}),
})
const { data: deals } = await res.json()
{
"success": true,
"data": [
{
"id": 7999,
"title": "Заявка с сайта",
"amount": 0,
"currency": "RUB",
"contactId": 0,
"assignedById": 1,
"createdAt": "2026-07-21T08:55:32.000Z"
}
],
"meta": { "total": 1, "hasMore": false, "durationMs": 1080 }
}
Фильтр categoryId: 0 ограничивает выборку основной воронкой. Уберите его, если бот следит за всеми воронками сразу.
Шаг 3. Имя контакта
В сделке лежит только идентификатор контакта. Имя отдаёт GET /v1/contacts/:id.
cURL
curl -s -H "X-Api-Key: $VIBE_API_KEY" "$VIBE_URL/v1/contacts/67"
JavaScript
async function contactName(contactId) {
if (!contactId) return 'не указан'
const res = await fetch(`${VIBE_URL}/v1/contacts/${contactId}`, {
headers: { 'X-Api-Key': VIBE_API_KEY },
})
if (!res.ok) return 'не указан'
const { data } = await res.json()
return [data.name, data.lastName].filter(Boolean).join(' ') || `Контакт ${contactId}`
}
{
"success": true,
"data": { "id": 67, "name": "Мария", "lastName": "Соколова", "typeId": "CLIENT", "companyId": 7 }
}
Когда контакт к сделке не привязан, contactId приходит нулём, а не null. Проверка if (!contactId) покрывает оба значения. Поля lastName и secondName бывают пустыми — собирайте имя из непустых частей.
Шаг 4. Смена стадии из чата
Кнопка под сообщением вызывает PATCH /v1/deals/:id с новым stageId. Ответ содержит поле previousStageId — по нему видно, с какой стадии ушла сделка.
cURL
curl -s -X PATCH -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
"$VIBE_URL/v1/deals/7999" \
-d '{ "stageId": "EXECUTING" }'
JavaScript
async function moveDeal(dealId, stageId) {
const res = await fetch(`${VIBE_URL}/v1/deals/${dealId}`, {
method: 'PATCH',
headers: { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({ stageId }),
})
const { data } = await res.json()
return data.stageId
}
{
"success": true,
"data": { "id": 7999, "stageId": "EXECUTING", "previousStageId": "NEW", "movedTime": "2026-07-21T08:56:02.000Z" }
}
Названия стадий для подписей на кнопках берутся с портала — GET /v1/statuses с фильтром entityId=DEAL_STAGE. Администратор портала переименовывает стадии и добавляет новые, поэтому подписи в коде держать нельзя.
{
"success": true,
"data": [
{ "id": 1042, "statusId": "NEW", "name": "Новая", "sort": 10, "entityId": "DEAL_STAGE" },
{ "id": 1044, "statusId": "PREPARATION", "name": "Подготовка документов", "sort": 20, "entityId": "DEAL_STAGE" }
],
"meta": { "total": 2, "hasMore": false }
}
Из ответа нужны три поля: statusId уходит в stageId при перемещении сделки, name становится подписью кнопки, sort задаёт порядок кнопок. Идентификатор id — это запись справочника, в сделку он не передаётся.
Шаг 5. Курсор, переживающий перезапуск
Курсор в переменной процесса теряется при перезапуске: бот заново берёт стартовое значение из шага 1 и пропускает всё, что появилось, пока он не работал. Файл рядом со скриптом снимает это ограничение без внешних зависимостей. Это работа с файловой системой, а не вызов API, поэтому примера на cURL здесь нет.
import { readFileSync, writeFileSync, renameSync } from 'node:fs'
const CURSOR_FILE = './cursor.json'
function readCursor() {
try {
return JSON.parse(readFileSync(CURSOR_FILE, 'utf8')).lastDealId ?? 0
} catch {
return 0
}
}
function writeCursor(lastDealId) {
writeFileSync(`${CURSOR_FILE}.tmp`, JSON.stringify({ lastDealId }))
renameSync(`${CURSOR_FILE}.tmp`, CURSOR_FILE)
}
Запись идёт во временный файл с последующим переименованием. Переименование внутри одной файловой системы атомарно, поэтому остановка процесса посреди записи не оставит обрезанный файл, который сбросит курсор в ноль.
Курсор двигается только после того, как сообщение ушло в Telegram. Если отправка упала, сделка попадёт в следующий опрос повторно — это лучше, чем потерять заявку.
Ограничения
Опрос, а не события. Бот сам спрашивает портал, а не получает уведомления. Между созданием сделки и сообщением в чат проходит до двух минут — интервал задаётся расписанием node-cron.
Видны только новые сделки. Курсор идёт по идентификатору, а идентификатор у существующей сделки не меняется. Новая сумма, смена ответственного, переход по стадиям — всё это пройдёт мимо бота. Чтобы ловить изменения, нужен курсор по movedTime или updatedTime, как в рецепте Автоматизация задач.
Один запущенный экземпляр. Файл курсора принадлежит своему процессу. Две копии бота на разных машинах читают свои файлы и пришлют одну и ту же сделку дважды. Для нескольких экземпляров курсор выносится в общее хранилище, а опрос оставляется одному назначенному.
Стадии привязаны к воронке. В основной воронке стадия выглядит как NEW, в дополнительной — как C9:NEW с номером воронки в префиксе. Бот запрашивает список стадий той воронки, за которой следит, и подставляет в кнопки идентификаторы из этого списка.
Очередь портала. Портал выполняет ограниченное число запросов одновременно, остальные ждут в очереди. Предел задаётся настройками платформы, и на конкретное число полагаться нельзя: цикл, который шлёт запросы подряд, рано или поздно получит 429. QUEUE_OVERFLOW означает, что очередь переполнена и запрос отклонён сразу, QUEUE_TIMEOUT — что он прождал места дольше 30 секунд. В обоих случаях до Битрикс24 запрос не дошёл, поэтому повтор безопасен.
{
"success": false,
"error": {
"code": "QUEUE_OVERFLOW",
"message": "Portal queue overloaded — 128 Bitrix24 calls already pending",
"userMessage": "Слишком много одновременных запросов к Bitrix24 — повторите через несколько секунд.",
"hint": "Honor the Retry-After header. Use exponential backoff with jitter for repeated failures.",
"retryAfter": 3
}
}
Рекомендованная пауза приходит заголовком Retry-After и дублируется в error.retryAfter. Повторяйте, а не бросайте запрос, — так и сделано в «Полном коде». Разбор лимитов — Лимиты и оптимизация, полный перечень кодов — Ошибки.
Сон BLACKHOLE-сервера. Таймер простоя считает входящие обращения к серверу, а бот делает только исходящие вызовы — с точки зрения таймера сервер простаивает и засыпает вместе с ботом. Отключается через PATCH /v1/infra/servers/:id/sleep со значением sleepAfterMinutes: null. На вытесняемых тарифах (bc-agent, bc-micro) этого мало: облако всё равно перезапускает машину примерно раз в сутки. Для непрерывной работы берите невытесняемый тариф — курсор в файле переживёт и перезапуск, и пробуждение.
Секреты — в переменных окружения. Токен бота Telegram и API-ключ Вайбкод читаются из окружения. Файл курсора хранит только числовой идентификатор.
Полный код
// telegram-crm-bot.js — уведомления о новых сделках в Telegram
import { readFileSync, writeFileSync, renameSync } from 'node:fs'
import { Bot, InlineKeyboard } from 'grammy'
import cron from 'node-cron'
const VIBE_URL = process.env.VIBE_URL ?? 'https://vibecode.bitrix24.tech'
const VIBE_API_KEY = process.env.VIBE_API_KEY
if (!VIBE_API_KEY) throw new Error('Переменная окружения VIBE_API_KEY не задана')
const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN
const CHAT_ID = process.env.TELEGRAM_CHAT_ID
// Без токена и чата бот стартует, но падает на первом же сообщении в Telegram.
if (!BOT_TOKEN) throw new Error('Переменная окружения TELEGRAM_BOT_TOKEN не задана')
if (!CHAT_ID) throw new Error('Переменная окружения TELEGRAM_CHAT_ID не задана')
const CATEGORY_ID = 0
const CURSOR_FILE = './cursor.json'
const headers = { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' }
// Портал держит ограниченное число одновременных запросов и отвечает 429 при
// переполнении очереди. Без проверки тела деструктуризация даёт undefined,
// и скрипт падает на первом же обращении к полю вместо понятной ошибки.
// Любой отказ 429 означает, что запрос до Битрикс24 не дошёл, поэтому повтор
// безопасен. Рекомендованную паузу берём как есть из заголовка Retry-After,
// экспонента нужна только когда сервер паузу не назвал. Без повтора первый же
// QUEUE_OVERFLOW ронял бы тик планировщика.
const MAX_RETRIES = 5
async function readData(url, init = {}) {
for (let attempt = 0; ; attempt++) {
const res = await fetch(url, init)
const body = await res.json().catch(() => null)
if (res.status === 429 && attempt < MAX_RETRIES) {
const advised = res.headers.get('Retry-After') ?? body?.error?.retryAfter ?? null
// Потолок в 60 с: рекомендованную паузу мы уважаем, но неожиданно
// большое значение от промежуточного прокси не должно вешать цикл.
const base = advised === null ? Math.min(2 ** attempt, 30) : Number(advised)
const wait = Math.min(base, 60) + Math.random()
console.warn(`Очередь портала занята, повтор через ${Math.round(wait)} с`)
await new Promise(resolve => setTimeout(resolve, wait * 1000))
continue
}
if (!body?.success) throw new Error(body?.error?.message ?? `запрос отклонён (${res.status})`)
return body.data
}
}
const bot = new Bot(BOT_TOKEN)
function readCursor() {
try {
return JSON.parse(readFileSync(CURSOR_FILE, 'utf8')).lastDealId ?? 0
} catch {
return 0
}
}
function writeCursor(lastDealId) {
writeFileSync(`${CURSOR_FILE}.tmp`, JSON.stringify({ lastDealId }))
renameSync(`${CURSOR_FILE}.tmp`, CURSOR_FILE)
}
async function fetchStages() {
const entityId = CATEGORY_ID === 0 ? 'DEAL_STAGE' : `DEAL_STAGE_${CATEGORY_ID}`
const data = await readData(`${VIBE_URL}/v1/statuses?filter[entityId]=${entityId}`, { headers })
return data.sort((a, b) => a.sort - b.sort)
}
async function fetchLatestDealId() {
const data = await readData(`${VIBE_URL}/v1/deals/search`, {
method: 'POST',
headers,
body: JSON.stringify({
filter: { categoryId: CATEGORY_ID },
select: ['id'],
sort: { id: 'desc' },
limit: 1,
}),
})
return data[0]?.id ?? 0
}
async function fetchNewDeals(cursor) {
return readData(`${VIBE_URL}/v1/deals/search`, {
method: 'POST',
headers,
body: JSON.stringify({
filter: { id: { $gt: cursor }, categoryId: CATEGORY_ID },
select: ['id', 'title', 'amount', 'currency', 'contactId', 'assignedById', 'createdAt'],
sort: { id: 'asc' },
limit: 50,
}),
})
}
async function contactName(contactId) {
if (!contactId) return 'не указан'
// Ненайденный или недоступный контакт не должен ронять отправку карточки.
const data = await readData(`${VIBE_URL}/v1/contacts/${contactId}`, { headers })
.catch(() => null)
if (!data) return 'не указан'
return [data.name, data.lastName].filter(Boolean).join(' ') || `Контакт ${contactId}`
}
async function moveDeal(dealId, stageId) {
const data = await readData(`${VIBE_URL}/v1/deals/${dealId}`, {
method: 'PATCH',
headers,
body: JSON.stringify({ stageId }),
})
return data.stageId
}
function dealCard(deal, contact) {
return [
'<b>Новая сделка</b>',
'',
`Название: ${deal.title}`,
`Сумма: ${deal.amount} ${deal.currency}`,
`Контакт: ${contact}`,
`Создана: ${new Date(deal.createdAt).toLocaleString('ru-RU')}`,
`Идентификатор: ${deal.id}`,
].join('\n')
}
function stageKeyboard(dealId, stages) {
const keyboard = new InlineKeyboard()
for (const stage of stages.filter(s => s.statusId !== 'NEW').slice(0, 3)) {
keyboard.text(stage.name, `move:${dealId}:${stage.statusId}`)
}
return keyboard
}
async function poll(stages) {
let cursor = readCursor()
const deals = await fetchNewDeals(cursor)
for (const deal of deals) {
const contact = await contactName(deal.contactId)
await bot.api.sendMessage(CHAT_ID, dealCard(deal, contact), {
parse_mode: 'HTML',
reply_markup: stageKeyboard(deal.id, stages),
})
cursor = Math.max(cursor, deal.id)
writeCursor(cursor)
}
console.log(`[${new Date().toISOString()}] новых сделок: ${deals.length}`)
}
async function main() {
if (readCursor() === 0) writeCursor(await fetchLatestDealId())
const stages = await fetchStages()
const stageNames = new Map(stages.map(s => [s.statusId, s.name]))
bot.callbackQuery(/^move:(\d+):(.+)$/, async ctx => {
const [, dealId, stageId] = ctx.match
try {
const applied = await moveDeal(Number(dealId), stageId)
await ctx.answerCallbackQuery({ text: `Стадия: ${stageNames.get(applied) ?? applied}` })
await ctx.editMessageReplyMarkup()
} catch (error) {
// Без ответа на callback у менеджера навсегда останется индикатор нажатия.
await ctx.answerCallbackQuery({ text: `Не удалось: ${error.message}` })
}
})
bot.command('start', ctx => ctx.reply('Бот запущен. Новые сделки приходят в этот чат.'))
bot.command('status', ctx => ctx.reply(`Последняя обработанная сделка: ${readCursor()}`))
cron.schedule('*/2 * * * *', () => poll(stages).catch(console.error))
bot.start()
console.log('Бот запущен')
}
main().catch(console.error)