Для AI-агентов: markdown этой страницы — /docs-content/recipes/task-automation.md индекс документации — /llms.txt

Автоматизация задач

Сложность: средний | Скоупы: crm, task | Стек: cURL / JavaScript (Node.js)

Сервис следит за переходами сделок по стадиям воронки и создаёт задачу, когда сделка попадает на настроенную стадию. Задача уходит ответственному за сделку, получает дедлайн и привязывается к самой сделке, поэтому видна из её карточки.

Что понадобится

  • API-ключ Вайбкод со скоупами crm и task
  • Node.js 18 и новее

Во всех примерах $VIBE_URL — базовый адрес https://vibecode.bitrix24.tech, $VIBE_API_KEY — ваш API-ключ.

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

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

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

Шаг 1. Стадии воронки

Идентификаторы стадий отличаются от портала к порталу, а названия администратор меняет свободно. Список стадий основной воронки отдаёт GET /v1/statuses с фильтром по DEAL_STAGE.

cURL

Terminal
curl -s -H "X-Api-Key: $VIBE_API_KEY" \
  "$VIBE_URL/v1/statuses?filter[entityId]=DEAL_STAGE"

JavaScript

javascript
const res = await fetch(`${VIBE_URL}/v1/statuses?filter[entityId]=DEAL_STAGE`, {
  headers: { 'X-Api-Key': VIBE_API_KEY },
})
const { data: stages } = await res.json()
JSON
{
  "success": true,
  "data": [
    { "statusId": "NEW", "name": "Новая", "sort": 10, "semantics": null },
    { "statusId": "EXECUTING", "name": "В работе", "sort": 40, "semantics": null },
    { "statusId": "WON", "name": "Сделка успешна", "sort": 60, "semantics": "S" }
  ]
}

Поле sort задаёт порядок стадий в воронке, semantics отмечает финальные стадии: S — успешная, F — провальная, null — промежуточная. Для дополнительных воронок идентификатор фильтра выглядит как DEAL_STAGE_{categoryId}, где categoryId — номер воронки.

Сверьте идентификаторы из ответа с ключами конфигурации STAGE_TASKS в полном коде. Стадия, которой на портале нет, задачу не создаст.

Шаг 2. Сделки, сменившие стадию

У сделки есть поле movedTime — момент последнего перехода на стадию, и previousStageId — стадия, с которой сделка ушла. POST /v1/deals/search отбирает по movedTime всё, что двигалось после прошлого опроса.

cURL

Terminal
curl -s -X POST -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
  "$VIBE_URL/v1/deals/search" \
  -d '{
    "filter": { "movedTime": { "$gte": "2026-07-21T08:00:00Z" }, "categoryId": 0 },
    "select": ["id", "title", "stageId", "previousStageId", "movedTime", "assignedById"],
    "sort": { "movedTime": "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: { movedTime: { $gte: cursor }, categoryId: 0 },
    select: ['id', 'title', 'stageId', 'previousStageId', 'movedTime', 'assignedById'],
    sort: { movedTime: 'asc' },
    limit: 50,
  }),
})
const { data: moved } = await res.json()
JSON
{
  "success": true,
  "data": [
    {
      "id": 7999,
      "title": "Заявка с сайта",
      "stageId": "EXECUTING",
      "previousStageId": "NEW",
      "movedTime": "2026-07-21T08:56:02.000Z",
      "assignedById": 1
    }
  ],
  "meta": { "total": 1, "hasMore": false, "durationMs": 812 }
}

Отбор по movedTime заменяет хранение стадий всех сделок в памяти: портал сам сообщает, что двигалось и откуда. У сделки, которая ни разу не меняла стадию, previousStageId приходит пустым — такая запись в обработку не идёт.

Шаг 3. Проверка на задвоение

Задача привязывается к сделке через поле ufCrmTask значением D_<идентификатор сделки>. По этому же полю POST /v1/tasks/search находит все задачи сделки — перед созданием остаётся сверить заголовки.

cURL

Terminal
curl -s -X POST -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
  "$VIBE_URL/v1/tasks/search" \
  -d '{
    "filter": { "ufCrmTask": "D_7999" },
    "select": ["id", "title"],
    "limit": 50
  }'

JavaScript

javascript
async function taskExists(dealId, title) {
  const res = await fetch(`${VIBE_URL}/v1/tasks/search`, {
    method: 'POST',
    headers: { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      filter: { ufCrmTask: `D_${dealId}` },
      select: ['id', 'title'],
      limit: 50,
    }),
  })
  const { data } = await res.json()
  return data.some(task => task.title === title)
}
JSON
{
  "success": true,
  "data": [{ "id": "3933", "title": "Подготовить документы — Заявка с сайта" }],
  "meta": { "total": 1, "hasMore": false, "durationMs": 717 }
}

Шаг 4. Создание задачи

POST /v1/tasks создаёт задачу. Обязателен title, остальные поля дополняют карточку: responsibleId — ответственный, deadline — срок, priority — важность (0 — низкая, 1 — обычная, 2 — высокая), ufCrmTask — привязка к сделке.

