@retensy/mcp 0.12.1 → 0.14.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.
@@ -6,7 +6,7 @@
6
6
  {
7
7
  "name": "retensy-mcp",
8
8
  "source": "./",
9
- "description": "Сборка и публикация воронок ботов (Telegram/MAX/Instagram, Retensy Bots) из описания + публикация статей блога в Markdown — через MCP + скилл."
9
+ "description": "Сборка и публикация воронок ботов (Telegram/MAX, Retensy Bots) из описания, рассылки, подключение ботов и сервисов, сайтов из блоков + публикация статей блога в Markdown — через MCP + скилл."
10
10
  }
11
11
  ]
12
12
  }
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "retensy-mcp",
3
3
  "displayName": "Retensy MCP",
4
- "version": "0.12.1",
5
- "description": "MCP-сервер + скилл для Retensy Bots: сборка и публикация воронок ботов (Telegram/MAX/Instagram) и публикация статей блога в Markdown — всё одним токеном zmcp_.",
4
+ "version": "0.14.0",
5
+ "description": "MCP-сервер + скилл для Retensy Bots: подключение ботов и сервисов, сборка и публикация воронок ботов (Telegram/MAX), рассылки, сайты из блоков (Zero-блок, дизайны, шаблоны, свои домены, заявки) и статьи блога в Markdown — всё одним токеном zmcp_.",
6
6
  "author": { "name": "retensy", "url": "https://bots.retensy.com" },
7
7
  "homepage": "https://bots.retensy.com/bots",
8
8
  "repository": "https://github.com/retensy/retensy-mcp",
package/README.md CHANGED
@@ -7,9 +7,13 @@
7
7
 
