09. Плагины

Расширение возможностей платформы с помощью JavaScript- и PHP-плагинов.

Плагины

Инструкция пользователя

Плагины Metabot — это JavaScript библиотеки, скрипты которых доступны для повторного использования в различных скриптах ботов.

Существует два вида плагинов:

Другими словами, вы можете создать свои собственные JavaScript библиотеки или использовать предопределенные, готовые библиотеки.

Список общих плагинов

Наименование Подключение Пример Дополнительно
moment  require() let moment = require('moment')

moment-with-locales

require() let moment = require('moment-with-locales')

Это OpenSource библиотека для работы с датами, https://momentjs.com/

Версия библиотеки moment.js: 2.29.3

Common.Bot.Commands

require() | snippet() require('Common.Bot.Commands') | snippet('Common.Bot.Commands')

Если для вашей ситуации доступно подключение через require – то это более предпочтительный с точки зрения производительности вариант, используйте именно его. Если подключение с помощью require() недоступно или не приемлемо, то используйте подключение через snippet().

На данный момент подключение собственных библиотек бизнеса доступно только через snippet().

Интерфейс и логика настройки плагинов

image.png

Интерфейс плагинов на платформе Metabot24 состоит из двух уровней:

image.png

image.png

Полное имя библиотеки «клеится» из трех составляющих:

При подключении библиотеки «составляющие» разделяются точкой, а при обращении к методам библиотеки из JS «составляющие клеятся» вместе, т.е. пишутся слитно, без точки.

Сниппеты

Перед тем как разобрать пример использования собственного плагина, необходимо понять принцип действий сниппетов. Работа сниппетов аналогична макросам, т.е. это просто текст, который подставляется вместо указанного макроса.

Доступны два варианта подключения сниппета:

snippet("your_snippet_name")
или
[[:your_snippet_name:]]
Оба варианта работают как макрос, т.е.указанный вариант обьявления сниппета в JavaScript коде будет заменен на код, который указан в скрипте плагина, к которому мы обращаемся.

В содержимом скрипта плагина можно указывать любой JavaScript код, главное чтобы ваш код, в котором выполняется макроподстановка сниппета был валиден после замены

Рассмотрим сначала простой пример указания значения переменной, вместо JavaScript. Вместо JavaScript кода сниппета можно указать любую комбинацию, например:

let text = snippet("mysnippet");
А в самом сниппете указать любое значение (число, текст, булево и т.п.), например, если в сниппете мы указали код:

"Hello World!"
То, запускаемый JS интерпретатором код примет следующий вид:

let text = "Hello World!"
Обратите внимание, что символ точка с запятой является частью определения макроса, и может указываться после snippet, а может не указываться. Если вам необходимо чтобы «конечный исходный код», после замены содержал точку с запятой, то укажите ее в самом JavaScript коде сниппета (скрипта плагина), т.е.:

"Hello World!";
Тогда, запускаемый JS интерпритатором код примет следующий вид:

let text = "Hello World!";

Сниппет идентичен макроподстановке. Макроподстановка выполняется до запуска скрипта и не является командой интерпретатора JavaScript, поэтому если вы закомментируете объявление сниппета, то он все равно будет подставлен в исходный код ! Чтобы закомментировать сниппет нужно закомментировать его и нарушить синтаксис его объявления, чтобы система не нашла макрос со сниппетом и не выполнила макроподстановку, например написать: s!nippet("your_snippet_name").

Пример №1: Общий плагин (скрипт общего плагина)

Полное имя библиотеки «клеится» из трех составляющих:

Подключается такая библиотеки с помощью кода:

require('Common.Bot.Commands');

После подключения, в JavaScript автоматически будет доступна переменная CommonBotCommands.

К методам CommonBotCommands можно обращаться, например для отправки сообщения в мессенджер из JavaScript:

CommonBotCommands.SendText('Текст отправленный из V8');

Полный список команд каждой библиотеки выходит за рамки данной документации и может постоянно дополняться.

В данном примере речь идет именно об общей библиотеке (Common), внутри которой уже описан специальный объект, который можно использовать с помощью методов, чтобы добиться такого же варианта использования вы должны самостоятельно описать свой объект на JavaScript. Оформление в виде JavaScript обьекта при создании вашего собственного плагана бизнеса не является обязательным, за счет того что ваши скрипты будут подключаться в виде сниппетов. Вы можете просто описать любые переменные и функции в вашей библиотеке и обращаться к ним по имени.

Для библиотек уровня бизнеса автоматически создаваемые переменные-объекты не доступны (или доступны только в тех библиотеках которые для вас создаст команда Metabot).

Отметим, что существуют другие библиотеки, например, такие как moment.js. Подключение и использование таких библиотек отличается от использования библиотек созданных на платформе. Например, для moment.js, не нужно указывать три «состявляющие» названия скрипта (уровень доступа, плагин и скрипт). Для moment.js мы явно объявляем переменную и «экспортируем в нее библиотеку»:

let moment = require('moment');

Подключение различных библиотек может отличаться и зависит от реализации самой библиотеки. Но, в основном как подключать и будет ли доступна автоматически переменная-объект понятно из названия библиотеки (если при подключении нет указания уровня доступа, то скорее всего это OpenSource библиотека).

Если вы подключаете к одному скрипту несколько сниппетов, то переменные обьявленые внутри каждого из сниппетов не должны пересекаться между собой по имени, а также переменные объявленные в сниппете и вашем скрипте, к которому вы подключаете сниппет не должны пересекаться.

Пример №2: Плагин бизнеса (скрипт вашего плагина)

Создадим новый плагин для формирования текста, который мы будет отправлять пользователю с помощью атрибутов бота.

image.png

Заполним все необходимые поля.

image.png

Далее, перейдем в скрипты плагина.

image.png

И создадим новый скрипт.

image.png

Заполним в открывшемся окне необходимые поля.

image.png

Укажем в исходном коде скрипта:

const greetMsg = 'Привет, ' + lead.getData('name') + '!';
memory.setAttr("greet", greetMsg);

Здесь мы сохраняем текст приветствия в memory (временном атрибуте бота).

Полное имя библиотеки «клеится» из трех составляющих:

Подключается с помощью кода:

snippet('Business.Notifications.HelloLead');
image.png
Для проверки работы плагина, в скрипт бота необходимо добавить две команды:
snippet('Business.Notifications.HelloLead');
{{ &$greet }}
image.png

Результат работы скрипта в телеграм:

image.png

Пример №3: Использование общего плагина в плагине бизнеса

Модифицируем пример 2 так, чтобы текст отправлялся не с помощью команды Отправить текст, а прям из JS кода плагина.

image.png

В исходном коде скрипта укажем:

require('Common.Bot.Commands');
const greetMsg = 'Привет, ' + lead.getData('name') + '!';
CommonBotCommands.sendText(greetMsg);

image.png

Удалим команду Отправить текст, команду Выполнить JavaScript оставляем без изменений.

image.png

Результат работы данного скрипта будет идентичным примеру 2:

image.png


Библиотека плагинов

Библиотека плагинов

Плагин для Mindbox

Название плагина Mindbox
Разработчик Официальные плагины от Metabot
Авторы

Петрова Ирина Дмитриевна (ira.petrova@metabot.org)

Гарашко Артем Юрьевич (artem@metabot.org)

Дата создания 22 Ноября 2022
Последняя дата обновления 26 Ноября 2022

Описание

Mindbox — это платформа автоматизации маркетинга и клиентских данных. Этот плагин к платформе Metabot позволяет интегрировать ваш чат-бот, разрабатываемый на Metabot, с платформой Mindbox. Подробнее с Mindbox можно ознакомиться на их официальном сайте: https://mindbox.ru.

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

Подключение

Для интеграции Mindbox с вашим чат-ботом вам необходимо сделать несколько вещей:

  1. На стороне Mindbox настройте все нужные точки интеграции и операции. Подробнее смотрите в документации Mindbox.
  2. Зарегистрируйтесь на платформе Metabot, подтвердите почту, авторизуйтесь и создайте чат-бот.
  3. Используйте готовый общий плагин или создайте плагин для своего бизнеса и скопируйте в него наш исходный код. Инструкции для обоих вариантов будут указаны ниже.
  4. Создайте в чат-боте системный атрибут mindbox.secretKey.<Системное имя точки интеграции> и сохраните в него секретный ключ (токен) авторизации API запросов к Mindbox. 
    • Если у вас планируется несколько точек интеграции в чат-боте, то нужно задать свой ключ для каждой из них. Например, так:
      • Mindbox.SecretKey.MyBusiness.Chatbot - атрибута с ключом для точки MyBusiness.Chatbot
      • Mindbox.SecretKey.MyBusiness.Chatbot.Shop - атрибута с ключом для точки MyBusiness.Chatbot.Shop
    • Внимание! Называйте атрибуты в точности и с учетом регистра именно так, как они указаны в Mindbox. 
  5. Реализуйте диалоговый сценарий (скрипт) с опросом данных пользователя (например, имя, фамилия, email адрес, телефон и прочее). 
  6. В конце диалога, обязательно (!) запросите согласие пользователя на обработку персональных данных и пришлите ссылку на положение о конфиденциальности компании. Если подписываете на маркетинговую рассылку, то дополнительно запросите согласие. Храните согласие в атрибуте лида вашего чат-бота на случай, если это понадобится юридической службе вашей компании. 

  7. Выберите универсальный или упрощенный метод для генерации запроса к Mindbox (смотрите детали ниже) и скопируйте код примера к себе в скрипт. 
  8. Кастомизируйте код интеграции под свои задачи. Если у вас возникнут затруднения, обратитесь за помощью в Телеграм-чат cообщества или поддержку Metabot.  

Вызов в диалоге

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

Способ 1. Универсальная (JSON) функция

Первый способ использования плагина в диалоге — с помощью универсальной функции callMindboxEndpoint(). Вы сами формируете тело запроса в формате JSON и передаете его в функцию.

Пример использования универсального (JSON) метода:

let email = lead.getAttr("email")
let phone = lead.getAttr("phone")
let lastName = lead.getAttr("lastName")
let firstName = lead.getAttr("firstName")
let endpointId = "<Идентификатор точки интеграции>"
let operation = "<Системное имя операции>"

let jsonBody = { "customer": {
    "email": email,
    "mobilePhone": phone,
    "lastName": lastName,
    "firstName": firstName,    
    "customFields": {
       "AdCommunicationAgreement": true,
       "PersonalDataAgreement": true
    },
    "subscriptions": [
      {
        "brand": "<Системное имя бренда подписки клиента>",
        "pointOfContact": "<Системное имя канала подписки: Email, SMS, Viber, Webpush, Mobilepush>",
        "topic": "<Внешний идентификатор тематики подписки>"
      }
    ]
  },
  "pointOfContact": "<Внешний идентификатор точки контакта>"
 }

// Подключаем сниппет кода из плагина Mindbox
snippet('Common.Mindbox.Operations')

// Вызываем точку интеграциии Mindbox и передаем нужный нам JSON
callMindboxEndpoint(endpointId, operation, jsonBody)

Функция требует передачи трех переменных в строгом порядке:

endpointId

Уникальный идентификатор интеграции.

Не забудьте, что каждому endpointId  соответствует свой secretKey, который нужно сохранить в системную атрибуту бота.

Интеграции настраивается в системе Mindbox.

operation Название операции в Mindbox. Каждому типу действия в Mindbox соответствует своя операция.
Список операций настраивается в системе Mindbox.
jsonBody Тело запроса в формате JSON.
Формат тела запроса, различается в зависимости от типа операции.

Способ 2. Альтернативная (параметризованная) функция

Второй способ использования плагина — с помощью альтернативной функции callMindboxEndpointAlt(), в которую вы передаете параметры запроса, а функция внутри себя формирует тело запроса в формате JSON и затем вызывает описанную выше универсальную функцию. 

Пример использования альтернативного (параметризованного) метода:

let email = lead.getAttr("email")
let phone = lead.getAttr("phone")
let lastName = lead.getAttr("lastName")
let firstName = lead.getAttr("firstName")
let endpointId = "<Идентификатор точки интеграции>"
let operation = "<Системное имя операции>"

// Подключаем сниппет кода из плагина Mindbox
snippet('Common.Mindbox.Operations')

// Вызываем точку интеграциии Mindbox и передаем нужные нам параметры
callMindboxEndpointAlt(endpointId, operation, 
                       email, phone, lastName, firstName, 
                       subscriptionTopic, pointOfContact)

Функция требует передачи нескольких переменных в строгом порядке. Добавляйте и удаляйте параметры по вкусу ;) но при этом не забудьте поменять в плагине код формирования JSON.

endpointId

Уникальный идентификатор интеграции.

Не забудьте, что каждому endpointId  соответствует свой secretKey, который нужно сохранить в системную атрибуту бота.

Интеграции настраивается в системе Mindbox.

operation Название операции в Mindbox. Каждому типу действия в Mindbox соответствует своя операция.
Список операций настраивается в системе Mindbox.
email Email пользователя
phone

Мобильный телефон

lastName

Фамилия

firstName

Имя

subscriptionTopic

Внешний идентификатор тематики подписки. Настраивается в Mindbox.

pointOfContact

Внешний идентификатор точки контакта. Настраивается в Mindbox.

Варианты подключения плагина

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

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

snippet('Common.Mindbox.Operations');

Либо, вы можете создать свой плагин в вашем бизнесе, скопировать наш и изменив код под себя, для этого:

  1. Cоздайте плагин в вашем бизнесе и назовите его Mindbox.
  2. Создайте в плагине скрипт и назовите его Operations.
  3. Скопируйте код, размещенный ниже, в код нового скрипта.

В этому случае, в примерах, указанных выше, замените вызов общего сниппета на ваш собственный:

snippet('Business.Mindbox.Operations');  

В обоих случаях, перед вызовом сниппета требуется объявить все необходимые переменные и передать им соответствующие значения.

При успешном запросе во вкладке "Клиенты" в Mindbox будет создан клиент с переданными из чат-бота данными о нем: 

image.png

Так же, во вкладке "Действия" вы сможете найти историю выполненной операции:

image.png

Обработка ошибок

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

Хранилище  Название атрибута Описание
lead plugin.mindbox.lastError.code

В случае ошибки, будет содержать код ошибки плагина. Смотрите таблицу ошибок ниже.
bot
lead plugin.mindbox.lastError.message

В случае ошибки, будет содержать сообщение об ошибке плагина.  Смотрите таблицу ошибок ниже.
bot
Список ошибок

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

Код ошибки Сообщение об ошибке Рекомендации
1

Ошибка вызова Mindbox API ({код ошибки}).

Плагин вернет код ошибки Mindbox API. Поскольку чат-бот не запоминает детали вызова API потому что вызовы асинхронны (async), при получении данной ошибки детали смотрите на стороне Mindbox. 


Все возможные коды ошибок Mindbox API cмотрите в документации к Mindbox: https://developers.mindbox.ru/docs/error_processing


Подробнее про вызов операции через Mindbox API, который применяется в этом плагине, смотрите по ссылке: https://developers.mindbox.ru/docs/v3

2

Не задан ключ интеграции {имя атрибута} в атрибутах бота.

Проверьте не ошиблись ли вы в регистре символов или названии.

3

Не заданы обязательные параметры параметризованного метода.

Проверьте не пытаетесь ли вы передать пустые данные в Mindbox в одном из полей (смотрите поля в атрибутах лида). А если параметр допустимо передавать пустым, удалите соответствующую валидацию в коде в callMindboxEndpointAlt(). 

Исходный код плагина

Cкопируйте указанный ниже код в скрипт Operations вашего плагина Mindbox и измените как вам требуется.

/**
 * Универсальная функция для регистрации операции в Mindbox через точку интеграции.
 * Используется явное указание тела запроса в формате JSON.
 * @param {string} endpointID - Идентификатор точки интеграции в Mindbox
 * @param {string} operation - Системное имя операции
 * @param {object} jsonBody - Тело запроса (берется из настроек операции в Mindbox)
 * @returns {bool} - Результат выполнения функции: успешно (true), проблемы (false).
 */
function callMindboxEndpoint(endpointId, operation, jsonBody) {
  // Считываем ключ авторизации из аттрибуты бота
  let secretKey = bot.getAttr('mindbox.secretKey.'+ endpointId)
  // Если ключ не настроен
  if (!secretKey) {
    outputError(endpointId, operation, '2', 'Не задан ключ интеграции plugin.Mindbox.secretKey в атрибутах чат-бота.')
    return false
  }
  
  // Задаем заголовок запроса
  api.setHeaders({'authorization':'Mindbox secretKey="' + secretKey + '"'})
 // Задаем URL запроса (используем асинхронный 'async' метод)
  let url = "https://api.mindbox.ru/v3/operations/async?endpointId=" + endpointId + "&operation=" + operation

  // Выполняем запрос с помощью метода POST и запоминаем результат
  let jsonResponse = api.postJson(url, jsonBody) 
  let jsonResponseCode = api.getLastResponseCode()

  // Возникла ошибка
  if (jsonResponseCode != 200) {
    outputError(endpointId, operation, '1', 'Проблема вызова Mindbox API. Детали смотрите в Mindbox.')
    return false // Выполнено с проблемами
  }
  
  return true // Все ок
}

