@retensy/mcp 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -1
- package/.claude-plugin/plugin.json +2 -2
- package/README.md +76 -6
- package/package.json +6 -3
- package/skills/build-bot-funnel/SKILL.md +4 -3
- package/skills/build-bot-funnel/reference/schema.md +8 -0
- package/skills/build-bot-funnel/validate.mjs +6 -0
- package/skills/build-site/SKILL.md +43 -2
- package/skills/send-broadcast/SKILL.md +48 -0
- package/src/index.mjs +449 -9
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
{
|
|
7
7
|
"name": "retensy-mcp",
|
|
8
8
|
"source": "./",
|
|
9
|
-
"
|
|
9
|
+
"version": "0.15.0",
|
|
10
|
+
"description": "Сборка и публикация воронок ботов (Telegram/MAX, Retensy Bots) из описания, рассылки, подключение ботов и сервисов, сайтов из блоков + публикация статей блога в Markdown — через MCP + скилл."
|
|
10
11
|
}
|
|
11
12
|
]
|
|
12
13
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "retensy-mcp",
|
|
3
3
|
"displayName": "Retensy MCP",
|
|
4
|
-
"version": "0.
|
|
5
|
-
"description": "MCP-сервер + скилл для Retensy Bots: сборка и публикация воронок ботов (Telegram/MAX
|
|
4
|
+
"version": "0.15.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
|
-
- 🤖
|
|
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; креды хранятся зашифрованными и не возвращаются), `integration_catalog`/`integration_status`/`integration_test` (каталог Integration Core и живая проверка подключения), `channel_post` (пост в канал Telegram/MAX).
|
|
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
|
|
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
|
-
Подтянутся
|
|
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,17 @@ 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,
|
|
103
|
+
| `list_integrations()` | подключённые сервисы (amoCRM, Битрикс24, GetCourse, Я.Метрика, ЮKassa): `id` = `connectionId` для действий сценария |
|
|
104
|
+
| `connect_integration(provider, creds?, title?, connectionId?)` | подключить/обновить сервис; без кредов — какие поля нужны; Google Таблицы → ссылка входа Google, Instagram → ссылка на кабинет |
|
|
105
|
+
| `disconnect_integration(connectionId)` | удалить подключение |
|
|
106
|
+
| `integration_catalog()` | каталог Integration Core: поля подключения (`configSchema`) и действия (`actions[].kind`) сервисов |
|
|
107
|
+
| `integration_status(connectionId)` | статус подключения: `OK` / `NEEDS_REAUTH` / `ERROR` / `UNKNOWN`, последняя проверка и ошибка |
|
|
108
|
+
| `integration_test(connectionId)` | живая проверка ключа во внешнем сервисе от имени владельца (без побочных эффектов) |
|
|
109
|
+
| `channel_post(botId, chatId, text?, mediaUrl?)` | разовый пост в канал/группу Telegram или MAX (раздел «Публикации»); файл — из `upload_file` |
|
|
98
110
|
| `get_graph(graphId, [summary], [saveToFile])` | получить граф; `summary:true` — компактная сводка (id/type/title + рёбра), `saveToFile` — записать полный JSON на диск (для больших графов, чтобы не упереться в лимит токенов) |
|
|
99
111
|
| `create_graph(botId, name)` | создать пустой граф (DRAFT) |
|
|
100
112
|
| `update_graph(graphId, graphFile\|graph\|nodes,edges)` | залить узлы/рёбра (PUT); `graphFile` — путь к локальному JSON, граф не нужно слать инлайном |
|
|
@@ -115,11 +127,69 @@ MCP-сервер (+ скилл для Claude Code) для **сборки и пу
|
|
|
115
127
|
| `graph_analytics(graphId)` | прохождение сценария по узлам (где отваливается воронка) |
|
|
116
128
|
| `list_bot_users(botId)` | подписчики/лиды бота (постранично, поиск `query`) |
|
|
117
129
|
| `list_links(botId)` | стартовые трекинговые ссылки бота с UTM |
|
|
130
|
+
| `site_list()` | сайты пользователя (id, mode, url, publishedRevision) |
|
|
131
|
+
| `site_create(title, slug?)` | новый сайт из блоков → `id` |
|
|
132
|
+
| `site_get(siteId, saveToFile?)` | модель сайта (`revision`, `draft`, `versions[]` публикаций) |
|
|
133
|
+
| `site_schema()` | JSON Schema модели и операций — читать перед правкой |
|
|
134
|
+
| `site_edit(siteId, ops[], revision?, init?)` | правка операциями, всё или ничего: страницы и папки, блоки, Zero-элементы, код блока (`get/set/add_block_code`), дизайны и их кадры, шаблоны (`add_template`), тема, попапы |
|
|
135
|
+
| `site_templates(category?, full?)` | библиотека шаблонов блоков для `add_template` |
|
|
136
|
+
| `site_publish(siteId)` | опубликовать черновик → `url` |
|
|
137
|
+
| `site_rollback(siteId, revision)` | вернуть прошлую публикацию |
|
|
138
|
+
| `site_upload_asset(siteId, path\|url)` | картинка/видео в сайт → `assets/…` |
|
|
139
|
+
| `site_domains(siteId, action, host?, withWww?, domainId?)` | свои домены: list / add / check / remove (число — по тарифу) |
|
|
140
|
+
| `site_leads(siteId, page?, size?)` | заявки из форм (поля, UTM, статус доставки) |
|
|
141
|
+
| `site_lead_settings(siteId, settings?)` | куда доставлять заявки: бот уведомлений, почта, вебхук, вебхук-сценарий, amoCRM, «Интеграция» (`coreDelivery {connectionId, kind, params}` из `coreConnections`) |
|
|
142
|
+
| `broadcast_list(botId?, group?, page?, size?)` | рассылки + счётчики разделов (черновики/запланированные/отправленные/повторы) |
|
|
143
|
+
| `broadcast_get(broadcastId)` | рассылка целиком: статус, счётчики, сообщения |
|
|
144
|
+
| `broadcast_preview(botIds, tagsAll?, tagsNone?)` | сколько подписчиков получат рассылку |
|
|
145
|
+
| `broadcast_send(name, botIds, messages \| graphId, tagsAll?, tagsNone?, scheduledAt?, draftId?)` | отправить сейчас / запланировать; по нескольким ботам; или запуск сценария |
|
|
146
|
+
| `broadcast_cancel(broadcastId)` | отменить запланированную/идущую |
|
|
147
|
+
| `broadcast_recurring(action, …)` | повторяющиеся рассылки: list / create (DAILY·MONTHLY·YEARLY) / stop |
|
|
148
|
+
| `broadcast_drafts(action, …)` | черновики: list / get / create / update / delete |
|
|
149
|
+
| `broadcast_duplicate(broadcastId)` | копия рассылки как черновик |
|
|
118
150
|
| `article_list()` | свои статьи блога (id, slug, title, просмотры) |
|
|
119
151
|
| `article_get(slug)` | статья по slug (Markdown content, excerpt, обложка) |
|
|
120
152
|
| `article_publish(content, title?, cover?, excerpt?)` | новая статья (Markdown; title из `# ...`, если не задан; обложка из `cover`-URL или 1-й картинки → OG; `excerpt` явно или авто) → id, slug, URL |
|
|
121
153
|
| `article_update(id, content, title?)` | обновить свою статью по id |
|
|
122
154
|
|
|
155
|
+
### Рассылки
|
|
156
|
+
|
|
157
|
+
Сообщение рассылки — как в мастере кабинета: `{type, text?, mediaUrl?, mediaUrls?, buttons?}`, до 5 сообщений.
|
|
158
|
+
|
|
159
|
+
| type | Обязательно | Текст | Кнопки |
|
|
160
|
+
|---|---|---|---|
|
|
161
|
+
| `TEXT` | `text` (до 4096) | Telegram-HTML | до 8 URL-кнопок |
|
|
162
|
+
| `PHOTO` `VIDEO` `AUDIO` `FILE` `VOICE` | `mediaUrl` | подпись до 1024 | до 8 |
|
|
163
|
+
| `VIDEONOTE` (кружок) | `mediaUrl` | нет | до 8 |
|
|
164
|
+
| `GALLERY` | `mediaUrls` — 2–10 картинок | подпись до 1024 | нет |
|
|
165
|
+
|
|
166
|
+
- HTML: `<b> <i> <u> <s> <code> <pre> <blockquote> <tg-spoiler> <a href="https://…">`, перенос строки — `\n`.
|
|
167
|
+
- Кнопки только URL `[{text, url}]` — callback-кнопок в рассылке нет. Медиа — сначала `upload_file`, потом его `url`.
|
|
168
|
+
- Аудитория — подписчики бота, фильтр тегами: `tagsAll` (есть все), `tagsNone` (нет ни одного). До 50 000 на бота, до 20 ботов одного владельца за раз.
|
|
169
|
+
- `scheduledAt` — ISO 8601; без часового пояса считается московским. Пусто — отправить сейчас.
|
|
170
|
+
- Рассылки — на платном тарифе; квота получателей месячная. При нехватке — ошибка с прямой ссылкой на смену тарифа.
|
|
171
|
+
- У Instagram-ботов рассылок нет.
|
|
172
|
+
|
|
173
|
+
```json
|
|
174
|
+
{"name": "Распродажа", "botIds": ["<id>"], "tagsAll": ["клиент"], "scheduledAt": "2026-10-10T10:00",
|
|
175
|
+
"messages": ["<b>Только сегодня</b> — скидка 30%",
|
|
176
|
+
{"type": "PHOTO", "mediaUrl": "https://…/sale.jpg", "text": "Успей до полуночи",
|
|
177
|
+
"buttons": [{"text": "В магазин", "url": "https://shop.example"}]}]}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### Когда нужен браузер
|
|
181
|
+
|
|
182
|
+
Через API не делается то, что требует входа пользователя у стороннего сервиса, оплаты или входа в аккаунт.
|
|
183
|
+
Такие инструменты не падают, а возвращают `{needsBrowser: true, url, instruction}` или ошибку со ссылкой:
|
|
184
|
+
|
|
185
|
+
| Ситуация | Ссылка |
|
|
186
|
+
|---|---|
|
|
187
|
+
| нет токена / токен отозван | `/bots/mcp-tokens` — создать токен и прислать агенту |
|
|
188
|
+
| Google Таблицы (`connect_integration`) | одноразовая ссылка согласия Google (OAuth) |
|
|
189
|
+
| Instagram (`create_bot`, `connect_integration`) | `/bots/connect` — подключается входом через Facebook; сейчас выключен в сервисе |
|
|
190
|
+
| лимит тарифа, рассылки на бесплатном (HTTP 402) | `upgradeUrl` из ответа или `/bots/subscription` |
|
|
191
|
+
| свой домен сайта | DNS у регистратора: A-запись на `dnsTarget` из `site_domains` |
|
|
192
|
+
|
|
123
193
|
---
|
|
124
194
|
|
|
125
195
|
## Формат графа и проверка
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@retensy/mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "MCP server to build and publish Telegram
|
|
3
|
+
"version": "0.15.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
6
|
"bin": {
|
|
7
7
|
"retensy-mcp": "src/index.mjs"
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"scripts": {
|
|
22
22
|
"start": "node src/index.mjs",
|
|
23
23
|
"check": "node --check src/index.mjs",
|
|
24
|
-
"test": "node scripts/smoke.mjs && node scripts/telemetry.test.mjs && node scripts/live-edit-422.test.mjs",
|
|
24
|
+
"test": "node scripts/smoke.mjs && node scripts/telemetry.test.mjs && node scripts/live-edit-422.test.mjs && node scripts/broadcast-connect.test.mjs && node scripts/integration-core.test.mjs",
|
|
25
25
|
"validate": "node skills/build-bot-funnel/validate.mjs"
|
|
26
26
|
},
|
|
27
27
|
"engines": {
|
|
@@ -34,8 +34,11 @@
|
|
|
34
34
|
"instagram",
|
|
35
35
|
"bot",
|
|
36
36
|
"funnel",
|
|
37
|
+
"broadcast",
|
|
37
38
|
"automation",
|
|
38
39
|
"articles",
|
|
40
|
+
"website-builder",
|
|
41
|
+
"landing-page",
|
|
39
42
|
"blog",
|
|
40
43
|
"markdown",
|
|
41
44
|
"retensy",
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: build-bot-funnel
|
|
3
|
-
description: Собрать воронку (сценарий) бота для сервиса /bots из текстового описания — сгенерировать валидный граф формата retensy-bot-graph, проверить его и (через MCP bot-graph) залить в бота, прогнать dry-run и опубликовать. Поддерживает Telegram
|
|
3
|
+
description: Собрать воронку (сценарий) бота для сервиса /bots из текстового описания — сгенерировать валидный граф формата retensy-bot-graph, проверить его и (через MCP bot-graph) залить в бота, прогнать dry-run и опубликовать. Поддерживает Telegram и MAX (Instagram сейчас выключен в сервисе — IG-бота не подключить). Использовать, когда пользователь описывает воронку/прогрев/сценарий бота словами и просит «собрать», «сделать граф», «залить в бота», «опубликовать сценарий».
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Сборка воронки бота (retensy /bots)
|
|
7
7
|
|
|
8
|
-
Превращает **текстовое описание воронки** в валидный граф `retensy-bot-graph` и (опционально) публикует его через MCP-сервер `bot-graph`. Поддерживаются платформы **Telegram
|
|
8
|
+
Превращает **текстовое описание воронки** в валидный граф `retensy-bot-graph` и (опционально) публикует его через MCP-сервер `bot-graph`. Поддерживаются платформы **Telegram и MAX** — у каждой свои триггеры и ограничения, описанные в schema.md. **Instagram сейчас выключен в сервисе — IG-бота не подключить**; разделы о нём оставлены на будущее включение.
|
|
9
9
|
|
|
10
10
|
**Различия платформ (кратко):**
|
|
11
11
|
- **Telegram** — полный функционал: `/start` и другие команды, все типы узлов, SUBSCRIBED, кнопки-контакты, рассылки.
|
|
12
12
|
- **MAX** — те же узлы, но без SUBSCRIBED/reply-клавиатур (мягкие предупреждения при публикации, не блокируют).
|
|
13
|
-
- **Instagram** — ограниченный набор узлов; вход только через комментарий/директ/историю (нет `/start`); нет рассылок; DELAY не более 24ч; ASK_QUESTION только TEXT/EMAIL/PHONE/NUMBER/CONTACT (CONTACT = ручной ввод номера, кнопки «Поделиться номером» в IG нет); онбординг через OAuth на странице `/bots/instagram` («Подключения» → Instagram), без токена бота.
|
|
13
|
+
- **Instagram** (сейчас выключен в сервисе — IG-бота не подключить) — ограниченный набор узлов; вход только через комментарий/директ/историю (нет `/start`); нет рассылок; DELAY не более 24ч; ASK_QUESTION только TEXT/EMAIL/PHONE/NUMBER/CONTACT (CONTACT = ручной ввод номера, кнопки «Поделиться номером» в IG нет); онбординг через OAuth на странице `/bots/instagram` («Подключения» → Instagram), без токена бота.
|
|
14
14
|
|
|
15
15
|
## Когда применять
|
|
16
16
|
Пользователь описывает сценарий бота словами: приветствие → подписка → вопрос с кнопками → ветки → задержки → вебинар и т.п. Либо просит залить/опубликовать готовую воронку.
|
|
@@ -39,6 +39,7 @@ description: Собрать воронку (сценарий) бота для с
|
|
|
39
39
|
- **не ждать** — ветку `paid` не рисуй, блок просто пришлёт счёт и пойдёт по `next`; выдать доступ потом сможет отдельный сценарий с триггером `TRIGGER_PAYMENT`.
|
|
40
40
|
Счёт обязан быть **последним** действием в блоке и только одним (`INVOICE_NOT_LAST`, `INVOICE_DUPLICATE`).
|
|
41
41
|
- **`TRIGGER_PAYMENT` («оплата прошла»)** — если счёт выставлял бот, подписчик определяется САМ (счёт помнит его в metadata платежа): можно сразу слать сообщение и вешать метку, разбирать ответ кассы не нужно. Если оплата пришла мимо бота — прогон headless, получателя задавай через `config.target`. Ставь фильтр `minAmount`/`descriptionContains`, иначе сценарий сработает на любую оплату в кассе.
|
|
42
|
+
- **`TRIGGER_SITE_FORM` («Заявка с сайта»)** — `{siteId, formId}`: посетитель отправил форму на ОПУБЛИКОВАННОМ сайте владельца (`site_list` → id сайта, id формы — из `site_get`). Пустой/отсутствующий `formId` = любая форма сайта. Поля формы — `{{var.<имя поля>}}`; имя, почта, телефон всегда доступны как `{{var.name}}`, `{{var.email}}`, `{{var.phone}}` (если поле названо иначе — находятся по подписи), UTM — `{{var.utm_source}}` и т.п. Чата с посетителем нет: узлы отправки работают только с явным `config.target`, CRM/таблицы/вебхуки — как обычно. Обычно это сценарий без бота (источник WEBHOOK, как вебхук-сценарий) — ему `TRIGGER_WEBHOOK` не нужен; можно и в сценарии бота. Чужой сайт → `SITE_FORM_FOREIGN_SITE`, без сайта → `SITE_FORM_NO_SITE`. Одна заявка — один запуск; `TRIGGER_ANY_UPDATE` на заявки не срабатывает.
|
|
42
43
|
|
|
43
44
|
4. **Проверь локально** перед заливкой:
|
|
44
45
|
```bash
|
|
@@ -72,6 +72,12 @@
|
|
|
72
72
|
если не подошёл ни один конкретный триггер. Удобен как «ничего не понял» / отладка.
|
|
73
73
|
- `TRIGGER_WEBHOOK` — `{}`, точка входа сценария с источником WEBHOOK (не бот). Такие сценарии
|
|
74
74
|
создаются в вебе; у графа бота этот триггер не сработает.
|
|
75
|
+
- `TRIGGER_SITE_FORM` — `{ "siteId": "<uuid сайта>", "formId": "<id формы>" }` — заявка с формы
|
|
76
|
+
опубликованного сайта владельца. `formId` пустой/нет = любая форма сайта. `siteId` обязателен
|
|
77
|
+
(`SITE_FORM_NO_SITE`), сайт должен быть свой (`SITE_FORM_FOREIGN_SITE`). Переменные: поля формы
|
|
78
|
+
`{{var.<имя>}}`, всегда `{{var.name}}`/`{{var.email}}`/`{{var.phone}}` (по имени поля, иначе по подписи),
|
|
79
|
+
UTM `{{var.utm_*}}`. Прогон без чата: узлы отправки — только с `config.target`. Работает и в сценарии
|
|
80
|
+
без бота (источник WEBHOOK — тогда `TRIGGER_WEBHOOK` не обязателен), и в сценарии бота; не для Instagram.
|
|
75
81
|
- ~~`TRIGGER_COMMENT`~~ — **мёртвый тип**: рантайм нигде не выставляет `event="comment"`, сработать
|
|
76
82
|
он не может. Убран из палитры редактора. Комментарии Instagram — `TRIGGER_IG_COMMENT`.
|
|
77
83
|
|
|
@@ -361,6 +367,8 @@ IG-боты не поддерживают команды (`/start`). Вход
|
|
|
361
367
|
|
|
362
368
|
## Платформа Instagram
|
|
363
369
|
|
|
370
|
+
> **Сейчас Instagram выключен в сервисе:** IG-бота не подключить, страница `/bots/instagram` ведёт на `/bots`. Раздел оставлен на будущее включение.
|
|
371
|
+
|
|
364
372
|
IG-боты подключаются через OAuth на странице **`/bots/instagram`** (раздел «Подключения» → карточка
|
|
365
373
|
Instagram; прежний раздел «Инструменты роста» / `/growth` расформирован и редиректит) — **без вставки токена вручную**; у IG нет персонального бот-токена. После OAuth бот получает доступ к Messaging API через привязанный Instagram Business/Creator-аккаунт.
|
|
366
374
|
|
|
@@ -213,6 +213,12 @@ for (const n of nodes) {
|
|
|
213
213
|
if (!blank(f.status) && ev !== "chat_member" && ev !== "my_chat_member") warns.push(`${who}: фильтр по статусу работает только у chat_member/my_chat_member.`);
|
|
214
214
|
break;
|
|
215
215
|
}
|
|
216
|
+
case "TRIGGER_SITE_FORM":
|
|
217
|
+
// Бэкенд (GraphValidator.validateSiteFormTrigger): siteId обязателен и должен быть UUID своего сайта.
|
|
218
|
+
if (blank(c.siteId)) errors.push(`SITE_FORM_NO_SITE: ${who} — нужен siteId (id сайта из site_list).`);
|
|
219
|
+
else if (!UUID_RE.test(String(c.siteId).trim())) errors.push(`SITE_FORM_NO_SITE: ${who} — siteId «${c.siteId}» не UUID сайта (site_list).`);
|
|
220
|
+
if (c.formId != null && typeof c.formId !== "string") errors.push(`${who}: formId — строка (id формы из site_get) или пусто = любая форма.`);
|
|
221
|
+
break;
|
|
216
222
|
case "TRIGGER_COMMENT":
|
|
217
223
|
errors.push(`TRIGGER_COMMENT_DEAD: ${who} — этот тип триггера не сработает никогда (рантайм не выставляет event=comment). Для Instagram используй TRIGGER_IG_COMMENT.`);
|
|
218
224
|
break;
|
|
@@ -11,12 +11,18 @@ description: Собрать сайт или лендинг из блоков в
|
|
|
11
11
|
|
|
12
12
|
## Порядок
|
|
13
13
|
|
|
14
|
-
1. `site_schema` — какие блоки и поля бывают (`model`) и какие операции есть (`ops`)
|
|
14
|
+
1. `site_schema` — какие блоки и поля бывают (`model`) и какие операции есть (`ops`); `site_templates` — готовые
|
|
15
|
+
секции из библиотеки.
|
|
15
16
|
2. `site_create {title}` → `id`. Для правки существующего — `site_list`, `site_get {siteId}`.
|
|
16
17
|
3. Первый `site_edit` с `init`: `starter` — готовый лендинг (шапка, обложка, текст, преимущества, форма, подвал) или
|
|
17
18
|
`blank` — пустая главная. Заполни тексты, не выдумывай факты о бизнесе — спрашивай.
|
|
18
19
|
4. Картинки — `site_upload_asset {siteId, path|url}` → `assets/…` в поля `image`, `logo`, `icon`, `style.bg.image`.
|
|
19
|
-
5. `site_get` — проверь модель, `site_publish` — сайт открыт по `url`.
|
|
20
|
+
5. `site_get` — проверь модель, `site_publish` — сайт открыт по `url`.
|
|
21
|
+
6. После публикации: заявки — `site_leads`, куда их слать — `site_lead_settings` (бот уведомлений, почта, вебхук,
|
|
22
|
+
вебхук-сценарий бота, amoCRM, «Интеграция» — `coreDelivery {connectionId, kind, params?}`: любое подключение из
|
|
23
|
+
`coreConnections`, действие — `kind` из `integration_catalog`; запуск автоматизаций по заявке — сценарий с триггером
|
|
24
|
+
`TRIGGER_SITE_FORM {siteId, formId}`, скилл build-bot-funnel); свой домен — `site_domains` (`add` → пользователь ставит A-запись на `dnsTarget`
|
|
25
|
+
→ `check`); неудачная публикация — `site_rollback {revision}` из `site_get → versions[]`.
|
|
20
26
|
|
|
21
27
|
## Правила модели
|
|
22
28
|
|
|
@@ -29,6 +35,41 @@ description: Собрать сайт или лендинг из блоков в
|
|
|
29
35
|
- Цвета — `#rrggbb`. Шрифты: inter, montserrat, roboto, pt-sans, pt-serif, rubik, oswald.
|
|
30
36
|
- `revision` из ответа передавай в следующий `site_edit` — сервер не даст перезаписать правки из кабинета (409).
|
|
31
37
|
|
|
38
|
+
## Zero-блок — свободная вёрстка (как Zero Block в Tilda)
|
|
39
|
+
|
|
40
|
+
Блок `type: "zero"` — артборд, на котором элементы стоят по координатам. Для дизайнерских экранов, где готовых
|
|
41
|
+
блоков мало.
|
|
42
|
+
|
|
43
|
+
- Добавить: `add_block {container, type: "zero", props: {height: {d: 600, m: 720}}}` → id блока в `results`.
|
|
44
|
+
- Элементы: `add_element {blockId, kind, frame, props, style?, link?, anim?}`, `kind`: `text`, `image`, `button`,
|
|
45
|
+
`shape`, `video`, `html`, `group`. `frame` — по экранам: `{d: {x, y, w, h}, m?: {…}}`; `h: "auto"` у текста — высота
|
|
46
|
+
по содержимому. Нет `t`/`m` — кадр выводится из большего экрана в масштабе; задай `m`, если на телефоне нужна
|
|
47
|
+
другая раскладка. `container: "window"` + `axisX/axisY` — привязка к краям окна, а не к сетке.
|
|
48
|
+
- Правка — `update_element` (Merge Patch), порядок слоёв — `move_element {delta}`, группы — `group_elements` /
|
|
49
|
+
`ungroup_element`; у элемента в группе кадр — относительно группы (`parent`).
|
|
50
|
+
- Целиком переписать блок проще кодом: `get_block_code {blockId}` → в `results[i].code` разметка `<zero …>…</zero>`,
|
|
51
|
+
поправь и верни `set_block_code {blockId, code}`; новый блок из кода — `add_block_code {container, code}`. У
|
|
52
|
+
обычных блоков код — JSON `{type, variant, props, style}`. Тот же код пользователь видит во вкладке «Код» редактора.
|
|
53
|
+
|
|
54
|
+
## Папки, дизайны, шаблоны
|
|
55
|
+
|
|
56
|
+
- Папки страниц (для навигации в кабинете): `add_folder {name}` → id в `results`; `rename_folder {folderId, name}`,
|
|
57
|
+
`remove_folder {folderId}`. Страницу в папку — `update_page {pageId, patch: {folder: folderId}}`.
|
|
58
|
+
- Дизайны — отдельные экраны-макеты из Zero-кадров (не страницы сайта): `add_design {name}` → id;
|
|
59
|
+
`update_design {designId, name}`, `remove_design {designId}`. Кадр: `add_design_frame {designId, name, w, h}` →
|
|
60
|
+
в `results` `blockId` Zero-кадра — дальше на нём работают `add_element`/`update_element`/`get_block_code` и др.
|
|
61
|
+
- Шаблоны: `site_templates` → `{categories, templates: [{id, category, title, description?, blocks}]}` (`blocks` —
|
|
62
|
+
сколько блоков вставится). Вставка — `add_template {container, templateId, after?}`: блоки шаблона
|
|
63
|
+
встают в страницу/попап, дальше правь их тексты обычными `update_block`. Быстрее, чем собирать блоки с нуля.
|
|
64
|
+
|
|
65
|
+
## Тариф
|
|
66
|
+
|
|
67
|
+
- HTML-блок и HTML-элемент Zero публикуются только на платном тарифе: на бесплатном `site_publish` вернёт 422 с
|
|
68
|
+
путями этих элементов — замени их обычными блоками или предложи тариф.
|
|
69
|
+
- Число своих доменов ограничено тарифом (`site_domains add` → 402 со ссылкой на смену тарифа — передай её
|
|
70
|
+
пользователю, оплата только в браузере); www-пара корневого домена не в счёт.
|
|
71
|
+
- На бесплатном тарифе адрес на pages.retensy.com закрыт от поисковиков (noindex).
|
|
72
|
+
|
|
32
73
|
## Пример: лендинг кофейни с попапом заявки
|
|
33
74
|
|
|
34
75
|
```json
|
|
@@ -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.
|
|
30
|
+
const VERSION = "0.15.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
|
|
275
|
-
throw new Error(`Доступ отклонён (HTTP
|
|
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,7 +364,7 @@ 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(
|
|
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
|
}
|
|
@@ -365,7 +406,7 @@ async function uploadSiteAsset(siteId, { filePath, url }) {
|
|
|
365
406
|
let data = null; try { data = text ? JSON.parse(text) : null; } catch { data = text; }
|
|
366
407
|
if (!res.ok) {
|
|
367
408
|
if (res.status === 401 || res.status === 403) throw new Error(`Доступ отклонён (HTTP ${res.status}). Токен невалиден/отозван — создай новый на ${TOKENS_PAGE}.`);
|
|
368
|
-
if (res.status === 402) throw new Error(
|
|
409
|
+
if (res.status === 402) throw new Error(`Лимит хранилища тарифа исчерпан (HTTP 402). Подними тариф: 🔗 ${SUBSCRIPTION_PAGE}`);
|
|
369
410
|
throw httpError("POST", `/api/bots/pages/${siteId}/upload`, res.status, data);
|
|
370
411
|
}
|
|
371
412
|
return { asset: `assets/${safe}`, sizeBytes: bytes.length };
|
|
@@ -406,13 +447,151 @@ function graphSummary(g) {
|
|
|
406
447
|
return { graphId: g?.id, name: g?.name, status: g?.status, version: g?.version, counts: { nodes: nodes.length, edges: edges.length }, nodes, edges };
|
|
407
448
|
}
|
|
408
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
|
+
/** IA v2: каталог и подключения — раздел «Интеграции». Старые /bots/connect|integrations остаются в кабинете (Instagram — там). */
|
|
546
|
+
const CONNECT_PAGE = `${BASE}/integrations`;
|
|
547
|
+
const INTEGRATIONS_PAGE = CONNECT_PAGE;
|
|
548
|
+
const LEGACY_CONNECT_PAGE = `${BASE}/bots/connect`;
|
|
549
|
+
/** Ключи с секретами: бэкенд их не отдаёт (только маска hint), но вычищаем и здесь — секрет не должен попасть в вывод. */
|
|
550
|
+
const SECRET_OUT_RX = /^(creds|credsEnc|credentials|password|api_?key|.*secret(key)?|.*token)$/i;
|
|
551
|
+
function withoutSecrets(v) {
|
|
552
|
+
if (Array.isArray(v)) return v.map(withoutSecrets);
|
|
553
|
+
if (!v || typeof v !== "object") return v;
|
|
554
|
+
const out = {};
|
|
555
|
+
for (const [k, x] of Object.entries(v)) {
|
|
556
|
+
if (SECRET_OUT_RX.test(k) && typeof x !== "boolean") continue; // secret:true в схеме каталога — флаг, не секрет
|
|
557
|
+
out[k] = withoutSecrets(x);
|
|
558
|
+
}
|
|
559
|
+
return out;
|
|
560
|
+
}
|
|
561
|
+
/** Поля кредов — как форма кабинета (IntegrationsPage.tsx PROVIDER_FIELDS). */
|
|
562
|
+
const PROVIDER_FIELDS = {
|
|
563
|
+
AMOCRM: { name: "amoCRM", fields: { subdomain: "поддомен: acme из acme.amocrm.ru", longToken: "долгосрочный токен: amoCRM → Интеграции → ваша интеграция → Ключи и доступы" } },
|
|
564
|
+
BITRIX24: { name: "Битрикс24", fields: { webhookUrl: "URL входящего вебхука: Приложения → Разработчикам → Входящий вебхук (права: CRM)" } },
|
|
565
|
+
GETCOURSE: { name: "GetCourse", fields: { account: "аккаунт: school из school.getcourse.ru", apiKey: "секретный ключ: Настройки → API (показывается один раз)" } },
|
|
566
|
+
YAMETRIKA: { name: "Яндекс Метрика", fields: { counterId: "номер счётчика", oauthToken: "OAuth-токен Яндекса с доступом к загрузке офлайн-конверсий (выдаётся на oauth.yandex.ru)" } },
|
|
567
|
+
YOOKASSA: { name: "ЮKassa", fields: { shopId: "shopId магазина — число: ЮKassa → Настройки → Магазин", secretKey: "секретный ключ: ЮKassa → Интеграция → Ключи API (показывается один раз)" } },
|
|
568
|
+
};
|
|
569
|
+
const PROVIDER_ALIASES = {
|
|
570
|
+
amocrm: "AMOCRM", amo: "AMOCRM", bitrix24: "BITRIX24", bitrix: "BITRIX24", getcourse: "GETCOURSE",
|
|
571
|
+
yametrika: "YAMETRIKA", metrika: "YAMETRIKA", yandex_metrika: "YAMETRIKA", yandexmetrika: "YAMETRIKA",
|
|
572
|
+
yookassa: "YOOKASSA", yukassa: "YOOKASSA", ukassa: "YOOKASSA",
|
|
573
|
+
google_sheets: "GOOGLE_SHEETS", googlesheets: "GOOGLE_SHEETS", sheets: "GOOGLE_SHEETS", google: "GOOGLE_SHEETS",
|
|
574
|
+
instagram: "INSTAGRAM", telegram: "TELEGRAM", max: "MAX",
|
|
575
|
+
};
|
|
576
|
+
const normProvider = (p) => PROVIDER_ALIASES[String(p || "").trim().toLowerCase().replace(/[\s.-]+/g, "_")] || String(p || "").trim().toUpperCase();
|
|
577
|
+
|
|
578
|
+
/** Instagram подключается только входом через Facebook (OAuth) и сейчас выключен в сервисе (instagram.enabled). */
|
|
579
|
+
function instagramAnswer() {
|
|
580
|
+
return linkResult("Instagram: подключение через вход Facebook (OAuth) — сейчас выключено в сервисе", LEGACY_CONNECT_PAGE,
|
|
581
|
+
"Instagram-аккаунт нельзя подключить по API или токену: только входом через Facebook в кабинете. Сейчас подключение Instagram в retensy выключено (страница /bots/instagram ведёт на список ботов). Открой каталог подключений по ссылке — когда Instagram включат, он появится там. Пока доступны Telegram и MAX (create_bot).");
|
|
582
|
+
}
|
|
583
|
+
|
|
409
584
|
const TOOLS = [
|
|
410
585
|
{ name: "setup", description: "Показать статус авторизации и пошаговую инструкцию подключения. Вызывай первым, если пользователь не знает, что делать, или при ошибке доступа.", inputSchema: { type: "object", properties: {} } },
|
|
411
586
|
{ name: "set_token", description: "Сохранить персональный токен (zmcp_...), который пользователь создал на /bots/mcp-tokens. Применяется сразу, без рестарта.", inputSchema: { type: "object", properties: { token: { type: "string", description: "Секрет токена, начинается с zmcp_" } }, required: ["token"] } },
|
|
412
587
|
{ name: "list_bots", description: "Список ботов пользователя (id, имя, статус).", inputSchema: { type: "object", properties: {} } },
|
|
413
588
|
{ name: "list_graphs", description: "Список сценариев САМОГО бота (без узлов). Вебхук-сценарии, которые лишь отвечают через этого бота, сюда не входят — их публикуют в вебе, в «Сценариях» автора.", inputSchema: { type: "object", properties: { botId: { type: "string" } }, required: ["botId"] } },
|
|
414
589
|
{ name: "list_channels", description: "Список каналов/групп, подключённых к боту (chatId, title, type, статус бота, дата). chatId — числовой id для условия SUBSCRIBED («Подписан на канал»).", inputSchema: { type: "object", properties: { botId: { type: "string" } }, required: ["botId"] } },
|
|
415
|
-
{ name: "
|
|
590
|
+
{ name: "integration_catalog", description: "Каталог сервисов Integration Core (GET /api/integrations/catalog): [{provider, name, category, authType, configSchema: [{key, label, hint, secret}] — поля подключения, actions: [{kind, label, inputs}] — действия для сценария и coreDelivery, healthCheck — умеет ли integration_test}]. 404 — Integration Core выключен в сервисе. Read-only.", inputSchema: { type: "object", properties: {} } },
|
|
591
|
+
{ name: "integration_status", description: "Статус подключения (GET /api/integrations/{id}/status): {status: UNKNOWN | OK | NEEDS_REAUTH (ключ отозван/устарел — обнови через connect_integration) | ERROR, lastCheckedAt, lastError, supported}. Только своё подключение (чужое — 403). Read-only, внешний сервис не вызывает.", inputSchema: { type: "object", properties: { connectionId: { type: "string", description: "id из list_integrations" } }, required: ["connectionId"] } },
|
|
592
|
+
{ name: "integration_test", description: "Проверить подключение (POST /api/integrations/{id}/test): выполняет ЖИВУЮ проверку ключа во внешнем сервисе от имени владельца (без побочных эффектов — ничего не создаёт) и обновляет статус. Ответ как у integration_status; supported:false — сервис проверку не умеет. Только своё подключение (чужое — 403); 404 — Integration Core выключен.", inputSchema: { type: "object", properties: { connectionId: { type: "string", description: "id из list_integrations" } }, required: ["connectionId"] } },
|
|
593
|
+
{ name: "channel_post", description: "Разовый пост от имени бота в канал/группу Telegram или MAX (раздел «Публикации», POST /api/bots/{botId}/linked-chats/{chatId}/post). Бот должен быть администратором канала (list_channels). text и/или mediaUrl — файл из upload_file (тип фото/видео/документ берётся из файла; чужие ссылки не принимаются). Лимиты: Telegram — 4096 символов текста, 1024 подписи к файлу; MAX — 4000. Ответ {ok, messageId?}. Публикует сразу — подтверди текст с пользователем.", inputSchema: { type: "object", properties: { botId: { type: "string" }, chatId: { type: "number", description: "chatId из list_channels" }, text: { type: "string" }, mediaUrl: { type: "string", description: "url из upload_file" } }, required: ["botId", "chatId"] } },
|
|
594
|
+
{ 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: {} } },
|
|
416
595
|
{ 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"] } },
|
|
417
596
|
{ name: "create_graph", description: "Создать пустой граф (DRAFT) в боте. Возвращает граф с id.", inputSchema: { type: "object", properties: { botId: { type: "string" }, name: { type: "string" } }, required: ["botId", "name"] } },
|
|
418
597
|
{ 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"] } },
|
|
@@ -438,14 +617,52 @@ const TOOLS = [
|
|
|
438
617
|
{ 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"] } },
|
|
439
618
|
{ 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"] } },
|
|
440
619
|
{ name: "site_schema", description: "JSON Schema модели сайта (model) и операций правки (ops) — какие блоки и поля бывают (GET /api/bots/pages/schema). Читай перед первой правкой.", inputSchema: { type: "object", properties: {} } },
|
|
441
|
-
{ name: "site_edit", description: "Правка сайта операциями — всё или ничего (POST /api/bots/pages/{siteId}/document/ops).
|
|
620
|
+
{ name: "site_edit", description: "Правка сайта операциями — всё или ничего (POST /api/bots/pages/{siteId}/document/ops). " +
|
|
621
|
+
"Страницы: 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}. " +
|
|
622
|
+
"Папки страниц: add_folder{name} (id в results) · rename_folder{folderId,name} · remove_folder{folderId}; страница в папку — update_page{pageId, patch:{folder: folderId}}. " +
|
|
623
|
+
"Дизайны (отдельные экраны-макеты из Zero-кадров): add_design{name} (id в results) · update_design{designId,name} · remove_design{designId} · add_design_frame{designId,name,w,h} → в results blockId Zero-кадра, дальше с ним работают операции Zero-элементов. " +
|
|
624
|
+
"Шаблоны: add_template{container, templateId, after?} — вставить шаблон из библиотеки (site_templates). " +
|
|
625
|
+
"Блоки: 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}. " +
|
|
626
|
+
"Код блока: get_block_code{blockId} (results[i].code: Zero — разметка <zero>…</zero>, остальные — JSON) · set_block_code{blockId,code} · add_block_code{container,code,after?}. " +
|
|
627
|
+
"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. " +
|
|
628
|
+
"Сайт: set_global{slot: header|footer, on} · set_theme{theme} · set_settings{settings} · add_popup{name} · update_popup{popupId,name?,width?} · remove_popup{popupId}. " +
|
|
629
|
+
"props/style/theme/frame — JSON Merge Patch (null удаляет ключ, массивы заменяются целиком). Типы блоков: header, cover, text, image, gallery, buttons, features, form, video, html, spacer, footer, zero. " +
|
|
630
|
+
"Значения по экранам: {d, t?, m?} (десктоп/планшет/телефон). revision — защита от перезаписи (409, если сайт изменили); init (starter|blank) — с чего начать пустой сайт. " +
|
|
631
|
+
"Ответ: новая 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"] } },
|
|
442
632
|
{ name: "site_publish", description: "Опубликовать черновик сайта (POST /api/bots/pages/{siteId}/publish): рендер в статику, адрес начинает отдавать новую версию. Ошибки проверки — HTTP 422 с путями. Возвращает publishedRevision и url.", inputSchema: { type: "object", properties: { siteId: { type: "string" } }, required: ["siteId"] } },
|
|
443
633
|
{ 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"] } },
|
|
634
|
+
{ 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"] } },
|
|
635
|
+
{ 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"] } },
|
|
636
|
+
{ name: "site_lead_settings", description: "Куда доставлять заявки из форм сайта (/api/bots/pages/{siteId}/lead-settings). Без settings — прочитать: {settings, scenarios (вебхук-сценарии), amoConnections, coreConnections: [{id, name, provider}] — подключения для доставки «Интеграция», без кредов}. С settings — сохранить ЦЕЛИКОМ (сначала прочитай и поменяй нужное): {notifyBot: в бот уведомлений из профиля, notifyEmail: письмо на почту аккаунта, webhookUrl?: POST JSON на ваш адрес, scenarioId?: вебхук-сценарий, который запускает заявка, amoConnectionId?: сделка в amoCRM, coreDelivery?: {connectionId: id из coreConnections, kind: действие сервиса из integration_catalog (actions[].kind, например amocrm_send), params?: {поле действия: шаблон}} | null}. Пустые params заполнятся из заявки (имя, телефон, почта, текст); в шаблонах — {{var.name}}, {{var.phone}}, {{var.email}}, {{var.<имя поля>}}, {{var.lead_text}} (текст заявки). Чужое подключение или неизвестный kind — 400. Запуск сценариев по заявке — триггер TRIGGER_SITE_FORM (скилл build-bot-funnel).", inputSchema: { type: "object", properties: { siteId: { type: "string" }, settings: { type: "object", properties: { notifyBot: { type: "boolean" }, notifyEmail: { type: "boolean" }, webhookUrl: { type: "string" }, scenarioId: { type: "string" }, amoConnectionId: { type: "string" }, coreDelivery: { type: ["object", "null"], properties: { connectionId: { type: "string" }, kind: { type: "string" }, params: { type: "object", additionalProperties: { type: "string" } } } } } } }, required: ["siteId"] } },
|
|
444
637
|
{ 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"] } },
|
|
445
638
|
{ 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: {} } },
|
|
446
639
|
{ 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"] } },
|
|
447
640
|
{ 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"] } },
|
|
448
641
|
{ 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"] } },
|
|
642
|
+
// ---- Боты ----
|
|
643
|
+
{ 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"] } },
|
|
644
|
+
{ name: "bot_stop", description: "Остановить бота (POST /api/bots/{botId}/stop): снимает вебхук, бот перестаёт отвечать, сценарии и подписчики сохраняются. Запуск обратно — bot_resume.", inputSchema: { type: "object", properties: { botId: { type: "string" } }, required: ["botId"] } },
|
|
645
|
+
{ name: "bot_resume", description: "Запустить остановленного бота или бота, приостановленного лимитом тарифа (POST /api/bots/{botId}/resume). Если лимит ботов тарифа исчерпан — HTTP 402 со ссылкой на смену тарифа.", inputSchema: { type: "object", properties: { botId: { type: "string" } }, required: ["botId"] } },
|
|
646
|
+
// ---- Подключения ----
|
|
647
|
+
{ 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. Креды хранятся в сервисе зашифрованными и НИКОГДА не возвращаются — ни здесь, ни в list_integrations (только маска hint); в отчёты не попадают. Проверить ключ после подключения — integration_test.", 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"] } },
|
|
648
|
+
{ name: "disconnect_integration", description: "Удалить подключение сервиса по id из list_integrations (DELETE /api/bots/integrations/{id}). Действия сценария с этим connectionId перестанут работать.", inputSchema: { type: "object", properties: { connectionId: { type: "string" } }, required: ["connectionId"] } },
|
|
649
|
+
// ---- Рассылки ----
|
|
650
|
+
{ 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" } } } },
|
|
651
|
+
{ name: "broadcast_get", description: "Рассылка целиком по id (GET /api/bots/broadcasts/{id}): статус, счётчики отправки, фильтр аудитории, сообщения, время. Read-only.", inputSchema: { type: "object", properties: { broadcastId: { type: "string" } }, required: ["broadcastId"] } },
|
|
652
|
+
{ 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" } } } } },
|
|
653
|
+
{ name: "broadcast_send", description: "Отправить рассылку сейчас или запланировать (scheduledAt). " +
|
|
654
|
+
"Прямая (по умолчанию, POST /api/bots/broadcasts/direct): name, botIds[] (1–20 ботов ОДНОГО владельца; по каждому создаётся своя рассылка), messages[] (1–5), tagsAll?/tagsNone? (фильтр по тегам). " +
|
|
655
|
+
"Сообщение: {type, text?, mediaUrl?, mediaUrls?, buttons?}; type: TEXT (text обязателен, до 4096) · PHOTO | VIDEO | AUDIO | FILE | VOICE (mediaUrl обязателен, text — подпись до 1024) · VIDEONOTE (кружок: mediaUrl, без текста) · GALLERY (mediaUrls: 2–10 картинок, подпись, БЕЗ кнопок). " +
|
|
656
|
+
"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. " +
|
|
657
|
+
"По сценарию (graphId): POST /api/bots/{botId}/broadcasts — один botId, сценарий самого бота, entryNodeId? — с какого узла начать. " +
|
|
658
|
+
"draftId — отправить черновик (поля черновика, переданные аргументы их перекрывают); после отправки черновик удаляется. " +
|
|
659
|
+
"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" } } } },
|
|
660
|
+
{ name: "broadcast_cancel", description: "Отменить рассылку (POST /api/bots/broadcasts/{id}/cancel): запланированная не уйдёт, идущая остановится (статус CANCELLING → CANCELLED). Уже завершённую отменить нельзя — HTTP 409.", inputSchema: { type: "object", properties: { broadcastId: { type: "string" } }, required: ["broadcastId"] } },
|
|
661
|
+
{ 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"] } },
|
|
662
|
+
{ 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"] } },
|
|
663
|
+
{ 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"] } },
|
|
664
|
+
// ---- Сайты: библиотека шаблонов ----
|
|
665
|
+
{ 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" } } } },
|
|
449
666
|
];
|
|
450
667
|
|
|
451
668
|
async function handleCall(params) {
|
|
@@ -478,7 +695,26 @@ async function handleCall(params) {
|
|
|
478
695
|
case "list_bots": return okResult(await api("/api/bots"));
|
|
479
696
|
case "list_graphs": return okResult(await api(`/api/bots/${a.botId}/graphs`));
|
|
480
697
|
case "list_channels": return okResult(await api(`/api/bots/${a.botId}/linked-chats`));
|
|
481
|
-
case "list_integrations": return okResult(await api("/api/bots/integrations"));
|
|
698
|
+
case "list_integrations": return okResult(withoutSecrets(await api("/api/bots/integrations")));
|
|
699
|
+
case "integration_catalog": return okResult(withoutSecrets(await api("/api/integrations/catalog")));
|
|
700
|
+
case "integration_status":
|
|
701
|
+
case "integration_test": {
|
|
702
|
+
const id = String(a.connectionId || "").trim();
|
|
703
|
+
if (!id) throw new Error("Передай connectionId — id подключения из list_integrations.");
|
|
704
|
+
const live = params.name === "integration_test";
|
|
705
|
+
const p_ = `/api/integrations/${encodeURIComponent(id)}/${live ? "test" : "status"}`;
|
|
706
|
+
return okResult(withoutSecrets(await api(p_, live ? { method: "POST" } : undefined)));
|
|
707
|
+
}
|
|
708
|
+
case "channel_post": {
|
|
709
|
+
if (!a.botId) throw new Error("Передай botId (list_bots).");
|
|
710
|
+
const chatId = Number(a.chatId);
|
|
711
|
+
if (!Number.isSafeInteger(chatId)) throw new Error("chatId — числовой id канала из list_channels (например -1001234567890).");
|
|
712
|
+
const text = a.text == null ? "" : String(a.text);
|
|
713
|
+
if (!text.trim() && !a.mediaUrl) throw new Error("Пустой пост: передай text и/или mediaUrl (файл из upload_file).");
|
|
714
|
+
const body = { text };
|
|
715
|
+
if (a.mediaUrl) body.mediaUrl = String(a.mediaUrl);
|
|
716
|
+
return okResult(await api(`/api/bots/${encodeURIComponent(a.botId)}/linked-chats/${chatId}/post`, { method: "POST", body }));
|
|
717
|
+
}
|
|
482
718
|
case "get_graph": {
|
|
483
719
|
const g = await api(`/api/bots/graphs/${a.graphId}`);
|
|
484
720
|
if (a.saveToFile) {
|
|
@@ -673,6 +909,28 @@ async function handleCall(params) {
|
|
|
673
909
|
if (a.size != null) qs.push(`size=${encodeURIComponent(a.size)}`);
|
|
674
910
|
return okResult(await api(`/api/bots/pages/${a.siteId}/leads${qs.length ? `?${qs.join("&")}` : ""}`));
|
|
675
911
|
}
|
|
912
|
+
case "site_rollback": {
|
|
913
|
+
if (typeof a.revision !== "number") throw new Error("Передай revision — номер публикации (site_get → versions[]).");
|
|
914
|
+
const r = await api(`/api/bots/pages/${a.siteId}/publish/rollback`, { method: "POST", body: { revision: a.revision } });
|
|
915
|
+
return okResult({ publishedRevision: r?.publishedRevision, url: r?.page?.url });
|
|
916
|
+
}
|
|
917
|
+
case "site_domains": {
|
|
918
|
+
const base = `/api/bots/pages/${a.siteId}/domains`;
|
|
919
|
+
if (a.action === "list") return okResult(await api(base));
|
|
920
|
+
if (a.action === "add") {
|
|
921
|
+
if (!a.host) throw new Error("Передай host — домен, например example.ru.");
|
|
922
|
+
return okResult(await api(base, { method: "POST", body: { host: a.host, withWww: !!a.withWww } }));
|
|
923
|
+
}
|
|
924
|
+
if (!a.domainId) throw new Error("Передай domainId (site_domains action=list).");
|
|
925
|
+
if (a.action === "check") return okResult(await api(`${base}/${a.domainId}/check`, { method: "POST" }));
|
|
926
|
+
if (a.action === "remove") return okResult(await api(`${base}/${a.domainId}`, { method: "DELETE" }));
|
|
927
|
+
throw new Error("action: list | add | check | remove.");
|
|
928
|
+
}
|
|
929
|
+
case "site_lead_settings": {
|
|
930
|
+
const p_ = `/api/bots/pages/${a.siteId}/lead-settings`;
|
|
931
|
+
if (a.settings == null) return okResult(withoutSecrets(await api(p_)));
|
|
932
|
+
return okResult(withoutSecrets(await api(p_, { method: "PUT", body: a.settings })));
|
|
933
|
+
}
|
|
676
934
|
case "article_list": return okResult(await api("/api/articles/my"));
|
|
677
935
|
case "article_get": return okResult(await api(`/api/articles/by-slug/${encodeURIComponent(a.slug)}`));
|
|
678
936
|
case "article_publish": {
|
|
@@ -686,6 +944,188 @@ async function handleCall(params) {
|
|
|
686
944
|
const updated = await api(`/api/articles/${a.id}`, { method: "PUT", body: { title: a.title, content: a.content } });
|
|
687
945
|
return okResult({ ...updated, publicUrl: updated?.slug ? `${BASE}/articles/${updated.slug}` : null });
|
|
688
946
|
}
|
|
947
|
+
// ---- Боты ----
|
|
948
|
+
case "create_bot": {
|
|
949
|
+
const platform = String(a.platform || "").trim().toUpperCase();
|
|
950
|
+
if (platform === "INSTAGRAM") return instagramAnswer();
|
|
951
|
+
if (platform !== "TELEGRAM" && platform !== "MAX") throw new Error("platform: TELEGRAM | MAX (Instagram подключается только в кабинете).");
|
|
952
|
+
const token = String(a.token || "").trim();
|
|
953
|
+
if (!token) {
|
|
954
|
+
return okResult(platform === "MAX"
|
|
955
|
+
? "Нужен токен MAX-бота: создай бота у MasterBot в MAX и пришли токен — я подключу его (create_bot {platform:\"MAX\", token})."
|
|
956
|
+
: "Нужен токен Telegram-бота: открой https://t.me/BotFather → /newbot (или /token для существующего) и пришли токен вида 123456789:AA… — я подключу его (create_bot {platform:\"TELEGRAM\", token}).");
|
|
957
|
+
}
|
|
958
|
+
let bot;
|
|
959
|
+
try {
|
|
960
|
+
bot = await api("/api/bots", { method: "POST", body: { token, platform } });
|
|
961
|
+
} catch (e) {
|
|
962
|
+
// 400 приходит строкой-причиной (неверный токен, бот уже подключён) — её и показываем.
|
|
963
|
+
if (e.status === 400) throw new Error(`Бот не подключён: ${typeof e.data === "string" && e.data ? e.data : "токен не принят"}. Проверь токен (${platform === "MAX" ? "MasterBot в MAX" : "@BotFather → /token"}).`);
|
|
964
|
+
throw e;
|
|
965
|
+
}
|
|
966
|
+
if (a.name && bot?.id) {
|
|
967
|
+
try { bot = await api(`/api/bots/${bot.id}`, { method: "PATCH", body: { name: a.name } }); }
|
|
968
|
+
catch (e) { return okResult({ ...bot, warning: `Бот подключён, но имя не задано: ${(e.message || "").split("\n")[0]}` }); }
|
|
969
|
+
}
|
|
970
|
+
return okResult(bot);
|
|
971
|
+
}
|
|
972
|
+
case "bot_stop": return okResult(await api(`/api/bots/${a.botId}/stop`, { method: "POST" }));
|
|
973
|
+
case "bot_resume": return okResult(await api(`/api/bots/${a.botId}/resume`, { method: "POST" }));
|
|
974
|
+
// ---- Подключения ----
|
|
975
|
+
case "connect_integration": {
|
|
976
|
+
const provider = normProvider(a.provider);
|
|
977
|
+
if (provider === "INSTAGRAM") return instagramAnswer();
|
|
978
|
+
if (provider === "TELEGRAM" || provider === "MAX") {
|
|
979
|
+
return okResult(`${provider === "MAX" ? "MAX" : "Telegram"} подключается как бот, а не интеграция: вызови create_bot {platform:"${provider}", token}.`);
|
|
980
|
+
}
|
|
981
|
+
if (provider === "GOOGLE_SHEETS") {
|
|
982
|
+
const r = await api(`/api/bots/google/auth-url${qs({ returnPath: "/bots/integrations" })}`, { method: "POST" });
|
|
983
|
+
let connected = [];
|
|
984
|
+
try { const ids = await api("/api/bots/google/identities"); connected = Array.isArray(ids) ? ids : []; } catch { /* список не обязателен */ }
|
|
985
|
+
if (!r?.authUrl) throw new Error(`Сервер не вернул ссылку авторизации Google. Подключи в кабинете: ${INTEGRATIONS_PAGE}`);
|
|
986
|
+
return linkResult("Google Таблицы: вход через Google (OAuth)", r.authUrl,
|
|
987
|
+
"Открой ссылку, выбери Google-аккаунт и разреши доступ к таблицам — затем таблица выбирается в узле сценария «Google Таблицы». Ссылка одноразовая и живёт недолго: если истекла, вызови connect_integration ещё раз.",
|
|
988
|
+
{ connectedGoogleAccounts: connected });
|
|
989
|
+
}
|
|
990
|
+
const spec = PROVIDER_FIELDS[provider];
|
|
991
|
+
if (!spec) throw new Error(`Неизвестный provider «${a.provider}». Бывают: ${Object.keys(PROVIDER_FIELDS).join(", ")}, GOOGLE_SHEETS, INSTAGRAM. Каталог: ${CONNECT_PAGE}`);
|
|
992
|
+
const creds = a.creds && typeof a.creds === "object" ? Object.fromEntries(
|
|
993
|
+
Object.entries(a.creds).filter(([, v]) => v != null && String(v).trim() !== "").map(([k, v]) => [k, String(v).trim()])) : {};
|
|
994
|
+
const missing = Object.keys(spec.fields).filter((k) => !creds[k]);
|
|
995
|
+
if (missing.length && (!a.connectionId || Object.keys(creds).length)) {
|
|
996
|
+
return okResult({
|
|
997
|
+
connected: false,
|
|
998
|
+
provider,
|
|
999
|
+
need: Object.fromEntries(missing.map((k) => [k, spec.fields[k]])),
|
|
1000
|
+
instruction: `Для ${spec.name} не хватает полей creds: ${missing.join(", ")}. Попроси их у пользователя и вызови connect_integration ещё раз. Или пусть подключит сам в кабинете: ${CONNECT_PAGE}`,
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
const title = a.title || spec.name;
|
|
1004
|
+
try {
|
|
1005
|
+
const saved = a.connectionId
|
|
1006
|
+
? await api(`/api/bots/integrations/${a.connectionId}`, { method: "PUT", body: { title: a.title, creds: Object.keys(creds).length ? creds : undefined } })
|
|
1007
|
+
: await api("/api/bots/integrations", { method: "POST", body: { provider, title, creds } });
|
|
1008
|
+
return okResult({ connected: true, connectionId: saved?.id, ...withoutSecrets(saved), note: "Креды сохранены зашифрованными и обратно не отдаются (только маска hint).", usage: "connectionId ставь в действия сценария (amocrm_send, bitrix24_call, getcourse_send, yametrika_event, оплата ЮKassa) и в site_lead_settings (amoConnectionId или coreDelivery.connectionId). Проверить ключ — integration_test." });
|
|
1009
|
+
} catch (e) {
|
|
1010
|
+
if (e.status === 400) throw new Error(`${spec.name} не подключён: ${bodyReason(e.data) || "креды не приняты"}. Проверь поля: ${Object.entries(spec.fields).map(([k, v]) => `${k} — ${v}`).join("; ")}.`);
|
|
1011
|
+
throw e;
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
case "disconnect_integration":
|
|
1015
|
+
await api(`/api/bots/integrations/${a.connectionId}`, { method: "DELETE" });
|
|
1016
|
+
return okResult(`🗑️ Подключение ${a.connectionId} удалено.`);
|
|
1017
|
+
// ---- Рассылки ----
|
|
1018
|
+
case "broadcast_list": {
|
|
1019
|
+
if (a.botId) return okResult(await api(`/api/bots/${a.botId}/broadcasts`));
|
|
1020
|
+
const page = await api(`/api/bots/broadcasts${qs({ page: a.page, size: a.size, group: a.group })}`);
|
|
1021
|
+
let counts = null;
|
|
1022
|
+
try { counts = await api("/api/bots/broadcasts/counts"); } catch { /* счётчики не обязательны */ }
|
|
1023
|
+
return okResult({ counts, page });
|
|
1024
|
+
}
|
|
1025
|
+
case "broadcast_get": return okResult(await api(`/api/bots/broadcasts/${a.broadcastId}`));
|
|
1026
|
+
case "broadcast_preview": {
|
|
1027
|
+
const ids = botIdsOf(a);
|
|
1028
|
+
await assertBroadcastBots(ids);
|
|
1029
|
+
const filter = { tagsAll: strList(a.tagsAll), tagsNone: strList(a.tagsNone) };
|
|
1030
|
+
const perBot = [];
|
|
1031
|
+
for (const id of ids) {
|
|
1032
|
+
const r = await api(`/api/bots/${id}/broadcasts/preview`, { method: "POST", body: filter });
|
|
1033
|
+
perBot.push({ botId: id, count: r?.count ?? null, limit: r?.limit ?? null });
|
|
1034
|
+
}
|
|
1035
|
+
return okResult({ total: perBot.reduce((s, r) => s + (Number(r.count) || 0), 0), perBot, filter });
|
|
1036
|
+
}
|
|
1037
|
+
case "broadcast_send": {
|
|
1038
|
+
if (a.graphId) {
|
|
1039
|
+
const ids = botIdsOf(a);
|
|
1040
|
+
if (ids.length !== 1) throw new Error("Рассылка по сценарию — ровно один botId (сценарий принадлежит боту).");
|
|
1041
|
+
await assertBroadcastBots(ids);
|
|
1042
|
+
if (!a.name) throw new Error("Передай name рассылки.");
|
|
1043
|
+
const b = await api(`/api/bots/${ids[0]}/broadcasts`, { method: "POST", body: {
|
|
1044
|
+
name: a.name, audienceFilter: { tagsAll: strList(a.tagsAll), tagsNone: strList(a.tagsNone) },
|
|
1045
|
+
graphId: a.graphId, entryNodeId: a.entryNodeId || undefined, scheduledAt: toInstant(a.scheduledAt, "scheduledAt") } });
|
|
1046
|
+
return okResult({ broadcastIds: [b?.id], status: b?.status, scheduledAt: b?.scheduledAt ?? null, broadcast: b });
|
|
1047
|
+
}
|
|
1048
|
+
let draft = null;
|
|
1049
|
+
if (a.draftId) draft = await api(`/api/bots/broadcasts/drafts/${a.draftId}`);
|
|
1050
|
+
const pick = (k) => (a[k] !== undefined ? a[k] : draft?.[k]);
|
|
1051
|
+
const ids = botIdsOf({ botIds: a.botIds ?? (a.botId ? undefined : draft?.botIds), botId: a.botId });
|
|
1052
|
+
await assertBroadcastBots(ids);
|
|
1053
|
+
const name = String(pick("name") || "").trim();
|
|
1054
|
+
if (!name) throw new Error("Передай name рассылки (видно только тебе в списке рассылок).");
|
|
1055
|
+
const body = {
|
|
1056
|
+
name, botIds: ids,
|
|
1057
|
+
tagsAll: strList(a.tagsAll !== undefined ? a.tagsAll : draft?.audienceFilter?.tagsAll),
|
|
1058
|
+
tagsNone: strList(a.tagsNone !== undefined ? a.tagsNone : draft?.audienceFilter?.tagsNone),
|
|
1059
|
+
messages: normalizeBroadcastMessages(pick("messages")),
|
|
1060
|
+
scheduledAt: toInstant(pick("scheduledAt"), "scheduledAt"),
|
|
1061
|
+
};
|
|
1062
|
+
if (body.scheduledAt && new Date(body.scheduledAt).getTime() <= Date.now()) {
|
|
1063
|
+
// Прошедшее время бэкенд молча считает «сейчас» — для черновика с устаревшей датой это сюрприз.
|
|
1064
|
+
if (a.scheduledAt !== undefined) throw new Error(`scheduledAt ${body.scheduledAt} уже прошло — укажи будущее время или не передавай его (отправить сейчас).`);
|
|
1065
|
+
body.scheduledAt = null;
|
|
1066
|
+
}
|
|
1067
|
+
const r = await api("/api/bots/broadcasts/direct", { method: "POST", body });
|
|
1068
|
+
let draftDeleted = false;
|
|
1069
|
+
if (a.draftId) { try { await api(`/api/bots/broadcasts/drafts/${a.draftId}`, { method: "DELETE" }); draftDeleted = true; } catch { /* не критично */ } }
|
|
1070
|
+
return okResult({ ...r, scheduledAt: body.scheduledAt, ...(a.draftId ? { draftDeleted } : {}) });
|
|
1071
|
+
}
|
|
1072
|
+
case "broadcast_cancel":
|
|
1073
|
+
await api(`/api/bots/broadcasts/${a.broadcastId}/cancel`, { method: "POST" });
|
|
1074
|
+
return okResult(`⏹️ Рассылка ${a.broadcastId} отменяется (CANCELLING → CANCELLED).`);
|
|
1075
|
+
case "broadcast_recurring": {
|
|
1076
|
+
const base = "/api/bots/broadcasts/recurring";
|
|
1077
|
+
if (a.action === "list") return okResult(await api(base));
|
|
1078
|
+
if (a.action === "stop") {
|
|
1079
|
+
if (!a.ruleId) throw new Error("Передай ruleId (broadcast_recurring action=list).");
|
|
1080
|
+
await api(`${base}/${a.ruleId}/stop`, { method: "POST" });
|
|
1081
|
+
return okResult(`⏹️ Правило ${a.ruleId} остановлено — новых прогонов не будет.`);
|
|
1082
|
+
}
|
|
1083
|
+
if (a.action === "create") {
|
|
1084
|
+
const ids = botIdsOf(a);
|
|
1085
|
+
await assertBroadcastBots(ids);
|
|
1086
|
+
if (!a.name) throw new Error("Передай name.");
|
|
1087
|
+
const recurrence = String(a.recurrence || "").toUpperCase();
|
|
1088
|
+
if (!["DAILY", "MONTHLY", "YEARLY"].includes(recurrence)) throw new Error("recurrence: DAILY | MONTHLY | YEARLY.");
|
|
1089
|
+
const firstRunAt = toInstant(a.firstRunAt, "firstRunAt");
|
|
1090
|
+
if (!firstRunAt || new Date(firstRunAt).getTime() <= Date.now()) throw new Error("firstRunAt — время первого запуска в будущем (ISO 8601, без пояса — московское).");
|
|
1091
|
+
return okResult(await api(base, { method: "POST", body: {
|
|
1092
|
+
name: a.name, botIds: ids, tagsAll: strList(a.tagsAll), tagsNone: strList(a.tagsNone),
|
|
1093
|
+
messages: normalizeBroadcastMessages(a.messages), recurrence, firstRunAt } }));
|
|
1094
|
+
}
|
|
1095
|
+
throw new Error("action: list | create | stop.");
|
|
1096
|
+
}
|
|
1097
|
+
case "broadcast_drafts": {
|
|
1098
|
+
const base = "/api/bots/broadcasts/drafts";
|
|
1099
|
+
if (a.action === "list") return okResult(await api(base));
|
|
1100
|
+
if (a.action === "create" || a.action === "update") {
|
|
1101
|
+
if (a.action === "update" && !a.draftId) throw new Error("Передай draftId (broadcast_drafts action=list).");
|
|
1102
|
+
// update: PUT заменяет черновик целиком — непереданные поля берём из текущего, чтобы правка текста не стёрла ботов.
|
|
1103
|
+
const cur = a.action === "update" ? (await api(`${base}/${a.draftId}`)) || {} : {};
|
|
1104
|
+
const has = (k) => a[k] !== undefined;
|
|
1105
|
+
const body = {
|
|
1106
|
+
name: has("name") ? a.name : cur.name,
|
|
1107
|
+
botIds: has("botIds") || has("botId") ? botIdsOf(a) : (cur.botIds || []),
|
|
1108
|
+
tagsAll: strList(has("tagsAll") ? a.tagsAll : cur.audienceFilter?.tagsAll),
|
|
1109
|
+
tagsNone: strList(has("tagsNone") ? a.tagsNone : cur.audienceFilter?.tagsNone),
|
|
1110
|
+
messages: normalizeBroadcastMessages(has("messages") ? a.messages : cur.messages, false),
|
|
1111
|
+
scheduledAt: toInstant(has("scheduledAt") ? a.scheduledAt : cur.scheduledAt, "scheduledAt"),
|
|
1112
|
+
};
|
|
1113
|
+
if (a.action === "create") return okResult(await api(base, { method: "POST", body }));
|
|
1114
|
+
return okResult(await api(`${base}/${a.draftId}`, { method: "PUT", body }));
|
|
1115
|
+
}
|
|
1116
|
+
if (!a.draftId) throw new Error("Передай draftId (broadcast_drafts action=list).");
|
|
1117
|
+
if (a.action === "get") return okResult(await api(`${base}/${a.draftId}`));
|
|
1118
|
+
if (a.action === "delete") { await api(`${base}/${a.draftId}`, { method: "DELETE" }); return okResult(`🗑️ Черновик ${a.draftId} удалён.`); }
|
|
1119
|
+
throw new Error("action: list | get | create | update | delete.");
|
|
1120
|
+
}
|
|
1121
|
+
case "broadcast_duplicate":
|
|
1122
|
+
return okResult(await api(`/api/bots/broadcasts/${a.broadcastId}/duplicate`, { method: "POST" }));
|
|
1123
|
+
case "site_templates": {
|
|
1124
|
+
const r = await api("/api/bots/pages/templates");
|
|
1125
|
+
let templates = Array.isArray(r?.templates) ? r.templates : [];
|
|
1126
|
+
if (a.category) templates = templates.filter((t) => t?.category === a.category);
|
|
1127
|
+
return okResult({ categories: r?.categories ?? [], templates });
|
|
1128
|
+
}
|
|
689
1129
|
default:
|
|
690
1130
|
throw new Error(`Неизвестный инструмент: ${params && params.name}`);
|
|
691
1131
|
}
|