@retensy/mcp 0.23.0 → 0.25.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.
@@ -7,7 +7,7 @@
7
7
  "name": "retensy-mcp",
8
8
  "source": "./",
9
9
  "version": "0.15.1",
10
- "description": "Сборка и публикация воронок ботов (Telegram/MAX, Retensy Bots) из описания, рассылки, подключение ботов и сервисов, сайтов из блоков + публикация статей блога в Markdown — через MCP + скилл."
10
+ "description": "Сборка и публикация воронок ботов (Telegram/MAX, Retensy Bots) из описания или переноса из другого конструктора (BotHelp, SaleBot, Senler и др.), рассылки, подключение ботов и сервисов, сайтов из блоков + публикация статей блога в Markdown — через MCP + скилл."
11
11
  }
12
12
  ]
13
13
  }
@@ -2,7 +2,7 @@
2
2
  "name": "retensy-mcp",
3
3
  "displayName": "Retensy MCP",
4
4
  "version": "0.15.1",
5
- "description": "MCP-сервер + скилл для Retensy Bots: подключение ботов и сервисов, сборка и публикация воронок ботов (Telegram/MAX), рассылки, сайты из блоков (Zero-блок, дизайны, шаблоны, свои домены, заявки) и статьи блога в Markdown — всё одним токеном zmcp_.",
5
+ "description": "MCP-сервер + скилл для Retensy Bots: подключение ботов и сервисов, сборка и публикация воронок ботов (Telegram/MAX), перенос воронок из других конструкторов (BotHelp, SaleBot, Senler, Leadteh, Smartsender, ManyChat и др.), рассылки, сайты из блоков (Zero-блок, дизайны, шаблоны, свои домены, заявки) и статьи блога в Markdown — всё одним токеном zmcp_.",
6
6
  "author": { "name": "retensy", "url": "https://bots.retensy.com" },
7
7
  "homepage": "https://bots.retensy.com/bots",
8
8
  "repository": "https://github.com/retensy/retensy-mcp",
package/README.md CHANGED
@@ -7,6 +7,47 @@
7
7
 