/**
 * Альтернативная (параметризованная) функция для регистрации операции в Mindbox, который подготавливает тело запроса.
 * Адаптируйте эту функцию под свой проект или создайте копию.
 * @param {string} endpointID - Идентификатор точки интеграции в Mindbox
 * @param {string} operation - Системное имя операции
 * @param {string} email - Email
 * @param {string} phone - Мобильный телефон
 * @param {string} lastName - Фамилия
 * @param {string} firstName - Имя
 * @param {string} subscriptionTopic - Внешний идентификатор тематики подписки
 * @param {string} pointOfContact - Внешний идентификатор точки контакта
 * @returns {bool} - Результат выполнения функции: успешно (true), проблемы (false).
 */
function callMindboxEndpointAlt(endpointId, operation, email, phone, lastName, firstName, subscriptionTopic, pointOfContact) {
  // Если передали пустые значения
  if (isStrEmpty(endpointId) || 
      isStrEmpty(operation) || 
      isStrEmpty(email) || 
      isStrEmpty(phone) || 
      isStrEmpty(lastName) || 
      isStrEmpty(firstName) || 
      isStrEmpty(subscriptionTopic) || 
      isStrEmpty(pointOfContact))
  {
    outputError('3', 'Не заданы обязательные параметры параметризованного метода.')
    return false
  }
  
  // Формируем тело запроса в JSON (в вашем проекте запрос может отличаться)
  let jsonBody = { 
    "customer": {
      "email": email,
      "mobilePhone": phone,
      "lastName": lastName,
      "firstName": firstName,    
      "customFields": {
         "AdCommunicationAgreement": true, // Согласие на рассылку
         "PersonalDataAgreement": true     // Согласие на обработку персональных данных
      },
      "subscriptions": [
        {
          "brand": "<Системное имя бренда подписки клиента>",
          "pointOfContact": "<Системное имя канала подписки: Email, SMS, Viber, Webpush, Mobilepush>",
          "topic": subscriptionTopic
        }
      ]
    },
    "pointOfContact": pointOfContact
  }
  
  // Вызываем универсальную функцию и возвращем результат
  return callMindboxEndpoint(endpointId, operation, jsonBody)
}

/**
 * Вспомогательная функция, которая сохраняет сведения об ошибке в атрибутах лида и атрибутах бота.
 * @param {string} endpointId - Точка интеграции
 * @param {string} operation - Операция
 * @param {string} code - Код ошибки
 * @param {string} message - Сообщение об ошибке
 */
function outputError(endpointId, operation, code, message) {
  // Добавляем в конце сообщения доп. инфу.
  message = message + ' (точка=' + endpointId + ', операция=' + operation + ')'
  lead.setAttr("plugin.Mindbox.lastError.Code", code)
  lead.setAttr("plugin.Mindbox.lastError.Message", message)  
  bot.setAttr("plugin.Mindbox.lastError.Code", code)
  bot.setAttr("plugin.Mindbox.lastError.Message", message)   
}

/**
 * Вспомогательная функцию, которая проверяет является ли строка пустой.
 * @param {string} code - Код ошибки
 * @returns {bool} - если строка пустая (true), иначе (false).
 */
function isStrEmpty(str) {
  return (typeof str === 'string' && str.trim().length === 0) ? true : false
}


Библиотека плагинов

Диалоговое путешествие (Dialog Journey)

Название плагина Диалоговое путешествие (Dialog Journey)
Разработчик Официальные плагины от Metabot
Авторы

Гарашко Артем Юрьевич (artem@metabot.org)

Дата создания 04 Января 2023
Последняя дата обновления 06 Января 2023

Описание

Платформа Metabot — это платформа автоматизации коммуникаций. Плагин Dialog Journey (DJ) для платформы Metabot позволяет интегрировать в ваш чат-бот, разработанный на Metabot или на любой другой бот-платформе, поддерживающей вызовы и приемы API веб-хуков, возможность проектирования, отслеживания и визуализации клиентских путешествий, также называемых клиентскими путями (customer journeys).

Используя плагин и платформу Metabot, вы можете создавать диалоговые стратегии и путешествия клиентов, которые помогут вашим клиентам получать пользу, а вашей компании достигать поставленных целей, собирать аналитику о ходе продвижения клиентов по диалогам в чат-боте, отслеживать и визуализировать карты их путей.

Плагин позволит организовать развитие вашего чат-бота таким образом, чтобы все разрозненные диалоги объединялись в единую коммуникационную стратегию компании, представленную в виде путешествий (journeys), разбитых на фазы (phases) с целями (goals), предоставляемой пользой (values) и измеряемыми показателями (metrics). Более подробное описание концепции и устройства плагина, а также предлагаемой методологии маркетинговой стратегии смотрите ниже.

Пример

Пример работы чат-бота с интегрированным плагином можете посмотреть перейдя по ссылке.

Настройка 

Для интеграции плагина DJ с вашим чат-ботом вам необходимо сделать следующее:

  1. Создать кастомные таблицы и заполнить их, согласно схеме базы данных, опубликованной в разделе Справочники.
  2. Спроектировать путешествия для ваших клиентских сегментов и настроить справочники, описания назначения которых смотрите ниже.
    1. Классы персон.
    2. Подклассы персоны.
    3. Путешествия.
    4. Фазы путешествия.
    5. Польза.
    6. Цели.
    7. Показатели.
    8. Активности.
  3. Скачать готовый шаблон чат-бота, в который уже интегрирован плагин DJ, адаптировать его код и структуру под ваши задачи в своем чат-боте.
    1. Ссылка.
  4. Ознакомиться с примерами когда вызова методов DJ из вашего чат-бота в разделе JS команды.
  5. Ознакомиться с инструкций как пользоваться аналитикой и отчетами в разделе Аналитика и отчеты.   

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

  1. Чтобы ваш чат-бот мог передавать в базу данных Мetabot все нужные вам события для аналитики, например, сообщать, что пользователь достиг цели или перешел к следующей фазе путешествия.
  2. Чтобы ваш чат-бот мог запрашивать в базе данных Metabot информацию о состоянии путешествия пользователя, например, проверять достиг ли пользователь цели, были ли предоставлена польза, узнавать фазу путешествия на которой находится пользователь и так далее.

Инструкцию о том, как подключить Dialog Journeys к чат-боту (а может и не только к чат-боту), создаваемому на сторонней платформе, смотрите в разделе Интеграция со сторонними системами.

Методология 

Предисловие

Представленное решение — это уникальный синтез нашего собственного практического опыта и изысканий с идеями ведущих мировых теоретиков из сферы маркетинга, управления бизнесом и инноваций. 

Возможно модель подойдет к вашей бизнес-практике идеально «как есть». Мы постарались сделать модель простой и в то же время достаточно гибкой — принципы, лежащие в ее основе, универсальны и действенны.

Возможно вы не согласитесь с представленной моделью работы с клиентами и решите сделать по-другому, либо захотите расширить или откорректировать модель. Вы вправе это сделать. Если вам потребуется разработка уникального плагина под ваше видение, обращайтесь в наш отдел разработки и мы сделаем решение «под ключ».

Методология маркетинга, на которой строится работа данного плагина, стоит на нескольких китах. Во-первых, это решение для современного маркетинга для «экономики связей» или «экономики подключения» (Connection Economy). Подробнее об этой концепции смотрите здесь

Во-вторых, это решение строится на маркетинге доверия. Подробнее об этой концепции смотрите соответствующие обучающие материалы и экспертов. Например, здесь.

Справочники

Классы персон

Проектирование начинается с описания клиентских сегментов — мы их называем Классы персон. Подобно, RPG играм, опишите все классы, с которыми имеет дело ваш бизнес.

image.png

Подклассы персон

Создайте Подклассы персон, если вам нужно разбить клиентов на более узкие сегменты и строить с ними более персонализированные отношения.

image.png

Путешествия

Создайте все необходимые Путешествия, как общие для всех классов и подклассов, так и индивидуальные для каждого сегмента.

Если вы впервые сталкивайтесь с разработкой чат-ботов, мы рекомендуем не усложнять и начать автоматизацию с одного наиболее популярного и критичного путешествия, например, с вовлечения и онбординга нового пользователя. 

В любое случае, рекомендуем обдумать все путешествия заранее, хотя бы верхнеуровнево, которые "проживают" или будут "проживать" ваши клиенты, взаимодействия с вашей компанией.

Примеры путешествий:

image.png

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

Цели 

Следующим шагом, который на самом деле надо делать одновременно с предыдущим, мы рекомендуем заполнить Цели для путешествий. Мы же с вами занимаемся бизнесом и не хотим создавать бесцельные путешествия, верно? 

Пример целей для онбординга партнеров:

Пример целей для обучения стрельбе из луков:

image.png

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

Фазы путешествия

Разбейте каждое путешествие на Фазы. Мы сознательно используем слово «фазы», а не «этапы» или «шаги», потому что оно нам больше нравится — ведь переход от фазы к фазе т.е. так называемый «фазовый переход» подразумевает некую качественно новую форму, например, лед при нагревании превращается в воду, а при еще большем нагревании в пар. Мы же с вами хотим строить диалоги с нашей аудиторией так, чтобы каждый раз выходить на новый уровень взаимоотношений, верно?

Пример фаз для онбординга нового партнера:

Пример фаз для учебы стрельбе из лука:

Согласитесь, что каждая из фаз, приведенных в примерах выше, символизирует качественно новое состояние, дойдя до которого, наш пользователь, уже не сможет вернуться назад и забыть все то, что было до этого?

Наша цель при проектировании и разработке чат-ботов и авто-воронок заключается в том, чтобы выстраивать диалоговые коммуникации таким образом, чтобы они помогали пользователю двигаться от фазы к фазе, получая пользу, и тем самым доходить до завершения путешествия и помогая нам достигать бизнес-цели. Как именно строить такие коммуникации мы разберем ниже.

image.png

Плагин работает так, что запоминает один единственный раз, когда пользователь завершал одну фазу путешествия и начинал новую. Плагин запоминает дату и время фазовых переходов, чтобы по ним строить аналитику. Если пользователь пришел в сценарий в чат-боте, в котором в DJ сообщается о смене фазы, то плагин проигнорирует повторные команды. 

Польза 

Теперь самое интересное. Мы расписали наши путешествия, фазы и цели. Теперь давайте подумаем о том, как же мы будем влиять на наших пользователей, чтобы они достигали нужных нам целей? 

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

image.png

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

Аналогично целям, предоставление пользы фиксируется один единственный раз и повторные выдачи той же самой пользы не фиксируются в базе данных, поэтому вы можете не опасаться за то, что пользователь несколько раз обратился за одним и тем же контентом — это не собьет статистику. Предоставление пользы будет отслежено только самый первый раз. 

Показатели

Спланируйте Показатели (или метрики), которые вам необходимо измерять.

Пример из жизни: 

Сказочный пример:

image.png

Показатели, в отличие от Целей и Пользы, можно измерять сколько угодно раз и в любое время. То есть, если с течением времени показатель меняется, у вас будет вся история изменений.

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

Активности

Активности — это события, которые происходят в ходе путешествия или действия, которые совершает пользователя, во время путешествия.

image.png

Существует 7 основных событий, которые позволяют управлять ходом путешествия и отслеживать его хронологию:

ID Код Название Пояснение
1 journeyStarted Путешествие начато Используется в самом начале путешествия, когда стало понятно, что пользователь "отправился в путешествие".
2 phaseStarted     Фаза начата Используется при запуске следующей фазы, а также автоматически в начале путешествия.
3 phaseCompleted     Фаза завершена  Используется при запуске следующей фазы, завершая предыдущую, и при завершении всего путешествия.
4 phaseInterrupted     Фаза прервана Используется при прерывании фазы, которое происходит либо при прерывании всего путешествия, либо когда по каким-то причинам в нам нужно будет прервать фазу.
5 phaseSkipped     Фаза пропущена  Используется по каким-то причинам когда вам необходимо пропустить целую фазу.
6 journeyCancelled     Путешествие отменено   Используется когда путешествие было отменено, например, пользователь передумал.
7 journeyCompleted     Путешествие завершено Путешествие успешно завершено, пользователь дошел до победного конца.

В одном из будущих релизов планируется возможность добавлять пользовательские активности, чтобы вы могли отслеживать промежуточные шаги во время фазы, если вам это понадобится.

Журналы

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

Ни в коем случае не нарушайте целостность данных, если уже запустили трафик и идет сбор данных о путешествиях пользователей. Если у вас есть журналы с данными, а вы решите удалить или изменить данные в Справочниках, отдавайте себе отчет, что это может повлиять на исторические данные, ранее собранные чат-ботом, а значит и на отчеты.  

Журнал путешествий 

В этом журнале хранится история фаз и активностей, а также начала и завершения путешествий.

image.png

Журнал пользы

В этом журнале хранится история предоставления пользы пользователям.

image.png

Журнал целей

В этом журнале хранится история достижения целей.

image.png

Журнал показателей 

В этом журнале хранятся собранные метрики.

image.png

Состояние пользователя в путешествии

В этой таблице хранится состояние пользователя в конкретном путешествии. 

image.png

Ни в коем случае не трогайте эти данные на продакшене, кроме случаев отладки, иначе нарушите ход путешествия для пользователей.

JS команды

Для отправления информации в плагин и фиксации данных в базе необходимо в нужном вам месте в чат-боте подключить плагин, инициализировать менеджер путешествий и выполнить нужную команды.

Список команд и примеры кода представлены в таблице ниже.

Название метода Пример кода
1

Начать путешествие

// Подключаем плагин
snippet("Common.DialogJourney.Manager")

// Создаем менеджер путешествия и передаем ему лид
let jm = new JourneyManager(lead)

// Начинаем новое
let isNewJourneyStarted = jm.startNewJourney("myJourney")

// Пишем результат в память
memory.setAttr("isNewJourneyStarted", isNewJourneyStarted)

При инициализации JourneyManager для лида будет создана персона, если она еще не была создана! В текущей версии плагин позволяет отслеживать пути персон и не возможно отслеживание путей лидов. Поэтому персона создается для каждого лида на самой ранней стадии пути, чтобы иметь возможность отслеживать путь как можно раньше - даже когда лид/персона еще не взаимодействовали с бизнесом.

В качестве роли персоны будет использована роль по умолчанию, которую необходимо задать в настройках чат-бота.

2

Завершить путешествие

// Подключаем плагин
snippet("Common.DialogJourney.Manager")

// Создаем менеджер путешествия и передаем ему лид
let jm = new JourneyManager(lead)

// Выбираем путешествие
jm.selectJourney("myJourney")

// Завершаем выбранно путешествие
let isJourneyCompleted = jm.completeJourney()

// Пишем результат в память
memory.setAttr("isJourneyCompleted", isJourneyCompleted)
3 Следующая фаза
// Подключаем плагин
snippet("Common.DialogJourney.Manager")

// Создаем менеджер путешествия и передаем ему лид
let jm = new JourneyManager(lead)

// Выбираем путешествие
jm.selectJourney("myJourney")

// Запоминаем название текущей фазы
let oldPhaseName = jm.getCurrentPhase().name

// Стартуем новыую фазу путешествия
let isPhaseStarted = jm.startNextPhase("nextPhaseCode")

// Запоминаем название новой фазы (если она установилась, конечно)
let newPhaseName = jm.getCurrentPhase().name

// Пишем результат в память
memory.setAttr("isPhaseStarted", isPhaseStarted)
memory.setAttr("oldPhaseName", oldPhaseName)
memory.setAttr("newPhaseName", newPhaseName)
4 Предоставить пользу
// Подключаем плагин
snippet("Common.DialogJourney.Manager")

// Создаем менеджер путешествия и передаем ему лид
let jm = new JourneyManager(lead)

// Выбираем путешествие
jm.selectJourney("myJourney")

// Сообщаем менеджеру путешествия, что мы предоставили пользу
let isValueGiven = jm.giveValue("valueCode")

// Загружаем информацию о метрике из справочника
let valueInfo = jm.getValueInfo("valueCode")

// Загружаем информацию о собранной метрике
let value = jm.getValue("valueCode")

