@retensy/mcp 0.30.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -50,14 +50,14 @@ MCP-сервер (+ скилл для Claude Code) для **сборки и пу
50
50
 
51
51
  - 🤖 **Сценарии ботов**: `list_bots`, `list_graphs`, `list_channels`, `get_graph`, `create_graph`, `update_graph`, `edit_graph_live`, `patch_graph`, `dry_run`, `publish_graph`, `unpublish_graph`, `import_funnel`, `list_templates`, `create_graph_from_template`, `clone_graph`, `copy_graph`, `rename_graph`, `set_active_graph`, `delete_graph`, `upload_file`, `list_files`, `delete_file`, `graph_analytics`, `list_bot_users`, `list_links` (+ `setup`/`set_token`).
52
52
  - 🔌 **Боты и сервисы**: `create_bot` (Telegram/MAX по токену), `bot_stop`/`bot_resume`, `connect_integration`/`disconnect_integration` (amoCRM, Битрикс24, GetCourse, Я.Метрика, ЮKassa; Google Таблицы — ссылкой на вход Google; креды хранятся зашифрованными и не возвращаются), `integration_catalog`/`integration_status`/`integration_test` (каталог Integration Core и живая проверка подключения), `integration_ingress_url` (URL ингресса «Внешних событий» — Tilda, JivoSite, WooCommerce, amoCRM, Битрикс24, GetCourse, CloudPayments, Robokassa, Т-Банк, Prodamus — для `TRIGGER_WEBHOOK`), `channel_post` (пост в канал Telegram/MAX).
53
- - 📣 **Рассылки**: `broadcast_preview` (размер аудитории), `broadcast_send` (сейчас или по расписанию, сразу по нескольким ботам; сообщения или запуск сценария; полный фильтр аудитории `audienceFilter`, дедуп `dedup` и распределённый запуск `stagger` по ботам), `broadcast_list`/`broadcast_get`/`broadcast_cancel`, повторяющиеся (`broadcast_recurring`), черновики (`broadcast_drafts`, `broadcast_duplicate`), мульти-бот группы (`broadcast_group` — preview/list/get/pause/resume/cancel) + скилл `send-broadcast`.
53
+ - 📣 **Рассылки**: `broadcast_preview` (оценка охвата до отправки), `broadcast_send` (сейчас или по расписанию, сразу по нескольким ботам; сообщения или запуск сценария; полный фильтр аудитории `audienceFilter` или сохранённый сегмент `segmentId`, дедуп `dedup`, распределённый запуск `stagger` по ботам, `localTime` - в HH:mm по часам каждого подписчика), `broadcast_test` (тест себе), `audience_segments` (сохранённые сегменты), `bot_delivery_window` (тихие часы бота), `broadcast_list`/`broadcast_get`/`broadcast_cancel`, повторяющиеся (`broadcast_recurring`), черновики (`broadcast_drafts`, `broadcast_duplicate`), мульти-бот группы (`broadcast_group` — preview/list/get/pause/resume/cancel) + скилл `send-broadcast`.
54
54
  - 👥 **Аудитория подписчиков**: `audience_search`/`audience_fields` — кросс-бот поиск и фильтр (теги, даты, статус, условия по переменным), `audience_tag_edit`/`audience_variable_edit` — массовое редактирование тегов/переменных, `audience_export` — потоковая выгрузка выборки в CSV/XLSX, `dialog_search` — поиск по «Диалогам» (включая текст сообщений).
55
55
  - 📤 **Миграция подписчиков** из других конструкторов: `migration_presets`/`migration_preview`/`migration_commit` — generic CSV column-mapper для BotHelp/SaleBot/Senler/Leadteh/Smartsender/ManyChat (переносятся только подписчики — ни один из них не публикует документированный формат экспорта воронки).
56
56
  - 📅 **Запись на слоты**: `booking_calendar_*` (буферы до/после брони, горизонт записи, типы встреч своей длительности, форма клиента, публичная страница `/book/<slug>`), `booking_confirm`/`booking_reject` — ручное подтверждение брони (`confirmationMode:"MANUAL"`).
57
57
  - 📰 **Публикации и VK**: `publications_usage` — месячная квота (общая для Telegram/MAX/Instagram/VK), `vk_status`/`vk_connect`/`vk_disconnect`/`vk_post` — подключение VK-сообщества токеном и публикация на стену.
58
58
  - 🔗 **Ссылка вместо отказа**: что нельзя сделать через API (вход через Google/Instagram, оплата тарифа, вход в аккаунт) — инструмент возвращает прямую ссылку и одну строку, что сделать.
59
59
  - 📝 **Статьи блога** (тот же токен `zmcp_…`): `article_publish`, `article_update`, `article_list`, `article_get` — публикация статей в Markdown (как README на GitHub) в раздел **/articles**.
60
- - 🧠 **ИИ-агенты и база знаний**: `agent_list`/`agent_get`/`agent_create`/`agent_update`/`agent_publish`/`agent_health`/`agent_test_chat`/`agent_unanswered`, `kb_docs`/`kb_add_qa`/`kb_add_text`/`kb_add_site`/`kb_reindex` — настройка ИИ-агента и его базы знаний (раздел «ИИ-агенты»).
60
+ - 🧠 **ИИ-агенты и база знаний**: `agent_list`/`agent_get`/`agent_create`/`agent_update`/`agent_publish`/`agent_health`/`agent_test_chat`/`agent_unanswered`, `agent_tool_calls`, `kb_docs`/`kb_add_qa`/`kb_add_text`/`kb_add_site`/`kb_reindex` — настройка ИИ-агента и его базы знаний (раздел «ИИ-агенты»).
61
61
  - 📎 **Медиа**: `upload_file` грузит фото/видео/документы в библиотеку **/bots/files** (до 50 МБ) и возвращает публичный URL — его вставляешь в медиа-карточку сценария.
62
62
  - 🌐 **Сайты из блоков** (раздел «Страницы»): создание, правка операциями (блоки, Zero-блок со свободной вёрсткой, код блока, папки страниц, дизайны, шаблоны из библиотеки), публикация и откат, свои домены, заявки из форм и куда их доставлять — инструменты `site_*` + скилл `build-site`.
63
63
  - 🧠 **Скилл `build-bot-funnel`**: учит агента собирать корректный граф (типы узлов, ветки, кнопки, задержки) и проверять его перед публикацией. Поддерживает Telegram, MAX и Instagram.
@@ -206,6 +206,8 @@ MCP-сервер (+ скилл для Claude Code) для **сборки и пу
206
206
  | `dry_run(graphId, kind, value)` | прогон без публикации |
207
207
  | `publish_graph(graphId, secondary?)` | публикация (вернёт `errors[]` при провале); `secondary: true` — дополнительный сценарий рядом с основным (запускают VK-лендинги и ссылки с `targetGraphId`) |
208
208
  | `unpublish_graph(graphId)` | снять дополнительный сценарий с публикации (основной — 409) |
209
+ | `graph_versions_list(graphId, [version], [compareTo])` | история версий сценария (снимки при публикации, правках живого графа, ручные); с `version:N` — сравнение с текущим графом или другой версией (`compareTo`). Право `read` |
210
+ | `graph_version_rollback(graphId, version)` | откат сценария на версию = публикация снимка (проверки как у `edit_graph_live`, текущее состояние сохраняется отдельной версией). Право `write:bots` |
209
211
  | `import_funnel(botId, name, graphFile\|graph)` | всё за раз: create → update → dry-run → publish |
210
212
  | `list_templates()` | готовые шаблоны воронок |
211
213
  | `create_graph_from_template(botId, templateId, name)` | граф из шаблона (DRAFT) |
@@ -233,8 +235,11 @@ MCP-сервер (+ скилл для Claude Code) для **сборки и пу
233
235
  | `site_lead_settings(siteId, settings?)` | куда доставлять заявки: бот уведомлений, почта, вебхук, вебхук-сценарий, amoCRM, «Интеграция» (`coreDelivery {connectionId, kind, params}` из `coreConnections`) |
234
236
  | `broadcast_list(botId?, group?, page?, size?)` | рассылки + счётчики разделов (черновики/запланированные/отправленные/повторы) |
235
237
  | `broadcast_get(broadcastId)` | рассылка целиком: статус, счётчики, сообщения, причины ошибок |
236
- | `broadcast_preview(botIds, tagsAll?, tagsNone?)` | сколько подписчиков получат рассылку |
237
- | `broadcast_send(name, botIds, messages \| graphId, tagsAll?/tagsNone? или audienceFilter?, scheduledAt?, draftId?, dedup?, stagger?)` | отправить сейчас / запланировать; по нескольким ботам; или запуск сценария; `dedup`/`stagger` → мульти-бот группа (`broadcast_group`) вместо прямых рассылок |
238
+ | `broadcast_preview(botIds, tagsAll?, tagsNone?, audienceFilter?, segmentId?)` | оценка охвата: сколько подписчиков получат рассылку |
239
+ | `broadcast_send(name, botIds, messages \| graphId, tagsAll?/tagsNone? или audienceFilter?, scheduledAt?, draftId?, segmentId?, localTime?, dedup?, stagger?)` | отправить сейчас / запланировать; по нескольким ботам; или запуск сценария; `dedup`/`stagger` → мульти-бот группа (`broadcast_group`) вместо прямых рассылок |
240
+ | `audience_segments(action, botId, …)` | сохранённые сегменты аудитории: list / create {name, audienceFilter} / delete {segmentId}; дальше `segmentId` в `broadcast_send`/`broadcast_preview` |
241
+ | `bot_delivery_window(action, botId, …)` | тихие часы бота (по умолчанию выкл): get / set {enabled, quietFrom, quietTo, timezone?}; рассылки в окне переносятся на его конец, пояс - подписчика (переменная `timezone`), иначе бота |
242
+ | `broadcast_test(botId, messages, chatId?)` | тест рассылки себе: на привязанный Telegram владельца или в чат подписчика; мимо квоты и очереди |
238
243
  | `broadcast_cancel(broadcastId)` | отменить запланированную/идущую (для группы — `broadcast_group action:"cancel"`) |
239
244
  | `broadcast_recurring(action, …)` | повторяющиеся рассылки: list / create (DAILY·MONTHLY·YEARLY, те же `audienceFilter`/`dedup`/`stagger`) / stop |
240
245
  | `broadcast_drafts(action, …)` | черновики: list / get / create / update / delete |
@@ -314,7 +319,8 @@ MCP-сервер (+ скилл для Claude Code) для **сборки и пу
314
319
  | `site_analytics_events(siteId, from?, to?, page?, size?)` | сырые события сайта постранично (`PAGE_VIEW`\|`CLICK`\|`FORM_SUBMIT`, UTM) |
315
320
  | `bot_subscriber_analytics(botId, from, to)` | новые подписчики/активность бота по дням (дашборд аналитики, диапазон до 92 дней) |
316
321
  | `scenario_timeseries(graphId, from, to)` | дневной ряд прогонов сценария `{ok, failed, other}` (по узлам — `graph_analytics`) |
317
- | `agent_tool_test(agentId, toolId, params?)` | проверить инструмент агента (`AMO_LOOKUP`/`SHEETS_LOOKUP`/`HTTP_API`) напрямую, без модели — бюджет ИИ не тратится |
322
+ | `agent_tool_test(agentId, toolId, params?, live?)` | проверить инструмент агента напрямую, без модели — бюджет ИИ не тратится. Чтение: `AMO_LOOKUP`/`SHEETS_LOOKUP`/`HTTP_API`. Запись (`AMO_CREATE_LEAD`, `AMO_UPDATE_LEAD`, `AMO_UPDATE_CONTACT`, `SHEETS_APPEND`, `HTTP_POST`, `SET_FIELD`, `ADD_TAG`, `HANDOFF`): по умолчанию песочница (ничего не уходит вовне), `live:true` отправляет по-настоящему |
323
+ | `agent_tool_calls(agentId, toolId?, limit?)` | журнал вызовов инструментов записи агента: кто/когда/аргументы без секретов/результат |
318
324
  | `site_lead_file_download(siteId, leadId, fileId)` | метаданные вложения заявки + ссылка на скачивание (сам файл через MCP не передаётся — HEAD-запрос, не GET) |
319
325
  | `scenario_list()` | сценарии автора, включая вебхук-сценарии (`sourceType: WEBHOOK`\|`BOT`) — в `list_graphs` вебхук-сценарии не входят |
320
326
  | `scenario_create(name, botId?)` | создать сценарий: без `botId` — вебхук-сценарий (сервер сам выпускает путь входа и секрет), с `botId` — бот-сценарий, сразу привязанный к боту |
@@ -476,3 +482,8 @@ RETENSY_MCP_TOKEN=zmcp_... node src/index.mjs # стартует stdio MCP-с
476
482
  ## Лицензия
477
483
 
478
484
  MIT — см. [LICENSE](LICENSE).
485
+
486
+
487
+ ## Инструменты агента на запись
488
+
489
+ `agent_update {patch:{tools:[...]}}` включает агенту инструменты записи по одному: создать/обновить сделку и контакт в amoCRM (через подключение из «Подключений»), добавить строку в Google Таблицу, POST на свой API (SSRF-защита, без редиректов), сохранить поле/тег подписчика, передать оператору. У каждого — JSON-схема аргументов (`argsSchema`), шаблоны `{аргумент}`, подтверждение у пользователя (`confirmBeforeRun`, по умолчанию включено для внешних записей), лимиты на диалог и сутки, идемпотентность. Список проверяется локально до отправки. Проверка — `agent_tool_test` (песочница по умолчанию), журнал — `agent_tool_calls`. Не работает в публичном веб-чате агента.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retensy/mcp",
3
- "version": "0.30.0",
3
+ "version": "0.35.0",
4
4
  "description": "MCP server to build and publish Telegram and MAX bot funnels/automations, broadcasts, service integrations, block-based websites (incl. free-layout Zero blocks, designs, templates, custom domains, form leads) and Markdown blog articles in the Retensy Bots service. Zero dependencies.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,7 +21,7 @@
