botruntime
Разработка

Обработчики диалога

Примитив Conversation задаёт поведение диалога: единый props по типу события, ответ пользователю, состояние, lifecycle и прерывание.

Обработчик диалога решает, что бот делает на каждое входящее событие. Сюда приходит всё в рамках одного диалога: сообщение пользователя, результат воркфлоу, интеграционное событие, сигнал бездействия. Один обработчик ведёт весь диалог целиком. Ветвление по типу события живёт внутри него.

Объявите обработчик через примитив Conversation и положите в src/conversations/index.ts как default-export.

Файл диалога

Диалог — это new Conversation({ ... }) с default-export. Минимальный обработчик запускает агентный цикл, и на этом всё. Агент сам сформулирует и отправит ответ.

src/conversations/index.ts
import { Conversation } from '@holocronlab/botruntime-runtime'

export default new Conversation({
  channel: '*',
  handler: async ({ execute }) => {
    await execute({ instructions: 'Ты — помощник. Отвечай кратко и по-русски.' })
  },
})

В режиме чата execute уже предсвязан. Агент пишет и исполняет код, вызывает инструменты и сам отвечает пользователю текстом. Отдельный conversation.send(...) для обычного ответа не нужен.

Файл лежит рядом с остальными примитивами проекта:

index.ts
agent.config.ts

Поля Conversation

new Conversation({
  channel,            // где ловим: канал, массив каналов или '*'
  handler,            // async (props) => void — вся логика диалога
  state,              // zod-схема состояния диалога (авто-персист)
  events,             // какие не-message события слушать
  lifecycle,          // nudge-по-молчанию и истечение сессии
  shouldInterrupt,    // какие новые события прерывают текущий ход
  components,          // кастомные UI-компоненты для webchat
})

Обязательны только channel и handler. Остальное подключает дополнительные возможности из разделов ниже.

Каналы

channel задаёт, откуда обработчик получает события:

ЗначениеЧто ловит
'*'любой канал — интеграция маршрутизирует в бота каждое сообщение
'telegram.channel'один конкретный канал
['webchat.channel', 'slack.dm']несколько перечисленных каналов

'*' — простой выбор: одно поведение на все каналы. Для разной логики по каналам объявите несколько диалогов с конкретными channel.

Единый props по типу события

handler получает один объект props. Это дискриминированное объединение по ключу type. Сначала читайте props.type, потом берите нужное поле:

typeЧто пришлоГде данные
'message'сообщение пользователяprops.message
'event'интеграционное или своё событиеprops.event
'workflow_request'воркфлоу запросил данныеprops.request, props.event
'workflow_callback'воркфлоу завершилсяprops.completion
'workflow_notify'промежуточное уведомление воркфлоуprops.notification
'nudge'сработал nudge из lifecycleprops.event
'expire'сессия диалога истеклаprops.event
src/conversations/index.ts
import { Conversation } from '@holocronlab/botruntime-runtime'

export default new Conversation({
  channel: '*',
  handler: async (props) => {
    switch (props.type) {
      case 'message': {
        const text = props.message.payload.text ?? ''
        await props.execute({ instructions: `Ответь на: ${text}` })
        return
      }
      case 'workflow_callback': {
        // Величины посчитал КОД воркфлоу — модель их только формулирует.
        const out = props.completion.output
        await props.conversation.send({
          type: 'text',
          payload: { text: `Готово: ${out?.summary ?? 'без деталей'}` },
        })
        return
      }
      case 'event':
        // Реакция на интеграционное/своё событие — см. раздел ниже.
        return
    }
  },
})

По умолчанию в обработчик приходят только сообщения (type: 'message') и воркфлоу-ходы. Для интеграционных или своих событий перечислите их в events. См. «Не-message события».

Что всегда доступно

Независимо от type, в props всегда есть:

  • conversation — текущий диалог: conversation.id, conversation.channel, методы send, startTyping, stopTyping.
  • state — мутируемое состояние диалога (авто-персист, ниже).
  • client — низкоуровневый API-клиент платформы (сообщения, файлы, таблицы).
  • execute — запуск агентного цикла.
  • chat — доступ к транскрипту диалога.

Ответ пользователю

В агентном режиме отвечает сам execute. Отправляйте сообщение вручную только для детерминированного ответа мимо модели: подтверждение, кнопка, служебная реплика.

src/conversations/index.ts
// Обычный текст
await conversation.send({ type: 'text', payload: { text: 'Принято.' } })

// Кнопки выбора — для коротких вариантов
await conversation.send({
  type: 'choice',
  payload: {
    text: 'Как получать обновления?',
    options: [
      { label: 'В чате', value: 'chat' },
      { label: 'По email', value: 'email' },
    ],
  },
})

Typing-индикатором управляет рантайм. Он включает «печатает…» на входе в обработчик и снимает на выходе. Методы conversation.startTyping() и stopTyping() дают ручной контроль, но в обычном ходе не нужны.

Используйте кнопки для явных подтверждений и коротких выборов. Не превращайте диалог в длинное меню. Внутреннее состояние сценария не нужно показывать пользователю.

Состояние диалога

state — типизированное состояние, привязанное к диалогу. Объявите схему через state в конструкторе. Читайте и присваивайте поля напрямую — рантайм сам персистит изменения. Отдельного save нет.

src/conversations/index.ts
import { Conversation, z } from '@holocronlab/botruntime-runtime'