// Выводим результат работы
memory.setAttr("isValueGiven", isValueGiven)
memory.setAttr("valueName", valueInfo.name)
memory.setAttr("valueWeight", value.weight)
5 Достигнуть цель
// Подключаем плагин
snippet("Common.DialogJourney.Manager")

// Создаем менеджер путешествия и передаем ему лид
let jm = new JourneyManager(lead)

// Выбираем путешествие
jm.selectJourney("myJourney")

// Сообщаем менеджеру путешествия, что мы достигли цели
let isGoalAchieved = jm.achieveGoal("goalCode")

// Загружаем информацию о цели
let goal = jm.getGoalInfo(goalCode)

// Выводим результат работы
memory.setAttr("isGoalAchieved", isGoalAchieved)
memory.setAttr("goalName", goal.name)
6 Записать показатель
// Подключаем плагин
snippet("Common.DialogJourney.Manager")

// Создаем менеджер путешествия и передаем ему лид
let jm = new JourneyManager(lead)

// Выбираем путешествие
jm.selectJourney("myJourney")

// Считываем значение метрики
let metricValue = lead.getAttr("metricValue")

// Сохраняем метрику
let isMetricSaved = jm.saveJourneyMetric("metricCode", metricValue)

// Загружаем информацию о метрике из справочника
let metricInfo = jm.getMetricInfo("metricCode")

// Загружаем информацию о собранной метрике
let metric = jm.getLatestJourneyMetric("metricCode")

// Выводим результат работы
memory.setAttr("isMetricSaved", isMetricSaved)
memory.setAttr("metricName", metricInfo.name)
memory.setAttr("metricValue", metric.value)

Другие примеры кода с использованием плагина смотрите в шаблоне чат-бота, доступного для скачивания здесь.

Интеграция со сторонними системами

Вы можете использовать DJ плагин для сбора данных о путешествии пользователей не только в чат-боте, созданном на платформе Metabot. Вы можете вести разработку на других бот-платформах и использовать Metabot для сбора и визуализации аналитики.

Также, вы можете отправлять в Metabot информацию о событиях из других систем, чтобы фиксировать прохождение пользователя по пути, когда пользователь совершает нужное целевое действие на сайте (например, размещает товар в корзину, но не оплачивает). 

Для интеграции сторонних систем с вашим Dialog Journey в чат-боте на Metabot, необходимо настроить точки интеграции для всех необходимых событий, которые вам нужно регистрировать в Metabot. Информацию о точках интеграции смотрите в разделе Точки интеграции и конструктор API. В коде точки интеграции разработайте код согласно примерам выше.

Библиотека плагинов

Business.Helpers.Response

Плагин Business.Helpers.Response служит утилитой для стандартизации ответов API в бизнес-приложениях. Его основная цель - упростить создание последовательных и структурированных ответов для различных сценариев, возникающих во время взаимодействия с API.

/** 
 * Prepares and returns an API response object for a failed operation.
 * This should be used when an operation does not complete successfully,
 * with the provided error message included in the response.
 * 
 * @param {string} errorMessage - The error message to be included in the response.
 * @returns {Object} An object representing a failed operation response.
 */
function getErrorResponse(errorMessage) {
  return {
    success: false,
    message: errorMessage
  };  
}

/** 
 * Prepares and returns an API response object for a successful operation.
 * This should be used when an operation completes successfully 
 * and no additional data needs to be returned.
 * 
 * @returns {Object} An object representing a successful operation response.
 */
function getSuccessResponse() {
  return {
    success: true  
  };
}

/** 
 * Prepares and returns an API response object for a successful operation 
 * with additional JSON data. This should be used when an operation completes 
 * successfully and there is additional data to return in the response.
 * 
 * @param {Object} json - The JSON data to be included in the response.
 * @returns {Object} An object representing a successful operation response with additional data.
 */
function getSuccessResponseWithJson(json) {
  return {
      ...json,
      success: true
  }
}
Библиотека плагинов

Интеграция с Google Sheets

Первым делом необходимо создать новую таблицу в Google Sheets и добавить нового редактора api@metabot.org.

image.png

Далее копируем ID таблицы из адресной строки.

image.png

Копируем название листа.

image.png

Копируем название столбцов в таблице.

image.png

Метод для добавления нового столбца

Записываем в нужное место скрипта следующий код:

var GoogleSheetsService = require('Common.Integrations.GoogleSheets') // Плагин для работы с Google Sheets

GoogleSheetsService.sheetId = '11muAnepqhpRQ9ElE9CzC3E-edmf9JbRE3gwmBTDa5pE' // ID скопированный из таблицы
GoogleSheetsService.listName = 'list' // Название листа

// Параметры где ключ - название столбца, значение - данные которые занесутся в строку
let params = {
	"region": "Москвская область",
	"name": 'Тест',
	"age": "24",
	"city": "Москва",
}

let result = GoogleSheetsService.addRow(params) // Функция для добавления строк в таблицу

debug(result) // Вернётся результат выполенния с Id в строки в которую записались данные

Пример ответа:

{
    "status": "success",
    "message": "Row added successfully", // Сообщение, если есть ошибка - вернётся описание ошибки
    "rowId": 8 // Id в строки в которую записались данные
}

Метод для поиска и замены значения в ячейке

Записываем в нужное место скрипта следующий код:

var GoogleSheetsService = require('Common.Integrations.GoogleSheets') // Плагин для работы с Google Sheets

GoogleSheetsService.sheetId = '11muAnepqhpRQ9ElE9CzC3E-edmf9JbRE3gwmBTDa5pE' // ID скопированный из таблицы
GoogleSheetsService.listName = 'list' // Название листа

// Параметры со настройками для замены
let params = {
    colomn_search_name: 'region',
    colomn_edit_name: 'region',
    search_value: '123123',
    match_entire_cell: true,
    new_value: "Антон"
}

let result = GoogleSheetsService.searchAndEditRow(params) // Функция для поиска и замены строк

debug(result) // Вернётся результат выполенния или код ошибки

Пример ответа:

{
    "status": "success",
    "message": 'Значение найдёно и измененно'
}
Библиотека плагинов

Документация Telegram-плагина

Название плагина Telegram
Разработчик Официальные плагины от Metabot
Авторы

