@retensy/mcp 0.24.0 → 0.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -62,6 +62,7 @@ MCP-сервер (+ скилл для Claude Code) для **сборки и пу
62
62
  - 🌐 **Сайты из блоков** (раздел «Страницы»): создание, правка операциями (блоки, Zero-блок со свободной вёрсткой, код блока, папки страниц, дизайны, шаблоны из библиотеки), публикация и откат, свои домены, заявки из форм и куда их доставлять — инструменты `site_*` + скилл `build-site`.
63
63
  - 🧠 **Скилл `build-bot-funnel`**: учит агента собирать корректный граф (типы узлов, ветки, кнопки, задержки) и проверять его перед публикацией. Поддерживает Telegram, MAX и Instagram.
64
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»).
65
66
  - 📦 **Без зависимостей** — чистый Node ≥18, ставится и запускается сразу.
66
67
 
67
68
  ### Поддерживаемые платформы
@@ -83,7 +84,7 @@ MCP-сервер (+ скилл для Claude Code) для **сборки и пу
83
84
  /plugin install retensy-mcp@retensy
84
85
  ```
85
86
 
86
- Подтянутся MCP-сервер и скиллы `build-bot-funnel`, `rebuild-funnel-from-builder`, `build-site`, `send-broadcast`. Проверить: `/mcp` и `/plugin`.
87
+ Подтянутся MCP-сервер и скиллы `build-bot-funnel`, `rebuild-funnel-from-builder`, `build-site`, `send-broadcast`, `build-publication-chain`. Проверить: `/mcp` и `/plugin`.
87
88
 
88
89
  ### Вариант B — как обычный MCP-сервер (Claude Code / Cursor / Windsurf / любой MCP-клиент)
89
90
 
@@ -224,7 +225,7 @@ MCP-сервер (+ скилл для Claude Code) для **сборки и пу
224
225
  | `bot_user_runs(botId, chatId)` | журнал запусков с шагами — проверка, что сценарий реально выполнил действия (CRM, уведомление, HTTP) |
225
226
  | `dialog_messages(botId, chatId)` / `dialog_reply(botId, chatId, text)` | переписка с подписчиком / ответ оператора (уходит реальному человеку) |
226
227
  | `dialog_handoff(botId, chatId, active)` | передать диалог оператору (бот и ИИ молчат) / вернуть боту |
227
- | `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` только выставляет блокировку, никогда не снимает) |
228
229
  | `bot_runs(botId` или `runId)` | журнал прогонов бота / один прогон с шагами (статус `PARTIAL` — «завершён с ошибками») |
229
230
  | `scenario_runs(graphId, page?, size?)` | журнал прогонов одного сценария, включая headless (вебхук, заявки сайта, `TRIGGER_SCHEDULE`) |
230
231
  | `bot_delete(botId, confirm:true)` | удалить бота навсегда (только по явной просьбе) |
@@ -258,8 +259,14 @@ MCP-сервер (+ скилл для Claude Code) для **сборки и пу
258
259
  | `migration_presets()` | каталог источников миграции подписчиков (BotHelp/SaleBot/Senler/Leadteh/Smartsender/ManyChat/универсальный CSV) |
259
260
  | `migration_preview(botId, path, platform, mapping?)` | пробный прогон CSV (ничего не пишет): без `mapping` — определение заголовков/подсказка сопоставления, с `mapping` — полный dry-run (новые/существующие, превышение лимита) |
260
261
  | `migration_commit(botId, path, platform, mapping)` | реальный импорт подписчиков тем же `mapping`, что подтвердил `migration_preview` |
261
- | `chain_list()` / `chain_get(chainId)` | цепочки публикаций («Публикации» → «Цепочки», автопостинг source → destinations[]) / одна цепочка целиком |
262
- | `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: один узел может кормить несколько назначений, слияния путей нет); граф-узлы — опциональные `x?`/`y?` (координаты холста, UI-подсказка, бэкенд не интерпретирует, опусти → авто-layout); каждое `destination`/`destinations[]` — опциональный `aiOverride?: {enabled, aiMode, tone?, instructions?}`, персональная адаптация ИИ сверх общего `AI_ADAPT` только для этого назначения; HTTP 409 — прямой цикл с другой цепочкой; графовый 400 — «Схема цепочки: …» (см. `chain_validate`) |
267
+ | `chain_create_draft(name)` | создать минимальный черновик цепочки (`enabled:false`, без источника/назначений) для потока «создать → сразу донастроить на холсте „Схема“»; не проходит обычную валидацию `chain_create` |
268
+ | `chain_validate(nodes, edges)` / `chain_validate(chain)` | офлайн-проверка графа (зеркало серверного `ChainGraphValidator`, не строже сервера) — перед `chain_create`/`chain_update` на сложном графе |
269
+ | `chain_destinations_catalog(botIds?)` | одним вызовом: все TG/MAX-каналы по ботам владельца + VK-аккаунты — собрать «1 источник → N каналов» без ручного обхода `list_bots`+`list_channels`+`vk_status` |
263
270
  | `chain_delete(chainId, confirm:true)` | удалить цепочку (теряется история заданий) |
264
271
  | `chain_enable(chainId)` / `chain_pause(chainId)` | включить / поставить на паузу приём новых постов источника |
265
272
  | `chain_runs(chainId, page?, size?)` | история заданий цепочки, новые сверху (бэкенд без постраничной выдачи — `page`/`size`, если переданы, режут список в MCP) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retensy/mcp",
