Для AI-агентов: markdown этой страницы — /docs-content/recipes/crm-assistant-bot.md индекс документации — /llms.txt
Бот-помощник по сделкам и процессам
Сложность: продвинутый | Скоупы: imbot, crm, bizproc, user, vibe:ai | Стек: cURL / JavaScript
Сотрудник пишет боту в чат Битрикс24, бот собирает текущие данные портала — запущенные бизнес-процессы и сделки, изменённые за период, — и отвечает языковой моделью строго по этим данным. Модель не видит портал сама, поэтому точность ответа определяется тем, какие данные бот ей передал и как подписал время.
Что понадобится
- API-ключ Вайбкод со скоупами
imbot,crm,bizproc,userиvibe:ai. Скоупuserнужен дляGET /v1/users/:id, откуда скрипт берёт часовой пояс - Node.js 18 или новее
- Машина, которая остаётся в сети: бот получает сообщения постоянным опросом, требования к ней — в разделе Где работает бот
Во всех примерах $VIBE_URL — базовый адрес https://vibecode.bitrix24.tech, $VIBE_API_KEY — ваш API-ключ.
Как устроено решение
- Один раз регистрируем бота и запоминаем его идентификатор.
- Опрашиваем события бота и берём из них вопрос сотрудника и диалог для ответа.
- Считаем границу периода в поясе владельца ключа и выбираем сделки, изменённые за этот период.
- Получаем запущенные бизнес-процессы и подставляем к ним названия сделок.
- Передаём модели данные текстом, указав текущее время и пояс сотрудника.
- Отправляем ответ модели в тот же диалог.
Примеры шагов показывают отдельные вызовы. Готовый к запуску скрипт — в разделе «Полный код».
Шаг 1. Регистрация бота
POST /v1/bots регистрирует бота. Режим eventMode: "fetch" означает, что бот сам забирает события опросом и публичный адрес ему не нужен.
cURL
curl -s -X POST -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
"$VIBE_URL/v1/bots" \
-d '{
"code": "process_assistant",
"name": "Ассистент по процессам",
"type": "bot", "eventMode": "fetch",
"workPosition": "Отвечает на вопросы о сделках и процессах"
}'
JavaScript
const headers = { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' }
const res = await fetch(`${VIBE_URL}/v1/bots`, {
method: 'POST',
headers,
body: JSON.stringify({
code: 'process_assistant',
name: 'Ассистент по процессам',
type: 'bot', eventMode: 'fetch',
workPosition: 'Отвечает на вопросы о сделках и процессах',
}),
})
const { data } = await res.json()
const botId = data.botId
Показаны основные поля ответа, имя бота приходит в массиве data.users:
{
"success": true,
"data": {
"botId": 1587,
"bot": { "id": 1587, "code": "process_assistant", "type": "bot", "eventMode": "fetch" }
}
}
Идентификатор data.botId передаётся скрипту из раздела «Полный код» переменной окружения BOT_ID. Регистрация выполняется один раз. 409 — бот с таким code уже зарегистрирован. Если бот зарегистрирован через Вайбкод, его идентификатор приходит в data.botId того же ответа:
{
"success": false,
"error": { "code": "BOT_ALREADY_EXISTS", "message": "Bot with this code already exists" },
"data": { "botId": 1587, "code": "process_assistant", "name": "Ассистент по процессам" }
}
Бот привязан к ключу, которым зарегистрирован, и все дальнейшие вызовы идут тем же ключом. Идентификатор из ответа 409 проверьте вызовом GET /v1/bots/:botId: ответ 403 BOT_ACCESS_DENIED значит, что бот зарегистрирован другим ключом, — порядок действий в разделе Восстановление доступа к боту. Если data в ответе 409 нет, код занят ботом, заведённым в обход платформы, и перенос к нему неприменим — выберите другой code.
Шаг 2. Вопрос сотрудника
GET /v1/bots/:botId/events возвращает накопившиеся события. Вопрос сотрудника приходит событием ONIMBOTV2MESSAGEADD.
cURL
curl -s -H "X-Api-Key: $VIBE_API_KEY" "$VIBE_URL/v1/bots/1587/events"
JavaScript
const res = await fetch(`${VIBE_URL}/v1/bots/${botId}/events`, { headers })
const { data } = await res.json()
const messages = data.events.filter(e => e.type === 'ONIMBOTV2MESSAGEADD')
Показаны основные поля события:
{
"success": true,
"data": {
"events": [
{
"eventId": 441,
"type": "ONIMBOTV2MESSAGEADD",
"date": "2026-10-01T10:29:24+03:00",
"data": {
"message": {
"id": 42039,
"chatId": 5317,
"authorId": 1317,
"text": "Какие процессы сейчас запущены и что со сделками за последнюю неделю?",
"isSystem": false
},
"chat": { "id": 5317, "dialogId": "1317", "type": "private" },
"user": { "id": 1317, "name": "Иван Петров", "firstName": "Иван" }
}
}
],
"nextOffset": 442, "hasMore": false,
"storedOffset": 0, "persisted": true
}
}
Ответ отправляется в диалог data.chat.dialogId. Кто спросил, видно по data.user.id.
Запрос без параметра offset продолжает с позиции, которую платформа хранит сама, поэтому после перезапуска бот не получит уже обработанные сообщения повторно. Остальные типы событий — в справочнике Bot-события.
Шаг 3. Сделки за период
Граница периода в фильтре задаётся литералом без пояса, например 2026-09-24T00:00:00. Этот литерал читается в часовом поясе из профиля владельца ключа, а не в поясе портала и не в поясе того, кто задал вопрос. Поэтому сначала узнаём владельца ключа и его пояс.
Владельца ключа возвращает GET /v1/me в поле data.owner.userId, а пояс из его профиля — GET /v1/users/:id в поле timeZone. Оба вызова выполняются один раз при запуске, как в «Полном коде». Показаны основные поля ответа GET /v1/users/:id:
{
"success": true,
"data": { "id": 1317, "name": "Иван", "lastName": "Петров", "timeZone": "Asia/Tbilisi" }
}
Границу «семь дней назад» переводим в этот пояс и передаём в фильтр POST /v1/deals/search. Суффикс Z или -05:00 в значении фильтра ничего не меняет: платформа его отбрасывает, подробнее в разделе Часовой пояс в значении фильтра.
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, "updatedAt": { "$gte": "2026-09-24T00:00:00" } },
"select": ["id", "title", "stageId", "amount", "currency", "updatedAt"],
"sort": { "updatedAt": "desc" },
"limit": 30
}'
JavaScript
// localLiteral переводит дату в литерал ГГГГ-ММ-ДДTЧЧ:ММ:СС в нужном поясе, код — в «Полном коде»
const since = new Date(Date.now() - 7 * 24 * 3600 * 1000)
const res = await fetch(`${VIBE_URL}/v1/deals/search`, {
method: 'POST',
headers,
body: JSON.stringify({
filter: { categoryId: 0, updatedAt: { $gte: localLiteral(since, ownerTimeZone) } },
select: ['id', 'title', 'stageId', 'amount', 'currency', 'updatedAt'],
sort: { updatedAt: 'desc' },
limit: 30,
}),
})
const { data: deals, meta } = await res.json()
{
"success": true,
"data": [
{ "id": 8663, "title": "Заказ интернет-магазина №993", "stageId": "NEW", "amount": 400, "currency": "RUB", "updatedAt": "2026-09-30T10:34:34.000Z" }
],
"meta": { "hasMore": true, "durationMs": 399 }
}
Время в ответе приходит в UTC с суффиксом Z, хотя граница в фильтре была задана в поясе владельца ключа. Признак meta.hasMore говорит, что за период сделок больше, чем вернул запрос. Передайте это модели явной строкой, иначе она примет длину выборки за общее число сделок и назовёт его сотруднику.
Модели понятнее название стадии, чем код stageId. Названия стадий воронки по умолчанию отдаёт GET /v1/statuses с фильтром entityId=DEAL_STAGE, для воронки с идентификатором 9 — с фильтром entityId=DEAL_STAGE_9. Список из нескольких значений entityId в одном запросе не принимается, поэтому пример ограничен воронкой по умолчанию — categoryId: 0.
Шаг 4. Запущенные бизнес-процессы
GET /v1/workflows возвращает запущенные экземпляры бизнес-процессов, сначала недавно изменённые.
cURL
curl -s -H "X-Api-Key: $VIBE_API_KEY" "$VIBE_URL/v1/workflows?limit=50"
JavaScript
const res = await fetch(`${VIBE_URL}/v1/workflows?limit=50`, { headers })
const { data: workflows } = await res.json()
Показаны основные поля ответа:
{
"success": true,
"data": [
{
"id": "6a638a15c3eb98.67333533",
"modifiedAt": "2026-07-24T18:51:49+03:00",
"documentId": "DEAL_7885",
"startedAt": "2026-07-24T18:51:49+03:00",
"templateId": "709"
}
],
"meta": { "total": 5 }
}
Экземпляр процесса описан идентификаторами: документ DEAL_7885, шаблон 709. Модели эти коды ничего не говорят, поэтому документы-сделки превращаем в названия одним запросом POST /v1/deals/search с оператором $in по id — фрагмент ниже, а целиком функция runningWorkflows в «Полном коде».
const dealIds = workflows
.map(w => w.documentId.match(/^DEAL_(\d+)$/)?.[1])
.filter(Boolean)
.map(Number)
// тело запроса: { filter: { id: { $in: dealIds } }, select: ['id', 'title'], limit: 50 }
Время у процессов приходит со смещением, в примере выше +03:00. Перед передачей модели приводите его и время сделок к одному поясу, как это делает функция formatTime в «Полном коде».
Шаг 5. Ответ модели
Данные передаём модели текстом в сообщении пользователя, а правила — в системном сообщении: отвечать только по данным, признаваться, когда ответа нет, и не называть точное число записей, если показана часть. Текущее время и пояс указываем для того сотрудника, который спросил: пояс берётся из его профиля через GET /v1/users/:id по data.user.id из события.
POST /v1/ai/chat/completions вызывается тем же ключом. Доступные модели перечисляет GET /v1/ai/models.
cURL
curl -s -X POST -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
"$VIBE_URL/v1/ai/chat/completions" \
-d '{
"model": "bitrix/bitrixgpt-5.5", "temperature": 0.2, "max_tokens": 500,
"messages": [
{
"role": "system",
"content": "Ты помощник сотрудников в чате Битрикс24. Отвечай только по блоку «Данные портала». Если ответа там нет, так и скажи. Сейчас 2026-10-01T10:30:00, время в данных указано в поясе Asia/Tbilisi. Отвечай кратко."
},
{
"role": "user",
"content": "Данные портала:\nЗапущенные бизнес-процессы (1):\n- шаблон 709, сделка «Заказ интернет-магазина №875», запущен 24.07.2026, 19:51\n\nВопрос: Какие процессы сейчас запущены?"
}
]
}'
JavaScript
const res = await fetch(`${VIBE_URL}/v1/ai/chat/completions`, {
method: 'POST',
headers,
body: JSON.stringify({
model: 'bitrix/bitrixgpt-5.5', temperature: 0.2, max_tokens: 500,
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: `Данные портала:\n${context}\n\nВопрос: ${question}` },
],
}),
})
const completion = await res.json()
if (!res.ok) throw new Error(completion.error?.message)
const answer = completion.choices[0].message.content
{
"id": "chatcmpl-9249fb3d0e10562c",
"object": "chat.completion",
"model": "bitrix/bitrixgpt-5.5",
"choices": [
{
"index": 0,
"message": {
"content": "Запущен один процесс: шаблон 709, сделка «Заказ интернет-магазина №875» (запущен 24.07.2026, 19:51).",
"role": "assistant"
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 166,
"completion_tokens": 48,
"total_tokens": 214
}
}
Ответ приходит в формате, совместимом с OpenAI, без обёртки { success, data }. Текст лежит в choices[0].message.content. Поле usage.prompt_tokens растёт вместе с объёмом переданных данных.
Шаг 6. Ответ в чат
POST /v1/bots/:botId/messages отправляет текст модели в диалог из шага 2.
cURL
curl -s -X POST -H "X-Api-Key: $VIBE_API_KEY" -H "Content-Type: application/json" \
"$VIBE_URL/v1/bots/1587/messages" \
-d '{ "dialogId": "1317", "fields": { "message": "Запущен один процесс по сделке «Заказ интернет-магазина №875»." } }'
JavaScript
await fetch(`${VIBE_URL}/v1/bots/${botId}/messages`, {
method: 'POST',
headers,
body: JSON.stringify({ dialogId, fields: { message: answer } }),
})
{ "success": true, "data": { "id": 42041, "uuidMap": [], "failureMap": [] } }
Пока модель готовит ответ, «Полный код» показывает в чате индикатор «думает» вызовом POST /v1/bots/:botId/typing.
Ограничения
Права владельца ключа. Все запросы бота идут от имени владельца ключа. Бот отвечает любому сотруднику, который ему написал, данными, которые видит владелец ключа, включая сделки, недоступные самому сотруднику. Регистрируйте бота ключом сотрудника, который видит не больше, чем положено каждому, кто задаёт вопросы.
Состояние процесса. GET /v1/workflows сообщает, что процесс запущен, по какому документу и когда он менялся. Текущего шага, статуса задания и ответственного за шаг в ответе нет, и модель не сможет о них рассказать. Фильтра по дате у метода тоже нет, только templateId и startedBy: отбор «процессы за неделю» делается в коде по полю modifiedAt или startedAt.
Названия шаблонов. Название шаблона бизнес-процесса отдаёт GET /v1/bizproc-templates, но только ключу OAuth-приложения vibe_app_… с заголовком Authorization: Bearer. Личный ключ получает 403 OAUTH_REQUIRED. С личным ключом соответствие templateId и названия держите в настройках бота.
Пояс в профиле не указан. Если поле timeZone в профиле пустое, скрипт берёт пояс из переменной окружения FALLBACK_TIME_ZONE и без неё не запустится. Заполните поле в профиле владельца ключа, чтобы граница периода считалась в его поясе.
Объём данных. Скрипт передаёт модели не больше 30 сделок и 50 процессов. Чем выше предел, тем дороже каждый вопрос, а при большом числе записей данные не поместятся в контекст модели.
Очередь портала. Каждый вопрос сотрудника — это несколько запросов к порталу. При потоке вопросов запросы получают 429 QUEUE_OVERFLOW или QUEUE_TIMEOUT. «Полный код» повторяет такой запрос до пяти раз с паузой из заголовка Retry-After, подробнее о повторах — в Лимитах и оптимизации. Отказ модели 402 с кодом insufficient_balance, ai_quota_exhausted или company_budget_exhausted повтором не снимается: нужно пополнить баланс или поднять квоту, а «Полный код» сообщает об этом сотруднику. Полный перечень кодов — Ошибки.
Полный код
// assistant-bot.mjs — бот отвечает сотрудникам по сделкам и бизнес-процессам портала
const VIBE_URL = process.env.VIBE_URL ?? 'https://vibecode.bitrix24.tech'
const VIBE_API_KEY = process.env.VIBE_API_KEY
const BOT_ID = Number(process.env.BOT_ID)
if (!VIBE_API_KEY || !BOT_ID) throw new Error('Задайте переменные окружения VIBE_API_KEY и BOT_ID')
const MODEL = process.env.MODEL ?? 'bitrix/bitrixgpt-5.5'
const PERIOD_DAYS = 7, DEALS_LIMIT = 30
const headers = { 'X-Api-Key': VIBE_API_KEY, 'Content-Type': 'application/json' }
async function api(path, body, attempt = 0) {
const init = body ? { method: 'POST', headers, body: JSON.stringify(body) } : { headers }
const res = await fetch(`${VIBE_URL}${path}`, init)
if (res.status === 429 && attempt < 5) return new Promise(r => setTimeout(r, Number(res.headers.get('Retry-After') ?? 2) * 1000)).then(() => api(path, body, attempt + 1))
const json = await res.json().catch(() => null)
if (!json?.success) throw new Error(json?.error?.message ?? `запрос ${path} отклонён (${res.status})`)
return json
}
// Дата и время в указанном поясе в виде ГГГГ-ММ-ДДTЧЧ:ММ:СС, без суффикса пояса
const localLiteral = (date, timeZone) =>
date.toLocaleString('sv-SE', { timeZone, hour12: false }).replace(' ', 'T')
const formatTime = (iso, timeZone) => new Date(iso).toLocaleString('ru-RU', { timeZone, dateStyle: 'short', timeStyle: 'short' })
async function userTimeZone(userId) {
const { data } = await api(`/v1/users/${userId}`)
const timeZone = data.timeZone || process.env.FALLBACK_TIME_ZONE
if (!timeZone) throw new Error(`у пользователя ${userId} не указан часовой пояс, задайте FALLBACK_TIME_ZONE`)
return timeZone
}
// Фильтр по дате читается в поясе владельца ключа, поэтому граница периода считается в нём
const { data: me } = await api('/v1/me')
if (!me.owner?.userId) throw new Error('у ключа не определён владелец, часовой пояс фильтра неизвестен')
const OWNER_TIME_ZONE = await userTimeZone(me.owner.userId)
const { data: stageList } = await api('/v1/statuses?filter[entityId]=DEAL_STAGE')
const STAGES = Object.fromEntries(stageList.map(s => [s.statusId, s.name]))
async function recentDeals() {
const since = new Date(Date.now() - PERIOD_DAYS * 24 * 3600 * 1000)
const { data, meta } = await api('/v1/deals/search', {
filter: { categoryId: 0, updatedAt: { $gte: localLiteral(since, OWNER_TIME_ZONE) } },
select: ['id', 'title', 'stageId', 'amount', 'currency', 'updatedAt'],
sort: { updatedAt: 'desc' },
limit: DEALS_LIMIT,
})
return { deals: data, truncated: Boolean(meta?.hasMore) }
}
async function runningWorkflows() {
const { data: workflows, meta } = await api('/v1/workflows?limit=50')
const dealIds = workflows.map(w => w.documentId?.match(/^DEAL_(\d+)$/)?.[1]).filter(Boolean).map(Number)
const titles = {}
if (dealIds.length) {
const { data } = await api('/v1/deals/search', { filter: { id: { $in: dealIds } }, select: ['id', 'title'], limit: 50 })
for (const d of data) titles[`DEAL_${d.id}`] = d.title
}
return Object.assign(workflows.map(w => ({ ...w, documentTitle: titles[w.documentId] })), { total: meta?.total })
}
function describe({ deals, truncated }, workflows, tz) {
const lines = [`Запущенные бизнес-процессы (${workflows.total ?? workflows.length}):`]
for (const w of workflows) {
const doc = w.documentTitle ? `сделка «${w.documentTitle}»` : w.documentId
lines.push(`- шаблон ${w.templateId}, ${doc}, запущен ${formatTime(w.startedAt, tz)}, изменён ${formatTime(w.modifiedAt, tz)}`)
}
lines.push('', `Сделки основной воронки, изменённые за ${PERIOD_DAYS} дней:`)
for (const d of deals) {
lines.push(`- #${d.id} «${d.title}»: стадия «${STAGES[d.stageId] ?? d.stageId}», ${d.amount} ${d.currency}, изменена ${formatTime(d.updatedAt, tz)}`)
}
if (!deals.length) lines.push('- за период сделки не менялись')
if (truncated) lines.push(`Показаны ${deals.length} последних сделок, за период их больше. Точное число в данных не указано.`)
return lines.join('\n')
}
async function answer(question, context, tz) {
const res = await fetch(`${VIBE_URL}/v1/ai/chat/completions`, {
method: 'POST',
headers,
body: JSON.stringify({
model: MODEL,
temperature: 0.2,
max_tokens: 500,
messages: [
{
role: 'system',
content: 'Ты помощник сотрудников в чате Битрикс24. Отвечай только по блоку «Данные портала». ' +
'Если ответа там нет, так и скажи. Если в данных сказано, что показана часть записей, не называй их точное число. ' +
`Сейчас ${localLiteral(new Date(), tz)}, время в данных указано в поясе ${tz}. Отвечай кратко.`,
},
{ role: 'user', content: `Данные портала:\n${context}\n\nВопрос: ${question}` },
],
}),
})
const completion = await res.json().catch(() => null)
if (!res.ok) throw new Error(`${res.status} ${completion?.error?.message ?? 'модель не ответила'}`)
return completion.choices[0].message.content
}
const reply = (dialogId, message) => api(`/v1/bots/${BOT_ID}/messages`, { dialogId, fields: { message } })
async function handleMessage(data) {
if (data.message.isSystem || data.message.authorId === BOT_ID) return
const dialogId = data.chat.dialogId
await api(`/v1/bots/${BOT_ID}/typing`, { dialogId, statusMessageCode: 'IMBOT_AGENT_ACTION_THINKING' }).catch(() => {})
// Время в ответе показываем в поясе того, кто спросил
const tz = await userTimeZone(data.user.id)
const [deals, workflows] = await Promise.all([recentDeals(), runningWorkflows()])
await reply(dialogId, await answer(data.message.text, describe(deals, workflows, tz), tz))
}
console.log(`Бот ${BOT_ID} слушает события, граница периода в поясе ${OWNER_TIME_ZONE}`)
while (true) {
try {
const { data } = await api(`/v1/bots/${BOT_ID}/events`)
for (const event of data.events) {
if (event.type !== 'ONIMBOTV2MESSAGEADD') continue
await handleMessage(event.data).catch(async error => {
console.error('Сообщение не обработано:', error.message)
await reply(event.data.chat.dialogId, error.message.startsWith('402') ? 'Закончился баланс или AI-квота портала, сообщите администратору.' : 'Не получилось собрать ответ, попробуйте позже.').catch(() => {})
})
}
if (data.hasMore) continue
} catch (error) {
console.error('Ошибка опроса:', error.message)
}
await new Promise(resolve => setTimeout(resolve, 3000))
}
Запуск: BOT_ID=1587 VIBE_API_KEY=YOUR_API_KEY node assistant-bot.mjs.