Борисов Павел (https://t.me/mr_result)

Дата создания 02 Октября 2023
Последняя дата обновления 25 Апреля 2024

Описание

Этот плагин предоставляет удобный интерфейс для работы с Telegram Bot API. Он поддерживает отправку текстовых сообщений, фотографий, создание клавиатур и обработку ответов пользователя.

Основной класс TelegramMessage

Подключение и инициализация

let TelegramMessage = require('Common.Integrations.Telegram')
let msg = new TelegramMessage()

Основные параметры

msg.text = "Ваше сообщение" // Текст сообщения
msg.parse_mode = "HTML" // Режим форматирования (HTML или MarkdownV2)
msg.protect_content = true // Защита контента от пересылки
msg.keyboard = "Да[yes]==Нет[no]" // Создание клавиатуры

Форматы клавиатуры

msg.keyboard = "Кнопка1[btn1]==Кнопка2[btn2]" // Кнопки будут расположены вертикально
msg.keyboard = "Кнопка1[btn1]||Кнопка2[btn2]" // Кнопки будут расположены горизонтально
msg.keyboard = "Кнопка1[btn1]||Кнопка2[btn2]==Кнопка3[btn3]||Кнопка4[btn4]"

Специальные типы кнопок

msg.keyboard = "Отправить контакт[telegram_contact]"
msg.keyboard = "Отправить локацию[telegram_location]"
msg.keyboard = "Посетить сайт[<https://example.com>]"
msg.keyboard = "Открыть приложение{<https://webapp-url.com>}"
msg.keyboard = "Написать админу[tg://user?id=123456789]"

Примеры использования

Простое меню с обработкой ответов

Код будет работать корректно только в команде JS Callback.

let TelegramMessage = require('Common.Integrations.Telegram')
let msg = new TelegramMessage()
msg.text = "Выберите действие:"
msg.keyboard = "Информация[info]==Помощь[help]==Настройки[settings]"
msg.parse_mode = "HTML"

switch (true) {
    case (isFirstImmediateCall):
        msg.send()
        return false

    case (msg.getMessagePayload()?.callback_data == "info"):
        msg.addReplyToText()
        bot.sendMessage("Информация о боте...")
        return false

    case (msg.getMessagePayload()?.callback_data == "help"):
        msg.addReplyToText()
        bot.sendMessage("Справка по использованию...")
        return false

    case (msg.getMessagePayload()?.callback_data == "settings"):
        msg.addReplyToText()
        bot.runScriptForLead(123, leadId) // Запуск скрипта настроек
        return false
}

Справочник методов

Основные методы отправки

Получение информации

Обработка ответов пользователя

// Проверка типа ввода
if (msg.getMessagePayload()?.input_type == "write") {
    // Пользователь написал текст
}

// Проверка нажатия кнопки
if (msg.getMessagePayload()?.callback_data == "button_id") {
    // Пользователь нажал кнопку
}

Отладка

msg.debug("Отладочное сообщение") // Отправка отладочной информации. 
								  //Не отправляется пользователю но логируется в карточке лида

Лучшие практики

if (isFirstImmediateCall) {
    msg.send()
    return false
}
if (msg.getMessagePayload()?.input_type == "write") {
    bot.sendMessage('Пожалуйста, используйте кнопки')
    return false
}
msg.addReplyToText() // После нажатия на кнопку записывает её в сообщение

Форматирование текста и entities

В плагине доступно два способа форматирования текста: HTML и MarkdownV2. По умолчанию параметр parse_mode не установлен — это сделано специально для возможности работы с entities (когда нужно сохранить форматирование текста, которое пользователь отправил в Telegram).

HTML форматирование

Документация: https://core.telegram.org/bots/api#html-style

msg.text = "<b>Жирный текст</b>\n<i>Курсив</i>\n<code>Моноширинный текст</code>"
msg.parse_mode = "HTML"

MarkdownV2 форматирование

Документация: https://core.telegram.org/bots/api#markdownv2-style

msg.text = "**Жирный текст**\n__Курсив__\n`Моноширинный текст`"
msg.parse_mode = "MarkdownV2"

Работа с entities

Entities используются, когда нужно сохранить форматирование текста, которое пользователь отправил в Telegram. Это особенно полезно при создании различных конструкторов сообщений или при пересылке сообщений с сохранением форматирования.

Пример сохранения форматированного сообщения пользователя:

let TelegramMessage = require('Common.Integrations.Telegram')
let msg = new TelegramMessage()

msg.text = `Введите текст для рассылки`
msg.keyboard = `↩️ Назад[${backScripts}]`
msg.parse_mode = 'HTML'

switch (true) {
    case (isFirstImmediateCall):
        msg.send()
        return false

    case (msg.getMessagePayload()?.input_type == "write"):
        msg.removeInlineKeyboard()
        // Получаем webhook и извлекаем текст с entities
        let webhook = msg.getMessagePayload()
        let messageData = {
            "text": webhook?.payload?.message?.text,
            "entities": webhook?.payload?.message?.entities // Сохраняем entities для сохранения форматирования
        }

        // Сохраняем сообщение с форматированием в базу данных
        lead.setJsonAttr("messageData", messageData)

        bot.run({
            script_id: nextScript
        })
        return false

    default:
        msg.addReplyToText()
        bot.run({
            script_id: msg.getMessagePayload().callback_data
        })
        return true
}

В этом примере:

  1. Когда пользователь отправляет форматированное сообщение, мы получаем не только сам текст, но и массив entities.
  2. Entities содержат информацию о форматировании: жирный текст, курсив, ссылки и т.д.
  3. Мы сохраняем и текст, и entities, чтобы позже можно было воспроизвести сообщение с точно таким же форматированием.
  4. Важно: для работы с entities параметр parse_mode должен быть не установлен (режим по умолчанию).

Теперь после запоминания entities мы можем сделать его вывод таким образом:

let TelegramMessage = require('Common.Integrations.Telegram')
let msg = new TelegramMessage()

let messageData = lead.getJsonAttr("messageData")

msg.text = messageData?.text
msg.entities = messageData?.entities

// Если попробовать использовать parse_mode то форматирование будет некорректным

Работа с файлами

Плагин позволяет обрабатывать файлы, которые пользователи отправляют в Telegram. Вы можете получить URL файла и сохранить его в карточку лида, не отправляя обратно в Telegram.

Получение и обработка файлов

// Подключаем библиотеку
let TelegramMessage = require('Common.Integrations.Telegram')
let msg = new TelegramMessage()

// Все входящие файлы
let attachments = bot.getAllAttachments()

if(Boolean(attachments?.[0]?.url)){

uploadData = bot.downloadFileFromUrl(attachments[0].url)

msg.debug('Пользователь отправил файл: ' + uploadData.url)
msg.debug('Файл ID : ' + JSON.stringify(attachments[0]?.payload?.file_id)) // данная строка вспомогательная для
																		   // автоматизации вывода полученных 
lead.setAttr('file', uploadData.url)									   // файлов в других местах бота
// Запуск скрипта...
bot.runScriptByCodeForLead("recieveFile", lead.getData('id'))

bot.stop()
}

В этом примере:

  1. Проверяем наличие прикрепленных файлов с помощью bot.getAllAttachments().
  2. Если файл есть, скачиваем его через bot.downloadFileFromUrl().
  3. Логируем получение файла с помощью msg.debug().
  4. Сохраняем URL файла в атрибут лида.
  5. Запускаем скрипт обработки файла.
  6. Останавливаем текущий скрипт.

Дополнительный пример:

let TelegramMessage = require('Common.Integrations.Telegram')

let msg = new TelegramMessage()

msg.text = 'А теперь жду твоё фото 😉'
msg.keyboard = ``
msg.parse_mode = 'HTML'

let scriptCode = bot.getCurrentScriptCode()
lead.setAttr("script_code", scriptCode)

let data = bot.getAllAttachments()

switch (true) {

    case (isFirstImmediateCall):

        msg.send()
        return false
        break

    case (Boolean(data?.[0]?.type != 'image')):

        bot.sendMessage('Отправь сжатое изображение. Такой формат не подходит')
        return false
        break

    case (Boolean(data?.[0]?.type == 'image')):

        uploadData = bot.downloadFileFromUrl(data[0].url)
        msg.debug('Пользователь отправил файл: ' + uploadData.url)
        lead.setAttr('photo', uploadData.url)
        bot.sendMessage('Класс! Фото загрузил')
        return true
        break

    default:

        msg.addReplyToText()
        return true
}

Рекомендации по работе с файлами

Библиотека плагинов

Плагин для работы с CustomStorage

Плагин CustomStorage

Плагин CustomStorage предназначен для работы с кастомными таблицами атрибутов в JavaScript скриптах. Плагин позволяет сохранять и получать данные из кастомных таблиц, аналогично работе с атрибутами лидов через lead.setAttr() и lead.getAttr() и т.д.

Плагин поддерживает:


Первоначальная настройка (Setup)

Перед использованием плагина необходимо создать кастомную таблицу для хранения атрибутов. Это можно сделать двумя способами:

Способ 1: Автоматическое создание через метод setup()

Метод setup() автоматически создает кастомную таблицу со всеми необходимыми полями и уникальным индексом.

Важно: Метод setup() можно вызвать только один раз для каждой таблицы. При повторном вызове будет выброшена ошибка, если таблица уже существует.

Пример использования:

let customStorage = require('Common.Platform.CustomStorage')

// Создаем новую таблицу для атрибутов
customStorage.setup('my_attributes_table')

// Теперь можно работать с таблицей
customStorage.setAttr('server_name', 'production-server-01')

Что делает метод setup():

  1. Проверяет, что таблицы с таким именем еще не существует
  2. Создает кастомную таблицу с необходимыми полями:
    • id - автоинкрементный идентификатор
    • key - первый ключ (обязательный)
    • key2 - второй ключ (опциональный, для подкатегорий)
    • key3 - третий ключ (опциональный, для подкатегорий)
    • value_type - тип значения (string или json)
    • value - значение атрибута
    • created_at - дата создания
    • updated_at - дата обновления
  3. Создает уникальный индекс на комбинацию (key, key2, key3) для предотвращения дубликатов

Способ 2: Ручное создание через интерфейс админки

Вы можете создать таблицу вручную через интерфейс админки в разделе Таблицы, используя структуру из JSON примера ниже.

Структура таблицы (JSON для импорта):

{
    "version": "2.50.0",
    "format": 2,
    "custom_tables": {
        "business_attributes": {
            "is_enabled": 1,
            "name": "business_attributes",
            "title": "Атрибуты бизнеса",
            "comment": null,
            "is_sync_struct": 1,
            "is_show_in_menu": 1,
            "has_api_access": 0,
            "sort_order": 0,
            "is_compact_view": 0,
            "line_height": 1,
            "max_width_px": null,
            "css": null,
            "js": null,
            "sort_order_data": 1,
            "fields": {
                "id": {
                    "is_enabled": 1,
                    "name": "id",
                    "title": "ID",
                    "type": "AUTOINC",
                    "is_required": 1,
                    "size": null,
                    "default_value": null,
                    "hint": null,
                    "tooltip": null,
                    "sort_order": 0,
                    "precision": null,
                    "is_sync_struct": 1,
                    "is_locked_for_regular_user": 0,
                    "max_width_px": null
                },
                "key": {
                    "is_enabled": 1,
                    "name": "key",
                    "title": "Первый ключ",
                    "type": "TEXT",
                    "is_required": 1,
                    "size": null,
                    "default_value": "",
                    "hint": null,
                    "tooltip": null,
                    "sort_order": 2,
                    "precision": null,
                    "is_sync_struct": 1,
                    "is_locked_for_regular_user": 0,
                    "max_width_px": null
                },
                "key2": {
                    "is_enabled": 1,
                    "name": "key2",
                    "title": "Второй ключ",
                    "type": "TEXT",
                    "is_required": 0,
                    "size": null,
                    "default_value": "",
                    "hint": null,
                    "tooltip": null,
                    "sort_order": 3,
                    "precision": null,
                    "is_sync_struct": 1,
                    "is_locked_for_regular_user": 0,
                    "max_width_px": null
                },
                "key3": {
                    "is_enabled": 1,
                    "name": "key3",
                    "title": "Третий ключ",
                    "type": "TEXT",
                    "is_required": 0,
                    "size": null,
                    "default_value": "",
                    "hint": null,
                    "tooltip": null,
                    "sort_order": 4,
                    "precision": null,
                    "is_sync_struct": 1,
                    "is_locked_for_regular_user": 0,
                    "max_width_px": null
                },
                "value_type": {
                    "is_enabled": 1,
                    "name": "value_type",
                    "title": "Тип значения",
                    "type": "TEXT",
                    "is_required": 0,
                    "size": null,
                    "default_value": "",
                    "hint": null,
                    "tooltip": null,
                    "sort_order": 5,
                    "precision": null,
                    "is_sync_struct": 1,
                    "is_locked_for_regular_user": 0,
                    "max_width_px": null
                },
                "value": {
                    "is_enabled": 1,
                    "name": "value",
                    "title": "Значение",
                    "type": "TEXT",
                    "is_required": 1,
                    "size": null,
                    "default_value": "",
                    "hint": null,
                    "tooltip": null,
                    "sort_order": 6,
                    "precision": null,
                    "is_sync_struct": 1,
                    "is_locked_for_regular_user": 0,
                    "max_width_px": null
                },
                "created_at": {
                    "is_enabled": 1,
                    "name": "created_at",
                    "title": "Дата создания",
                    "type": "CREATED_AT",
                    "is_required": 1,
                    "size": null,
                    "default_value": "NOW",
                    "hint": null,
                    "tooltip": null,
                    "sort_order": 7,
                    "precision": null,
                    "is_sync_struct": 1,
                    "is_locked_for_regular_user": 0,
                    "max_width_px": null
                },
                "updated_at": {
                    "is_enabled": 1,
                    "name": "updated_at",
                    "title": "Дата изменения",
                    "type": "UPDATED_AT",
                    "is_required": 0,
                    "size": null,
                    "default_value": null,
                    "hint": null,
                    "tooltip": null,
                    "sort_order": 8,
                    "precision": null,
                    "is_sync_struct": 1,
                    "is_locked_for_regular_user": 0,
                    "max_width_px": null
                }
            }
        }
    }
}

Важно: После создания таблицы вручную необходимо создать уникальный индекс на комбинацию (key, key2, key3) в базе данных.


Подключение плагина

Для использования плагина в JavaScript скрипте необходимо подключить его через require():

let customStorage = require('Common.Platform.CustomStorage')

После подключения необходимо установить имя таблицы, с которой будет работать плагин:

customStorage.setTableName('business_attributes')

Методы работы с таблицей

setTableName()

Устанавливает имя кастомной таблицы для работы.

Сигнатура:

customStorage.setTableName(string $tableName): self

Параметры:

Возвращает: self - Возвращает сам объект для цепочки вызовов

Пример:

customStorage.setTableName('business_attributes')

getTableName()

Возвращает имя текущей установленной таблицы.

Сигнатура:

customStorage.getTableName(): string|null

Возвращает: string|null - Имя таблицы или null, если таблица не установлена

Пример:

let tableName = customStorage.getTableName()
bot.sendText('Работаем с таблицей: ' + tableName)

setup()

Создает новую кастомную таблицу для атрибутов со всеми необходимыми полями и уникальным индексом.

Сигнатура:

customStorage.setup(string $tableName): self

Параметры:

Возвращает: self - Возвращает сам объект для цепочки вызовов

Исключения:

Пример:

// Создаем новую таблицу
customStorage.setup('my_attributes_table')

// Теперь можно работать с ней
customStorage.setAttr('test_key', 'test_value')

Важно: Метод setup() автоматически устанавливает созданную таблицу как текущую, поэтому после вызова setup() можно сразу начинать работу с атрибутами.


Методы работы с обычными атрибутами

setAttr()

Устанавливает значение обычного атрибута в базе данных.

Сигнатура:

customStorage.setAttr(string $key, mixed $value, ?string $key2 = null, ?string $key3 = null): self

Параметры:

Возвращает: self - Возвращает сам объект для цепочки вызовов

Примеры:

// Простое сохранение
customStorage.setAttr('server_name', 'production-server-01')
customStorage.setAttr('server_port', '8080')

// С подкатегориями
customStorage.setAttr('server_name', 'server-01', 'region1', 'dc1')
customStorage.setAttr('server_ip', '192.168.1.1', 'region1', 'dc1')

Примечание: Если для комбинации (key, key2, key3) уже существует запись, она будет обновлена. Если найдено несколько записей, будет выброшена ошибка.


getAttr()

Получает значение обычного атрибута из базы данных.

Сигнатура:

customStorage.getAttr(string $key, ?string $key2 = null, ?string $key3 = null): mixed|null

Параметры:

Возвращает: mixed|null - Значение атрибута или null, если атрибут не найден

Примеры:

// Простое получение
let serverName = customStorage.getAttr('server_name')

// С подкатегориями
let serverName = customStorage.getAttr('server_name', 'region1', 'dc1')
let serverIp = customStorage.getAttr('server_ip', 'region1', 'dc1')

Примечание: Если для комбинации (key, key2, key3) найдено несколько записей, будет выброшена ошибка.


getIntAttr()

Получает значение атрибута, преобразованное в целое число.

Сигнатура:

customStorage.getIntAttr(string $key, ?int $default = 0, ?string $key2 = null, ?string $key3 = null): int|null

Параметры:

Возвращает: int|null - Целое число или значение по умолчанию

Примеры:

// Получение с значением по умолчанию
let port = customStorage.getIntAttr('server_port', 8080)
let nonExistent = customStorage.getIntAttr('non_existent', 100) // вернет 100

// С подкатегориями
let port = customStorage.getIntAttr('server_port', 8080, 'region1', 'dc1')

getFloatAttr()

Получает значение атрибута, преобразованное в число с плавающей точкой.

Сигнатура:

customStorage.getFloatAttr(string $key, ?float $default = 0.0, ?string $key2 = null, ?string $key3 = null): float|null

Параметры:

Возвращает: float|null - Число с плавающей точкой или значение по умолчанию

Примеры:

let price = customStorage.getFloatAttr('price', 0.0)
let nonExistent = customStorage.getFloatAttr('non_existent', 2.5) // вернет 2.5

getBoolAttr()

Получает значение атрибута, преобразованное в булево значение.

Сигнатура:

customStorage.getBoolAttr(string $key, ?bool $default = false, ?string $key2 = null, ?string $key3 = null): bool|null

Параметры:

Возвращает: bool|null - Булево значение или значение по умолчанию

Особенности обработки строковых значений:

Примеры:

let isActive = customStorage.getBoolAttr('is_active', false)
let nonExistent = customStorage.getBoolAttr('non_existent', true) // вернет true

// Строковые значения обрабатываются корректно
customStorage.setAttr('bool_true', 'true')
customStorage.setAttr('bool_false', 'false')
let val1 = customStorage.getBoolAttr('bool_true') // вернет true
let val2 = customStorage.getBoolAttr('bool_false') // вернет false

isAttrExist()

Проверяет существование атрибута в базе данных.

Сигнатура:

customStorage.isAttrExist(string $key, ?string $key2 = null, ?string $key3 = null): bool

Параметры:

Возвращает: bool - true, если атрибут существует, false - если не существует

Примеры:

if (customStorage.isAttrExist('server_name')) {
    bot.sendText('Сервер найден: ' + customStorage.getAttr('server_name'))
} else {
    bot.sendText('Сервер не найден')
}

// С подкатегориями
if (customStorage.isAttrExist('server_name', 'region1', 'dc1')) {
    bot.sendText('Сервер найден в регионе 1')
}

issetAttr()

Проверяет существование атрибута в базе данных (аналог isAttrExist()).

Сигнатура:

customStorage.issetAttr(string $key, ?string $key2 = null, ?string $key3 = null): bool

Параметры:

Возвращает: bool - true, если атрибут существует, false - если не существует

Примеры:

if (customStorage.issetAttr('server_name')) {
    bot.sendText('Сервер найден')
}

deleteAttr()

Удаляет атрибут из базы данных.

Сигнатура:

customStorage.deleteAttr(string $key, ?string $key2 = null, ?string $key3 = null): self

Параметры:

Возвращает: self - Возвращает сам объект для цепочки вызовов

Примеры:

// Удаление простого атрибута
customStorage.deleteAttr('server_port')

// Удаление с подкатегориями
customStorage.deleteAttr('server_name', 'region1', 'dc1')

getAllAttr()

Получает все обычные атрибуты (не JSON) из базы данных.

Сигнатура:

customStorage.getAllAttr(): array

Возвращает: array - Ассоциативный массив всех атрибутов, где ключ - это комбинация key, key2, key3 (разделенные точками), а значение - значение атрибута

Примеры:

let allAttrs = customStorage.getAllAttr()
bot.sendText('Всего атрибутов: ' + Object.keys(allAttrs).length)

// Пример структуры результата:
// {
//     "server_name": "server-01",
//     "server_name.region1.dc1": "server-01",
//     "server_ip.region1.dc1": "192.168.1.1"
// }

Методы работы с JSON атрибутами

setJsonAttr()

Устанавливает значение JSON атрибута в базе данных. Поддерживает точечную нотацию для вложенных структур.

Сигнатура:

customStorage.setJsonAttr(string $key, mixed $value, ?string $key2 = null, ?string $key3 = null): self

Параметры:

Возвращает: self - Возвращает сам объект для цепочки вызовов

Особенности:

Примеры:

// Простой JSON объект
customStorage.setJsonAttr('config', {
    timeout: 30,
    retries: 3,
    enabled: true
})

// Точечная нотация для вложенных значений
customStorage.setJsonAttr('user.profile.name', 'John Doe')
customStorage.setJsonAttr('user.profile.email', 'john@example.com')
customStorage.setJsonAttr('user.profile.age', 30)
customStorage.setJsonAttr('user.settings.theme', 'dark')

// Глубокая вложенность
customStorage.setJsonAttr('app.settings.database.host', 'localhost')
customStorage.setJsonAttr('app.settings.database.port', 5432)
customStorage.setJsonAttr('app.settings.cache.enabled', true)

// С подкатегориями (key2, key3)
customStorage.setJsonAttr('server.config', { cpu: 8, ram: 32 }, 'region1', 'dc1')
customStorage.setJsonAttr('server.config', { cpu: 16, ram: 64 }, 'region2', 'dc2')

// Массивы
customStorage.setJsonAttr('packages', [
    { name: 'nginx', version: '1.18.0' },
    { name: 'php', version: '8.1.0' },
    { name: 'mysql', version: '8.0.0' }
])

Примечание: Если для комбинации (key, key2, key3) уже существует запись, JSON объект будет обновлен. Если найдено несколько записей, будет выброшена ошибка.


getJsonAttr()

Получает значение JSON атрибута из базы данных. Поддерживает точечную нотацию для извлечения вложенных значений.

Сигнатура:

customStorage.getJsonAttr(string $key, ?string $key2 = null, ?string $key3 = null): mixed|null

Параметры:

Возвращает: mixed|null - Значение JSON атрибута или null, если атрибут не найден

Примеры:

// Получение простого JSON объекта
let config = customStorage.getJsonAttr('config')
if (config) {
    bot.sendText('Timeout: ' + config.timeout)
    bot.sendText('Retries: ' + config.retries)
}

// Получение вложенных значений через точечную нотацию
let userName = customStorage.getJsonAttr('user.profile.name')
let userEmail = customStorage.getJsonAttr('user.profile.email')
let userAge = customStorage.getJsonAttr('user.profile.age')

// Получение всего объекта верхнего уровня
let userProfile = customStorage.getJsonAttr('user')
// userProfile будет содержать: { profile: { name: "John Doe", email: "john@example.com", age: 30 }, settings: { theme: "dark" } }

// Глубокая вложенность
let dbHost = customStorage.getJsonAttr('app.settings.database.host')
let dbPort = customStorage.getJsonAttr('app.settings.database.port')

// С подкатегориями
let serverConfig = customStorage.getJsonAttr('server.config', 'region1', 'dc1')
if (serverConfig) {
    bot.sendText('CPU: ' + serverConfig.cpu + ' cores')
    bot.sendText('RAM: ' + serverConfig.ram + ' GB')
}

Примечание: Если для комбинации (key, key2, key3) найдено несколько записей, будет выброшена ошибка.


isJsonAttrKeyExist()

Проверяет существование JSON атрибута в базе данных.

Сигнатура:

customStorage.isJsonAttrKeyExist(string $key, ?string $key2 = null, ?string $key3 = null): bool

Параметры:

Возвращает: bool - true, если JSON атрибут существует, false - если не существует

Примеры:

if (customStorage.isJsonAttrKeyExist('user.profile.name')) {
    bot.sendText('Имя пользователя: ' + customStorage.getJsonAttr('user.profile.name'))
}

// С подкатегориями
if (customStorage.isJsonAttrKeyExist('server.config', 'region1', 'dc1')) {
    bot.sendText('Конфигурация сервера найдена')
}

issetJsonAttr()

Проверяет существование JSON атрибута в базе данных (аналог isJsonAttrKeyExist()).

Сигнатура:

customStorage.issetJsonAttr(string $key, ?string $key2 = null, ?string $key3 = null): bool

Параметры:

Возвращает: bool - true, если JSON атрибут существует, false - если не существует

Примеры:

if (customStorage.issetJsonAttr('user.profile.email')) {
    bot.sendText('Email установлен')
}

deleteJsonAttr()

Удаляет JSON атрибут из базы данных.

Сигнатура:

customStorage.deleteJsonAttr(string $key, ?string $key2 = null, ?string $key3 = null): self

Параметры:

Возвращает: self - Возвращает сам объект для цепочки вызовов

Важно: При удалении через точечную нотацию удаляется весь объект верхнего уровня. Например, deleteJsonAttr('user') удалит всю запись с key='user', включая все вложенные значения (user.profile.name, user.profile.email и т.д.).

Примеры:

// Удаление простого JSON атрибута
customStorage.deleteJsonAttr('config')

// Удаление вложенного JSON атрибута (удаляет весь верхний уровень)
customStorage.deleteJsonAttr('user') // Удалит всю запись с key='user'

// Удаление с подкатегориями
customStorage.deleteJsonAttr('server.config', 'region1', 'dc1')

getAllJsonAttrs()

Получает все JSON атрибуты из базы данных.

Сигнатура:

customStorage.getAllJsonAttrs(): array

Возвращает: array - Ассоциативный массив всех JSON атрибутов, где ключ - это комбинация key, key2, key3 (разделенные точками), а значение - распарсенный JSON объект

Примеры:

let allJsonAttrs = customStorage.getAllJsonAttrs()
bot.sendText('Всего JSON атрибутов: ' + Object.keys(allJsonAttrs).length)

// Пример структуры результата:
// {
//     "config": { timeout: 30, retries: 3, enabled: true },
//     "user": { profile: { name: "John Doe", email: "john@example.com" } },
//     "server.region1.dc1": { config: { cpu: 8, ram: 32 } }
// }

Полный справочник методов

Методы работы с таблицей

Метод Описание
setTableName(string $tableName): self Устанавливает имя кастомной таблицы для работы
getTableName(): string|null Возвращает имя текущей установленной таблицы
setup(string $tableName): self Создает новую кастомную таблицу для атрибутов со всеми необходимыми полями и уникальным индексом

Методы работы с обычными атрибутами

Метод Описание
setAttr(string $key, mixed $value, ?string $key2 = null, ?string $key3 = null): self Устанавливает значение обычного атрибута в базе данных
getAttr(string $key, ?string $key2 = null, ?string $key3 = null): mixed|null Получает значение обычного атрибута из базы данных
getIntAttr(string $key, ?int $default = 0, ?string $key2 = null, ?string $key3 = null): int|null Получает значение атрибута, преобразованное в целое число
getFloatAttr(string $key, ?float $default = 0.0, ?string $key2 = null, ?string $key3 = null): float|null Получает значение атрибута, преобразованное в число с плавающей точкой
getBoolAttr(string $key, ?bool $default = false, ?string $key2 = null, ?string $key3 = null): bool|null Получает значение атрибута, преобразованное в булево значение
isAttrExist(string $key, ?string $key2 = null, ?string $key3 = null): bool Проверяет существование атрибута в базе данных
issetAttr(string $key, ?string $key2 = null, ?string $key3 = null): bool Проверяет существование атрибута в базе данных (аналог isAttrExist)
deleteAttr(string $key, ?string $key2 = null, ?string $key3 = null): self Удаляет атрибут из базы данных
getAllAttr(): array Получает все обычные атрибуты (не JSON) из базы данных

Методы работы с JSON атрибутами

Метод Описание
setJsonAttr(string $key, mixed $value, ?string $key2 = null, ?string $key3 = null): self Устанавливает значение JSON атрибута в базе данных. Поддерживает точечную нотацию для вложенных структур
getJsonAttr(string $key, ?string $key2 = null, ?string $key3 = null): mixed|null Получает значение JSON атрибута из базы данных. Поддерживает точечную нотацию для извлечения вложенных значений
isJsonAttrKeyExist(string $key, ?string $key2 = null, ?string $key3 = null): bool Проверяет существование JSON атрибута в базе данных
issetJsonAttr(string $key, ?string $key2 = null, ?string $key3 = null): bool Проверяет существование JSON атрибута в базе данных (аналог isJsonAttrKeyExist)
deleteJsonAttr(string $key, ?string $key2 = null, ?string $key3 = null): self Удаляет JSON атрибут из базы данных
getAllJsonAttrs(): array Получает все JSON атрибуты из базы данных

Примеры использования

Пример 1: Базовое использование

let customStorage = require('Common.Platform.CustomStorage')

// Устанавливаем таблицу
customStorage.setTableName('business_attributes')

// Сохранение простых атрибутов
customStorage.setAttr('server_name', 'production-server-01')
customStorage.setAttr('server_ip', '192.168.1.100')
customStorage.setAttr('server_port', '8080')

// Получение атрибутов
let serverName = customStorage.getAttr('server_name')
let serverIp = customStorage.getAttr('server_ip')
let port = customStorage.getIntAttr('server_port', 0)

bot.sendText('Сервер: ' + serverName + ' (' + serverIp + ':' + port + ')')

Пример 2: Использование подкатегорий (key2, key3)

let customStorage = require('Common.Platform.CustomStorage')
customStorage.setTableName('business_attributes')

// Сохранение данных для разных регионов и датацентров
customStorage.setAttr('server_name', 'server-01', 'us-east', 'dc-01')
customStorage.setAttr('server_ip', '10.0.1.10', 'us-east', 'dc-01')
customStorage.setAttr('server_name', 'server-02', 'us-east', 'dc-02')
customStorage.setAttr('server_ip', '10.0.2.10', 'us-east', 'dc-02')

// Получение данных для конкретного региона и датацентра
let serverName = customStorage.getAttr('server_name', 'us-east', 'dc-01')
let serverIp = customStorage.getAttr('server_ip', 'us-east', 'dc-01')

bot.sendText('Сервер в us-east/dc-01: ' + serverName + ' (' + serverIp + ')')

Пример 3: Работа с JSON атрибутами

let customStorage = require('Common.Platform.CustomStorage')
customStorage.setTableName('business_attributes')

// Сохранение простого JSON объекта
customStorage.setJsonAttr('config', {
    timeout: 30,
    retries: 3,
    enabled: true
})

// Получение JSON объекта
let config = customStorage.getJsonAttr('config')
if (config) {
    bot.sendText('Timeout: ' + config.timeout)
    bot.sendText('Retries: ' + config.retries)
    bot.sendText('Enabled: ' + config.enabled)
}

Пример 4: Точечная нотация для вложенных JSON структур

let customStorage = require('Common.Platform.CustomStorage')
customStorage.setTableName('business_attributes')

// Установка вложенных значений через точечную нотацию
customStorage.setJsonAttr('user.profile.name', 'John Doe')
customStorage.setJsonAttr('user.profile.email', 'john@example.com')
customStorage.setJsonAttr('user.profile.age', 30)
customStorage.setJsonAttr('user.settings.theme', 'dark')
customStorage.setJsonAttr('user.settings.language', 'ru')

// Получение вложенных значений
let userName = customStorage.getJsonAttr('user.profile.name')
let userEmail = customStorage.getJsonAttr('user.profile.email')
let userTheme = customStorage.getJsonAttr('user.settings.theme')

bot.sendText('Пользователь: ' + userName + ' (' + userEmail + ')')
bot.sendText('Тема: ' + userTheme)

// Получение всего объекта верхнего уровня
let userProfile = customStorage.getJsonAttr('user')
// userProfile будет содержать: { profile: { name: "John Doe", email: "john@example.com", age: 30 }, settings: { theme: "dark", language: "ru" } }

Пример 5: JSON атрибуты с подкатегориями

let customStorage = require('Common.Platform.CustomStorage')
customStorage.setTableName('business_attributes')

// Сохранение JSON конфигурации для разных регионов
customStorage.setJsonAttr('server.config', { cpu: 8, ram: 32 }, 'region1', 'dc1')
customStorage.setJsonAttr('server.config', { cpu: 16, ram: 64 }, 'region2', 'dc2')

// Получение конфигурации для конкретного региона
let config1 = customStorage.getJsonAttr('server.config', 'region1', 'dc1')
let config2 = customStorage.getJsonAttr('server.config', 'region2', 'dc2')

if (config1) {
    bot.sendText('Регион 1: CPU=' + config1.cpu + ', RAM=' + config1.ram)
}
if (config2) {
    bot.sendText('Регион 2: CPU=' + config2.cpu + ', RAM=' + config2.ram)
}

Пример 6: Глубокая вложенность JSON

let customStorage = require('Common.Platform.CustomStorage')
customStorage.setTableName('business_attributes')

// Установка глубоко вложенных значений
customStorage.setJsonAttr('app.settings.database.host', 'localhost')
customStorage.setJsonAttr('app.settings.database.port', 5432)
customStorage.setJsonAttr('app.settings.database.name', 'myapp')
customStorage.setJsonAttr('app.settings.cache.enabled', true)
customStorage.setJsonAttr('app.settings.cache.ttl', 3600)

// Получение глубоко вложенных значений
let dbHost = customStorage.getJsonAttr('app.settings.database.host')
let dbPort = customStorage.getJsonAttr('app.settings.database.port')
let cacheEnabled = customStorage.getJsonAttr('app.settings.cache.enabled')

bot.sendText('DB Host: ' + dbHost)
bot.sendText('DB Port: ' + dbPort)
bot.sendText('Cache Enabled: ' + cacheEnabled)

Пример 7: Работа с массивами

let customStorage = require('Common.Platform.CustomStorage')
customStorage.setTableName('business_attributes')

// Сохранение массива
customStorage.setJsonAttr('packages', [
    { name: 'nginx', version: '1.18.0' },
    { name: 'php', version: '8.1.0' },
    { name: 'mysql', version: '8.0.0' }
])

// Получение массива
let packages = customStorage.getJsonAttr('packages')
if (packages && Array.isArray(packages)) {
    bot.sendText('Установлено пакетов: ' + packages.length)
    packages.forEach(function(pkg) {
        bot.sendText('- ' + pkg.name + ' v' + pkg.version)
    })
}

Пример 8: Проверка существования и удаление

let customStorage = require('Common.Platform.CustomStorage')
customStorage.setTableName('business_attributes')

// Проверка существования
if (customStorage.isAttrExist('server_name')) {
    let serverName = customStorage.getAttr('server_name')
    bot.sendText('Сервер найден: ' + serverName)
} else {
    bot.sendText('Сервер не найден')
}

// Проверка JSON атрибута
if (customStorage.isJsonAttrKeyExist('user.profile.name')) {
    let userName = customStorage.getJsonAttr('user.profile.name')
    bot.sendText('Имя пользователя: ' + userName)
}

// Удаление атрибутов
customStorage.deleteAttr('server_port')
customStorage.deleteJsonAttr('config')

// Удаление с подкатегориями
customStorage.deleteAttr('server_name', 'region1', 'dc1')
customStorage.deleteJsonAttr('server.config', 'region1', 'dc1')

Пример 9: Массовые операции

let customStorage = require('Common.Platform.CustomStorage')
customStorage.setTableName('business_attributes')

// Получение всех обычных атрибутов
let allAttrs = customStorage.getAllAttr()
bot.sendText('Всего обычных атрибутов: ' + Object.keys(allAttrs).length)

// Получение всех JSON атрибутов
let allJsonAttrs = customStorage.getAllJsonAttrs()
bot.sendText('Всего JSON атрибутов: ' + Object.keys(allJsonAttrs).length)

Важные замечания

Безопасность

Уникальность записей

Работа с памятью

Точечная нотация для JSON

Подкатегории key2 и key3


Обработка ошибок

Все методы плагина могут выбрасывать исключения типа UnauthotizedV8Exception в следующих случаях:

Важно: В V8Js невозможно отлавливать исключения бэкенда через try/catch в JavaScript. Поэтому рекомендуется использовать специальные методы проверки существования атрибутов перед их использованием:

Проверка существования обычных атрибутов

Для проверки существования обычных атрибутов используйте методы isAttrExist() или issetAttr():

let customStorage = require('Common.Platform.CustomStorage')
customStorage.setTableName('business_attributes')

// Безопасное получение атрибута с проверкой существования
if (customStorage.isAttrExist('server_name')) {
    let value = customStorage.getAttr('server_name')
    bot.sendText('Значение: ' + value)
} else {
    bot.sendText('Атрибут не найден')
}

// Альтернативный вариант с issetAttr()
if (customStorage.issetAttr('server_name')) {
    let value = customStorage.getAttr('server_name')
    bot.sendText('Значение: ' + value)
}

// С подкатегориями
if (customStorage.isAttrExist('server_name', 'region1', 'dc1')) {
    let value = customStorage.getAttr('server_name', 'region1', 'dc1')
    bot.sendText('Сервер в регионе 1: ' + value)
}

Проверка существования JSON атрибутов

Для проверки существования JSON атрибутов используйте методы isJsonAttrKeyExist() или issetJsonAttr():

let customStorage = require('Common.Platform.CustomStorage')
customStorage.setTableName('business_attributes')

// Проверка простого JSON атрибута
if (customStorage.isJsonAttrKeyExist('config')) {
    let config = customStorage.getJsonAttr('config')
    bot.sendText('Timeout: ' + config.timeout)
}

// Проверка вложенного JSON атрибута с точечной нотацией
if (customStorage.isJsonAttrKeyExist('user.profile.name')) {
    let userName = customStorage.getJsonAttr('user.profile.name')
    bot.sendText('Имя пользователя: ' + userName)
}

// Альтернативный вариант с issetJsonAttr()
if (customStorage.issetJsonAttr('user.profile.email')) {
    let email = customStorage.getJsonAttr('user.profile.email')
    bot.sendText('Email: ' + email)
}

// С подкатегориями
if (customStorage.isJsonAttrKeyExist('server.config', 'region1', 'dc1')) {
    let config = customStorage.getJsonAttr('server.config', 'region1', 'dc1')
    bot.sendText('CPU: ' + config.cpu + ' cores')
}

Рекомендация: Всегда используйте методы проверки существования (isAttrExist(), issetAttr(), isJsonAttrKeyExist(), issetJsonAttr()) вместо проверки на null после вызова getAttr() или getJsonAttr(). Это более явный и безопасный способ проверки.


Сравнение с атрибутами лидов

Плагин CustomStorage предоставляет аналогичный функционал, что и работа с атрибутами лидов через lead.setAttr() и lead.getAttr() и т.д., но с дополнительными возможностями:

Функция lead customStorage
Работа с обычными атрибутами
Работа с JSON атрибутами
Точечная нотация для JSON
Подкатегории (key2, key3)
Работа с кастомными таблицами
Прямая работа с БД

Заключение

Плагин CustomStorage предоставляет мощный инструмент для работы с кастомными таблицами атрибутов в JavaScript скриптах. Он позволяет гибко хранить и получать данные с поддержкой подкатегорий и вложенных JSON структур, что делает его незаменимым инструментом для сложных сценариев работы с данными.

Инструкция по подключению Google Scripts

Настройка Google Scripts (Не будет ли проблем из-за копирования)

  1. Создаём и настраиваем Google Sheets
  2. Переходим в Google Scripts через вкладку “Расширения”

image.png

  1. Копируем и вставляем этот скрипт в редактор Google Scripts

    Google scripts.txt

  2. Нажимаем на иконку “Сохранить”, а за тем “Выполнить”

image.png

В первый раз приложение запросит доступы к Google аккаунту, нужно всё разрешить.

  1. Нажимаем на кнопку “Начать развёртывание” >> “Новое развёртывание”

image.png

  1. Выбираем тип “Веб приложение” >> У кого есть доступ “Все” >> Начать развёртывание

image.png

  1. Копируем идентификатор развёртывания.

image.png

Настройка плагина в Metabot

Подключение плагина и вызов методов

Для подключения плагина нужно использовать вот такой код:

snippet('Business.DataStudio.LogEvent'); // Вызываем плагин бизнеса

После этого вызываем необходимые для нас функции.

Если нужно добавить пользователя:

snippet('Business.DataStudio.LogEvent'); // Вызываем плагин бизнеса
AddUser(user_id, user, subscription_datetime); // Добавляем нового пользователя

Если нам нужно обновить данные о пользователе

snippet('Business.DataStudio.LogEvent'); // Вызываем плагин бизнеса
UpdateUser(user_id, user, subscription_datetime); // Обновляем данные о пользователе

Если нужно записать событие, в котором участвует пользователь

snippet('Business.DataStudio.LogEvent'); // Вызываем плагин бизнеса
NewEvent(user_id, type_name, contest_id, event_type_id,	task_id, event_datetime);
user_id id пользователя (Тот же что выбрали при добавлении пользователя) integer Опционально
type_name Название конкурса String Обязательно
contest_id id конкурса integer Опционально
event_type_id id типа события integer Опционально
task_id id задачи integer Опционально
event_datetime Дата/время наступления события String Опционально

Документация по Notifier

Модуль Notifier предназначен для отправки уведомлений в Telegram-чаты, например, о событиях, ошибках или статусах работы системы. Используется для оперативного информирования администраторов и команд.


Конфигурация


Методы

sendToTelegramChat(message, jsonParams, groupCode, type)

Отправляет сообщение в указанный Telegram-чат.

Параметры

Имя Тип Описание
message String Текст сообщения (обязательно)
jsonParams Object Дополнительные параметры, выводятся как JSON-блок (опционально)
groupCode String Код группы чата, например "default", "admins" (по умолчанию "default")
type String Тип сообщения: "info", "success", "warning", "error", "debug" (по умолчанию "info")

Возвращает
Boolean — true если успешно, false если ошибка.

Пример:

Notifier.sendToTelegramChat(
  "Выполнена операция", 
  { user: "admin", status: "ok" }, 
  "admins", 
  "success"
)

Формат сообщения


Обработка ошибок


Быстрый старт

  1. Настройте атрибут plg_notifier_chat_ids с нужными чатами.
  2. Все уведомления из плагинов LLM уже приходят в ваш чат.
  3. Или вручную используйте Notifier.sendToTelegramChat() для отправки уведомлений.

 

Common.Observability.* — Инструменты наблюдаемости

Observability — пакет системных инструментов для наблюдаемости, диагностики и эскалации событий в Metabot. Предназначен для фиксации, анализа и понимания поведения системы без влияния на бизнес-логику.

Common.Observability.* — Инструменты наблюдаемости

Common.Observability.Tracer — Универсальный трассировщик событий

Автор: Art Yg

Tracer предназначен для записи любых диагностических событий в таблицы базы данных:

Tracer не знает:

Он записывает ровно те данные, которые ему передали.


Основные принципы

Tracer можно удалить из проекта — бизнес-логика продолжит работать.


Минимальные требования

Для начала работы достаточно:

  1. Создать любую таблицу в БД (даже без полей, кроме id)
  2. Добавить атрибут бота TRACER_CONFIG
  3. Вызвать trace()

Рекомендуемое, но не обязательное поле таблицы:

Tracer не управляет временем. Если поле есть — БД заполнит его автоматически. Если нет — запись всё равно создаётся.


Конфигурация

Tracer настраивается через один JSON в атрибутах бота: TRACER_CONFIG.

{
  "navigation": {
    "enabled": true,
    "table": "nav_trace"
  },
  "ai": {
    "enabled": false,
    "table": "ai_trace"
  }
}

Использование

const Tracer = require("Common.Observability.Tracer");

Tracer.trace("navigation", {
  category: "NAVIGATION",
  component: "Actor",
  action: "hasAchievement",
  level: "OK",
  payload: {
    actor_id: 42,
    achievement: "first_step",
    result: true
  }
});

Если tracer выключен — метод молча завершится.


Методы Tracer

Tracer предоставляет несколько эквивалентных методов:

Все методы:


Данные события

Tracer принимает любой объект.

Все поля:

Рекомендуемые (но не обязательные):

Если таблица не содержит поле — БД вернёт ошибку. В таком случае используйте payload.


Работа со временем

Tracer:

{
  event_time: "2026-01-16T12:00:00Z"
}

или

{
  created_at: "2026-01-16T12:00:00Z"
}

Когда использовать


Когда не использовать


Итог

Common.Observability.Tracer — простой и ненавязчивый способ видеть, что происходит внутри системы.

Никакой магии. Никаких обязательств. Никакой боли.


Версия 1.1 — Интеграция с Incident

В версии 1.1 Tracer получил опциональную интеграцию с системой инцидентов.

Tracer по-прежнему:

Интеграция включается исключительно через конфигурацию.


Что добавлено

Tracer теперь может:

Если интеграция не настроена — Tracer ведёт себя точно так же, как в версии 1.0.


Расширенная конфигурация

В TRACER_CONFIG можно указать блок incident для любого tracera:

{
  "navigation": {
    "enabled": true,
    "table": "nav_trace",
    "incident": {
      "enabled": true,
      "type": "navigation_failed",
      "severity": "error"
    }
  }
}

Поведение:

Инцидент инициируется только если:


Архитектурные гарантии

Интеграция с Incident:

Tracer остаётся:


Использование (без изменений)

Tracer.error("navigation", {
  component: "Reflection",
  action: "build",
  payload: {
    reason: "profile_missing"
  }
});

Если Incident включён — будет создан инцидент. Если нет — только запись в таблицу.


Итог версии 1.1

Версия 1.1 добавляет Tracer’у способность сигналить о сбоях, не превращая его в логгер, нотификатор или бизнес-модуль.

Tracer по-прежнему ничего не «решает». Он просто сообщает — системе, а не человеку.


Common.Observability.* — Инструменты наблюдаемости

Common.Observability.Incident — Централизованный обработчик инцидентов

Common.Observability.Incident

Incident — централизованный обработчик инцидентов в Metabot. Предназначен для регистрации и уведомления о сбоях, ошибках и критических состояниях системы.

Класс не содержит бизнес-логики и не влияет на выполнение сценариев. Он используется как реакция на события (например, из Tracer) или вызывается напрямую из сценариев.


Назначение

Incident решает одну задачу: превратить техническое событие в уведомление для команды.

Типичные случаи:


Уведомления

В текущей реализации Incident использует Telegram через плагин:

Common.Notifications.Telegram

Для работы требуются два атрибута бота:

Incident сам не хранит токены и не управляет доступами.


Шаблоны сообщений

Тексты уведомлений настраиваются через атрибут бота INCIDENT_TEMPLATES (JSON).

Пример:

{
  "profiling_failed": {
    "ru": {
      "title": "🧭 ORION · Сбой профилирования",
      "body": [
        "Профиль не был корректно сформирован.",
        "",
        "Lead ID: {{lead_id}}",
        "{{error}}"
      ]
    }
  }
}

Поддерживаются:


Использование

Incident может вызываться:

Это позволяет централизовать обработку ошибок без дублирования кода уведомлений.


Incident — это точка ответственности за инциденты, а не ещё один логгер или бизнес-модуль.

Common.Platform.* — Платформенные примитивы исполнения

Пакет системных плагинов, формирующих основу исполнения, контекста и жизненного цикла диалогов и процессов внутри платформы. Этот пакет содержит платформенные примитивы, которые: * управляют состоянием выполнения, * хранят и передают контекст, * используются guard’ами, input-компонентами и системными проверками, * не зависят от конкретных сценариев, каналов или бизнес-доменов. Platform не является: — бизнес-логикой, — сценарным фреймворком, — workflow-движком, — пользовательским интерфейсом, — конкретным каналом ввода (voice / text / ui). Platform — это инфраструктурный слой, на котором строятся остальные механизмы системы.

Common.Platform.* — Платформенные примитивы исполнения

Common.Platform.AsyncFallback — Универсальный helper для таймаутов асинхронных операций

Автор: Art Yg
Версия: 1.0

AsyncFallback — это платформенный helper, который решает одну практическую проблему:
как безопасно обработать ситуацию “мы отправили async-запрос, но ответ может не прийти вовремя или вообще не прийти”.

Он нужен для любых операций, где есть “PHASE 1 → отправили запрос” и “PHASE 2 → пришёл callback”:

AsyncFallback не является AI-модулем. Это оркестрация платформенного уровня.


Зачем он существует

В реальном мире async-вызовы ломаются не потому что “код плохой”, а потому что:

Без таймаута сценарий может залипнуть: пользователь пишет, а система “ждёт” бесконечно.

AsyncFallback решает это через стандартный механизм Metabot Scheduler:

  1. Планирует job: “через N секунд выполнить fallback-script”
  2. Отменяет job, когда callback успешно пришёл
  3. Позволяет фиксировать причину ошибки и детали, чтобы дальше можно было принять решение (редирект, retry, сообщение пользователю)

Основные принципы


Минимальные требования

Чтобы использовать AsyncFallback, нужно:

  1. Иметь доступ к планировщику Metabot:
    • bot.scheduleJob({ lead_id, script_code, run_after_sec })
    • bot.clearJobsByScriptCode(script_code, lead_id)
  2. Иметь leadId (или возможность вычислить lead_id)

AsyncFallback не требует базы данных, таблиц или дополнительных сервисов.


Концепция Namespace

Namespace — обязательный “scope” для идентификации конкретной операции.

Это важно, потому что на одном lead могут одновременно идти:

Если бы мы хранили “timeout.script” без namespace — они бы перетирали друг друга.

Пример namespace:


Конфигурация

AsyncFallback конфигурируется на вызове:

Поля конфигурации


Как использовать

PHASE 1 — отправка async-запроса (isFirstImmediateCall)

  1. Конфигурируем fallback
  2. Планируем таймаут
  3. Отправляем remote request
  4. Возвращаем false (ждём callback)
const AsyncFallback = require("Common.Platform.AsyncFallback");

const fb = AsyncFallback.configure({
  lead,
  namespace: "orion_image_reflection",
  timeout: { seconds: 120, script: "Orion_Image_Timeout" },
  error: { flagAttr: "orion_image_error", reasonAttr: "orion_image_error_reason" }
});

fb.schedule();

// ... RemoteApiCall.send(..., asyncResponse: true)
return false;

PHASE 2 — пришёл callback

  1. Снимаем таймаут (если он был)
  2. Валидируем результат
  3. Если контракт нарушен — fail(reason, details)
  4. Дальше ты решаешь: редиректить в error-script или вернуться “в ту же точку”
const AsyncFallback = require("Common.Platform.AsyncFallback");

const fb = AsyncFallback.configure({
  lead,
  namespace: "orion_image_reflection"
});

fb.unschedule();

// если ответ плохой (например нет url при requireUrl=true)
fb.fail("url_missing", { requireUrl: true });

// твоя стратегия выхода:
return bot.run({ script_code: "Orion_Image_Error" });

Методы

AsyncFallback.configure(params) → instance

Создаёт instance и сохраняет конфиг в lead под namespace.
Главная точка входа. Используй и в PHASE 1, и в PHASE 2 — единообразно.


instance.schedule() → boolean

Планирует fallback-job, если timeout.seconds и timeout.script заданы.

Поведение:


instance.unschedule() → boolean

Отменяет запланированный fallback-job (если он был).

Поведение:


instance.fail(reason, details?) → true

Фиксирует ошибку и причину:

Это не редирект и не exception — это маркер, после которого ты сам решаешь, что делать.


instance.clear() → true

Очищает служебные данные namespace:

Полезно после успешного завершения операции (опционально).


Типовой паттерн: “пользователь пишет, пока ждём”

AsyncFallback — про таймаут, но он закрывает важный кусок UX:

Обычно это делается так:

(Эта логика живёт в конкретном плагине типа ImageGen/LLMQuery, а AsyncFallback даёт им общий таймаутный механизм.)


Когда использовать


Когда не использовать


Практические замечания, чтобы не словить редкий ад

Поздний callback после таймаута

Может случиться: таймаут-скрипт уже отработал, а потом всё же прилетел success-callback.
AsyncFallback снимает job на callback, но если job уже выполнился, снять уже нечего.

Правильный паттерн на уровне компонента:

Это не обязанность AsyncFallback, потому что стратегия зависит от бизнес-логики.


Итог

Common.Platform.AsyncFallback — простой, но критически полезный слой платформенной оркестрации:

Common.AI.* — Работа с AI-сервисами

Пакет плагинов для выполнения запросов к AI-сервисам (LLM, генерация изображений, речь и т.д.). Содержит: * клиентов и запросы к AI-провайдерам; * асинхронную обработку ответов; * таймауты и ошибки; * сохранение и нормализацию результатов. Пакет не содержит логики принятия решений и не является агентным или сценарным слоем. AI здесь — это внешний сервис, а не субъект.

Common.AI.* — Работа с AI-сервисами

Common.AI.ImageGen — Генерация изображений

Автор: Art Yg
Версия: 1.0

ImageGen — универсальный плагин для асинхронной генерации изображений через внешний Webhook Processor, с сохранением результата в lead (URL и/или base64) и поддержкой сценарного выхода (success/error/timeout).

Плагин спроектирован так, чтобы работать в двухфазном режиме, как LLMQuery:


Зачем он существует

В Metabot-сценариях нельзя считать генерацию изображения “быстрой синхронной функцией”:

ImageGen стандартизирует этот поток:


Что использует внутри

ImageGen — это обёртка, которая опирается на инфраструктурные компоненты:

Важно: RemoteApiCall как транспорт в принципе поддерживает любых провайдеров, но текущая версия ImageGen по умолчанию заточена под OpenAI Images API.


Поддерживаемые провайдеры

На текущий момент поддержан:

Если вам нужен другой провайдер (например, Replicate, Stability, Midjourney proxy, внутренний сервис) — свяжитесь с командой, и мы расширим плагин.

Примечание по архитектуре: сейчас таблица провайдеров (PROVIDERS) находится внутри плагина. При необходимости её можно вынести наружу (в конфиг бота/таблицу/отдельный реестр), чтобы вы могли подключать свои провайдеры без изменения кода плагина.


Что сохраняет

В зависимости от настроек:


Двухфазный протокол выполнения

PHASE 1 — отправка запроса

Когда isFirstImmediateCall = true:

  1. Инициализирует timeout-policy через Common.Platform.AsyncFallback (если задан timeout)

  2. Берёт токен из атрибута бота (по auth.tokenKey)

  3. Собирает итоговый prompt:

    • если задан prompts — собирает system[] + user[]

    • элементы массива могут быть:

      • inline строка
      • ссылка $alias (берётся из таблицы promptTable для agentName)
    • если в prompts используются $alias, то обязательны agentName и promptTable

  4. Формирует request body под /images/generations

  5. Отправляет запрос через RemoteApiCall.send(..., asyncResponse: true)

  6. (опционально) показывает messages.wait

  7. Возвращает false — сценарий “выходит” и ждёт callback


PHASE 2 — обработка callback

Когда isFirstImmediateCall = false:

  1. Проверяет, что это действительно callback от процессора (payload.is_async_response)

  2. Если это не callback (пользователь что-то написал):

    • показывает messages.processing
    • возвращает false
  3. Если callback:

    • снимает таймаут-job через AsyncFallback.unschedule()
    • парсит payload.content
    • извлекает url или b64_json
    • сохраняет в lead
    • если включён requireUrl и url нет → ошибка
  4. Если задан successScript — делает bot.run(successScript), иначе возвращается true


Конфигурация

Ниже описаны ключевые блоки ImageGen.run().


Provider + Auth

provider: "openai",
auth: { tokenKey: "OPENAI_API_KEY" }

Prompts (таблица + массивы)

ImageGen поддерживает интерфейс промптов “на вырост” — как в LLMQuery: массивы промптов и табличные ссылки.

agentName: "orion",
promptTable: "gpt_prompts",

prompts: {
  system: ["$avatar_brief_generator"],
  user: ["...основной запрос..."]
}

Принципы:

Примечание: OpenAI Images API принимает один prompt (строкой), поэтому ImageGen склеивает массивы в итоговый текст (обычно system + пустая строка + user). Это сделано для унификации с LLMQuery и поддержки других провайдеров/форматов в будущем.


Image параметры

image: {
  model: "dall-e-3",
  n: 1,
  size: "1024x1792",
  quality: "standard",
  style: "natural",
  background: null,
  response_format: "url"
}

Практика по форматам результата:


requireUrl

requireUrl: true

Если true, то отсутствие URL считается ошибкой (даже если пришёл b64).

Это удобно для MVP-потока “дай URL, а скачивание/сохранение сделаю потом”.


Таймаут (fallback)

timeout: {
  seconds: 120,
  script: "Orion_Image_Timeout"
}

Если callback не пришёл за указанное время — планировщик Metabot запускает timeout.script.

Таймаут реализован через Common.Platform.AsyncFallback внутри ImageGen.


Namespace для fallback

fallback: {
  namespace: "orion_image_reflection"
}

Нужен, чтобы несколько асинхронных операций не конфликтовали.

Если не задан, будет auto:


UX сообщения

messages: {
  wait: "🜁 Генерируем…",
  processing: "⏳ Подожди, ещё формируется…",
  error: "⚠️ Не удалось получить изображение"
}

Ошибки

error: {
  script: "Orion_Image_Error",
  flagAttr: "orion_image_error",
  reasonAttr: "orion_image_error_reason"
}

Успешный выход

successScript: "Orion_Image_Ready"

Если не указан — ImageGen.run() возвращается в ту же точку (return true), без перехода.


Таймаут: встроенный vs внешний

В большинстве сценариев таймаут проще и чище задавать внутри ImageGen через timeout, потому что плагин сам использует Common.Platform.AsyncFallback.

Внешнее управление таймаутом имеет смысл, если:


Примеры использования

Пример 1 — как используется в Orion: system prompt из таблицы + timeout + error + URL (MVP)

/**
 * orion_profiling_reflection_image
 *
 * Назначение:
 * - асинхронно сгенерировать вертикальный "Operator Reflection" образ
 * - сохранить URL в лид (скачивание/сохранение — отдельным шагом)
 * - timeout + error внутри ImageGen (как у LLMQuery)
 */

const ImageGen = require("Common.AI.ImageGen");

return ImageGen.run({
  lead,
  isFirstImmediateCall,

  code: "OrionActorImage",

  // Provider/Auth
  provider: "openai",
  auth: { tokenKey: "OPENAI_API_KEY" },

  // Prompts из таблицы + inline
  agentName: "orion",
  promptTable: "gpt_prompts",
  prompts: {
    // системный слой (табличный)
    system: ["$avatar_brief_generator"],

    // основной запрос (inline)
    user: [`
Create a vertical codex-grade mythotech Operator icon in Aurum Void aesthetic.

Aurum Void: cold matte gold schematic lines (axes, nodes, ritual UI glyphs) over deep graphite void.
No glossy sci-fi, no neon, no superhero vibe, no humor.

Faceless figure (hood/shadow/mask/void), calm, stable, centered on a strong vertical axis.
Heavy materials: carbon composite armor/robe, matte metal, dense fabric, worn realistic textures.
Subtle fog/particles for depth.

This is NOT a human portrait. It is an operational manifestation of a system entrepreneur / product leader at the scaling stage.
Output: a visual artifact, not an illustration.
    `.trim()]
  },

  // Таймаут (fallback)
  timeout: {
    seconds: 120,
    script: "Orion_Image_Timeout"
  },

  // Ошибки (как в LLMQuery)
  error: {
    script: "Orion_Image_Error",
    flagAttr: "orion_image_error",
    reasonAttr: "orion_image_error_reason"
  },

  // Namespace чтобы не конфликтовать
  fallback: {
    namespace: "orion_image_reflection"
  },

  // MVP: хотим именно URL
  requireUrl: true,

  image: {
    model: "dall-e-3",
    size: "1024x1792",
    quality: "standard",
    style: "natural",
    response_format: "url"
  },

  messages: {
    wait: "🜁 ORION формирует визуальное отражение…",
    processing: "⏳ Подожди, образ ещё куется…",
    error: "⚠️ Не удалось получить изображение"
  },

  save: {
    urlAttr: "orion_actor_image_url",
    b64Attr: "orion_actor_image_b64",
    rawJsonAttr: "orion_actor_image_payload"
  }

  // successScript: "Orion_Image_Ready" // опционально
});

Пример 2 — минимальный сценарий с таблицей промптов, без successScript

Подходит, когда вы хотите вернуться “в ту же точку” и решать дальше в текущем шаге.

const ImageGen = require("Common.AI.ImageGen");

return ImageGen.run({
  lead,
  isFirstImmediateCall,

  code: "OperatorIcon",

  provider: "openai",
  auth: { tokenKey: "OPENAI_API_KEY" },

  agentName: "orion",
  promptTable: "gpt_prompts",
  prompts: {
    system: ["$avatar_brief_generator"],
    user: ["Create a vertical mythotech Operator icon in Aurum Void aesthetic..."]
  },

  timeout: { seconds: 90, script: "Image_Timeout" },
  error: { script: "Image_Error" },

  requireUrl: true,

  image: {
    model: "dall-e-3",
    size: "1024x1792",
    response_format: "url"
  },

  save: {
    urlAttr: "operator_icon_url",
    rawJsonAttr: "operator_icon_payload"
  }
});

Пример 3 — внешний контроль timeout через AsyncFallback (продвинутый режим)

Этот способ полезен, если:

const ImageGen = require("Common.AI.ImageGen");
const AsyncFallback = require("Common.Platform.AsyncFallback");

if (isFirstImmediateCall) {
  AsyncFallback.configure({
    lead,
    namespace: "orion_image_reflection",
    timeout: { seconds: 120, script: "Orion_Image_Timeout" },
    error: { flagAttr: "orion_image_error", reasonAttr: "orion_image_error_reason" }
  }).schedule();
}

const res = ImageGen.run({
  lead,
  isFirstImmediateCall,

  code: "OrionActorImage",
  provider: "openai",
  auth: { tokenKey: "OPENAI_API_KEY" },

  agentName: "orion",
  promptTable: "gpt_prompts",
  prompts: {
    system: ["$avatar_brief_generator"],
    user: ["Create a vertical mythotech Operator icon in Aurum Void aesthetic..."]
  },

  // timeout внутри не задаём, потому что контролируем снаружи
  error: {
    script: "Orion_Image_Error",
    flagAttr: "orion_image_error",
    reasonAttr: "orion_image_error_reason"
  },

  requireUrl: true,

  image: {
    model: "dall-e-3",
    size: "1024x1792",
    response_format: "url"
  },

  save: {
    urlAttr: "orion_actor_image_url",
    rawJsonAttr: "orion_actor_image_payload"
  }
});

if (!isFirstImmediateCall && res === true) {
  AsyncFallback.configure({
    lead,
    namespace: "orion_image_reflection"
  }).unschedule();
}

return res;

Частые сценарии отказов и как реагировать


Итог

Common.AI.ImageGen — стандартизированный способ подключить генерацию изображений в сценарии Metabot:

Common.AI.* — Работа с AI-сервисами

Common.AI.Prompts — Универсальный резолвер и сборщик промптов

Автор: Art Yg
Версия: 1.0

Prompts — инфраструктурный helper для унифицированной работы с промптами в сценариях Metabot и внутри других плагинов (например, Common.AI.ImageGen, LLMQuery/LMClient).

Он решает типовую боль: не держать промпты в коде, не копировать одно и то же “склеивание”, и иметь единый механизм:


Зачем он существует

В продуктовых сценариях промпты обычно:

Без Prompts это превращается в дублирование:

Prompts стандартизирует это как маленький инфраструктурный слой, который можно подключать где угодно.


Основные принципы


Минимальные требования

Для работы в “inline-режиме” достаточно:

  1. lead
  2. вызвать Prompts.buildText(...)

Для работы с табличными ссылками ($... / @...) дополнительно нужно:

  1. наличие table.find в runtime
  2. таблица промптов (promptTable)
  3. агент (agentName) — обязателен только для $name

Что умеет Prompts

1) Ссылки на промпты из таблицы