8
8
  MCP-сервер (+ скилл для Claude Code) для **сборки и публикации воронок/автоматизаций ботов (Telegram, MAX и Instagram)** в сервисе [retensy `/bots`](https://bots.retensy.com/bots): из текстового описания → валидный граф сценария → заливка и публикация через API.
9
9
 
10
- - 🤖 **30 инструментов сборки/публикации**: `list_bots`, `list_graphs`, `list_channels`, `get_graph`, `create_graph`, `update_graph`, `edit_graph_live`, `patch_graph`, `dry_run`, `publish_graph`, `import_funnel`, `list_templates`, `create_graph_from_template`, `clone_graph`, `copy_graph`, `rename_graph`, `set_active_graph`, `delete_graph`, `upload_file`, `list_files`, `delete_file`, `graph_analytics`, `list_bot_users`, `list_links` (+ `setup`/`set_token`).
10
+ - 🤖 **Сценарии ботов**: `list_bots`, `list_graphs`, `list_channels`, `get_graph`, `create_graph`, `update_graph`, `edit_graph_live`, `patch_graph`, `dry_run`, `publish_graph`, `import_funnel`, `list_templates`, `create_graph_from_template`, `clone_graph`, `copy_graph`, `rename_graph`, `set_active_graph`, `delete_graph`, `upload_file`, `list_files`, `delete_file`, `graph_analytics`, `list_bot_users`, `list_links` (+ `setup`/`set_token`).
11
+ - 🔌 **Боты и сервисы**: `create_bot` (Telegram/MAX по токену), `bot_stop`/`bot_resume`, `connect_integration`/`disconnect_integration` (amoCRM, Битрикс24, GetCourse, Я.Метрика, ЮKassa; Google Таблицы — ссылкой на вход Google).
12
+ - 📣 **Рассылки**: `broadcast_preview` (размер аудитории), `broadcast_send` (сейчас или по расписанию, сразу по нескольким ботам; сообщения или запуск сценария), `broadcast_list`/`broadcast_get`/`broadcast_cancel`, повторяющиеся (`broadcast_recurring`), черновики (`broadcast_drafts`, `broadcast_duplicate`) + скилл `send-broadcast`.
13
+ - 🔗 **Ссылка вместо отказа**: что нельзя сделать через API (вход через Google/Facebook, оплата тарифа, вход в аккаунт) — инструмент возвращает прямую ссылку и одну строку, что сделать.
11
14
  - 📝 **Статьи блога** (тот же токен `zmcp_…`): `article_publish`, `article_update`, `article_list`, `article_get` — публикация статей в Markdown (как README на GitHub) в раздел **/articles**.
12
15
  - 📎 **Медиа**: `upload_file` грузит фото/видео/документы в библиотеку **/bots/files** (до 50 МБ) и возвращает публичный URL — его вставляешь в медиа-карточку сценария.
16
+ - 🌐 **Сайты из блоков** (раздел «Страницы»): создание, правка операциями (блоки, Zero-блок со свободной вёрсткой, код блока, папки страниц, дизайны, шаблоны из библиотеки), публикация и откат, свои домены, заявки из форм и куда их доставлять — инструменты `site_*` + скилл `build-site`.
13
17
  - 🧠 **Скилл `build-bot-funnel`**: учит агента собирать корректный граф (типы узлов, ветки, кнопки, задержки) и проверять его перед публикацией. Поддерживает Telegram, MAX и Instagram.
14
18
  - 📦 **Без зависимостей** — чистый Node ≥18, ставится и запускается сразу.
15
19
 
@@ -17,9 +21,9 @@ MCP-сервер (+ скилл для Claude Code) для **сборки и пу
17
21
 
18
22
  | Платформа | Онбординг | Триггеры входа | Ограничения |
19
23
  |---|---|---|---|
20
- | **Telegram** | Токен бота (BotFather) | `/start`, команды, callback, текст, рассылки | Полный функционал |
21
- | **MAX** | Токен бота (MAX Developer) | Команды, callback, текст | Без SUBSCRIBED/reply-клавиатур (мягкие предупреждения) |
22
- | **Instagram** | OAuth в `/bots/instagram` (без токена) | Комментарий/Direct/Ответ на историю/Упоминание | Ограниченный набор узлов; DELAY ≤ 24ч; ASK_QUESTION только TEXT/EMAIL/PHONE/NUMBER/CONTACT (CONTACT = ручной ввод номера); без рассылок |
24
+ | **Telegram** | Токен бота (BotFather) → `create_bot` | `/start`, команды, callback, текст, рассылки | Полный функционал |
25
+ | **MAX** | Токен бота (MasterBot в MAX) → `create_bot` | Команды, callback, текст | Без SUBSCRIBED/reply-клавиатур (мягкие предупреждения) |
26
+ | **Instagram** ⏸ | OAuth в `/bots/instagram` (без токена) — **сейчас выключен в сервисе** (подключение новых IG-ботов скрыто, флаг `instagram.enabled`) | Комментарий/Direct/Ответ на историю/Упоминание | Ограниченный набор узлов; DELAY ≤ 24ч; ASK_QUESTION только TEXT/EMAIL/PHONE/NUMBER/CONTACT (CONTACT = ручной ввод номера); без рассылок |
23
27
 
24
28
  ---
25
29
 
@@ -32,7 +36,7 @@ MCP-сервер (+ скилл для Claude Code) для **сборки и пу
32
36
  /plugin install retensy-mcp@retensy
33
37
  ```
34
38
 
35
- Подтянутся и MCP-сервер `bot-graph`, и скилл `build-bot-funnel`. Проверить: `/mcp` и `/plugin`.
39
+ Подтянутся MCP-сервер и скиллы `build-bot-funnel`, `build-site`, `send-broadcast`. Проверить: `/mcp` и `/plugin`.
36
40
 
37
41
  ### Вариант B — как обычный MCP-сервер (Claude Code / Cursor / Windsurf / любой MCP-клиент)
38
42
 
@@ -92,9 +96,13 @@ MCP-сервер (+ скилл для Claude Code) для **сборки и пу
92
96
  | `setup` | статус авторизации + пошаговая инструкция подключения |
93
97
  | `set_token` | сохранить присланный токен `zmcp_…` (без env/рестарта) |
94
98
  | `list_bots` | список ботов |
99
+ | `create_bot(platform, token, name?)` | подключить бота Telegram/MAX по токену (Instagram → ссылка на кабинет) |
100
+ | `bot_stop(botId)` / `bot_resume(botId)` | остановить / запустить бота |
95
101
  | `list_graphs(botId)` | графы (сценарии) бота |
96
102
  | `list_channels(botId)` | каналы/группы, подключённые к боту (chatId для условия SUBSCRIBED) |
97
- | `list_integrations()` | подключённые сервисы (amoCRM, Битрикс24, GetCourse, Я.Метрика): `id` = `connectionId` для действий сценария |
103
+ | `list_integrations()` | подключённые сервисы (amoCRM, Битрикс24, GetCourse, Я.Метрика, ЮKassa): `id` = `connectionId` для действий сценария |
104
+ | `connect_integration(provider, creds?, title?, connectionId?)` | подключить/обновить сервис; без кредов — какие поля нужны; Google Таблицы → ссылка входа Google, Instagram → ссылка на кабинет |
105
+ | `disconnect_integration(connectionId)` | удалить подключение |
98
106
  | `get_graph(graphId, [summary], [saveToFile])` | получить граф; `summary:true` — компактная сводка (id/type/title + рёбра), `saveToFile` — записать полный JSON на диск (для больших графов, чтобы не упереться в лимит токенов) |
99
107
  | `create_graph(botId, name)` | создать пустой граф (DRAFT) |
100
108
  | `update_graph(graphId, graphFile\|graph\|nodes,edges)` | залить узлы/рёбра (PUT); `graphFile` — путь к локальному JSON, граф не нужно слать инлайном |
@@ -115,11 +123,69 @@ MCP-сервер (+ скилл для Claude Code) для **сборки и пу
115
123
  | `graph_analytics(graphId)` | прохождение сценария по узлам (где отваливается воронка) |
116
124
  | `list_bot_users(botId)` | подписчики/лиды бота (постранично, поиск `query`) |
117
125
  | `list_links(botId)` | стартовые трекинговые ссылки бота с UTM |
126
+ | `site_list()` | сайты пользователя (id, mode, url, publishedRevision) |
127
+ | `site_create(title, slug?)` | новый сайт из блоков → `id` |
128
+ | `site_get(siteId, saveToFile?)` | модель сайта (`revision`, `draft`, `versions[]` публикаций) |
129
+ | `site_schema()` | JSON Schema модели и операций — читать перед правкой |
130
+ | `site_edit(siteId, ops[], revision?, init?)` | правка операциями, всё или ничего: страницы и папки, блоки, Zero-элементы, код блока (`get/set/add_block_code`), дизайны и их кадры, шаблоны (`add_template`), тема, попапы |
131
+ | `site_templates(category?, full?)` | библиотека шаблонов блоков для `add_template` |
132
+ | `site_publish(siteId)` | опубликовать черновик → `url` |
133
+ | `site_rollback(siteId, revision)` | вернуть прошлую публикацию |
134
+ | `site_upload_asset(siteId, path\|url)` | картинка/видео в сайт → `assets/…` |
135
+ | `site_domains(siteId, action, host?, withWww?, domainId?)` | свои домены: list / add / check / remove (число — по тарифу) |
136
+ | `site_leads(siteId, page?, size?)` | заявки из форм (поля, UTM, статус доставки) |
137
+ | `site_lead_settings(siteId, settings?)` | куда доставлять заявки: бот уведомлений, почта, вебхук, вебхук-сценарий, amoCRM |
138
+ | `broadcast_list(botId?, group?, page?, size?)` | рассылки + счётчики разделов (черновики/запланированные/отправленные/повторы) |
139
+ | `broadcast_get(broadcastId)` | рассылка целиком: статус, счётчики, сообщения |
140
+ | `broadcast_preview(botIds, tagsAll?, tagsNone?)` | сколько подписчиков получат рассылку |
141
+ | `broadcast_send(name, botIds, messages \| graphId, tagsAll?, tagsNone?, scheduledAt?, draftId?)` | отправить сейчас / запланировать; по нескольким ботам; или запуск сценария |
142
+ | `broadcast_cancel(broadcastId)` | отменить запланированную/идущую |
143
+ | `broadcast_recurring(action, …)` | повторяющиеся рассылки: list / create (DAILY·MONTHLY·YEARLY) / stop |
144
+ | `broadcast_drafts(action, …)` | черновики: list / get / create / update / delete |
145
+ | `broadcast_duplicate(broadcastId)` | копия рассылки как черновик |
118
146
  | `article_list()` | свои статьи блога (id, slug, title, просмотры) |
119
147
  | `article_get(slug)` | статья по slug (Markdown content, excerpt, обложка) |
120
148
  | `article_publish(content, title?, cover?, excerpt?)` | новая статья (Markdown; title из `# ...`, если не задан; обложка из `cover`-URL или 1-й картинки → OG; `excerpt` явно или авто) → id, slug, URL |
121
149
  | `article_update(id, content, title?)` | обновить свою статью по id |
122
150
 
151
+ ### Рассылки
152
+
153
+ Сообщение рассылки — как в мастере кабинета: `{type, text?, mediaUrl?, mediaUrls?, buttons?}`, до 5 сообщений.
154
+
155
+ | type | Обязательно | Текст | Кнопки |
156
+ |---|---|---|---|
157
+ | `TEXT` | `text` (до 4096) | Telegram-HTML | до 8 URL-кнопок |
158
+ | `PHOTO` `VIDEO` `AUDIO` `FILE` `VOICE` | `mediaUrl` | подпись до 1024 | до 8 |
159
+ | `VIDEONOTE` (кружок) | `mediaUrl` | нет | до 8 |
160
+ | `GALLERY` | `mediaUrls` — 2–10 картинок | подпись до 1024 | нет |
161
+
162
+ - HTML: `<b> <i> <u> <s> <code> <pre> <blockquote> <tg-spoiler> <a href="https://…">`, перенос строки — `\n`.
163
+ - Кнопки только URL `[{text, url}]` — callback-кнопок в рассылке нет. Медиа — сначала `upload_file`, потом его `url`.
164
+ - Аудитория — подписчики бота, фильтр тегами: `tagsAll` (есть все), `tagsNone` (нет ни одного). До 50 000 на бота, до 20 ботов одного владельца за раз.
165
+ - `scheduledAt` — ISO 8601; без часового пояса считается московским. Пусто — отправить сейчас.
166
+ - Рассылки — на платном тарифе; квота получателей месячная. При нехватке — ошибка с прямой ссылкой на смену тарифа.
167
+ - У Instagram-ботов рассылок нет.
168
+
169
+ ```json
170
+ {"name": "Распродажа", "botIds": ["<id>"], "tagsAll": ["клиент"], "scheduledAt": "2026-10-10T10:00",
171
+ "messages": ["<b>Только сегодня</b> — скидка 30%",
172
+ {"type": "PHOTO", "mediaUrl": "https://…/sale.jpg", "text": "Успей до полуночи",
173
+ "buttons": [{"text": "В магазин", "url": "https://shop.example"}]}]}
174
+ ```
175
+
176
+ ### Когда нужен браузер
177
+
178
+ Через API не делается то, что требует входа пользователя у стороннего сервиса, оплаты или входа в аккаунт.
179
+ Такие инструменты не падают, а возвращают `{needsBrowser: true, url, instruction}` или ошибку со ссылкой:
180
+
181
+ | Ситуация | Ссылка |
182
+ |---|---|
183
+ | нет токена / токен отозван | `/bots/mcp-tokens` — создать токен и прислать агенту |
184
+ | Google Таблицы (`connect_integration`) | одноразовая ссылка согласия Google (OAuth) |
185
+ | Instagram (`create_bot`, `connect_integration`) | `/bots/connect` — подключается входом через Facebook; сейчас выключен в сервисе |
186
+ | лимит тарифа, рассылки на бесплатном (HTTP 402) | `upgradeUrl` из ответа или `/bots/subscription` |
187
+ | свой домен сайта | DNS у регистратора: A-запись на `dnsTarget` из `site_domains` |
188
+
123
189
  ---
124
190
 
125
191
  ## Формат графа и проверка
package/package.json CHANGED
@@ -1,21 +1,54 @@
1
1
  {
2
2
  "name": "@retensy/mcp",
3
- "version": "0.12.1",
4
- "description": "MCP server to build and publish Telegram, MAX and Instagram bot funnels/automations — and publish Markdown blog articles — in the Retensy Bots service. Zero dependencies.",
3
+ "version": "0.14.0",
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
- "bin": { "retensy-mcp": "src/index.mjs" },
6
+ "bin": {
7
+ "retensy-mcp": "src/index.mjs"
8
+ },
7
9
  "main": "src/index.mjs",
8
- "publishConfig": { "access": "public" },
9
- "files": ["src", "skills", ".mcp.json", ".claude-plugin", "README.md", "LICENSE"],
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "files": [
14
+ "src",
15
+ "skills",
16
+ ".mcp.json",
17
+ ".claude-plugin",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
10
21
  "scripts": {
11
22
  "start": "node src/index.mjs",
12
23
  "check": "node --check src/index.mjs",
13
- "test": "node scripts/smoke.mjs && node scripts/telemetry.test.mjs && node scripts/live-edit-422.test.mjs",
24
+ "test": "node scripts/smoke.mjs && node scripts/telemetry.test.mjs && node scripts/live-edit-422.test.mjs && node scripts/broadcast-connect.test.mjs",
14
25
  "validate": "node skills/build-bot-funnel/validate.mjs"
15
26
  },
16
- "engines": { "node": ">=18" },
17
- "keywords": ["mcp", "telegram", "max", "instagram", "bot", "funnel", "automation", "articles", "blog", "markdown", "retensy", "claude-code", "model-context-protocol"],
18
- "repository": { "type": "git", "url": "git+https://github.com/retensy/retensy-mcp.git" },
27
+ "engines": {
28
+ "node": ">=18"
29
+ },
30
+ "keywords": [
31
+ "mcp",
32
+ "telegram",
33
+ "max",
34
+ "instagram",
35
+ "bot",
36
+ "funnel",
37
+ "broadcast",
38
+ "automation",
39
+ "articles",
40
+ "website-builder",
41
+ "landing-page",
42
+ "blog",
43
+ "markdown",
44
+ "retensy",
45
+ "claude-code",
46
+ "model-context-protocol"
47
+ ],
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "git+https://github.com/retensy/retensy-mcp.git"
51
+ },
19
52
  "homepage": "https://bots.retensy.com/bots",
20
53
  "license": "MIT"
21
54
  }
@@ -54,16 +54,16 @@ description: Собрать воронку (сценарий) бота для с
54
54
  - `list_bots` → выбрать `botId` (или `create_graph` в существующем боте).
55
55
  - `create_graph(botId, name)` → получить `graphId`. Либо стартуй с готовой основы: `list_templates` → `create_graph_from_template(botId, templateId, name)`.
56
56
  - `update_graph(graphId, nodes, edges, canvasMeta)` → залить узлы/рёбра.
57
- - `dry_run(graphId, kind:"command", value:"start")` → прогнать стартовую ветку, проверить `runStatus`.
57
+ - `dry_run(graphId, kind:"command", value:"start")` → прогнать стартовую ветку, проверить `runStatus`. `dry_run` ничего не делает снаружи: внешние запросы, CRM, таблицы, письма, уведомления, счета и ответы ИИ пропускаются (у шага `skipped: "dry-run"`), переменные из их ответов остаются пустыми — это проверяется только живым прогоном.
58
58
  - `publish_graph(graphId)` → при отказе инструмент вернёт ошибку `HTTP 422` со ВСЕМИ причинами построчно (`code@nodeId: message`) — разобрать, починить узлы, обновить, опубликовать снова.
59
59
  - Управление сценариями: `clone_graph`, `rename_graph`, `set_active_graph` (переключить живой граф), `delete_graph` (активный нельзя — сначала переключи).
60
60
  Если MCP не подключён — отдай готовый `import.json` и подскажи: /bots → граф → **Импорт**.
61
61
 
62
62
  5b. **Правка СУЩЕСТВУЮЩЕГО / живого сценария — по умолчанию `edit_graph_live`, а НЕ clone+publish.**
63
- - Когда пользователь просит «поправь сценарий X» (особенно если он уже открыт в редакторе или опубликован) — правь **ТОТ ЖЕ `graphId`** через **`edit_graph_live(graphId, nodes, edges)`**. Он сам снимает авто-бэкап предыдущего состояния (один rolling-граф «🔙 Авто-бэкап») и делает PUT на месте — **id не меняется**. Живой сценарий бота — граф с `isActive: true` в `list_graphs` (статус `PUBLISHED`; после `publish_graph` черновика это `publishedGraphId`, а **не** id черновика) — правь его id, иначе правка ляжет в черновик и до бота не дойдёт.
63
+ - Когда пользователь просит «поправь сценарий X» (особенно если он уже открыт в редакторе или опубликован) — правь **ТОТ ЖЕ `graphId`** через **`edit_graph_live(graphId, nodes, edges)`**. Он сам снимает авто-бэкап предыдущего состояния (один rolling-граф «🔙 Авто-бэкап») и делает PUT на месте — **id не меняется**. Живой сценарий бота (бот-сценария) — граф с `isActive: true` в `list_graphs` (статус `PUBLISHED`; после `publish_graph` черновика это `publishedGraphId`, а **не** id черновика) — правь его id, иначе правка ляжет в черновик и до бота не дойдёт. Вебхук-сценарий отдельного опубликованного графа не имеет — правь его по его же id.
64
64
  - Почему так: бэкенд при PUT/публикации шлёт `external_update` в WS-комнату → открытые редакторы перечитывают граф **вживую** (юзеру не надо перезаходить). Бот читает активный граф **заново из БД на каждое сообщение** → правка живого PUBLISHED-графа применяется **сразу, без отдельной публикации**.
65
- - `clone_graph`+`publish_graph` НЕ создают новый живой граф и НЕ песочница: публикация копирует клон (как любой черновик) в id ТЕКУЩЕГО опубликованного графа бота и затирает его содержимое без бэкапа — вместе с живыми правками, сделанными после прошлой публикации; `set_active_graph` на прежний id после этого вернёт уже новое содержимое, а открытые редакторы живого графа об этом не узнают. Клон годится, чтобы спокойно собрать крупный рискованный рефактор в черновике, но перед его публикацией сохрани живой граф: `get_graph(graphId, saveToFile)` — откат = залить файл обратно через `edit_graph_live`/`update_graph` с `graphFile`.
66
- - ⚠️ PUT **активного** (PUBLISHED) графа сервер проверяет как `publish`: валидатор, платные блоки, лимит блоков тарифа, платформа. Ошибка → `HTTP 422` со всеми `code@nodeId`, граф **не сохранён**, бот работает на прежней версии. Черновик (`DRAFT`) PUT сохраняет без проверок, и на бота он не влияет: `publish_graph` черновика копирует его в отдельный `PUBLISHED`-граф (`publishedGraphId`), а сам черновик остаётся `DRAFT`. Всё равно прогоняй `validate.mjs` + `dry_run` заранее. Откат: версии активного не хранятся — опубликовать граф «🔙 Авто-бэкап» (или скопировать его содержимое обратно).
65
+ - `clone_graph`+`publish_graph` НЕ создают новый живой граф и НЕ песочница: публикация копирует клон (как любой черновик) в id ТЕКУЩЕГО опубликованного графа бота и затирает его содержимое без бэкапа — вместе с живыми правками, сделанными после прошлой публикации; `set_active_graph` на прежний id после этого вернёт уже новое содержимое; открытые редакторы живого графа перечитают его (у кого есть несохранённые правки — получат просьбу обновить страницу). Клон годится, чтобы спокойно собрать крупный рискованный рефактор в черновике, но перед его публикацией сохрани живой граф: `get_graph(graphId, saveToFile)` — откат = залить файл обратно через `edit_graph_live`/`update_graph` с `graphFile`.
66
+ - ⚠️ PUT **активного** (PUBLISHED) графа сервер проверяет как `publish`: валидатор, платные блоки, лимит блоков тарифа, платформа. Ошибка → `HTTP 422` со всеми `code@nodeId`, граф **не сохранён**, бот работает на прежней версии. Черновик (`DRAFT`) PUT сохраняет без проверок (кроме размера: больше 4 МБ → 422 `GRAPH_TOO_LARGE`), и на бота он не влияет: `publish_graph` черновика копирует его в отдельный `PUBLISHED`-граф (`publishedGraphId`), а сам черновик остаётся `DRAFT`. Всё равно прогоняй `validate.mjs` + `dry_run` заранее. Откат: версии активного не хранятся — опубликовать граф «🔙 Авто-бэкап» (или скопировать его содержимое обратно).
67
67
 
68
68
  6. **Отчитайся**: сколько узлов/веток, какие тексты помечены на проверку, ссылка/ id графа.
69
69
 
@@ -71,6 +71,8 @@ description: Собрать воронку (сценарий) бота для с
71
71
  - **Источник = текст** (этот режим). Если просят распознать с приватной Miro-доски — самый надёжный путь: CSV-экспорт из Miro; либо запуск залогиненного Chrome пользователя и съёмка экрана (headless WebGL-холст Miro не отдаёт). Это отдельный сценарий, не основной для этого скилла.
72
72
  - **Авторизация MCP** — персональный токен (создаётся в вебе на `/bots/mcp-tokens`, формат `zmcp_…`, полный доступ). Если инструмент вернул «нет токена» или ошибку доступа — **вызови `setup`**, объясни пользователю шаги, попроси прислать токен и сохрани его через **`set_token`** (применяется сразу, без env/рестарта). Также работают env `RETENSY_MCP_TOKEN` и session-cookie.
73
73
  - Бэкенд читает плоские поля `config.text`/`config.photoUrl`; редактор берёт текст из первой карточки `type:"text"`. Поэтому **всегда заполняй и `text`, и `cards`**.
74
+ - **Размер графа ≤ 4 МБ** (JSON `{nodes, edges, canvasMeta}` в UTF-8): больше — `HTTP 422 GRAPH_TOO_LARGE` на `update_graph`/`edit_graph_live`/`patch_graph`/`import_funnel`, и у черновика тоже; граф не сохранён. `validate.mjs` проверяет заранее.
75
+ - **Переменные подписчика:** строка ≤ 65 536 символов (длиннее обрезается), список/объект длиннее не сохраняется; все переменные одного подписчика вместе с именами ≤ 262 144 символов — запись сверх лимита не сохраняется. Большой ответ API не клади в переменную целиком — вытаскивай нужное через `extract`.
74
76
 
75
77
  ## Если возможности не хватает — она фиксируется автоматически
76
78
 
@@ -41,7 +41,7 @@
41
41
  **Ставь хотя бы один фильтр:** без них сценарий будет запускаться на КАЖДУЮ оплату в кассе.
42
42
  В контексте: `{{payment.id}}`, `{{payment.amount}}`, `{{payment.status}}`,
43
43
  `{{payment.description}}`, `{{payment.currency}}`, плюс весь ответ ЮKassa как `body`.
44
- Запуск — один раз на платёж: повторные уведомления ЮKassa о той же оплате сценарий не перезапускают.
44
+ Запуск — один раз на платёж: повторные уведомления ЮKassa о той же оплате сценарий не перезапускают. Сюда же приходит оплата счёта, пришедшая уже после `timeout` блока со счётом, — в диалоге того подписчика (пока бот выключен, оплата ждёт повтора ЮKassa).
45
45
  - **Счёт выставлял бот** (действие `issue_invoice`) → подписчик определяется САМ: счёт
46
46
  помнит его в `metadata` платежа. Прогон идёт в обычной сессии — можно прямо ставить
47
47
  «Отправить сообщение» и «Добавить метку», указывать получателя и разбирать ответ кассы
@@ -129,6 +129,10 @@ IG-боты не поддерживают команды (`/start`). Вход
129
129
  - **`TOMORROW`** («Отправить завтра»): `{ "kind":"TOMORROW", "time":"18:00" }` — завтра в указанное время `HH:mm` (МСК), относительно момента, когда пользователь дошёл до узла.
130
130
  - **`UNTIL`** («Отправить в»): `{ "kind":"UNTIL", "isoTimestamp":"2026-06-25T15:00:00Z" }` — конкретный момент в ISO-8601 (UTC). ⚠️ Рантайм читает только `isoTimestamp`; пары `isoDate`+`time` НЕ работают.
131
131
  - `SCHEDULE` — `{ "isoDate":"2026-06-25", "time":"18:00", "timezone":"Europe/Moscow" }`. Выходы `scheduled` / `past`.
132
+ - Прогон без пауз (между `DELAY`, `ASK_QUESTION`/`awaitReply`, ожиданием оплаты) ограничен **5 минутами**: дольше — рантайм обрывает его с ошибкой шага `run deadline exceeded` (прогон `FAILED`, пауза подписчика снята). Длинные цепочки `external_request` / `CALL_WEBHOOK` / `AI_REPLY` разноси `DELAY` — после паузы начинается новый прогон.
133
+ - Массовый одинаковый момент срабатывания (`TOMORROW`/`UNTIL`/`SCHEDULE` у тысяч подписчиков одного бота) расходится постепенно: до 10 срабатываний на бота в секунду, время растёт с аудиторией (≈100 с на 1000 подписчиков, ≈17 мин на 10 000), другие боты не ждут. Не рассчитывай, что все получат сообщение в одну секунду.
134
+ - Если Telegram вернул боту 429 (`retry after N`, пауза не дольше часа), срабатывания `DELAY`/`SCHEDULE` и рассылки этого бота (и по `BROADCAST_FILTER`, и прямые) ждут конца паузы; после такой паузы рассылка продолжается без потерь и без повтора уже доставленного. Это только про паузу 429: если сервис перезапустился посреди отправки, получатель, на котором её прервали, через ≤15 мин может получить рассылку (или прогон сценария) повторно. Ответ подписчику в живом диалоге отправляется сразу, без ожидания; если Telegram откажет снова, шаг уходит в ветку «Ошибка», если она проведена, иначе дальше по обычному выходу.
135
+ - `AI_REPLY` при сбое провайдера (5xx, таймаут) уходит в `error` примерно через минуту — не рассчитывай на долгое ожидание ответа модели.
132
136
 
133
137
  ### Состояние / действия
134
138
  - `SET_VARIABLE` (`{ "key":"name", "value":"..." }`), `ADD_TAG`/`REMOVE_TAG` (`{ "tag":"lead" }`), `FORMULA` (`{ "expression":"...", "saveTo":"name" }`)
@@ -142,7 +146,7 @@ IG-боты не поддерживают команды (`/start`). Вход
142
146
  ветвиться по коду ответа надо следующим блоком `SWITCH`. **Платное действие** (как `CALL_WEBHOOK`):
143
147
  на бесплатном тарифе публикация падает с `PREMIUM_NODE_FORBIDDEN`.
144
148
  Ещё есть `subscriber_webhook` — `url` + `method`/`headersJson`/`bodyTemplate`
145
- - **уведомления**: `notify` (`text`), `subscriber_email` (`email`,`text`), `agent_chat`
149
+ - **уведомления**: `notify` (`text`) — владельцу бота в бот уведомлений из его профиля (Telegram/MAX); не подключён → действие не удалось, блок уходит в `error`; `subscriber_email` (`email`,`text`), `agent_chat`
146
150
  - **бот/шаг**: `stop_bot`, `delete_step_message`, `cancel_payment_subscription`
147
151
  - **Google Таблицы (работает)**: `gsheets_send` — дописать строку-заявку в таблицу: `{ "kind":"gsheets_send", "googleEmail":"me@gmail.com", "spreadsheetId":"<id таблицы>", "sheetName":"Лист1", "cells":["{{from.first_name}}","{{var.phone}}","{{var.email}}"] }`. `cells` — значения по порядку (шаблоны), бот дописывает их строкой в конец листа. Google-аккаунт подключается В ВЕБЕ (`/bots` → у действия кнопка «Подключить Google»), НЕ через MCP — у пользователя уже должен быть подключён `googleEmail`. Нужны `googleEmail` + `spreadsheetId` + непустой `cells[]` (иначе `ACTION_GSHEETS_INCOMPLETE`).
148
152
  Остальные четыре действия с таблицами тоже РАБОТАЮТ и тоже требуют `googleEmail` + `spreadsheetId`:
@@ -186,7 +190,7 @@ IG-боты не поддерживают команды (`/start`). Вход
186
190
  прижимается к диапазону **500…30000 мс**.
187
191
  - `saveStatusTo` / `saveBodyTo` — имена переменных для HTTP-кода и тела ответа целиком.
188
192
  Пишутся ВСЕГДА, в том числе на `error`: при сетевой ошибке код `0` и пустое тело (чтобы в
189
- переменной не осталось значение прошлого прогона). Тело длиннее 64 КБ обрезается.
193
+ переменной не осталось значение прошлого прогона). Тело длиннее 65 536 символов обрезается — как любое строковое значение переменной (список/объект длиннее, например из `extract`, не сохраняется); все переменные подписчика вместе с именами — до 262 144 символов, запись сверх лимита не сохраняется (переменная прежняя).
190
194
  Это штатный способ разветвиться по коду ответа: `saveStatusTo` → `SWITCH`.