const dialogState = z.object({
  mode: z.enum(['auto', 'manual']).default('auto'),
  greeted: z.boolean().default(false),
})

export default new Conversation({
  channel: '*',
  state: dialogState,
  handler: async ({ state, conversation, execute }) => {
    if (!state.greeted) {
      await conversation.send({ type: 'text', payload: { text: 'Здравствуйте!' } })
      state.greeted = true          // помечается изменённым и персистится
      return
    }
    if (state.mode === 'manual') return   // диалог ведёт человек — бот молчит
    await execute({ instructions: 'Продолжай разговор.' })
  },
})

Персист срабатывает на присваивание поля верхнего уровня объекта state. Для вложенной структуры переприсвойте всё поле целиком (state.case = { ...state.case, status: 'paid' }), а не мутируйте вглубь. Подробнее о видах состояния — «Состояние».

Не-message события

По умолчанию диалог получает только сообщения. Чтобы реагировать на интеграционные или свои события, перечислите их имена в events. Тогда они придут в обработчик с type: 'event' и данными в props.event:

src/conversations/index.ts
export default new Conversation({
  channel: 'webchat.channel',
  events: ['webchat:conversationStarted', 'orderPlaced'],
  handler: async (props) => {
    if (props.type === 'event' && props.event.type === 'orderPlaced') {
      await props.conversation.send({
        type: 'text',
        payload: { text: 'Ваш заказ принят в работу.' },
      })
    }
  },
})

Имена интеграционных событий — в формате alias:eventName (webchat:conversationStarted). Свои события объявляются в agent.config.ts и пишутся без двоеточия.

Воркфлоу-ходы

Долгие процессы общаются с диалогом через события. Воркфлоу запросил данные (step.request) — обработчик переинвочится с type: 'workflow_request'. Сохраните props.event, соберите данные у пользователя следующими ходами и верните их воркфлоу через Wf.provide(event, data).

Воркфлоу завершился — приходит type: 'workflow_callback' с props.completion:

props.completion.status  // 'completed' | 'failed' | 'canceled' | 'timed_out'
props.completion.output  // результат (для 'completed')
props.completion.error   // текст ошибки (для остальных статусов)

Не молчите при status !== 'completed'. Сбой воркфлоу — видимое событие. Залогируйте его, эскалируйте оператору, дайте пользователю честную реплику. Тихая деградация недопустима.

Цены, сроки и другие точные значения считает код воркфлоу, а не модель. Обработчик диалога только формулирует итог.

Прерывание текущего хода

Пока обработчик выполняется, пользователь может прислать новое сообщение. По умолчанию любое новое событие прерывает текущий ход — так бот не отвечает на устаревший контекст. Сузьте это поведение предикатом shouldInterrupt:

src/conversations/index.ts
export default new Conversation({
  channel: '*',
  // Не прерываться на реакции — дать обработчику доработать ход
  shouldInterrupt: ({ kind, event }) =>
    !(kind === 'event' && event.type === 'telegram:reactionAdded'),
  handler: async ({ execute }) => {
    await execute({ instructions: 'Отвечай развёрнуто.' })
  },
})

Предикат синхронный: крутится в 50-мс цикле опроса. Он может только сужать прерывание, но не расширять. Внутренние события (workflow_notify, nudge, истечение сессии) не прерывают ход никогда. Это инвариант, предикатом не переопределяется.

Lifecycle: nudge и истечение

lifecycle включает подталкивание при молчании и истечение неактивной сессии. Длительности — строки вида "5m", "1h", "30s":

src/conversations/index.ts
export default new Conversation({
  channel: '*',
  lifecycle: {
    nudge: { after: '10m', interval: '30m', max: 2 },
    expire: { after: '24h' },
  },
  handler: async (props) => {
    if (props.type === 'nudge') {
      await props.conversation.send({
        type: 'text',
        payload: { text: 'Остались вопросы? Я на связи.' },
      })
      return
    }
    if (props.type === 'expire') return   // сессия закрывается, транскрипт очищается
    await props.execute({ instructions: 'Продолжай разговор.' })
  },
})
  • nudge.after — молчание до первого nudge. nudge.interval — интервал последующих (по умолчанию равен after). nudge.max — предел числа nudge.
  • expire.after — молчание до истечения сессии. По истечении рантайм отменяет активные воркфлоу и очищает транскрипт.

Кастомные компоненты (webchat)

Для богатого UI в webchat передайте компоненты в components. Каждый компонент обязан иметь метаданные, иначе рантайм бросит ошибку при старте:

src/conversations/index.ts
import { Conversation, CustomComponent, z } from '@holocronlab/botruntime-runtime'
import { TicketCard } from './components/ticket-card'

const ticketCard = new CustomComponent(TicketCard, {
  description: 'Карточка заявки со статусом',
  props: z.object({ ticketId: z.string(), title: z.string() }),
  exampleValues: [{ ticketId: 'TKT-001', title: 'Не открывается VPN' }],
})

export default new Conversation({
  channel: 'webchat.channel',
  components: [ticketCard],
  handler: async ({ execute }) => {
    await execute({ instructions: 'Покажи заявку карточкой, если она есть.' })
  },
})

Метаданные (description, props, exampleValues) описывают компонент для консоли и подсказывают модели, когда его отрисовать. На webchat агент может вернуть (yield) такой компонент прямо в ответе.

Дальше

On this page