# 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-ключ.

## Как устроено решение

1. Запоминаем идентификатор самой свежей сделки — это стартовое положение курсора.
2. Раз в две минуты запрашиваем сделки с идентификатором больше сохранённого.
3. По каждой найденной сделке подтягиваем имя контакта и отправляем сообщение в Telegram.
4. Кнопка под сообщением переводит сделку на выбранную стадию.
5. Курсор храним в файле рядом со скриптом, чтобы перезапуск не пропустил сделки и не прислал их повторно.

Примеры шагов показывают отдельные вызовы. Готовый к запуску скрипт — в разделе «Полный код».

## Шаг 1. Стартовое положение курсора

При первом запуске бот не должен присылать все сделки портала. [`POST /v1/deals/search`](/docs/entities/deals/search) с сортировкой по убыванию идентификатора и `limit: 1` отдаёт последнюю созданную сделку — её идентификатор становится курсором.

### cURL

```bash
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

```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
```

```json
{
  "success": true,
  "data": [{ "id": 7999 }],
  "meta": { "total": 1218, "hasMore": true, "durationMs": 1614 }
}
```

Поле `meta.total` — количество сделок, попавших под фильтр, а не длина массива `data`. Для курсора нужен только `data[0].id`.

## Шаг 2. Новые сделки

Условие `"id": { "$gt": <курсор> }` отбирает сделки, созданные после последнего опроса. Сортировка по возрастанию идентификатора нужна, чтобы сообщения уходили в порядке появления сделок, а курсор двигался вперёд.

### cURL

```bash
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

```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()
```

```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`](/docs/entities/contacts/get).

### cURL

```bash
curl -s -H "X-Api-Key: $VIBE_API_KEY" "$VIBE_URL/v1/contacts/67"
```

### JavaScript

```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}`
}
```

```json
{
  "success": true,
  "data": { "id": 67, "name": "Мария", "lastName": "Соколова", "typeId": "CLIENT", "companyId": 7 }
}
```

Когда контакт к сделке не привязан, `contactId` приходит нулём, а не `null`. Проверка `if (!contactId)` покрывает оба значения. Поля `lastName` и `secondName` бывают пустыми — собирайте имя из непустых частей.

## Шаг 4. Смена стадии из чата

Кнопка под сообщением вызывает [`PATCH /v1/deals/:id`](/docs/entities/deals/update) с новым `stageId`. Ответ содержит поле `previousStageId` — по нему видно, с какой стадии ушла сделка.

### cURL

```bash
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

```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
}
```

```json
{
  "success": true,
  "data": { "id": 7999, "stageId": "EXECUTING", "previousStageId": "NEW", "movedTime": "2026-07-21T08:56:02.000Z" }
}
```

Названия стадий для подписей на кнопках берутся с портала — [`GET /v1/statuses`](/docs/entities/statuses) с фильтром `entityId=DEAL_STAGE`. Администратор портала переименовывает стадии и добавляет новые, поэтому подписи в коде держать нельзя.

```json
{
  "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 здесь нет.

```javascript
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`, как в рецепте [Автоматизация задач](/docs/recipes/task-automation).

**Один запущенный экземпляр.** Файл курсора принадлежит своему процессу. Две копии бота на разных машинах читают свои файлы и пришлют одну и ту же сделку дважды. Для нескольких экземпляров курсор выносится в общее хранилище, а опрос оставляется одному назначенному.

**Стадии привязаны к воронке.** В основной воронке стадия выглядит как `NEW`, в дополнительной — как `C9:NEW` с номером воронки в префиксе. Бот запрашивает список стадий той воронки, за которой следит, и подставляет в кнопки идентификаторы из этого списка.

**Очередь портала.** Портал выполняет ограниченное число запросов одновременно, остальные ждут в очереди. Предел задаётся настройками платформы, и на конкретное число полагаться нельзя: цикл, который шлёт запросы подряд, рано или поздно получит `429`. `QUEUE_OVERFLOW` означает, что очередь переполнена и запрос отклонён сразу, `QUEUE_TIMEOUT` — что он прождал места дольше 30 секунд. В обоих случаях до Битрикс24 запрос не дошёл, поэтому повтор безопасен.

```json
{
  "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`. Повторяйте, а не бросайте запрос, — так и сделано в «Полном коде». Разбор лимитов — [Лимиты и оптимизация](/docs/optimization), полный перечень кодов — [Ошибки](/docs/errors).

**Сон BLACKHOLE-сервера.** Таймер простоя считает **входящие** обращения к серверу, а бот делает только исходящие вызовы — с точки зрения таймера сервер простаивает и засыпает вместе с ботом. Отключается через [`PATCH /v1/infra/servers/:id/sleep`](/docs/infra/lifecycle/sleep) со значением `sleepAfterMinutes: null`. На вытесняемых тарифах (`bc-agent`, `bc-micro`) этого мало: облако всё равно перезапускает машину примерно раз в сутки. Для непрерывной работы берите невытесняемый тариф — курсор в файле переживёт и перезапуск, и пробуждение.

**Секреты — в переменных окружения.** Токен бота Telegram и API-ключ Вайбкод читаются из окружения. Файл курсора хранит только числовой идентификатор.

## Полный код

```javascript
// 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)
```

## Смотрите также

- [Поиск сделок](/docs/entities/deals/search)
- [Обновление сделки](/docs/entities/deals/update)
- [Получение контакта](/docs/entities/contacts/get)
- [Стадии и статусы](/docs/entities/statuses)
- [Синтаксис фильтрации](/docs/filtering)
- [Автоматизация задач](/docs/recipes/task-automation)