cURL

Terminal
curl -s -X POST -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
  "$VIBE_URL/v1/tasks" \
  -d '{
    "title": "Подготовить документы — Заявка с сайта",
    "description": "Сделка перешла в работу. Подготовьте комплект документов.",
    "responsibleId": 1,
    "priority": 2,
    "deadline": "2026-07-26T12:00:00Z",
    "ufCrmTask": ["D_7999"]
  }'

JavaScript

javascript
const deadline = new Date(Date.now() + 5 * 86400_000).toISOString()

const res = await fetch(`${VIBE_URL}/v1/tasks`, {
  method: 'POST',
  headers: { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    title: `Подготовить документы — ${deal.title}`,
    description: 'Сделка перешла в работу. Подготовьте комплект документов.',
    responsibleId: deal.assignedById,
    priority: 2,
    deadline,
    ufCrmTask: [`D_${deal.id}`],
  }),
})
const { data: task } = await res.json()
JSON
{
  "success": true,
  "data": {
    "id": "3933",
    "title": "Подготовить документы — Заявка с сайта",
    "responsibleId": "1",
    "priority": "2",
    "deadline": "2026-07-26T14:00:00+02:00",
    "ufCrmTask": ["D_7999"],
    "status": "2"
  }
}

Числовые поля задачи возвращаются строками: "id": "3933", "responsibleId": "1", "priority": "2". При сравнении приводите значение к числу либо сравнивайте со строкой.

Пункты чек-листа добавляются к уже созданной задаче отдельными вызовами POST /v1/tasks/:taskId/checklist — по одному на пункт.

Шаг 5. Курсор, переживающий перезапуск

Курсор в переменной процесса теряется при перезапуске. Сервис берёт текущий момент за точку отсчёта и пропускает все переходы, случившиеся, пока он не работал. Файл рядом со скриптом снимает это ограничение без внешних зависимостей. Это работа с файловой системой, а не вызов API, поэтому примера на cURL здесь нет.

javascript
import { readFileSync, writeFileSync, renameSync } from 'node:fs'

const CURSOR_FILE = './stage-cursor.json'

function readCursor() {
  try {
    return JSON.parse(readFileSync(CURSOR_FILE, 'utf8')).movedAfter ?? null
  } catch {
    return null
  }
}

function writeCursor(movedAfter) {
  writeFileSync(`${CURSOR_FILE}.tmp`, JSON.stringify({ movedAfter }))
  renameSync(`${CURSOR_FILE}.tmp`, CURSOR_FILE)
}

Запись идёт во временный файл с последующим переименованием. Переименование внутри одной файловой системы атомарно, поэтому остановка процесса посреди записи не оставит обрезанный файл.

Курсор сдвигается на movedTime последней обработанной сделки. Условие фильтра $gte включает границу, поэтому последняя сделка попадёт и в следующую выборку — от повторной задачи защищает проверка из шага 3.

Ограничения

Опрос, а не события. Сервис опрашивает портал, а не получает события. Между переходом сделки и созданием задачи проходит до минуты — интервал задаётся в коде.

Повторный переход стадии. Поле movedTime обновляется при любом переходе, включая возврат сделки на предыдущую стадию. Сделка, которую двигают туда-обратно, попадёт в выборку каждый раз, и задача создастся повторно, если заголовок отличается от уже существующего. Заголовок задачи собирается из фиксированного шаблона и названия сделки, чтобы проверка из шага 3 находила совпадение.

Проверка видит 50 задач. Проверка берёт до 50 задач сделки (limit: 50 в шаге 3). У сделки, где задач больше, совпадение может не попасть в выборку, и задача создастся повторно — увеличьте limit или сузьте выборку фильтром.

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

Стадии привязаны к воронке. Идентификаторы стадий действуют внутри своей воронки. В основной воронке стадия выглядит как NEW, в дополнительной — как C9:NEW с номером воронки в префиксе. Конфигурация STAGE_TASKS пишется под ту воронку, которую задаёт CATEGORY_ID.

Дедлайн в поясе портала. Дедлайн отправляется в формате ISO 8601, а возвращается в часовом поясе портала: 2026-07-26T12:00:00Z приходит обратно как 2026-07-26T14:00:00+02:00. Это один и тот же момент времени в разной записи.

Постановщик — владелец ключа. Задачи создаются от имени владельца API-ключа. В карточке задачи он будет постановщиком, а ответственным — сотрудник из поля assignedById сделки.

Очередь портала. Портал выполняет ограниченное число запросов к API одновременно, остальные ждут в очереди. Предел задаётся настройками платформы и на конкретное число полагаться нельзя. Цикл, который шлёт запросы подряд, рано или поздно получит 429. Код QUEUE_OVERFLOW означает, что очередь переполнена и запрос отклонён сразу, QUEUE_TIMEOUT — что запрос прождал места дольше 30 секунд и до Битрикс24 не дошёл, поэтому повторить его безопасно. Рекомендованная пауза приходит в заголовке Retry-After и дублируется в error.retryAfter. Повторяйте с нарастающей задержкой, а не мгновенно — так и сделано в «Полном коде».

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