Поддерживаются два типа ref:

Пример:


2) Макросы (переменные из lead и bot)

Prompts умеет подставлять значения из атрибутов:

Поведение:


3) Нормализация входов и сборка блоков

На вход можно дать:

Дальше Prompts собирает:

И может вернуть:


Конфигурация

Все методы используют общие опции.

DEFAULTS

{
  promptTable: "gpt_prompts",
  agentName: null,   // обязателен для "$name"
  strict: true,      // если промпт не найден → throw
  applyMacros: true
}

Важные правила


API Prompts

Prompts.toArray(value)

Нормализует значение в массив строк.

Используется внутри, но можно использовать и снаружи.


Prompts.resolveOne(ref, opts)

Резолвит одну строку:


Prompts.resolveMany(list, opts)

Резолвит список refs/строк → массив строк.


Prompts.applyMacros(str, lead)

Применяет:


Prompts.buildBlocks(input, opts)

Собирает блоки по секциям:

{
  system: [],
  user: [],
  last: [],
  all: []
}

Prompts.buildText(input, opts)

Собирает итоговый prompt как строку:


Табличный формат промптов

Prompts ожидает, что таблица (promptTable) содержит хотя бы поля:

Запрос выполняется через:

table.find(promptTable, ["prompt"], [
  ["agent_name", agent],
  ["name", name]
]);

