@retensy/mcp 0.24.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.
- package/README.md +10 -4
- package/package.json +1 -1
- package/skills/build-publication-chain/SKILL.md +239 -0
- package/src/index.mjs +155 -8
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)` |
|
|
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,13 @@ 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
|
-
| `
|
|
262
|
-
| `
|
|
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` |
|
|
263
269
|
| `chain_delete(chainId, confirm:true)` | удалить цепочку (теряется история заданий) |
|
|
264
270
|
| `chain_enable(chainId)` / `chain_pause(chainId)` | включить / поставить на паузу приём новых постов источника |
|
|
265
271
|
| `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.
|
|
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": {
|
|
@@ -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`.
|
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.
|
|
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));
|
|
@@ -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: "
|
|
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: "
|
|
1036
|
-
|
|
1037
|
-
{ name: "
|
|
1038
|
-
{ name: "
|
|
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"] } },
|
|
@@ -1549,7 +1644,13 @@ async function handleCall(params) {
|
|
|
1549
1644
|
if (!/^[1-9]\d{0,18}$/.test(cid)) throw new Error(`rows[${i}].chatId — положительное число, получено: ${cid || "пусто"}.`);
|
|
1550
1645
|
if (r.tags != null && !Array.isArray(r.tags)) throw new Error(`rows[${i}].tags — массив строк.`);
|
|
1551
1646
|
if (r.variables != null && (typeof r.variables !== "object" || Array.isArray(r.variables))) throw new Error(`rows[${i}].variables — объект.`);
|
|
1552
|
-
|
|
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
|
+
};
|
|
1553
1654
|
});
|
|
1554
1655
|
const raw = JSON.stringify({ rows }).replace(/"@@CID(\d+)@@"/g, "$1");
|
|
1555
1656
|
return okResult(await api(`/api/bots/${uuidArg(a.botId, "botId")}/users/import`, { method: "POST", rawBody: raw }));
|
|
@@ -1768,6 +1869,23 @@ async function handleCall(params) {
|
|
|
1768
1869
|
const suffix = commit ? "commit" : "preview";
|
|
1769
1870
|
return okResult(await migrationMultipart(`/api/bots/${botId}/migration/subscribers/${suffix}`, { filePath: a.path, platform, mapping: a.mapping }));
|
|
1770
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
|
+
}
|
|
1771
1889
|
// ---- Боты ----
|
|
1772
1890
|
case "create_bot": {
|
|
1773
1891
|
const platform = String(a.platform || "").trim().toUpperCase();
|
|
@@ -2051,6 +2169,7 @@ async function handleCall(params) {
|
|
|
2051
2169
|
// ---- Публикации: цепочки ----
|
|
2052
2170
|
case "chain_list": return okResult(await api("/api/bots/chains"));
|
|
2053
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`));
|
|
2054
2173
|
case "chain_create": {
|
|
2055
2174
|
if (!a.chain || typeof a.chain !== "object") throw new Error("Передай chain — объект PublishChain (см. описание chain_create).");
|
|
2056
2175
|
return okResult(await api("/api/bots/chains", { method: "POST", body: a.chain }));
|
|
@@ -2085,6 +2204,34 @@ async function handleCall(params) {
|
|
|
2085
2204
|
await api(`/api/bots/chains/jobs/${jobId}/retry`, { method: "POST" });
|
|
2086
2205
|
return okResult(`🔁 Задание ${jobId} поставлено на повтор.`);
|
|
2087
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
|
+
}
|
|
2088
2235
|
// ---- Аналитика (дашборд) ----
|
|
2089
2236
|
case "site_analytics": {
|
|
2090
2237
|
const siteId = uuidArg(a.siteId, "siteId");
|