21
21
  "scripts": {
22
22
  "start": "node src/index.mjs",
23
23
  "check": "node --check src/index.mjs",
24
- "test": "node scripts/smoke.mjs && node scripts/telemetry.test.mjs && node scripts/live-edit-422.test.mjs && node scripts/broadcast-connect.test.mjs && node scripts/integration-core.test.mjs && node scripts/site-init.test.mjs && node scripts/ai-agents.test.mjs && node scripts/battery-tools.test.mjs && node scripts/parity-tools.test.mjs && node scripts/parity-2.test.mjs && node scripts/validate-graph.test.mjs && node scripts/call-scenario.test.mjs && node scripts/audience-publications.test.mjs && node scripts/chains-analytics.test.mjs && node scripts/platform-decisions.test.mjs && node scripts/token-scopes.test.mjs && node scripts/secondary-graphs.test.mjs",
24
+ "test": "node scripts/smoke.mjs && node scripts/telemetry.test.mjs && node scripts/live-edit-422.test.mjs && node scripts/broadcast-connect.test.mjs && node scripts/integration-core.test.mjs && node scripts/site-init.test.mjs && node scripts/ai-agents.test.mjs && node scripts/agent-write-tools.test.mjs && node scripts/battery-tools.test.mjs && node scripts/parity-tools.test.mjs && node scripts/parity-2.test.mjs && node scripts/validate-graph.test.mjs && node scripts/call-scenario.test.mjs && node scripts/audience-publications.test.mjs && node scripts/chains-analytics.test.mjs && node scripts/platform-decisions.test.mjs && node scripts/token-scopes.test.mjs && node scripts/secondary-graphs.test.mjs && node scripts/graph-versions.test.mjs",
25
25
  "validate": "node skills/build-bot-funnel/validate.mjs"
26
26
  },
