# Бот-помощник по сделкам и процессам

**Сложность:** продвинутый | **Скоупы:** imbot, crm, bizproc, user, vibe:ai | **Стек:** cURL / JavaScript

Сотрудник пишет боту в чат Битрикс24, бот собирает текущие данные портала — запущенные бизнес-процессы и сделки, изменённые за период, — и отвечает языковой моделью строго по этим данным. Модель не видит портал сама, поэтому точность ответа определяется тем, какие данные бот ей передал и как подписал время.

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

- API-ключ Вайбкод со скоупами `imbot`, `crm`, `bizproc`, `user` и `vibe:ai`. Скоуп `user` нужен для `GET /v1/users/:id`, откуда скрипт берёт часовой пояс
- Node.js 18 или новее
- Машина, которая остаётся в сети: бот получает сообщения постоянным опросом, требования к ней — в разделе [Где работает бот](/docs/bots#где-работает-бот)

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

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

1. Один раз регистрируем бота и запоминаем его идентификатор.
2. Опрашиваем события бота и берём из них вопрос сотрудника и диалог для ответа.
3. Считаем границу периода в поясе владельца ключа и выбираем сделки, изменённые за этот период.
4. Получаем запущенные бизнес-процессы и подставляем к ним названия сделок.
5. Передаём модели данные текстом, указав текущее время и пояс сотрудника.
6. Отправляем ответ модели в тот же диалог.

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

## Шаг 1. Регистрация бота

[`POST /v1/bots`](/docs/bots/management/create) регистрирует бота. Режим `eventMode: "fetch"` означает, что бот сам забирает события опросом и публичный адрес ему не нужен.

### cURL

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

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

```json
{
  "success": true,
  "data": {
    "botId": 1587,
    "bot": { "id": 1587, "code": "process_assistant", "type": "bot", "eventMode": "fetch" }
  }
}
```

Идентификатор `data.botId` передаётся скрипту из раздела «Полный код» переменной окружения `BOT_ID`. Регистрация выполняется один раз. 409 — бот с таким `code` уже зарегистрирован. Если бот зарегистрирован через Вайбкод, его идентификатор приходит в `data.botId` того же ответа:

```json
{
  "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` значит, что бот зарегистрирован другим ключом, — порядок действий в разделе [Восстановление доступа к боту](/docs/bots/ownership-recovery). Если `data` в ответе `409` нет, код занят ботом, заведённым в обход платформы, и перенос к нему неприменим — выберите другой `code`.

## Шаг 2. Вопрос сотрудника

[`GET /v1/bots/:botId/events`](/docs/bots/events/polling) возвращает накопившиеся события. Вопрос сотрудника приходит событием `ONIMBOTV2MESSAGEADD`.

### cURL

```bash
curl -s -H "X-Api-Key: $VIBE_API_KEY" "$VIBE_URL/v1/bots/1587/events"
```

### JavaScript

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

Показаны основные поля события:

```json
{
  "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-события](/docs/bots/events/bot-events).

## Шаг 3. Сделки за период

Граница периода в фильтре задаётся литералом без пояса, например `2026-09-24T00:00:00`. Этот литерал читается в часовом поясе из профиля владельца ключа, а не в поясе портала и не в поясе того, кто задал вопрос. Поэтому сначала узнаём владельца ключа и его пояс.

Владельца ключа возвращает [`GET /v1/me`](/docs/keys-auth) в поле `data.owner.userId`, а пояс из его профиля — [`GET /v1/users/:id`](/docs/entities/users) в поле `timeZone`. Оба вызова выполняются один раз при запуске, как в «Полном коде». Показаны основные поля ответа `GET /v1/users/:id`:

```json
{
  "success": true,
  "data": { "id": 1317, "name": "Иван", "lastName": "Петров", "timeZone": "Asia/Tbilisi" }
}
```

Границу «семь дней назад» переводим в этот пояс и передаём в фильтр [`POST /v1/deals/search`](/docs/entities/deals/search). Суффикс `Z` или `-05:00` в значении фильтра ничего не меняет: платформа его отбрасывает, подробнее в разделе [Часовой пояс в значении фильтра](/docs/filtering#часовой-пояс-в-значении-фильтра).

### 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, "updatedAt": { "$gte": "2026-09-24T00:00:00" } },
    "select": ["id", "title", "stageId", "amount", "currency", "updatedAt"],
    "sort": { "updatedAt": "desc" },
    "limit": 30
  }'
```

### JavaScript

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

```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`](/docs/entities/statuses) с фильтром `entityId=DEAL_STAGE`, для воронки с идентификатором `9` — с фильтром `entityId=DEAL_STAGE_9`. Список из нескольких значений `entityId` в одном запросе не принимается, поэтому пример ограничен воронкой по умолчанию — `categoryId: 0`.