Использование

Ниже примеры именно “для статьи”: чтобы читатель увидел, что есть несколько режимов и зачем это.


Примеры

Пример 1 — Inline: без таблиц, без агента

Подходит для простых сценариев и MVP.

const Prompts = require("Common.AI.Prompts");

const prompt = Prompts.buildText(
  {
    system: [
      "You are a strict image generator. Output must be cinematic, realistic, and calm."
    ],
    user: [
      "Create a vertical mythotech Operator icon in Aurum Void aesthetic."
    ]
  },
  { lead } // agentName/promptTable не нужны
);

// prompt — готовая строка, без обращений к таблицам

Пример 2 — Табличный промпт агента: $alias

Если используешь $..., то agentName обязателен, иначе будет throw.

const Prompts = require("Common.AI.Prompts");

const prompt = Prompts.buildText(
  {
    system: [
      "$avatar_brief_generator" // берём из таблицы для агента orion
    ],
    user: [
      "Output should be a codex-grade artifact. No neon. No superhero vibe."
    ]
  },
  {
    lead,
    agentName: "orion",
    promptTable: "gpt_prompts"
  }
);

Пример 3 — Общий промпт: @alias (agentName не нужен)

@name всегда читается из агента <<common>>.

const Prompts = require("Common.AI.Prompts");