27
27
  "engines": {
@@ -26,6 +26,7 @@ description: Собрать воронку (сценарий) бота для с
26
26
  - id узлов и рёбер — валидные UUID, уникальные.
27
27
  - Есть хотя бы один корневой `TRIGGER_*`; все нелистовые узлы достижимы от триггера; синхронных циклов нет (цикл только через `ASK_QUESTION`/`DELAY`/`SCHEDULE`).
28
28
  - Кнопки-выборы разведены по хэндлам `btn_N` — у такой `CALLBACK`-кнопки `value` ОБЯЗАТЕЛЬНО пустой (`""`): бот сам сгенерит `callback_data`, непустой `value` ломает переход (`NO_MATCH`). Цвет кнопки — только `""`/`#34C759`/`#FF3B30`. Условия `CONDITION` — `yes`/`no`; вопрос (`ASK_QUESTION` или `SEND_MESSAGE` с `awaitReply:true`) — `valid`/`invalid`.
29
+ - Подписчику нужно выдавать **случайный текст из набора целиком** (история, цитата, совет) → узел `RANDOM_TEXT` (`texts[]`, режимы `send`/`variable`, `noRepeat`), а не база знаний агента: база режется на фрагменты по ~800 знаков, и агент видит только найденные куски.
29
30
  - **Не добавляй узлы `END`** — ветка завершается сама на узле без исходящих рёбер; явный «конец сценария» убран из редактора.
30
31
  - `SEND_MESSAGE` умеет быть и сообщением, и **вопросом** (`awaitReply:true` + `saveTo`/`inputKind`/`validator`). У кнопок-ссылок (`kind:"URL"`) есть флаг `track:true` — на такой шаг ссылается условие `LINK_CLICKED`.
31
32
  - **Медиа** (фото/видео/аудио/документ/кружок/галерея): загрузи файл инструментом `upload_file` (локальный `path` или `url` для перезаливки) → получишь публичный URL → вставь его в медиа-карточку `SEND_MESSAGE` (`url`, у `gallery` — `urls[]`) или в `SEND_PHOTO.photoUrl`. Уже загруженное — `list_files`. Бинарь в графе не хранится, только ссылки. Подробности — в schema.md.
@@ -37,6 +38,7 @@ description: Собрать воронку (сценарий) бота для с
37
38
  - **Приём оплат** — действие `issue_invoice` внутри `ACTIONS` (НЕ отдельный узел: `YOOKASSA_PAYMENT` устарел). Деньги идут на кассу владельца бота; `connectionId` — подключение `provider=YOOKASSA` из `list_integrations`. Два режима, выбирай по задаче:
38
39
  - **дождаться оплаты прямо в диалоге** — протяни от блока ветку `paid` (появятся выходы `paid`/`timeout`); сценарий встанет на паузу до подтверждения кассой;
39
40
  - **не ждать** — ветку `paid` не рисуй, блок просто пришлёт счёт и пойдёт по `next`; выдать доступ потом сможет отдельный сценарий с триггером `TRIGGER_PAYMENT`.
41
+ - **Оплата звёздами Telegram (Stars, XTR)** — действие `issue_stars_invoice` внутри `ACTIONS`, ТОЛЬКО для Telegram-ботов (на MAX/VK/Instagram/WEB публикация даст `STARS_TELEGRAM_ONLY`). Кассы и `connectionId` нет: `{ "kind":"issue_stars_invoice", "amount":"150", "title":"Курс", "description":"Доступ", "timeoutMinutes":60 }` (`amount` — целое 1…10000, `title` до 32 знаков). Выходы и правила те же, что у `issue_invoice`: `paid`/`timeout`, счёт последним в блоке, один счёт на блок (ЮKassa и звёзды вместе тоже нельзя). После оплаты в переменных `{{var.stars_charge_id}}` (нужен для возврата), `{{var.stars_amount}}`, `{{var.stars_paid_at}}`; история и возврат — в кабинете «Платежи звёздами» (/bots/stars). Разрешение `pre_checkout_query` включается автоматически при публикации.
40
42
  Счёт обязан быть **последним** действием в блоке и только одним (`INVOICE_NOT_LAST`, `INVOICE_DUPLICATE`).
41
43
  - **`TRIGGER_PAYMENT` («оплата прошла»)** — если счёт выставлял бот, подписчик определяется САМ (счёт помнит его в metadata платежа): можно сразу слать сообщение и вешать метку, разбирать ответ кассы не нужно. Если оплата пришла мимо бота — прогон headless, получателя задавай через `config.target`. Ставь фильтр `minAmount`/`descriptionContains`, иначе сценарий сработает на любую оплату в кассе.
42
44
  - **`TRIGGER_SCHEDULE` («По расписанию»)** — `{cron:"0 9 * * 1-5", timezone}`: headless-прогон без чата (отправка — только с `config.target`), для сводок, эскалаций, SLA. Журнал — `scenario_runs`.
@@ -70,6 +72,7 @@ description: Собрать воронку (сценарий) бота для с
70
72
  - Почему так: бэкенд при PUT/публикации шлёт `external_update` в WS-комнату → открытые редакторы перечитывают граф **вживую** (юзеру не надо перезаходить). Бот читает активный граф **заново из БД на каждое сообщение** → правка живого PUBLISHED-графа применяется **сразу, без отдельной публикации**.
71
73
  - **Дополнительный сценарий** (отдельный живой граф под VK-лендинг/рекламную ссылку, основной не трогается): `publish_graph(graphId, secondary: true)` публикует граф НА МЕСТЕ (`publishedGraphId` = тот же id, снимка нет, `active` false, `activeGraphId` бота не меняется); проверки те же (422), нужен основной сценарий у бота (`SECONDARY_NO_MAIN`), основной сам дополнительным не делается (`SECONDARY_IS_MAIN`). Его запускают только `vk_landing_create/update` с `targetGraphId` (ответ показывает `targetGraphStatus`: MAIN/SECONDARY/NOT_PUBLISHED/MISSING) и `link_create` с `targetGraphId` (+`targetNodeId` узла в нём). Подписчик стартует с его /start (или первого триггера/блока); ответы на вопросы, кнопки, задержки продолжаются в нём, команды/ключевые слова — по основному, любой старт (/start, /start <код> не на дополнительный, VK «Начать») ведёт в основной (снимая ожидание вопроса). Нельзя в дополнительном: TRIGGER_SCHEDULE, TRIGGER_SITE_FORM, TRIGGER_TG_EVENT, TRIGGER_COMMENT, TRIGGER_WEBHOOK, TRIGGER_IG_* → 422 `SECONDARY_TRIGGER_UNSUPPORTED` (и при правке вживую); в режиме decoupled → `SECONDARY_UNSUPPORTED`. TRIGGER_PAYMENT в дополнительном — только для подписчика, который сейчас в нём. Клон основного при первой публикации дополнительным получает новые id совпавших узлов (рёбра/конфиги/ссылки на шаги переносятся) — после publish перечитай граф (`get_graph`), старые nodeId недействительны: PUT/`edit_graph_live` живого дополнительного со старыми id узлов основного → HTTP 409 `GRAPH_STALE`. Ссылки «Перейти в сценарий» (graphId другого графа) и текст при перенумерации не меняются. Подписчик возвращается в основной на блоке END дополнительного или по команде/ключевому слову основного; «Оплата прошла» в дополнительном отменяет основной для этой оплаты, только если его триггер реально сработал. Цель-черновик/чужая/основной — подписчик идёт в основной, как раньше. Правки дополнительного — тем же id через `edit_graph_live` (вживую, как основной). Снять — `unpublish_graph(graphId)`; «сделать основным» — `set_active_graph`. В `list_graphs` у него `secondary: true`. Публикация основного (`publish_graph` без `secondary`) дополнительные не удаляет. Дополнительный занимает место в лимите сценариев тарифа.
72
74
  - `clone_graph`+`publish_graph` НЕ создают новый живой граф и НЕ песочница: публикация копирует клон (как любой черновик) в id ТЕКУЩЕГО опубликованного графа бота и затирает его содержимое без бэкапа — вместе с живыми правками, сделанными после прошлой публикации; `set_active_graph` на прежний id после этого вернёт уже новое содержимое; открытые редакторы живого графа перечитают его (у кого есть несохранённые правки — получат просьбу обновить страницу). Клон годится, чтобы спокойно собрать крупный рискованный рефактор в черновике, но перед его публикацией сохрани живой граф: `get_graph(graphId, saveToFile)` — откат = залить файл обратно через `edit_graph_live`/`update_graph` с `graphFile`.
75
+ - **История версий и откат** (сервер ведёт сам, у опубликованного/живого графа): снимок при каждой публикации (`PUBLISH`), при правке живого сценария не чаще раза в 10 минут (`AUTO` — состояние ДО правки), по кнопке «Сохранить версию» в редакторе (`MANUAL`) и при откате (`ROLLBACK`); хранится последние 50 + все публикации/откаты за 90 дней. `graph_versions_list(graphId)` — список (number, reason, автор, время, число узлов); `graph_versions_list(graphId, version:N)` — сравнение версии N с текущим графом (сколько узлов added/removed/changed, менялись ли рёбра). `graph_version_rollback(graphId, version:N)` — откат = публикация снимка: у живого графа бот переходит на версию СРАЗУ, проверки как у `edit_graph_live` (при ошибках 422 граф не тронут), текущее состояние сервер сам сохраняет отдельной версией (`savedCurrentAs`), поэтому откат обратим. Перед крупной правкой живого графа посмотри `graph_versions_list` — есть ли свежая точка возврата; откат делай только с согласия пользователя. У черновика истории нет.
73
76
  - ⚠️ PUT **активного** (PUBLISHED) графа сервер проверяет как `publish`: валидатор, платные блоки, лимит блоков тарифа, платформа. Ошибка → `HTTP 422` со всеми `code@nodeId`, граф **не сохранён**, бот работает на прежней версии. Черновик (`DRAFT`) PUT сохраняет без проверок (кроме размера: больше 4 МБ → 422 `GRAPH_TOO_LARGE`), и на бота он не влияет: `publish_graph` черновика копирует его в отдельный `PUBLISHED`-граф (`publishedGraphId`), а сам черновик остаётся `DRAFT`. Всё равно прогоняй `validate.mjs` + `dry_run` заранее. Откат: версии активного не хранятся — опубликовать граф «🔙 Авто-бэкап» (или скопировать его содержимое обратно).
74
77
 
75
78
  6. **Отчитайся**: сколько узлов/веток, какие тексты помечены на проверку, ссылка/ id графа.
@@ -175,6 +175,17 @@ IG-боты не поддерживают команды (`/start`). Вход
175
175
  (`DELETE_BAD_MODE`), `NODE` без существующего узла (`DELETE_NODE_MISSING`) или с узлом, который ничего не шлёт
176
176
  (`DELETE_NODE_NOT_SENDER`), пауза не число (`DELETE_BAD_DELAY`) или от 24 часов (`DELETE_DELAY_TOO_LONG`),
177
177
  `NEXT` без паузы (`DELETE_NEXT_NO_DELAY`). Instagram узел не поддерживает.
178
+ - `RANDOM_TEXT` — **случайный текст из списка**, целиком. Нужен, когда клиент хочет выдавать подписчику
179
+ случайную историю/цитату/совет из набора дословно (база знаний агента для этого не годится: она режется
180
+ на фрагменты, и агент видит только найденные куски). `{ "texts":["Текст 1","Текст 2"], "mode":"send"|"variable",
181
+ "saveTo":"story", "noRepeat":false, "split":true, "parseMode":"HTML"? }`. `mode:"send"` (по умолчанию) отправляет
182
+ текст подписчику; текст длиннее лимита канала (TG 4096, MAX 4000, IG 1000) делится на сообщения по абзацам
183
+ (`split:false` - не делить, лишнее обрежется). `mode:"variable"` кладёт текст в `{{var.<saveTo>}}` (saveTo обязателен,
184
+ до 65 536 знаков), дальше его вставляют в любой `SEND_MESSAGE`. `noRepeat:true` - не повторять тексты у подписчика,
185
+ пока не выданы все, потом круг заново. В текстах работают `{{from.first_name}}` и `{{var.x}}`. Потолки: до 500 текстов,
186
+ до 65 536 знаков каждый, вместе до 1 500 000 (сценарий в целом ≤ 4 МБ). Выходы: `next`, `error` (пустой список, отказ
187
+ отправки, переменная не сохранилась). Не для Instagram (`IG_NODE_UNSUPPORTED`). Коды валидатора: `RANDOM_TEXT_NO_TEXTS`,
188
+ `RANDOM_TEXT_TOO_MANY`, `RANDOM_TEXT_TOO_LONG`, `RANDOM_TEXT_TOTAL_TOO_LONG`, `RANDOM_TEXT_BAD_SAVE_TO`.
178
189
  - `ASK_QUESTION` — вопрос со сбором ответа. `{ "promptText":"...","saveTo":"name","inputKind":"TEXT"|"PHOTO"|"DOCUMENT"|"CONTACT"|"LOCATION","validator":"ANY"|"PHONE"|"EMAIL"|"REGEX","regex":"...","retryText":"...","maxAttempts":3 }`. `inputKind` (по умолчанию `TEXT`) — что ждём в ответ (`CONTACT` → телефон: в Telegram показывается кнопка «Поделиться номером», в MAX/Instagram кнопки нет — номер вводится вручную и принимается как телефон на всех платформах; `LOCATION` → `lat,lon`, `PHOTO`/`DOCUMENT` → file_id). Выходы `valid` / `invalid`.
179
190
  - `END` — `{}` (конец ветки). **Не добавляй `END`**: ветка и так завершается на узле без исходящих рёбер; явный «конец сценария» бесполезен и убран из палитры редактора. Тип оставлен лишь для совместимости со старыми графами.
180
191
 
@@ -183,6 +194,7 @@ IG-боты не поддерживают команды (`/start`). Вход
183
194
  - **`FIXED`** («Отправить через»): `{ "kind":"FIXED", "durationSec": 86400 }` — `durationSec` в **секундах** (60 = 1 мин, 3600 = 1 час, 86400 = 1 сутки). Редактор также пишет `{ "kind":"FIXED", "duration": 24, "unit":"MINUTES"|"HOURS"|"DAYS" }` (минуты/часы/дни — чтобы не вбивать большие числа). Для генерации проще `durationSec`.
184
195
  - **`TOMORROW`** («Отправить завтра»): `{ "kind":"TOMORROW", "time":"18:00" }` — завтра в указанное время `HH:mm` (МСК), относительно момента, когда пользователь дошёл до узла.
185
196
  - **`UNTIL`** («Отправить в»): `{ "kind":"UNTIL", "isoTimestamp":"2026-06-25T15:00:00Z" }` — конкретный момент в ISO-8601. ⚠️ Рантайм читает только `isoTimestamp`; пары `isoDate`+`time` НЕ работают. Можно шаблон из переменной и смещение: `{ "kind":"UNTIL", "isoTimestamp":"{{var.slot_at}}", "offset":"-24h", "timezone":"Europe/Moscow" }` — «за сутки до записи» (`offset`: `-30m`, `+1d`, единицы s/m/h/d/w; дата без зоны читается в `timezone`, по умолчанию Москва). Момент уже прошёл → выход `late`, если ребро нарисовано, иначе шаг выполняется сразу (`snapshot.lateBy`). Шаблон не разобрался как дата → выход `error`.
197
+ - **Окно отправки для `DELAY`** (любой `kind`): `"windowFrom":"09:00", "windowTo":"21:00"` (HH:mm, окно может переходить через полночь) и/или `"days":["MON","TUE",...]` - если момент пробуждения вне окна/дня, он переносится на начало ближайшего окна. `"windowTz":"subscriber"` - считать окно по поясу подписчика (его переменная `timezone`/`tz`: IANA `Europe/Moscow` или `UTC+3`; если её нет - `timezone` узла, иначе Москва); без `windowTz` - Москва; допустим и явный пояс (`"Asia/Yekaterinburg"`). Тихие часы всего бота для рассылок настраивает `bot_delivery_window`.
186
198
  - `SCHEDULE` — `{ "isoDate":"2026-06-25", "time":"18:00", "timezone":"Europe/Moscow" }`. Выходы `scheduled` / `past`. `isoDate` может быть и ISO дата-временем или шаблоном (`"{{var.slot_at}}"`, тогда `time` не нужен) + `offset` как у `DELAY UNTIL`.
187
199
  - Прогон без пауз (между `DELAY`, `ASK_QUESTION`/`awaitReply`, ожиданием оплаты) ограничен **5 минутами**: дольше — рантайм обрывает его с ошибкой шага `run deadline exceeded` (прогон `FAILED`, пауза подписчика снята). Длинные цепочки `external_request` / `CALL_WEBHOOK` / `AI_REPLY` разноси `DELAY` — после паузы начинается новый прогон.
188
200
  - Массовый одинаковый момент срабатывания (`TOMORROW`/`UNTIL`/`SCHEDULE` у тысяч подписчиков одного бота) расходится постепенно: до 10 срабатываний на бота в секунду, время растёт с аудиторией (≈100 с на 1000 подписчиков, ≈17 мин на 10 000), другие боты не ждут. Не рассчитывай, что все получат сообщение в одну секунду.
@@ -359,6 +371,18 @@ IG-боты не поддерживают команды (`/start`). Вход
359
371
  - `YOOKASSA_PAYMENT` — узел оплаты. **В новых сценариях его не ставь**: выставление счёта — это
360
372
  действие, поэтому оно живёт действием `issue_invoice` внутри `ACTIONS` (см. ниже), а узла больше
361
373
  нет в палитре. Тип жив в рантайме только ради графов, где он уже стоит.
374
+ - Действие **`issue_stars_invoice`** внутри `ACTIONS` — счёт в **Telegram Stars (XTR)**, только для
375
+ Telegram-ботов (на MAX/VK/Instagram/WEB — `STARS_TELEGRAM_ONLY`). Кассы нет, `connectionId` не нужен.
376
+ `{ "kind":"issue_stars_invoice", "amount":"150", "title":"Доступ к курсу", "description":"Что получит покупатель", "timeoutMinutes":60 }`.
377
+ - `amount` обязателен: целое число звёзд 1…10000 или шаблон `{{var.price}}` (`STARS_NO_AMOUNT`/`STARS_BAD_AMOUNT`).
378
+ - `title` обязателен, до 32 знаков (`STARS_NO_TITLE`/`STARS_TITLE_TOO_LONG`); `description` необязателен, до 255 (`STARS_DESC_TOO_LONG`), по умолчанию = title.
379
+ - Выходы, ожидание, `timeoutMinutes` и правила «последний/один счёт» (`INVOICE_NOT_LAST`/`INVOICE_DUPLICATE`)
380
+ те же, что у `issue_invoice`; в блоке не может быть вместе ЮKassa-счёта и счёта в звёздах.
381
+ - После оплаты: `{{var.stars_charge_id}}` (telegram_payment_charge_id, нужен для возврата), `{{var.stars_amount}}`,
382
+ `{{var.stars_paid_at}}`; `{{var.payment_id}}` при выставлении счёта = внутренний invoice payload.
383
+ - Платёж идемпотентен по `charge_id`; оплата после таймаута ветку `paid` не запускает, но видна в истории.
384
+ Возврат — из кабинета (`refundStarPayment`), через MCP не делается.
385
+ - Повторный проход по счёту выставляет НОВЫЙ счёт, прежний перестаёт приниматься (в отличие от ЮKassa, где та же ссылка).
362
386
  - Действие **`issue_invoice`** внутри `ACTIONS` — настоящий счёт на **кассу владельца бота**
363
387
  (деньги идут на его магазин, не на счёт сервиса) и, если нужно, ожидание оплаты.
364
388
  `{ "kind":"issue_invoice", "connectionId":"<id подключения ЮKassa>", "amount":"990",
@@ -503,6 +527,7 @@ node validate.mjs graph.json --platform=INSTAGRAM
503
527
  | `CALL_WEBHOOK` | `ok`, `error` |
504
528
  | `ACTIONS` | `next`, плюс `error` — если внутри есть действие, которое может упасть (внешний запрос, CRM, Таблицы); `taken` — слот `booking_book` занят |
505
529
  | `SWITCH` | `case_<id>`, `default` |
530
+ | `RANDOM_TEXT` | `next`, `error` |
506
531
  | `STOP_AND_ERROR` | выходов нет (терминатор) |
507
532
  | `DELETE_MESSAGE` | `next` (всегда, даже если удалить не вышло) |
508
533
  | `SCHEDULE` | `scheduled`, `past` |
@@ -17,6 +17,12 @@
17
17
  - Кнопок недостаточно: сообщение только с `buttons` — пустое.
18
18
  - Пробелы/таб/перенос строки = пусто (`.isBlank()`).
19
19
 
20
+ ### `RANDOM_TEXT` — «Случайный текст»
21
+ - `config.texts[]` — хотя бы один непустой текст (`RANDOM_TEXT_NO_TEXTS`); не больше 500 (`RANDOM_TEXT_TOO_MANY`),
22
+ каждый ≤ 65 536 знаков (`RANDOM_TEXT_TOO_LONG`), вместе ≤ 1 500 000 (`RANDOM_TEXT_TOTAL_TOO_LONG`) - иначе сценарий не влезет в 4 МБ.
23
+ - `mode:"variable"` требует `saveTo` по `[a-z_][a-z0-9_]{0,63}` (`RANDOM_TEXT_BAD_SAVE_TO`).
24
+ - Для IG-ботов тип не разрешён (`IG_NODE_UNSUPPORTED`).
25
+
20
26
  ### Длина текста
21
27
  - `text` ≤ 4096; если есть `photoUrl` (подпись) — ≤ 1024 (`SEND_TOO_LONG`).
22
28
  - При `parseMode:"HTML"` — текст должен быть безопасным Telegram-HTML, иначе `HTML_NOT_SAFE`. Разрешены только `b,strong,i,em,u,ins,s,strike,del,code,pre,a[href],tg-spoiler,br`. Если не уверен — `PLAIN`.
@@ -63,6 +69,11 @@
63
69
  - **счёт должен быть ПОСЛЕДНИМ действием в блоке** (`INVOICE_NOT_LAST`) и только один (`INVOICE_DUPLICATE`).
64
70
  Причина: при наличии ветки `paid` блок встаёт на паузу до вебхука кассы, а возобновиться
65
71
  с середины списка движок не умеет — действия после счёта молча не выполнились бы.
72
+ - `ACTIONS` с действием `issue_stars_invoice` (счёт в Telegram Stars):
73
+ - `amount` обязателен (`STARS_NO_AMOUNT`), целое 1…10000 или шаблон (`STARS_BAD_AMOUNT`);
74
+ - `title` обязателен (`STARS_NO_TITLE`), не длиннее 32 (`STARS_TITLE_TOO_LONG`); `description` до 255 (`STARS_DESC_TOO_LONG`);
75
+ - только Telegram: на MAX/VK/Instagram/WEB — `STARS_TELEGRAM_ONLY` (при публикации; офлайн `validate.mjs --platform=MAX|INSTAGRAM` тоже);
76
+ - `timeoutMinutes` ∈ [1, 1440]; последний в блоке и единственный счёт (`INVOICE_NOT_LAST`/`INVOICE_DUPLICATE` считают оба вида счетов вместе).
66
77
  - `AI_REPLY` (`mode` не задан или `"simple"`): `userPromptTemplate` непустой (`AI_NO_PROMPT`); `temperature` ∈ [0.0, 2.0] (`AI_BAD_TEMPERATURE`); нужен `sendToUser:true` ИЛИ `saveTo` (`AI_NO_OUTPUT`).
67
78
  - `AI_REPLY` (`mode: "agent"`) — отдельный набор проверок, старые (`AI_NO_PROMPT` и т.п.) не применяются:
68
79
  - `strict` (по умолчанию `true`) без `knowledgeBaseId` → `AGENT_NO_KNOWLEDGE_BASE` (в строгом режиме без базы агент ответит «не знаю» на всё).
@@ -69,7 +69,7 @@ const ACTION_KINDS = new Set([
69
69
  "add_tag", "remove_tag", "set_field", "stop_bot", "delete_step_message",
70
70
  "subscribe", "unsubscribe", "autoflow_add", "autoflow_remove",
71
71
  "subscriber_webhook", "external_request", "notify",
72
- "subscriber_email", "agent_chat", "cancel_payment_subscription", "issue_invoice",
72
+ "subscriber_email", "agent_chat", "cancel_payment_subscription", "issue_invoice", "issue_stars_invoice",
73
73
  "getcourse_send", "getcourse_order", "amocrm_send", "amocrm_update", "bitrix24_call",
74
74
  "yametrika_event", "gsheets_send", "gsheets_get", "gsheets_update",
75
75
  "gsheets_write_cell", "gsheets_read_cell",
@@ -96,6 +96,11 @@ function validZone(z) {
96
96
  try { new Intl.DateTimeFormat("en", { timeZone: z }); return true; } catch { return false; }
97
97
  }
98
98
 
99
+ // Пояс окна DELAY: IANA-имя или смещение UTC+3 / +03:00 (зеркало DeliveryTime.parseZone)
100
+ function validWindowZone(z) {
101
+ return /^(?:UTC|GMT)?\s*[+-]\s*\d{1,2}(?::?\d{2})?$/i.test(String(z).trim()) || validZone(String(z).trim());
102
+ }
103
+
99
104
  // Telegram-safe HTML — разрешённые теги (эвристика; бэкенд использует jsoup-clean)
100
105
  const HTML_OK_TAGS = new Set([
101
106
  "b", "strong", "i", "em", "u", "ins", "s", "strike", "del",
@@ -250,6 +255,7 @@ for (const n of nodes) {
250
255
  if (blank(c.url) || !/^https?:\/\//.test(c.url)) errors.push(`${who}: url обязателен и http(s)://.`);
251
256
  break;
252
257
  case "DELAY":
258
+ if (!blank(c.windowTz) && String(c.windowTz).trim().toLowerCase() !== "subscriber" && !validWindowZone(c.windowTz)) errors.push(`DELAY_BAD_WINDOW_TZ: ${who} - windowTz = "subscriber" или пояс (Europe/Moscow, UTC+3).`);
253
259
  if (c.kind === "FIXED") { if (!(Number(c.durationSec) > 0) && !(Number(c.duration) > 0)) errors.push(`${who}: FIXED требует durationSec>0 (или duration>0 + unit MINUTES|HOURS|DAYS).`); }
254
260
  else if (c.kind === "TOMORROW") { if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(String(c.time || ""))) errors.push(`${who}: TOMORROW требует time = HH:mm.`); }
