Обработчики диалога
Примитив Conversation задаёт поведение диалога: единый props по типу события, ответ пользователю, состояние, lifecycle и прерывание.
Обработчик диалога решает, что бот делает на каждое входящее событие. Сюда приходит всё в рамках одного диалога: сообщение пользователя, результат воркфлоу, интеграционное событие, сигнал бездействия. Один обработчик ведёт весь диалог целиком. Ветвление по типу события живёт внутри него.
Объявите обработчик через примитив Conversation и положите в
src/conversations/index.ts как default-export.
Файл диалога
Диалог — это new Conversation({ ... }) с default-export. Минимальный обработчик
запускает агентный цикл, и на этом всё. Агент сам
сформулирует и отправит ответ.
import { Conversation } from '@holocronlab/botruntime-runtime'
export default new Conversation({
channel: '*',
handler: async ({ execute }) => {
await execute({ instructions: 'Ты — помощник. Отвечай кратко и по-русски.' })
},
})В режиме чата execute уже предсвязан. Агент пишет и исполняет код, вызывает
инструменты и сам отвечает пользователю текстом. Отдельный
conversation.send(...) для обычного ответа не нужен.
Файл лежит рядом с остальными примитивами проекта:
Поля 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 из lifecycle | props.event |
'expire' | сессия диалога истекла | props.event |
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. Отправляйте сообщение вручную только
для детерминированного ответа мимо модели: подтверждение, кнопка, служебная
реплика.
// Обычный текст
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 нет.
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:
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:
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":
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. Каждый компонент
обязан иметь метаданные, иначе рантайм бросит ошибку при старте:
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) такой компонент прямо в ответе.