Готовая функция повтора есть и в Лимитах и оптимизации.

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

Полный перечень кодов — Ошибки.

Полный код

javascript
// task-automation.js — задачи по переходам сделок между стадиями
import { readFileSync, writeFileSync, renameSync } from 'node:fs'

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 CATEGORY_ID = 0
const INTERVAL_MS = 60_000
const CURSOR_FILE = './stage-cursor.json'

const headers = { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' }

// Портал отвечает ошибкой в теле, а не только статусом: без проверки
// деструктуризация даёт undefined и скрипт падает на первом обращении к полю
// вместо понятного сообщения.
// Любой отказ 429 (QUEUE_OVERFLOW, QUEUE_TIMEOUT, RATE_LIMITED) означает,
// что запрос до Битрикс24 не дошёл, — повторить его безопасно. Сколько ждать, говорит заголовок
// Retry-After, а при его отсутствии поле error.retryAfter. К паузе добавляется
// случайная доля секунды, чтобы
// параллельные копии сервиса не пошли на повтор одновременно.
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) {
      // Рекомендованную паузу берём как есть: умножать её на номер попытки
      // нельзя — из Retry-After: 3 на пятой попытке вышло бы 48 секунд.
      // Экспонента нужна только когда сервер паузу не назвал.
      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 STAGE_TASKS = {
  EXECUTING: {
    title: 'Подготовить документы',
    description: 'Сделка перешла в работу. Подготовьте комплект документов и начните исполнение.',
    deadlineDays: 5,
    priority: 2,
  },
  PREPAYMENT_INVOICE: {
    title: 'Выставить счёт и проконтролировать оплату',
    description: 'Выставите счёт на предоплату и отследите поступление средств.',
    deadlineDays: 3,
    priority: 2,
  },
  FINAL_INVOICE: {
    title: 'Финальная сверка и закрытие',
    description: 'Подготовьте закрывающие документы по сделке.',
    deadlineDays: 7,
    priority: 1,
  },
}

function readCursor() {
  try {
    return JSON.parse(readFileSync(CURSOR_FILE, 'utf8')).movedAfter ?? null
  } catch {
    return null
  }
}

function writeCursor(movedAfter) {
  writeFileSync(`${CURSOR_FILE}.tmp`, JSON.stringify({ movedAfter }))
  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 new Map(data.map(stage => [stage.statusId, stage.name]))
}

async function fetchMovedDeals(cursor) {
  return readData(`${VIBE_URL}/v1/deals/search`, {
    method: 'POST',
    headers,
    body: JSON.stringify({
      filter: { movedTime: { $gte: cursor }, categoryId: CATEGORY_ID },
      select: ['id', 'title', 'stageId', 'previousStageId', 'movedTime', 'assignedById'],
      sort: { movedTime: 'asc' },
      limit: 50,
    }),
  })
}

async function taskExists(dealId, title) {
  const data = await readData(`${VIBE_URL}/v1/tasks/search`, {
    method: 'POST',
    headers,
    body: JSON.stringify({
      filter: { ufCrmTask: `D_${dealId}` },
      select: ['id', 'title'],
      limit: 50,
    }),
  })
  return data.some(task => task.title === title)
}

async function createTask(deal, config, title) {
  const deadline = new Date(Date.now() + config.deadlineDays * 86400_000).toISOString()
  const data = await readData(`${VIBE_URL}/v1/tasks`, {
    method: 'POST',
    headers,
    body: JSON.stringify({
      title,
      description: [config.description, '', `Сделка: ${deal.title} (${deal.id})`].join('\n'),
      responsibleId: deal.assignedById,
      priority: config.priority,
      deadline,
      ufCrmTask: [`D_${deal.id}`],
    }),
  })
  return data.id
}

async function tick(stageNames) {
  const cursor = readCursor()
  const deals = await fetchMovedDeals(cursor)

  for (const deal of deals) {
    if (!deal.previousStageId || deal.previousStageId === deal.stageId) continue

    const config = STAGE_TASKS[deal.stageId]
    if (!config) continue

    const title = `${config.title} — ${deal.title}`
    if (await taskExists(deal.id, title)) continue

    const taskId = await createTask(deal, config, title)
    const from = stageNames.get(deal.previousStageId) ?? deal.previousStageId
    const to = stageNames.get(deal.stageId) ?? deal.stageId
    console.log(`Сделка ${deal.id}: ${from} → ${to}, задача ${taskId}`)
  }

  const last = deals.at(-1)
  if (last) writeCursor(last.movedTime)
}

async function main() {
  if (!readCursor()) writeCursor(new Date().toISOString())

  const stageNames = await fetchStages()
  console.log(`Отслеживаемые стадии: ${Object.keys(STAGE_TASKS).join(', ')}`)

  await tick(stageNames)
  setInterval(() => tick(stageNames).catch(console.error), INTERVAL_MS)
}

main().catch(console.error)

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