255
261
  else if (c.kind === "UNTIL") {
@@ -360,6 +366,16 @@ for (const n of nodes) {
360
366
  case "REMOVE_TAG":
361
367
  if (!TAG_RE.test(String(c.tag || ""))) errors.push(`TAG_BAD_NAME: ${who} — tag ∈ [a-z0-9_-]{1,64}.`);
362
368
  break;
369
+ case "RANDOM_TEXT": {
370
+ // Зеркало GraphValidator.validateRandomText: потолки 500 текстов / 65 536 на текст / 1 500 000 суммарно.
371
+ const texts = Array.isArray(c.texts) ? c.texts.filter((x) => typeof x === "string" && x.trim()) : [];
372
+ if (texts.length === 0) errors.push(`RANDOM_TEXT_NO_TEXTS: ${who} — нужен хотя бы один непустой текст в config.texts[].`);
373
+ if (texts.length > 500) errors.push(`RANDOM_TEXT_TOO_MANY: ${who} — текстов больше 500.`);
374
+ if (texts.some((x) => x.length > 65536)) errors.push(`RANDOM_TEXT_TOO_LONG: ${who} — текст длиннее 65 536 символов.`);
375
+ if (texts.reduce((a, x) => a + x.length, 0) > 1500000) errors.push(`RANDOM_TEXT_TOTAL_TOO_LONG: ${who} — тексты вместе длиннее 1 500 000 символов.`);
376
+ if (c.mode === "variable" && !VAR_RE.test(String(c.saveTo || ""))) errors.push(`RANDOM_TEXT_BAD_SAVE_TO: ${who} — в режиме variable saveTo ∈ [a-z_][a-z0-9_]{0,63}.`);
377
+ break;
378
+ }
363
379
  case "FORMULA":
364
380
  if (blank(c.expression)) errors.push(`FORMULA_NO_EXPRESSION: ${who} — нужен expression.`);
365
381
  if (!VAR_RE.test(String(c.saveTo || ""))) errors.push(`FORMULA_BAD_SAVE_TO: ${who} — saveTo ∈ [a-z_][a-z0-9_]{0,63}.`);
@@ -386,7 +402,7 @@ for (const n of nodes) {
386
402
  // Счёт ставит блок на паузу до вебхука кассы, а возобновиться с середины списка движок
387
403
  // не умеет — значит действия после счёта молча не выполнятся. Зеркало GraphValidator.
388
404
  {
389
- const invIdx = c.actions.map((a, i) => (a && a.kind === "issue_invoice" ? i : -1)).filter((i) => i >= 0);
405
+ const invIdx = c.actions.map((a, i) => (a && (a.kind === "issue_invoice" || a.kind === "issue_stars_invoice") ? i : -1)).filter((i) => i >= 0);
390
406
  if (invIdx.length > 1) {
391
407
  errors.push(`INVOICE_DUPLICATE: ${who} — в блоке больше одного счёта, непонятно какой ждать.`);
392
408
  }
@@ -428,6 +444,23 @@ for (const n of nodes) {
428
444
  if (!Number.isFinite(t) || t < 1 || t > 1440) errors.push(`YK_BAD_TIMEOUT: ${who} — timeoutMinutes ∈ [1, 1440].`);
429
445
  }
430
446
  }
447
+ if (a.kind === "issue_stars_invoice") {
448
+ // Зеркало GraphValidator.validateStarsInvoice: Telegram Stars (XTR), только Telegram-боты.
449
+ const amount = String(a.amount == null ? "" : a.amount).trim();
450
+ if (blank(amount)) errors.push(`STARS_NO_AMOUNT: ${who} — нужна amount (целое число звёзд).`);
451
+ else if (!amount.includes("{{") && !(/^\d+$/.test(amount) && Number(amount) >= 1 && Number(amount) <= 10000))
452
+ errors.push(`STARS_BAD_AMOUNT: ${who} — amount должна быть целым числом звёзд от 1 до 10000.`);
453
+ if (blank(a.title)) errors.push(`STARS_NO_TITLE: ${who} — нужно title (название товара, до 32 знаков).`);
454
+ else if (!String(a.title).includes("{{") && String(a.title).trim().length > 32)
455
+ errors.push(`STARS_TITLE_TOO_LONG: ${who} — title не длиннее 32 символов.`);
456
+ if (!blank(a.description) && !String(a.description).includes("{{") && String(a.description).trim().length > 255)
457
+ errors.push(`STARS_DESC_TOO_LONG: ${who} — description не длиннее 255 символов.`);
458
+ if (platform !== "TELEGRAM") errors.push(`STARS_TELEGRAM_ONLY: ${who} — оплата звёздами доступна только в Telegram-ботах.`);
459
+ if (a.timeoutMinutes != null) {
460
+ const t = Number(a.timeoutMinutes);
461
+ if (!Number.isFinite(t) || t < 1 || t > 1440) errors.push(`YK_BAD_TIMEOUT: ${who} — timeoutMinutes ∈ [1, 1440].`);
462
+ }
463
+ }
431
464
  if (a.kind === "external_request") {
432
465
  // Имя переменной бэкенд не чистит: кривое имя запишется, а {{var.<имя>}} его не найдёт.
433
466
  for (const field of ["saveStatusTo", "saveBodyTo"]) {
@@ -11,11 +11,13 @@ description: Сделать рассылку подписчикам ботов r
11
11
  сценарий с триггером.
12
12
  2. Собери сообщения (до 5) и уточни у пользователя: кому (теги), когда, по каким ботам. Тексты не выдумывай.
13
13
  3. Медиа — `upload_file {path|url}` → `url` в `mediaUrl`/`mediaUrls`.
14
- 4. `broadcast_preview {botIds, tagsAll?, tagsNone?}` — назови пользователю число получателей и **дождись
14
+ 4. `broadcast_preview {botIds, tagsAll?, tagsNone? | audienceFilter? | segmentId?}` — оцени охват, назови пользователю число получателей и **дождись
15
15
  подтверждения**: рассылку не вернуть, а получатели списываются с месячной квоты тарифа.
16
16
  5. `broadcast_send {name, botIds, messages, tagsAll?, tagsNone?, scheduledAt?}` → `{broadcastIds, totalAudience}`.
17
17
  Не уверен в тексте — сначала `broadcast_drafts {action: "create", …}`: пользователь увидит черновик в кабинете,
18
18
  отправка — `broadcast_send {draftId}` (черновик после отправки удаляется).
19
+ Повторно используемую аудиторию сохрани `audience_segments {action:"create", botId, name, audienceFilter}` и дальше передавай `segmentId`. Перед отправкой можно прислать тест себе: `broadcast_test {botId, messages}` (на привязанный Telegram владельца, без квоты).
20
+ «В 10:00 по времени каждого» - `localTime:"10:00"` (пояс из переменной `timezone` подписчика, иначе пояс бота; не с `dedup`/`stagger`). Ночью не беспокоить - `bot_delivery_window {action:"set", botId, enabled:true, quietFrom:"22:00", quietTo:"09:00"}`: рассылки в окне переносятся на его конец.
19
21
  6. Статус — `broadcast_get {broadcastId}` (с причинами ошибок) / `broadcast_list`; остановить — `broadcast_cancel`.
20
22
  Повторить или взять за основу — `broadcast_duplicate {broadcastId | draftId}` → черновик «… (копия)».
21
23
 
@@ -0,0 +1,88 @@
1
+ // Проверка tools[] агента до отправки в agent_update: зеркало AiAgentService.tools + AgentWriteToolConfig
2
+ // на бэкенде (не строже его — иначе MCP отвергнет то, что сервер принял бы). Возвращает список ошибок.
3
+
4
+ const READ = ["AMO_LOOKUP", "SHEETS_LOOKUP", "HTTP_API"];
5
+ const WRITE = ["AMO_CREATE_LEAD", "AMO_UPDATE_LEAD", "AMO_UPDATE_CONTACT", "SHEETS_APPEND", "HTTP_POST", "SET_FIELD", "ADD_TAG", "HANDOFF"];
6
+ const ARG_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,39}$/;
7
+ const PROP_KEYS = new Set(["type", "description", "enum", "pattern", "maxLength", "minLength", "minimum", "maximum"]);
8
+ const SCHEMA_KEYS = new Set(["type", "properties", "required", "description"]);
9
+ const FORBIDDEN_HEADERS = new Set(["host", "content-length", "transfer-encoding", "connection", "content-type", "expect", "upgrade", "te", "trailer"]);
10
+ const MAX_TOOLS = 10;
11
+
12
+ export const AGENT_WRITE_TOOL_TYPES = WRITE;
13
+
14
+ function schemaErrors(schema, errs, id) {
15
+ if (!schema || typeof schema !== "object" || Array.isArray(schema)) return errs.push(`${id}: argsSchema — объект {properties, required}`), new Set();
16
+ if (schema.type != null && schema.type !== "object") errs.push(`${id}: argsSchema.type должен быть object`);
17
+ for (const k of Object.keys(schema)) if (!SCHEMA_KEYS.has(k)) errs.push(`${id}: ключевое слово «${k}» в argsSchema не поддерживается`);
18
+ const props = schema.properties;
19
+ if (!props || typeof props !== "object" || Array.isArray(props) || Object.keys(props).length === 0) {
20
+ errs.push(`${id}: опишите хотя бы один аргумент в argsSchema.properties`);
21
+ return new Set();
22
+ }
23
+ if (Object.keys(props).length > 10) errs.push(`${id}: не больше 10 аргументов`);
24
+ for (const [name, p] of Object.entries(props)) {
25
+ if (!ARG_NAME.test(name)) errs.push(`${id}: имя аргумента «${name}» — латиница, цифры, подчёркивание`);
26
+ if (!p || typeof p !== "object") { errs.push(`${id}: описание аргумента ${name} — объект`); continue; }
27
+ for (const k of Object.keys(p)) if (!PROP_KEYS.has(k)) errs.push(`${id}: ключевое слово «${k}» у аргумента ${name} не поддерживается`);
28
+ if (!["string", "number", "integer", "boolean"].includes(p.type)) errs.push(`${id}: type аргумента ${name} — string, number, integer или boolean`);
29
+ if (p.enum != null && (!Array.isArray(p.enum) || p.enum.length === 0 || p.enum.length > 30)) errs.push(`${id}: enum аргумента ${name} — от 1 до 30 значений`);
30
+ if (p.pattern != null) {
31
+ try { new RegExp(p.pattern); } catch { errs.push(`${id}: pattern аргумента ${name} не разбирается`); }
32
+ }
33
+ }
34
+ for (const r of schema.required || []) if (!(r in props)) errs.push(`${id}: required содержит неизвестный аргумент ${r}`);
35
+ return new Set(Object.keys(props));
36
+ }
37
+
38
+ function placeholders(tpl, known, errs, id) {
39
+ for (const m of String(tpl ?? "").matchAll(/\{([A-Za-z_][A-Za-z0-9_]{0,39})\}/g)) {
40
+ if (!known.has(m[1]) && m[1] !== "now") errs.push(`${id}: в шаблоне {${m[1]}} нет в argsSchema.properties`);
41
+ }
42
+ }
43
+
44
+ export function validateAgentTools(tools) {
45
+ const errs = [];
46
+ if (!Array.isArray(tools)) return ["tools должен быть массивом"];
47
+ if (tools.length > MAX_TOOLS) errs.push(`не больше ${MAX_TOOLS} инструментов`);
48
+ const ids = new Set();
49
+ for (const t of tools) {
50
+ const id = t?.id || "?";
51
+ if (!t || typeof t !== "object") { errs.push("инструмент — объект"); continue; }
52
+ if (!/^[a-z0-9_]{1,40}$/.test(t.id || "")) errs.push(`${id}: id — a-z, 0-9, _ (до 40)`);
53
+ if (ids.has(t.id)) errs.push(`${id}: id повторяется`);
54
+ ids.add(t.id);
55
+ if (!t.title) errs.push(`${id}: нужен title`);
56
+ if (![...READ, ...WRITE].includes(t.type)) { errs.push(`${id}: неизвестный type ${t.type}`); continue; }
57
+ if (!WRITE.includes(t.type)) continue;
58
+ for (const [k, max] of [["limitPerConversation", 20], ["limitPerDay", 1000]]) {
59
+ if (t[k] != null && !(Number.isInteger(t[k]) && t[k] >= 1 && t[k] <= max)) errs.push(`${id}: ${k} — число от 1 до ${max}`);
60
+ }
61
+ let known = new Set();
62
+ if (!["SET_FIELD", "ADD_TAG", "HANDOFF"].includes(t.type)) known = schemaErrors(t.argsSchema, errs, id);
63
+ if (t.type.startsWith("AMO_") && !t.amoConnectionId) errs.push(`${id}: нужен amoConnectionId (подключение amoCRM)`);
64
+ if (t.type === "AMO_CREATE_LEAD" && !t.amoLeadName) errs.push(`${id}: нужен amoLeadName`);
65
+ if (t.type === "AMO_UPDATE_LEAD" && !t.amoLeadId) errs.push(`${id}: нужен amoLeadId`);
66
+ if (t.type === "AMO_UPDATE_CONTACT" && !t.amoContactId) errs.push(`${id}: нужен amoContactId`);
67
+ if (t.type === "SHEETS_APPEND") {
68
+ if (!t.sheetsEmail || !t.sheetsSpreadsheetId) errs.push(`${id}: нужны sheetsEmail и sheetsSpreadsheetId`);
69
+ if (!Array.isArray(t.sheetsRowTemplate) || t.sheetsRowTemplate.length === 0) errs.push(`${id}: нужен sheetsRowTemplate (ячейки)`);
70
+ }
71
+ if (t.type === "HTTP_POST") {
72
+ if (!/^https?:\/\//i.test(t.httpUrlTemplate || "")) errs.push(`${id}: httpUrlTemplate — http:// или https://`);
73
+ if (t.httpBodyTemplate) {
74
+ try { JSON.parse(t.httpBodyTemplate); } catch { errs.push(`${id}: httpBodyTemplate — невалидный JSON`); }
75
+ }
76
+ for (const h of t.httpHeaders || []) {
77
+ if (!/^[A-Za-z][A-Za-z0-9-]{0,39}$/.test(h?.name || "") || FORBIDDEN_HEADERS.has(String(h.name).toLowerCase())) errs.push(`${id}: недопустимый заголовок ${h?.name}`);
78
+ }
79
+ }
80
+ if (t.type === "SET_FIELD" && !(t.allowedFields || []).length) errs.push(`${id}: нужен allowedFields`);
81
+ if (t.type === "ADD_TAG" && !(t.allowedTags || []).length) errs.push(`${id}: нужен allowedTags`);
82
+ for (const k of ["amoLeadId", "amoContactId", "amoLeadName", "amoPrice", "amoContactName", "amoPhone", "amoEmail", "httpUrlTemplate", "httpBodyTemplate"]) {
83
+ if (t[k]) placeholders(t[k], known, errs, id);
84
+ }
85
+ for (const c of t.sheetsRowTemplate || []) placeholders(c, known, errs, id);
86
+ }
87
+ return errs;
88
+ }
package/src/index.mjs CHANGED
@@ -21,13 +21,16 @@
21
21
  */
22
22
 
23
23
  import { createInterface } from "node:readline";
24
+ import { validateAgentTools } from "./agent-tools.mjs";
24
25
  import { fileURLToPath } from "node:url";
25
26
  import { spawn } from "node:child_process";
26
27
  import os from "node:os";
27
28
  import fs from "node:fs";
28
29
  import path from "node:path";
29
30
 
30
- const VERSION = "0.26.0";
31
+ const VERSION = JSON.parse(
32
+ fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8")
33
+ ).version;
31
34
  /** С чего начать пустой сайт (init у /document/ops; на сайте с черновиком игнорируется). */
32
35
  const SITE_INITS = ["starter", "blank", "mini-landing"];
33
36
  /** Безвредная операция, когда нужен только init: бэкенд не принимает пустой ops[]. */
@@ -668,6 +671,12 @@ function uuidArg(v, label) {
668
671
  if (!UUID_RE.test(s)) throw new Error(`${label} — UUID (например, из list_bots), получено: ${s || "пусто"}.`);
669
672
  return s;
670
673
  }
674
+ /** Номер версии сценария — целое ≥ 1 (приходит числом или строкой из JSON-клиента). */
675
+ function versionArg(v, label) {
676
+ const n = typeof v === "string" && /^\d+$/.test(v.trim()) ? Number(v) : v;
677
+ if (!Number.isInteger(n) || n < 1) throw new Error(`${label} — номер версии, целое ≥ 1 (см. graph_versions_list).`);
678
+ return n;
679
+ }
671
680
  /** Тело vk_landing_create/vk_landing_update — одна форма на оба (PUT полностью заменяет поля). */
672
681
  function landingBody(a) {
673
682
  return {
@@ -890,6 +899,24 @@ function audienceFilterArg(v) {
890
899
  return v;
891
900
  }
892
901
 
902
+ /** "HH:mm" (24ч) или ошибка; пусто -> undefined. */
903
+ function hhmmArg(v, field) {
904
+ if (v == null || v === "") return undefined;
905
+ const s = String(v).trim();
906
+ if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(s)) throw new Error(`${field}: формат HH:mm (24 часа), например 10:00.`);
907
+ return s;
908
+ }
909
+
910
+ /** Фильтр аудитории из segmentId (сохранённый сегмент бота) или audienceFilter; segmentId главнее. */
911
+ async function segmentFilterArg(a, botId) {
912
+ if (!a.segmentId) return audienceFilterArg(a.audienceFilter);
913
+ if (!botId) throw new Error("segmentId: передай botId (сегменты общие для ботов владельца, читаются через любой его бот).");
914
+ const list = await api(`/api/bots/${botId}/segments`);
915
+ const seg = (Array.isArray(list) ? list : []).find((s) => String(s.id) === String(a.segmentId));
916
+ if (!seg) throw new Error(`Сегмент ${a.segmentId} не найден (audience_segments action=list).`);
917
+ return seg.filter;
918
+ }
919
+
893
920
  /** У Instagram-ботов рассылок нет (окно 24 ч Meta) — отказываем до запроса. */
894
921
  async function assertBroadcastBots(botIds) {
895
922
  if (!botIds.length) throw new Error("Передай botIds — id ботов (list_bots), по которым рассылать.");
@@ -1025,6 +1052,8 @@ const TOOLS = [
1025
1052
  { name: "patch_graph", description: "Точечная правка БОЛЬШОГО/живого графа без отправки графа целиком: сервер сам берёт граф по graphId, делает строковые замены в его JSON, проверяет валидность и заливает обратно НА МЕСТЕ (с авто-бэкапом). Идеально, когда граф слишком велик, чтобы передавать его целиком через update_graph/edit_graph_live — напр. сменить id канала в условиях SUBSCRIBED, ссылки кнопок, тексты. replacements: [{find, replace}] — заменяются ВСЕ вхождения; делай find максимально специфичным, чтобы не задеть лишнее. preview=true — только показать число совпадений, ничего не сохраняя. Бот применит изменения сразу только у опубликованного графа (читает активный граф заново из БД); патч черновика до бота не доходит. Результат для активного графа сервер проверяет как публикацию: ошибки → HTTP 422 со всеми code@nodeId, граф не изменён.", inputSchema: { type: "object", properties: { graphId: { type: "string" }, replacements: { type: "array", items: { type: "object", properties: { find: { type: "string" }, replace: { type: "string" } }, required: ["find", "replace"] } }, preview: { type: "boolean", description: "true = только отчёт о числе совпадений, без сохранения" }, backup: { type: "boolean", description: "снять авто-бэкап предыдущего состояния перед правкой (по умолчанию true)" } }, required: ["graphId", "replacements"] } },
1026
1053
  { name: "dry_run", description: "Прогнать сценарий без публикации. kind: command|callback|text. Внешние действия (HTTP, CRM, таблицы, письма, ИИ) пропускаются (skipped: dry-run). Статус прогона: OK, FAILED (прерван) или PARTIAL — «завершён с ошибками»: дошёл до конца, но хотя бы одно действие упало (у шага ok:false, error «КОД: …»).", inputSchema: { type: "object", properties: { graphId: { type: "string" }, kind: { type: "string", enum: ["command", "callback", "text"] }, value: { type: "string" }, fromUsername: { type: "string" }, presetVariables: { type: "object" }, presetTags: { type: "array", items: { type: "string" } } }, required: ["graphId", "kind", "value"] } },
1027
1054
  { name: "publish_graph", description: "Опубликовать граф. У бота ОДИН активный сценарий: публикация черновика заменяет содержимое активного (id живого графа сохраняется); прежняя активная версия, которой нет ни в одном черновике бота (правки вживую), сохраняется черновиком «… (была активной до дата время)». Вернёт publishedGraphId; при отказе проверок — ошибка HTTP 422 со всеми причинами построчно (code@nodeId: message). Сценарий-вебхук (источник WEBHOOK) этим инструментом не публикуется — HTTP 409, его публикуют в вебе. secondary:true — «опубликовать как ДОПОЛНИТЕЛЬНЫЙ сценарий»: граф становится живым НА МЕСТЕ (publishedGraphId = graphId, снимка нет) рядом с основным, основной сценарий и activeGraphId НЕ меняются; его запускают только VK-лендинги (targetGraphId) и ссылки link_create с targetGraphId. Те же проверки (422), у бота должен быть основной (SECONDARY_NO_MAIN), основной сам дополнительным не делается (SECONDARY_IS_MAIN); триггеры расписания/заявки с сайта/событий канала/комментариев/Instagram/вебхука — 422 SECONDARY_TRIGGER_UNSUPPORTED; режим decoupled — SECONDARY_UNSUPPORTED. У клона основного совпавшие id узлов при первой такой публикации меняются — перечитай граф. Снять — unpublish_graph.", inputSchema: { type: "object", properties: { graphId: { type: "string" }, secondary: { type: "boolean", description: "true — дополнительный сценарий рядом с основным (для лендингов/ссылок); по умолчанию false — основной" } }, required: ["graphId"] } },
1055
+ { name: "graph_versions_list", description: "История версий сценария (GET /api/bots/graphs/{graphId}/versions): снимки узлов/рёбер, новые первыми — number, reason (PUBLISH публикация | MANUAL сохранено вручную | AUTO снимок состояния ДО правки живого сценария, не чаще раза в 10 мин | ROLLBACK откат), authorName, createdAt, note, restoredFrom, nodeCount, edgeCount, sizeBytes. Версии ведёт ОПУБЛИКОВАННЫЙ (живой) граф — для бот-сценария это publishedGraphId / граф с isActive:true из list_graphs, у черновика история пуста. Хранится последние 50 версий + все публикации и откаты за 90 дней. С version:N вернёт сравнение версии N с текущим графом (compareTo:'current', по умолчанию) или с другой версией (compareTo: номер): сколько узлов added/removed/changed, изменились ли рёбра, и список затронутых узлов — полный снимок не отдаётся, его смотрят в редакторе («История» → «Просмотр»). Нужно право read.", inputSchema: { type: "object", properties: { graphId: { type: "string" }, version: { type: "integer", description: "Номер версии — вернуть сравнение вместо списка" }, compareTo: { description: "С чем сравнить version: 'current' (по умолчанию) или номер другой версии", oneOf: [{ type: "string", enum: ["current"] }, { type: "integer" }] } }, required: ["graphId"] } },
1056
+ { name: "graph_version_rollback", description: "Откатить сценарий на версию (POST /api/bots/graphs/{graphId}/versions/{version}/rollback). Откат = публикация снимка: у ЖИВОГО графа бот переходит на ту версию сразу, и она проходит те же проверки, что edit_graph_live (валидатор, платные блоки, лимит блоков, платформа) — при ошибках HTTP 422 со всеми code@nodeId, граф НЕ изменён. Состояние ДО отката сервер сам сохраняет отдельной версией (savedCurrentAs), результат — версией reason ROLLBACK (newVersion), так что откат обратим тем же инструментом. Узлы получают ТЕ ЖЕ id, что были в снимке. Это действие на живом боте: перед вызовом покажи пользователю graph_versions_list (version:N — что изменится) и получи согласие. Нужно право write:bots.", inputSchema: { type: "object", properties: { graphId: { type: "string" }, version: { type: "integer", description: "Номер версии из graph_versions_list" } }, required: ["graphId", "version"] } },
1028
1057
  { name: "unpublish_graph", description: "Снять ДОПОЛНИТЕЛЬНЫЙ сценарий с публикации (POST /api/bots/graphs/{graphId}/unpublish): он снова черновик, лендинги и ссылки на него ведут в основной сценарий, подписчики в нём возвращаются в основной, его задержки отменяются. Основной сценарий так не снимается — HTTP 409.", inputSchema: { type: "object", properties: { graphId: { type: "string" } }, required: ["graphId"] } },
1029
1058
  { name: "import_funnel", description: "Всё за раз: создать граф, залить узлы/рёбра, (опц.) dry-run /start, опубликовать. Граф можно передать инлайном (graph) или файлом (graphFile).", inputSchema: { type: "object", properties: { botId: { type: "string" }, name: { type: "string" }, graph: { type: "object" }, graphFile: { type: "string", description: "Путь к локальному JSON графа вместо инлайн graph; поддерживается ~" }, dryRun: { type: "boolean" }, publish: { type: "boolean" } }, required: ["botId"] } },
1030
1059
  { name: "list_templates", description: "Список готовых шаблонов воронок (id, имя, описание). Можно стартовать граф из шаблона вместо сборки с нуля.", inputSchema: { type: "object", properties: {} } },
@@ -1070,13 +1099,13 @@ const TOOLS = [
1070
1099
  { name: "agent_list", description: "Список ИИ-агентов пользователя (GET /api/bots/agents): карточки — id, имя, статус. Read-only.", inputSchema: { type: "object", properties: {} } },
1071
1100
  { name: "agent_get", description: "ИИ-агент по id (GET /api/bots/agents/{agentId}): настройки (имя, язык, тон, длина/формат ответа, инструкции, запретные/передаточные темы, kbId базы знаний, статус). Чужой агент — 404. Read-only.", inputSchema: { type: "object", properties: { agentId: { type: "string" } }, required: ["agentId"] } },
1072
1101
  { name: "agent_create", description: "Создать ИИ-агента (POST /api/bots/agents): name, description — необязательны. Вместе с агентом создаётся его база знаний (kbId в ответе) — дальше kb_add_qa/kb_add_text/kb_add_site.", inputSchema: { type: "object", properties: { name: { type: "string" }, description: { type: "string" } } } },
1073
- { name: "agent_update", description: "Изменить настройки агента (PATCH /api/bots/agents/{agentId}): patch — объект с полями для правки (name, description, language: RU|EN|AUTO, tone: FRIENDLY|NEUTRAL|FORMAL, answerLength: SHORT|MEDIUM|LONG, format: PLAIN|LIST_FRIENDLY, instructions, forbiddenTopics[], handoffTopics[], fallback). Применяется частично — передавай только то, что меняешь.", inputSchema: { type: "object", properties: { agentId: { type: "string" }, patch: { type: "object", description: "Поля агента для частичного обновления" } }, required: ["agentId", "patch"] } },
1102
+ { name: "agent_update", description: "Изменить настройки агента (PATCH /api/bots/agents/{agentId}): patch — объект с полями для правки (name, description, language: RU|EN|AUTO, tone: FRIENDLY|NEUTRAL|FORMAL, answerLength: SHORT|MEDIUM|LONG, format: PLAIN|LIST_FRIENDLY, instructions, forbiddenTopics[], handoffTopics[], fallback, tools[]). Применяется частично — передавай только то, что меняешь. tools[] заменяет список целиком (до 10). Инструменты ЧТЕНИЯ: AMO_LOOKUP {amoConnectionId}, SHEETS_LOOKUP {sheetsEmail,sheetsSpreadsheetId,sheetsLookupColumn,…}, HTTP_API {httpUrlTemplate,httpParams}. Инструменты ЗАПИСИ (включаются явно по одному, id a-z0-9_): AMO_CREATE_LEAD {amoConnectionId, amoLeadName, amoContactName?, amoPhone?, amoEmail?, amoPrice?, amoPipelineId?, amoStatusId?}, AMO_UPDATE_LEAD {amoLeadId + что обновлять}, AMO_UPDATE_CONTACT {amoContactId, amoContactName?|amoPhone?|amoEmail?}, SHEETS_APPEND {sheetsEmail,sheetsSpreadsheetId,sheetsSheetName?, sheetsRowTemplate:[ячейки]}, HTTP_POST {httpUrlTemplate, httpBodyTemplate? (JSON-шаблон), httpHeaders?:[{name,value}], httpJsonPath?}, SET_FIELD {allowedFields[]}, ADD_TAG {allowedTags[]}, HANDOFF. Для записи (кроме SET_FIELD/ADD_TAG/HANDOFF) обязательна argsSchema — плоская JSON-схема {properties:{имя:{type:string|number|integer|boolean, description?, enum?, pattern?, maxLength?, minLength?, minimum?, maximum?}}, required?:[]}; в шаблонах {имя} подставляет аргумент, {now} — время. Общие поля записи: confirmBeforeRun (по умолчанию true для внешних записей — агент сначала спрашивает согласие пользователя), limitPerConversation (по умолч. 3, макс 20), limitPerDay (по умолч. 100, макс 1000). В публичном веб-чате инструменты записи не работают. Перед отправкой tools[] проверяются локально, ошибки схем возвращаются списком.", inputSchema: { type: "object", properties: { agentId: { type: "string" }, patch: { type: "object", description: "Поля агента для частичного обновления" } }, required: ["agentId", "patch"] } },
1074
1103
  { name: "agent_publish", description: "Опубликовать агента (POST /api/bots/agents/{agentId}/publish): агент начинает отвечать в подключённых сценариях. HTTP 409 CHECKLIST_FAILED с чеклистом, если агент ещё не готов (нет базы знаний, пустые инструкции и т.п.) — агент не меняется.", inputSchema: { type: "object", properties: { agentId: { type: "string" } }, required: ["agentId"] } },
1075
1104
  { name: "agent_health", description: "Здоровье базы знаний агента (GET /api/bots/agents/{agentId}/health): счётчики документов/фрагментов по запросу, без кэша. Read-only.", inputSchema: { type: "object", properties: { agentId: { type: "string" } }, required: ["agentId"] } },
1076
1105
  { name: "agent_test_chat", description: "Проверить ответ агента в песочнице без отправки клиенту (POST /api/bots/agents/{agentId}/test-chat): question (1–1000 символов), history — необязательная история диалога [{role: client|agent, text}], до 12 реплик. ТРАТИТ бюджет ИИ, как настоящий ответ — не вызывай массово.", inputSchema: { type: "object", properties: { agentId: { type: "string" }, question: { type: "string" }, history: { type: "array", items: { type: "object", properties: { role: { type: "string", enum: ["client", "agent"] }, text: { type: "string" } } } } }, required: ["agentId", "question"] } },
1077
1106
  { name: "kb_docs", description: "Документы базы знаний агента (GET /api/bots/kb/{kbId}/docs): источник (FILE/QA/SITE), статус индексации, число фрагментов. Только верхний уровень — у сайта страницы видны счётчиком. Read-only.", inputSchema: { type: "object", properties: { kbId: { type: "string", description: "kbId агента (agent_get)" } }, required: ["kbId"] } },
1078
1107
  { name: "kb_add_qa", description: "Добавить пары вопрос-ответ в базу знаний (POST /api/bots/kb/{kbId}/docs/qa): pairs — [{question, answer}], до 200 пар за раз (вопрос ≤500 символов, ответ ≤4000). Каждая пара — отдельный фрагмент для поиска.", inputSchema: { type: "object", properties: { kbId: { type: "string" }, pairs: { type: "array", items: { type: "object", properties: { question: { type: "string" }, answer: { type: "string" } }, required: ["question", "answer"] } } }, required: ["kbId", "pairs"] } },
1079
- { name: "kb_add_text", description: "Добавить источник «Текст/инструкция» в базу знаний (POST /api/bots/kb/{kbId}/docs/text): title (≤120 символов), text (≤100 000 символов). Индексация уходит в фон — документ появится в kb_docs со статусом PENDING → READY.", inputSchema: { type: "object", properties: { kbId: { type: "string" }, title: { type: "string" }, text: { type: "string" } }, required: ["kbId", "title", "text"] } },
1108
+ { name: "kb_add_text", description: "Добавить источник «Текст/инструкция» в базу знаний (POST /api/bots/kb/{kbId}/docs/text): title (≤120 символов), text (≤100 000 символов). Индексация уходит в фон — документ появится в kb_docs со статусом PENDING → READY. Агент видит найденные фрагменты (~800 знаков; текст до 2500 знаков хранится одним куском), а не документ целиком; чтобы выдавать текст дословно, используй узел сценария RANDOM_TEXT.", inputSchema: { type: "object", properties: { kbId: { type: "string" }, title: { type: "string" }, text: { type: "string" } }, required: ["kbId", "title", "text"] } },
1080
1109
  { name: "kb_add_site", description: "Добавить сайт в базу знаний обходом страниц (POST /api/bots/kb/{kbId}/docs/site): url (http/https, не внутренняя сеть), schedule — расписание повторного обхода: NEVER|DAILY|WEEKLY|MONTHLY (по умолчанию NEVER). Обход уходит в фон; документ появится в kb_docs со статусом PENDING.", inputSchema: { type: "object", properties: { kbId: { type: "string" }, url: { type: "string" }, schedule: { type: "string", enum: ["NEVER", "DAILY", "WEEKLY", "MONTHLY"] } }, required: ["kbId", "url"] } },
1081
1110
  { name: "kb_reindex", description: "Переиндексировать документ-файл базы знаний из сохранённого оригинала (POST /api/bots/kb/{kbId}/docs/{docId}/reindex) — «Повторить» после ошибки. headerRow — необязательно, для табличных файлов: номер строки с шапкой (1..50), если автоопределение ошиблось. Только для источника FILE.", inputSchema: { type: "object", properties: { kbId: { type: "string" }, docId: { type: "string" }, headerRow: { type: "number" } }, required: ["kbId", "docId"] } },
1082
1111
  { name: "agent_unanswered", description: "Вопросы без ответа агента за период (GET /api/bots/agents/{agentId}/unanswered): days — 7|30|90 (по умолчанию 30). Группы вопросов, на которые агент не нашёл ответ в базе знаний — подсказка, что туда добавить. Read-only.", inputSchema: { type: "object", properties: { agentId: { type: "string" }, days: { type: "number", enum: [7, 30, 90] } }, required: ["agentId"] } },
@@ -1097,7 +1126,7 @@ const TOOLS = [
1097
1126
  // ---- Рассылки ----
1098
1127
  { name: "broadcast_list", description: "Рассылки. Без botId — по всем ботам постранично (GET /api/bots/broadcasts): {counts: {drafts, scheduled, sent, recurring}, page: {content: [{id, botId, botUsername, name, status, direct, totalJobs, sentJobs, failedJobs, skippedByQuota, scheduledAt, createdAt}], totalElements…}}; group: scheduled (ещё не начали) | sent (идут/завершены). С botId — полная история одного бота. status: EXPANDING/MATERIALIZING/READY (ждёт) → RUNNING → DONE | CANCELLING → CANCELLED | FAILED. Read-only.", inputSchema: { type: "object", properties: { botId: { type: "string" }, group: { type: "string", enum: ["scheduled", "sent"] }, page: { type: "number" }, size: { type: "number", description: "до 100, по умолчанию 20" } } } },
1099
1128
  { name: "broadcast_get", description: "Рассылка целиком по id (GET /api/bots/broadcasts/{id}): статус, счётчики отправки, фильтр аудитории, сообщения, время. При failedJobs > 0 — ещё errors: топ-5 причин ошибок [{error, count}] (GET …/{id}/errors; детали живут 30 дней). Read-only.", inputSchema: { type: "object", properties: { broadcastId: { type: "string" } }, required: ["broadcastId"] } },
1100
- { name: "broadcast_preview", description: "Сколько подписчиков получат рассылку (POST /api/bots/{botId}/broadcasts/preview) — по каждому боту и всего. Фильтр по тегам: tagsAll — есть ВСЕ эти теги, tagsNone — нет НИ ОДНОГО; без тегов — все подписчики бота. Лимит — 50 000 получателей на бота. Ничего не отправляет.", inputSchema: { type: "object", properties: { botIds: { type: "array", items: { type: "string" } }, botId: { type: "string" }, tagsAll: { type: "array", items: { type: "string" } }, tagsNone: { type: "array", items: { type: "string" } } } } },
1129
+ { name: "broadcast_preview", description: "Оценить охват до отправки: сколько подписчиков получат рассылку (POST /api/bots/{botId}/broadcasts/preview) — по каждому боту и всего. Фильтр: tagsAll — есть ВСЕ эти теги, tagsNone — нет НИ ОДНОГО; без тегов — все подписчики бота; либо полный audienceFilter; либо segmentId — сохранённый сегмент (audience_segments). Лимит — 50 000 получателей на бота. Ничего не отправляет.", inputSchema: { type: "object", properties: { botIds: { type: "array", items: { type: "string" } }, botId: { type: "string" }, tagsAll: { type: "array", items: { type: "string" } }, tagsNone: { type: "array", items: { type: "string" } }, audienceFilter: AUDIENCE_FILTER_SCHEMA, segmentId: { type: "string", description: "id сохранённого сегмента (audience_segments action=list); главнее audienceFilter и тегов" } } } },
1101
1130
  { name: "broadcast_send", description: "Отправить рассылку сейчас или запланировать (scheduledAt). " +
1102
1131
  "Прямая (по умолчанию, POST /api/bots/broadcasts/direct): name, botIds[] (1–20 ботов ОДНОГО владельца; по каждому создаётся своя рассылка), messages[] (1–5), tagsAll?/tagsNone? (фильтр по тегам). " +
1103
1132
  "Сообщение: {type, text?, mediaUrl?, mediaUrls?, buttons?}; type: TEXT (text обязателен, до 4096) · PHOTO | VIDEO | AUDIO | FILE | VOICE (mediaUrl обязателен, text — подпись до 1024) · VIDEONOTE (кружок: mediaUrl, без текста) · GALLERY (mediaUrls: 2–10 картинок, подпись, БЕЗ кнопок). " +
@@ -1105,8 +1134,12 @@ const TOOLS = [
1105
1134
  "По сценарию (graphId): POST /api/bots/{botId}/broadcasts — один botId, сценарий самого бота, entryNodeId? — с какого узла начать. " +
1106
1135
  "draftId — отправить черновик (поля черновика, переданные аргументы их перекрывают); после отправки черновик удаляется. " +
1107
1136
  "scheduledAt — ISO 8601 (без пояса — московское время); пусто — сразу. Немедленная резервирует квоту получателей тарифа, отложенная считает аудиторию в момент отправки. Рассылки только на платном тарифе — HTTP 402 со ссылкой. У Instagram-ботов рассылок нет. " +
1108
- "audienceFilter — полный фильтр аудитории вместо tagsAll/tagsNone (см. свойство ниже; если задан, tagsAll/tagsNone игнорируются). dedup:true — «отправить каждому человеку только один раз» среди нескольких botIds (ключ — platform+userId/chatId приватного чата; человек уходит ПЕРВОМУ боту по порядку botIds, где у него активная сессия). stagger — «распределить по ботам» (см. свойство ниже). Любое из dedup/stagger → ответ вместо {broadcastIds, totalAudience} отдаёт {group:{id, status, perBot[]...}, isGroup:true} — статус/прогресс дальше смотри broadcast_group {action:\"get\", groupId}. Без них — как раньше: {broadcastIds, totalAudience}.",
1109
- inputSchema: { type: "object", properties: { name: { type: "string" }, botIds: { type: "array", items: { type: "string" } }, botId: { type: "string" }, messages: { type: "array", items: {} }, tagsAll: { type: "array", items: { type: "string" } }, tagsNone: { type: "array", items: { type: "string" } }, audienceFilter: AUDIENCE_FILTER_SCHEMA, scheduledAt: { type: "string", description: "ISO 8601, напр. 2026-10-06T10:00:00+03:00; пусто — сразу" }, graphId: { type: "string", description: "рассылка запуском сценария бота вместо сообщений" }, entryNodeId: { type: "string" }, draftId: { type: "string" }, dedup: { type: "boolean", description: "Не отправлять дважды одному человеку среди нескольких botIds (мульти-бот)" }, stagger: STAGGER_SCHEMA } } },
1137
+ "audienceFilter — полный фильтр аудитории вместо tagsAll/tagsNone (см. свойство ниже; если задан, tagsAll/tagsNone игнорируются). dedup:true — «отправить каждому человеку только один раз» среди нескольких botIds (ключ — platform+userId/chatId приватного чата; человек уходит ПЕРВОМУ боту по порядку botIds, где у него активная сессия). stagger — «распределить по ботам» (см. свойство ниже). Любое из dedup/stagger → ответ вместо {broadcastIds, totalAudience} отдаёт {group:{id, status, perBot[]...}, isGroup:true} — статус/прогресс дальше смотри broadcast_group {action:\"get\", groupId}. Без них — как раньше: {broadcastIds, totalAudience}. " +
1138
+ "segmentId — сохранённый сегмент аудитории вместо audienceFilter (audience_segments). localTime \"HH:mm\" — слать КАЖДОМУ подписчику в это время по его часам (пояс из переменной timezone подписчика, иначе пояс бота; первый такой момент не раньше отправки/scheduledAt); не сочетается с dedup/stagger. Тихие часы бота (bot_delivery_window) применяются к рассылкам всегда: попавшие в окно получатели ждут его конца.",
1139
+ inputSchema: { type: "object", properties: { name: { type: "string" }, botIds: { type: "array", items: { type: "string" } }, botId: { type: "string" }, messages: { type: "array", items: {} }, tagsAll: { type: "array", items: { type: "string" } }, tagsNone: { type: "array", items: { type: "string" } }, audienceFilter: AUDIENCE_FILTER_SCHEMA, scheduledAt: { type: "string", description: "ISO 8601, напр. 2026-10-06T10:00:00+03:00; пусто — сразу" }, graphId: { type: "string", description: "рассылка запуском сценария бота вместо сообщений" }, entryNodeId: { type: "string" }, draftId: { type: "string" }, segmentId: { type: "string", description: "id сохранённого сегмента аудитории (audience_segments); главнее audienceFilter и тегов" }, localTime: { type: "string", description: "HH:mm — слать каждому в это время по его часам (не с dedup/stagger)" }, dedup: { type: "boolean", description: "Не отправлять дважды одному человеку среди нескольких botIds (мульти-бот)" }, stagger: STAGGER_SCHEMA } } },
1140
+ { name: "audience_segments", description: "Сохранённые сегменты аудитории рассылок (имя + фильтр; /api/bots/{botId}/segments; общие для всех ботов владельца, до 50). action: list — сегменты {id, name, filter}; create {name, audienceFilter | tagsAll/tagsNone} — сохранить фильтр под именем (до 60 символов); delete {segmentId}. Использовать: broadcast_send / broadcast_preview {segmentId}. botId — любой твой бот.", inputSchema: { type: "object", properties: { action: { type: "string", enum: ["list", "create", "delete"] }, botId: { type: "string" }, name: { type: "string" }, audienceFilter: AUDIENCE_FILTER_SCHEMA, tagsAll: { type: "array", items: { type: "string" } }, tagsNone: { type: "array", items: { type: "string" } }, segmentId: { type: "string" } }, required: ["action", "botId"] } },
1141
+ { name: "bot_delivery_window", description: "Тихие часы бота (/api/bots/{botId}/delivery-window): в окно quietFrom..quietTo (HH:mm, может переходить через полночь, например 22:00..09:00) рассылки не уходят, а переносятся на конец окна. Время считается по поясу подписчика (переменная timezone/tz), если неизвестен — по поясу бота timezone (IANA или UTC+3, пусто = Москва). По умолчанию выключено. action: get · set {enabled, quietFrom?, quietTo?, timezone?} (enabled:true требует quietFrom и quietTo, они должны различаться; enabled:false выключает, время сохраняется).", inputSchema: { type: "object", properties: { action: { type: "string", enum: ["get", "set"] }, botId: { type: "string" }, enabled: { type: "boolean" }, quietFrom: { type: "string", description: "HH:mm" }, quietTo: { type: "string", description: "HH:mm" }, timezone: { type: "string", description: "IANA (Europe/Moscow) или UTC+3" } }, required: ["action", "botId"] } },
1142
+ { name: "broadcast_test", description: "Отправить тест рассылки себе (POST /api/bots/{botId}/broadcasts/test): те же сообщения, что у broadcast_send, на твой привязанный Telegram (бот должен быть Telegram, а ты — нажать /start у этого бота; иначе 422 owner_chat_not_linked или 502 от Telegram) либо в чат подписчика chatId этого бота (так тестируют MAX/VK). Мимо квоты, очереди и тихих часов, подписчикам ничего не уходит.", inputSchema: { type: "object", properties: { botId: { type: "string" }, messages: { type: "array", items: {} }, chatId: { type: "number", description: "чат подписчика этого бота; пусто — привязанный Telegram владельца" } }, required: ["botId", "messages"] } },
1110
1143
  { name: "broadcast_cancel", description: "Отменить рассылку (POST /api/bots/broadcasts/{id}/cancel): запланированная не уйдёт, идущая остановится (статус CANCELLING → CANCELLED). Уже завершённую отменить нельзя — HTTP 409. Для группы (dedup/stagger) — broadcast_group {action:\"cancel\"}.", inputSchema: { type: "object", properties: { broadcastId: { type: "string" } }, required: ["broadcastId"] } },
1111
1144
  { name: "broadcast_recurring", description: "Повторяющиеся рассылки (/api/bots/broadcasts/recurring). action: list — правила (активные и остановленные); create {name, botIds[], messages[], tagsAll?/tagsNone? или audienceFilter?, recurrence: DAILY|MONTHLY|YEARLY, firstRunAt, dedup?, stagger?} — сообщения и фильтр как в broadcast_send; dedup/stagger заданы — КАЖДЫЙ прогон правила порождает свою отдельную группу (своя дедупликация с нуля, аудитория у повтора может быть другой); firstRunAt — первый запуск в будущем (ISO 8601, без пояса — московское), дальше в то же время суток/число (Москва); stop {ruleId} — остановить правило (уже отправленные прогоны не трогаются). Нужен платный тариф (402 со ссылкой).", inputSchema: { type: "object", properties: { action: { type: "string", enum: ["list", "create", "stop"] }, ruleId: { type: "string" }, name: { type: "string" }, botIds: { type: "array", items: { type: "string" } }, botId: { type: "string" }, messages: { type: "array", items: {} }, tagsAll: { type: "array", items: { type: "string" } }, tagsNone: { type: "array", items: { type: "string" } }, audienceFilter: AUDIENCE_FILTER_SCHEMA, recurrence: { type: "string", enum: ["DAILY", "MONTHLY", "YEARLY"] }, firstRunAt: { type: "string" }, dedup: { type: "boolean" }, stagger: STAGGER_SCHEMA }, required: ["action"] } },
1112
1145
  { name: "broadcast_drafts", description: "Черновики рассылок (/api/bots/broadcasts/drafts) — те же, что в мастере кабинета. action: list · get {draftId} · create {name?, botIds?, messages?, tagsAll?, tagsNone?, scheduledAt?} · update {draftId, …те же поля — переданные заменяют, остальные остаются} · delete {draftId}. Черновик не проверяется на полноту; отправить — broadcast_send {draftId}. Лимит — 200 черновиков.", inputSchema: { type: "object", properties: { action: { type: "string", enum: ["list", "get", "create", "update", "delete"] }, draftId: { type: "string" }, name: { type: "string" }, botIds: { type: "array", items: { type: "string" } }, messages: { type: "array", items: {} }, tagsAll: { type: "array", items: { type: "string" } }, tagsNone: { type: "array", items: { type: "string" } }, scheduledAt: { type: "string" } }, required: ["action"] } },
@@ -1204,7 +1237,8 @@ const TOOLS = [
1204
1237
  { name: "bot_subscriber_analytics", description: "Новые подписчики/активность бота по дням (GET /api/bots/{botId}/analytics/subscribers?from&to): {newSubscribers, active, total, legacyExcluded, trend:[{day,newSubscribers,active}]}. from/to обязательны — ГГГГ-ММ-ДД, диапазон обрезается сервером до 92 дней. legacyExcluded — сессии без даты создания (заведены до этой фичи): не входят в newSubscribers/trend, но учтены в total. active — по updatedAt в диапазоне (одна сессия может посчитаться в несколько дней). Read-only.", inputSchema: { type: "object", properties: { botId: { type: "string" }, from: { type: "string", description: "ГГГГ-ММ-ДД" }, to: { type: "string", description: "ГГГГ-ММ-ДД" } }, required: ["botId", "from", "to"] } },
1205
1238
  { name: "scenario_timeseries", description: "Дневной ряд прогонов сценария (GET /api/bots/graphs/{graphId}/analytics/timeseries?from&to): [{day, ok, failed, other}] — по одной строке на каждый день диапазона (пустые дни — нулями), other = PARKED/NO_MATCH/PARTIAL (не отброшены). from/to обязательны — ГГГГ-ММ-ДД, диапазон обрезается до 92 дней. Разбивку по узлам сценария (не по дням) даёт graph_analytics — это разные срезы одних данных. Read-only.", inputSchema: { type: "object", properties: { graphId: { type: "string" }, from: { type: "string", description: "ГГГГ-ММ-ДД" }, to: { type: "string", description: "ГГГГ-ММ-ДД" } }, required: ["graphId", "from", "to"] } },
1206
1239
  // ---- ИИ-агенты: инструменты (вызов внешних систем моделью) ----
1207
- { name: "agent_tool_test", description: "Проверить инструмент агента напрямую, БЕЗ модели (POST /api/bots/agents/{agentId}/tools/{toolId}/test): params — аргументы вызова (AMO_LOOKUP: query ИЛИ leadId; SHEETS_LOOKUP: value; HTTP_API: имена из httpParams этого инструмента). Проверяет только конфиг инструмента (подключение amoCRM/таблицу Sheets/URL) — модель не вызывается, бюджет ИИ не тратится. {ok:true, result} при успехе; ошибка конфига/внешнего сервиса — HTTP 400 {ok:false, error} с причиной на русском (никогда не 500). toolId — id инструмента из agent_get → tools[].id (настраивается через agent_update {patch:{tools:[...]}})." , inputSchema: { type: "object", properties: { agentId: { type: "string" }, toolId: { type: "string" }, params: { type: "object", description: "Аргументы вызова инструмента (см. описание)" } }, required: ["agentId", "toolId"] } },
1240
+ { name: "agent_tool_test", description: "Проверить инструмент агента напрямую, БЕЗ модели (POST /api/bots/agents/{agentId}/tools/{toolId}/test): params — аргументы вызова (AMO_LOOKUP: query ИЛИ leadId; SHEETS_LOOKUP: value; HTTP_API: имена из httpParams этого инструмента). Проверяет только конфиг инструмента (подключение amoCRM/таблицу Sheets/URL) — модель не вызывается, бюджет ИИ не тратится. {ok:true, result} при успехе; ошибка конфига/внешнего сервиса — HTTP 400 {ok:false, error} с причиной на русском (никогда не 500). toolId — id инструмента из agent_get → tools[].id (настраивается через agent_update {patch:{tools:[...]}}). ИНСТРУМЕНТЫ ЗАПИСИ (AMO_CREATE_LEAD/…/HTTP_POST/SET_FIELD/ADD_TAG/HANDOFF): params — аргументы по argsSchema инструмента (для SET_FIELD: field,value; ADD_TAG: tag; HANDOFF: reason); по умолчанию это ПЕСОЧНИЦА (dryRun:true) — аргументы проверяются по схеме, возвращается что было бы отправлено, во внешние системы ничего не уходит; live:true отправляет по-настоящему (только внешние записи: amoCRM, Google Таблица, POST) — используй осознанно, это реальная запись." , inputSchema: { type: "object", properties: { agentId: { type: "string" }, toolId: { type: "string" }, params: { type: "object", description: "Аргументы вызова инструмента (см. описание)" }, live: { type: "boolean", description: "Только для инструментов записи: true — выполнить по-настоящему, иначе песочница (dry-run)" } }, required: ["agentId", "toolId"] } },
1241
+ { name: "agent_tool_calls", description: "Журнал вызовов инструментов ЗАПИСИ агента (GET /api/bots/agents/{agentId}/tool-calls): когда, какой инструмент, кто инициатор (AGENT/OWNER_TEST), статус (OK, FAILED, PENDING_CONFIRM, REJECTED по лимиту, DRY_RUN песочницы), аргументы без секретов, результат/ошибка. Фильтр toolId, limit до 200 (по умолч. 50). Хранится 90 дней. Read-only.", inputSchema: { type: "object", properties: { agentId: { type: "string" }, toolId: { type: "string" }, limit: { type: "number" } }, required: ["agentId"] } },
1208
1242
  // ---- Заявки сайта: файлы вложений ----
1209
1243
  { name: "site_lead_file_download", description: "Метаданные и ссылка на скачивание файла из заявки сайта (GET /api/bots/pages/{siteId}/leads/{leadId}/files/{fileId}, владелец/ADMIN сайта). Байты файла НЕ передаются через MCP (может быть большим/бинарным) — инструмент делает HEAD-запрос, чтобы подтвердить, что файл принадлежит этой заявке (404 иначе), и вернуть {fileId, fileName, contentType, sizeBytes, downloadUrl}. downloadUrl отдаёт файл только с тем же токеном, что у этого MCP (заголовок Authorization: Bearer zmcp_...), — скачай curl'ом с этим заголовком или открой заявку в кабинете (сайт → «Заявки»). Список файлов заявки с теми же полями уже есть в site_leads (поле files[] каждой заявки) — этот инструмент нужен, только когда нужна именно прямая ссылка на скачивание одного файла.", inputSchema: { type: "object", properties: { siteId: { type: "string" }, leadId: { type: "string" }, fileId: { type: "string" } }, required: ["siteId", "leadId", "fileId"] } },
1210
1244
  // ---- Вебхук-сценарии (/api/scenarios, PAT-доступ открыт решением владельца 14.10) ----
@@ -1520,6 +1554,10 @@ async function handleCall(params) {
1520
1554
  case "agent_create": return okResult(await api("/api/bots/agents", { method: "POST", body: { name: a.name, description: a.description } }));
1521
1555
  case "agent_update": {
1522
1556
  if (!a.patch || typeof a.patch !== "object") throw new Error("Передай patch — объект с полями агента для правки.");
1557
+ if (a.patch.tools !== undefined) {
1558
+ const errs = validateAgentTools(a.patch.tools);
1559
+ if (errs.length) throw new Error("Инструменты агента не прошли проверку:\n- " + errs.join("\n- "));
1560
+ }
1523
1561
  return okResult(await api(`/api/bots/agents/${a.agentId}`, { method: "PATCH", body: a.patch }));
1524
1562
  }
1525
1563
  case "agent_publish": return okResult(await api(`/api/bots/agents/${a.agentId}/publish`, { method: "POST" }));
@@ -1763,6 +1801,15 @@ async function handleCall(params) {
1763
1801
  await api(`/api/bots/${botId}/channels/${channelId}`, { method: "DELETE" });
1764
1802
  return okResult(`🗑️ Канал ${channelId} отключён от бота ${botId}.`);
1765
1803
  }
1804
+ case "graph_versions_list": {
1805
+ const graphId = uuidArg(a.graphId, "graphId");
1806
+ if (a.version == null) return okResult(await api(`/api/bots/graphs/${graphId}/versions`));
1807
+ const n = versionArg(a.version, "version");
1808
+ const to = a.compareTo == null || a.compareTo === "current" ? "current" : versionArg(a.compareTo, "compareTo");
1809
+ return okResult(await api(`/api/bots/graphs/${graphId}/versions/${n}/compare?to=${to}`));
1810
+ }
1811
+ case "graph_version_rollback":
1812
+ return okResult(await api(`/api/bots/graphs/${uuidArg(a.graphId, "graphId")}/versions/${versionArg(a.version, "version")}/rollback`, { method: "POST" }));
1766
1813
  case "unpublish_graph":
1767
1814
  return okResult(await api(`/api/bots/graphs/${uuidArg(a.graphId, "graphId")}/unpublish`, { method: "POST" }));
1768
1815
  case "link_create": {
@@ -2119,7 +2166,7 @@ async function handleCall(params) {
2119
2166
  case "broadcast_preview": {
2120
2167
  const ids = botIdsOf(a);
2121
2168
  await assertBroadcastBots(ids);
2122
- const filter = { tagsAll: strList(a.tagsAll), tagsNone: strList(a.tagsNone) };
2169
+ const filter = (await segmentFilterArg(a, ids[0])) || { tagsAll: strList(a.tagsAll), tagsNone: strList(a.tagsNone) };
2123
2170
  const perBot = [];
2124
2171
  for (const id of ids) {
2125
2172
  const r = await api(`/api/bots/${id}/broadcasts/preview`, { method: "POST", body: filter });
@@ -2133,7 +2180,7 @@ async function handleCall(params) {
2133
2180
  if (ids.length !== 1) throw new Error("Рассылка по сценарию — ровно один botId (сценарий принадлежит боту).");
2134
2181
  await assertBroadcastBots(ids);
2135
2182
  if (!a.name) throw new Error("Передай name рассылки.");
2136
- const audienceFilter = audienceFilterArg(a.audienceFilter) || { tagsAll: strList(a.tagsAll), tagsNone: strList(a.tagsNone) };
2183
+ const audienceFilter = (await segmentFilterArg(a, ids[0])) || { tagsAll: strList(a.tagsAll), tagsNone: strList(a.tagsNone) };
2137
2184
  const b = await api(`/api/bots/${ids[0]}/broadcasts`, { method: "POST", body: {
2138
2185
  name: a.name, audienceFilter,
2139
2186
  graphId: a.graphId, entryNodeId: a.entryNodeId || undefined, scheduledAt: toInstant(a.scheduledAt, "scheduledAt") } });
@@ -2146,9 +2193,11 @@ async function handleCall(params) {
2146
2193
  await assertBroadcastBots(ids);
2147
2194
  const name = String(pick("name") || "").trim();
2148
2195
  if (!name) throw new Error("Передай name рассылки (видно только тебе в списке рассылок).");
2149
- const audienceFilter = audienceFilterArg(a.audienceFilter);
2196
+ const audienceFilter = await segmentFilterArg(a, ids[0]);
2150
2197
  const dedup = a.dedup === true;
2151
2198
  const stagger = staggerArg(a.stagger);
2199
+ const localTime = hhmmArg(a.localTime, "localTime");
2200
+ if (localTime && (dedup || stagger)) throw new Error("localTime не сочетается с dedup/stagger: время группы задаёт stagger.startAt.");
2152
2201
  const body = {
2153
2202
  name, botIds: ids,
2154
2203
  ...(audienceFilter ? { audienceFilter } : {
@@ -2159,6 +2208,7 @@ async function handleCall(params) {
2159
2208
  scheduledAt: toInstant(pick("scheduledAt"), "scheduledAt"),
2160
2209
  ...(dedup ? { dedup } : {}),
2161
2210
  ...(stagger ? { stagger } : {}),
2211
+ ...(localTime ? { localTime } : {}),
2162
2212
  };
2163
2213
  if (body.scheduledAt && new Date(body.scheduledAt).getTime() <= Date.now()) {
2164
2214
  // Прошедшее время бэкенд молча считает «сейчас» — для черновика с устаревшей датой это сюрприз.
@@ -2172,6 +2222,44 @@ async function handleCall(params) {
2172
2222
  if (dedup || stagger) return okResult({ group: r, isGroup: true, ...(a.draftId ? { draftDeleted } : {}) });
2173
2223
  return okResult({ ...r, scheduledAt: body.scheduledAt, ...(a.draftId ? { draftDeleted } : {}) });
2174
2224
  }
2225
+ case "audience_segments": {
2226
+ const base = `/api/bots/${a.botId}/segments`;
2227
+ if (!a.botId) throw new Error("Передай botId (любой твой бот, list_bots).");
2228
+ if (a.action === "list") return okResult(await api(base));
2229
+ if (a.action === "create") {
2230
+ const name = String(a.name || "").trim();
2231
+ if (!name || name.length > 60) throw new Error("name: 1–60 символов.");
2232
+ const filter = audienceFilterArg(a.audienceFilter) || { tagsAll: strList(a.tagsAll), tagsNone: strList(a.tagsNone) };
2233
+ return okResult(await api(base, { method: "POST", body: { name, filter } }));
2234
+ }
2235
+ if (a.action === "delete") {
2236
+ if (!a.segmentId) throw new Error("Передай segmentId (audience_segments action=list).");
2237
+ await api(`${base}/${a.segmentId}`, { method: "DELETE" });
2238
+ return okResult(`Сегмент ${a.segmentId} удалён.`);
2239
+ }
2240
+ throw new Error("action: list | create | delete.");
2241
+ }
2242
+ case "bot_delivery_window": {
2243
+ if (!a.botId) throw new Error("Передай botId (list_bots).");
2244
+ const path = `/api/bots/${a.botId}/delivery-window`;
2245
+ if (a.action === "get") return okResult(await api(path));
2246
+ if (a.action === "set") {
2247
+ if (typeof a.enabled !== "boolean") throw new Error("enabled: true/false.");
2248
+ const quietFrom = hhmmArg(a.quietFrom, "quietFrom");
2249
+ const quietTo = hhmmArg(a.quietTo, "quietTo");
2250
+ if (a.enabled && (!quietFrom || !quietTo)) throw new Error("enabled:true требует quietFrom и quietTo (HH:mm).");
2251
+ if (quietFrom && quietFrom === quietTo) throw new Error("quietFrom и quietTo должны различаться.");
2252
+ const body = { enabled: a.enabled, quietFrom: quietFrom ?? null, quietTo: quietTo ?? null, timezone: a.timezone ? String(a.timezone).trim() : null };
2253
+ return okResult(await api(path, { method: "PUT", body }));
2254
+ }
2255
+ throw new Error("action: get | set.");
2256
+ }
2257
+ case "broadcast_test": {
2258
+ if (!a.botId) throw new Error("Передай botId (list_bots).");
2259
+ const body = { messages: normalizeBroadcastMessages(a.messages), chatId: a.chatId == null ? null : Number(a.chatId) };
2260
+ if (body.chatId != null && !Number.isFinite(body.chatId)) throw new Error("chatId — число.");
2261
+ return okResult(await api(`/api/bots/${a.botId}/broadcasts/test`, { method: "POST", body }));
2262
+ }
2175
2263
  case "broadcast_cancel":
2176
2264
  await api(`/api/bots/broadcasts/${a.broadcastId}/cancel`, { method: "POST" });
2177
2265
  return okResult(`⏹️ Рассылка ${a.broadcastId} отменяется (CANCELLING → CANCELLED).`);
@@ -2363,7 +2451,16 @@ async function handleCall(params) {
2363
2451
  case "agent_tool_test": {
2364
2452
  if (!a.agentId) throw new Error("Передай agentId.");
2365
2453
  const toolId = idArg(a.toolId, "toolId");
2366
- return okResult(await api(`/api/bots/agents/${a.agentId}/tools/${toolId}/test`, { method: "POST", body: { params: a.params || {} } }));
2454
+ const body = { params: a.params || {} };
2455
+ if (a.live === true) body.live = true;
2456
+ return okResult(await api(`/api/bots/agents/${a.agentId}/tools/${toolId}/test`, { method: "POST", body }));
2457
+ }
2458
+ case "agent_tool_calls": {
2459
+ if (!a.agentId) throw new Error("Передай agentId.");
2460
+ const q = new URLSearchParams();
2461
+ if (a.toolId) q.set("toolId", idArg(a.toolId, "toolId"));
2462
+ if (a.limit != null) q.set("limit", String(Math.max(1, Math.min(200, Number(a.limit) || 50))));
2463
+ return okResult(await api(`/api/bots/agents/${a.agentId}/tool-calls${q.toString() ? "?" + q : ""}`));
2367
2464
  }
2368
2465
  // ---- Заявки сайта: файлы ----
2369
2466
  case "site_lead_file_download": {