191
195
  - `extract` — разбор JSON-тела по JsonPath в переменные (работает только на валидном JSON).
192
196
  - `AI_REPLY` — ответ модели. Два режима, переключатель — поле `mode` (`"simple"`/не задано, либо
@@ -303,7 +307,7 @@ IG-боты не поддерживают команды (`/start`). Вход
303
307
  - Переменные после шага: `{{var.payment_url}}`, `{{var.payment_id}}`.
304
308
  - Повторный проход по счёту: пока попытка ЭТОГО шага жива (не оплачена, таймаут не сработал, другой счёт с
305
309
  `paid` её не сменил) — та же ссылка (тот же платёж), даже если между проходами подписчик ушёл в другую
306
- ветку; после оплаты или таймаута — новый счёт. Оплата, пришедшая после таймаута, сценарий не продолжает.
310
+ ветку; после оплаты или таймаута — новый счёт. Оплата, пришедшая после таймаута, ветку `paid` не продолжает, зато запускает сценарии владельца с `TRIGGER_PAYMENT` в диалоге этого подписчика (один раз на платёж; бот выключен — ЮKassa повторяет уведомление, запуск после включения бота) — для опоздавших заведи такой сценарий. Если таймаут был пропущен, пока бот был выключен, повторный проход сперва спрашивает ЮKassa: платёж оплачен — шаг сразу идёт по `paid` (нового счёта нет); статус не узнать — та же ссылка; не оплачен — новый счёт.
307
311
  - Без ветки `paid` ожидания нет: каждый проход по такому счёту — новый платёж с новой ссылкой.
308
312
  - Действие бесплатное (в отличие от `external_request`).
309
313
  - ⚠️ Чтобы `paid` вообще срабатывал, владелец должен вписать адрес уведомлений из карточки
@@ -1,9 +1,14 @@
1
1
  # Правила валидатора (GraphValidator) — чтобы граф публиковался
2
2
 
3
- Источник истины: `retensyBackend/.../service/bot/GraphValidator.java`. При `publish` и при PUT **активного** графа бэкенд отвечает `HTTP 422` с `errors: [{ nodeId, code, message }]` — MCP показывает их все построчно (`code@nodeId: message`). Черновик PUT сохраняет без проверок. Ниже — что проверяется и как не нарваться.
3
+ Источник истины: `retensyBackend/.../service/bot/GraphValidator.java`. При `publish` и при PUT **активного** графа бэкенд отвечает `HTTP 422` с `errors: [{ nodeId, code, message }]` — MCP показывает их все построчно (`code@nodeId: message`). Черновик PUT сохраняет без проверок — кроме размера: граф больше 4 МБ не сохраняется ни черновиком, ни активным (`GRAPH_TOO_LARGE`). Ниже — что проверяется и как не нарваться.
4
4
 
5
5
  ## Коды ошибок и условия
6
6
 
7
+ ### `GRAPH_TOO_LARGE` — «Сценарий больше 4 МБ»
8
+ Граф хранится одним документом. Если JSON `{nodes, edges, canvasMeta}` в UTF-8 длиннее 4 194 304 байт, `update_graph`,
9
+ `edit_graph_live`, `patch_graph` и `import_funnel` получают `HTTP 422 GRAPH_TOO_LARGE`, граф не сохранён — **и у черновика
10
+ тоже**. Сократи тексты или раздели сценарий на несколько. `validate.mjs` это проверяет.
11
+
7
12
  ### `SEND_NO_TEXT` — «Пустое сообщение — добавьте текст или картинку»
8
13
  `SEND_MESSAGE` считается пустым, если **`config.text` пустой/из пробелов И `config.photoUrl` пустой**.
9
14
  - Бэкенд читает плоское `config.text` (НЕ `cards`).
@@ -73,8 +78,8 @@
73
78
 
74
79
  ## Жизненный цикл
75
80
  - Статусы графа: `DRAFT` / `PUBLISHED`. Публикация заменяет активную опубликованную версию.
76
- - Правка активного (`PUBLISHED`) графа применяется к боту сразу и проверяется как публикация (плюс платные блоки и лимит блоков тарифа): ошибки → 422, граф не сохранён. `DRAFT` — черновик: PUT сохраняет его без проверок, на бота он не влияет. `publish_graph` черновика копирует его в отдельный `PUBLISHED`-граф (`publishedGraphId`), черновик остаётся `DRAFT` — живые правки делай по `publishedGraphId` (в `list_graphs` у него `isActive: true`).
77
- - Перед публикацией полезно прогнать `dry_run` (kind `command`/`callback`/`text`) — поймать рантайм-проблемы стартовой ветки.
81
+ - Правка активного (`PUBLISHED`) графа применяется к боту сразу и проверяется как публикация (плюс платные блоки и лимит блоков тарифа): ошибки → 422, граф не сохранён. `DRAFT` — черновик: PUT сохраняет его без проверок (кроме размера > 4 МБ → `GRAPH_TOO_LARGE`), на бота он не влияет. `publish_graph` черновика копирует его в отдельный `PUBLISHED`-граф (`publishedGraphId`), черновик остаётся `DRAFT` — живые правки бот-сценария делай по `publishedGraphId` (в `list_graphs` у него `isActive: true`; `list_graphs` показывает только сценарии самого бота); вебхук-сценарий правится по своему id.
82
+ - Перед публикацией полезно прогнать `dry_run` (kind `command`/`callback`/`text`) — поймать рантайм-проблемы стартовой ветки. Внешних действий `dry_run` не выполняет (шаги с `skipped: "dry-run"`): ответ ИИ, счёт и запись в CRM проверяются только живым прогоном.
78
83
 
79
84
  ## Платформенные правила — Instagram
80
85
 
@@ -119,7 +124,7 @@ Instagram доставляет сообщения только в течение
119
124
  ---
120
125
 
121
126
  ## Локальная проверка
122
- `node validate.mjs <import.json>` повторяет ключевые проверки: пустые сообщения с учётом `cardsToLegacy`, висячие рёбра, дубли id, достижимость от триггеров, длину текста, HTML-безопасность (эвристика по тегам), режим «Вопрос» (`awaitReply`→`saveTo`/`regex`), конфиг `DELAY`/`SCHEDULE`/`FORMULA`/`ACTIONS`/`AI_REPLY`(оба режима, включая `AGENT_*`)/`PAYMENT_LINK`/триггеров, условия `CONDITION` (вкл. `LINK_CLICKED` со ссылкой на отслеживаемый шаг). Гонять перед каждой заливкой.
127
+ `node validate.mjs <import.json>` повторяет ключевые проверки: пустые сообщения с учётом `cardsToLegacy`, висячие рёбра, дубли id, достижимость от триггеров, длину текста, HTML-безопасность (эвристика по тегам), режим «Вопрос» (`awaitReply`→`saveTo`/`regex`), конфиг `DELAY`/`SCHEDULE`/`FORMULA`/`ACTIONS`/`AI_REPLY`(оба режима, включая `AGENT_*`)/`PAYMENT_LINK`/триггеров, условия `CONDITION` (вкл. `LINK_CLICKED` со ссылкой на отслеживаемый шаг), размер графа (≤ 4 МБ). Гонять перед каждой заливкой.
123
128
 
124
129
  Для IG-ботов передавать `--platform=INSTAGRAM`:
125
130
  ```bash
@@ -27,6 +27,13 @@ const nodes = Array.isArray(g.nodes) ? g.nodes : [];
27
27
  const edges = Array.isArray(g.edges) ? g.edges : [];
28
28
  const errors = [];
29
29
  const warns = [];
30
+ // Потолок размера графа — зеркало TgGraphController.MAX_GRAPH_BYTES (аудит data#24): PUT больше 4 МБ → HTTP 422
31
+ // GRAPH_TOO_LARGE, и у черновика тоже. Байты UTF-8 того же JSON {nodes, edges, canvasMeta}.
32
+ const MAX_GRAPH_BYTES = 4 * 1024 * 1024;
33
+ const graphBytes = Buffer.byteLength(JSON.stringify({ nodes, edges, canvasMeta: g.canvasMeta ?? {} }), "utf8");
34
+ if (graphBytes > MAX_GRAPH_BYTES) {
35
+ errors.push(`GRAPH_TOO_LARGE: граф ${graphBytes} байт > ${MAX_GRAPH_BYTES} (4 МБ) — сократи тексты или раздели на несколько сценариев.`);
36
+ }
30
37
 
31
38
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
32
39
  const VAR_RE = /^[a-z_][a-z0-9_]{0,63}$/;
@@ -0,0 +1,86 @@
1
+ ---
2
+ name: build-site
3
+ description: Собрать сайт или лендинг из блоков в сервисе retensy (раздел «Страницы») — обложка, тексты, преимущества, галерея, форма заявки, попап, несколько страниц — и опубликовать. Использовать, когда пользователь просит «сделать сайт», «лендинг», «страницу с формой заявки», «сайт для бизнеса».
4
+ ---
5
+
6
+ # Сайт из блоков (retensy «Страницы»)
7
+
8
+ Сайт — JSON-модель: тема, общие шапка и подвал, страницы с блоками, попапы. Правится операциями `site_edit`
9
+ (всё или ничего, с проверкой схемы на сервере), публикуется `site_publish`. Тот же сайт пользователь потом правит
10
+ мышкой в кабинете — модель общая.
11
+
12
+ ## Порядок
13
+
14
+ 1. `site_schema` — какие блоки и поля бывают (`model`) и какие операции есть (`ops`); `site_templates` — готовые
15
+ секции из библиотеки.
16
+ 2. `site_create {title}` → `id`. Для правки существующего — `site_list`, `site_get {siteId}`.
17
+ 3. Первый `site_edit` с `init`: `starter` — готовый лендинг (шапка, обложка, текст, преимущества, форма, подвал) или
18
+ `blank` — пустая главная. Заполни тексты, не выдумывай факты о бизнесе — спрашивай.
19
+ 4. Картинки — `site_upload_asset {siteId, path|url}` → `assets/…` в поля `image`, `logo`, `icon`, `style.bg.image`.
20
+ 5. `site_get` — проверь модель, `site_publish` — сайт открыт по `url`.
21
+ 6. После публикации: заявки — `site_leads`, куда их слать — `site_lead_settings` (бот уведомлений, почта, вебхук,
22
+ вебхук-сценарий бота, amoCRM); свой домен — `site_domains` (`add` → пользователь ставит A-запись на `dnsTarget`
23
+ → `check`); неудачная публикация — `site_rollback {revision}` из `site_get → versions[]`.
24
+
25
+ ## Правила модели
26
+
27
+ - Значения по экранам — `{d, t?, m?}` (десктоп ≥1024, планшет 640–1023, телефон <640); без `t`/`m` берётся больший.
28
+ - `props`, `style`, `theme` в операциях — JSON Merge Patch: объекты сливаются, `null` удаляет ключ, массивы
29
+ (кнопки, ссылки, поля формы, фото) передаются целиком.
30
+ - Ссылки (`action`): `{kind:"url", href}` (https/tel/mailto/tg/#якорь), `{kind:"page", pageId}`,
31
+ `{kind:"anchor", blockId}`, `{kind:"popup", popupId}`.
32
+ - Текст с разметкой (Rich): только `<b> <i> <u> <s> <br> <a href> <span style="color:#…">`.
33
+ - Цвета — `#rrggbb`. Шрифты: inter, montserrat, roboto, pt-sans, pt-serif, rubik, oswald.
34
+ - `revision` из ответа передавай в следующий `site_edit` — сервер не даст перезаписать правки из кабинета (409).
35
+
36
+ ## Zero-блок — свободная вёрстка (как Zero Block в Tilda)
37
+
38
+ Блок `type: "zero"` — артборд, на котором элементы стоят по координатам. Для дизайнерских экранов, где готовых
39
+ блоков мало.
40
+
41
+ - Добавить: `add_block {container, type: "zero", props: {height: {d: 600, m: 720}}}` → id блока в `results`.
42
+ - Элементы: `add_element {blockId, kind, frame, props, style?, link?, anim?}`, `kind`: `text`, `image`, `button`,
43
+ `shape`, `video`, `html`, `group`. `frame` — по экранам: `{d: {x, y, w, h}, m?: {…}}`; `h: "auto"` у текста — высота
44
+ по содержимому. Нет `t`/`m` — кадр выводится из большего экрана в масштабе; задай `m`, если на телефоне нужна
45
+ другая раскладка. `container: "window"` + `axisX/axisY` — привязка к краям окна, а не к сетке.
46
+ - Правка — `update_element` (Merge Patch), порядок слоёв — `move_element {delta}`, группы — `group_elements` /
47
+ `ungroup_element`; у элемента в группе кадр — относительно группы (`parent`).
48
+ - Целиком переписать блок проще кодом: `get_block_code {blockId}` → в `results[i].code` разметка `<zero …>…</zero>`,
49
+ поправь и верни `set_block_code {blockId, code}`; новый блок из кода — `add_block_code {container, code}`. У
50
+ обычных блоков код — JSON `{type, variant, props, style}`. Тот же код пользователь видит во вкладке «Код» редактора.
51
+
52
+ ## Папки, дизайны, шаблоны
53
+
54
+ - Папки страниц (для навигации в кабинете): `add_folder {name}` → id в `results`; `rename_folder {folderId, name}`,
55
+ `remove_folder {folderId}`. Страницу в папку — `update_page {pageId, patch: {folder: folderId}}`.
56
+ - Дизайны — отдельные экраны-макеты из Zero-кадров (не страницы сайта): `add_design {name}` → id;
57
+ `update_design {designId, name}`, `remove_design {designId}`. Кадр: `add_design_frame {designId, name, w, h}` →
58
+ в `results` `blockId` Zero-кадра — дальше на нём работают `add_element`/`update_element`/`get_block_code` и др.
59
+ - Шаблоны: `site_templates` → `{categories, templates: [{id, category, title, description?, blocks}]}` (`blocks` —
60
+ сколько блоков вставится). Вставка — `add_template {container, templateId, after?}`: блоки шаблона
61
+ встают в страницу/попап, дальше правь их тексты обычными `update_block`. Быстрее, чем собирать блоки с нуля.
62
+
63
+ ## Тариф
64
+
65
+ - HTML-блок и HTML-элемент Zero публикуются только на платном тарифе: на бесплатном `site_publish` вернёт 422 с
66
+ путями этих элементов — замени их обычными блоками или предложи тариф.
67
+ - Число своих доменов ограничено тарифом (`site_domains add` → 402 со ссылкой на смену тарифа — передай её
68
+ пользователю, оплата только в браузере); www-пара корневого домена не в счёт.
69
+ - На бесплатном тарифе адрес на pages.retensy.com закрыт от поисковиков (noindex).
70
+
71
+ ## Пример: лендинг кофейни с попапом заявки
72
+
73
+ ```json
74
+ [
75
+ {"op": "set_theme", "theme": {"colors": {"primary": "#b5651d"}, "fonts": {"heading": "pt-serif", "body": "inter"}}},
76
+ {"op": "add_popup", "name": "Бронь столика"},
77
+ {"op": "add_block", "container": "<id главной>", "type": "cover", "after": -1,
78
+ "props": {"title": "Кофе, ради которого приходят", "subtitle": "Обжариваем сами, с 8:00 до 22:00",
79
+ "buttons": [{"label": "Забронировать столик", "style": "primary", "action": {"kind": "popup", "popupId": "<id из results>"}}],
80
+ "height": {"d": "screen", "m": "auto"}},
81
+ "style": {"bg": {"image": "assets/cafe-x1y2.jpg", "overlay": 0.5}, "textColor": "#ffffff"}}
82
+ ]
83
+ ```
84
+
85
+ Порядок важен: id созданного попапа приходит в `results` — если он нужен в той же правке, сделай два вызова
86
+ `site_edit` (сначала `add_popup`, потом блок с кнопкой).
@@ -0,0 +1,48 @@
1
+ ---
2
+ name: send-broadcast
3
+ description: Сделать рассылку подписчикам ботов retensy (Telegram/MAX) — сейчас, по расписанию или повторяющуюся, по нескольким ботам и с фильтром по тегам; черновики, отмена, статистика отправки. Использовать, когда пользователь просит «разослать», «сделать рассылку», «напомнить всем подписчикам», «отправить акцию в бота».
4
+ ---
5
+
6
+ # Рассылки retensy
7
+
8
+ ## Порядок
9
+
10
+ 1. `list_bots` → id ботов. У Instagram-ботов рассылок нет (Meta разрешает писать только в окне 24 ч) — предложи
11
+ сценарий с триггером.
12
+ 2. Собери сообщения (до 5) и уточни у пользователя: кому (теги), когда, по каким ботам. Тексты не выдумывай.
13
+ 3. Медиа — `upload_file {path|url}` → `url` в `mediaUrl`/`mediaUrls`.
14
+ 4. `broadcast_preview {botIds, tagsAll?, tagsNone?}` — назови пользователю число получателей и **дождись
15
+ подтверждения**: рассылку не вернуть, а получатели списываются с месячной квоты тарифа.
16
+ 5. `broadcast_send {name, botIds, messages, tagsAll?, tagsNone?, scheduledAt?}` → `{broadcastIds, totalAudience}`.
17
+ Не уверен в тексте — сначала `broadcast_drafts {action: "create", …}`: пользователь увидит черновик в кабинете,
18
+ отправка — `broadcast_send {draftId}` (черновик после отправки удаляется).
19
+ 6. Статус — `broadcast_get {broadcastId}` / `broadcast_list`; остановить — `broadcast_cancel`.
20
+
21
+ ## Сообщение
22
+
23
+ `{type, text?, mediaUrl?, mediaUrls?, buttons?}`; строка — короткая запись `TEXT`.
24
+
25
+ | type | Обязательно | Текст | Кнопки |
26
+ |---|---|---|---|
27
+ | `TEXT` | `text` до 4096 | да | до 8 |
28
+ | `PHOTO` `VIDEO` `AUDIO` `FILE` `VOICE` | `mediaUrl` | подпись до 1024 | до 8 |
29
+ | `VIDEONOTE` (кружок) | `mediaUrl` | нет | до 8 |
30
+ | `GALLERY` | `mediaUrls`: 2–10 картинок | подпись до 1024 | нет |
31
+
32
+ - Текст — Telegram-HTML: `<b> <i> <u> <s> <code> <pre> <blockquote> <tg-spoiler> <a href="https://…">`.
33
+ Перенос строки — `\n`, `<br>` не работает.
34
+ - Кнопки — только ссылки `[{text, url}]`. Нужна ветка по нажатию — это рассылка по сценарию: `broadcast_send
35
+ {botId, graphId, entryNodeId?, name}` запускает сценарий бота для каждого получателя.
36
+
37
+ ## Аудитория и время
38
+
39
+ - Подписчики бота; `tagsAll` — есть все эти теги, `tagsNone` — нет ни одного. До 50 000 на бота, до 20 ботов одного
40
+ владельца за раз (по каждому боту создаётся своя рассылка).
41
+ - `scheduledAt` — ISO 8601; без пояса — московское время. Отложенная считает аудиторию в момент отправки.
42
+ - Повтор: `broadcast_recurring {action: "create", name, botIds, messages, recurrence: DAILY|MONTHLY|YEARLY,
43
+ firstRunAt}`; список — `action: "list"`, остановить — `action: "stop", ruleId`.
44
+
45
+ ## Тариф
46
+
47
+ Рассылки — на платном тарифе. HTTP 402 приходит со ссылкой (`upgradeUrl` или `/bots/subscription`): передай её
48
+ пользователю — оплата и смена тарифа только в браузере, потом повтори отправку.
package/src/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * retensy-mcp — MCP-сервер для сборки и публикации воронок ботов
3
+ * retensy-mcp — MCP-сервер для сборки и публикации воронок ботов, рассылок, сайтов и статей
4
4
  * (Telegram, MAX, Instagram) через API сервиса retensy /bots.
5
5
  * Без внешних зависимостей (голый JSON-RPC по stdio).
6
6
  *
@@ -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.12.1";
30
+ const VERSION = "0.14.0";
31
31
  const PKG_NAME = "@retensy/mcp";
32
32
  const BASE = (process.env.RETENSY_BASE_URL || "https://bots.retensy.com").replace(/\/+$/, "");
33
33
  const CONFIG_DIR = path.join(os.homedir(), ".retensy-bot-graph");
@@ -271,15 +271,56 @@ async function api(path_, { method = "GET", body } = {}) {
271
271
  let data = null;
272
272
  try { data = text ? JSON.parse(text) : null; } catch { data = text; }
273
273
  if (!res.ok) {
274
- if (res.status === 401 || res.status === 403) {
275
- throw new Error(`Доступ отклонён (HTTP ${res.status}). Токен невалиден, отозван или истёк.\n` +
274
+ if (res.status === 401) {
275
+ throw new Error(`Доступ отклонён (HTTP 401). Токен невалиден, отозван или истёк.\n` +
276
276
  `Создай новый на ${TOKENS_PAGE} и пришли мне — я сохраню через set_token.`);
277
277
  }
278
+ if (res.status === 403) {
279
+ // 403 бэкенд отдаёт и на «не твой бот/граф/подключение» — это не всегда про токен.
280
+ const why = bodyReason(data);
281
+ throw new Error(`Доступ отклонён (HTTP 403)${why ? `: ${why}` : ""}. Нет прав на этот объект (чужой бот/граф/подключение) ` +
282
+ `или токен не действует. Если так отвечает любой инструмент — создай новый токен на ${TOKENS_PAGE} и пришли мне (set_token).`);
283
+ }
284
+ if (res.status === 402) throw paymentError(data);
278
285
  throw httpError(method, path_, res.status, data);
279
286
  }
280
287
  return data;
281
288
  }
282
289
 
290
+ /** Причина отказа из тела ответа: {error}/{message} или строка. */
291
+ function bodyReason(data) {
292
+ if (data == null) return "";
293
+ if (typeof data === "string") return data.slice(0, 300);
294
+ const e = data.message || data.error;
295
+ return typeof e === "string" && e !== "Forbidden" && e !== "Payment Required" ? e.slice(0, 300) : "";
296
+ }
297
+
298
+ const SUBSCRIPTION_PAGE = `${BASE}/bots/subscription`;
299
+ const PAYMENT_REASONS = {
300
+ broadcast_not_available: "рассылки недоступны на текущем тарифе",
301
+ broadcast_quota_exceeded: "исчерпана месячная квота получателей рассылок",
302
+ };
303
+ /** 402 — оплата/тариф: через API не решается, отдаём ссылку, которую пользователь откроет сам. */
304
+ function paymentError(data) {
305
+ const code = data && typeof data === "object" ? data.error : "";
306
+ const reason = PAYMENT_REASONS[code] || bodyReason(data) || "лимит тарифа исчерпан";
307
+ const url = (data && typeof data === "object" && typeof data.upgradeUrl === "string" && data.upgradeUrl) || SUBSCRIPTION_PAGE;
308
+ const count = data && typeof data === "object" && data.count != null ? ` (получателей: ${data.count})` : "";
309
+ const err = new Error(`Нужен тариф выше (HTTP 402): ${reason}${count}.\n` +
310
+ `🔗 Открой ${url} — смена тарифа/оплата делается только в браузере. После оплаты повтори действие.`);
311
+ err.status = 402;
312
+ err.data = data;
313
+ return err;
314
+ }
315
+
316
+ /**
317
+ * Действие, которое нельзя сделать через API (OAuth/вход/оплата/2FA): не падаем, а отдаём прямую ссылку и
318
+ * одну строку инструкции — ассистент передаёт её пользователю как есть.
319
+ */
320
+ function linkResult(title, url, instruction, extra) {
321
+ return okResult({ needsBrowser: true, title, url, instruction, ...(extra || {}) });
322
+ }
323
+
283
324
  // MIME по расширению — уходит как Content-Type части multipart, бэкенд по нему определяет тип медиа.
284
325
  const MIME_BY_EXT = {
285
326
  ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp", ".gif": "image/gif",
@@ -323,13 +364,54 @@ async function uploadMedia({ filePath, url, filename }) {
323
364
  let data = null; try { data = text ? JSON.parse(text) : null; } catch { data = text; }
324
365
  if (!res.ok) {
325
366
  if (res.status === 401 || res.status === 403) throw new Error(`Доступ отклонён (HTTP ${res.status}). Токен невалиден/отозван — создай новый на ${TOKENS_PAGE}.`);
326
- if (res.status === 402) throw new Error("Лимит хранилища тарифа исчерпан (HTTP 402). Удали ненужные файлы (delete_file) или подними тариф на /bots/subscription.");
367
+ if (res.status === 402) throw new Error(`Лимит хранилища тарифа исчерпан (HTTP 402). Удали ненужные файлы (delete_file) или подними тариф: 🔗 ${SUBSCRIPTION_PAGE}`);
327
368
  if (res.status === 413) throw new Error("Файл больше 50 МБ (HTTP 413) — лимит Telegram для видео/документов.");
328
369
  throw httpError("POST", "/api/bots/media", res.status, data);
329
370
  }
330
371
  return data;
331
372
  }
332
373
 
374
+ // Ассет сайта из блоков: POST /api/bots/pages/{id}/upload (multipart, dir=assets). Бэкенд принимает имена только из
375
+ // [A-Za-z0-9._@()+- ], поэтому имя приводим к латинице с коротким суффиксом.
376
+ async function uploadSiteAsset(siteId, { filePath, url }) {
377
+ if (!isAuthed()) throw new Error(NO_AUTH_HELP);
378
+ if (!siteId) throw new Error("Передай siteId.");
379
+ let bytes, name, mime;
380
+ if (filePath) {
381
+ const abs = path.resolve(String(filePath).replace(/^~(?=$|[/\\])/, os.homedir()));
382
+ try { bytes = fs.readFileSync(abs); } catch { throw new Error(`Файл не найден: ${abs}`); }
383
+ name = path.basename(abs);
384
+ mime = guessMime(name);
385
+ } else if (url) {
386
+ const r = await fetch(url);
387
+ if (!r.ok) throw new Error(`Не удалось скачать файл по url (HTTP ${r.status}).`);
388
+ bytes = Buffer.from(await r.arrayBuffer());
389
+ try { name = path.basename(new URL(url).pathname) || "file"; } catch { name = "file"; }
390
+ mime = r.headers.get("content-type") || guessMime(name);
391
+ } else {
392
+ throw new Error("Передай path (локальный файл) ИЛИ url.");
393
+ }
394
+ const ext = path.extname(name).toLowerCase().replace(/[^.a-z0-9]/g, "").slice(0, 9);
395
+ const base = path.basename(name, path.extname(name)).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "file";
396
+ const safe = `${base}-${Math.random().toString(36).slice(2, 6)}${ext}`;
397
+ const headers = {};
398
+ const token = getToken(); const cookie = getCookie();
399
+ if (token) headers.Authorization = `Bearer ${token}`;
400
+ else if (cookie) headers.Cookie = cookie;
401
+ const fd = new FormData();
402
+ fd.append("files", new Blob([bytes], { type: mime }), safe);
403
+ fd.append("dir", "assets");
404
+ const res = await fetch(`${BASE}/api/bots/pages/${siteId}/upload`, { method: "POST", headers, body: fd });
405
+ const text = await res.text();
406
+ let data = null; try { data = text ? JSON.parse(text) : null; } catch { data = text; }
407
+ if (!res.ok) {
408
+ if (res.status === 401 || res.status === 403) throw new Error(`Доступ отклонён (HTTP ${res.status}). Токен невалиден/отозван — создай новый на ${TOKENS_PAGE}.`);
409
+ if (res.status === 402) throw new Error(`Лимит хранилища тарифа исчерпан (HTTP 402). Подними тариф: 🔗 ${SUBSCRIPTION_PAGE}`);
410
+ throw httpError("POST", `/api/bots/pages/${siteId}/upload`, res.status, data);
411
+ }
412
+ return { asset: `assets/${safe}`, sizeBytes: bytes.length };
413
+ }
414
+
333
415
  const okResult = (obj) => ({ content: [{ type: "text", text: typeof obj === "string" ? obj : JSON.stringify(obj, null, 2) }] });
334
416
  const errResult = (e) => ({ isError: true, content: [{ type: "text", text: "❌ " + (e?.message || String(e)) }] });
335
417
 
@@ -365,17 +447,137 @@ function graphSummary(g) {
365
447
  return { graphId: g?.id, name: g?.name, status: g?.status, version: g?.version, counts: { nodes: nodes.length, edges: edges.length }, nodes, edges };
366
448
  }
367
449
 
450
+ // ============================================================================
451
+ // Рассылки
452
+ // ============================================================================
453
+ // Бэкенд на 400 отдаёт только статус (без причины), поэтому правила TgBroadcastController.validateDirectMessage
454
+ // повторены здесь — агент получает понятную ошибку до запроса, а не голый «HTTP 400».
455
+ const BC_TYPES = ["TEXT", "PHOTO", "VIDEO", "AUDIO", "FILE", "VOICE", "VIDEONOTE", "GALLERY"];
456
+ const BC_MEDIA = new Set(["PHOTO", "VIDEO", "AUDIO", "FILE", "VOICE", "VIDEONOTE"]);
457
+ const BC_MAX_MESSAGES = 5, BC_MAX_BOTS = 20, BC_MAX_BUTTONS = 8, BC_TEXT_MAX = 4096, BC_CAPTION_MAX = 1024;
458
+
459
+ /**
460
+ * Сообщение рассылки как его строит мастер в кабинете (broadcastBlocks.ts → blocksToMessages).
461
+ * Принимает и сокращения: строка → TEXT; url → mediaUrl; urls → mediaUrls; type в любом регистре.
462
+ * strict=false (черновик) — только нормализация, без проверки полноты.
463
+ */
464
+ function normalizeBroadcastMessage(m, i, strict = true) {
465
+ if (typeof m === "string") m = { type: "TEXT", text: m };
466
+ if (!m || typeof m !== "object") throw new Error(`messages[${i}]: ожидается объект {type, text?, mediaUrl?, mediaUrls?, buttons?}.`);
467
+ const mediaUrl = (m.mediaUrl || m.url || m.photoUrl || "").trim();
468
+ const type = String(m.type || (mediaUrl ? "PHOTO" : "TEXT")).toUpperCase();
469
+ if (!BC_TYPES.includes(type)) throw new Error(`messages[${i}]: неизвестный type ${type}. Бывают: ${BC_TYPES.join(", ")}.`);
470
+ const out = { type, parseMode: m.parseMode === null ? undefined : "HTML" };
471
+ const text = typeof m.text === "string" && m.text.trim() ? m.text : undefined;
472
+ if (text !== undefined && type !== "VIDEONOTE") out.text = text;
473
+ if (BC_MEDIA.has(type)) { out.mediaUrl = mediaUrl; if (type === "PHOTO") out.photoUrl = mediaUrl; }
474
+ if (type === "GALLERY") out.mediaUrls = (m.mediaUrls || m.urls || []).map((u) => String(u || "").trim()).filter(Boolean);
475
+ const buttons = (Array.isArray(m.buttons) ? m.buttons : [])
476
+ .filter((b) => b && String(b.text || "").trim() && String(b.url || "").trim())
477
+ .map((b) => ({ text: String(b.text).trim(), url: String(b.url).trim() }));
478
+ if (type !== "GALLERY") out.buttons = buttons;
479
+ if (!strict) return out;
480
+ if (type === "TEXT" && !out.text) throw new Error(`messages[${i}]: у TEXT нужен text.`);
481
+ if (BC_MEDIA.has(type) && !out.mediaUrl) throw new Error(`messages[${i}]: у ${type} нужен mediaUrl (загрузи файл через upload_file и возьми url).`);
482
+ if (type === "GALLERY" && (out.mediaUrls.length < 2 || out.mediaUrls.length > 10)) throw new Error(`messages[${i}]: GALLERY — от 2 до 10 картинок в mediaUrls.`);
483
+ if (type === "GALLERY" && buttons.length) throw new Error(`messages[${i}]: у GALLERY не бывает кнопок — вынеси их в следующее сообщение.`);
484
+ if (type === "VIDEONOTE" && text) throw new Error(`messages[${i}]: VIDEONOTE (кружок) не поддерживает текст.`);
485
+ const plain = (out.text || "").replace(/<[^>]+>/g, "").length;
486
+ const limit = type === "TEXT" ? BC_TEXT_MAX : BC_CAPTION_MAX;
487
+ if (plain > limit) throw new Error(`messages[${i}]: текст длиннее ${limit} символов (${plain}).`);
488
+ if (buttons.length > BC_MAX_BUTTONS) throw new Error(`messages[${i}]: не больше ${BC_MAX_BUTTONS} кнопок.`);
489
+ for (const b of buttons) if (!/^(https?:\/\/|tg:\/\/)/i.test(b.url)) throw new Error(`messages[${i}]: кнопка «${b.text}» — url должен быть ссылкой https://… (callback-кнопок в рассылке нет).`);
490
+ return out;
491
+ }
492
+
493
+ function normalizeBroadcastMessages(list, strict = true) {
494
+ if (!Array.isArray(list)) list = list == null ? [] : [list];
495
+ if (strict && (list.length < 1 || list.length > BC_MAX_MESSAGES)) throw new Error(`messages: от 1 до ${BC_MAX_MESSAGES} сообщений.`);
496
+ if (list.length > BC_MAX_MESSAGES) throw new Error(`messages: не больше ${BC_MAX_MESSAGES}.`);
497
+ return list.map((m, i) => normalizeBroadcastMessage(m, i, strict));
498
+ }
499
+
500
+ /** botIds из botIds[] или botId. */
501
+ function botIdsOf(a) {
502
+ const ids = Array.isArray(a.botIds) ? a.botIds : a.botId ? [a.botId] : [];
503
+ return [...new Set(ids.map(String).filter(Boolean))];
504
+ }
505
+
506
+ const strList = (v) => (Array.isArray(v) ? v : v ? [v] : []).map((t) => String(t).trim()).filter(Boolean);
507
+
508
+ /**
509
+ * Время запуска → ISO-instant. Строка без пояса (2026-10-06T10:00) считается московской (+03:00) — как
510
+ * продукт считает расписание повторов; «now»/пусто — сразу.
511
+ */
512
+ function toInstant(v, field) {
513
+ if (v == null || v === "" || v === "now") return null;
514
+ let s = String(v).trim();
515
+ if (/^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?)?$/.test(s)) s = (s.length === 10 ? `${s}T00:00` : s.replace(" ", "T")) + "+03:00";
516
+ const d = new Date(s);
517
+ if (Number.isNaN(d.getTime())) throw new Error(`${field}: не распознал время «${v}». Формат ISO 8601, например 2026-10-06T10:00:00+03:00.`);
518
+ return d.toISOString();
519
+ }
520
+
521
+ /** У Instagram-ботов рассылок нет (окно 24 ч Meta) — отказываем до запроса. */
522
+ async function assertBroadcastBots(botIds) {
523
+ if (!botIds.length) throw new Error("Передай botIds — id ботов (list_bots), по которым рассылать.");
524
+ if (botIds.length > BC_MAX_BOTS) throw new Error(`botIds: не больше ${BC_MAX_BOTS} ботов за раз.`);
525
+ const bots = await api("/api/bots");
526
+ const byId = new Map((Array.isArray(bots) ? bots : []).map((b) => [String(b.id), b]));
527
+ for (const id of botIds) {
528
+ const b = byId.get(id);
529
+ if (!b) throw new Error(`Бот ${id} не найден среди твоих ботов (list_bots).`);
530
+ if (String(b.platform || "").toUpperCase() === "INSTAGRAM") {
531
+ throw new Error(`Бот ${b.name || b.username || id} — Instagram: рассылок у Instagram-ботов нет (Meta разрешает писать только в окне 24 ч после сообщения пользователя). Используй сценарий с триггером.`);
532
+ }
533
+ }
534
+ return byId;
535
+ }
536
+
537
+ function qs(params) {
538
+ const parts = Object.entries(params).filter(([, v]) => v != null && v !== "").map(([k, v]) => `${k}=${encodeURIComponent(v)}`);
539
+ return parts.length ? `?${parts.join("&")}` : "";
540
+ }
541
+
542
+ // ============================================================================
543
+ // Подключения сервисов
544
+ // ============================================================================
545
+ const CONNECT_PAGE = `${BASE}/bots/connect`;
546
+ const INTEGRATIONS_PAGE = `${BASE}/bots/integrations`;
547
+ /** Поля кредов — как форма кабинета (IntegrationsPage.tsx PROVIDER_FIELDS). */
548
+ const PROVIDER_FIELDS = {
549
+ AMOCRM: { name: "amoCRM", fields: { subdomain: "поддомен: acme из acme.amocrm.ru", longToken: "долгосрочный токен: amoCRM → Интеграции → ваша интеграция → Ключи и доступы" } },
550
+ BITRIX24: { name: "Битрикс24", fields: { webhookUrl: "URL входящего вебхука: Приложения → Разработчикам → Входящий вебхук (права: CRM)" } },
551
+ GETCOURSE: { name: "GetCourse", fields: { account: "аккаунт: school из school.getcourse.ru", apiKey: "секретный ключ: Настройки → API (показывается один раз)" } },
552
+ YAMETRIKA: { name: "Яндекс Метрика", fields: { counterId: "номер счётчика", oauthToken: "OAuth-токен Яндекса с доступом к загрузке офлайн-конверсий (выдаётся на oauth.yandex.ru)" } },
553
+ YOOKASSA: { name: "ЮKassa", fields: { shopId: "shopId магазина — число: ЮKassa → Настройки → Магазин", secretKey: "секретный ключ: ЮKassa → Интеграция → Ключи API (показывается один раз)" } },
554
+ };
555
+ const PROVIDER_ALIASES = {
556
+ amocrm: "AMOCRM", amo: "AMOCRM", bitrix24: "BITRIX24", bitrix: "BITRIX24", getcourse: "GETCOURSE",
557
+ yametrika: "YAMETRIKA", metrika: "YAMETRIKA", yandex_metrika: "YAMETRIKA", yandexmetrika: "YAMETRIKA",
558
+ yookassa: "YOOKASSA", yukassa: "YOOKASSA", ukassa: "YOOKASSA",
559
+ google_sheets: "GOOGLE_SHEETS", googlesheets: "GOOGLE_SHEETS", sheets: "GOOGLE_SHEETS", google: "GOOGLE_SHEETS",
560
+ instagram: "INSTAGRAM", telegram: "TELEGRAM", max: "MAX",
561
+ };
562
+ const normProvider = (p) => PROVIDER_ALIASES[String(p || "").trim().toLowerCase().replace(/[\s.-]+/g, "_")] || String(p || "").trim().toUpperCase();
563
+
564
+ /** Instagram подключается только входом через Facebook (OAuth) и сейчас выключен в сервисе (instagram.enabled). */
565
+ function instagramAnswer() {
566
+ return linkResult("Instagram: подключение через вход Facebook (OAuth) — сейчас выключено в сервисе", CONNECT_PAGE,
567
+ "Instagram-аккаунт нельзя подключить по API или токену: только входом через Facebook в кабинете. Сейчас подключение Instagram в retensy выключено (страница /bots/instagram ведёт на список ботов). Открой каталог подключений по ссылке — когда Instagram включат, он появится там. Пока доступны Telegram и MAX (create_bot).");
568
+ }
569
+
368
570
  const TOOLS = [
369
571
  { name: "setup", description: "Показать статус авторизации и пошаговую инструкцию подключения. Вызывай первым, если пользователь не знает, что делать, или при ошибке доступа.", inputSchema: { type: "object", properties: {} } },
370
572
  { name: "set_token", description: "Сохранить персональный токен (zmcp_...), который пользователь создал на /bots/mcp-tokens. Применяется сразу, без рестарта.", inputSchema: { type: "object", properties: { token: { type: "string", description: "Секрет токена, начинается с zmcp_" } }, required: ["token"] } },
371
573
  { name: "list_bots", description: "Список ботов пользователя (id, имя, статус).", inputSchema: { type: "object", properties: {} } },
372
- { name: "list_graphs", description: "Список графов (сценариев) бота.", inputSchema: { type: "object", properties: { botId: { type: "string" } }, required: ["botId"] } },
574
+ { name: "list_graphs", description: "Список сценариев САМОГО бота (без узлов). Вебхук-сценарии, которые лишь отвечают через этого бота, сюда не входят — их публикуют в вебе, в «Сценариях» автора.", inputSchema: { type: "object", properties: { botId: { type: "string" } }, required: ["botId"] } },
373
575
  { name: "list_channels", description: "Список каналов/групп, подключённых к боту (chatId, title, type, статус бота, дата). chatId — числовой id для условия SUBSCRIBED («Подписан на канал»).", inputSchema: { type: "object", properties: { botId: { type: "string" } }, required: ["botId"] } },
374
- { name: "list_integrations", description: "Список подключённых сервисов пользователя (GET /api/bots/integrations): {id, provider, title, hint, createdAt}. **id отсюда — это `connectionId`**, обязательное поле действий amocrm_send/amocrm_update/bitrix24_call/getcourse_send/getcourse_order/yametrika_event. Без него действие упадёт «не выбрано подключение». Креды не отдаются — только маскированный hint. Read-only.", inputSchema: { type: "object", properties: {} } },
576
+ { name: "list_integrations", description: "Список подключённых сервисов пользователя (GET /api/bots/integrations): {id, provider, title, hint, createdAt}. **id отсюда — это `connectionId`**, обязательное поле действий amocrm_send/amocrm_update/bitrix24_call/getcourse_send/getcourse_order/yametrika_event. Без него действие упадёт «не выбрано подключение». Креды не отдаются — только маскированный hint. Подключить новый — connect_integration. Read-only.", inputSchema: { type: "object", properties: {} } },
375
577
  { name: "get_graph", description: "Получить граф по graphId. Для БОЛЬШИХ графов (десятки узлов JSON может превысить лимит токенов) используй summary:true (компактная сводка: id/type/title/позиции + рёбра) или saveToFile (записать полный граф на диск и вернуть сводку+путь — потом правь файл и заливай через update_graph/edit_graph_live с graphFile).", inputSchema: { type: "object", properties: { graphId: { type: "string" }, summary: { type: "boolean", description: "true = вернуть компактную сводку без объёмных text/cards/buttons" }, saveToFile: { type: "string", description: "Путь: записать полный граф (JSON) на диск, вернуть сводку + путь" } }, required: ["graphId"] } },
376
578
  { name: "create_graph", description: "Создать пустой граф (DRAFT) в боте. Возвращает граф с id.", inputSchema: { type: "object", properties: { botId: { type: "string" }, name: { type: "string" } }, required: ["botId", "name"] } },
377
- { name: "update_graph", description: "Залить узлы/рёбра в граф (PUT, сырой replace без бэкапа). Для правок СУЩЕСТВУЮЩЕГО/живого сценария используй edit_graph_live. Активный (PUBLISHED) граф сервер проверяет как публикацию: при ошибках HTTP 422 со всеми code@nodeId, граф НЕ сохранён. Черновик сохраняется без проверок. Принимает graphFile (путь к локальному файлу — НЕ нужно слать граф инлайном, удобно для больших графов), graph-контейнер или nodes/edges.", inputSchema: { type: "object", properties: { graphId: { type: "string" }, graphFile: { type: "string", description: "Путь к локальному JSON графа (контейнер retensy-bot-graph или {nodes,edges}); поддерживается ~" }, graph: { type: "object" }, nodes: { type: "array" }, edges: { type: "array" }, canvasMeta: { type: "object" }, name: { type: "string" } }, required: ["graphId"] } },
378
- { name: "edit_graph_live", description: "РЕКОМЕНДОВАННЫЙ способ правки СУЩЕСТВУЮЩЕГО (часто живого/опубликованного) сценария: редактирует ТОТ ЖЕ graphId НА МЕСТЕ (id не меняется) и сначала снимает авто-бэкап текущего состояния в один rolling-граф «🔙 Авто-бэкап». НЕ клонирует и НЕ создаёт новый активный граф. Открытые редакторы перечитают граф вживую (external_update), бот применит изменения сразу (читает активный граф заново из БД). Используй ВМЕСТО clone+publish, когда нужно поправить сценарий, который уже открыт/в проде. ВАЖНО: правку активного графа сервер проверяет как публикацию (валидатор, платные блоки, лимит блоков тарифа, платформа) — при ошибках HTTP 422 со всеми code@nodeId, граф НЕ изменён, бот работает на прежней версии. Прогоняй offline validate.mjs и dry_run заранее, чтобы не ловить 422. Живой граф бота — с isActive:true в list_graphs (после publish_graph черновика — publishedGraphId, не id черновика); правка черновика до бота не доходит.", inputSchema: { type: "object", properties: { graphId: { type: "string" }, graph: { type: "object" }, nodes: { type: "array" }, edges: { type: "array" }, canvasMeta: { type: "object" }, name: { type: "string" }, graphFile: { type: "string", description: "Путь к локальному JSON графа (вместо инлайн-передачи); поддерживается ~" }, backup: { type: "boolean", description: "Снимать авто-бэкап предыдущего состояния перед правкой (по умолчанию true)." } }, required: ["graphId"] } },
579
+ { name: "update_graph", description: "Залить узлы/рёбра в граф (PUT, сырой replace без бэкапа). Для правок СУЩЕСТВУЮЩЕГО/живого сценария используй edit_graph_live. Активный (PUBLISHED) граф сервер проверяет как публикацию: при ошибках HTTP 422 со всеми code@nodeId, граф НЕ сохранён. Черновик сохраняется без проверок публикации, кроме размера: граф больше 4 МБ → HTTP 422 GRAPH_TOO_LARGE, не сохранён. Принимает graphFile (путь к локальному файлу — НЕ нужно слать граф инлайном, удобно для больших графов), graph-контейнер или nodes/edges.", inputSchema: { type: "object", properties: { graphId: { type: "string" }, graphFile: { type: "string", description: "Путь к локальному JSON графа (контейнер retensy-bot-graph или {nodes,edges}); поддерживается ~" }, graph: { type: "object" }, nodes: { type: "array" }, edges: { type: "array" }, canvasMeta: { type: "object" }, name: { type: "string" } }, required: ["graphId"] } },
580
+ { name: "edit_graph_live", description: "РЕКОМЕНДОВАННЫЙ способ правки СУЩЕСТВУЮЩЕГО (часто живого/опубликованного) сценария: редактирует ТОТ ЖЕ graphId НА МЕСТЕ (id не меняется) и сначала снимает авто-бэкап текущего состояния в один rolling-граф «🔙 Авто-бэкап». НЕ клонирует и НЕ создаёт новый активный граф. Открытые редакторы перечитают граф вживую (external_update), бот применит изменения сразу (читает активный граф заново из БД). Используй ВМЕСТО clone+publish, когда нужно поправить сценарий, который уже открыт/в проде. ВАЖНО: правку активного графа сервер проверяет как публикацию (валидатор, платные блоки, лимит блоков тарифа, платформа) — при ошибках HTTP 422 со всеми code@nodeId, граф НЕ изменён, бот работает на прежней версии. Прогоняй offline validate.mjs и dry_run заранее, чтобы не ловить 422. Живой граф бота — с isActive:true в list_graphs — для бот-сценария (после publish_graph черновика — publishedGraphId, не id черновика); правка черновика до бота не доходит.", inputSchema: { type: "object", properties: { graphId: { type: "string" }, graph: { type: "object" }, nodes: { type: "array" }, edges: { type: "array" }, canvasMeta: { type: "object" }, name: { type: "string" }, graphFile: { type: "string", description: "Путь к локальному JSON графа (вместо инлайн-передачи); поддерживается ~" }, backup: { type: "boolean", description: "Снимать авто-бэкап предыдущего состояния перед правкой (по умолчанию true)." } }, required: ["graphId"] } },
379
581
  { name: "patch_graph", description: "Точечная правка БОЛЬШОГО/живого графа без отправки графа целиком: сервер сам берёт граф по graphId, делает строковые замены в его JSON, проверяет валидность и заливает обратно НА МЕСТЕ (с авто-бэкапом). Идеально, когда граф слишком велик, чтобы передавать его целиком через update_graph/edit_graph_live — напр. сменить id канала в условиях SUBSCRIBED, ссылки кнопок, тексты. replacements: [{find, replace}] — заменяются ВСЕ вхождения; делай find максимально специфичным, чтобы не задеть лишнее. preview=true — только показать число совпадений, ничего не сохраняя. Бот применит изменения сразу только у опубликованного графа (читает активный граф заново из БД); патч черновика до бота не доходит. Результат для активного графа сервер проверяет как публикацию: ошибки → HTTP 422 со всеми code@nodeId, граф не изменён.", inputSchema: { type: "object", properties: { graphId: { type: "string" }, replacements: { type: "array", items: { type: "object", properties: { find: { type: "string" }, replace: { type: "string" } }, required: ["find", "replace"] } }, preview: { type: "boolean", description: "true = только отчёт о числе совпадений, без сохранения" }, backup: { type: "boolean", description: "снять авто-бэкап предыдущего состояния перед правкой (по умолчанию true)" } }, required: ["graphId", "replacements"] } },
380
582
  { name: "dry_run", description: "Прогнать сценарий без публикации. kind: command|callback|text.", inputSchema: { type: "object", properties: { graphId: { type: "string" }, kind: { type: "string", enum: ["command", "callback", "text"] }, value: { type: "string" }, fromUsername: { type: "string" }, presetVariables: { type: "object" }, presetTags: { type: "array", items: { type: "string" } } }, required: ["graphId", "kind", "value"] } },
381
583
  { name: "publish_graph", description: "Опубликовать граф. Вернёт publishedGraphId; при отказе проверок — ошибка HTTP 422 со всеми причинами построчно (code@nodeId: message). Сценарий-вебхук (источник WEBHOOK) этим инструментом не публикуется — HTTP 409, его публикуют в вебе.", inputSchema: { type: "object", properties: { graphId: { type: "string" } }, required: ["graphId"] } },
@@ -393,10 +595,56 @@ const TOOLS = [
393
595
  { name: "graph_analytics", description: "Аналитика прохождения сценария по узлам (GET /api/bots/graphs/{graphId}/analytics): сколько пользователей дошло до каждого узла — видно, где отваливается воронка. Read-only.", inputSchema: { type: "object", properties: { graphId: { type: "string" } }, required: ["graphId"] } },
394
596
  { name: "list_bot_users", description: "Пользователи (подписчики/лиды) бота, постранично (GET /api/bots/{botId}/users). Опц. page (с 0), size (по умолч. 25), query (поиск по имени/username/id). Read-only.", inputSchema: { type: "object", properties: { botId: { type: "string" }, page: { type: "number" }, size: { type: "number" }, query: { type: "string" } }, required: ["botId"] } },
395
597
  { name: "list_links", description: "Стартовые (трекинговые) ссылки бота с UTM (GET /api/bots/{botId}/links): code, метки, число стартов. Это точки входа в воронку. Read-only.", inputSchema: { type: "object", properties: { botId: { type: "string" } }, required: ["botId"] } },
598
+ { name: "site_list", description: "Сайты пользователя (раздел «Страницы», GET /api/bots/pages): id, title, mode (BLOCKS — сайт из блоков, CODE — файлы/Mini App), url (основной адрес), publishedRevision. Read-only.", inputSchema: { type: "object", properties: {} } },
599
+ { name: "site_create", description: "Создать сайт из блоков (POST /api/bots/pages, mode=BLOCKS). Возвращает id. Дальше: site_edit (init=starter — стартовый лендинг, init=blank — пустая главная) → site_publish. slug — «название» в адресе pages.retensy.com/<id>/<slug>/ (необязательно, по умолчанию транслит title).", inputSchema: { type: "object", properties: { title: { type: "string" }, slug: { type: "string" } }, required: ["title"] } },
600
+ { name: "site_get", description: "Модель сайта из блоков (GET /api/bots/pages/{siteId}/document): revision, draft (SiteModel: theme, globals.header/footer, pages[].blocks[], popups[]) — id страниц/блоков/попапов нужны для site_edit. draft=null — сайт пуст (первый site_edit создаст его). saveToFile — записать модель на диск и вернуть путь.", inputSchema: { type: "object", properties: { siteId: { type: "string" }, saveToFile: { type: "string" } }, required: ["siteId"] } },
601
+ { name: "site_schema", description: "JSON Schema модели сайта (model) и операций правки (ops) — какие блоки и поля бывают (GET /api/bots/pages/schema). Читай перед первой правкой.", inputSchema: { type: "object", properties: {} } },
602
+ { name: "site_edit", description: "Правка сайта операциями — всё или ничего (POST /api/bots/pages/{siteId}/document/ops). " +
603
+ "Страницы: add_page{title} · update_page{pageId,patch: {title?, path?, seo?{title,description,noindex,ogTitle,ogDescription,ogImage}, showHeader?, showFooter?, folder?}} · remove_page{pageId} · move_page{pageId,delta}. " +
604
+ "Папки страниц: add_folder{name} (id в results) · rename_folder{folderId,name} · remove_folder{folderId}; страница в папку — update_page{pageId, patch:{folder: folderId}}. " +
605
+ "Дизайны (отдельные экраны-макеты из Zero-кадров): add_design{name} (id в results) · update_design{designId,name} · remove_design{designId} · add_design_frame{designId,name,w,h} → в results blockId Zero-кадра, дальше с ним работают операции Zero-элементов. " +
606
+ "Шаблоны: add_template{container, templateId, after?} — вставить шаблон из библиотеки (site_templates). " +
607
+ "Блоки: add_block{container: id страницы|попапа, type, after?, variant?, props?, style?} · update_block{blockId, props?, style?, variant?} · move_block{blockId,delta} · duplicate_block{blockId} · remove_block{blockId}. " +
608
+ "Код блока: get_block_code{blockId} (results[i].code: Zero — разметка <zero>…</zero>, остальные — JSON) · set_block_code{blockId,code} · add_block_code{container,code,after?}. " +
609
+ "Zero-блок (type zero, свободная вёрстка как в Tilda): add_element{blockId, kind: text|image|button|shape|video|html|group, frame?{d:{x,y,w,h,container?,axisX?,axisY?}, t?, m?}, props?, style?, hover?, anim?, link?, parent?, name?, fixed?} · update_element{blockId,elementId, …те же поля, hidden?, locked?, link:null — убрать} · remove_element · move_element{delta: +1 — слой выше} · group_elements{blockId,elementIds[],name?} · ungroup_element. " +
610
+ "Сайт: set_global{slot: header|footer, on} · set_theme{theme} · set_settings{settings} · add_popup{name} · update_popup{popupId,name?,width?} · remove_popup{popupId}. " +
611
+ "props/style/theme/frame — JSON Merge Patch (null удаляет ключ, массивы заменяются целиком). Типы блоков: header, cover, text, image, gallery, buttons, features, form, video, html, spacer, footer, zero. " +
612
+ "Значения по экранам: {d, t?, m?} (десктоп/планшет/телефон). revision — защита от перезаписи (409, если сайт изменили); init (starter|blank) — с чего начать пустой сайт. " +
613
+ "Ответ: новая revision и results[] с id созданного (и code у get_block_code). Ошибки — HTTP 422 с путями. Тариф: HTML-блок и HTML-элемент Zero публикуются только на платном тарифе (422 при site_publish).", inputSchema: { type: "object", properties: { siteId: { type: "string" }, ops: { type: "array", items: { type: "object" } }, revision: { type: "number" }, init: { type: "string", enum: ["starter", "blank"] } }, required: ["siteId", "ops"] } },
614
+ { name: "site_publish", description: "Опубликовать черновик сайта (POST /api/bots/pages/{siteId}/publish): рендер в статику, адрес начинает отдавать новую версию. Ошибки проверки — HTTP 422 с путями. Возвращает publishedRevision и url.", inputSchema: { type: "object", properties: { siteId: { type: "string" } }, required: ["siteId"] } },
615
+ { name: "site_upload_asset", description: "Загрузить картинку/видео в сайт (POST /api/bots/pages/{siteId}/upload, папка assets). Передай path (локальный файл) ИЛИ url. Возвращает asset — строку вида assets/<имя> для полей image/logo/icon/style.bg.image.", inputSchema: { type: "object", properties: { siteId: { type: "string" }, path: { type: "string" }, url: { type: "string" } }, required: ["siteId"] } },
616
+ { name: "site_rollback", description: "Вернуть прошлую публикацию сайта (POST /api/bots/pages/{siteId}/publish/rollback): revision — номер из истории публикаций (site_get → versions[]). Черновик заменяется этой версией и сразу публикуется. Возвращает publishedRevision и url.", inputSchema: { type: "object", properties: { siteId: { type: "string" }, revision: { type: "number" } }, required: ["siteId", "revision"] } },
617
+ { name: "site_domains", description: "Свои домены сайта (/api/bots/pages/{siteId}/domains). action: list — домены, статусы и dnsTarget (IP для A-записи); add {host, withWww?} — привязать (withWww у корневого домена добавляет www-пару); check {domainId} — перепроверить DNS и сертификат; remove {domainId} — отвязать. Число доменов ограничено тарифом (HTTP 402 с upgradeUrl). Каждое действие возвращает актуальный список.", inputSchema: { type: "object", properties: { siteId: { type: "string" }, action: { type: "string", enum: ["list", "add", "check", "remove"] }, host: { type: "string" }, withWww: { type: "boolean" }, domainId: { type: "string" } }, required: ["siteId", "action"] } },
618
+ { name: "site_lead_settings", description: "Куда доставлять заявки из форм сайта (/api/bots/pages/{siteId}/lead-settings). Без settings — прочитать: текущие настройки и доступные вебхук-сценарии и подключения amoCRM. С settings — сохранить целиком: {notifyBot: в бот уведомлений из профиля, notifyEmail: письмо на почту аккаунта, webhookUrl?: POST JSON на ваш адрес, scenarioId?: вебхук-сценарий, который запускает заявка, amoConnectionId?: сделка в amoCRM}.", inputSchema: { type: "object", properties: { siteId: { type: "string" }, settings: { type: "object" } }, required: ["siteId"] } },
619
+ { name: "site_leads", description: "Заявки из форм сайта (GET /api/bots/pages/{siteId}/leads): поля, UTM, статус доставки. page (с 0), size (до 100). Read-only.", inputSchema: { type: "object", properties: { siteId: { type: "string" }, page: { type: "number" }, size: { type: "number" } }, required: ["siteId"] } },
396
620
  { name: "article_list", description: "Список СВОИХ статей блога retensy (GET /api/articles/my): id, slug, title, viewCount, даты. id нужен для article_update, slug — публичный адрес /articles/{slug}. Read-only.", inputSchema: { type: "object", properties: {} } },
397
621
  { name: "article_get", description: "Получить статью блога по slug (GET /api/articles/by-slug/{slug}) — публичное чтение, в т.ч. чужие. Возвращает title, content (Markdown), excerpt, coverImage, viewCount.", inputSchema: { type: "object", properties: { slug: { type: "string", description: "slug статьи (часть адреса /articles/{slug})" } }, required: ["slug"] } },
398
622
  { name: "article_publish", description: "Опубликовать НОВУЮ статью блога retensy (POST /api/articles). content — Markdown (как README на GitHub: заголовки, списки, таблицы, код, картинки по URL). title необязателен: если не передать, заголовком станет первая строка вида «# Заголовок», и она убирается из текста. Обложку можно задать явно через cover (URL картинки) — иначе берётся первая картинка из текста; excerpt (SEO-описание) тоже можно задать явно, иначе генерируется из текста. Возвращает статью с id и slug + публичный URL.", inputSchema: { type: "object", properties: { title: { type: "string", description: "Заголовок (необязателен, если content начинается с «# ...»)" }, content: { type: "string", description: "Тело статьи в Markdown" }, cover: { type: "string", description: "URL обложки (coverImage/OG). Если не задан — берётся первая картинка из текста." }, excerpt: { type: "string", description: "Краткое SEO-описание (≤160 симв). Если не задан — генерируется из текста." } }, required: ["content"] } },
399
623
  { name: "article_update", description: "Обновить СВОЮ статью по id (PUT /api/articles/{id}; id бери из article_list). content — Markdown; title необязателен (как в article_publish, иначе берётся из «# ...»). Только владелец — чужую вернёт 403.", inputSchema: { type: "object", properties: { id: { type: "string", description: "id статьи из article_list" }, title: { type: "string" }, content: { type: "string", description: "Новое тело в Markdown" } }, required: ["id", "content"] } },
624
+ // ---- Боты ----
625
+ { name: "create_bot", description: "Подключить бота по токену (POST /api/bots): platform TELEGRAM (токен от @BotFather) или MAX (токен от MasterBot в MAX). Вебхук настраивается сам; name — отображаемое имя (иначе @username). Возвращает бота с id. Число ботов ограничено тарифом — HTTP 402 со ссылкой на смену тарифа. platform INSTAGRAM по токену не подключается (только вход через Facebook в кабинете, сейчас выключен) — инструмент вернёт ссылку на кабинет вместо ошибки.", inputSchema: { type: "object", properties: { platform: { type: "string", enum: ["TELEGRAM", "MAX", "INSTAGRAM"] }, token: { type: "string", description: "Токен бота: 123456789:AA… (Telegram) или токен MAX" }, name: { type: "string" } }, required: ["platform"] } },
626
+ { name: "bot_stop", description: "Остановить бота (POST /api/bots/{botId}/stop): снимает вебхук, бот перестаёт отвечать, сценарии и подписчики сохраняются. Запуск обратно — bot_resume.", inputSchema: { type: "object", properties: { botId: { type: "string" } }, required: ["botId"] } },
627
+ { name: "bot_resume", description: "Запустить остановленного бота или бота, приостановленного лимитом тарифа (POST /api/bots/{botId}/resume). Если лимит ботов тарифа исчерпан — HTTP 402 со ссылкой на смену тарифа.", inputSchema: { type: "object", properties: { botId: { type: "string" } }, required: ["botId"] } },
628
+ // ---- Подключения ----
629
+ { name: "connect_integration", description: "Подключить сервис (POST /api/bots/integrations) — дальше его id (= connectionId) ставится в действия сценария и в site_lead_settings. provider и creds: AMOCRM {subdomain, longToken} · BITRIX24 {webhookUrl} · GETCOURSE {account, apiKey} · YAMETRIKA {counterId, oauthToken} · YOOKASSA {shopId, secretKey}. Без нужных creds вернёт, какие поля и где их взять. connectionId — обновить креды/название существующего подключения (PUT). Сервисы со входом через браузер не падают, а возвращают ссылку для пользователя: GOOGLE_SHEETS → ссылка согласия Google (OAuth; после неё таблицы выбираются в узле «Google Таблицы»), INSTAGRAM → кабинет (вход через Facebook, сейчас выключен). TELEGRAM/MAX — это боты: используй create_bot. Креды не возвращаются и не попадают в отчёты.", inputSchema: { type: "object", properties: { provider: { type: "string", description: "AMOCRM | BITRIX24 | GETCOURSE | YAMETRIKA | YOOKASSA | GOOGLE_SHEETS | INSTAGRAM" }, title: { type: "string", description: "Название подключения в кабинете (например «amoCRM продажи»)" }, creds: { type: "object", description: "Поля провайдера, см. описание" }, connectionId: { type: "string", description: "id существующего подключения (list_integrations) — обновить его" } }, required: ["provider"] } },
630
+ { name: "disconnect_integration", description: "Удалить подключение сервиса по id из list_integrations (DELETE /api/bots/integrations/{id}). Действия сценария с этим connectionId перестанут работать.", inputSchema: { type: "object", properties: { connectionId: { type: "string" } }, required: ["connectionId"] } },
631
+ // ---- Рассылки ----
632
+ { name: "broadcast_list", description: "Рассылки. Без botId — по всем ботам постранично (GET /api/bots/broadcasts): {counts: {drafts, scheduled, sent, recurring}, page: {content: [{id, botId, botUsername, name, status, direct, totalJobs, sentJobs, failedJobs, skippedByQuota, scheduledAt, createdAt}], totalElements…}}; group: scheduled (ещё не начали) | sent (идут/завершены). С botId — полная история одного бота. status: EXPANDING/MATERIALIZING/READY (ждёт) → RUNNING → DONE | CANCELLING → CANCELLED | FAILED. Read-only.", inputSchema: { type: "object", properties: { botId: { type: "string" }, group: { type: "string", enum: ["scheduled", "sent"] }, page: { type: "number" }, size: { type: "number", description: "до 100, по умолчанию 20" } } } },
633
+ { name: "broadcast_get", description: "Рассылка целиком по id (GET /api/bots/broadcasts/{id}): статус, счётчики отправки, фильтр аудитории, сообщения, время. Read-only.", inputSchema: { type: "object", properties: { broadcastId: { type: "string" } }, required: ["broadcastId"] } },
634
+ { name: "broadcast_preview", description: "Сколько подписчиков получат рассылку (POST /api/bots/{botId}/broadcasts/preview) — по каждому боту и всего. Фильтр по тегам: tagsAll — есть ВСЕ эти теги, tagsNone — нет НИ ОДНОГО; без тегов — все подписчики бота. Лимит — 50 000 получателей на бота. Ничего не отправляет.", inputSchema: { type: "object", properties: { botIds: { type: "array", items: { type: "string" } }, botId: { type: "string" }, tagsAll: { type: "array", items: { type: "string" } }, tagsNone: { type: "array", items: { type: "string" } } } } },
635
+ { name: "broadcast_send", description: "Отправить рассылку сейчас или запланировать (scheduledAt). " +
636
+ "Прямая (по умолчанию, POST /api/bots/broadcasts/direct): name, botIds[] (1–20 ботов ОДНОГО владельца; по каждому создаётся своя рассылка), messages[] (1–5), tagsAll?/tagsNone? (фильтр по тегам). " +
637
+ "Сообщение: {type, text?, mediaUrl?, mediaUrls?, buttons?}; type: TEXT (text обязателен, до 4096) · PHOTO | VIDEO | AUDIO | FILE | VOICE (mediaUrl обязателен, text — подпись до 1024) · VIDEONOTE (кружок: mediaUrl, без текста) · GALLERY (mediaUrls: 2–10 картинок, подпись, БЕЗ кнопок). " +
638
+ "text — Telegram-HTML: <b> <i> <u> <s> <code> <pre> <blockquote> <tg-spoiler> <a href=\"https://…\">, перенос строки — \\n (не <br>); прочее экранируется. buttons — до 8 URL-кнопок [{text, url}] (callback-кнопок в рассылке нет). Можно строкой — это TEXT. Медиа — сначала upload_file, потом его url. " +
639
+ "По сценарию (graphId): POST /api/bots/{botId}/broadcasts — один botId, сценарий самого бота, entryNodeId? — с какого узла начать. " +
640
+ "draftId — отправить черновик (поля черновика, переданные аргументы их перекрывают); после отправки черновик удаляется. " +
641
+ "scheduledAt — ISO 8601 (без пояса — московское время); пусто — сразу. Немедленная резервирует квоту получателей тарифа, отложенная считает аудиторию в момент отправки. Рассылки только на платном тарифе — HTTP 402 со ссылкой. У Instagram-ботов рассылок нет. Возвращает {broadcastIds, totalAudience}.", inputSchema: { type: "object", properties: { name: { type: "string" }, botIds: { type: "array", items: { type: "string" } }, botId: { type: "string" }, messages: { type: "array", items: {} }, tagsAll: { type: "array", items: { type: "string" } }, tagsNone: { type: "array", items: { type: "string" } }, scheduledAt: { type: "string", description: "ISO 8601, напр. 2026-10-06T10:00:00+03:00; пусто — сразу" }, graphId: { type: "string", description: "рассылка запуском сценария бота вместо сообщений" }, entryNodeId: { type: "string" }, draftId: { type: "string" } } } },
642
+ { name: "broadcast_cancel", description: "Отменить рассылку (POST /api/bots/broadcasts/{id}/cancel): запланированная не уйдёт, идущая остановится (статус CANCELLING → CANCELLED). Уже завершённую отменить нельзя — HTTP 409.", inputSchema: { type: "object", properties: { broadcastId: { type: "string" } }, required: ["broadcastId"] } },
643
+ { name: "broadcast_recurring", description: "Повторяющиеся рассылки (/api/bots/broadcasts/recurring). action: list — правила (активные и остановленные); create {name, botIds[], messages[], tagsAll?, tagsNone?, recurrence: DAILY|MONTHLY|YEARLY, firstRunAt} — сообщения как в broadcast_send, firstRunAt — первый запуск в будущем (ISO 8601, без пояса — московское), дальше в то же время суток/число (Москва); stop {ruleId} — остановить правило (уже отправленные прогоны не трогаются). Нужен платный тариф (402 со ссылкой).", inputSchema: { type: "object", properties: { action: { type: "string", enum: ["list", "create", "stop"] }, ruleId: { type: "string" }, name: { type: "string" }, botIds: { type: "array", items: { type: "string" } }, botId: { type: "string" }, messages: { type: "array", items: {} }, tagsAll: { type: "array", items: { type: "string" } }, tagsNone: { type: "array", items: { type: "string" } }, recurrence: { type: "string", enum: ["DAILY", "MONTHLY", "YEARLY"] }, firstRunAt: { type: "string" } }, required: ["action"] } },
644
+ { name: "broadcast_drafts", description: "Черновики рассылок (/api/bots/broadcasts/drafts) — те же, что в мастере кабинета. action: list · get {draftId} · create {name?, botIds?, messages?, tagsAll?, tagsNone?, scheduledAt?} · update {draftId, …те же поля — переданные заменяют, остальные остаются} · delete {draftId}. Черновик не проверяется на полноту; отправить — broadcast_send {draftId}. Лимит — 200 черновиков.", inputSchema: { type: "object", properties: { action: { type: "string", enum: ["list", "get", "create", "update", "delete"] }, draftId: { type: "string" }, name: { type: "string" }, botIds: { type: "array", items: { type: "string" } }, messages: { type: "array", items: {} }, tagsAll: { type: "array", items: { type: "string" } }, tagsNone: { type: "array", items: { type: "string" } }, scheduledAt: { type: "string" } }, required: ["action"] } },
645
+ { name: "broadcast_duplicate", description: "Копия прямой рассылки как черновик «Копия — …» (POST /api/bots/broadcasts/{id}/duplicate): бот, фильтр, сообщения. Рассылку по сценарию не дублировать — HTTP 409. Дальше broadcast_drafts update / broadcast_send {draftId}.", inputSchema: { type: "object", properties: { broadcastId: { type: "string" } }, required: ["broadcastId"] } },
646
+ // ---- Сайты: библиотека шаблонов ----
647
+ { name: "site_templates", description: "Библиотека шаблонов блоков сайта (GET /api/bots/pages/templates): {categories: [{id, title, description?}], templates: [{id, category, title, description?, blocks: сколько блоков вставится}]}. Вставка — site_edit add_template {container, templateId, after?} (results.id — первый блок, results.ids — все); дальше блоки правятся как обычные. category — фильтр по id категории.", inputSchema: { type: "object", properties: { category: { type: "string" } } } },
400
648
  ];
401
649
 
402
650
  async function handleCall(params) {
@@ -474,7 +722,7 @@ async function handleCall(params) {
474
722
  // его копия (publishedGraphId). Иначе агент решит, что поправил бота, а правка легла в черновик.
475
723
  steps.push(saved?.status === "PUBLISHED"
476
724
  ? `правка применена НА МЕСТЕ к ${a.graphId} (id не изменился; редакторы и бот подхватят live)`
477
- : `сохранено в черновик ${a.graphId}: до бота НЕ доходит — живые правки делай по id опубликованного графа (isActive:true в list_graphs; после publish_graph черновика — publishedGraphId)`);
725
+ : `сохранено в черновик ${a.graphId}: до бота НЕ доходит — живые правки делай по id опубликованного графа (isActive:true в list_graphs — для бот-сценария; после publish_graph черновика — publishedGraphId)`);
478
726
  return okResult({ graphId: a.graphId, backupGraphId, inPlace: true, status: saved?.status ?? null, nodes: Array.isArray(saved?.nodes) ? saved.nodes.length : null, edges: Array.isArray(saved?.edges) ? saved.edges.length : null, steps });
479
727
  }
480
728
  case "patch_graph": {
@@ -594,6 +842,58 @@ async function handleCall(params) {
594
842
  return okResult(await api(`/api/bots/${a.botId}/users${qs.length ? `?${qs.join("&")}` : ""}`));
595
843
  }
596
844
  case "list_links": return okResult(await api(`/api/bots/${a.botId}/links`));
845
+ case "site_list": return okResult(await api("/api/bots/pages"));
846
+ case "site_create": {
847
+ if (!a.title) throw new Error("Передай title сайта.");
848
+ return okResult(await api("/api/bots/pages", { method: "POST", body: { title: a.title, slug: a.slug || undefined, mode: "BLOCKS" } }));
849
+ }
850
+ case "site_get": {
851
+ const doc = await api(`/api/bots/pages/${a.siteId}/document`);
852
+ if (a.saveToFile) {
853
+ const abs = path.resolve(String(a.saveToFile).replace(/^~(?=$|[/\\])/, os.homedir()));
854
+ fs.writeFileSync(abs, JSON.stringify(doc, null, 2));
855
+ return okResult({ revision: doc?.revision, publishedRevision: doc?.publishedRevision, savedTo: abs });
856
+ }
857
+ return okResult(doc);
858
+ }
859
+ case "site_schema": return okResult(await api("/api/bots/pages/schema"));
860
+ case "site_edit": {
861
+ if (!Array.isArray(a.ops) || !a.ops.length) throw new Error("Передай ops — массив операций (см. site_schema).");
862
+ return okResult(await api(`/api/bots/pages/${a.siteId}/document/ops`, { method: "POST", body: { ops: a.ops, revision: a.revision, init: a.init } }));
863
+ }
864
+ case "site_publish": {
865
+ const r = await api(`/api/bots/pages/${a.siteId}/publish`, { method: "POST" });
866
+ return okResult({ publishedRevision: r?.publishedRevision, url: r?.page?.url });
867
+ }
868
+ case "site_upload_asset": return okResult(await uploadSiteAsset(a.siteId, { filePath: a.path, url: a.url }));
869
+ case "site_leads": {
870
+ const qs = [];
871
+ if (a.page != null) qs.push(`page=${encodeURIComponent(a.page)}`);
872
+ if (a.size != null) qs.push(`size=${encodeURIComponent(a.size)}`);
873
+ return okResult(await api(`/api/bots/pages/${a.siteId}/leads${qs.length ? `?${qs.join("&")}` : ""}`));
874
+ }
875
+ case "site_rollback": {
876
+ if (typeof a.revision !== "number") throw new Error("Передай revision — номер публикации (site_get → versions[]).");
877
+ const r = await api(`/api/bots/pages/${a.siteId}/publish/rollback`, { method: "POST", body: { revision: a.revision } });
878
+ return okResult({ publishedRevision: r?.publishedRevision, url: r?.page?.url });
879
+ }
880
+ case "site_domains": {
881
+ const base = `/api/bots/pages/${a.siteId}/domains`;
882
+ if (a.action === "list") return okResult(await api(base));
883
+ if (a.action === "add") {
884
+ if (!a.host) throw new Error("Передай host — домен, например example.ru.");
885
+ return okResult(await api(base, { method: "POST", body: { host: a.host, withWww: !!a.withWww } }));
886
+ }
887
+ if (!a.domainId) throw new Error("Передай domainId (site_domains action=list).");
888
+ if (a.action === "check") return okResult(await api(`${base}/${a.domainId}/check`, { method: "POST" }));
889
+ if (a.action === "remove") return okResult(await api(`${base}/${a.domainId}`, { method: "DELETE" }));
890
+ throw new Error("action: list | add | check | remove.");
891
+ }
892
+ case "site_lead_settings": {
893
+ const p_ = `/api/bots/pages/${a.siteId}/lead-settings`;
894
+ if (a.settings == null) return okResult(await api(p_));
895
+ return okResult(await api(p_, { method: "PUT", body: a.settings }));
896
+ }
597
897
  case "article_list": return okResult(await api("/api/articles/my"));
598
898
  case "article_get": return okResult(await api(`/api/articles/by-slug/${encodeURIComponent(a.slug)}`));
599
899
  case "article_publish": {
@@ -607,6 +907,188 @@ async function handleCall(params) {
607
907
  const updated = await api(`/api/articles/${a.id}`, { method: "PUT", body: { title: a.title, content: a.content } });
608
908
  return okResult({ ...updated, publicUrl: updated?.slug ? `${BASE}/articles/${updated.slug}` : null });
609
909
  }
910
+ // ---- Боты ----
911
+ case "create_bot": {
912
+ const platform = String(a.platform || "").trim().toUpperCase();
913
+ if (platform === "INSTAGRAM") return instagramAnswer();
914
+ if (platform !== "TELEGRAM" && platform !== "MAX") throw new Error("platform: TELEGRAM | MAX (Instagram подключается только в кабинете).");
915
+ const token = String(a.token || "").trim();
916
+ if (!token) {
917
+ return okResult(platform === "MAX"
918
+ ? "Нужен токен MAX-бота: создай бота у MasterBot в MAX и пришли токен — я подключу его (create_bot {platform:\"MAX\", token})."
919
+ : "Нужен токен Telegram-бота: открой https://t.me/BotFather → /newbot (или /token для существующего) и пришли токен вида 123456789:AA… — я подключу его (create_bot {platform:\"TELEGRAM\", token}).");
920
+ }
921
+ let bot;
922
+ try {
923
+ bot = await api("/api/bots", { method: "POST", body: { token, platform } });
924
+ } catch (e) {
925
+ // 400 приходит строкой-причиной (неверный токен, бот уже подключён) — её и показываем.
926
+ if (e.status === 400) throw new Error(`Бот не подключён: ${typeof e.data === "string" && e.data ? e.data : "токен не принят"}. Проверь токен (${platform === "MAX" ? "MasterBot в MAX" : "@BotFather → /token"}).`);
927
+ throw e;
928
+ }
929
+ if (a.name && bot?.id) {
930
+ try { bot = await api(`/api/bots/${bot.id}`, { method: "PATCH", body: { name: a.name } }); }
931
+ catch (e) { return okResult({ ...bot, warning: `Бот подключён, но имя не задано: ${(e.message || "").split("\n")[0]}` }); }
932
+ }
933
+ return okResult(bot);
934
+ }
935
+ case "bot_stop": return okResult(await api(`/api/bots/${a.botId}/stop`, { method: "POST" }));
936
+ case "bot_resume": return okResult(await api(`/api/bots/${a.botId}/resume`, { method: "POST" }));
937
+ // ---- Подключения ----
938
+ case "connect_integration": {
939
+ const provider = normProvider(a.provider);
940
+ if (provider === "INSTAGRAM") return instagramAnswer();
941
+ if (provider === "TELEGRAM" || provider === "MAX") {
942
+ return okResult(`${provider === "MAX" ? "MAX" : "Telegram"} подключается как бот, а не интеграция: вызови create_bot {platform:"${provider}", token}.`);
943
+ }
944
+ if (provider === "GOOGLE_SHEETS") {
945
+ const r = await api(`/api/bots/google/auth-url${qs({ returnPath: "/bots/integrations" })}`, { method: "POST" });
946
+ let connected = [];
947
+ try { const ids = await api("/api/bots/google/identities"); connected = Array.isArray(ids) ? ids : []; } catch { /* список не обязателен */ }
948
+ if (!r?.authUrl) throw new Error(`Сервер не вернул ссылку авторизации Google. Подключи в кабинете: ${INTEGRATIONS_PAGE}`);
949
+ return linkResult("Google Таблицы: вход через Google (OAuth)", r.authUrl,
950
+ "Открой ссылку, выбери Google-аккаунт и разреши доступ к таблицам — затем таблица выбирается в узле сценария «Google Таблицы». Ссылка одноразовая и живёт недолго: если истекла, вызови connect_integration ещё раз.",
951
+ { connectedGoogleAccounts: connected });
952
+ }
953
+ const spec = PROVIDER_FIELDS[provider];
954
+ if (!spec) throw new Error(`Неизвестный provider «${a.provider}». Бывают: ${Object.keys(PROVIDER_FIELDS).join(", ")}, GOOGLE_SHEETS, INSTAGRAM. Каталог: ${CONNECT_PAGE}`);
955
+ const creds = a.creds && typeof a.creds === "object" ? Object.fromEntries(
956
+ Object.entries(a.creds).filter(([, v]) => v != null && String(v).trim() !== "").map(([k, v]) => [k, String(v).trim()])) : {};
957
+ const missing = Object.keys(spec.fields).filter((k) => !creds[k]);
958
+ if (missing.length && (!a.connectionId || Object.keys(creds).length)) {
959
+ return okResult({
960
+ connected: false,
961
+ provider,
962
+ need: Object.fromEntries(missing.map((k) => [k, spec.fields[k]])),
963
+ instruction: `Для ${spec.name} не хватает полей creds: ${missing.join(", ")}. Попроси их у пользователя и вызови connect_integration ещё раз. Или пусть подключит сам в кабинете: ${CONNECT_PAGE}`,
964
+ });
965
+ }
966
+ const title = a.title || spec.name;
967
+ try {
968
+ const saved = a.connectionId
969
+ ? await api(`/api/bots/integrations/${a.connectionId}`, { method: "PUT", body: { title: a.title, creds: Object.keys(creds).length ? creds : undefined } })
970
+ : await api("/api/bots/integrations", { method: "POST", body: { provider, title, creds } });
971
+ return okResult({ connected: true, connectionId: saved?.id, ...saved, hint: "connectionId ставь в действия сценария (amocrm_send, bitrix24_call, getcourse_send, yametrika_event, оплата ЮKassa) и в site_lead_settings.amoConnectionId." });
972
+ } catch (e) {
973
+ if (e.status === 400) throw new Error(`${spec.name} не подключён: ${bodyReason(e.data) || "креды не приняты"}. Проверь поля: ${Object.entries(spec.fields).map(([k, v]) => `${k} — ${v}`).join("; ")}.`);
974
+ throw e;
975
+ }
976
+ }
977
+ case "disconnect_integration":
978
+ await api(`/api/bots/integrations/${a.connectionId}`, { method: "DELETE" });
979
+ return okResult(`🗑️ Подключение ${a.connectionId} удалено.`);
980
+ // ---- Рассылки ----
981
+ case "broadcast_list": {
982
+ if (a.botId) return okResult(await api(`/api/bots/${a.botId}/broadcasts`));
983
+ const page = await api(`/api/bots/broadcasts${qs({ page: a.page, size: a.size, group: a.group })}`);
984
+ let counts = null;
985
+ try { counts = await api("/api/bots/broadcasts/counts"); } catch { /* счётчики не обязательны */ }
986
+ return okResult({ counts, page });
987
+ }
988
+ case "broadcast_get": return okResult(await api(`/api/bots/broadcasts/${a.broadcastId}`));
989
+ case "broadcast_preview": {
990
+ const ids = botIdsOf(a);
991
+ await assertBroadcastBots(ids);
992
+ const filter = { tagsAll: strList(a.tagsAll), tagsNone: strList(a.tagsNone) };
993
+ const perBot = [];
994
+ for (const id of ids) {
995
+ const r = await api(`/api/bots/${id}/broadcasts/preview`, { method: "POST", body: filter });
996
+ perBot.push({ botId: id, count: r?.count ?? null, limit: r?.limit ?? null });
997
+ }
998
+ return okResult({ total: perBot.reduce((s, r) => s + (Number(r.count) || 0), 0), perBot, filter });
999
+ }
1000
+ case "broadcast_send": {
1001
+ if (a.graphId) {
1002
+ const ids = botIdsOf(a);
1003
+ if (ids.length !== 1) throw new Error("Рассылка по сценарию — ровно один botId (сценарий принадлежит боту).");
1004
+ await assertBroadcastBots(ids);
1005
+ if (!a.name) throw new Error("Передай name рассылки.");
1006
+ const b = await api(`/api/bots/${ids[0]}/broadcasts`, { method: "POST", body: {
1007
+ name: a.name, audienceFilter: { tagsAll: strList(a.tagsAll), tagsNone: strList(a.tagsNone) },
1008
+ graphId: a.graphId, entryNodeId: a.entryNodeId || undefined, scheduledAt: toInstant(a.scheduledAt, "scheduledAt") } });
1009
+ return okResult({ broadcastIds: [b?.id], status: b?.status, scheduledAt: b?.scheduledAt ?? null, broadcast: b });
1010
+ }
1011
+ let draft = null;
1012
+ if (a.draftId) draft = await api(`/api/bots/broadcasts/drafts/${a.draftId}`);
1013
+ const pick = (k) => (a[k] !== undefined ? a[k] : draft?.[k]);
1014
+ const ids = botIdsOf({ botIds: a.botIds ?? (a.botId ? undefined : draft?.botIds), botId: a.botId });
1015
+ await assertBroadcastBots(ids);
1016
+ const name = String(pick("name") || "").trim();
1017
+ if (!name) throw new Error("Передай name рассылки (видно только тебе в списке рассылок).");
1018
+ const body = {
1019
+ name, botIds: ids,
1020
+ tagsAll: strList(a.tagsAll !== undefined ? a.tagsAll : draft?.audienceFilter?.tagsAll),
1021
+ tagsNone: strList(a.tagsNone !== undefined ? a.tagsNone : draft?.audienceFilter?.tagsNone),
1022
+ messages: normalizeBroadcastMessages(pick("messages")),
1023
+ scheduledAt: toInstant(pick("scheduledAt"), "scheduledAt"),
1024
+ };
1025
+ if (body.scheduledAt && new Date(body.scheduledAt).getTime() <= Date.now()) {
1026
+ // Прошедшее время бэкенд молча считает «сейчас» — для черновика с устаревшей датой это сюрприз.
1027
+ if (a.scheduledAt !== undefined) throw new Error(`scheduledAt ${body.scheduledAt} уже прошло — укажи будущее время или не передавай его (отправить сейчас).`);
1028
+ body.scheduledAt = null;
1029
+ }
1030
+ const r = await api("/api/bots/broadcasts/direct", { method: "POST", body });
1031
+ let draftDeleted = false;
1032
+ if (a.draftId) { try { await api(`/api/bots/broadcasts/drafts/${a.draftId}`, { method: "DELETE" }); draftDeleted = true; } catch { /* не критично */ } }
1033
+ return okResult({ ...r, scheduledAt: body.scheduledAt, ...(a.draftId ? { draftDeleted } : {}) });
1034
+ }
1035
+ case "broadcast_cancel":
1036
+ await api(`/api/bots/broadcasts/${a.broadcastId}/cancel`, { method: "POST" });
1037
+ return okResult(`⏹️ Рассылка ${a.broadcastId} отменяется (CANCELLING → CANCELLED).`);
1038
+ case "broadcast_recurring": {
1039
+ const base = "/api/bots/broadcasts/recurring";
1040
+ if (a.action === "list") return okResult(await api(base));
1041
+ if (a.action === "stop") {
1042
+ if (!a.ruleId) throw new Error("Передай ruleId (broadcast_recurring action=list).");
1043
+ await api(`${base}/${a.ruleId}/stop`, { method: "POST" });
1044
+ return okResult(`⏹️ Правило ${a.ruleId} остановлено — новых прогонов не будет.`);
1045
+ }
1046
+ if (a.action === "create") {
1047
+ const ids = botIdsOf(a);
1048
+ await assertBroadcastBots(ids);
1049
+ if (!a.name) throw new Error("Передай name.");
1050
+ const recurrence = String(a.recurrence || "").toUpperCase();
1051
+ if (!["DAILY", "MONTHLY", "YEARLY"].includes(recurrence)) throw new Error("recurrence: DAILY | MONTHLY | YEARLY.");
1052
+ const firstRunAt = toInstant(a.firstRunAt, "firstRunAt");
1053
+ if (!firstRunAt || new Date(firstRunAt).getTime() <= Date.now()) throw new Error("firstRunAt — время первого запуска в будущем (ISO 8601, без пояса — московское).");
1054
+ return okResult(await api(base, { method: "POST", body: {
1055
+ name: a.name, botIds: ids, tagsAll: strList(a.tagsAll), tagsNone: strList(a.tagsNone),
1056
+ messages: normalizeBroadcastMessages(a.messages), recurrence, firstRunAt } }));
1057
+ }
1058
+ throw new Error("action: list | create | stop.");
1059
+ }
1060
+ case "broadcast_drafts": {
1061
+ const base = "/api/bots/broadcasts/drafts";
1062
+ if (a.action === "list") return okResult(await api(base));
1063
+ if (a.action === "create" || a.action === "update") {
1064
+ if (a.action === "update" && !a.draftId) throw new Error("Передай draftId (broadcast_drafts action=list).");
1065
+ // update: PUT заменяет черновик целиком — непереданные поля берём из текущего, чтобы правка текста не стёрла ботов.
1066
+ const cur = a.action === "update" ? (await api(`${base}/${a.draftId}`)) || {} : {};
1067
+ const has = (k) => a[k] !== undefined;
1068
+ const body = {
1069
+ name: has("name") ? a.name : cur.name,
1070
+ botIds: has("botIds") || has("botId") ? botIdsOf(a) : (cur.botIds || []),
1071
+ tagsAll: strList(has("tagsAll") ? a.tagsAll : cur.audienceFilter?.tagsAll),
1072
+ tagsNone: strList(has("tagsNone") ? a.tagsNone : cur.audienceFilter?.tagsNone),
1073
+ messages: normalizeBroadcastMessages(has("messages") ? a.messages : cur.messages, false),
1074
+ scheduledAt: toInstant(has("scheduledAt") ? a.scheduledAt : cur.scheduledAt, "scheduledAt"),
1075
+ };
1076
+ if (a.action === "create") return okResult(await api(base, { method: "POST", body }));
1077
+ return okResult(await api(`${base}/${a.draftId}`, { method: "PUT", body }));
1078
+ }
1079
+ if (!a.draftId) throw new Error("Передай draftId (broadcast_drafts action=list).");
1080
+ if (a.action === "get") return okResult(await api(`${base}/${a.draftId}`));
1081
+ if (a.action === "delete") { await api(`${base}/${a.draftId}`, { method: "DELETE" }); return okResult(`🗑️ Черновик ${a.draftId} удалён.`); }
1082
+ throw new Error("action: list | get | create | update | delete.");
1083
+ }
1084
+ case "broadcast_duplicate":
1085
+ return okResult(await api(`/api/bots/broadcasts/${a.broadcastId}/duplicate`, { method: "POST" }));
1086
+ case "site_templates": {
1087
+ const r = await api("/api/bots/pages/templates");
1088
+ let templates = Array.isArray(r?.templates) ? r.templates : [];
1089
+ if (a.category) templates = templates.filter((t) => t?.category === a.category);
1090
+ return okResult({ categories: r?.categories ?? [], templates });
1091
+ }
610
1092
  default:
611
1093
  throw new Error(`Неизвестный инструмент: ${params && params.name}`);
612
1094
  }