const prompt = Prompts.buildText(
  {
    system: [
      "@safety_rules",
      "@style_aurum_void"
    ],
    user: [
      "Create an abstract Operator icon."
    ]
  },
  {
    lead,
    promptTable: "gpt_prompts"
  }
);

Пример 4 — Макросы: подтягиваем контекст из lead и bot

const Prompts = require("Common.AI.Prompts");

// допустим:
// lead.getAttr("actor_stage") = "scaling"
// bot.getAttr("BRAND_TONE") = "discipline, meaning, depth"

const prompt = Prompts.buildText(
  {
    system: [
      "Tone: {{$$BRAND_TONE}}"
    ],
    user: [
      "Stage: {{$actor_stage}}",
      "Create a vertical Operator artifact."
    ]
  },
  {
    lead,
    applyMacros: true
  }
);

Пример 5 — Сборка blocks отдельно (для отладки/логирования)

Иногда полезно видеть, какие блоки получились до склейки.

const Prompts = require("Common.AI.Prompts");

const blocks = Prompts.buildBlocks(
  {
    system: ["@style_aurum_void", "$avatar_brief_generator"],
    user: ["Generate an icon for current stage: {{$actor_stage}}"]
  },
  {
    lead,
    agentName: "orion",
    promptTable: "gpt_prompts"
  }
);

// blocks.system / blocks.user / blocks.all — можно сохранить в lead или tracer

Пример 6 — Мягкий режим (strict=false)

Иногда нужен режим “не падать”, а вернуть сообщение/заглушку.

const Prompts = require("Common.AI.Prompts");

const prompt = Prompts.buildText(
  {
    system: ["$missing_alias"],
    user: ["Create an icon."]
  },
  {
    lead,
    agentName: "orion",
    promptTable: "gpt_prompts",
    strict: false
  }
);

// В strict=false при отсутствии промпта вернётся текст-предупреждение (а не throw)

Использование внутри других плагинов

Common.AI.Prompts специально сделан как “маленький кусок инфраструктуры”, чтобы:

Где он уже естественно применяется


Частые ошибки и как их избежать

1) Использовали $alias, но не указали agentName

Это ошибка по контракту, будет throw:

Решение: передай agentName.


2) Использовали $alias / @alias, но не указали promptTable

Это тоже ошибка:

Решение: передай promptTable (или оставь дефолт "gpt_prompts").


3) Хотели “просто текст”, но случайно начали строку с $

Если текст реально должен начинаться с $, то сейчас это будет воспринято как ref.

Практический паттерн: не начинай “сырой текст” с $/@. Если прям надо — лучше добавить пробел или явную экранировку на уровне твоего контента.


Итог

Common.AI.Prompts — это базовый инфраструктурный helper, который:

Он может использоваться:

Если в твоей архитектуре дальше появятся новые источники промптов (реестр провайдеров, JSON-конфиги, версии промптов, AB-тесты) — вот этот слой и будет правильным местом для расширения. И это как раз тот случай, где можно внезапно сделать себе ловушку, если начать “подмешивать” сюда бизнес-логику — держи его инфраструктурным, иначе потом будет боль.

Компонентная разработка vs «Durex-код»

📘 Памятка инженера Metabot

1️⃣ В чём разница подходов

Скриптовый подход Компонентный подход
Код вставляется прямо в сценарий Сценарий использует готовый компонент
Telegram-логика торчит в webhook-скрипте Telegram спрятан внутри плагина
Сценарист не может этим пользоваться Сценарист работает декларативно
Нет чёткого контракта Есть входы, выходы, параметры
Разовое решение Переиспользуемый модуль
Зависит от конкретного проекта Доставляется в любой проект

2️⃣ Что такое «Durex-код»

Durex-код — это:

Это не плохо. Иногда это допустимо.

Но это не архитектурный стиль Metabot.


3️⃣ Что такое компонентный стиль Metabot

Компонент — это:

Пример:

const VoiceInput = require('Common.Voice.VoiceInput')

return VoiceInput.expect({
  code: "orion_profiling_q1_voice",

  lead,

  successScript: "orion_profiling_q2",
  cancelScript: "orion_profiling_cancelled",

  targetAttr: "orion_profiling_q1_text",
  sourceAttr: "orion_profiling_q1_voice_url",

  extraAttrs: {
    active_agent: "orion",
    voice_context: "orion_profiling_q1",
    input_mode: "profiling"
  },

  processorScript: "System_VoiceInput_Processor",

  stt: {
    provider: "openai",
    options: { model: "whisper-1", language: "ru" },
    asyncResponse: true,
    tokenKey: "OPENAI_API_KEY"
  }
})

Сценарист видит:

Он не видит:

Это декларативный стиль.


4️⃣ Что такое декларативный подход

Декларативный подход — это:

Мы описываем ЧТО должно произойти, а не КАК это происходит.

Сценарист пишет:

Ожидай голос.
Перейди в success.
Сохрани результат.

А не:

Проверь payload.message.voice
Достань file_id
Сходи в Telegram API
Отправь в STT
Подожди callback
Распарси JSON

5️⃣ Принципы компонентной разработки Metabot

1. Чёткий контракт

Каждый компонент обязан иметь:


2. Нет неявной передачи данных

❌ Плохо:

memory.foo = 123

Потом в другом месте:

memory.foo

Это разрыв контракта.

✔ Правильно:


3. Одна зона ответственности

Пример:

VoiceInput:

ArtifactResolver:


4. Никаких «всё в один файл»

Если логика растёт — создаём слой.

Пример:

Это инженерный подход.


5. Повторное использование — обязательное требование

Если модуль нельзя переиспользовать — это не компонент.

Компонент должен быть:


6️⃣ Почему это важно

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

И самое интересное:

⏱ По времени это занимает примерно столько же.

Разница — в мышлении.


6.1 🔓 Декларативный стиль = расширяемая палитра Metabot

Декларативный подход — это не только «красиво».

Это позволяет нам:

Сейчас в Metabot есть ~21 атомарная команда.

Примеры:

Каждый компонент:

Это позволяет:

1️⃣ Легко расширять платформу Добавили новый компонент → он сразу становится частью палитры.

2️⃣ Делать визуальные конструкторы Если у компонента есть вход/выход — можно рисовать схемы.

3️⃣ Строить workflow-системы Компоненты становятся узлами графа.

4️⃣ Делать экспортируемые решения Компонент можно доставить в другой проект.


6.2 🧱 Атомарность = возможность визуализации

Если компонент описан как:

его можно:

Если код:

его нельзя:


7️⃣ Архитектор vs Скриптовик

Скриптовик думает:

Мне нужно сделать, чтобы работало.

Архитектор думает:

Как сделать, чтобы это работало в 10 проектах.


8️⃣ Как использовать это как AI-чеклист

Можно кидать в AI вместе с кодом и спрашивать перед коммитом:

Если на 3+ вопроса ответ «нет» — ты пишешь Durex.


9️⃣ Когда допустим Durex-код

Иногда допустимо:

Но:


1️⃣0️⃣ Когда допустимо писать код в скрипте

Код в скрипте допускается.

Но только если соблюдены условия.


✔ Допустимые случаи

1️⃣ Абсолютно разовая логика

Пример:

if (lead.getAttr("score") > 10) {
  return bot.run({ script_code: "vip_branch" })
}

Это допустимо.


2️⃣ Логика понятна сценаристу

Если сценарист:

— код допустим.

Если сценарист не может понять код — код не должен находиться в сценарии.

Исключение — системные вещи без которых абсолютно нельзя обойтись, например, вызов плагинов.


3️⃣ Логика не нарушает архитектуру

Допустимый код:


❌ Недопустимый Durex-код

Код нельзя писать в сценарии, если:

Такой код должен быть:


1️⃣1️⃣ Финальная мысль

Metabot — это не набор скриптов.

Это:

Если ты пишешь код, который нельзя вынести в палитру — ты... выпускаешь Durex.

Если ты пишешь код, который расширяет палитру — ты работаешь как инженер платформы.

P.S. Почему «Durex»?

Компонент — это капитал: инвестиция и актив, который ставится на баланс. Его можно переиспользовать, масштабировать и доставлять в десятках проектов — он создаёт системную ценность.

А разовый скрипт — это расход периода: проект сделали, прибыль получили — но актива не появилось. Работу сделали один раз, в следующем проекте — делаем то же самое заново. Все при деле, задача выполняется, но капитал не создаётся.

HTTP

HTTP

Common.HTTP.ProxyFetch — загрузка URL-контента через прокси

Пакет: Http
Полное имя компонента: Common.Http.ProxyFetch

Что это

ProxyFetch — системный PHP-компонент (V8Wrapper) для загрузки содержимого по HTTP/HTTPS URL внутри сценариев Metabot.

Проще всего воспринимать так:

это аналог file_get_contents($url), но с поддержкой прокси, таймаутов и повторных попыток, на том же cURL-стеке, что используется для надёжных загрузок в платформе (CdnFtp).

Зачем нужен ProxyFetch

ProxyFetch даёт одну точку входа для сценариев и плагинов: загрузить строку по URL или получить метаданные (HEAD) с теми же правилами отказоустойчивости.

Два контракта: generic fetch vs файловая загрузка

В платформе два разных метода с разной семантикой успеха. Не путайте их:


fetchUrlContents() / ProxyFetch downloadFileFromUrlToBusiness()
Назначение Получить тело ответа в память Скачать и сохранить файл на диск
ok / успех curl_exec !== false и http_code > 0 Только 2xx + non-empty body
404 ok=true, http_code=404by design Ошибка, return null
Пустой body Допустим (204, HEAD-like API) Ошибка
Retry cURL error / http_code=0 / 5xx cURL error / http_code=0 / не-2xx / пустой body