8
8
  MCP-сервер (+ скилл для Claude Code) для **сборки и публикации воронок/автоматизаций ботов (Telegram, MAX и Instagram)** в сервисе [retensy `/bots`](https://bots.retensy.com/bots): из текстового описания → валидный граф сценария → заливка и публикация через API.
9
9
 
10
+ ---
11
+
12
+ ## Быстрый старт
13
+
14
+ 1. **Получи токен.** Залогинься на https://bots.retensy.com → открой `/bots/mcp-tokens` → создай токен → скопируй секрет `zmcp_...` (показывается один раз).
15
+ 2. **Подключи у своего клиента** (env-переменные везде одни и те же: `RETENSY_BASE_URL`, `RETENSY_MCP_TOKEN`):
16
+
17
+ **Claude Desktop** — добавь в `claude_desktop_config.json`:
18
+ ```json
19
+ {
20
+ "mcpServers": {
21
+ "retensy-mcp": {
22
+ "command": "npx",
23
+ "args": ["-y", "@retensy/mcp@latest"],
24
+ "env": {
25
+ "RETENSY_BASE_URL": "https://bots.retensy.com",
26
+ "RETENSY_MCP_TOKEN": "zmcp_ваш_токен"
27
+ }
28
+ }
29
+ }
30
+ }
31
+ ```
32
+
33
+ **Claude Code** — одной командой:
34
+ ```bash
35
+ claude mcp add retensy-mcp -e RETENSY_BASE_URL=https://bots.retensy.com -e RETENSY_MCP_TOKEN=zmcp_ваш_токен -- npx -y @retensy/mcp@latest
36
+ ```
37
+ Или как плагин (подтянет и скиллы, см. [«Вариант A» ниже](#вариант-a--как-плагин-claude-code-рекомендуется)):
38
+ ```text
39
+ /plugin marketplace add retensy/retensy-mcp
40
+ /plugin install retensy-mcp@retensy
41
+ ```
42
+
43
+ **Cursor** — Settings → MCP → Add new MCP Server (или `.cursor/mcp.json` в проекте) — тот же JSON-объект, что и для Claude Desktop выше (`mcpServers.retensy-mcp` с `command`/`args`/`env`).
44
+
45
+ 3. **Собери первую воронку.** Опиши сценарий словами агенту — он сам соберёт граф и опубликует (см. [«Использование»](#использование)), либо перенеси готовую воронку из другого сервиса — см. [«Перенос воронки из другого конструктора»](#перенос-воронки-из-другого-конструктора) ниже.
46
+
47
+ Проверить подключение в любом клиенте: спроси агента «настрой подключение» — вызовет `setup` и подскажет, если что-то не так.
48
+
49
+ ---
50
+
10
51
  - 🤖 **Сценарии ботов**: `list_bots`, `list_graphs`, `list_channels`, `get_graph`, `create_graph`, `update_graph`, `edit_graph_live`, `patch_graph`, `dry_run`, `publish_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`).
11
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).
12
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`.
@@ -20,6 +61,8 @@ MCP-сервер (+ скилл для Claude Code) для **сборки и пу
20
61
  - 📎 **Медиа**: `upload_file` грузит фото/видео/документы в библиотеку **/bots/files** (до 50 МБ) и возвращает публичный URL — его вставляешь в медиа-карточку сценария.
21
62
  - 🌐 **Сайты из блоков** (раздел «Страницы»): создание, правка операциями (блоки, Zero-блок со свободной вёрсткой, код блока, папки страниц, дизайны, шаблоны из библиотеки), публикация и откат, свои домены, заявки из форм и куда их доставлять — инструменты `site_*` + скилл `build-site`.
22
63
  - 🧠 **Скилл `build-bot-funnel`**: учит агента собирать корректный граф (типы узлов, ветки, кнопки, задержки) и проверять его перед публикацией. Поддерживает Telegram, MAX и Instagram.
64
+ - 🔁 **Скилл `rebuild-funnel-from-builder`**: переносит воронку из другого конструктора (BotHelp, SaleBot, Senler, Leadteh, Smartsender, ManyChat, PuzzleBot и др.) по export JSON/тексту, скриншотам или пересказу — в граф Retensy, новым черновиком, без перезаписи текущего сценария. См. [«Перенос воронки из другого конструктора»](#перенос-воронки-из-другого-конструктора).
65
+ - 🔗 **Скилл `build-publication-chain`**: учит агента собирать «Цепочки публикаций» (автопостинг source → граф обработки → N назначений) — линейно или графом (общий/раздельный ИИ-адаптер на ветках), UTM по назначению, согласование, фан-аут в один заход («1 источник → общий ИИ → 10 TG-каналов + 2 VK + 4 MAX»).
23
66
  - 📦 **Без зависимостей** — чистый Node ≥18, ставится и запускается сразу.
24
67
 
25
68
  ### Поддерживаемые платформы
@@ -41,7 +84,7 @@ MCP-сервер (+ скилл для Claude Code) для **сборки и пу
41
84
  /plugin install retensy-mcp@retensy
42
85
  ```
43
86
 
44
- Подтянутся MCP-сервер и скиллы `build-bot-funnel`, `build-site`, `send-broadcast`. Проверить: `/mcp` и `/plugin`.
87
+ Подтянутся MCP-сервер и скиллы `build-bot-funnel`, `rebuild-funnel-from-builder`, `build-site`, `send-broadcast`, `build-publication-chain`. Проверить: `/mcp` и `/plugin`.
45
88
 
46
89
  ### Вариант B — как обычный MCP-сервер (Claude Code / Cursor / Windsurf / любой MCP-клиент)
47
90
 
@@ -79,6 +122,10 @@ MCP-сервер (+ скилл для Claude Code) для **сборки и пу
79
122
 
80
123
  Отозвать токен можно там же — доступ блокируется мгновенно.
81
124
 
125
+ > С 14.10 тот же токен `zmcp_...` полностью покрывает и вебхук-сценарии (`/api/scenarios` — `scenario_*`),
126
+ > и управление подключениями сервисов (`/api/integrations` — `connect_integration`/`disconnect_integration`/
127
+ > `integration_update` и остальные `integration_*`), не только `/api/bots`. Отдельного токена под эти инструменты не нужно.
128
+
82
129
  > **Не знаешь, что делать?** Скажи агенту «настрой подключение» — он вызовет `setup`, объяснит шаги и попросит токен. Любой инструмент при отсутствии токена тоже вернёт пошаговую инструкцию.
83
130
 
84
131
  > Дев-окружение: `RETENSY_BASE_URL=http://localhost:8066`.
@@ -178,7 +225,7 @@ MCP-сервер (+ скилл для Claude Code) для **сборки и пу
178
225
  | `bot_user_runs(botId, chatId)` | журнал запусков с шагами — проверка, что сценарий реально выполнил действия (CRM, уведомление, HTTP) |
179
226
  | `dialog_messages(botId, chatId)` / `dialog_reply(botId, chatId, text)` | переписка с подписчиком / ответ оператора (уходит реальному человеку) |
180
227
  | `dialog_handoff(botId, chatId, active)` | передать диалог оператору (бот и ИИ молчат) / вернуть боту |
181
- | `bot_users_import(botId, rows)` | добавить подписчикам метки и новые поля (`rows: [{chatId, tags?, variables?}]`; существующие поля не перезаписываются) |
228
+ | `bot_users_import(botId, rows)` | добавить/обновить подписчиков пачкой JSON (`rows: [{chatId, tags?, variables?, subscribedAt?, source?, status?}]`; существующие поля не перезаписываются, `status: BLOCKED/UNSUBSCRIBED` только выставляет блокировку, никогда не снимает) |
182
229
  | `bot_runs(botId` или `runId)` | журнал прогонов бота / один прогон с шагами (статус `PARTIAL` — «завершён с ошибками») |
183
230
  | `scenario_runs(graphId, page?, size?)` | журнал прогонов одного сценария, включая headless (вебхук, заявки сайта, `TRIGGER_SCHEDULE`) |
184
231
  | `bot_delete(botId, confirm:true)` | удалить бота навсегда (только по явной просьбе) |
@@ -212,8 +259,13 @@ MCP-сервер (+ скилл для Claude Code) для **сборки и пу
212
259
  | `migration_presets()` | каталог источников миграции подписчиков (BotHelp/SaleBot/Senler/Leadteh/Smartsender/ManyChat/универсальный CSV) |
213
260
  | `migration_preview(botId, path, platform, mapping?)` | пробный прогон CSV (ничего не пишет): без `mapping` — определение заголовков/подсказка сопоставления, с `mapping` — полный dry-run (новые/существующие, превышение лимита) |
214
261
  | `migration_commit(botId, path, platform, mapping)` | реальный импорт подписчиков тем же `mapping`, что подтвердил `migration_preview` |
215
- | `chain_list()` / `chain_get(chainId)` | цепочки публикаций («Публикации» → «Цепочки», автопостинг source → destinations[]) / одна цепочка целиком |
216
- | `chain_create(chain)` / `chain_update(chainId, chain)` | создать / изменить цепочку целиком (`chain`: `name`, `source` TELEGRAM\|VK\|RSS, `destinations[]` TELEGRAM\|MAX\|VK\|INSTAGRAM, `steps[]` FILTER\|TEXT_TRANSFORM\|AI_ADAPT\|UTM\|DELAY, `approvalMode?`, `utmDefaults?`); HTTP 409 — прямой цикл с другой цепочкой |
262
+ | `bothelp_scenario_preview(botId, scenarioFile, externalRequestsDir?)` | dry-run структурной конвертации ОДНОГО сценария BotHelp (его собственный JSON-экспорт, не CSV) в граф Retensy — ничего не пишет, возвращает отчёт `notes[]` (что сконвертировано приближённо/заглушкой) |
263
+ | `bothelp_scenario_commit(botId, scenarioFile, externalRequestsDir?)` | то же + сохраняет граф (DRAFT); идемпотентно по `(botId, bothelpScenarioId)` — повторный вызов обновляет тот же граф |
264
+ | `chain_list()` / `chain_get(chainId)` | цепочки публикаций («Публикации» → «Цепочки», автопостинг source → граф обработки/назначений) / одна цепочка как хранится (legacy либо граф) |
265
+ | `chain_graph_get(chainId)` | граф цепочки ВСЕГДА graph-shaped `{nodes, edges}`, даже для старых линейных цепочек (синтезируется на лету) |
266
+ | `chain_create(chain)` / `chain_update(chainId, chain)` | создать / изменить цепочку целиком (`chain`: `name`, `source` TELEGRAM\|VK\|RSS, `approvalMode?`, `utmDefaults?`, и либо legacy `destinations[]`+`steps[]`, либо граф `nodes[]`+`edges[]` — дерево с ветвлением, не произвольный DAG: один узел может кормить несколько назначений, слияния путей нет); HTTP 409 — прямой цикл с другой цепочкой; графовый 400 — «Схема цепочки: …» (см. `chain_validate`) |
267
+ | `chain_validate(nodes, edges)` / `chain_validate(chain)` | офлайн-проверка графа (зеркало серверного `ChainGraphValidator`, не строже сервера) — перед `chain_create`/`chain_update` на сложном графе |
268
+ | `chain_destinations_catalog(botIds?)` | одним вызовом: все TG/MAX-каналы по ботам владельца + VK-аккаунты — собрать «1 источник → N каналов» без ручного обхода `list_bots`+`list_channels`+`vk_status` |
217
269
  | `chain_delete(chainId, confirm:true)` | удалить цепочку (теряется история заданий) |
218
270
  | `chain_enable(chainId)` / `chain_pause(chainId)` | включить / поставить на паузу приём новых постов источника |
219
271
  | `chain_runs(chainId, page?, size?)` | история заданий цепочки, новые сверху (бэкенд без постраничной выдачи — `page`/`size`, если переданы, режут список в MCP) |
@@ -225,6 +277,15 @@ MCP-сервер (+ скилл для Claude Code) для **сборки и пу
225
277
  | `scenario_timeseries(graphId, from, to)` | дневной ряд прогонов сценария `{ok, failed, other}` (по узлам — `graph_analytics`) |
226
278
  | `agent_tool_test(agentId, toolId, params?)` | проверить инструмент агента (`AMO_LOOKUP`/`SHEETS_LOOKUP`/`HTTP_API`) напрямую, без модели — бюджет ИИ не тратится |
227
279
  | `site_lead_file_download(siteId, leadId, fileId)` | метаданные вложения заявки + ссылка на скачивание (сам файл через MCP не передаётся — HEAD-запрос, не GET) |
280
+ | `scenario_list()` | сценарии автора, включая вебхук-сценарии (`sourceType: WEBHOOK`\|`BOT`) — в `list_graphs` вебхук-сценарии не входят |
281
+ | `scenario_create(name, botId?)` | создать сценарий: без `botId` — вебхук-сценарий (сервер сам выпускает путь входа и секрет), с `botId` — бот-сценарий, сразу привязанный к боту |
282
+ | `scenario_get(scenarioId)` | сценарий целиком — граф + `source` (у вебхук-сценария: путь/секрет входа) + `outputBinding` |
283
+ | `scenario_update_source(scenarioId, botId?, channel?, recipientFrom?)` | привязка выхода вебхук-сценария, заменяется ЦЕЛИКОМ (без `botId` — привязка снимается, без `recipientFrom` — очищается); только для `sourceType:"WEBHOOK"` |
284
+ | `scenario_publish(scenarioId)` | опубликовать вебхук-сценарий (бот-сценарий — через `publish_graph`) |
285
+ | `scenario_rotate_secret(scenarioId)` | выпустить новый секрет входа вебхук-сценария — старый сразу перестаёт приниматься |
286
+ | `agent_api_key_create(agentId, name?)` / `agent_api_key_list(agentId)` / `agent_api_key_revoke(agentId, keyId, confirm:true)` | API-ключи публичного чата агента (`zagt_...`, секрет виден один раз) — для `POST /api/public/agents/{agentId}/chat` без основного токена |
287
+ | `ai_packs()` | каталог пакетов ответов ИИ сверх тарифа + текущий баланс (`aiPackAnswersLeft`) |
288
+ | `ai_pack_purchase(sku, method?, confirm:true)` | купить пакет — возвращает ссылку на оплату, деньги не списываются этим вызовом |
228
289
 
229
290
  ### Рассылки
230
291
 
@@ -285,6 +346,20 @@ node skills/build-bot-funnel/validate.mjs path/to/import.json --platform=MAX
285
346
 
286
347
  ---
287
348
 
349
+ ## Перенос воронки из другого конструктора
350
+
351
+ Есть готовая воронка в BotHelp, SaleBot, Senler, Leadteh, Smartsender, ManyChat, PuzzleBot или
352
+ любом другом сервисе — скилл [`rebuild-funnel-from-builder`](skills/rebuild-funnel-from-builder/SKILL.md)
353
+ учит агента перенести её в Retensy: разобрать export JSON/текст/скриншоты/пересказ → построить
354
+ инвентарь блоков → сопоставить с реальными типами узлов Retensy (таблица в
355
+ [reference/mapping.md](skills/rebuild-funnel-from-builder/reference/mapping.md)) → прямо спросить
356
+ про то, что не мапится, вместо того чтобы выдумывать замену → собрать граф → проверить
357
+ `validate.mjs`/`dry_run` → залить **новым** черновиком (никогда не поверх текущего сценария) →
358
+ опубликовать только после явного подтверждения. Перенос самих подписчиков (не графа) — отдельно,
359
+ через `migration_presets`/`migration_preview`/`migration_commit` (CSV).
360
+
361
+ ---
362
+
288
363
  ## Разработка
289
364
 
290
365
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retensy/mcp",
3
- "version": "0.23.0",
3
+ "version": "0.25.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",
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",
25
25
  "validate": "node skills/build-bot-funnel/validate.mjs"
26
26
  },
27
27
  "engines": {
@@ -73,8 +73,15 @@
73
73
  Бот должен быть админом канала/группы, иначе события оттуда не придут.
74
74
  - `TRIGGER_ANY_UPDATE` — `{}`, ловит ЛЮБОЙ апдейт. Приоритет самый низкий: срабатывает, только
75
75
  если не подошёл ни один конкретный триггер. Удобен как «ничего не понял» / отладка.
76
- - `TRIGGER_WEBHOOK` — `{}`, точка входа сценария с источником WEBHOOK (не бот). Такие сценарии
77
- создаются в вебе; у графа бота этот триггер не сработает.
76
+ - `TRIGGER_WEBHOOK` — `{}`, точка входа сценария с источником WEBHOOK (не бот); у графа БОТ-сценария
77
+ этот триггер не сработает. Такой сценарий создаётся `scenario_create(name)` без `botId` — сервер сам
78
+ выпускает путь входа и секрет (видны в `scenario_get` → `source.config`, ротируются `scenario_rotate_secret`);
79
+ узлы/рёбра — как у обычного графа, через `update_graph`/`edit_graph_live`/`patch_graph` по вернувшемуся id.
80
+ Выходной бот (кому слать ответ) задаётся/снимается отдельно через `scenario_update_source(scenarioId, botId?,
81
+ channel?, recipientFrom?)` — поле заменяется ЦЕЛИКОМ, без `botId` сценарий остаётся headless. Публикуется
82
+ такой сценарий `scenario_publish`, НЕ `publish_graph` (у него на source.type WEBHOOK — 409). Список своих
83
+ сценариев, включая вебхук-сценарии (их нет в `list_graphs`), — `scenario_list`; удаление — `delete_graph`
84
+ (тем же id). Собрать так же можно и в вебе — оба пути ведут в один и тот же объект.
78
85
  - **Внешние события интеграции** — тот же `TRIGGER_WEBHOOK`, но с заполненными
79
86
  `{ "provider": "<provider-ключ из integration_catalog, нижний регистр>", "connectionId": "<id из list_integrations>",
80
87
  "event": "<events[].key нужного события из inbound.events того же provider в integration_catalog>" }`.
@@ -0,0 +1,239 @@
1
+ ---
2
+ name: build-publication-chain
3
+ description: Собрать «Цепочку публикаций» (автопостинг source → обработка → назначения) для сервиса /bots через MCP retensy-mcp — спроектировать граф (DAG-дерево с ветвлением), настроить общий/раздельный ИИ-адаптер, UTM на назначение, квоту, режим согласования и 1→N фан-аут (несколько TG-каналов + VK + MAX одним шагом). Использовать, когда пользователь описывает автопостинг/репост между площадками словами и просит «настроить цепочку», «автоматически репостить», «разослать по каналам», «собрать схему публикаций».
4
+ ---
5
+
6
+ # Сборка цепочки публикаций (retensy /bots → «Публикации» → «Цепочки»)
7
+
8
+ Превращает описание автопостинга в `PublishChain` и заливает его через MCP-инструменты `chain_*`
9
+ сервера `retensy-mcp`. Цепочка — это **источник → граф обработки → одно или несколько назначений**:
10
+ один пост в источнике может размножиться на N каналов за один проход, с общей или раздельной
11
+ обработкой на пути к каждому.
12
+
13
+ ## Когда применять
14
+ Пользователь хочет: «публикуй из этого Telegram-канала во все остальные», «перепощивай VK в
15
+ Telegram с меткой UTM», «собери контент-хаб: один канал — источник, остальные 10 — зеркала»,
16
+ «прогоняй через ИИ и переписывай под каждую площадку». Не для разового поста (`channel_post`/`vk_post`)
17
+ и не для рассылок по подписчикам бота (`broadcast_*`) — цепочка работает с ПОСТАМИ ИСТОЧНИКА, не с
18
+ аудиторией бота.
19
+
20
+ ## Модель данных — коротко
21
+
22
+ `PublishChain` = `{id, name, enabled, approvalMode, source, utmDefaults, createdAt/updatedAt, И ЛИБО
23
+ legacy (steps[]+destinations[]) ЛИБО граф (nodes[]+edges[])}`.
24
+
25
+ - **`source`** — ВСЕГДА отдельное top-level поле, в графовом режиме тоже. Узел `SOURCE` в `nodes` —
26
+ просто маркер-корень графа, он НЕ дублирует конфигурацию источника.
27
+ `{type: "TELEGRAM"|"VK"|"RSS", botId?, chatId?, accountId?, rssUrl?}`.
28
+ - TELEGRAM: `botId`+`chatId` — свой привязанный канал (`chain_destinations_catalog` → `telegram[]`,
29
+ или `bot_channel_list`/`list_channels`). Источник срабатывает сразу по событию (webhook).
30
+ - VK: `accountId` — подключённый VK-аккаунт (`chain_destinations_catalog` → `vk[]`, или `vk_status`).
31
+ Опрос раз в 3 минуты.
32
+ - RSS: `rssUrl` — любой http(s)-URL. Опрос раз в 3 минуты.
33
+
34
+ - **Граф (режим «Схема»)** — приоритетнее legacy, если `nodes` непуст. Дерево-с-ветвлением
35
+ (**out-tree**), НЕ произвольный DAG: у любого не-`SOURCE` узла **ровно одно** входящее ребро;
36
+ ветвление делается НЕСКОЛЬКИМИ исходящими рёбрами ОДНОГО узла (например, один общий `AI_ADAPT` →
37
+ рёбра на 10 узлов `DESTINATION`). Слияния путей (несколько родителей у узла) не поддерживается.
38
+ ```
39
+ nodes: [{ id, type: SOURCE|FILTER|TEXT_TRANSFORM|AI_ADAPT|UTM|DELAY|DESTINATION, x?, y?,
40
+ step?: {...поля шага, см. ниже — только для type≠SOURCE/DESTINATION},
41
+ destination?: {id, type: TELEGRAM|MAX|VK|INSTAGRAM, botId?, chatId?, accountId?, utmOverride?} }]
42
+ edges: [{ fromNodeId, toNodeId }]
43
+ ```
44
+ `x`/`y` — координаты на холсте, чисто для UI редактора; можно не задавать (MCP-агенту они не нужны).
45
+ Узел — «мешок полей»: только поля своего `type` что-то значат.
46
+
47
+ - **Legacy (линейный режим, `nodes` пуст)** — проще для одного source → один путь обработки → N
48
+ назначений БЕЗ ветвления посередине (ветвление всё равно есть — просто неявное, как «один путь,
49
+ потом веер в destinations»):
50
+ ```
51
+ steps: [{ type: FILTER|TEXT_TRANSFORM|AI_ADAPT|UTM|DELAY, ...поля }] // применяются по порядку ко всем
52
+ destinations: [{ type: TELEGRAM|MAX|VK|INSTAGRAM, botId?, chatId?, accountId?, utmOverride? }]
53
+ ```
54
+ Используй legacy, когда обработка ОДИНАКОВА для всех назначений (линия, без развилок). Переходи на
55
+ граф, когда назначениям нужна РАЗНАЯ обработка (например, свой тон ИИ под каждую площадку) или нужно
56
+ явно показать ветвление в UI.
57
+
58
+ - **Шаги обработки (`step`/`steps[]` — поля по `type`)**:
59
+ - `FILTER`: `containsAny[]`, `notContainsAny[]`, `hasMedia` (true=только с медиа, false=только без,
60
+ null=не проверять), `minLength`, `requireHashtag`.
61
+ - `TEXT_TRANSFORM`: `prependText`, `appendText`, `removeLinks`, `removeHashtags`, `replaceFrom`/`replaceTo`.
62
+ - `AI_ADAPT`: `aiMode: NONE|LIGHT|FULL`, `targetPlatform`, `tone`, `targetLength`, `useEmoji`,
63
+ `useHashtags`, `preserveLinks` (default true), `preserveAdMarkers` (default true), `instructions`.
64
+ Тратит AI-бюджет тарифа (`bot_ai_usage`) — ОДИН РАЗ на элемент источника, даже если один узел
65
+ `AI_ADAPT` кормит 10 назначений (общий узел = одна оплата на пост, не 10).
66
+ - `UTM`: самостоятельного конфига не несёт — разметку делает `utmDefaults`/`destination.utmOverride`
67
+ (ниже), шаг `UTM` в графе просто фиксирует момент применения в цепочке обработки.
68
+ - `DELAY`: `delaySeconds`.
69
+
70
+ - **`destination`/`destinations[]`**: `{id (строка, стабильный ключ идемпотентности — не меняй после
71
+ первого сохранения), type: TELEGRAM|MAX|VK|INSTAGRAM, botId?, chatId?, accountId?, utmOverride?}`.
72
+ TELEGRAM/MAX — `botId`+`chatId` (свой привязанный канал, бот должен быть админом). VK — `accountId`.
73
+ **INSTAGRAM принимается моделью, но публикация ГАРАНТИРОВАННО уходит в `SKIPPED`** (не `FAILED`) —
74
+ нет медиа-процесса в этой волне; не предлагай его пользователю как рабочее назначение, только если
75
+ он сам просит «добавить на будущее».
76
+
77
+ - **`utmDefaults`** (на уровне цепочки) / **`destination.utmOverride`** (на уровне назначения, `null` =
78
+ взять `utmDefaults`): `{enabled, source, medium, campaign, content, term,
79
+ overridePolicy: KEEP_EXISTING|OVERRIDE (default KEEP_EXISTING), skipInternalLinks (default true)}`.
80
+ Ставь РАЗНЫЙ `utmOverride.source`/`campaign` на разных назначениях, если нужно различать трафик по
81
+ каналам в аналитике — иначе один `utmDefaults` на всю цепочку.
82
+
83
+ - **`approvalMode`**: `NONE` (публикуется сразу) | `AI_ONLY` (на согласование — только то, что прошло
84
+ через `AI_ADAPT` с режимом ≠ `NONE`) | `ALL` (вообще всё, даже без ИИ). Согласование — через
85
+ `chain_runs` (ищи `AWAITING_APPROVAL`) → `chain_approve`/`chain_reject`.
86
+
87
+ - **Квота**: каждое назначение = отдельная публикация в месячной квоте владельца (как `channel_post`/
88
+ `vk_post`) — 10 TG-каналов + 2 VK + 4 MAX в одной цепочке = 16 публикаций за один проход источника,
89
+ это не одна операция с точки зрения лимита тарифа. Предупреди пользователя при большом фан-ауте.
90
+
91
+ ## Рабочий процесс
92
+
93
+ 1. **Собери вводные.** Источник (платформа + канал/аккаунт/URL), что меняется в тексте по пути
94
+ (фильтры, переписывание, ИИ), куда публикуется (один список площадок или разные пути на разные
95
+ площадки), нужно ли согласование, нужна ли UTM-метка и различается ли она по каналам.
96
+
97
+ 2. **Найди id источника/назначений одним вызовом — `chain_destinations_catalog`** (без `botIds` —
98
+ обойдёт все боты владельца): вернёт `{telegram: [{botId, botName, chatId, title, type, status}],
99
+ max: [{...}], vk: [{id, label, enabled, isDefault}]}`. Это замена ручного обхода `list_bots` +
100
+ `list_channels` на каждого бота + `vk_status`. Бот должен быть администратором канала, иначе он не
101
+ попадёт в `linked-chats` этого бота (и в каталог) — такие случаи решаются в самом /bots, не здесь.
102
+
103
+ 3. **Выбери режим:**
104
+ - Одна обработка на всех → **legacy** (`steps`+`destinations`), проще и короче JSON.
105
+ - Разная обработка по ветвям, либо хочешь явную схему в UI, либо общий дорогой узел (например,
106
+ один `AI_ADAPT`, кормящий много каналов, чтобы не платить за адаптацию N раз) → **граф**
107
+ (`nodes`+`edges`).
108
+
109
+ 4. **Собери граф/legacy и проверь офлайн — `chain_validate`** (nodes/edges напрямую или
110
+ `chain: {...}`), ПЕРЕД отправкой на сервер. Зеркалит серверный `ChainGraphValidator`: ровно один
111
+ `SOURCE` без входящих рёбер, у любого другого узла ровно одно входящее, `DESTINATION` без исходящих,
112
+ без циклов, каждый `DESTINATION` достижим от `SOURCE`. Возвращает `{valid, errors: [{nodeId,
113
+ message}]}` — почини всё, что найдёт, прежде чем звать `chain_create`/`chain_update`. Для legacy
114
+ (`steps`+`destinations`) валидация не нужна — она всегда корректна по построению.
115
+
116
+ 5. **Создай/обнови — `chain_create`/`chain_update`.** 400 — не хватает полей или источник/назначение
117
+ не найдены/не твои (чаще всего: бот не админ канала, или `accountId` не свой); в графовом режиме
118
+ 400 также приходит как «Схема цепочки: <причины>» — те же правила, что в п.4, просто сервер —
119
+ последнее слово. 409 — прямой цикл с другой твоей цепочкой (A→B, когда уже есть B→A): переосмысли
120
+ направление, не пытайся обойти через третью цепочку (сервер ловит только прямой случай — не создавай
121
+ его намеренно).
122
+
123
+ 6. **Включи — `chain_enable`** (черновик создаётся выключенным, если явно не передать `enabled:true`).
124
+ TELEGRAM начнёт публиковать сразу по следующему посту в источнике; VK/RSS — со следующего опроса
125
+ (до 3 минут).
126
+
127
+ 7. **Проверь первый прогон — `chain_runs`** (page/size режут список на стороне MCP, бэкенд отдаёт
128
+ всё целиком). Статусы: `PENDING→RUNNING→DONE|FAILED`, либо `AWAITING_APPROVAL` (approvalMode),
129
+ либо `SKIPPED` (не прошло фильтр / loop-guard / назначение исчезло / Instagram). `FAILED` с
130
+ транзиентной причиной ретраится автоматически (до 5 раз, бэкофф); постоянная ошибка (бот не админ,
131
+ квота кончилась, VK отклонил) — сразу `FAILED` без ретраев, почини причину и `chain_retry` вручную.
132
+
133
+ 8. **Отчитайся**: источник, число шагов/ветвей, число назначений по площадкам, включена ли, режим
134
+ согласования, ссылка/id цепочки.
135
+
136
+ ## Пример 1 — простой линейный репост (legacy)
137
+
138
+ TG-канал → убрать ссылки → разметить UTM → в VK-сообщество.
139
+
140
+ ```json
141
+ {
142
+ "name": "TG блог → VK стена",
143
+ "enabled": true,
144
+ "approvalMode": "NONE",
145
+ "source": { "type": "TELEGRAM", "botId": "<botId из chain_destinations_catalog.telegram>", "chatId": 1001 },
146
+ "steps": [
147
+ { "type": "TEXT_TRANSFORM", "removeLinks": true },
148
+ { "type": "UTM" }
149
+ ],
150
+ "destinations": [
151
+ { "type": "VK", "accountId": "<accountId из chain_destinations_catalog.vk>",
152
+ "utmOverride": { "enabled": true, "source": "vk", "medium": "repost", "campaign": "blog" } }
153
+ ],
154
+ "utmDefaults": { "enabled": true, "source": "tg", "medium": "repost", "campaign": "blog" }
155
+ }
156
+ ```
157
+
158
+ ## Пример 2 — «1 источник → общий ИИ → 10 TG-каналов + 2 VK + 4 MAX» (граф, фан-аут)
159
+
160
+ Один общий узел `AI_ADAPT` адаптирует пост под нейтральный деловой тон ОДИН раз, затем веером — на
161
+ 16 назначений без повторной оплаты ИИ за каждое. Ниже сокращённо (3 назначения для примера, остальные —
162
+ та же форма узла/ребра с новым `id`).
163
+
164
+ ```json
165
+ {
166
+ "name": "Контент-хаб: 1 источник → ИИ → все каналы",
167
+ "enabled": true,
168
+ "approvalMode": "AI_ONLY",
169
+ "source": { "type": "TELEGRAM", "botId": "<botId>", "chatId": 1001 },
170
+ "utmDefaults": { "enabled": true, "source": "hub", "medium": "crosspost", "campaign": "2026q4" },
171
+ "nodes": [
172
+ { "id": "src", "type": "SOURCE" },
173
+ { "id": "ai1", "type": "AI_ADAPT",
174
+ "step": { "type": "AI_ADAPT", "aiMode": "LIGHT", "tone": "деловой, нейтральный",
175
+ "preserveLinks": true, "preserveAdMarkers": true } },
176
+ { "id": "d-tg-1", "type": "DESTINATION",
177
+ "destination": { "id": "d-tg-1", "type": "TELEGRAM", "botId": "<botId>", "chatId": 2001 } },
178
+ { "id": "d-tg-2", "type": "DESTINATION",
179
+ "destination": { "id": "d-tg-2", "type": "TELEGRAM", "botId": "<botId>", "chatId": 2002 } },
180
+ { "id": "d-vk-1", "type": "DESTINATION",
181
+ "destination": { "id": "d-vk-1", "type": "VK", "accountId": "<accountId1>",
182
+ "utmOverride": { "enabled": true, "source": "vk", "medium": "crosspost", "campaign": "2026q4" } } },
183
+ { "id": "d-max-1", "type": "DESTINATION",
184
+ "destination": { "id": "d-max-1", "type": "MAX", "botId": "<maxBotId>", "chatId": 3001 } }
185
+ ],
186
+ "edges": [
187
+ { "fromNodeId": "src", "toNodeId": "ai1" },
188
+ { "fromNodeId": "ai1", "toNodeId": "d-tg-1" },
189
+ { "fromNodeId": "ai1", "toNodeId": "d-tg-2" },
190
+ { "fromNodeId": "ai1", "toNodeId": "d-vk-1" },
191
+ { "fromNodeId": "ai1", "toNodeId": "d-max-1" }
192
+ ]
193
+ }
194
+ ```
195
+
196
+ Чтобы собрать все 16 назначений (10 TG + 2 VK + 4 MAX): пройдись по `chain_destinations_catalog`
197
+ результату (`telegram[]`, `vk[]`, `max[]`), для каждого выбранного канала добавь один узел
198
+ `DESTINATION` с уникальным `id` и одно ребро `{fromNodeId: "ai1", toNodeId: "<тот же id>"}` — узел
199
+ `ai1` остаётся общим, обработка оплачивается один раз на пост источника, не 16.
200
+
201
+ ## Пример 3 — раздельная обработка по ветвям (граф, разный тон на разные площадки)
202
+
203
+ Источник → в Telegram без изменений, в VK — через ИИ с более разговорным тоном.
204
+
205
+ ```json
206
+ {
207
+ "name": "TG как есть, VK — адаптировано",
208
+ "source": { "type": "TELEGRAM", "botId": "<botId>", "chatId": 1001 },
209
+ "nodes": [
210
+ { "id": "src", "type": "SOURCE" },
211
+ { "id": "d-tg", "type": "DESTINATION",
212
+ "destination": { "id": "d-tg", "type": "TELEGRAM", "botId": "<botId>", "chatId": 2001 } },
213
+ { "id": "ai-vk", "type": "AI_ADAPT",
214
+ "step": { "type": "AI_ADAPT", "aiMode": "LIGHT", "tone": "неформальный, дружелюбный" } },
215
+ { "id": "d-vk", "type": "DESTINATION",
216
+ "destination": { "id": "d-vk", "type": "VK", "accountId": "<accountId>" } }
217
+ ],
218
+ "edges": [
219
+ { "fromNodeId": "src", "toNodeId": "d-tg" },
220
+ { "fromNodeId": "src", "toNodeId": "ai-vk" },
221
+ { "fromNodeId": "ai-vk", "toNodeId": "d-vk" }
222
+ ]
223
+ }
224
+ ```
225
+
226
+ (Источник ветвится напрямую на два пути — это тоже «несколько исходящих у одного родителя», просто
227
+ родитель здесь `SOURCE`, а не промежуточный узел обработки.)
228
+
229
+ ## Авторизация MCP
230
+
231
+ Персональный токен (`/bots/mcp-tokens`, `zmcp_…`). Нет токена/ошибка доступа → вызови `setup`,
232
+ объясни шаги пользователю, сохрани токен через `set_token`. Также работают env `RETENSY_MCP_TOKEN`
233
+ и session-cookie.
234
+
235
+ ## Если возможности не хватает
236
+
237
+ При неудаче инструмента сервер шлёт анонимный отчёт на `POST /api/mcp/report` (без секретов) —
238
+ не выдумывай обходных путей для того, чего нет в этой схеме, скажи прямо и не повторяй падающий
239
+ вызов по кругу. Пользователь может выключить отчёты `RETENSY_MCP_TELEMETRY=off`.
@@ -0,0 +1,154 @@
1
+ ---
2
+ name: rebuild-funnel-from-builder
3
+ description: Перенести воронку (сценарий бота) из другого конструктора — BotHelp, SaleBot, Senler, Leadteh, Smartsender, ManyChat, PuzzleBot или любого другого — в Retensy. На входе export JSON/текст, скриншоты редактора или устное описание воронки другого сервиса; на выходе валидный граф retensy-bot-graph, созданный НОВЫМ черновиком (никогда не перезаписывая существующий активный сценарий), проверенный validate.mjs/dry_run и опубликованный только после подтверждения пользователя. Использовать, когда пользователь просит «перенеси воронку из BotHelp/SaleBot/...», «импортируй бота с другого конструктора», присылает экспорт JSON/CSV другого сервиса, скриншоты его редактора сценариев или пересказывает словами, как была устроена воронка там.
4
+ ---
5
+
6
+ # Перенос воронки из другого конструктора в Retensy
7
+
8
+ Превращает воронку, собранную в ДРУГОМ сервисе (BotHelp, SaleBot, Senler, Leadteh, Smartsender,
9
+ ManyChat, PuzzleBot — или любом не названном здесь), в валидный граф `retensy-bot-graph`. Источник —
10
+ что угодно: export JSON/CSV, скопированный текст из экспорта, скриншоты экрана редактора, либо
11
+ устный пересказ пользователем «как это было устроено».
12
+
13
+ Это не альтернатива [`build-bot-funnel`](../build-bot-funnel/SKILL.md), а его надстройка для
14
+ конкретного случая «source = чужой конструктор»: формат графа, типы узлов, правила валидатора,
15
+ публикация — всё то же самое, описано там. Прочитай
16
+ [`../build-bot-funnel/reference/schema.md`](../build-bot-funnel/reference/schema.md) и
17
+ [`../build-bot-funnel/reference/validation.md`](../build-bot-funnel/reference/validation.md) —
18
+ это единственный источник правды о том, какие типы узлов РЕАЛЬНО существуют в Retensy. Не выдумывай
19
+ несуществующий тип узла только потому, что в другом конструкторе была похожая кнопка.
20
+
21
+ ## Когда применять
22
+
23
+ Пользователь присылает экспорт/скриншоты/описание воронки ДРУГОГО бот-конструктора и просит её
24
+ «перенести», «воссоздать», «импортировать», «сделать то же самое у нас». Если он просто описывает
25
+ воронку своими словами без привязки к другому сервису — это обычный `build-bot-funnel`.
26
+
27
+ ## Рабочий процесс
28
+
29
+ ### 1. Собери вход и опиши его как инвентарь блоков
30
+
31
+ Прими то, что есть, без требования единого формата:
32
+ - **Export JSON/XML/CSV** другого конструктора — прочитай файл целиком, не фрагментом.
33
+ - **Вставленный текст** экспорта (многие сервисы отдают воронку как список шагов текстом/таблицей).
34
+ - **Скриншоты редактора** — опиши по ним каждый блок: тип, текст, кнопки и их подписи, куда ведёт
35
+ каждая кнопка (по стрелке или по названию следующего блока).
36
+ - **Устный пересказ** — задавай уточняющие вопросы по узлам (см. п.3), не додумывай недостающее
37
+ самостоятельно без спроса.
38
+
39
+ Построй **плоский инвентарь блоков** ДО всякого маппинга: порядковый номер/id в исходнике, тип блока
40
+ как он назван у источника (напр. «Сообщение», «Условие», «Задержка», «Webhook», «Оплата»), текст/содержимое,
41
+ исходящие связи (на какой блок/по какому условию). Это промежуточный список, который потом построчно
42
+ переводится в узлы Retensy — не пропускай этот шаг и не иди сразу в генерацию JSON по памяти о скриншотах.
43
+
44
+ ### 2. Сопоставь концепции с типами узлов Retensy
45
+
46
+ Таблица сопоставления — в [reference/mapping.md](reference/mapping.md): по каждому общему понятию
47
+ builders (сообщение, кнопка, задержка, тег, переменная, условие/ветка, оплата, форма, интеграция,
48
+ AI/бот-ответчик, рассылка, вебхук) — какой РЕАЛЬНЫЙ тип узла/конфиг Retensy его покрывает, и ссылка
49
+ на точный раздел schema.md. Особенности конкретных источников (BotHelp, SaleBot, Senler, Leadteh,
50
+ Smartsender, ManyChat, PuzzleBot) и частые ловушки каждого — там же.
51
+
52
+ Общее правило маппинга: сопоставляй **по смыслу шага**, а не по названию блока источника. «Условие»
53
+ в одном конструкторе может оказаться у нас `CONDITION` (да/нет), `BRANCH` (A/B) или `SWITCH`
54
+ (детерминированный выбор по значению) — смотри, что блок реально делает.
55
+
56
+ ### 3. Зафиксируй немаппируемое и спроси пользователя
57
+
58
+ После прохода по инвентарю у тебя будет три корзины:
59
+ - **Маппится напрямую** — идёт в граф.
60
+ - **Маппится с потерей/приближением** (напр. специфичный виджет оплаты источника → наш `issue_invoice`
61
+ без части его настроек) — помечай и предупреждай пользователя, что именно теряется.
62
+ - **Не маппится вовсе** (нет такого типа узла/действия в Retensy — см. schema.md) — **не выдумывай
63
+ обходной путь молча**. Перечисли пользователю такие блоки списком («в источнике был блок X — в
64
+ Retensy аналога нет; варианты: пропустить / заменить на Y / реализовать иначе») и жди решения,
65
+ прежде чем собирать граф целиком. Это тот же принцип, что в `build-bot-funnel`: отказ инструмента
66
+ на несуществующую возможность уже регистрируется автоматически на сервере — тебе отдельно
67
+ дублировать это не нужно, просто не подменяй отсутствующее самоделкой.
68
+
69
+ Не выдумывай маркетинговые тексты за пользователя — если текст сообщения не виден в источнике
70
+ (обрезан на скриншоте, не указан в экспорте), спроси его или пометь `[ВСТАВЬ ТЕКСТ]` только если
71
+ пользователь сам разрешил так делать.
72
+
73
+ ### 4. Построй граф
74
+
75
+ Дальше — как в `build-bot-funnel`: дерево узлов и рёбер, у каждой кнопки-выбора свой `btn_N`,
76
+ валидные UUID, хотя бы один корневой `TRIGGER_*`, без циклов вне `ASK_QUESTION`/`DELAY`/`SCHEDULE`,
77
+ без узлов `END`. Все детали типов/конфигов — в schema.md, не здесь (чтобы не держать два
78
+ расходящихся описания одного формата).
79
+
80
+ Особенность переноса: исходная воронка почти всегда имела **одну** точку входа (команда/кнопка
81
+ запуска у источника). Замени её на подходящий Retensy-триггер по платформе назначения
82
+ (`TRIGGER_COMMAND` для Telegram/MAX, `TRIGGER_IG_DM`/`TRIGGER_IG_COMMENT`/... для Instagram) — см.
83
+ раздел триггеров в schema.md и платформенные отличия в самом начале `build-bot-funnel/SKILL.md`.
84
+
85
+ ### 5. Проверь локально
86
+
87
+ ```bash
88
+ node "<путь к build-bot-funnel>/validate.mjs" <путь к import.json>
89
+ # Для IG-бота:
90
+ node "<путь к build-bot-funnel>/validate.mjs" <путь к import.json> --platform=INSTAGRAM
91
+ # Для MAX-бота:
92
+ node "<путь к build-bot-funnel>/validate.mjs" <путь к import.json> --platform=MAX
93
+ ```
94
+
95
+ Исправь всё, что покажет валидатор, прежде чем предлагать заливку.
96
+
97
+ ### 6. Залей как НОВЫЙ черновик — никогда не перезаписывай существующее
98
+
99
+ Это главное отличие переноса из другого конструктора от обычной правки:
100
+ - **Всегда `create_graph` (новый `graphId`) или `create_graph_from_template`**, затем `update_graph`.
101
+ Либо `import_funnel(botId, name, graph)` — он сам делает create → update → dry-run → publish, но
102
+ publish из него всё равно требует подтверждения (см. п.7) — не вызывай `import_funnel` раньше, чем
103
+ пользователь согласился на публикацию, либо вызови и объясни, что опубликованный результат — это
104
+ черновик, который он должен подтвердить, ещё раз, прежде чем переключать его в активный.
105
+ - **Никогда не используй `edit_graph_live`, `patch_graph` или PUT в существующий `graphId`** для
106
+ результата переноса — это чужая, параллельно создаваемая воронка, а не правка уже работающего
107
+ сценария. Если у бота есть похожий/одноимённый старый граф — не трогай его.
108
+ - Назови новый граф так, чтобы было видно происхождение, например `"Перенос из BotHelp — <название>"`.
109
+ - Прогони `dry_run(graphId, kind:"command", value:"start")` (или подходящий триггер для IG) и
110
+ разбери `runStatus`.
111
+
112
+ ### 7. Публикуй только после подтверждения пользователя
113
+
114
+ Собранный и провалидированный черновик — не значит «готово к продакшену». Покажи пользователю: сколько
115
+ узлов/веток получилось, что промаплено с потерей (из п.3), что попало в `[ВСТАВЬ ТЕКСТ]`, ссылку/id
116
+ графа. **Публикуй (`publish_graph`) и переключай на активный (`set_active_graph`) только по явному
117
+ «ОК, публикуй» пользователя** — перенос почти всегда замещает текущую рабочую воронку бота, ошибка
118
+ здесь стоит дороже, чем при сборке воронки с нуля.
119
+
120
+ ### 8. (Опционально) перенос подписчиков
121
+
122
+ Перенос графа не переносит людей — это отдельный процесс через `migration_*` инструменты (CSV):
123
+ - `migration_presets()` — какие источники/пресеты колонок уже знает сервис (BotHelp, SaleBot, Senler,
124
+ Leadteh, Smartsender, ManyChat, универсальный CSV).
125
+ - `migration_preview(botId, path, platform, mapping?)` — без `mapping` вернёт подсказку сопоставления
126
+ колонок; с `mapping` — полный dry-run (ничего не пишет): сколько новых, сколько уже есть, нет ли
127
+ превышения лимита подписчиков тарифа.
128
+ - `migration_commit(botId, path, platform, mapping)` — реальный импорт тем же `mapping`, что
129
+ подтвердил `migration_preview`. Делай только после явного согласия пользователя, как и публикацию
130
+ графа.
131
+
132
+ Экспорт воронки (структуры сценария) ни один из этих сервисов документированно не отдаёт — поэтому
133
+ граф всегда собирается по п.1–7, а `migration_*` покрывает только список подписчиков (CSV).
134
+
135
+ ## Важные ограничения
136
+
137
+ - Если источник прислал JSON с кастомными типами блоков, которых нет у нас — **сначала** сверься со
138
+ schema.md, **потом** спрашивай пользователя (п.3). Никогда не придумывай тип узла по аналогии.
139
+ - Как и `build-bot-funnel`, при распознавании с приватной доски (Miro и т.п.) надёжнее всего
140
+ CSV-экспорт из неё самой, а не попытка распознать скриншот холста.
141
+ - Все лимиты и правила валидации графа (размер ≤4 МБ, длины текста, платные узлы/действия, лимиты
142
+ переменных) — те же, что в `build-bot-funnel`; не дублируются здесь.
143
+
144
+ ## Файлы скилла
145
+
146
+ - [reference/mapping.md](reference/mapping.md) — таблица сопоставления понятий (сообщения, кнопки,
147
+ задержки, теги, переменные, условия, оплаты, формы) с реальными узлами Retensy + ловушки по
148
+ конкретным источникам (BotHelp, SaleBot, Senler, Leadteh, Smartsender, ManyChat, PuzzleBot).
149
+ - [`../build-bot-funnel/reference/schema.md`](../build-bot-funnel/reference/schema.md) — формат графа
150
+ и единственный источник правды о типах узлов.
151
+ - [`../build-bot-funnel/reference/validation.md`](../build-bot-funnel/reference/validation.md) —
152
+ правила валидатора и коды ошибок.
153
+ - [`../build-bot-funnel/validate.mjs`](../build-bot-funnel/validate.mjs) — используй его же, отдельного
154
+ валидатора у этого скилла нет.
@@ -0,0 +1,95 @@
1
+ # Таблица сопоставления: другой конструктор → Retensy
2
+
3
+ Общие понятия, которые есть почти в любом bot-builder'е, и к чему они маппятся в `retensy-bot-graph`.
4
+ Полные конфиги и правила валидатора — в [`../../build-bot-funnel/reference/schema.md`](../../build-bot-funnel/reference/schema.md)
5
+ и [`validation.md`](../../build-bot-funnel/reference/validation.md) — здесь только «куда класть», не дублируем поля.
6
+
7
+ ## Таблица по концепциям
8
+
9
+ | Концепция в источнике | Узел/конфиг Retensy | Примечания |
10
+ |---|---|---|
11
+ | Точка входа / команда запуска / приветственное сообщение | `TRIGGER_COMMAND` (`/start` и т.п., Telegram/MAX) | Для Instagram входа нет — `TRIGGER_IG_DM`/`TRIGGER_IG_COMMENT`/`TRIGGER_IG_STORY_REPLY`/`TRIGGER_IG_STORY_MENTION` |
12
+ | Триггер по ключевому слову/тексту | `TRIGGER_TEXT` (`matchMode: ANY/EQUALS/CONTAINS/REGEX`) | |
13
+ | Триггер по нажатию инлайн-кнопки (legacy callback без нашего авто-handle) | `TRIGGER_CALLBACK` | Для НОВЫХ кнопок-выборов отдельный триггер не нужен — используй рёбра `btn_N` от `SEND_MESSAGE` |
14
+ | Сообщение / текстовый блок / карточка | `SEND_MESSAGE` (`config.text` + `cards[0]`) | Оба поля обязательны, см. `SEND_NO_TEXT` в validation.md |
15
+ | Сообщение с фото/видео/документом/голосовым/кружком | `SEND_MESSAGE` с медиа-карточкой (`cards[].type`) или `SEND_PHOTO` | Файл заранее через `upload_file`, в графе хранится только URL |
16
+ | Альбом/галерея фото | `SEND_MESSAGE` карточка `type:"gallery"`, `urls[]` | 2–10 картинок |
17
+ | Инлайн-кнопка с выбором (ведёт к ветке сценария) | Кнопка `kind:"CALLBACK"` на `SEND_MESSAGE`, `value:""`, ребро `btn_N` | Непустой `value` ломает переход — см. `validation.md` |
18
+ | Кнопка-ссылка (переход на внешний сайт) | Кнопка `kind:"URL"`, `value` = ссылка; `track:true` если нужно считать клики | Условие по клику — `CONDITION kind:"LINK_CLICKED"` |
19
+ | Вопрос с ожиданием свободного ответа / сбор данных (имя/телефон/email) | `ASK_QUESTION` или `SEND_MESSAGE` с `awaitReply:true` + `saveTo`/`inputKind`/`validator` | Выходы `valid`/`invalid` |
20
+ | Задержка / «подождать N часов/дней» | `DELAY kind:"FIXED"` (`durationSec`) | |
21
+ | Отправка «завтра в HH:MM» | `DELAY kind:"TOMORROW"` | |
22
+ | Отправка в конкретный момент / «через сутки после записи» | `DELAY kind:"UNTIL"` (`isoTimestamp`, опц. `offset`) | |
23
+ | Запуск по расписанию (cron, ежедневная рассылка сводки и т.п.) | `TRIGGER_SCHEDULE` (`cron`, `timezone`) | Headless — без чата, отправка только через `config.target` |
24
+ | Тег / метка подписчика | `ADD_TAG` / `REMOVE_TAG`, условие `CONDITION kind:"TAG"` | `tag` ∈ `[a-z0-9_-]{1,64}` |
25
+ | Переменная / поле подписчика (custom field) | `SET_VARIABLE`, условие `CONDITION kind:"VARIABLE"` | `key` ∈ `[a-z_][a-z0-9_]{0,63}` |
26
+ | Условие «если/иначе» (бинарное) | `CONDITION` (выходы `yes`/`no`) | `match: ALL/ANY` по списку `conditions[]` |
27
+ | Условие со случайным сплитом (A/B тест) | `BRANCH` (`abTest:true`, выходы `case_<id>`) | |
28
+ | Ветвление по значению переменной/коду ответа (больше двух исходов) | `SWITCH` (`expression`, `cases[].value`, выходы `case_<id>`+`default`) | Детерминированный выбор, не случайный |
29
+ | Условие «подписан ли на канал» | `CONDITION kind:"SUBSCRIBED"` | Только Telegram/MAX |
30
+ | Условие по UTM-метке | `CONDITION kind:"UTM"` | |
31
+ | HTTP-запрос / вебхук наружу | Действие `external_request` в блоке `ACTIONS` | Платное действие; своих выходов нет — ветвиться по коду ответа через `saveStatusTo`+`SWITCH` |
32
+ | Входящий вебхук от стороннего сервиса | `TRIGGER_WEBHOOK` (+ `provider`/`connectionId`/`event` для готовых интеграций) | |
33
+ | Оплата / приём платежа | Действие `issue_invoice` в `ACTIONS` (НЕ отдельный узел) | `connectionId` подключения `provider=YOOKASSA`; ветка `paid` — если нужно ждать оплату в диалоге |
34
+ | Триггер «оплата прошла» | `TRIGGER_PAYMENT` | Фильтр `minAmount`/`descriptionContains` обязателен, иначе срабатывает на любую оплату |
35
+ | Форма на сайте / лендинге | `TRIGGER_SITE_FORM` (`siteId`, `formId`) | Только для своего опубликованного сайта в Retensy |
36
+ | Интеграция с CRM (amoCRM, Битрикс24, GetCourse и т.п.) | Действия `amocrm_send`/`bitrix24_call`/`getcourse_send`/... в `ACTIONS` | Нужен `connectionId` из `list_integrations` |
37
+ | Передача диалога оператору / «позвать менеджера» | Действие `agent_chat` в `ACTIONS` | Возврат — `dialog_handoff {active:false}` |
38
+ | AI-ответчик / встроенный GPT-блок | `AI_REPLY` (`mode: "simple"` или `"agent"`) | Платный узел на любом тарифе; у режима `agent` нет выхода `next`, только именованные ветки |
39
+ | Переход в другой сценарий / подворонку | Действие `call_scenario` в `ACTIONS` (`mode: "goto"`/`"call"`) | Ставится последним действием блока |
40
+ | Рассылка по сегменту/тегу | Отдельный инструмент `broadcast_send`, НЕ узел графа | Описание — скилл `send-broadcast` |
41
+ | Конец ветки / «стоп» | Ничего — ветка просто не имеет исходящих рёбер | **Не добавляй** узел `END`, его больше нет в палитре |
42
+
43
+ ## Особенности конкретных источников
44
+
45
+ Эти сервисы не публикуют документированный формат экспорта воронки/сценария (только список
46
+ подписчиков CSV-экспортом) — поэтому граф почти всегда переносится по скриншотам/пересказу, а не
47
+ по машинному JSON. Ниже — на что обращать внимание при разборе каждого.
48
+
49
+ - **BotHelp** — сценарии строятся из «блоков» с условными переходами по тегам и триггерам ключевых
50
+ слов; у BotHelp есть «цепочки» (broadcast-подобные серии сообщений с задержками) — переносятся как
51
+ последовательность `SEND_MESSAGE` + `DELAY`, а не как единая рассылка. Переменные BotHelp
52
+ (`{{переменная}}`) — проверь синтаксис конкретных полей (имя/телефон/UTM) и сопоставь с нашими
53
+ `{{var.*}}`/`{{from.*}}`.
54
+ - **SaleBot** — активно использует HTTP-блоки («Внешний запрос») и JS-код внутри сценария. Код
55
+ (кастомная логика на JS) не маппится напрямую — спроси пользователя, что конкретно он делает, и
56
+ реализуй через `external_request`/`FORMULA`/`CONDITION`, либо зафиксируй как немаппируемое.
57
+ - **Senler** — завязан на ВКонтакте (группы, UTM ВК). У Retensy VK — это публикации на стену
58
+ (`vk_post`), а не полноценный бот-конструктор ВК: диалоговые сценарии Senler ВК переносятся в
59
+ Telegram/MAX/Instagram-бота Retensy, это не 1:1 перенос платформы, предупреди пользователя.
60
+ - **Leadteh** — похож на BotHelp по модели «блоки + условия по тегам»; отдельно стоит «воронка
61
+ продаж» (канбан-доска сделок) — это НЕ часть графа сценария, в Retensy ближайший аналог —
62
+ интеграция с внешней CRM (amoCRM/Битрикс24) через `ACTIONS`, не переноси как узлы графа.
63
+ - **Smartsender** — мультиканальный (email + мессенджеры + SMS); переноси только мессенджер-ветку в
64
+ граф, email/SMS-рассылки за пределами диалога — отдельный процесс (не маппятся в узлы графа).
65
+ - **ManyChat** — активно использует «Growth Tools» (виджеты входа с разных площадок — Ref URL, Click
66
+ Widget, Facebook Comments) и условные Smart Delay. Смарт-триггеры входа превращаются в один
67
+ подходящий `TRIGGER_*` Retensy (чаще `TRIGGER_COMMAND`/`TRIGGER_TEXT`); «Customer Chat» элементы
68
+ (facebook-специфика) обычно немаппируемы — фиксируй в корзине «не маппится».
69
+ - **PuzzleBot** — конструктор на основе «кубиков» с явными condition-блоками по переменным/рефералам;
70
+ реферальная система (учёт кто кого привёл) не имеет прямого аналога в Retensy — переносится как
71
+ `SET_VARIABLE`/`CONDITION` вручную по переменной, зафиксируй ограничение пользователю.
72
+ - **Любой другой источник, не названный выше** — та же методика: инвентарь → сопоставление по
73
+ смыслу шага (не по названию блока) → немаппируемое в отдельный список на подтверждение.
74
+
75
+ ## Частые ловушки при переносе (из любого источника)
76
+
77
+ - **Кнопка-выбор с непустым `value`.** В исходнике кнопка часто хранит «значение» (тег/id ветки) —
78
+ при переносе в CALLBACK-кнопку Retensy это значение **не** переносится в поле `value` (оно должно
79
+ остаться `""`), переход задаётся только ребром `btn_N`. Если очень нужно явное значение — это
80
+ отдельный узел `TRIGGER_CALLBACK`, а не кнопка-выбор.
81
+ - **Задержки в других единицах.** Источники часто пишут задержку как «1440 минут» или «1 день» —
82
+ переведи в секунды (`durationSec`) аккуратно, не копируй число как есть в другое поле.
83
+ - **HTML/markdown разметка текста.** Форматирование источника (жирный/курсив/ссылки) почти никогда
84
+ не совпадает байт-в-байт с безопасным Telegram-HTML Retensy (`b,strong,i,em,u,ins,s,strike,del,code,pre,a[href],tg-spoiler,br`).
85
+ Если не уверен, что разметка пройдёт — ставь `parseMode:"PLAIN"` и убери теги.
86
+ Пустой экран над таблицей выбора кнопок — тоже источник незаметной потери контента, см. п.1 скилла
87
+ (сначала инвентарь, потом граф).
88
+ - **«Условие» в источнике — не всегда `CONDITION`.** Если исходный блок на самом деле случайно
89
+ делит аудиторию (A/B) — это `BRANCH`, а не `CONDITION`; если выбирает по значению из списка
90
+ вариантов — это `SWITCH`. Смотри поведение, не название.
91
+ - **Платные блоки источника vs платные узлы Retensy не совпадают 1:1.** AI-блок / внешний запрос /
92
+ оплата — в Retensy тоже платные (`PREMIUM_NODE_FORBIDDEN`/`PREMIUM_NODES`) независимо от того,
93
+ был ли эквивалент бесплатным в источнике — предупреди пользователя при переносе таких блоков.
94
+ - **Не копируй ID узлов источника как ID узлов Retensy.** Нужны валидные UUID (п.4 skill) —
95
+ генерируй новые, при необходимости сохраняя исходные имена/заголовки в `_title` для трассируемости.
package/src/index.mjs CHANGED
@@ -27,7 +27,7 @@ import os from "node:os";
27
27
  import fs from "node:fs";
28
28
  import path from "node:path";
29
29
 
30
- const VERSION = "0.23.0";
30
+ const VERSION = "0.25.0";
31
31
  /** С чего начать пустой сайт (init у /document/ops; на сайте с черновиком игнорируется). */
32
32
  const SITE_INITS = ["starter", "blank", "mini-landing"];
33
33
  /** Безвредная операция, когда нужен только init: бэкенд не принимает пустой ops[]. */
@@ -489,6 +489,89 @@ async function migrationMultipart(path_, { filePath, platform, mapping }) {
489
489
  const okResult = (obj) => ({ content: [{ type: "text", text: typeof obj === "string" ? obj : JSON.stringify(obj, null, 2) }] });
490
490
  const errResult = (e) => ({ isError: true, content: [{ type: "text", text: "❌ " + (e?.message || String(e)) }] });
491
491
 
492
+ // Чистое зеркало org.skiddgoddamn.service.bot.chain.ChainGraphValidator (backend/retensy-bots) —
493
+ // см. мэппинг каждого блока на соответствующую проверку Java-оригинала. Не строже сервера: не
494
+ // добавляй сюда правило, которого нет в Java-версии — сервер остаётся источником истины.
495
+ function validateChainGraph(nodes, edges) {
496
+ const errors = [];
497
+ const push = (nodeId, message) => errors.push({ nodeId: nodeId ?? null, message });
498
+ const list = Array.isArray(nodes) ? nodes : [];
499
+ const edgeList = Array.isArray(edges) ? edges : [];
500
+
501
+ const sources = list.filter((n) => n?.type === "SOURCE");
502
+ if (sources.length === 0) { push(null, "В цепочке нет узла источника"); return { valid: false, errors }; }
503
+ if (sources.length > 1) {
504
+ for (const s of sources) push(s?.id, "В цепочке больше одного источника");
505
+ return { valid: false, errors };
506
+ }
507
+ const source = sources[0];
508
+
509
+ const byId = new Map();
510
+ const seenIds = new Set();
511
+ for (const n of list) {
512
+ if (!n?.id) { push(null, "У узла не задан id"); continue; }
513
+ if (seenIds.has(n.id)) push(n.id, `Повторяющийся id узла: ${n.id}`);
514
+ seenIds.add(n.id);
515
+ byId.set(n.id, n);
516
+ }
517
+
518
+ for (const e of edgeList) {
519
+ if (!byId.has(e?.fromNodeId)) push(e?.fromNodeId, `Ребро ссылается на несуществующий узел: ${e?.fromNodeId}`);
520
+ if (!byId.has(e?.toNodeId)) push(e?.toNodeId, `Ребро ссылается на несуществующий узел: ${e?.toNodeId}`);
521
+ }
522
+
523
+ const incoming = new Map();
524
+ const childrenOf = (id) => edgeList.filter((e) => e?.fromNodeId === id).map((e) => byId.get(e.toNodeId)).filter(Boolean);
525
+ for (const e of edgeList) incoming.set(e.toNodeId, (incoming.get(e.toNodeId) || 0) + 1);
526
+
527
+ for (const n of list) {
528
+ if (!n?.id) continue;
529
+ const inCount = incoming.get(n.id) || 0;
530
+ if (n.type === "SOURCE") {
531
+ if (inCount !== 0) push(n.id, "У источника не может быть входящих рёбер");
532
+ } else if (inCount === 0) {
533
+ push(n.id, "Узел не подключён к графу (нет входящего ребра)");
534
+ } else if (inCount > 1) {
535
+ push(n.id, "У узла больше одного входящего ребра — объединение путей не поддерживается");
536
+ }
537
+ }
538
+
539
+ const destinations = list.filter((n) => n?.type === "DESTINATION");
540
+ for (const d of destinations) {
541
+ if (childrenOf(d.id).length > 0) push(d.id, "У назначения не может быть исходящих рёбер");
542
+ }
543
+
544
+ // цикл: DFS с цветами (0=white,1=gray,2=black) от источника
545
+ const color = new Map();
546
+ const detectCycle = (id) => {
547
+ const c = color.get(id);
548
+ if (c === 1) return true;
549
+ if (c === 2) return false;
550
+ color.set(id, 1);
551
+ for (const child of childrenOf(id)) if (detectCycle(child.id)) return true;
552
+ color.set(id, 2);
553
+ return false;
554
+ };
555
+ if (source?.id && detectCycle(source.id)) push(null, "В графе цепочки есть цикл");
556
+
557
+ // достижимость от источника
558
+ const reachable = new Set();
559
+ const visiting = new Set();
560
+ const collectReachable = (id) => {
561
+ if (reachable.has(id)) return;
562
+ reachable.add(id);
563
+ if (visiting.has(id)) return;
564
+ visiting.add(id);
565
+ for (const child of childrenOf(id)) collectReachable(child.id);
566
+ };
567
+ if (source?.id) collectReachable(source.id);
568
+ for (const d of destinations) {
569
+ if (!reachable.has(d.id)) push(d.id, "Назначение не связано с источником путём обработки");
570
+ }
571
+
572
+ return { valid: errors.length === 0, errors };
573
+ }
574
+
492
575
  function extractGraph(g) {
493
576
  if (!g || typeof g !== "object") throw new Error("graph должен быть объектом (контейнер retensy-bot-graph или {nodes,edges}).");
494
577
  const nodes = g.nodes ?? g.graph?.nodes;
@@ -507,6 +590,13 @@ function readGraphFile(p) {
507
590
  try { obj = JSON.parse(raw); } catch (e) { throw new Error(`Файл графа — невалидный JSON: ${abs}. ${e?.message || e}`); }
508
591
  return obj;
509
592
  }
593
+ // То же самое для BotHelp-дампов (scenario.json / external_requests/*.json) — локальный файл, не инлайн.
594
+ function readJsonFile(p, label) {
595
+ const abs = path.resolve(String(p).replace(/^~(?=$|[/\\])/, os.homedir()));
596
+ let raw;
597
+ try { raw = fs.readFileSync(abs, "utf8"); } catch { throw new Error(`${label} не найден: ${abs}`); }
598
+ try { return JSON.parse(raw); } catch (e) { throw new Error(`${label} — невалидный JSON: ${abs}. ${e?.message || e}`); }
599
+ }
510
600
  // Источник графа для пишущих инструментов: graphFile (путь) > graph (контейнер) > nodes/edges.
511
601
  function resolveGraphInput(a) {
512
602
  if (a.graphFile) return extractGraph(readGraphFile(a.graphFile));
@@ -889,7 +979,7 @@ const TOOLS = [
889
979
  { name: "rename_graph", description: "Переименовать сценарий (работает и для опубликованных — имя не влияет на исполнение).", inputSchema: { type: "object", properties: { graphId: { type: "string" }, name: { type: "string" } }, required: ["graphId", "name"] } },
890
980
  { name: "clone_graph", description: "Склонировать граф в новый DRAFT «… (copy)» — безопасно итерировать поверх опубликованного. Клон сценария-вебхука получает собственный путь и секрет вебхука.", inputSchema: { type: "object", properties: { graphId: { type: "string" } }, required: ["graphId"] } },
891
981
  { name: "copy_graph", description: "Скопировать граф в ДРУГОГО бота (в т.ч. на другую платформу). Возвращает {graphId, sourcePlatform, targetPlatform, notes[]}. notes[] помечают, что адаптировано (severity=TRANSFORM, напр. вопрос-контакт → ввод телефона текстом), что требует ручной правки (MANUAL, напр. условие SUBSCRIBED в MAX) и особенности платформы (INFO). Авто-адаптация узлов реализована для Telegram⇄MAX; при копировании в/из Instagram-бота граф копируется без трансформаций — несовместимые узлы будут отмечены при публикации (IG-allowlist). preview=true — только проверка совместимости, без копирования. Тот же бот запрещён (для дублирования есть clone_graph). Копия сценария-вебхука становится обычным сценарием бота-получателя (вход по вебхуку не переносится).", inputSchema: { type: "object", properties: { graphId: { type: "string" }, targetBotId: { type: "string", description: "id бота-получателя (см. list_bots)" }, preview: { type: "boolean", description: "true = только отчёт о совместимости, ничего не сохраняется" } }, required: ["graphId", "targetBotId"] } },
892
- { name: "delete_graph", description: "Удалить граф. Активный (опубликованный и назначенный боту) удалить нельзя — будет 409; сначала переключи активный через set_active_graph.", inputSchema: { type: "object", properties: { graphId: { type: "string" } }, required: ["graphId"] } },
982
+ { name: "delete_graph", description: "Удалить граф (DELETE /api/bots/graphs/{id}) — тот же эндпоинт удаляет и вебхук-сценарий (scenarioId из scenario_list/scenario_create — это id того же графа). Активный (опубликованный и назначенный боту) удалить нельзя — будет 409; сначала переключи активный через set_active_graph. Необратимо.", inputSchema: { type: "object", properties: { graphId: { type: "string" } }, required: ["graphId"] } },
893
983
  { name: "set_active_graph", description: "Назначить, какой опубликованный граф активен у бота (переключение живого сценария без перепубликации). HTTP 409 — граф не опубликован или это сценарий-вебхук (источник WEBHOOK — такой включается своей публикацией в вебе).", inputSchema: { type: "object", properties: { botId: { type: "string" }, graphId: { type: "string" } }, required: ["botId", "graphId"] } },
894
984
  { name: "upload_file", description: "Загрузить файл в библиотеку /bots/files (POST /api/bots/media) и получить публичный URL для вставки в сценарий. Передай path (локальный файл) ИЛИ url (перезалить файл по ссылке в своё хранилище). Возвращает {id, url, mediaType, sizeBytes, originalName}. Полученный url ставь в медиа-карточку SEND_MESSAGE (image/video/audio/file/voice/videonote → поле url; gallery → urls[]) или в SEND_PHOTO.photoUrl. Лимит 50 МБ; типы: image/video/audio/pdf/zip/doc(x)/xlsx/pptx/txt (SVG запрещён); при нехватке места — HTTP 402.", inputSchema: { type: "object", properties: { path: { type: "string", description: "Путь к локальному файлу (поддерживается ~)" }, url: { type: "string", description: "Ссылка на файл — будет скачан и перезалит в /bots/files" }, filename: { type: "string", description: "Переопределить имя файла (необязательно)" } } } },
895
985
  { name: "list_files", description: "Список файлов в библиотеке /bots/files (GET /api/bots/media) + использовано/лимит байт. Бери готовые url отсюда, чтобы не загружать одно и то же повторно.", inputSchema: { type: "object", properties: {} } },
@@ -979,7 +1069,7 @@ const TOOLS = [
979
1069
  { name: "site_lead_status", description: "Статус заявки сайта (PATCH /api/bots/pages/{siteId}/leads/{leadId} {status}): NEW → IN_PROGRESS → DONE | REJECTED. leadId — из site_leads. Недопустимый переход → ошибка 409.", inputSchema: { type: "object", properties: { siteId: { type: "string" }, leadId: { type: "string" }, status: { type: "string", enum: ["NEW", "IN_PROGRESS", "DONE", "REJECTED"] } }, required: ["siteId", "leadId", "status"] } },
980
1070
  { name: "integration_update", description: "Изменить подключение (PUT /api/bots/integrations/{connectionId}): title — новое название; creds — новые ключи доступа (заменяют старые; поля — как в connect_integration для этого провайдера). Передай хотя бы одно.", inputSchema: { type: "object", properties: { connectionId: { type: "string" }, title: { type: "string" }, creds: { type: "object", additionalProperties: { type: "string" } } }, required: ["connectionId"] } },
981
1071
  { name: "web_widget_settings", description: "Вид чат-виджета сайта (бот platform WEB). Без settings — прочитать (GET /api/bots/web/{botId}/settings: settings, brandingRemovable, snippet). С settings — изменить (PUT): переданные поля накладываются на текущие, остальные сохраняются; сервер проверяет значения (уходят в разметку чужих сайтов).", inputSchema: { type: "object", properties: { botId: { type: "string" }, settings: { type: "object", description: "Поля настроек виджета для изменения (имена — как в ответе чтения)" } }, required: ["botId"] } },
982
- { name: "bot_users_import", description: "Добавить подписчикам метки и поля (POST /api/bots/{botId}/users/import): rows — [{chatId, username?, tags?: [строки], variables?: {ключ: значение}}], до 10 000 строк. Метки ДОБАВЛЯЮТСЯ; переменная пишется, только если у подписчика её ещё нет (существующие не перезаписываются); новый chatId создаёт подписчика (в пределах лимита тарифа). Снять метку или перезаписать поле через API нельзя.", inputSchema: { type: "object", properties: { botId: { type: "string" }, rows: { type: "array", items: { type: "object", properties: { chatId: { type: "string" }, username: { type: "string" }, tags: { type: "array", items: { type: "string" } }, variables: { type: "object" } }, required: ["chatId"] } } }, required: ["botId", "rows"] } },
1072
+ { name: "bot_users_import", description: "Добавить/обновить подписчиков пачкой JSON (POST /api/bots/{botId}/users/import): rows — [{chatId, username?, tags?: [строки], variables?: {ключ: значение}, subscribedAt?: ISO-дата, source?: строка, status?: ACTIVE|UNSUBSCRIBED|BLOCKED}], до 10 000 строк за вызов. Метки ДОБАВЛЯЮТСЯ; переменная/username/subscribedAt/source пишутся, только если у подписчика их ещё нет (существующие не перезаписываются — так переносится дата подписки/источник из BotHelp без порчи того, что уже накопил рантайм); новый chatId создаёт подписчика (в пределах лимита тарифа), дедуп — по (botId, chatId). status=UNSUBSCRIBED|BLOCKED односторонне помечает подписчика заблокированным (сценарий/рассылка ему больше ничего не шлют) — снять эту метку через импорт нельзя, ACTIVE её не снимает.", inputSchema: { type: "object", properties: { botId: { type: "string" }, rows: { type: "array", items: { type: "object", properties: { chatId: { type: "string" }, username: { type: "string" }, tags: { type: "array", items: { type: "string" } }, variables: { type: "object" }, subscribedAt: { type: "string", description: "ISO-8601, напр. 2024-01-01T00:00:00Z" }, source: { type: "string" }, status: { type: "string", enum: ["ACTIVE", "UNSUBSCRIBED", "BLOCKED"] } }, required: ["chatId"] } } }, required: ["botId", "rows"] } },
983
1073
  { name: "bot_runs", description: "Журнал прогонов сценариев бота. Без runId — список свежих прогонов (GET /api/bots/{botId}/runs, page/size); с runId — один прогон с шагами (GET /api/bots/runs/{runId}, в т.ч. headless-прогоны вебхук-сценариев и расписаний). По подписчику — bot_user_runs, по сценарию — scenario_runs. Статус прогона: OK, FAILED (прерван) или PARTIAL — «завершён с ошибками»: дошёл до конца, но хотя бы одно действие упало (у шага ok:false, error «КОД: …»). Read-only.", inputSchema: { type: "object", properties: { botId: { type: "string" }, runId: { type: "string" }, page: { type: "number" }, size: { type: "number" } } } },
984
1074
  { name: "scenario_runs", description: "Журнал прогонов одного сценария (GET /api/bots/graphs/{graphId}/runs, page/size), включая headless-прогоны без чата — вебхук-сценарии, заявки сайта, TRIGGER_SCHEDULE. Доступ — как к просмотру графа. Шаги прогона — bot_runs {runId}. Статус прогона: OK, FAILED (прерван) или PARTIAL — «завершён с ошибками»: дошёл до конца, но хотя бы одно действие упало (у шага ok:false, error «КОД: …»). Read-only.", inputSchema: { type: "object", properties: { graphId: { type: "string" }, page: { type: "number" }, size: { type: "number" } }, required: ["graphId"] } },
985
1075
  { name: "bot_delete", description: "УДАЛИТЬ бота навсегда (DELETE /api/bots/{botId}) вместе с каналами, сценариями, подписчиками, журналами, рассылками и ссылками. Только владелец. Необратимо: вызывай только по явной просьбе пользователя и с confirm:true. Временно выключить — bot_stop.", inputSchema: { type: "object", properties: { botId: { type: "string" }, confirm: { type: "boolean", description: "true — пользователь явно подтвердил удаление" } }, required: ["botId", "confirm"] } },
@@ -1031,11 +1121,14 @@ const TOOLS = [
1031
1121
  { name: "migration_presets", description: "Каталог поддерживаемых источников миграции (GET /api/bots/migration/presets): BOTHELP|SALEBOT|SENLER|LEADTEH|SMARTSENDER|MANYCHAT|GENERIC — у каждого note (как экспортировать оттуда) и guessed*Headers (подсказки для автосопоставления колонок). Переносятся ТОЛЬКО подписчики (funnelImportSupported всегда false — ни один из этих билдеров не публикует документированный формат экспорта сценария; свои воронки переносятся через import_funnel/create_graph с retensy-bot-graph JSON, это другой формат). Read-only.", inputSchema: { type: "object", properties: {} } },
1032
1122
  { name: "migration_preview", description: "Пробный прогон CSV-импорта подписчиков, НИЧЕГО не пишет в базу (POST /api/bots/{botId}/migration/subscribers/preview, multipart). path — локальный CSV (≤10 МБ, ≤20000 строк данных), platform — id из migration_presets. Без mapping — режим определения: headers, sampleRaw (до 5 строк), suggestedMapping (угадан по подсказкам пресета — только подсказка, не контракт). С mapping ({userIdColumn (обязательно — заголовок колонки с числовым chatId), usernameColumn?, tagsColumn?, tagsDelimiter? (по умолч. \"|;,\"), variableColumns?}) — полный dry-run: validRows, invalidRows (причины, до 20), newSubscribers/existingSubscribers (дедуп по chatId против текущих подписчиков бота), overCapacity (сверх лимита тарифа), remainingCapacity (-1 — безлимит), sample (до 10 строк как они импортируются), totalRowsInFile/truncated.", inputSchema: { type: "object", properties: { botId: { type: "string" }, path: { type: "string", description: "Локальный путь к CSV-файлу" }, platform: { type: "string", enum: ["BOTHELP", "SALEBOT", "SENLER", "LEADTEH", "SMARTSENDER", "MANYCHAT", "GENERIC"] }, mapping: { type: "object", description: "MigrationColumnMapping — передай ПОСЛЕ того, как проверил suggestedMapping/detect-режимом", properties: { userIdColumn: { type: "string" }, usernameColumn: { type: "string" }, tagsColumn: { type: "string" }, tagsDelimiter: { type: "string" }, variableColumns: { type: "array", items: { type: "string" } } } } }, required: ["botId", "path", "platform"] } },
1033
1123
  { name: "migration_commit", description: "Реальный импорт подписчиков из CSV тем же mapping, что подтвердил migration_preview (POST /api/bots/{botId}/migration/subscribers/commit, multipart) — тот же путь дедупа по chatId/лимита тарифа/«никогда не перезаписывать существующее», что у bot_users_import. Возвращает {totalRowsInFile, processedRows, truncated, result: ImportUsersResponse}.", inputSchema: { type: "object", properties: { botId: { type: "string" }, path: { type: "string" }, platform: { type: "string", enum: ["BOTHELP", "SALEBOT", "SENLER", "LEADTEH", "SMARTSENDER", "MANYCHAT", "GENERIC"] }, mapping: { type: "object", properties: { userIdColumn: { type: "string" }, usernameColumn: { type: "string" }, tagsColumn: { type: "string" }, tagsDelimiter: { type: "string" }, variableColumns: { type: "array", items: { type: "string" } } }, required: ["userIdColumn"] } }, required: ["botId", "path", "platform", "mapping"] } },
1034
- // ---- Публикации: цепочки (автопостинг «Цепочки публикаций») ----
1035
- { name: "chain_list", description: "Цепочки публикаций пользователя (GET /api/bots/chains): автоматический репост source → destinations[] с шагами обработки (steps[]) и UTM. Цепочка привязана к владельцу, не к конкретному боту. Read-only.", inputSchema: { type: "object", properties: {} } },
1036
- { name: "chain_get", description: "Цепочка по id (GET /api/bots/chains/{id}). 404 — не своя/не найдена. Read-only.", inputSchema: { type: "object", properties: { chainId: { type: "string" } }, required: ["chainId"] } },
1037
- { name: "chain_create", description: "Создать цепочку публикаций (POST /api/bots/chains): chain — {name, enabled?, approvalMode?: NONE|AI_ONLY|ALL, source: {type: TELEGRAM|VK|RSS, botId?, chatId?, accountId?, rssUrl?}, destinations: [{type: TELEGRAM|MAX|VK|INSTAGRAM, botId?, chatId?, accountId?, utmOverride?}], steps?: [{type: FILTER|TEXT_TRANSFORM|AI_ADAPT|UTM|DELAY, ...поля шага}], utmDefaults?}. TELEGRAM-источник — свой канал бота (bot_channel_list/list_channels), VK-источник/назначение — подключённый аккаунт (vk_status). INSTAGRAM в destinations принимается моделью, но публикация требует медиа и подключённый Instagram-аккаунт — без них задание уходит в SKIPPED с причиной, не FAILED. HTTP 400 — не хватает полей или источник/назначение не найдены/не ваши; HTTP 409 — прямой цикл с другой вашей цепочкой (A→B при уже существующей B→A).", inputSchema: { type: "object", properties: { chain: { type: "object", description: "PublishChain draft — см. описание инструмента" } }, required: ["chain"] } },
1038
- { name: "chain_update", description: "Изменить цепочку целиком (PUT /api/bots/chains/{id}): chain — PublishChain draft (та же форма, что в chain_create), заменяет текущую конфигурацию полностью — сначала прочитай chain_get и поменяй нужное. Те же 400/409, что у chain_create.", inputSchema: { type: "object", properties: { chainId: { type: "string" }, chain: { type: "object", description: "PublishChain draft целиком" } }, required: ["chainId", "chain"] } },
1124
+ { name: "bothelp_scenario_preview", description: "Dry-run конвертации ОДНОГО сценария BotHelp в граф Retensy (POST /api/bots/{botId}/migration/bothelp/scenario/preview) — НИЧЕГО не пишет в базу. scenarioFile — локальный JSON-дамп bots/<channel>/scenarios/<id>.json (как есть, с полями complexBot/steps); externalRequestsDir — опционально, папка bots/<channel>/external_requests/ (каждый *.json грузится под своим uuid — нужно для шагов make_external_request/send_lead_webhook). Возвращает {bothelpScenarioId, nodeCount, edgeCount, unsupportedCount, partialCount, notes: [{stepId, bothelpKind, severity, message}]} — notes с severity=UNSUPPORTED/PARTIAL перечисляют всё, что сконвертировано приближённо или узлом-заглушкой (см. docs/migration/bothelp/02-mapping.md, 03-gaps.md), прочитай их перед commit.", inputSchema: { type: "object", properties: { botId: { type: "string" }, scenarioFile: { type: "string", description: "Путь к scenarios/<id>.json" }, externalRequestsDir: { type: "string", description: "Путь к папке external_requests/ этого бота (опционально)" } }, required: ["botId", "scenarioFile"] } },
1125
+ { name: "bothelp_scenario_commit", description: "Реальный импорт: конвертирует сценарий BotHelp (как bothelp_scenario_preview) и сохраняет граф (POST /api/bots/{botId}/migration/bothelp/scenario/commit). Идемпотентно по (botId, bothelpScenarioId=scenario.complexBot.id) — повторный вызов с тем же файлом ОБНОВЛЯЕТ тот же граф вместо дубля (created=false, тот же graphId). Граф создаётся в статусе DRAFT — опубликуй его отдельно (publish_graph) после ручной сверки notes. Возвращает тот же отчёт, что preview, плюс {graphId, created}.", inputSchema: { type: "object", properties: { botId: { type: "string" }, scenarioFile: { type: "string" }, externalRequestsDir: { type: "string" } }, required: ["botId", "scenarioFile"] } },
1126
+ // ---- Публикации: цепочки (автопостинг «Цепочки публикаций», граф DAG — режим «Схема») ----
1127
+ { name: "chain_list", description: "Цепочки публикаций пользователя (GET /api/bots/chains): автоматический репост source → граф обработки/назначений. Цепочка привязана к владельцу, не к конкретному боту. Read-only.", inputSchema: { type: "object", properties: {} } },
1128
+ { name: "chain_get", description: "Цепочка по id целиком, как хранится в базе — легаси-поля (source/steps/destinations) ИЛИ нативный граф (nodes/edges), смотря что заполнено (GET /api/bots/chains/{id}). Для гарантированно графовой формы (в т.ч. у старых линейных цепочек) используй chain_graph_get. 404 — не своя/не найдена. Read-only.", inputSchema: { type: "object", properties: { chainId: { type: "string" } }, required: ["chainId"] } },
1129
+ { name: "chain_graph_get", description: "Граф цепочки В ЛЮБОМ случае (GET /api/bots/chains/{id}/graph): {nodes, edges} — если цепочка легаси (линейная), бэкенд синтезирует эквивалентный граф на лету (ничего не сохраняет). Используй это вместо самостоятельной сборки графа из steps/destinations. Read-only.", inputSchema: { type: "object", properties: { chainId: { type: "string" } }, required: ["chainId"] } },
1130
+ { name: "chain_create", description: "Создать цепочку публикаций (POST /api/bots/chains): chain — PublishChain draft, {name, enabled?, approvalMode?: NONE|AI_ONLY|ALL, source: {type: TELEGRAM|VK|RSS, botId?, chatId?, accountId?, rssUrl?}, utmDefaults?, И ЛИБО легаси-поля (steps?, destinations?) ЛИБО нативный граф (nodes?, edges?)}. `source` ВСЕГДА отдельное top-level поле — даже в графовом режиме узел SOURCE в nodes его не дублирует, это просто маркер-корень графа. ГРАФ (nodes непустой — приоритетнее steps/destinations, которые тогда игнорируются): nodes — [{id, type: SOURCE|FILTER|TEXT_TRANSFORM|AI_ADAPT|UTM|DELAY|DESTINATION, x?, y?, step?: {...поля шага, форма как у steps[] в legacy, см. chain_update}, destination?: {id, type: TELEGRAM|MAX|VK|INSTAGRAM, botId?, chatId?, accountId?, utmOverride?} — только для type=DESTINATION}], edges — [{fromNodeId, toNodeId}]. Модель — дерево-с-ветвлением (out-tree), НЕ произвольный DAG: у любого не-SOURCE узла ровно ОДНО входящее ребро; ветвление — через НЕСКОЛЬКО исходящих у одного родителя (напр. один общий AI_ADAPT → много DESTINATION = «1 источник → общий ИИ → N каналов»); слияния путей (несколько родителей) нет. Перед сохранением сложного графа прогони chain_validate (клиентское зеркало серверных правил) и chain_destinations_catalog (список доступных назначений). ЛЕГАСИ (nodes пуст): destinations: [{type: TELEGRAM|MAX|VK|INSTAGRAM, botId?, chatId?, accountId?, utmOverride?}], steps?: [{type: FILTER|TEXT_TRANSFORM|AI_ADAPT|UTM|DELAY, ...поля шага}]. TELEGRAM/MAX источник или назначение — свой привязанный канал бота (bot_channel_list/list_channels), VK — подключённый аккаунт (vk_status); chain_destinations_catalog отдаёт оба списка сразу. INSTAGRAM в destinations принимается моделью, но публикация требует медиа и подключённый Instagram-аккаунт — без них задание уходит в SKIPPED с причиной, не FAILED. HTTP 400 — не хватает полей, источник/назначение не найдены/не ваши, ИЛИ (графовый режим) структурная ошибка графа: «Схема цепочки: <причины>» (нет/>1 SOURCE, цикл, узел без входящего ребра или с >1 входящих, DESTINATION с исходящими рёбрами, недостижимое DESTINATION) — ровно те же правила, что проверяет chain_validate. HTTP 409 — прямой цикл с другой вашей цепочкой (A→B при уже существующей B→A).", inputSchema: { type: "object", properties: { chain: { type: "object", description: "PublishChain draft — см. описание инструмента" } }, required: ["chain"] } },
1131
+ { name: "chain_update", description: "Изменить цепочку целиком (PUT /api/bots/chains/{id}): chain — PublishChain draft (та же форма, что в chain_create, легаси ИЛИ граф), заменяет текущую конфигурацию полностью — сначала прочитай chain_get (или chain_graph_get, если нужен гарантированно графовый вид) и поменяй нужное. Те же 400/409, что у chain_create.", inputSchema: { type: "object", properties: { chainId: { type: "string" }, chain: { type: "object", description: "PublishChain draft целиком" } }, required: ["chainId", "chain"] } },
1039
1132
  { name: "chain_delete", description: "УДАЛИТЬ цепочку публикаций (DELETE /api/bots/chains/{id}). История заданий (chain_runs) теряется вместе с ней. Необратимо: только по явной просьбе, с confirm:true.", inputSchema: { type: "object", properties: { chainId: { type: "string" }, confirm: { type: "boolean" } }, required: ["chainId", "confirm"] } },
1040
1133
  { name: "chain_enable", description: "Включить цепочку (POST /api/bots/chains/{id}/enable) — начинает принимать новые посты источника (TELEGRAM — сразу по событию, VK/RSS — со следующего опроса, раз в 3 мин).", inputSchema: { type: "object", properties: { chainId: { type: "string" } }, required: ["chainId"] } },
1041
1134
  { name: "chain_pause", description: "Поставить цепочку на паузу (POST /api/bots/chains/{id}/pause) — перестаёт принимать новые посты источника (VK/RSS — опрос останавливается, TELEGRAM — события игнорируются); история и настройки сохраняются.", inputSchema: { type: "object", properties: { chainId: { type: "string" } }, required: ["chainId"] } },
@@ -1043,6 +1136,8 @@ const TOOLS = [
1043
1136
  { name: "chain_approve", description: "Согласовать задание, ждущее подтверждения (approvalMode AI_ONLY/ALL) — POST /api/bots/chains/jobs/{jobId}/approve: AWAITING_APPROVAL → PENDING, подхватится ближайшим тиком исполнения (раз в 15 сек). jobId — из chain_runs. HTTP 400 — задание сейчас не ждёт согласования.", inputSchema: { type: "object", properties: { jobId: { type: "string" } }, required: ["jobId"] } },
1044
1137
  { name: "chain_reject", description: "Отклонить задание, ждущее подтверждения — POST /api/bots/chains/jobs/{jobId}/reject: AWAITING_APPROVAL → SKIPPED, публикации не будет. jobId — из chain_runs. HTTP 400 — задание сейчас не ждёт согласования.", inputSchema: { type: "object", properties: { jobId: { type: "string" } }, required: ["jobId"] } },
1045
1138
  { name: "chain_retry", description: "Повторить проваленное задание вручную, минуя бэкофф (POST /api/bots/chains/jobs/{jobId}/retry): сбрасывает lastError, задание подхватится ближайшим тиком исполнения. jobId — из chain_runs (status FAILED).", inputSchema: { type: "object", properties: { jobId: { type: "string" } }, required: ["jobId"] } },
1139
+ { name: "chain_validate", description: "Офлайн-проверка структуры графа цепочки (nodes/edges), БЕЗ запроса к бэкенду — зеркало серверного ChainGraphValidator (не строже сервера; сервер — последнее слово). Передай nodes/edges напрямую, либо chain — объект с полями nodes/edges (напр. результат chain_graph_get). Проверяет: ровно один SOURCE (без входящих рёбер), у любого другого узла ровно одно входящее ребро, DESTINATION без исходящих рёбер, нет циклов (DFS от источника), каждый DESTINATION достижим от SOURCE, рёбра не ссылаются на несуществующие id, нет дублей id узлов. Возвращает {valid, errors: [{nodeId, message}]}. Полезно перед chain_create/chain_update на сложном графе (общий AI_ADAPT → много DESTINATION и т.п.) — дешевле, чем ловить 400 от сервера.", inputSchema: { type: "object", properties: { nodes: { type: "array", items: { type: "object" } }, edges: { type: "array", items: { type: "object" } }, chain: { type: "object", description: "Альтернатива nodes/edges: объект с полями nodes/edges (напр. из chain_graph_get)" } } } },
1140
+ { name: "chain_destinations_catalog", description: "Список площадок, которые можно подключить как source/destination цепочки, одним вызовом — чтобы не дёргать list_bots + list_channels по каждому боту + vk_status вручную при сборке «1 источник → N каналов». Агрегирует: TELEGRAM/MAX — по каждому боту владельца (list_bots) привязанные каналы (list_channels, т.е. GET /api/bots/{botId}/linked-chats, бот должен быть в них администратором); VK — подключённые сообщества/страницы (vk_status). Возвращает {telegram: [{botId, botName, chatId, title, type, status}], max: [{...то же}], vk: [{accountId, label, enabled, isDefault}]}. botId опционален — ограничить обход только этими ботами (иначе обходятся все боты владельца, может быть несколько запросов). Instagram сюда не входит (подключается только в браузере, см. create_bot). Read-only, чисто агрегация существующих read-only эндпоинтов — ничего не создаёт/не меняет.", inputSchema: { type: "object", properties: { botIds: { type: "array", items: { type: "string" }, description: "Ограничить обход этими botId (из list_bots); по умолчанию — все боты владельца" } } } },
1046
1141
  // ---- Аналитика (дашборд «/bots/analytics»: сайт/бот/сценарий/рассылка) ----
1047
1142
  { name: "site_analytics", description: "KPI+тренд сайта (GET /api/bots/pages/{siteId}/analytics?from&to): {pageViews, clicks, formSubmits, uniqueVisitors, leads, topPages, topReferrers, utmSources, trend:[{day,pageViews,clicks,formSubmits,uniqueVisitors}]}. from/to — ГГГГ-ММ-ДД, по умолчанию последние 30 дней UTC. uniqueVisitors — сумма дневных уников (не дедуп по всему периоду, это приближение). clicks считает только элементы с goalName, заданным в редакторе сайта (data-rs-track), а не любой клик. Read-only.", inputSchema: { type: "object", properties: { siteId: { type: "string" }, from: { type: "string", description: "ГГГГ-ММ-ДД" }, to: { type: "string", description: "ГГГГ-ММ-ДД" } }, required: ["siteId"] } },
1048
1143
  { name: "site_analytics_events", description: "Сырые события аналитики сайта постранично (GET /api/bots/pages/{siteId}/analytics/events?from&to&page&size): {items:[{id, type: PAGE_VIEW|CLICK|FORM_SUBMIT, objectId, visitorId, page, referrer, utmSource/Medium/Campaign/Content/Term, props, createdAt}], total}. from/to — ГГГГ-ММ-ДД (по умолчанию последние 30 дней), page (с 0), size (по умолчанию 50). Read-only.", inputSchema: { type: "object", properties: { siteId: { type: "string" }, from: { type: "string" }, to: { type: "string" }, page: { type: "number" }, size: { type: "number" } }, required: ["siteId"] } },
@@ -1052,6 +1147,20 @@ const TOOLS = [
1052
1147
  { 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"] } },
1053
1148
  // ---- Заявки сайта: файлы вложений ----
1054
1149
  { 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"] } },
1150
+ // ---- Вебхук-сценарии (/api/scenarios, PAT-доступ открыт решением владельца 14.10) ----
1151
+ { name: "scenario_list", description: "Сценарии автора (GET /api/scenarios): [{id, name, status, active, sourceType: BOT|WEBHOOK, botId}] — включая вебхук-сценарии (в list_graphs их нет, см. его описание). sourceType WEBHOOK — свой вход по адресу (path+secret в scenario_get), выход — произвольный бот через scenario_update_source; BOT — живёт в графах привязанного бота (тот же объект, что и в list_graphs/get_graph этого бота). Read-only.", inputSchema: { type: "object", properties: {} } },
1152
+ { name: "scenario_create", description: "Создать сценарий (POST /api/scenarios), пустым — узлы/рёбра потом через update_graph/edit_graph_live по id из ответа. botId не передан → вебхук-сценарий (source.type WEBHOOK): сервер сам выпускает путь входа и секрет (видны в scenario_get, ротируются scenario_rotate_secret), выходной бот задаётся отдельно через scenario_update_source. botId передан → бот-сценарий (source.type BOT), сразу привязан к этому боту — нужен доступ на редактирование его графов (owner/EDITOR/ADMIN), иначе 403/404; публикуется он уже через publish_graph, не через scenario_publish. Лимит сценариев тарифа — HTTP 402.", inputSchema: { type: "object", properties: { name: { type: "string" }, botId: { type: "string", description: "UUID бота — делает сценарий бот-сценарием вместо вебхук-сценария" } }, required: ["name"] } },
1153
+ { name: "scenario_get", description: "Сценарий по id (GET /api/scenarios/{id}) — тот же граф, что get_graph, плюс source: {type: BOT|WEBHOOK, config: у WEBHOOK path/secret входа и (если задан) botId выхода, у BOT — только botId} и outputBinding {channel, recipientFrom}. Свой сценарий — всегда; чужой бот-сценарий — если есть доступ к боту (как GET .../graphs/{id}); чужой вебхук-сценарий — 404 (в нём секрет входа, виден только автору).", inputSchema: { type: "object", properties: { scenarioId: { type: "string" } }, required: ["scenarioId"] } },
1154
+ { name: "scenario_update_source", description: "Привязка выхода вебхук-сценария, ЦЕЛИКОМ заменяет текущую (PUT /api/scenarios/{id}/source) — только у source.type WEBHOOK, у BOT-сценария 409. botId — бот-выход (нужен доступ на редактирование его графов — owner/EDITOR/ADMIN, иначе 403/404); НЕ передан → привязка снимается, сценарий остаётся headless без бота. channel — OutputChannel: TG_BOT_API (обычный Bot API Telegram/MAX) | TG_USERBOT | IG | MAX; не передан — прежнее значение сохраняется. recipientFrom — откуда брать chatId получателя (например \"CHAT\" — чат-инициатор, или имя переменной сценария); НЕ передан → очищается в пустую строку (поле не частичное — шли его всегда, если хочешь сохранить текущее значение, сначала прочитай scenario_get). У опубликованного сценария новая привязка применяется сразу; если она добавляет НОВЫЕ ошибки проверки (платформа/лимит), которых не было при старой, — HTTP 422 со всеми code@nodeId, ничего не сохранено (уже имевшиеся ошибки не блокируют).", inputSchema: { type: "object", properties: { scenarioId: { type: "string" }, botId: { type: "string", description: "UUID бота-выхода; опусти, чтобы снять привязку" }, channel: { type: "string", enum: ["TG_BOT_API", "TG_USERBOT", "IG", "MAX"] }, recipientFrom: { type: "string", description: "Не передашь — очистится в пустую строку" } }, required: ["scenarioId"] } },
1155
+ { name: "scenario_publish", description: "Опубликовать вебхук-сценарий (POST /api/scenarios/{id}/publish) — только source.type WEBHOOK (у BOT-сценария 409, публикуй через publish_graph). Те же проверки, что у publish_graph (узлы/лимиты/платформа выходного бота, если он задан): отказ → HTTP 422 со всеми причинами построчно (code@nodeId: message), ничего не опубликовано.", inputSchema: { type: "object", properties: { scenarioId: { type: "string" } }, required: ["scenarioId"] } },
1156
+ { name: "scenario_rotate_secret", description: "Выпустить новый секрет входа вебхук-сценария (POST /api/scenarios/{id}/rotate-secret) — старый сразу перестаёт приниматься. Секрет виден в ответе этого вызова и в scenario_get (source.config.secret) — это собственный секрет сценария для настройки его входящего вебхука, не чужой секрет интеграции.", inputSchema: { type: "object", properties: { scenarioId: { type: "string" } }, required: ["scenarioId"] } },
1157
+ // ---- Публичный API ИИ-агента: ключи (решение владельца 14.10) ----
1158
+ { name: "agent_api_key_create", description: "Выпустить API-ключ публичного чата агента (POST /api/bots/agents/{agentId}/api-keys): один ключ работает только с ЭТИМ агентом (не аккаунтом целиком, в отличие от zmcp-токена). Секрет (вид zagt_...) виден ТОЛЬКО в этом ответе — дальше хранится лишь хеш; сохрани/покажи пользователю сразу, повторно его не получить. До 20 активных ключей на агента — сверх лимита HTTP 409 TOO_MANY_KEYS, сначала отзови лишний (agent_api_key_revoke). Ключ шлют заголовком Authorization: Bearer <ключ> на POST /api/public/agents/{agentId}/chat (публичный чат агента, без zmcp-токена/сессии) — {conversationId?, message (1–1000 симв.), metadata?} → {reply, conversationId, sources?}; 30 запросов/мин на ключ (429 иначе), агент должен быть опубликован (agent_publish).", inputSchema: { type: "object", properties: { agentId: { type: "string" }, name: { type: "string", description: "Название ключа для списка, например «Виджет на сайте»" } }, required: ["agentId"] } },
1159
+ { name: "agent_api_key_list", description: "Список API-ключей агента (GET /api/bots/agents/{agentId}/api-keys): {id, name, keyPrefix, createdAt, lastUsedAt} — без секрета (key:null), секрет виден только один раз при agent_api_key_create. Read-only.", inputSchema: { type: "object", properties: { agentId: { type: "string" } }, required: ["agentId"] } },
1160
+ { name: "agent_api_key_revoke", description: "Отозвать API-ключ агента (DELETE /api/bots/agents/{agentId}/api-keys/{keyId}) — публичный чат с этим ключом сразу перестаёт отвечать (401). keyId — из agent_api_key_list. Необратимо: только по явной просьбе пользователя, с confirm:true.", inputSchema: { type: "object", properties: { agentId: { type: "string" }, keyId: { type: "string" }, confirm: { type: "boolean" } }, required: ["agentId", "keyId", "confirm"] } },
1161
+ // ---- Пакеты ответов ИИ сверх тарифа (решение владельца 14.10) ----
1162
+ { name: "ai_packs", description: "Пакеты ответов ИИ сверх месячного бюджета тарифа: каталог (GET /api/bots/ai-packs) — [{sku, answers, priceRub, kopecks}] (ai_pack_1000 — 1000 ответов/490₽, ai_pack_5000 — 5000/1990₽, ai_pack_20000 — 20000/6990₽; kopecks — внутренняя единица бюджета, справочно, не присылай её). Плюс баланс — aiPackAnswersLeft/aiPackTokensLeft из GET /api/bots/subscription: пакет не сгорает без активного тарифа, но списывается только при нём, ПОСЛЕ месячного бюджета тарифа, FIFO по старшинству покупки (сначала самый старый). Read-only; купить — ai_pack_purchase.", inputSchema: { type: "object", properties: {} } },
1163
+ { name: "ai_pack_purchase", description: "ЭТО ПЛАТЁЖ, не покупка напрямую. Купить пакет ответов ИИ (POST /api/bots/ai-packs/purchase {sku, method}): тот же конвейер оплаты (ЮKassa/крипта), что у смены тарифа — ответ НЕ списывает деньги сам, а возвращает invoiceUrl, ссылку на оплату; пакет зачислится на баланс только после подтверждения платежа пользователем в браузере. sku — из ai_packs; method — \"card\" (по умолчанию), как у subscribe. Только по явной просьбе пользователя, с confirm:true.", inputSchema: { type: "object", properties: { sku: { type: "string", description: "sku из ai_packs, например ai_pack_5000" }, method: { type: "string", description: "card (по умолчанию) и т.п., как у subscribe" }, confirm: { type: "boolean" } }, required: ["sku", "confirm"] } },
1055
1164
  ];
1056
1165
 
1057
1166
  async function handleCall(params) {
@@ -1535,7 +1644,13 @@ async function handleCall(params) {
1535
1644
  if (!/^[1-9]\d{0,18}$/.test(cid)) throw new Error(`rows[${i}].chatId — положительное число, получено: ${cid || "пусто"}.`);
1536
1645
  if (r.tags != null && !Array.isArray(r.tags)) throw new Error(`rows[${i}].tags — массив строк.`);
1537
1646
  if (r.variables != null && (typeof r.variables !== "object" || Array.isArray(r.variables))) throw new Error(`rows[${i}].variables — объект.`);
1538
- return { chatId: `@@CID${cid}@@`, username: r.username, tags: r.tags, variables: r.variables };
1647
+ if (r.status != null && !["ACTIVE", "UNSUBSCRIBED", "BLOCKED"].includes(String(r.status).toUpperCase())) {
1648
+ throw new Error(`rows[${i}].status — ACTIVE|UNSUBSCRIBED|BLOCKED, получено: ${r.status}.`);
1649
+ }
1650
+ return {
1651
+ chatId: `@@CID${cid}@@`, username: r.username, tags: r.tags, variables: r.variables,
1652
+ subscribedAt: r.subscribedAt, source: r.source, status: r.status,
1653
+ };
1539
1654
  });
1540
1655
  const raw = JSON.stringify({ rows }).replace(/"@@CID(\d+)@@"/g, "$1");
1541
1656
  return okResult(await api(`/api/bots/${uuidArg(a.botId, "botId")}/users/import`, { method: "POST", rawBody: raw }));
@@ -1754,6 +1869,23 @@ async function handleCall(params) {
1754
1869
  const suffix = commit ? "commit" : "preview";
1755
1870
  return okResult(await migrationMultipart(`/api/bots/${botId}/migration/subscribers/${suffix}`, { filePath: a.path, platform, mapping: a.mapping }));
1756
1871
  }
1872
+ case "bothelp_scenario_preview":
1873
+ case "bothelp_scenario_commit": {
1874
+ const botId = uuidArg(a.botId, "botId");
1875
+ const scenario = readJsonFile(a.scenarioFile, "scenarioFile");
1876
+ let externalRequests = {};
1877
+ if (a.externalRequestsDir) {
1878
+ const dir = path.resolve(String(a.externalRequestsDir).replace(/^~(?=$|[/\\])/, os.homedir()));
1879
+ let files = [];
1880
+ try { files = fs.readdirSync(dir).filter((f) => f.endsWith(".json")); }
1881
+ catch { throw new Error(`externalRequestsDir не найден: ${dir}`); }
1882
+ for (const f of files) externalRequests[f.replace(/\.json$/, "")] = readJsonFile(path.join(dir, f), f);
1883
+ }
1884
+ const suffix = params.name === "bothelp_scenario_commit" ? "commit" : "preview";
1885
+ return okResult(await api(`/api/bots/${botId}/migration/bothelp/scenario/${suffix}`, {
1886
+ method: "POST", body: { scenario, externalRequests },
1887
+ }));
1888
+ }
1757
1889
  // ---- Боты ----
1758
1890
  case "create_bot": {
1759
1891
  const platform = String(a.platform || "").trim().toUpperCase();
@@ -2037,6 +2169,7 @@ async function handleCall(params) {
2037
2169
  // ---- Публикации: цепочки ----
2038
2170
  case "chain_list": return okResult(await api("/api/bots/chains"));
2039
2171
  case "chain_get": return okResult(await api(`/api/bots/chains/${uuidArg(a.chainId, "chainId")}`));
2172
+ case "chain_graph_get": return okResult(await api(`/api/bots/chains/${uuidArg(a.chainId, "chainId")}/graph`));
2040
2173
  case "chain_create": {
2041
2174
  if (!a.chain || typeof a.chain !== "object") throw new Error("Передай chain — объект PublishChain (см. описание chain_create).");
2042
2175
  return okResult(await api("/api/bots/chains", { method: "POST", body: a.chain }));
@@ -2071,6 +2204,34 @@ async function handleCall(params) {
2071
2204
  await api(`/api/bots/chains/jobs/${jobId}/retry`, { method: "POST" });
2072
2205
  return okResult(`🔁 Задание ${jobId} поставлено на повтор.`);
2073
2206
  }
2207
+ case "chain_validate": {
2208
+ const nodes = Array.isArray(a.nodes) ? a.nodes : Array.isArray(a.chain?.nodes) ? a.chain.nodes : null;
2209
+ const edges = Array.isArray(a.edges) ? a.edges : Array.isArray(a.chain?.edges) ? a.chain.edges : null;
2210
+ if (!nodes || !edges) throw new Error("Передай nodes и edges (массивы) напрямую, либо chain — объект с полями nodes/edges (см. chain_graph_get).");
2211
+ return okResult(validateChainGraph(nodes, edges));
2212
+ }
2213
+ case "chain_destinations_catalog": {
2214
+ const wanted = Array.isArray(a.botIds) && a.botIds.length ? new Set(a.botIds.map(String)) : null;
2215
+ const [bots, vk] = await Promise.all([api("/api/bots"), api("/api/bots/vk/status")]);
2216
+ const botList = (Array.isArray(bots) ? bots : []).filter((b) => (b?.platform === "TELEGRAM" || b?.platform === "MAX") && (!wanted || wanted.has(String(b?.id))));
2217
+ const perBot = await Promise.all(botList.map(async (b) => {
2218
+ try {
2219
+ const chats = await api(`/api/bots/${b.id}/linked-chats`);
2220
+ return { bot: b, chats: Array.isArray(chats) ? chats : [] };
2221
+ } catch {
2222
+ return { bot: b, chats: [] }; // чужой/недоступный бот — просто не попадёт в каталог, не валим весь вызов
2223
+ }
2224
+ }));
2225
+ const telegram = [];
2226
+ const max = [];
2227
+ for (const { bot, chats } of perBot) {
2228
+ const bucket = bot.platform === "MAX" ? max : telegram;
2229
+ for (const c of chats) {
2230
+ bucket.push({ botId: bot.id, botName: bot.name || bot.username || bot.id, chatId: c.chatId, title: c.title, type: c.type, status: c.status });
2231
+ }
2232
+ }
2233
+ return okResult({ telegram, max, vk: Array.isArray(vk?.accounts) ? vk.accounts : [] });
2234
+ }
2074
2235
  // ---- Аналитика (дашборд) ----
2075
2236
  case "site_analytics": {
2076
2237
  const siteId = uuidArg(a.siteId, "siteId");
@@ -2112,6 +2273,77 @@ async function handleCall(params) {
2112
2273
  const meta = await siteLeadFileHead(siteId, leadId, fileId);
2113
2274
  return okResult({ ...meta, authHint: "Скачать: curl -H \"Authorization: Bearer <твой zmcp_-токен>\" \"<downloadUrl>\" -o файл — либо открой заявку в кабинете (сайт → «Заявки»)." });
2114
2275
  }
2276
+ // ---- Вебхук-сценарии ----
2277
+ case "scenario_list": return okResult(await api("/api/scenarios"));
2278
+ case "scenario_create": {
2279
+ const name = String(a.name ?? "").trim();
2280
+ if (!name) throw new Error("Передай name — название сценария.");
2281
+ const source = a.botId != null && String(a.botId).trim() !== ""
2282
+ ? { type: "BOT", config: { botId: uuidArg(a.botId, "botId") } }
2283
+ : { type: "WEBHOOK", config: {} };
2284
+ return okResult(await api("/api/scenarios", { method: "POST", body: { name, source } }));
2285
+ }
2286
+ case "scenario_get": return okResult(await api(`/api/scenarios/${uuidArg(a.scenarioId, "scenarioId")}`));
2287
+ case "scenario_update_source": {
2288
+ const scenarioId = uuidArg(a.scenarioId, "scenarioId");
2289
+ const body = {
2290
+ botId: a.botId != null && String(a.botId).trim() !== "" ? uuidArg(a.botId, "botId") : null,
2291
+ channel: a.channel != null ? enumArg(a.channel, "channel", ["TG_BOT_API", "TG_USERBOT", "IG", "MAX"]) : null,
2292
+ recipientFrom: a.recipientFrom != null ? String(a.recipientFrom) : null,
2293
+ };
2294
+ try {
2295
+ return okResult(await api(`/api/scenarios/${scenarioId}/source`, { method: "PUT", body }));
2296
+ } catch (e) {
2297
+ if (e.status === 409) throw new Error("Это не вебхук-сценарий (source.type BOT) — у бот-сценария выход не перепривязывается, это сам бот графа.");
2298
+ throw e;
2299
+ }
2300
+ }
2301
+ case "scenario_publish": {
2302
+ try {
2303
+ return okResult(await api(`/api/scenarios/${uuidArg(a.scenarioId, "scenarioId")}/publish`, { method: "POST" }));
2304
+ } catch (e) {
2305
+ if (e.status === 409) throw new Error("Это не вебхук-сценарий (source.type BOT) — публикуй через publish_graph.");
2306
+ throw e;
2307
+ }
2308
+ }
2309
+ case "scenario_rotate_secret": return okResult(await api(`/api/scenarios/${uuidArg(a.scenarioId, "scenarioId")}/rotate-secret`, { method: "POST" }));
2310
+ // ---- Публичный API ИИ-агента: ключи ----
2311
+ case "agent_api_key_list": return okResult(await api(`/api/bots/agents/${idArg(a.agentId, "agentId")}/api-keys`));
2312
+ case "agent_api_key_create": {
2313
+ const body = {};
2314
+ if (a.name != null && String(a.name).trim() !== "") body.name = String(a.name).trim();
2315
+ try {
2316
+ return okResult(await api(`/api/bots/agents/${idArg(a.agentId, "agentId")}/api-keys`, { method: "POST", body }));
2317
+ } catch (e) {
2318
+ if (e.status === 409) throw new Error("До 20 активных ключей на агента — сначала отзови лишний (agent_api_key_revoke).");
2319
+ throw e;
2320
+ }
2321
+ }
2322
+ case "agent_api_key_revoke": {
2323
+ const agentId = idArg(a.agentId, "agentId");
2324
+ const keyId = idArg(a.keyId, "keyId");
2325
+ if (a.confirm !== true) throw new Error("Отзыв ключа необратим: публичный чат с ним сразу перестанет отвечать. Спроси пользователя и повтори с confirm:true.");
2326
+ await api(`/api/bots/agents/${agentId}/api-keys/${keyId}`, { method: "DELETE" });
2327
+ return okResult(`🗑️ Ключ ${keyId} отозван.`);
2328
+ }
2329
+ // ---- Пакеты ответов ИИ ----
2330
+ case "ai_packs": {
2331
+ const [skus, sub] = await Promise.all([
2332
+ api("/api/bots/ai-packs"),
2333
+ api("/api/bots/subscription").catch(() => null),
2334
+ ]);
2335
+ return okResult({
2336
+ skus,
2337
+ balance: sub ? { aiPackAnswersLeft: sub.aiPackAnswersLeft, aiPackTokensLeft: sub.aiPackTokensLeft } : null,
2338
+ });
2339
+ }
2340
+ case "ai_pack_purchase": {
2341
+ const sku = String(a.sku ?? "").trim();
2342
+ if (!sku) throw new Error("Передай sku — из ai_packs.");
2343
+ if (a.confirm !== true) throw new Error("Это платёж. Спроси пользователя и повтори с confirm:true.");
2344
+ const r = await api("/api/bots/ai-packs/purchase", { method: "POST", body: { sku, method: a.method || "card" } });
2345
+ return okResult({ ...r, hint: "Пакет зачислится после подтверждения оплаты по invoiceUrl — ничего не списано автоматически." });
2346
+ }
2115
2347
  default:
2116
2348
  throw new Error(`Неизвестный инструмент: ${params && params.name}`);
2117
2349
  }