Для 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. Стадии воронки
Идентификаторы стадий отличаются от портала к порталу, а названия администратор меняет свободно. Список стадий основной воронки отдаёт GET /v1/statuses с фильтром по DEAL_STAGE.
cURL
curl -s -H "X-Api-Key: $VIBE_API_KEY" \
"$VIBE_URL/v1/statuses?filter[entityId]=DEAL_STAGE"
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()
{
"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
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
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()
{
"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
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
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)
}
{
"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
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
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()
{
"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 здесь нет.
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. Повторяйте с нарастающей задержкой, а не мгновенно — так и сделано в «Полном коде».
{
"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) этого недостаточно: облако всё равно перезапускает машину примерно раз в сутки. Курсор в файле переживёт перезапуск, но за время сна стадии могут смениться несколько раз, и сервис увидит только последнюю.
Полный перечень кодов — Ошибки.
Полный код
// 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)