Важно: если кто-то «оптимизирует» один контракт под другой — сломает либо плагины, либо загрузку файлов.

Где используется

Где находится компонент

Подключение в JavaScript-сценарии (V8):

const ProxyFetch = require("Common.Http.ProxyFetch")

PHP-класс плагина:

Plugins\Dynamic\Common\Http\V8Wrapper\ProxyFetch

Платформенное ядро (можно вызывать из PHP-плагинов без V8):

App\Services\CdnFtp::fetchUrlContents()
App\Services\CdnFtp::getFileInfoByUrl() (расширенная сигнатура с прокси и ретраями)

Как работает ProxyFetch

Архитектура из трёх слоёв:

V8 JS  →  Common.Http.ProxyFetch  →  mergeWithDefaults (http_outbound + options)
       →  CdnFtp::fetchUrlContents / getFileInfoByUrl
       →  CdnFtp::curlExecWithFallback / curlExec  (общий cURL-раннер)
       →  cURL (прокси, таймауты, SSL, exec, диагностика)

Общий раннер: curlExec() — единая точка init/proxy/timeout/SSL/exec/close; curlExecWithFallback() — обёртка с href_encode(url) → fallback на raw URL. Переиспользуется в fetchUrlContents и getFileInfoByUrl.

Ретраи: отдельные «раунды»; в каждом раунде curlExecWithFallback перебирает варианты URL. Повтор, если curl_exec дал сбой, http_code === 0 или http_code >= 500.

Логирование: при HTTP_OUTBOUND_LOG_REQUESTS=true:

Что нужно настроить

1. Конфиг config/http_outbound.php

Загружается автоматически; значения берутся из .env.

2. Переменные .env

Переменная Назначение
HTTP_OUTBOUND_PROXY Строка прокси (например socks5://login:password@host:port). Пусто — прокси не задаётся.
HTTP_OUTBOUND_PROXY_VERSION Опционально: v4 или v6 (разрешение DNS/IP для cURL).
HTTP_OUTBOUND_TIMEOUT Таймаут запроса, сек (по умолчанию 30).
HTTP_OUTBOUND_CONNECT_TIMEOUT Таймаут на установку соединения, сек (по умолчанию 10).
HTTP_OUTBOUND_RETRIES Число раундов попыток (по умолчанию 3).
HTTP_OUTBOUND_RETRY_AFTER_SEC Пауза между раундами, сек (по умолчанию 2).
HTTP_OUTBOUND_LOG_REQUESTS true/false — писать диагностические строки в лог (по умолчанию true).

Важно: эта группа независима от botman.telegram. Если на проекте один прокси на всё, можно задать одинаковые значения в обеих группах; если прокси разные — настраивайте раздельно.

3. Установка плагина на инстансе

Файл уже в Plugins/Dynamic/Common/Http/V8Wrapper/ProxyFetch.php. Автозагрузка через SPL autoload в ModuleLoader — дополнительных действий не требуется.

Создание плагина в админке

Если плагин не поставляется из репозитория, создайте его вручную:

  1. Плагин: имя каталога/пакета Http, тип стандартный, Common, уровень доступа SYSTEM.
  2. Скрипт: имя ProxyFetch, тип PHP (WRAPPER FOR V8).
  3. Вставьте код из файла Plugins/Dynamic/Common/Http/V8Wrapper/ProxyFetch.php (namespace и имя класса должны совпадать).
  4. Включите скрипт (is_enabled).

Платформа сохранит PHP-файл в структуру динамических плагинов; ModuleLoader через SPL autoload видит класс автоматически.

Сигнатура вызова (JavaScript / V8)

getContents

const ProxyFetch = require("Common.Http.ProxyFetch")

let result = ProxyFetch.getContents("https://example.com/data.csv")

if (!result.ok) {
  debug("Ошибка загрузки: " + result.last_curl_error)
  return false
}

let text = result.content

С переопределением параметров:

let result = ProxyFetch.getContents("https://example.com/big.bin", {
  proxy: "socks5://user:pass@proxy.example:1080",
  timeout: 120,
  connect_timeout: 15,
  max_attempts: 5,
  retry_after_sec: 3
})

getFileInfo

let info = ProxyFetch.getFileInfo("https://example.com/photo.jpg")

if (info.exists && info.size_kb < 20480) {
  // условно безопасный размер
}

Параметры options

Поле Тип Обязателен Описание
proxy string Нет Переопределить прокси для этого вызова
timeout number Нет Таймаут cURL, сек (дефолт из http_outbound.timeout)
connect_timeout number Нет Таймаут соединения, сек
max_attempts number Нет Число раундов ретраев (http_outbound.retries)
retry_after_sec number Нет Пауза между раундами
log_requests boolean Нет Логировать попытки (http_outbound.log_requests)

Формат ответа getContents

Поле Тип Описание
ok boolean Транспорт доставил ответ: curl_exec !== false и http_code > 0
content string | null Тело ответа; null при транспортном провале
http_code number HTTP-код последнего ответа
attempts number Сколько раундов было выполнено
last_curl_error string Текст ошибки при ok === false; при успехе — пустая строка

Про 404: при ответе 404 платформа вернёт ok === true и тело страницы ошибки — всегда проверяйте http_code, если вам нужен именно успешный ресурс. Это by design: generic fetch не навязывает семантику HTTP-статусов.

Формат ответа getFileInfo

Прежние поля:

Добавлены:

Использование из PHP (без V8)

use App\Services\CdnFtp;

$result = CdnFtp::fetchUrlContents(
    $url,
    config('http_outbound.proxy'),
    (int) config('http_outbound.timeout', 30),
    (int) config('http_outbound.connect_timeout', 10),
    (int) config('http_outbound.retries', 3),
    (int) config('http_outbound.retry_after_sec', 2),
    (bool) config('http_outbound.log_requests', true)
);

if (!$result['ok']) {
    // логировать $result['last_curl_error']
}

HEAD / метаданные:

$info = CdnFtp::getFileInfoByUrl(
    $url,
    config('http_outbound.proxy'),
    (int) config('http_outbound.connect_timeout', 10),
    (int) config('http_outbound.timeout', 30),
    (int) config('http_outbound.retries', 3),
    (int) config('http_outbound.retry_after_sec', 2)
);

Одноаргументный вызов CdnFtp::getFileInfoByUrl($url) сохраняет старое поведение (без таймаутов из http_outbound).

Пример замены file_get_contents в плагине

Было:

$data = file_get_contents($url);

Стало (через ядро):

$fetch = \App\Services\CdnFtp::fetchUrlContents(
    $url,
    config('http_outbound.proxy'),
    (int) config('http_outbound.timeout', 30),
    (int) config('http_outbound.connect_timeout', 10),
    (int) config('http_outbound.retries', 3),
    (int) config('http_outbound.retry_after_sec', 2)
);
if (!$fetch['ok']) {
    throw new \RuntimeException($fetch['last_curl_error'] ?: 'fetch failed');
}
$data = $fetch['content'];

Обработка ошибок

Ситуация Что ожидать
Таймаут ok === false, в last_curl_error часто cURL error 28: ...
Прокси недоступен ok === false, типично cURL error 7: ...
5xx после ретраев ok === false, last_curl_error содержит HTTP 503 и т.д.
404 ok === true, http_code === 404 — проверяйте код
Невалидный URL / пустая строка ok === false, last_curl_error поясняет причину

Сетевую ошибку и «битый» контент (HTML вместо CSV) различайте по http_code и валидации содержимого в сценарии.

Как отлаживать

  1. .env — заданы ли HTTP_OUTBOUND_PROXY, таймауты и ретраи.
  2. Ручной curl с того же сервера (с тем же прокси, если нужен) — доступен ли URL.
  3. Логи приложения — строки CdnFtp outbound fetch: ... / CdnFtp outbound fileInfo: ... при включённом HTTP_OUTBOUND_LOG_REQUESTS.
  4. result.last_curl_error / info.last_curl_error — конкретная причина отказа.
  5. http_code — отличить 404/403 от обрыва соединения.
  6. attempts — сколько раундов реально выполнено (ретраи работают).

FAQ

Можно ли задать другой прокси только для одного вызова?
Да, передайте proxy в объекте options.

Как отключить ретраи?
max_attempts: 1 в options или HTTP_OUTBOUND_RETRIES=1.

Чем отличается от bot.downloadFileFromUrl()?
Скачивание в сценарий бота кладёт файл в хранилище бизнеса. ProxyFetch / fetchUrlContents возвращают строку в памяти, без сохранения на диск платформы. Кроме того, семантика успеха разная: download требует 2xx, fetch допускает 404.

Работает ли без прокси?
Да: если HTTP_OUTBOUND_PROXY пуст и в options не передан proxy, используется прямой cURL.

Нужен ли отдельный токен или callback?
Нет, это синхронный HTTP-запрос из PHP, без webhook'ов.

Что дальше

Связанные материалы

Стандарт компонентной и плагинной разработки Metabot v1.0

Инженерные принципы разработки плагинов и модулей Metabot

Зачем это нужно

Metabot развивается как платформа, а не как набор разрозненных скриптов, временных обходов и локальных helper-классов. Поэтому для нас критично не просто “написать рабочий код”, а делать это так, чтобы решения можно было повторно использовать, безопасно развивать, тестировать, документировать и передавать между разработчиками без потери смысла.

Этот стандарт нужен, чтобы:

Главная идея простая: мы не пишем код “под случай”, мы строим расширяемую инженерную среду.


1. Сначала короткий spec, потом код

Принцип

Любая новая платформенная доработка начинается с короткого спека: контекст, цель, границы изменения, ограничения, критерии приёмки.

Почему это важно

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

Плохо

“Надо быстро подправить два места, чтобы заработало.”

Хорошо

“Нужно ввести общий outbound HTTP-примитив с proxy/retry и перевести на него конкретные точки, не ломая старые одноаргументные вызовы.”

Пример

Если появляется системный модуль для исходящих HTTP-запросов, он должен начинаться не с куска кода, а со спека: где он используется, что считается успехом, какие ошибки retryable, как он доставляется в V8 и как тестируется.


2. Один модуль — одна причина к изменению

Принцип

У класса, сервиса или плагина должна быть одна понятная причина измениться.

Почему это важно

Если модуль одновременно:

то он уже не является компонентом. Это комбайн. Такие модули плохо тестируются, плохо переиспользуются и становятся центром роста техдолга.

Плохо

Один Request-класс, который “умеет всё”.

Хорошо

Отдельные компоненты:

Пример

Компонент голосового ввода должен отвечать за голосовой ввод, а не одновременно за Telegram payload, загрузку аудио, транскрибацию, файловое хранилище и обработку бизнес-сценария.


3. У каждого компонента должен быть явный контракт

Принцип

У компонента должны быть:

Почему это важно

Компонент без контракта быстро превращается в “магическую штуку”, которую кто-то когда-то написал, а остальные боятся трогать. Это ломает повторное использование и делает развитие платформы случайным.

Плохо

Метод с названием getFileInfoByUrl(), который в одних случаях только возвращает метаданные, а в других ещё скачивает файл, создаёт temp file и вычисляет MIME по содержимому.

Хорошо

Пример

Событие ticket_status_changed должно явно документировать доступные поля, а не оставлять разработчика угадывать, есть ли там предыдущий статус, текущий статус или только ticket_id.


4. Неявная передача данных запрещена

Принцип

Компоненты не должны зависеть от скрытых связей, случайных значений в памяти, жёстко заданных путей, доменов, записей в БД или “магии рантайма”, если это явно не является частью контракта.

Почему это важно

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

Плохо

Хорошо

Пример

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


5. Канальная логика должна быть спрятана внутри компонента

Принцип

Сценарии и верхнеуровневая логика должны описывать что происходит, а не вручную реализовывать Telegram, Max, webhook-переходы, форматы multipart или повторные попытки запросов.

Почему это важно

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

Плохо

Сценарий сам знает, как устроен Telegram file API, как формируется URL, как грузится файл через прокси и как повторять запрос.

Хорошо

Сценарий обращается к компоненту:

Пример

Если платформа начинает работать и с Telegram, и с Max, и с другими каналами, различия между ними должны жить в адаптерах и компонентах, а не размазываться по V8-скриптам.


6. Общий платформенный код не должен зависеть от конкретного проекта

Принцип

Если код претендует на статус общего системного компонента, он должен быть переносимым и пригодным для повторного использования.

Почему это важно

Компонент, который работает только в одном проекте, на одном домене, в одном окружении или с одним bot ID, не является компонентом платформы. Это проектный helper.

Плохо

Хорошо

Пример

Общий HTTP-модуль — хороший кандидат на платформенный слой. Утилита, которая пишет файлы только в конкретную папку конкретного инстанса, — нет.


7. Legacy не расширяем дальше как новый стандарт

Принцип

Если старый модуль уже используется во многих местах, это не значит, что его нужно продолжать развивать как основной архитектурный центр.

Почему это важно

Legacy-код может быть полезен как compatibility layer, но если в него продолжать складывать новые обязанности, он становится точкой системного разложения.

Плохо

Ради новой задачи расширять старый helper ещё сильнее, потому что “он и так уже везде используется”.

Хорошо

Пример

Если есть старый модуль, который уже умеет и HTTP, и файлы, и CSV, и multipart, это не повод использовать его как базу для новой платформенной логики. Это повод остановить его рост и начать выносить отдельные системные примитивы.


8. Сначала системный примитив, потом точечная миграция

Принцип

Повторяющиеся инженерные проблемы должны решаться один раз как системный примитив, а не много раз в разных helper-классах.

Почему это важно

Когда прокси, retry, таймауты, fallback-логика и диагностика реализуются в разных местах по-разному, это не ускорение, а размножение будущих багов.

Плохо

Хорошо

Пример

Если проблема в file_get_contents без прокси возникает в нескольких местах, правильное решение — не “пропатчить ещё одну точку”, а ввести платформенный способ исходящих HTTP-запросов и постепенно перевести туда нужные вызовы.


9. Границы контекстов должны быть названы

Принцип

У каждого модуля и сервиса должен быть понятный контекст: о чём он и о чём он не должен знать.

Почему это важно

Без границ всё начинает прилипать ко всему. HTTP начинает знать про файлы, файлы — про каналы, каналы — про бизнес-правила, а бизнес-правила — про способы доставки JS-модулей.

Плохо

Один модуль сразу “про интеграции”, “про файлы”, “про экспорт”, “про CSV”, “про загрузку”, “про политики” и “про события”.

Хорошо

Отдельные контексты:

Пример

ticket_status_changed — это контекст событий и контрактов. ProxyFetch — это контекст исходящего HTTP. downloadFileFromUrlToBusiness() — это контекст скачивания и сохранения файла. Смешивать это в одну сущность нельзя.


10. Любая платформенная фича обязана приехать вместе с четырьмя хвостами

Принцип

Код без доставки и сопровождения не считается завершённой фичей.

Минимальный комплект

Почему это важно

Если код есть в git, но:

то это не готовая функциональность, а полуфабрикат.

Пример

Новый системный модуль должен:


11. Перед началом реализации команда должна ответить на пять вопросов

Обязательные вопросы

  1. Какова цель изменения?
  2. Где проходит граница модуля или сервиса?
  3. Какие контракты меняются?
  4. Какие инварианты нельзя нарушить?
  5. Как проверяется результат?

Почему это важно

Если на эти вопросы нет ответа, значит команда ещё не проектирует систему, а только реагирует на симптомы.

Пример

Если в событие смены статуса добавляется previous_status_id, это не “маленькая доработка”. Это изменение event contract. Значит, нужно заранее понять:


Практические антипаттерны

Антипаттерн: “универсальный helper”

Признаки

Что делать


Антипаттерн: “разовый фикс становится стандартом”

Признаки

Что делать


Антипаттерн: “доставка не доведена”

Признаки

Что делать


Merge-checklist для платформенного кода

Перед merge каждый новый платформенный модуль должен пройти проверку:

  1. Есть ли короткий spec?
  2. Понятна ли одна причина к изменению?
  3. Есть ли явный контракт входов и выходов?
  4. Нет ли скрытых зависимостей или жёсткого хардкода?
  5. Не смешаны ли несколько контекстов в одном модуле?
  6. Это системный примитив или проектный helper?
  7. Не развиваем ли мы legacy-комбайн вместо нового слоя?
  8. Есть ли docs, changelog и stage-сценарий проверки?

Итоговая позиция

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

Наша цель — не просто ускорить написание кода, а построить среду, в которой:

Каждая новая доработка должна отвечать не только на вопрос “как это сделать”, но и на вопрос “где это должно жить как часть системы”.