## Шаг 4. Запущенные бизнес-процессы

[`GET /v1/workflows`](/docs/automation/workflows/list) возвращает запущенные экземпляры бизнес-процессов, сначала недавно изменённые.

### cURL

```bash
curl -s -H "X-Api-Key: $VIBE_API_KEY" "$VIBE_URL/v1/workflows?limit=50"
```

### JavaScript

```javascript
const res = await fetch(`${VIBE_URL}/v1/workflows?limit=50`, { headers })
const { data: workflows } = await res.json()
```

Показаны основные поля ответа:

```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` в «Полном коде».

```javascript
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`](/docs/ai/chat/completions) вызывается тем же ключом. Доступные модели перечисляет [`GET /v1/ai/models`](/docs/ai/models/list).

### cURL

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

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

```json
{
  "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`](/docs/bots/messages/send) отправляет текст модели в диалог из шага 2.

### cURL

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

```javascript
await fetch(`${VIBE_URL}/v1/bots/${botId}/messages`, {
  method: 'POST',
  headers,
  body: JSON.stringify({ dialogId, fields: { message: answer } }),
})
```

```json
{ "success": true, "data": { "id": 42041, "uuidMap": [], "failureMap": [] } }
```

Пока модель готовит ответ, «Полный код» показывает в чате индикатор «думает» вызовом [`POST /v1/bots/:botId/typing`](/docs/bots/ui/typing).

## Ограничения

**Права владельца ключа.** Все запросы бота идут от имени владельца ключа. Бот отвечает любому сотруднику, который ему написал, данными, которые видит владелец ключа, включая сделки, недоступные самому сотруднику. Регистрируйте бота ключом сотрудника, который видит не больше, чем положено каждому, кто задаёт вопросы.

**Состояние процесса.** `GET /v1/workflows` сообщает, что процесс запущен, по какому документу и когда он менялся. Текущего шага, статуса задания и ответственного за шаг в ответе нет, и модель не сможет о них рассказать. Фильтра по дате у метода тоже нет, только `templateId` и `startedBy`: отбор «процессы за неделю» делается в коде по полю `modifiedAt` или `startedAt`.

**Названия шаблонов.** Название шаблона бизнес-процесса отдаёт [`GET /v1/bizproc-templates`](/docs/entities/bizproc-templates), но только ключу OAuth-приложения `vibe_app_…` с заголовком `Authorization: Bearer`. Личный ключ получает `403 OAUTH_REQUIRED`. С личным ключом соответствие `templateId` и названия держите в настройках бота.

**Пояс в профиле не указан.** Если поле `timeZone` в профиле пустое, скрипт берёт пояс из переменной окружения `FALLBACK_TIME_ZONE` и без неё не запустится. Заполните поле в профиле владельца ключа, чтобы граница периода считалась в его поясе.

**Объём данных.** Скрипт передаёт модели не больше 30 сделок и 50 процессов. Чем выше предел, тем дороже каждый вопрос, а при большом числе записей данные не поместятся в контекст модели.

**Очередь портала.** Каждый вопрос сотрудника — это несколько запросов к порталу. При потоке вопросов запросы получают `429 QUEUE_OVERFLOW` или `QUEUE_TIMEOUT`. «Полный код» повторяет такой запрос до пяти раз с паузой из заголовка `Retry-After`, подробнее о повторах — в [Лимитах и оптимизации](/docs/optimization). Отказ модели `402` с кодом `insufficient_balance`, `ai_quota_exhausted` или `company_budget_exhausted` повтором не снимается: нужно пополнить баланс или поднять квоту, а «Полный код» сообщает об этом сотруднику. Полный перечень кодов — [Ошибки](/docs/errors).

## Полный код

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

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

- [Бот-платформа](/docs/bots)
- [Получить события (polling)](/docs/bots/events/polling)
- [Отправить сообщение](/docs/bots/messages/send)
- [Список запущенных бизнес-процессов](/docs/automation/workflows/list)
- [Поиск сделок](/docs/entities/deals/search)
- [Синтаксис фильтрации](/docs/filtering)
- [Создать чат-комплишен](/docs/ai/chat/completions)
- [AI-ассистент для сделки](/docs/recipes/ai-assistant)