3
- "version": "0.24.0",
3
+ "version": "0.25.1",
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": {
@@ -0,0 +1,284 @@
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?,
42
+ utmOverride?, aiOverride?} }]
43
+ edges: [{ fromNodeId, toNodeId }]
44
+ ```
45
+ `x`/`y` (number, опционально) — координаты узла на холсте «Схема», чисто UI-подсказка, бэкенд их не
46
+ интерпретирует и ничего не ломается, если их не передать: фронтенд сам раскладывает узлы
47
+ BFS-авто-layout'ом по столбцам/строкам. Не придумывай координаты сам — просто опускай поле (или
48
+ оставляй `null`), если пользователь явно не просит конкретную раскладку.
49
+ Узел — «мешок полей»: только поля своего `type` что-то значат.
50
+
51
+ - **Legacy (линейный режим, `nodes` пуст)** — проще для одного source → один путь обработки → N
52
+ назначений БЕЗ ветвления посередине (ветвление всё равно есть — просто неявное, как «один путь,
53
+ потом веер в destinations»):
54
+ ```
55
+ steps: [{ type: FILTER|TEXT_TRANSFORM|AI_ADAPT|UTM|DELAY, ...поля }] // применяются по порядку ко всем
56
+ destinations: [{ type: TELEGRAM|MAX|VK|INSTAGRAM, botId?, chatId?, accountId?, utmOverride?, aiOverride? }]
57
+ ```
58
+ Используй legacy, когда обработка ОДИНАКОВА для всех назначений (линия, без развилок). Переходи на
59
+ граф, когда назначениям нужна РАЗНАЯ обработка (например, свой тон ИИ под каждую площадку) или нужно
60
+ явно показать ветвление в UI.
61
+
62
+ - **Шаги обработки (`step`/`steps[]` — поля по `type`)**:
63
+ - `FILTER`: `containsAny[]`, `notContainsAny[]`, `hasMedia` (true=только с медиа, false=только без,
64
+ null=не проверять), `minLength`, `requireHashtag`.
65
+ - `TEXT_TRANSFORM`: `prependText`, `appendText`, `removeLinks`, `removeHashtags`, `replaceFrom`/`replaceTo`.
66
+ - `AI_ADAPT`: `aiMode: NONE|LIGHT|FULL`, `targetPlatform`, `tone`, `targetLength`, `useEmoji`,
67
+ `useHashtags`, `preserveLinks` (default true), `preserveAdMarkers` (default true), `instructions`.
68
+ Тратит AI-бюджет тарифа (`bot_ai_usage`) — ОДИН РАЗ на элемент источника, даже если один узел
69
+ `AI_ADAPT` кормит 10 назначений (общий узел = одна оплата на пост, не 10).
70
+ - `UTM`: самостоятельного конфига не несёт — разметку делает `utmDefaults`/`destination.utmOverride`
71
+ (ниже), шаг `UTM` в графе просто фиксирует момент применения в цепочке обработки.
72
+ - `DELAY`: `delaySeconds`.
73
+
74
+ - **`destination`/`destinations[]`**: `{id (строка, стабильный ключ идемпотентности — не меняй после
75
+ первого сохранения), type: TELEGRAM|MAX|VK|INSTAGRAM, botId?, chatId?, accountId?, utmOverride?,
76
+ aiOverride?}`.
77
+ TELEGRAM/MAX — `botId`+`chatId` (свой привязанный канал, бот должен быть админом). VK — `accountId`.
78
+ **INSTAGRAM принимается моделью, но публикация ГАРАНТИРОВАННО уходит в `SKIPPED`** (не `FAILED`) —
79
+ нет медиа-процесса в этой волне; не предлагай его пользователю как рабочее назначение, только если
80
+ он сам просит «добавить на будущее».
81
+
82
+ - **Общий ИИ vs персональный `aiOverride` — когда что использовать.** Два РАЗНЫХ способа применить
83
+ адаптацию ИИ, и их можно комбинировать:
84
+ - **Общий узел `AI_ADAPT`** (`step`/`steps[]`) — обрабатывает пост ОДИН раз и кормит ВСЕ назначения
85
+ на этом пути одинаковым результатом. Оплачивается один раз на пост источника, даже если на выходе
86
+ 10 назначений. Используй, когда у всех (или у большинства) каналов один и тот же тон/формат.
87
+ - **`destination.aiOverride`** — `{enabled, aiMode: NONE|LIGHT|FULL, tone?, instructions?}`,
88
+ персональная ВТОРАЯ адаптация ТОЛЬКО для этого одного назначения: применяется ПОСЛЕ результата
89
+ общего `AI_ADAPT` на пути (или поверх сырого текста источника, если общего узла на пути к этому
90
+ назначению нет), своими `aiMode`/`tone`/`instructions`, и не трогает соседние назначения под тем
91
+ же общим узлом. `null`/`enabled:false` (по умолчанию) — поведение не меняется, работает только
92
+ общий узел как обычно. Каждое назначение с `aiOverride.enabled:true` — это ОТДЕЛЬНЫЙ оплачиваемый
93
+ вызов ИИ (сверх общего узла, если он есть), не путай с общим узлом с точки зрения AI-бюджета.
94
+ - Используй `aiOverride`, когда нужен один общий тон для большинства каналов, но для одного-двух —
95
+ особый (например, более формальный для делового VK-сообщества при неформальном общем тоне), без
96
+ заведения отдельного `AI_ADAPT`-узла и ветки в графе ради одного назначения — проще и легаси, и
97
+ граф. Если РАЗНЫХ тонов больше двух-трёх и они устойчивые — удобнее явные ветки (Пример 3), чтобы
98
+ схема была видна в UI; `aiOverride` — для точечных исключений из общего правила.
99
+ - Пример (легаси, общий `AI_ADAPT` деловым тоном + одно назначение с персональным разговорным
100
+ тоном):
101
+ ```json
102
+ {
103
+ "steps": [{ "type": "AI_ADAPT", "aiMode": "LIGHT", "tone": "деловой, нейтральный" }],
104
+ "destinations": [
105
+ { "type": "TELEGRAM", "botId": "<botId>", "chatId": 2001 },
106
+ { "type": "VK", "accountId": "<accountId>",
107
+ "aiOverride": { "enabled": true, "aiMode": "LIGHT", "tone": "разговорный, с эмодзи",
108
+ "instructions": "короче, без канцелярита" } }
109
+ ]
110
+ }
111
+ ```
112
+ TG получает результат общего узла как есть; VK получает ЕЩЁ ОДНУ адаптацию поверх общего —
113
+ разговорным тоном, с отдельной оплатой ИИ только за это назначение.
114
+
115
+ - **`utmDefaults`** (на уровне цепочки) / **`destination.utmOverride`** (на уровне назначения, `null` =
116
+ взять `utmDefaults`): `{enabled, source, medium, campaign, content, term,
117
+ overridePolicy: KEEP_EXISTING|OVERRIDE (default KEEP_EXISTING), skipInternalLinks (default true)}`.
118
+ Ставь РАЗНЫЙ `utmOverride.source`/`campaign` на разных назначениях, если нужно различать трафик по
119
+ каналам в аналитике — иначе один `utmDefaults` на всю цепочку.
120
+
121
+ - **`approvalMode`**: `NONE` (публикуется сразу) | `AI_ONLY` (на согласование — только то, что прошло
122
+ через `AI_ADAPT` с режимом ≠ `NONE`) | `ALL` (вообще всё, даже без ИИ). Согласование — через
123
+ `chain_runs` (ищи `AWAITING_APPROVAL`) → `chain_approve`/`chain_reject`.
124
+
125
+ - **Квота**: каждое назначение = отдельная публикация в месячной квоте владельца (как `channel_post`/
126
+ `vk_post`) — 10 TG-каналов + 2 VK + 4 MAX в одной цепочке = 16 публикаций за один проход источника,
127
+ это не одна операция с точки зрения лимита тарифа. Предупреди пользователя при большом фан-ауте.
128
+
129
+ ## Рабочий процесс
130
+
131
+ 0. **(Опционально) `chain_create_draft`** — если пользователь хочет «создать цепочку и донастроить
132
+ в UI на холсте» без того, чтобы ты сразу собирал полный граф/legacy: передай только `{name}`,
133
+ получишь `PublishChain` с `enabled:false`, без источника/назначений — черновик не проходит обычную
134
+ валидацию `chain_create` и не может случайно уйти в работу недонастроенным. Обычно НЕ нужен агенту:
135
+ если у тебя уже есть все вводные (источник, назначения, обработка), просто используй `chain_create`
136
+ с полным объектом за один вызов — это быстрее для пользователя.
137
+
138
+ 1. **Собери вводные.** Источник (платформа + канал/аккаунт/URL), что меняется в тексте по пути
139
+ (фильтры, переписывание, ИИ), куда публикуется (один список площадок или разные пути на разные
140
+ площадки), нужно ли согласование, нужна ли UTM-метка и различается ли она по каналам.
141
+
142
+ 2. **Найди id источника/назначений одним вызовом — `chain_destinations_catalog`** (без `botIds` —
143
+ обойдёт все боты владельца): вернёт `{telegram: [{botId, botName, chatId, title, type, status}],
144
+ max: [{...}], vk: [{id, label, enabled, isDefault}]}`. Это замена ручного обхода `list_bots` +
145
+ `list_channels` на каждого бота + `vk_status`. Бот должен быть администратором канала, иначе он не
146
+ попадёт в `linked-chats` этого бота (и в каталог) — такие случаи решаются в самом /bots, не здесь.
147
+
148
+ 3. **Выбери режим:**
149
+ - Одна обработка на всех → **legacy** (`steps`+`destinations`), проще и короче JSON.
150
+ - Разная обработка по ветвям, либо хочешь явную схему в UI, либо общий дорогой узел (например,
151
+ один `AI_ADAPT`, кормящий много каналов, чтобы не платить за адаптацию N раз) → **граф**
152
+ (`nodes`+`edges`).
153
+
154
+ 4. **Собери граф/legacy и проверь офлайн — `chain_validate`** (nodes/edges напрямую или
155
+ `chain: {...}`), ПЕРЕД отправкой на сервер. Зеркалит серверный `ChainGraphValidator`: ровно один
156
+ `SOURCE` без входящих рёбер, у любого другого узла ровно одно входящее, `DESTINATION` без исходящих,
157
+ без циклов, каждый `DESTINATION` достижим от `SOURCE`. Возвращает `{valid, errors: [{nodeId,
158
+ message}]}` — почини всё, что найдёт, прежде чем звать `chain_create`/`chain_update`. Для legacy
159
+ (`steps`+`destinations`) валидация не нужна — она всегда корректна по построению.
160
+
161
+ 5. **Создай/обнови — `chain_create`/`chain_update`.** 400 — не хватает полей или источник/назначение
162
+ не найдены/не твои (чаще всего: бот не админ канала, или `accountId` не свой); в графовом режиме
163
+ 400 также приходит как «Схема цепочки: <причины>» — те же правила, что в п.4, просто сервер —
164
+ последнее слово. 409 — прямой цикл с другой твоей цепочкой (A→B, когда уже есть B→A): переосмысли
165
+ направление, не пытайся обойти через третью цепочку (сервер ловит только прямой случай — не создавай
166
+ его намеренно).
167
+
168
+ 6. **Включи — `chain_enable`** (черновик создаётся выключенным, если явно не передать `enabled:true`).
169
+ TELEGRAM начнёт публиковать сразу по следующему посту в источнике; VK/RSS — со следующего опроса
170
+ (до 3 минут).
171
+
172
+ 7. **Проверь первый прогон — `chain_runs`** (page/size режут список на стороне MCP, бэкенд отдаёт
173
+ всё целиком). Статусы: `PENDING→RUNNING→DONE|FAILED`, либо `AWAITING_APPROVAL` (approvalMode),
174
+ либо `SKIPPED` (не прошло фильтр / loop-guard / назначение исчезло / Instagram). `FAILED` с
175
+ транзиентной причиной ретраится автоматически (до 5 раз, бэкофф); постоянная ошибка (бот не админ,
176
+ квота кончилась, VK отклонил) — сразу `FAILED` без ретраев, почини причину и `chain_retry` вручную.
177
+
178
+ 8. **Отчитайся**: источник, число шагов/ветвей, число назначений по площадкам, включена ли, режим
179
+ согласования, ссылка/id цепочки.
180
+
181
+ ## Пример 1 — простой линейный репост (legacy)
182
+
183
+ TG-канал → убрать ссылки → разметить UTM → в VK-сообщество.
184
+
185
+ ```json
186
+ {
187
+ "name": "TG блог → VK стена",
188
+ "enabled": true,
189
+ "approvalMode": "NONE",
190
+ "source": { "type": "TELEGRAM", "botId": "<botId из chain_destinations_catalog.telegram>", "chatId": 1001 },
191
+ "steps": [
192
+ { "type": "TEXT_TRANSFORM", "removeLinks": true },
193
+ { "type": "UTM" }
194
+ ],
195
+ "destinations": [
196
+ { "type": "VK", "accountId": "<accountId из chain_destinations_catalog.vk>",
197
+ "utmOverride": { "enabled": true, "source": "vk", "medium": "repost", "campaign": "blog" } }
198
+ ],
199
+ "utmDefaults": { "enabled": true, "source": "tg", "medium": "repost", "campaign": "blog" }
200
+ }
201
+ ```
202
+
203
+ ## Пример 2 — «1 источник → общий ИИ → 10 TG-каналов + 2 VK + 4 MAX» (граф, фан-аут)
204
+
205
+ Один общий узел `AI_ADAPT` адаптирует пост под нейтральный деловой тон ОДИН раз, затем веером — на
206
+ 16 назначений без повторной оплаты ИИ за каждое. Ниже сокращённо (3 назначения для примера, остальные —
207
+ та же форма узла/ребра с новым `id`).
208
+
209
+ ```json
210
+ {
211
+ "name": "Контент-хаб: 1 источник → ИИ → все каналы",
212
+ "enabled": true,
213
+ "approvalMode": "AI_ONLY",
214
+ "source": { "type": "TELEGRAM", "botId": "<botId>", "chatId": 1001 },
215
+ "utmDefaults": { "enabled": true, "source": "hub", "medium": "crosspost", "campaign": "2026q4" },
216
+ "nodes": [
217
+ { "id": "src", "type": "SOURCE" },
218
+ { "id": "ai1", "type": "AI_ADAPT",
219
+ "step": { "type": "AI_ADAPT", "aiMode": "LIGHT", "tone": "деловой, нейтральный",
220
+ "preserveLinks": true, "preserveAdMarkers": true } },
221
+ { "id": "d-tg-1", "type": "DESTINATION",
222
+ "destination": { "id": "d-tg-1", "type": "TELEGRAM", "botId": "<botId>", "chatId": 2001 } },
223
+ { "id": "d-tg-2", "type": "DESTINATION",
224
+ "destination": { "id": "d-tg-2", "type": "TELEGRAM", "botId": "<botId>", "chatId": 2002 } },
225
+ { "id": "d-vk-1", "type": "DESTINATION",
226
+ "destination": { "id": "d-vk-1", "type": "VK", "accountId": "<accountId1>",
227
+ "utmOverride": { "enabled": true, "source": "vk", "medium": "crosspost", "campaign": "2026q4" } } },
228
+ { "id": "d-max-1", "type": "DESTINATION",
229
+ "destination": { "id": "d-max-1", "type": "MAX", "botId": "<maxBotId>", "chatId": 3001 } }
230
+ ],
231
+ "edges": [
232
+ { "fromNodeId": "src", "toNodeId": "ai1" },
233
+ { "fromNodeId": "ai1", "toNodeId": "d-tg-1" },
234
+ { "fromNodeId": "ai1", "toNodeId": "d-tg-2" },
235
+ { "fromNodeId": "ai1", "toNodeId": "d-vk-1" },
236
+ { "fromNodeId": "ai1", "toNodeId": "d-max-1" }
237
+ ]
238
+ }
239
+ ```
240
+
241
+ Чтобы собрать все 16 назначений (10 TG + 2 VK + 4 MAX): пройдись по `chain_destinations_catalog`
242
+ результату (`telegram[]`, `vk[]`, `max[]`), для каждого выбранного канала добавь один узел
243
+ `DESTINATION` с уникальным `id` и одно ребро `{fromNodeId: "ai1", toNodeId: "<тот же id>"}` — узел
244
+ `ai1` остаётся общим, обработка оплачивается один раз на пост источника, не 16.
245
+
246
+ ## Пример 3 — раздельная обработка по ветвям (граф, разный тон на разные площадки)
247
+
248
+ Источник → в Telegram без изменений, в VK — через ИИ с более разговорным тоном.
249
+
250
+ ```json
251
+ {
252
+ "name": "TG как есть, VK — адаптировано",
253
+ "source": { "type": "TELEGRAM", "botId": "<botId>", "chatId": 1001 },
254
+ "nodes": [
255
+ { "id": "src", "type": "SOURCE" },
256
+ { "id": "d-tg", "type": "DESTINATION",
257
+ "destination": { "id": "d-tg", "type": "TELEGRAM", "botId": "<botId>", "chatId": 2001 } },
258
+ { "id": "ai-vk", "type": "AI_ADAPT",
259
+ "step": { "type": "AI_ADAPT", "aiMode": "LIGHT", "tone": "неформальный, дружелюбный" } },
260
+ { "id": "d-vk", "type": "DESTINATION",
261
+ "destination": { "id": "d-vk", "type": "VK", "accountId": "<accountId>" } }
262
+ ],
263
+ "edges": [
264
+ { "fromNodeId": "src", "toNodeId": "d-tg" },
265
+ { "fromNodeId": "src", "toNodeId": "ai-vk" },
266
+ { "fromNodeId": "ai-vk", "toNodeId": "d-vk" }
267
+ ]
268
+ }
269
+ ```
270
+
271
+ (Источник ветвится напрямую на два пути — это тоже «несколько исходящих у одного родителя», просто
272
+ родитель здесь `SOURCE`, а не промежуточный узел обработки.)
273
+
274
+ ## Авторизация MCP
275
+
276
+ Персональный токен (`/bots/mcp-tokens`, `zmcp_…`). Нет токена/ошибка доступа → вызови `setup`,
277
+ объясни шаги пользователю, сохрани токен через `set_token`. Также работают env `RETENSY_MCP_TOKEN`
278
+ и session-cookie.
279
+
280
+ ## Если возможности не хватает
281
+
282
+ При неудаче инструмента сервер шлёт анонимный отчёт на `POST /api/mcp/report` (без секретов) —
283
+ не выдумывай обходных путей для того, чего нет в этой схеме, скажи прямо и не повторяй падающий
284
+ вызов по кругу. Пользователь может выключить отчёты `RETENSY_MCP_TELEMETRY=off`.
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.24.0";
30
+ const VERSION = "0.25.1";
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));
@@ -540,6 +630,20 @@ function uuidArg(v, label) {
540
630
  if (!UUID_RE.test(s)) throw new Error(`${label} — UUID (например, из list_bots), получено: ${s || "пусто"}.`);
541
631
  return s;
542
632
  }
633
+ /** Тело vk_landing_create/vk_landing_update — одна форма на оба (PUT полностью заменяет поля). */
634
+ function landingBody(a) {
635
+ return {
636
+ botId: uuidArg(a.botId, "botId"),
637
+ title: String(a.title),
638
+ subtitle: a.subtitle ?? null,
639
+ imageUrl: a.imageUrl ?? null,
640
+ buttonText: a.buttonText ?? null,
641
+ targetGraphId: a.targetGraphId ? uuidArg(a.targetGraphId, "targetGraphId") : null,
642
+ tags: Array.isArray(a.tags) ? a.tags.map(String) : [],
643
+ autoSubscribe: !!a.autoSubscribe,
644
+ bothelpLandingId: a.bothelpLandingId ?? null,
645
+ };
646
+ }
543
647
  // id из Mongo/строковые ключи: только безопасный сегмент пути, без «/», «..», пробелов.
544
648
  function idArg(v, label) {
545
649
  const s = String(v ?? "").trim();
@@ -938,7 +1042,13 @@ const TOOLS = [
938
1042
  { name: "kb_reindex", description: "Переиндексировать документ-файл базы знаний из сохранённого оригинала (POST /api/bots/kb/{kbId}/docs/{docId}/reindex) — «Повторить» после ошибки. headerRow — необязательно, для табличных файлов: номер строки с шапкой (1..50), если автоопределение ошиблось. Только для источника FILE.", inputSchema: { type: "object", properties: { kbId: { type: "string" }, docId: { type: "string" }, headerRow: { type: "number" } }, required: ["kbId", "docId"] } },
939
1043
  { name: "agent_unanswered", description: "Вопросы без ответа агента за период (GET /api/bots/agents/{agentId}/unanswered): days — 7|30|90 (по умолчанию 30). Группы вопросов, на которые агент не нашёл ответ в базе знаний — подсказка, что туда добавить. Read-only.", inputSchema: { type: "object", properties: { agentId: { type: "string" }, days: { type: "number", enum: [7, 30, 90] } }, required: ["agentId"] } },
940
1044
  // ---- Боты ----
941
- { name: "create_bot", description: "Подключить бота по токену (POST /api/bots): platform TELEGRAM (токен от @BotFather) или MAX (токен от MasterBot в MAX). Вебхук настраивается сам; name — отображаемое имя (иначе @username). Возвращает бота с id. Число ботов ограничено тарифом — HTTP 402 со ссылкой на смену тарифа. platform WEB — чат-виджет для сайта без токена (POST /api/bots/web): вернёт botId, key и snippet — код вставки на сайт (у существующего виджета — web_widget_snippet). platform INSTAGRAM по токену не подключается — только входом через Instagram (OAuth) в кабинете (/bots/instagram), нужен Instagram-аккаунт Business или Creator — инструмент вернёт ссылку на кабинет вместо ошибки.", inputSchema: { type: "object", properties: { platform: { type: "string", enum: ["TELEGRAM", "MAX", "WEB", "INSTAGRAM"] }, token: { type: "string", description: "Токен бота: 123456789:AA… (Telegram) или токен MAX" }, name: { type: "string" } }, required: ["platform"] } },
1045
+ { name: "create_bot", description: "Подключить бота по токену (POST /api/bots): platform TELEGRAM (токен от @BotFather) или MAX (токен от MasterBot в MAX). Вебхук настраивается сам; name — отображаемое имя (иначе @username). Возвращает бота с id. Число ботов ограничено тарифом — HTTP 402 со ссылкой на смену тарифа. platform WEB — чат-виджет для сайта без токена (POST /api/bots/web): вернёт botId, key и snippet — код вставки на сайт (у существующего виджета — web_widget_snippet). platform INSTAGRAM по токену не подключается — только входом через Instagram (OAuth) в кабинете (/bots/instagram), нужен Instagram-аккаунт Business или Creator — инструмент вернёт ссылку на кабинет вместо ошибки. platform VK — сообщество как полноценный канал (Callback API, НЕ путать с vk_connect — тот для публикаций на стену): token — токен СООБЩЕСТВА (Управление сообществом → Работа с API → Создать ключ, доступ «Сообщения сообщества»), groupId — числовой id сообщества (обязателен). После подключения нужно вручную включить Callback API в настройках сообщества — см. webhookSecret в ответе и URL /vk/hooks/{botId} (подробный чек-лист у разработчика).", inputSchema: { type: "object", properties: { platform: { type: "string", enum: ["TELEGRAM", "MAX", "WEB", "INSTAGRAM", "VK"] }, token: { type: "string", description: "Токен бота/сообщества: 123456789:AA… (Telegram), токен MAX, или community token VK" }, groupId: { type: "string", description: "Только platform=VK: числовой id сообщества" }, name: { type: "string" } }, required: ["platform"] } },
1046
+ // ---- VK-лендинги (growth tools, Mini App) ----
1047
+ { name: "vk_landing_list", description: "Список VK-лендингов (Mini App, GET /api/growth/vk-landings): контент, сценарий-цель, авто-режим, готовые ссылки links.{base,auto}, статистика views/clicks/subscribes. Read-only.", inputSchema: { type: "object", properties: {} } },
1048
+ { name: "vk_landing_get", description: "Один VK-лендинг по id (GET /api/growth/vk-landings/{id}). 404 — не найден/чужой.", inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] } },
1049
+ { name: "vk_landing_create", description: "Создать VK-лендинг (POST /api/growth/vk-landings): botId — VK-бот (create_bot platform VK), title обязателен, остальное опционально — subtitle, imageUrl, buttonText, targetGraphId (сценарий — список scenario_list/list_graphs), tags (добавляются подписчику при переходе), autoSubscribe (true = &auto=1, подписка сразу при открытии), bothelpLandingId (маппинг старой ссылки BotHelp). Возвращает лендинг с готовыми ссылками links.base/links.auto вида vk.com/app<id>_-<groupId>#l=<id>[&auto=1] — публикуй их в сообществе.", inputSchema: { type: "object", properties: { botId: { type: "string" }, title: { type: "string" }, subtitle: { type: "string" }, imageUrl: { type: "string" }, buttonText: { type: "string" }, targetGraphId: { type: "string" }, tags: { type: "array", items: { type: "string" } }, autoSubscribe: { type: "boolean" }, bothelpLandingId: { type: "string" } }, required: ["botId", "title"] } },
1050
+ { name: "vk_landing_update", description: "Изменить VK-лендинг (PUT /api/growth/vk-landings/{id}) — те же поля, что vk_landing_create, полностью заменяют текущие (не частичный патч).", inputSchema: { type: "object", properties: { id: { type: "string" }, botId: { type: "string" }, title: { type: "string" }, subtitle: { type: "string" }, imageUrl: { type: "string" }, buttonText: { type: "string" }, targetGraphId: { type: "string" }, tags: { type: "array", items: { type: "string" } }, autoSubscribe: { type: "boolean" }, bothelpLandingId: { type: "string" } }, required: ["id", "botId", "title"] } },
1051
+ { name: "vk_landing_delete", description: "Удалить VK-лендинг (DELETE /api/growth/vk-landings/{id}) — ссылки перестают работать.", inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] } },
942
1052
  { name: "bot_stop", description: "Остановить бота (POST /api/bots/{botId}/stop): снимает вебхук, бот перестаёт отвечать, сценарии и подписчики сохраняются. Запуск обратно — bot_resume.", inputSchema: { type: "object", properties: { botId: { type: "string" } }, required: ["botId"] } },
943
1053
  { name: "bot_resume", description: "Запустить остановленного бота или бота, приостановленного лимитом тарифа (POST /api/bots/{botId}/resume). Если лимит ботов тарифа исчерпан — HTTP 402 со ссылкой на смену тарифа.", inputSchema: { type: "object", properties: { botId: { type: "string" } }, required: ["botId"] } },
944
1054
  // ---- Подключения ----
@@ -979,7 +1089,7 @@ const TOOLS = [
979
1089
  { 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
1090
  { 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
1091
  { 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"] } },
1092
+ { 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
1093
  { 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
1094
  { 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
1095
  { 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 +1141,15 @@ const TOOLS = [
1031
1141
  { 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
1142
  { 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
1143
  { 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"] } },
1144
+ { 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"] } },
1145
+ { 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"] } },
1146
+ // ---- Публикации: цепочки (автопостинг «Цепочки публикаций», граф DAG — режим «Схема») ----
1147
+ { name: "chain_list", description: "Цепочки публикаций пользователя (GET /api/bots/chains): автоматический репост source → граф обработки/назначений. Цепочка привязана к владельцу, не к конкретному боту. Read-only.", inputSchema: { type: "object", properties: {} } },
1148
+ { 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"] } },
1149
+ { 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"] } },
1150
+ { 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?, aiOverride?} — только для type=DESTINATION}], edges — [{fromNodeId, toNodeId}]. `x`/`y` (number, опционально) — координаты узла для отрисовки на холсте «Схема»; чисто UI-подсказка, бэкенд их не интерпретирует и не валидирует. Опусти (или оставь null) — фронтенд сам раскладывает узлы BFS-авто-layout'ом по столбцам/строкам; не нужно придумывать координаты самостоятельно. `destination.aiOverride` (опционально, та же форма и в легаси destinations[], см. ниже) — персональная адаптация ИИ ТОЛЬКО для этого назначения: {enabled: bool, aiMode: NONE|LIGHT|FULL, tone?: string, instructions?: string}. null/enabled:false (по умолчанию) — ничего не меняется, применяется только общий узел AI_ADAPT на пути к этому назначению (если он есть), как обычно. enabled:true — ПОСЛЕ результата общего узла (или сырого текста источника, если общего AI_ADAPT на пути нет) вызывается ЕЩЁ ОДНА адаптация ИИ с параметрами override, результат идёт ТОЛЬКО в это назначение — соседние назначения под тем же общим узлом не затрагиваются (означает лишний вызов ИИ и лишнее списание только за это назначение). Используй, когда нужен один общий тон для большинства каналов, но для одного-двух — особый (напр. более формальный) без заведения отдельного AI_ADAPT узла в графе. Модель — дерево-с-ветвлением (out-tree), НЕ произвольный DAG: у любого не-SOURCE узла ровно ОДНО входящее ребро; ветвление — через НЕСКОЛЬКО исходящих у одного родителя (напр. один общий AI_ADAPT → много DESTINATION = «1 источник → общий ИИ → N каналов»); слияния путей (несколько родителей) нет. Перед сохранением сложного графа прогони chain_validate (клиентское зеркало серверных правил) и chain_destinations_catalog (список доступных назначений). Для быстрого создания черновика без источника/назначений (поток «создать → сразу в Схему») используй chain_create_draft вместо этого инструмента. ЛЕГАСИ (nodes пуст): destinations: [{type: TELEGRAM|MAX|VK|INSTAGRAM, botId?, chatId?, accountId?, utmOverride?, aiOverride?}], steps?: [{type: FILTER|TEXT_TRANSFORM|AI_ADAPT|UTM|DELAY, ...поля шага}]. `aiOverride` работает в легаси-режиме так же, как в графовом (см. выше) — применяется поверх общего шага AI_ADAPT в steps[], если он есть. 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"] } },
1151
+ { name: "chain_create_draft", description: "Создать минимальный черновик цепочки (POST /api/bots/chains/draft) по имени: {name}. Возвращает PublishChain с enabled=false, source={type:TELEGRAM} БЕЗ конкретного botId/chatId, без steps/destinations/nodes/edges — обходит обычную валидацию chain_create (которая потребовала бы источник и ≥1 назначение). Только для потока «новая цепочка → сразу открыть на холсте „Схема“ и донастроить источник/назначения там» — черновик не может случайно уйти в работу недонастроенным (enabled всегда false, обычный chain_update с теми же правилами валидации, что и chain_create, не даст сохранить его без источника и назначений). Для обычного создания цепочки сразу с источником и назначениями используй chain_create.", inputSchema: { type: "object", properties: { name: { type: "string" } }, required: ["name"] } },
1152
+ { name: "chain_update", description: "Изменить цепочку целиком (PUT /api/bots/chains/{id}): chain — PublishChain draft (та же форма, что в chain_create, легаси ИЛИ граф), заменяет текущую конфигурацию полностью — сначала прочитай chain_get (или chain_graph_get, если нужен гарантированно графовый вид) и поменяй нужное. Те же 400/409, что у chain_create (включая x?/y? на узлах и aiOverride? на destination — см. chain_create).", inputSchema: { type: "object", properties: { chainId: { type: "string" }, chain: { type: "object", description: "PublishChain draft целиком" } }, required: ["chainId", "chain"] } },
1039
1153
  { 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
1154
  { name: "chain_enable", description: "Включить цепочку (POST /api/bots/chains/{id}/enable) — начинает принимать новые посты источника (TELEGRAM — сразу по событию, VK/RSS — со следующего опроса, раз в 3 мин).", inputSchema: { type: "object", properties: { chainId: { type: "string" } }, required: ["chainId"] } },
1041
1155
  { name: "chain_pause", description: "Поставить цепочку на паузу (POST /api/bots/chains/{id}/pause) — перестаёт принимать новые посты источника (VK/RSS — опрос останавливается, TELEGRAM — события игнорируются); история и настройки сохраняются.", inputSchema: { type: "object", properties: { chainId: { type: "string" } }, required: ["chainId"] } },
@@ -1043,6 +1157,8 @@ const TOOLS = [
1043
1157
  { 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
1158
  { 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
1159
  { 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"] } },
1160
+ { 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)" } } } },
1161
+ { 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
1162
  // ---- Аналитика (дашборд «/bots/analytics»: сайт/бот/сценарий/рассылка) ----
1047
1163
  { 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
1164
  { 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"] } },
@@ -1549,7 +1665,13 @@ async function handleCall(params) {
1549
1665
  if (!/^[1-9]\d{0,18}$/.test(cid)) throw new Error(`rows[${i}].chatId — положительное число, получено: ${cid || "пусто"}.`);
1550
1666
  if (r.tags != null && !Array.isArray(r.tags)) throw new Error(`rows[${i}].tags — массив строк.`);
1551
1667
  if (r.variables != null && (typeof r.variables !== "object" || Array.isArray(r.variables))) throw new Error(`rows[${i}].variables — объект.`);
1552
- return { chatId: `@@CID${cid}@@`, username: r.username, tags: r.tags, variables: r.variables };
1668
+ if (r.status != null && !["ACTIVE", "UNSUBSCRIBED", "BLOCKED"].includes(String(r.status).toUpperCase())) {
1669
+ throw new Error(`rows[${i}].status — ACTIVE|UNSUBSCRIBED|BLOCKED, получено: ${r.status}.`);
1670
+ }
1671
+ return {
1672
+ chatId: `@@CID${cid}@@`, username: r.username, tags: r.tags, variables: r.variables,
1673
+ subscribedAt: r.subscribedAt, source: r.source, status: r.status,
1674
+ };
1553
1675
  });
1554
1676
  const raw = JSON.stringify({ rows }).replace(/"@@CID(\d+)@@"/g, "$1");
1555
1677
  return okResult(await api(`/api/bots/${uuidArg(a.botId, "botId")}/users/import`, { method: "POST", rawBody: raw }));
@@ -1768,13 +1890,48 @@ async function handleCall(params) {
1768
1890
  const suffix = commit ? "commit" : "preview";
1769
1891
  return okResult(await migrationMultipart(`/api/bots/${botId}/migration/subscribers/${suffix}`, { filePath: a.path, platform, mapping: a.mapping }));
1770
1892
  }
1893
+ case "bothelp_scenario_preview":
1894
+ case "bothelp_scenario_commit": {
1895
+ const botId = uuidArg(a.botId, "botId");
1896
+ const scenario = readJsonFile(a.scenarioFile, "scenarioFile");
1897
+ let externalRequests = {};
1898
+ if (a.externalRequestsDir) {
1899
+ const dir = path.resolve(String(a.externalRequestsDir).replace(/^~(?=$|[/\\])/, os.homedir()));
1900
+ let files = [];
1901
+ try { files = fs.readdirSync(dir).filter((f) => f.endsWith(".json")); }
1902
+ catch { throw new Error(`externalRequestsDir не найден: ${dir}`); }
1903
+ for (const f of files) externalRequests[f.replace(/\.json$/, "")] = readJsonFile(path.join(dir, f), f);
1904
+ }
1905
+ const suffix = params.name === "bothelp_scenario_commit" ? "commit" : "preview";
1906
+ return okResult(await api(`/api/bots/${botId}/migration/bothelp/scenario/${suffix}`, {
1907
+ method: "POST", body: { scenario, externalRequests },
1908
+ }));
1909
+ }
1771
1910
  // ---- Боты ----
1772
1911
  case "create_bot": {
1773
1912
  const platform = String(a.platform || "").trim().toUpperCase();
1774
1913
  if (platform === "INSTAGRAM") return instagramAnswer();
1775
1914
  if (platform === "WEB") return okResult(await api("/api/bots/web", { method: "POST", body: { name: a.name || undefined } }));
1776
- if (platform !== "TELEGRAM" && platform !== "MAX") throw new Error("platform: TELEGRAM | MAX | WEB (Instagram подключается только в кабинете).");
1915
+ if (platform !== "TELEGRAM" && platform !== "MAX" && platform !== "VK") throw new Error("platform: TELEGRAM | MAX | VK | WEB (Instagram подключается только в кабинете).");
1777
1916
  const token = String(a.token || "").trim();
1917
+ if (platform === "VK") {
1918
+ const groupId = String(a.groupId || "").trim();
1919
+ if (!token || !groupId) {
1920
+ return okResult("Нужны токен сообщества и его id: Управление сообществом → Работа с API → Создать ключ (доступ «Сообщения сообщества») — пришли token и groupId (create_bot {platform:\"VK\", token, groupId}).");
1921
+ }
1922
+ let vkBot;
1923
+ try {
1924
+ vkBot = await api("/api/bots", { method: "POST", body: { token, platform: "VK", groupId } });
1925
+ } catch (e) {
1926
+ if (e.status === 400) throw new Error(`VK-бот не подключён: ${typeof e.data === "string" && e.data ? e.data : "токен или groupId не приняты"}. Проверь токен и id сообщества.`);
1927
+ throw e;
1928
+ }
1929
+ if (a.name && vkBot?.id) {
1930
+ try { vkBot = await api(`/api/bots/${vkBot.id}`, { method: "PATCH", body: { name: a.name } }); }
1931
+ catch (e) { return okResult({ ...vkBot, warning: `Бот подключён, но имя не задано: ${(e.message || "").split("\n")[0]}` }); }
1932
+ }
1933
+ return okResult({ ...vkBot, note: "Дальше включи Callback API в настройках сообщества (Управление → Работа с API → Callback API): URL https://<домен>/vk/hooks/" + vkBot.id + ", секретный ключ — поле vkCallbackSecret в этом же ответе, тип события «Входящее сообщение», затем «Подтвердить сервер»." });
1934
+ }
1778
1935
  if (!token) {
1779
1936
  return okResult(platform === "MAX"
1780
1937
  ? "Нужен токен MAX-бота: создай бота у MasterBot в MAX и пришли токен — я подключу его (create_bot {platform:\"MAX\", token})."
@@ -1794,6 +1951,19 @@ async function handleCall(params) {
1794
1951
  }
1795
1952
  return okResult(bot);
1796
1953
  }
1954
+ case "vk_landing_list": return okResult(await api("/api/growth/vk-landings"));
1955
+ case "vk_landing_get": return okResult(await api(`/api/growth/vk-landings/${uuidArg(a.id, "id")}`));
1956
+ case "vk_landing_create": {
1957
+ if (!a.botId || !a.title) throw new Error("botId и title обязательны");
1958
+ return okResult(await api("/api/growth/vk-landings", { method: "POST", body: landingBody(a) }));
1959
+ }
1960
+ case "vk_landing_update": {
1961
+ if (!a.id || !a.botId || !a.title) throw new Error("id, botId и title обязательны");
1962
+ return okResult(await api(`/api/growth/vk-landings/${uuidArg(a.id, "id")}`, { method: "PUT", body: landingBody(a) }));
1963
+ }
1964
+ case "vk_landing_delete":
1965
+ await api(`/api/growth/vk-landings/${uuidArg(a.id, "id")}`, { method: "DELETE" });
1966
+ return okResult({ deleted: true });
1797
1967
  case "bot_stop": return okResult(await api(`/api/bots/${a.botId}/stop`, { method: "POST" }));
1798
1968
  case "bot_resume": return okResult(await api(`/api/bots/${a.botId}/resume`, { method: "POST" }));
1799
1969
  // ---- Подключения ----
@@ -2051,10 +2221,15 @@ async function handleCall(params) {
2051
2221
  // ---- Публикации: цепочки ----
2052
2222
  case "chain_list": return okResult(await api("/api/bots/chains"));
2053
2223
  case "chain_get": return okResult(await api(`/api/bots/chains/${uuidArg(a.chainId, "chainId")}`));
2224
+ case "chain_graph_get": return okResult(await api(`/api/bots/chains/${uuidArg(a.chainId, "chainId")}/graph`));
2054
2225
  case "chain_create": {
2055
2226
  if (!a.chain || typeof a.chain !== "object") throw new Error("Передай chain — объект PublishChain (см. описание chain_create).");
2056
2227
  return okResult(await api("/api/bots/chains", { method: "POST", body: a.chain }));
2057
2228
  }
2229
+ case "chain_create_draft": {
2230
+ if (!a.name || typeof a.name !== "string") throw new Error("Передай name — название черновика цепочки.");
2231
+ return okResult(await api("/api/bots/chains/draft", { method: "POST", body: { name: a.name } }));
2232
+ }
2058
2233
  case "chain_update": {
2059
2234
  const chainId = uuidArg(a.chainId, "chainId");
2060
2235
  if (!a.chain || typeof a.chain !== "object") throw new Error("Передай chain — объект PublishChain целиком (сначала прочитай chain_get).");
@@ -2085,6 +2260,34 @@ async function handleCall(params) {
2085
2260
  await api(`/api/bots/chains/jobs/${jobId}/retry`, { method: "POST" });
2086
2261
  return okResult(`🔁 Задание ${jobId} поставлено на повтор.`);
2087
2262
  }
2263
+ case "chain_validate": {
2264
+ const nodes = Array.isArray(a.nodes) ? a.nodes : Array.isArray(a.chain?.nodes) ? a.chain.nodes : null;
2265
+ const edges = Array.isArray(a.edges) ? a.edges : Array.isArray(a.chain?.edges) ? a.chain.edges : null;
2266
+ if (!nodes || !edges) throw new Error("Передай nodes и edges (массивы) напрямую, либо chain — объект с полями nodes/edges (см. chain_graph_get).");
2267
+ return okResult(validateChainGraph(nodes, edges));
2268
+ }
2269
+ case "chain_destinations_catalog": {
2270
+ const wanted = Array.isArray(a.botIds) && a.botIds.length ? new Set(a.botIds.map(String)) : null;
2271
+ const [bots, vk] = await Promise.all([api("/api/bots"), api("/api/bots/vk/status")]);
2272
+ const botList = (Array.isArray(bots) ? bots : []).filter((b) => (b?.platform === "TELEGRAM" || b?.platform === "MAX") && (!wanted || wanted.has(String(b?.id))));
2273
+ const perBot = await Promise.all(botList.map(async (b) => {
2274
+ try {
2275
+ const chats = await api(`/api/bots/${b.id}/linked-chats`);
2276
+ return { bot: b, chats: Array.isArray(chats) ? chats : [] };
2277
+ } catch {
2278
+ return { bot: b, chats: [] }; // чужой/недоступный бот — просто не попадёт в каталог, не валим весь вызов
2279
+ }
2280
+ }));
2281
+ const telegram = [];
2282
+ const max = [];
2283
+ for (const { bot, chats } of perBot) {
2284
+ const bucket = bot.platform === "MAX" ? max : telegram;
2285
+ for (const c of chats) {
2286
+ bucket.push({ botId: bot.id, botName: bot.name || bot.username || bot.id, chatId: c.chatId, title: c.title, type: c.type, status: c.status });
2287
+ }
2288
+ }
2289
+ return okResult({ telegram, max, vk: Array.isArray(vk?.accounts) ? vk.accounts : [] });
2290
+ }
2088
2291
  // ---- Аналитика (дашборд) ----
2089
2292
  case "site_analytics": {
2090
2293
  const siteId = uuidArg(a.siteId, "siteId");