@retensy/mcp 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +2 -2
- package/README.md +72 -6
- package/package.json +6 -3
- package/skills/build-site/SKILL.md +41 -2
- package/skills/send-broadcast/SKILL.md +48 -0
- package/src/index.mjs +411 -8
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
{
|
|
7
7
|
"name": "retensy-mcp",
|
|
8
8
|
"source": "./",
|
|
9
|
-
"description": "Сборка и публикация воронок ботов (Telegram/MAX
|
|
9
|
+
"description": "Сборка и публикация воронок ботов (Telegram/MAX, Retensy Bots) из описания, рассылки, подключение ботов и сервисов, сайтов из блоков + публикация статей блога в Markdown — через MCP + скилл."
|
|
10
10
|
}
|
|
11
11
|
]
|
|
12
12
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "retensy-mcp",
|
|
3
3
|
"displayName": "Retensy MCP",
|
|
4
|
-
"version": "0.
|
|
5
|
-
"description": "MCP-сервер + скилл для Retensy Bots: сборка и публикация воронок ботов (Telegram/MAX
|
|
4
|
+
"version": "0.14.0",
|
|
5
|
+
"description": "MCP-сервер + скилл для Retensy Bots: подключение ботов и сервисов, сборка и публикация воронок ботов (Telegram/MAX), рассылки, сайты из блоков (Zero-блок, дизайны, шаблоны, свои домены, заявки) и статьи блога в Markdown — всё одним токеном zmcp_.",
|
|
6
6
|
"author": { "name": "retensy", "url": "https://bots.retensy.com" },
|
|
7
7
|
"homepage": "https://bots.retensy.com/bots",
|
|
8
8
|
"repository": "https://github.com/retensy/retensy-mcp",
|
package/README.md
CHANGED
|
@@ -7,9 +7,13 @@
|
|
|
7
7
|
|
|
8
8
|
MCP-сервер (+ скилл для Claude Code) для **сборки и публикации воронок/автоматизаций ботов (Telegram, MAX и Instagram)** в сервисе [retensy `/bots`](https://bots.retensy.com/bots): из текстового описания → валидный граф сценария → заливка и публикация через API.
|
|
9
9
|
|
|
10
|
-
- 🤖
|
|
10
|
+
- 🤖 **Сценарии ботов**: `list_bots`, `list_graphs`, `list_channels`, `get_graph`, `create_graph`, `update_graph`, `edit_graph_live`, `patch_graph`, `dry_run`, `publish_graph`, `import_funnel`, `list_templates`, `create_graph_from_template`, `clone_graph`, `copy_graph`, `rename_graph`, `set_active_graph`, `delete_graph`, `upload_file`, `list_files`, `delete_file`, `graph_analytics`, `list_bot_users`, `list_links` (+ `setup`/`set_token`).
|
|
11
|
+
- 🔌 **Боты и сервисы**: `create_bot` (Telegram/MAX по токену), `bot_stop`/`bot_resume`, `connect_integration`/`disconnect_integration` (amoCRM, Битрикс24, GetCourse, Я.Метрика, ЮKassa; Google Таблицы — ссылкой на вход Google).
|
|
12
|
+
- 📣 **Рассылки**: `broadcast_preview` (размер аудитории), `broadcast_send` (сейчас или по расписанию, сразу по нескольким ботам; сообщения или запуск сценария), `broadcast_list`/`broadcast_get`/`broadcast_cancel`, повторяющиеся (`broadcast_recurring`), черновики (`broadcast_drafts`, `broadcast_duplicate`) + скилл `send-broadcast`.
|
|
13
|
+
- 🔗 **Ссылка вместо отказа**: что нельзя сделать через API (вход через Google/Facebook, оплата тарифа, вход в аккаунт) — инструмент возвращает прямую ссылку и одну строку, что сделать.
|
|
11
14
|
- 📝 **Статьи блога** (тот же токен `zmcp_…`): `article_publish`, `article_update`, `article_list`, `article_get` — публикация статей в Markdown (как README на GitHub) в раздел **/articles**.
|
|
12
15
|
- 📎 **Медиа**: `upload_file` грузит фото/видео/документы в библиотеку **/bots/files** (до 50 МБ) и возвращает публичный URL — его вставляешь в медиа-карточку сценария.
|
|
16
|
+
- 🌐 **Сайты из блоков** (раздел «Страницы»): создание, правка операциями (блоки, Zero-блок со свободной вёрсткой, код блока, папки страниц, дизайны, шаблоны из библиотеки), публикация и откат, свои домены, заявки из форм и куда их доставлять — инструменты `site_*` + скилл `build-site`.
|
|
13
17
|
- 🧠 **Скилл `build-bot-funnel`**: учит агента собирать корректный граф (типы узлов, ветки, кнопки, задержки) и проверять его перед публикацией. Поддерживает Telegram, MAX и Instagram.
|
|
14
18
|
- 📦 **Без зависимостей** — чистый Node ≥18, ставится и запускается сразу.
|
|
15
19
|
|
|
@@ -17,9 +21,9 @@ MCP-сервер (+ скилл для Claude Code) для **сборки и пу
|
|
|
17
21
|
|
|
18
22
|
| Платформа | Онбординг | Триггеры входа | Ограничения |
|
|
19
23
|
|---|---|---|---|
|
|
20
|
-
| **Telegram** | Токен бота (BotFather) | `/start`, команды, callback, текст, рассылки | Полный функционал |
|
|
21
|
-
| **MAX** | Токен бота (MAX
|
|
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,13 @@ MCP-сервер (+ скилл для Claude Code) для **сборки и пу
|
|
|
92
96
|
| `setup` | статус авторизации + пошаговая инструкция подключения |
|
|
93
97
|
| `set_token` | сохранить присланный токен `zmcp_…` (без env/рестарта) |
|
|
94
98
|
| `list_bots` | список ботов |
|
|
99
|
+
| `create_bot(platform, token, name?)` | подключить бота Telegram/MAX по токену (Instagram → ссылка на кабинет) |
|
|
100
|
+
| `bot_stop(botId)` / `bot_resume(botId)` | остановить / запустить бота |
|
|
95
101
|
| `list_graphs(botId)` | графы (сценарии) бота |
|
|
96
102
|
| `list_channels(botId)` | каналы/группы, подключённые к боту (chatId для условия SUBSCRIBED) |
|
|
97
|
-
| `list_integrations()` | подключённые сервисы (amoCRM, Битрикс24, GetCourse,
|
|
103
|
+
| `list_integrations()` | подключённые сервисы (amoCRM, Битрикс24, GetCourse, Я.Метрика, ЮKassa): `id` = `connectionId` для действий сценария |
|
|
104
|
+
| `connect_integration(provider, creds?, title?, connectionId?)` | подключить/обновить сервис; без кредов — какие поля нужны; Google Таблицы → ссылка входа Google, Instagram → ссылка на кабинет |
|
|
105
|
+
| `disconnect_integration(connectionId)` | удалить подключение |
|
|
98
106
|
| `get_graph(graphId, [summary], [saveToFile])` | получить граф; `summary:true` — компактная сводка (id/type/title + рёбра), `saveToFile` — записать полный JSON на диск (для больших графов, чтобы не упереться в лимит токенов) |
|
|
99
107
|
| `create_graph(botId, name)` | создать пустой граф (DRAFT) |
|
|
100
108
|
| `update_graph(graphId, graphFile\|graph\|nodes,edges)` | залить узлы/рёбра (PUT); `graphFile` — путь к локальному JSON, граф не нужно слать инлайном |
|
|
@@ -115,11 +123,69 @@ MCP-сервер (+ скилл для Claude Code) для **сборки и пу
|
|
|
115
123
|
| `graph_analytics(graphId)` | прохождение сценария по узлам (где отваливается воронка) |
|
|
116
124
|
| `list_bot_users(botId)` | подписчики/лиды бота (постранично, поиск `query`) |
|
|
117
125
|
| `list_links(botId)` | стартовые трекинговые ссылки бота с UTM |
|
|
126
|
+
| `site_list()` | сайты пользователя (id, mode, url, publishedRevision) |
|
|
127
|
+
| `site_create(title, slug?)` | новый сайт из блоков → `id` |
|
|
128
|
+
| `site_get(siteId, saveToFile?)` | модель сайта (`revision`, `draft`, `versions[]` публикаций) |
|
|
129
|
+
| `site_schema()` | JSON Schema модели и операций — читать перед правкой |
|
|
130
|
+
| `site_edit(siteId, ops[], revision?, init?)` | правка операциями, всё или ничего: страницы и папки, блоки, Zero-элементы, код блока (`get/set/add_block_code`), дизайны и их кадры, шаблоны (`add_template`), тема, попапы |
|
|
131
|
+
| `site_templates(category?, full?)` | библиотека шаблонов блоков для `add_template` |
|
|
132
|
+
| `site_publish(siteId)` | опубликовать черновик → `url` |
|
|
133
|
+
| `site_rollback(siteId, revision)` | вернуть прошлую публикацию |
|
|
134
|
+
| `site_upload_asset(siteId, path\|url)` | картинка/видео в сайт → `assets/…` |
|
|
135
|
+
| `site_domains(siteId, action, host?, withWww?, domainId?)` | свои домены: list / add / check / remove (число — по тарифу) |
|
|
136
|
+
| `site_leads(siteId, page?, size?)` | заявки из форм (поля, UTM, статус доставки) |
|
|
137
|
+
| `site_lead_settings(siteId, settings?)` | куда доставлять заявки: бот уведомлений, почта, вебхук, вебхук-сценарий, amoCRM |
|
|
138
|
+
| `broadcast_list(botId?, group?, page?, size?)` | рассылки + счётчики разделов (черновики/запланированные/отправленные/повторы) |
|
|
139
|
+
| `broadcast_get(broadcastId)` | рассылка целиком: статус, счётчики, сообщения |
|
|
140
|
+
| `broadcast_preview(botIds, tagsAll?, tagsNone?)` | сколько подписчиков получат рассылку |
|
|
141
|
+
| `broadcast_send(name, botIds, messages \| graphId, tagsAll?, tagsNone?, scheduledAt?, draftId?)` | отправить сейчас / запланировать; по нескольким ботам; или запуск сценария |
|
|
142
|
+
| `broadcast_cancel(broadcastId)` | отменить запланированную/идущую |
|
|
143
|
+
| `broadcast_recurring(action, …)` | повторяющиеся рассылки: list / create (DAILY·MONTHLY·YEARLY) / stop |
|
|
144
|
+
| `broadcast_drafts(action, …)` | черновики: list / get / create / update / delete |
|
|
145
|
+
| `broadcast_duplicate(broadcastId)` | копия рассылки как черновик |
|
|
118
146
|
| `article_list()` | свои статьи блога (id, slug, title, просмотры) |
|
|
119
147
|
| `article_get(slug)` | статья по slug (Markdown content, excerpt, обложка) |
|
|
120
148
|
| `article_publish(content, title?, cover?, excerpt?)` | новая статья (Markdown; title из `# ...`, если не задан; обложка из `cover`-URL или 1-й картинки → OG; `excerpt` явно или авто) → id, slug, URL |
|
|
121
149
|
| `article_update(id, content, title?)` | обновить свою статью по id |
|
|
122
150
|
|
|
151
|
+
### Рассылки
|
|
152
|
+
|
|
153
|
+
Сообщение рассылки — как в мастере кабинета: `{type, text?, mediaUrl?, mediaUrls?, buttons?}`, до 5 сообщений.
|
|
154
|
+
|
|
155
|
+
| type | Обязательно | Текст | Кнопки |
|
|
156
|
+
|---|---|---|---|
|
|
157
|
+
| `TEXT` | `text` (до 4096) | Telegram-HTML | до 8 URL-кнопок |
|
|
158
|
+
| `PHOTO` `VIDEO` `AUDIO` `FILE` `VOICE` | `mediaUrl` | подпись до 1024 | до 8 |
|
|
159
|
+
| `VIDEONOTE` (кружок) | `mediaUrl` | нет | до 8 |
|
|
160
|
+
| `GALLERY` | `mediaUrls` — 2–10 картинок | подпись до 1024 | нет |
|
|
161
|
+
|
|
162
|
+
- HTML: `<b> <i> <u> <s> <code> <pre> <blockquote> <tg-spoiler> <a href="https://…">`, перенос строки — `\n`.
|
|
163
|
+
- Кнопки только URL `[{text, url}]` — callback-кнопок в рассылке нет. Медиа — сначала `upload_file`, потом его `url`.
|
|
164
|
+
- Аудитория — подписчики бота, фильтр тегами: `tagsAll` (есть все), `tagsNone` (нет ни одного). До 50 000 на бота, до 20 ботов одного владельца за раз.
|
|
165
|
+
- `scheduledAt` — ISO 8601; без часового пояса считается московским. Пусто — отправить сейчас.
|
|
166
|
+
- Рассылки — на платном тарифе; квота получателей месячная. При нехватке — ошибка с прямой ссылкой на смену тарифа.
|
|
167
|
+
- У Instagram-ботов рассылок нет.
|
|
168
|
+
|
|
169
|
+
```json
|
|
170
|
+
{"name": "Распродажа", "botIds": ["<id>"], "tagsAll": ["клиент"], "scheduledAt": "2026-10-10T10:00",
|
|
171
|
+
"messages": ["<b>Только сегодня</b> — скидка 30%",
|
|
172
|
+
{"type": "PHOTO", "mediaUrl": "https://…/sale.jpg", "text": "Успей до полуночи",
|
|
173
|
+
"buttons": [{"text": "В магазин", "url": "https://shop.example"}]}]}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### Когда нужен браузер
|
|
177
|
+
|
|
178
|
+
Через API не делается то, что требует входа пользователя у стороннего сервиса, оплаты или входа в аккаунт.
|
|
179
|
+
Такие инструменты не падают, а возвращают `{needsBrowser: true, url, instruction}` или ошибку со ссылкой:
|
|
180
|
+
|
|
181
|
+
| Ситуация | Ссылка |
|
|
182
|
+
|---|---|
|
|
183
|
+
| нет токена / токен отозван | `/bots/mcp-tokens` — создать токен и прислать агенту |
|
|
184
|
+
| Google Таблицы (`connect_integration`) | одноразовая ссылка согласия Google (OAuth) |
|
|
185
|
+
| Instagram (`create_bot`, `connect_integration`) | `/bots/connect` — подключается входом через Facebook; сейчас выключен в сервисе |
|
|
186
|
+
| лимит тарифа, рассылки на бесплатном (HTTP 402) | `upgradeUrl` из ответа или `/bots/subscription` |
|
|
187
|
+
| свой домен сайта | DNS у регистратора: A-запись на `dnsTarget` из `site_domains` |
|
|
188
|
+
|
|
123
189
|
---
|
|
124
190
|
|
|
125
191
|
## Формат графа и проверка
|
package/package.json
CHANGED
|
@@ -1,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.14.0",
|
|
4
|
+
"description": "MCP server to build and publish Telegram and MAX bot funnels/automations, broadcasts, service integrations, block-based websites (incl. free-layout Zero blocks, designs, templates, custom domains, form leads) and Markdown blog articles in the Retensy Bots service. Zero dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
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",
|
|
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",
|
|
@@ -11,12 +11,16 @@ 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); свой домен — `site_domains` (`add` → пользователь ставит A-запись на `dnsTarget`
|
|
23
|
+
→ `check`); неудачная публикация — `site_rollback {revision}` из `site_get → versions[]`.
|
|
20
24
|
|
|
21
25
|
## Правила модели
|
|
22
26
|
|
|
@@ -29,6 +33,41 @@ description: Собрать сайт или лендинг из блоков в
|
|
|
29
33
|
- Цвета — `#rrggbb`. Шрифты: inter, montserrat, roboto, pt-sans, pt-serif, rubik, oswald.
|
|
30
34
|
- `revision` из ответа передавай в следующий `site_edit` — сервер не даст перезаписать правки из кабинета (409).
|
|
31
35
|
|
|
36
|
+
## Zero-блок — свободная вёрстка (как Zero Block в Tilda)
|
|
37
|
+
|
|
38
|
+
Блок `type: "zero"` — артборд, на котором элементы стоят по координатам. Для дизайнерских экранов, где готовых
|
|
39
|
+
блоков мало.
|
|
40
|
+
|
|
41
|
+
- Добавить: `add_block {container, type: "zero", props: {height: {d: 600, m: 720}}}` → id блока в `results`.
|
|
42
|
+
- Элементы: `add_element {blockId, kind, frame, props, style?, link?, anim?}`, `kind`: `text`, `image`, `button`,
|
|
43
|
+
`shape`, `video`, `html`, `group`. `frame` — по экранам: `{d: {x, y, w, h}, m?: {…}}`; `h: "auto"` у текста — высота
|
|
44
|
+
по содержимому. Нет `t`/`m` — кадр выводится из большего экрана в масштабе; задай `m`, если на телефоне нужна
|
|
45
|
+
другая раскладка. `container: "window"` + `axisX/axisY` — привязка к краям окна, а не к сетке.
|
|
46
|
+
- Правка — `update_element` (Merge Patch), порядок слоёв — `move_element {delta}`, группы — `group_elements` /
|
|
47
|
+
`ungroup_element`; у элемента в группе кадр — относительно группы (`parent`).
|
|
48
|
+
- Целиком переписать блок проще кодом: `get_block_code {blockId}` → в `results[i].code` разметка `<zero …>…</zero>`,
|
|
49
|
+
поправь и верни `set_block_code {blockId, code}`; новый блок из кода — `add_block_code {container, code}`. У
|
|
50
|
+
обычных блоков код — JSON `{type, variant, props, style}`. Тот же код пользователь видит во вкладке «Код» редактора.
|
|
51
|
+
|
|
52
|
+
## Папки, дизайны, шаблоны
|
|
53
|
+
|
|
54
|
+
- Папки страниц (для навигации в кабинете): `add_folder {name}` → id в `results`; `rename_folder {folderId, name}`,
|
|
55
|
+
`remove_folder {folderId}`. Страницу в папку — `update_page {pageId, patch: {folder: folderId}}`.
|
|
56
|
+
- Дизайны — отдельные экраны-макеты из Zero-кадров (не страницы сайта): `add_design {name}` → id;
|
|
57
|
+
`update_design {designId, name}`, `remove_design {designId}`. Кадр: `add_design_frame {designId, name, w, h}` →
|
|
58
|
+
в `results` `blockId` Zero-кадра — дальше на нём работают `add_element`/`update_element`/`get_block_code` и др.
|
|
59
|
+
- Шаблоны: `site_templates` → `{categories, templates: [{id, category, title, description?, blocks}]}` (`blocks` —
|
|
60
|
+
сколько блоков вставится). Вставка — `add_template {container, templateId, after?}`: блоки шаблона
|
|
61
|
+
встают в страницу/попап, дальше правь их тексты обычными `update_block`. Быстрее, чем собирать блоки с нуля.
|
|
62
|
+
|
|
63
|
+
## Тариф
|
|
64
|
+
|
|
65
|
+
- HTML-блок и HTML-элемент Zero публикуются только на платном тарифе: на бесплатном `site_publish` вернёт 422 с
|
|
66
|
+
путями этих элементов — замени их обычными блоками или предложи тариф.
|
|
67
|
+
- Число своих доменов ограничено тарифом (`site_domains add` → 402 со ссылкой на смену тарифа — передай её
|
|
68
|
+
пользователю, оплата только в браузере); www-пара корневого домена не в счёт.
|
|
69
|
+
- На бесплатном тарифе адрес на pages.retensy.com закрыт от поисковиков (noindex).
|
|
70
|
+
|
|
32
71
|
## Пример: лендинг кофейни с попапом заявки
|
|
33
72
|
|
|
34
73
|
```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.14.0";
|
|
31
31
|
const PKG_NAME = "@retensy/mcp";
|
|
32
32
|
const BASE = (process.env.RETENSY_BASE_URL || "https://bots.retensy.com").replace(/\/+$/, "");
|
|
33
33
|
const CONFIG_DIR = path.join(os.homedir(), ".retensy-bot-graph");
|
|
@@ -271,15 +271,56 @@ async function api(path_, { method = "GET", body } = {}) {
|
|
|
271
271
|
let data = null;
|
|
272
272
|
try { data = text ? JSON.parse(text) : null; } catch { data = text; }
|
|
273
273
|
if (!res.ok) {
|
|
274
|
-
if (res.status === 401
|
|
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,133 @@ 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
|
+
const CONNECT_PAGE = `${BASE}/bots/connect`;
|
|
546
|
+
const INTEGRATIONS_PAGE = `${BASE}/bots/integrations`;
|
|
547
|
+
/** Поля кредов — как форма кабинета (IntegrationsPage.tsx PROVIDER_FIELDS). */
|
|
548
|
+
const PROVIDER_FIELDS = {
|
|
549
|
+
AMOCRM: { name: "amoCRM", fields: { subdomain: "поддомен: acme из acme.amocrm.ru", longToken: "долгосрочный токен: amoCRM → Интеграции → ваша интеграция → Ключи и доступы" } },
|
|
550
|
+
BITRIX24: { name: "Битрикс24", fields: { webhookUrl: "URL входящего вебхука: Приложения → Разработчикам → Входящий вебхук (права: CRM)" } },
|
|
551
|
+
GETCOURSE: { name: "GetCourse", fields: { account: "аккаунт: school из school.getcourse.ru", apiKey: "секретный ключ: Настройки → API (показывается один раз)" } },
|
|
552
|
+
YAMETRIKA: { name: "Яндекс Метрика", fields: { counterId: "номер счётчика", oauthToken: "OAuth-токен Яндекса с доступом к загрузке офлайн-конверсий (выдаётся на oauth.yandex.ru)" } },
|
|
553
|
+
YOOKASSA: { name: "ЮKassa", fields: { shopId: "shopId магазина — число: ЮKassa → Настройки → Магазин", secretKey: "секретный ключ: ЮKassa → Интеграция → Ключи API (показывается один раз)" } },
|
|
554
|
+
};
|
|
555
|
+
const PROVIDER_ALIASES = {
|
|
556
|
+
amocrm: "AMOCRM", amo: "AMOCRM", bitrix24: "BITRIX24", bitrix: "BITRIX24", getcourse: "GETCOURSE",
|
|
557
|
+
yametrika: "YAMETRIKA", metrika: "YAMETRIKA", yandex_metrika: "YAMETRIKA", yandexmetrika: "YAMETRIKA",
|
|
558
|
+
yookassa: "YOOKASSA", yukassa: "YOOKASSA", ukassa: "YOOKASSA",
|
|
559
|
+
google_sheets: "GOOGLE_SHEETS", googlesheets: "GOOGLE_SHEETS", sheets: "GOOGLE_SHEETS", google: "GOOGLE_SHEETS",
|
|
560
|
+
instagram: "INSTAGRAM", telegram: "TELEGRAM", max: "MAX",
|
|
561
|
+
};
|
|
562
|
+
const normProvider = (p) => PROVIDER_ALIASES[String(p || "").trim().toLowerCase().replace(/[\s.-]+/g, "_")] || String(p || "").trim().toUpperCase();
|
|
563
|
+
|
|
564
|
+
/** Instagram подключается только входом через Facebook (OAuth) и сейчас выключен в сервисе (instagram.enabled). */
|
|
565
|
+
function instagramAnswer() {
|
|
566
|
+
return linkResult("Instagram: подключение через вход Facebook (OAuth) — сейчас выключено в сервисе", CONNECT_PAGE,
|
|
567
|
+
"Instagram-аккаунт нельзя подключить по API или токену: только входом через Facebook в кабинете. Сейчас подключение Instagram в retensy выключено (страница /bots/instagram ведёт на список ботов). Открой каталог подключений по ссылке — когда Instagram включат, он появится там. Пока доступны Telegram и MAX (create_bot).");
|
|
568
|
+
}
|
|
569
|
+
|
|
409
570
|
const TOOLS = [
|
|
410
571
|
{ name: "setup", description: "Показать статус авторизации и пошаговую инструкцию подключения. Вызывай первым, если пользователь не знает, что делать, или при ошибке доступа.", inputSchema: { type: "object", properties: {} } },
|
|
411
572
|
{ name: "set_token", description: "Сохранить персональный токен (zmcp_...), который пользователь создал на /bots/mcp-tokens. Применяется сразу, без рестарта.", inputSchema: { type: "object", properties: { token: { type: "string", description: "Секрет токена, начинается с zmcp_" } }, required: ["token"] } },
|
|
412
573
|
{ name: "list_bots", description: "Список ботов пользователя (id, имя, статус).", inputSchema: { type: "object", properties: {} } },
|
|
413
574
|
{ name: "list_graphs", description: "Список сценариев САМОГО бота (без узлов). Вебхук-сценарии, которые лишь отвечают через этого бота, сюда не входят — их публикуют в вебе, в «Сценариях» автора.", inputSchema: { type: "object", properties: { botId: { type: "string" } }, required: ["botId"] } },
|
|
414
575
|
{ name: "list_channels", description: "Список каналов/групп, подключённых к боту (chatId, title, type, статус бота, дата). chatId — числовой id для условия SUBSCRIBED («Подписан на канал»).", inputSchema: { type: "object", properties: { botId: { type: "string" } }, required: ["botId"] } },
|
|
415
|
-
{ name: "list_integrations", description: "Список подключённых сервисов пользователя (GET /api/bots/integrations): {id, provider, title, hint, createdAt}. **id отсюда — это `connectionId`**, обязательное поле действий amocrm_send/amocrm_update/bitrix24_call/getcourse_send/getcourse_order/yametrika_event. Без него действие упадёт «не выбрано подключение». Креды не отдаются — только маскированный hint. Read-only.", inputSchema: { type: "object", properties: {} } },
|
|
576
|
+
{ name: "list_integrations", description: "Список подключённых сервисов пользователя (GET /api/bots/integrations): {id, provider, title, hint, createdAt}. **id отсюда — это `connectionId`**, обязательное поле действий amocrm_send/amocrm_update/bitrix24_call/getcourse_send/getcourse_order/yametrika_event. Без него действие упадёт «не выбрано подключение». Креды не отдаются — только маскированный hint. Подключить новый — connect_integration. Read-only.", inputSchema: { type: "object", properties: {} } },
|
|
416
577
|
{ name: "get_graph", description: "Получить граф по graphId. Для БОЛЬШИХ графов (десятки узлов JSON может превысить лимит токенов) используй summary:true (компактная сводка: id/type/title/позиции + рёбра) или saveToFile (записать полный граф на диск и вернуть сводку+путь — потом правь файл и заливай через update_graph/edit_graph_live с graphFile).", inputSchema: { type: "object", properties: { graphId: { type: "string" }, summary: { type: "boolean", description: "true = вернуть компактную сводку без объёмных text/cards/buttons" }, saveToFile: { type: "string", description: "Путь: записать полный граф (JSON) на диск, вернуть сводку + путь" } }, required: ["graphId"] } },
|
|
417
578
|
{ name: "create_graph", description: "Создать пустой граф (DRAFT) в боте. Возвращает граф с id.", inputSchema: { type: "object", properties: { botId: { type: "string" }, name: { type: "string" } }, required: ["botId", "name"] } },
|
|
418
579
|
{ name: "update_graph", description: "Залить узлы/рёбра в граф (PUT, сырой replace без бэкапа). Для правок СУЩЕСТВУЮЩЕГО/живого сценария используй edit_graph_live. Активный (PUBLISHED) граф сервер проверяет как публикацию: при ошибках HTTP 422 со всеми code@nodeId, граф НЕ сохранён. Черновик сохраняется без проверок публикации, кроме размера: граф больше 4 МБ → HTTP 422 GRAPH_TOO_LARGE, не сохранён. Принимает graphFile (путь к локальному файлу — НЕ нужно слать граф инлайном, удобно для больших графов), graph-контейнер или nodes/edges.", inputSchema: { type: "object", properties: { graphId: { type: "string" }, graphFile: { type: "string", description: "Путь к локальному JSON графа (контейнер retensy-bot-graph или {nodes,edges}); поддерживается ~" }, graph: { type: "object" }, nodes: { type: "array" }, edges: { type: "array" }, canvasMeta: { type: "object" }, name: { type: "string" } }, required: ["graphId"] } },
|
|
@@ -438,14 +599,52 @@ const TOOLS = [
|
|
|
438
599
|
{ name: "site_create", description: "Создать сайт из блоков (POST /api/bots/pages, mode=BLOCKS). Возвращает id. Дальше: site_edit (init=starter — стартовый лендинг, init=blank — пустая главная) → site_publish. slug — «название» в адресе pages.retensy.com/<id>/<slug>/ (необязательно, по умолчанию транслит title).", inputSchema: { type: "object", properties: { title: { type: "string" }, slug: { type: "string" } }, required: ["title"] } },
|
|
439
600
|
{ name: "site_get", description: "Модель сайта из блоков (GET /api/bots/pages/{siteId}/document): revision, draft (SiteModel: theme, globals.header/footer, pages[].blocks[], popups[]) — id страниц/блоков/попапов нужны для site_edit. draft=null — сайт пуст (первый site_edit создаст его). saveToFile — записать модель на диск и вернуть путь.", inputSchema: { type: "object", properties: { siteId: { type: "string" }, saveToFile: { type: "string" } }, required: ["siteId"] } },
|
|
440
601
|
{ 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).
|
|
602
|
+
{ name: "site_edit", description: "Правка сайта операциями — всё или ничего (POST /api/bots/pages/{siteId}/document/ops). " +
|
|
603
|
+
"Страницы: add_page{title} · update_page{pageId,patch: {title?, path?, seo?{title,description,noindex,ogTitle,ogDescription,ogImage}, showHeader?, showFooter?, folder?}} · remove_page{pageId} · move_page{pageId,delta}. " +
|
|
604
|
+
"Папки страниц: add_folder{name} (id в results) · rename_folder{folderId,name} · remove_folder{folderId}; страница в папку — update_page{pageId, patch:{folder: folderId}}. " +
|
|
605
|
+
"Дизайны (отдельные экраны-макеты из Zero-кадров): add_design{name} (id в results) · update_design{designId,name} · remove_design{designId} · add_design_frame{designId,name,w,h} → в results blockId Zero-кадра, дальше с ним работают операции Zero-элементов. " +
|
|
606
|
+
"Шаблоны: add_template{container, templateId, after?} — вставить шаблон из библиотеки (site_templates). " +
|
|
607
|
+
"Блоки: add_block{container: id страницы|попапа, type, after?, variant?, props?, style?} · update_block{blockId, props?, style?, variant?} · move_block{blockId,delta} · duplicate_block{blockId} · remove_block{blockId}. " +
|
|
608
|
+
"Код блока: get_block_code{blockId} (results[i].code: Zero — разметка <zero>…</zero>, остальные — JSON) · set_block_code{blockId,code} · add_block_code{container,code,after?}. " +
|
|
609
|
+
"Zero-блок (type zero, свободная вёрстка как в Tilda): add_element{blockId, kind: text|image|button|shape|video|html|group, frame?{d:{x,y,w,h,container?,axisX?,axisY?}, t?, m?}, props?, style?, hover?, anim?, link?, parent?, name?, fixed?} · update_element{blockId,elementId, …те же поля, hidden?, locked?, link:null — убрать} · remove_element · move_element{delta: +1 — слой выше} · group_elements{blockId,elementIds[],name?} · ungroup_element. " +
|
|
610
|
+
"Сайт: set_global{slot: header|footer, on} · set_theme{theme} · set_settings{settings} · add_popup{name} · update_popup{popupId,name?,width?} · remove_popup{popupId}. " +
|
|
611
|
+
"props/style/theme/frame — JSON Merge Patch (null удаляет ключ, массивы заменяются целиком). Типы блоков: header, cover, text, image, gallery, buttons, features, form, video, html, spacer, footer, zero. " +
|
|
612
|
+
"Значения по экранам: {d, t?, m?} (десктоп/планшет/телефон). revision — защита от перезаписи (409, если сайт изменили); init (starter|blank) — с чего начать пустой сайт. " +
|
|
613
|
+
"Ответ: новая revision и results[] с id созданного (и code у get_block_code). Ошибки — HTTP 422 с путями. Тариф: HTML-блок и HTML-элемент Zero публикуются только на платном тарифе (422 при site_publish).", inputSchema: { type: "object", properties: { siteId: { type: "string" }, ops: { type: "array", items: { type: "object" } }, revision: { type: "number" }, init: { type: "string", enum: ["starter", "blank"] } }, required: ["siteId", "ops"] } },
|
|
442
614
|
{ name: "site_publish", description: "Опубликовать черновик сайта (POST /api/bots/pages/{siteId}/publish): рендер в статику, адрес начинает отдавать новую версию. Ошибки проверки — HTTP 422 с путями. Возвращает publishedRevision и url.", inputSchema: { type: "object", properties: { siteId: { type: "string" } }, required: ["siteId"] } },
|
|
443
615
|
{ name: "site_upload_asset", description: "Загрузить картинку/видео в сайт (POST /api/bots/pages/{siteId}/upload, папка assets). Передай path (локальный файл) ИЛИ url. Возвращает asset — строку вида assets/<имя> для полей image/logo/icon/style.bg.image.", inputSchema: { type: "object", properties: { siteId: { type: "string" }, path: { type: "string" }, url: { type: "string" } }, required: ["siteId"] } },
|
|
616
|
+
{ name: "site_rollback", description: "Вернуть прошлую публикацию сайта (POST /api/bots/pages/{siteId}/publish/rollback): revision — номер из истории публикаций (site_get → versions[]). Черновик заменяется этой версией и сразу публикуется. Возвращает publishedRevision и url.", inputSchema: { type: "object", properties: { siteId: { type: "string" }, revision: { type: "number" } }, required: ["siteId", "revision"] } },
|
|
617
|
+
{ name: "site_domains", description: "Свои домены сайта (/api/bots/pages/{siteId}/domains). action: list — домены, статусы и dnsTarget (IP для A-записи); add {host, withWww?} — привязать (withWww у корневого домена добавляет www-пару); check {domainId} — перепроверить DNS и сертификат; remove {domainId} — отвязать. Число доменов ограничено тарифом (HTTP 402 с upgradeUrl). Каждое действие возвращает актуальный список.", inputSchema: { type: "object", properties: { siteId: { type: "string" }, action: { type: "string", enum: ["list", "add", "check", "remove"] }, host: { type: "string" }, withWww: { type: "boolean" }, domainId: { type: "string" } }, required: ["siteId", "action"] } },
|
|
618
|
+
{ name: "site_lead_settings", description: "Куда доставлять заявки из форм сайта (/api/bots/pages/{siteId}/lead-settings). Без settings — прочитать: текущие настройки и доступные вебхук-сценарии и подключения amoCRM. С settings — сохранить целиком: {notifyBot: в бот уведомлений из профиля, notifyEmail: письмо на почту аккаунта, webhookUrl?: POST JSON на ваш адрес, scenarioId?: вебхук-сценарий, который запускает заявка, amoConnectionId?: сделка в amoCRM}.", inputSchema: { type: "object", properties: { siteId: { type: "string" }, settings: { type: "object" } }, required: ["siteId"] } },
|
|
444
619
|
{ name: "site_leads", description: "Заявки из форм сайта (GET /api/bots/pages/{siteId}/leads): поля, UTM, статус доставки. page (с 0), size (до 100). Read-only.", inputSchema: { type: "object", properties: { siteId: { type: "string" }, page: { type: "number" }, size: { type: "number" } }, required: ["siteId"] } },
|
|
445
620
|
{ name: "article_list", description: "Список СВОИХ статей блога retensy (GET /api/articles/my): id, slug, title, viewCount, даты. id нужен для article_update, slug — публичный адрес /articles/{slug}. Read-only.", inputSchema: { type: "object", properties: {} } },
|
|
446
621
|
{ name: "article_get", description: "Получить статью блога по slug (GET /api/articles/by-slug/{slug}) — публичное чтение, в т.ч. чужие. Возвращает title, content (Markdown), excerpt, coverImage, viewCount.", inputSchema: { type: "object", properties: { slug: { type: "string", description: "slug статьи (часть адреса /articles/{slug})" } }, required: ["slug"] } },
|
|
447
622
|
{ name: "article_publish", description: "Опубликовать НОВУЮ статью блога retensy (POST /api/articles). content — Markdown (как README на GitHub: заголовки, списки, таблицы, код, картинки по URL). title необязателен: если не передать, заголовком станет первая строка вида «# Заголовок», и она убирается из текста. Обложку можно задать явно через cover (URL картинки) — иначе берётся первая картинка из текста; excerpt (SEO-описание) тоже можно задать явно, иначе генерируется из текста. Возвращает статью с id и slug + публичный URL.", inputSchema: { type: "object", properties: { title: { type: "string", description: "Заголовок (необязателен, если content начинается с «# ...»)" }, content: { type: "string", description: "Тело статьи в Markdown" }, cover: { type: "string", description: "URL обложки (coverImage/OG). Если не задан — берётся первая картинка из текста." }, excerpt: { type: "string", description: "Краткое SEO-описание (≤160 симв). Если не задан — генерируется из текста." } }, required: ["content"] } },
|
|
448
623
|
{ name: "article_update", description: "Обновить СВОЮ статью по id (PUT /api/articles/{id}; id бери из article_list). content — Markdown; title необязателен (как в article_publish, иначе берётся из «# ...»). Только владелец — чужую вернёт 403.", inputSchema: { type: "object", properties: { id: { type: "string", description: "id статьи из article_list" }, title: { type: "string" }, content: { type: "string", description: "Новое тело в Markdown" } }, required: ["id", "content"] } },
|
|
624
|
+
// ---- Боты ----
|
|
625
|
+
{ name: "create_bot", description: "Подключить бота по токену (POST /api/bots): platform TELEGRAM (токен от @BotFather) или MAX (токен от MasterBot в MAX). Вебхук настраивается сам; name — отображаемое имя (иначе @username). Возвращает бота с id. Число ботов ограничено тарифом — HTTP 402 со ссылкой на смену тарифа. platform INSTAGRAM по токену не подключается (только вход через Facebook в кабинете, сейчас выключен) — инструмент вернёт ссылку на кабинет вместо ошибки.", inputSchema: { type: "object", properties: { platform: { type: "string", enum: ["TELEGRAM", "MAX", "INSTAGRAM"] }, token: { type: "string", description: "Токен бота: 123456789:AA… (Telegram) или токен MAX" }, name: { type: "string" } }, required: ["platform"] } },
|
|
626
|
+
{ name: "bot_stop", description: "Остановить бота (POST /api/bots/{botId}/stop): снимает вебхук, бот перестаёт отвечать, сценарии и подписчики сохраняются. Запуск обратно — bot_resume.", inputSchema: { type: "object", properties: { botId: { type: "string" } }, required: ["botId"] } },
|
|
627
|
+
{ name: "bot_resume", description: "Запустить остановленного бота или бота, приостановленного лимитом тарифа (POST /api/bots/{botId}/resume). Если лимит ботов тарифа исчерпан — HTTP 402 со ссылкой на смену тарифа.", inputSchema: { type: "object", properties: { botId: { type: "string" } }, required: ["botId"] } },
|
|
628
|
+
// ---- Подключения ----
|
|
629
|
+
{ name: "connect_integration", description: "Подключить сервис (POST /api/bots/integrations) — дальше его id (= connectionId) ставится в действия сценария и в site_lead_settings. provider и creds: AMOCRM {subdomain, longToken} · BITRIX24 {webhookUrl} · GETCOURSE {account, apiKey} · YAMETRIKA {counterId, oauthToken} · YOOKASSA {shopId, secretKey}. Без нужных creds вернёт, какие поля и где их взять. connectionId — обновить креды/название существующего подключения (PUT). Сервисы со входом через браузер не падают, а возвращают ссылку для пользователя: GOOGLE_SHEETS → ссылка согласия Google (OAuth; после неё таблицы выбираются в узле «Google Таблицы»), INSTAGRAM → кабинет (вход через Facebook, сейчас выключен). TELEGRAM/MAX — это боты: используй create_bot. Креды не возвращаются и не попадают в отчёты.", inputSchema: { type: "object", properties: { provider: { type: "string", description: "AMOCRM | BITRIX24 | GETCOURSE | YAMETRIKA | YOOKASSA | GOOGLE_SHEETS | INSTAGRAM" }, title: { type: "string", description: "Название подключения в кабинете (например «amoCRM продажи»)" }, creds: { type: "object", description: "Поля провайдера, см. описание" }, connectionId: { type: "string", description: "id существующего подключения (list_integrations) — обновить его" } }, required: ["provider"] } },
|
|
630
|
+
{ name: "disconnect_integration", description: "Удалить подключение сервиса по id из list_integrations (DELETE /api/bots/integrations/{id}). Действия сценария с этим connectionId перестанут работать.", inputSchema: { type: "object", properties: { connectionId: { type: "string" } }, required: ["connectionId"] } },
|
|
631
|
+
// ---- Рассылки ----
|
|
632
|
+
{ name: "broadcast_list", description: "Рассылки. Без botId — по всем ботам постранично (GET /api/bots/broadcasts): {counts: {drafts, scheduled, sent, recurring}, page: {content: [{id, botId, botUsername, name, status, direct, totalJobs, sentJobs, failedJobs, skippedByQuota, scheduledAt, createdAt}], totalElements…}}; group: scheduled (ещё не начали) | sent (идут/завершены). С botId — полная история одного бота. status: EXPANDING/MATERIALIZING/READY (ждёт) → RUNNING → DONE | CANCELLING → CANCELLED | FAILED. Read-only.", inputSchema: { type: "object", properties: { botId: { type: "string" }, group: { type: "string", enum: ["scheduled", "sent"] }, page: { type: "number" }, size: { type: "number", description: "до 100, по умолчанию 20" } } } },
|
|
633
|
+
{ name: "broadcast_get", description: "Рассылка целиком по id (GET /api/bots/broadcasts/{id}): статус, счётчики отправки, фильтр аудитории, сообщения, время. Read-only.", inputSchema: { type: "object", properties: { broadcastId: { type: "string" } }, required: ["broadcastId"] } },
|
|
634
|
+
{ name: "broadcast_preview", description: "Сколько подписчиков получат рассылку (POST /api/bots/{botId}/broadcasts/preview) — по каждому боту и всего. Фильтр по тегам: tagsAll — есть ВСЕ эти теги, tagsNone — нет НИ ОДНОГО; без тегов — все подписчики бота. Лимит — 50 000 получателей на бота. Ничего не отправляет.", inputSchema: { type: "object", properties: { botIds: { type: "array", items: { type: "string" } }, botId: { type: "string" }, tagsAll: { type: "array", items: { type: "string" } }, tagsNone: { type: "array", items: { type: "string" } } } } },
|
|
635
|
+
{ name: "broadcast_send", description: "Отправить рассылку сейчас или запланировать (scheduledAt). " +
|
|
636
|
+
"Прямая (по умолчанию, POST /api/bots/broadcasts/direct): name, botIds[] (1–20 ботов ОДНОГО владельца; по каждому создаётся своя рассылка), messages[] (1–5), tagsAll?/tagsNone? (фильтр по тегам). " +
|
|
637
|
+
"Сообщение: {type, text?, mediaUrl?, mediaUrls?, buttons?}; type: TEXT (text обязателен, до 4096) · PHOTO | VIDEO | AUDIO | FILE | VOICE (mediaUrl обязателен, text — подпись до 1024) · VIDEONOTE (кружок: mediaUrl, без текста) · GALLERY (mediaUrls: 2–10 картинок, подпись, БЕЗ кнопок). " +
|
|
638
|
+
"text — Telegram-HTML: <b> <i> <u> <s> <code> <pre> <blockquote> <tg-spoiler> <a href=\"https://…\">, перенос строки — \\n (не <br>); прочее экранируется. buttons — до 8 URL-кнопок [{text, url}] (callback-кнопок в рассылке нет). Можно строкой — это TEXT. Медиа — сначала upload_file, потом его url. " +
|
|
639
|
+
"По сценарию (graphId): POST /api/bots/{botId}/broadcasts — один botId, сценарий самого бота, entryNodeId? — с какого узла начать. " +
|
|
640
|
+
"draftId — отправить черновик (поля черновика, переданные аргументы их перекрывают); после отправки черновик удаляется. " +
|
|
641
|
+
"scheduledAt — ISO 8601 (без пояса — московское время); пусто — сразу. Немедленная резервирует квоту получателей тарифа, отложенная считает аудиторию в момент отправки. Рассылки только на платном тарифе — HTTP 402 со ссылкой. У Instagram-ботов рассылок нет. Возвращает {broadcastIds, totalAudience}.", inputSchema: { type: "object", properties: { name: { type: "string" }, botIds: { type: "array", items: { type: "string" } }, botId: { type: "string" }, messages: { type: "array", items: {} }, tagsAll: { type: "array", items: { type: "string" } }, tagsNone: { type: "array", items: { type: "string" } }, scheduledAt: { type: "string", description: "ISO 8601, напр. 2026-10-06T10:00:00+03:00; пусто — сразу" }, graphId: { type: "string", description: "рассылка запуском сценария бота вместо сообщений" }, entryNodeId: { type: "string" }, draftId: { type: "string" } } } },
|
|
642
|
+
{ name: "broadcast_cancel", description: "Отменить рассылку (POST /api/bots/broadcasts/{id}/cancel): запланированная не уйдёт, идущая остановится (статус CANCELLING → CANCELLED). Уже завершённую отменить нельзя — HTTP 409.", inputSchema: { type: "object", properties: { broadcastId: { type: "string" } }, required: ["broadcastId"] } },
|
|
643
|
+
{ name: "broadcast_recurring", description: "Повторяющиеся рассылки (/api/bots/broadcasts/recurring). action: list — правила (активные и остановленные); create {name, botIds[], messages[], tagsAll?, tagsNone?, recurrence: DAILY|MONTHLY|YEARLY, firstRunAt} — сообщения как в broadcast_send, firstRunAt — первый запуск в будущем (ISO 8601, без пояса — московское), дальше в то же время суток/число (Москва); stop {ruleId} — остановить правило (уже отправленные прогоны не трогаются). Нужен платный тариф (402 со ссылкой).", inputSchema: { type: "object", properties: { action: { type: "string", enum: ["list", "create", "stop"] }, ruleId: { type: "string" }, name: { type: "string" }, botIds: { type: "array", items: { type: "string" } }, botId: { type: "string" }, messages: { type: "array", items: {} }, tagsAll: { type: "array", items: { type: "string" } }, tagsNone: { type: "array", items: { type: "string" } }, recurrence: { type: "string", enum: ["DAILY", "MONTHLY", "YEARLY"] }, firstRunAt: { type: "string" } }, required: ["action"] } },
|
|
644
|
+
{ name: "broadcast_drafts", description: "Черновики рассылок (/api/bots/broadcasts/drafts) — те же, что в мастере кабинета. action: list · get {draftId} · create {name?, botIds?, messages?, tagsAll?, tagsNone?, scheduledAt?} · update {draftId, …те же поля — переданные заменяют, остальные остаются} · delete {draftId}. Черновик не проверяется на полноту; отправить — broadcast_send {draftId}. Лимит — 200 черновиков.", inputSchema: { type: "object", properties: { action: { type: "string", enum: ["list", "get", "create", "update", "delete"] }, draftId: { type: "string" }, name: { type: "string" }, botIds: { type: "array", items: { type: "string" } }, messages: { type: "array", items: {} }, tagsAll: { type: "array", items: { type: "string" } }, tagsNone: { type: "array", items: { type: "string" } }, scheduledAt: { type: "string" } }, required: ["action"] } },
|
|
645
|
+
{ name: "broadcast_duplicate", description: "Копия прямой рассылки как черновик «Копия — …» (POST /api/bots/broadcasts/{id}/duplicate): бот, фильтр, сообщения. Рассылку по сценарию не дублировать — HTTP 409. Дальше broadcast_drafts update / broadcast_send {draftId}.", inputSchema: { type: "object", properties: { broadcastId: { type: "string" } }, required: ["broadcastId"] } },
|
|
646
|
+
// ---- Сайты: библиотека шаблонов ----
|
|
647
|
+
{ name: "site_templates", description: "Библиотека шаблонов блоков сайта (GET /api/bots/pages/templates): {categories: [{id, title, description?}], templates: [{id, category, title, description?, blocks: сколько блоков вставится}]}. Вставка — site_edit add_template {container, templateId, after?} (results.id — первый блок, results.ids — все); дальше блоки правятся как обычные. category — фильтр по id категории.", inputSchema: { type: "object", properties: { category: { type: "string" } } } },
|
|
449
648
|
];
|
|
450
649
|
|
|
451
650
|
async function handleCall(params) {
|
|
@@ -673,6 +872,28 @@ async function handleCall(params) {
|
|
|
673
872
|
if (a.size != null) qs.push(`size=${encodeURIComponent(a.size)}`);
|
|
674
873
|
return okResult(await api(`/api/bots/pages/${a.siteId}/leads${qs.length ? `?${qs.join("&")}` : ""}`));
|
|
675
874
|
}
|
|
875
|
+
case "site_rollback": {
|
|
876
|
+
if (typeof a.revision !== "number") throw new Error("Передай revision — номер публикации (site_get → versions[]).");
|
|
877
|
+
const r = await api(`/api/bots/pages/${a.siteId}/publish/rollback`, { method: "POST", body: { revision: a.revision } });
|
|
878
|
+
return okResult({ publishedRevision: r?.publishedRevision, url: r?.page?.url });
|
|
879
|
+
}
|
|
880
|
+
case "site_domains": {
|
|
881
|
+
const base = `/api/bots/pages/${a.siteId}/domains`;
|
|
882
|
+
if (a.action === "list") return okResult(await api(base));
|
|
883
|
+
if (a.action === "add") {
|
|
884
|
+
if (!a.host) throw new Error("Передай host — домен, например example.ru.");
|
|
885
|
+
return okResult(await api(base, { method: "POST", body: { host: a.host, withWww: !!a.withWww } }));
|
|
886
|
+
}
|
|
887
|
+
if (!a.domainId) throw new Error("Передай domainId (site_domains action=list).");
|
|
888
|
+
if (a.action === "check") return okResult(await api(`${base}/${a.domainId}/check`, { method: "POST" }));
|
|
889
|
+
if (a.action === "remove") return okResult(await api(`${base}/${a.domainId}`, { method: "DELETE" }));
|
|
890
|
+
throw new Error("action: list | add | check | remove.");
|
|
891
|
+
}
|
|
892
|
+
case "site_lead_settings": {
|
|
893
|
+
const p_ = `/api/bots/pages/${a.siteId}/lead-settings`;
|
|
894
|
+
if (a.settings == null) return okResult(await api(p_));
|
|
895
|
+
return okResult(await api(p_, { method: "PUT", body: a.settings }));
|
|
896
|
+
}
|
|
676
897
|
case "article_list": return okResult(await api("/api/articles/my"));
|
|
677
898
|
case "article_get": return okResult(await api(`/api/articles/by-slug/${encodeURIComponent(a.slug)}`));
|
|
678
899
|
case "article_publish": {
|
|
@@ -686,6 +907,188 @@ async function handleCall(params) {
|
|
|
686
907
|
const updated = await api(`/api/articles/${a.id}`, { method: "PUT", body: { title: a.title, content: a.content } });
|
|
687
908
|
return okResult({ ...updated, publicUrl: updated?.slug ? `${BASE}/articles/${updated.slug}` : null });
|
|
688
909
|
}
|
|
910
|
+
// ---- Боты ----
|
|
911
|
+
case "create_bot": {
|
|
912
|
+
const platform = String(a.platform || "").trim().toUpperCase();
|
|
913
|
+
if (platform === "INSTAGRAM") return instagramAnswer();
|
|
914
|
+
if (platform !== "TELEGRAM" && platform !== "MAX") throw new Error("platform: TELEGRAM | MAX (Instagram подключается только в кабинете).");
|
|
915
|
+
const token = String(a.token || "").trim();
|
|
916
|
+
if (!token) {
|
|
917
|
+
return okResult(platform === "MAX"
|
|
918
|
+
? "Нужен токен MAX-бота: создай бота у MasterBot в MAX и пришли токен — я подключу его (create_bot {platform:\"MAX\", token})."
|
|
919
|
+
: "Нужен токен Telegram-бота: открой https://t.me/BotFather → /newbot (или /token для существующего) и пришли токен вида 123456789:AA… — я подключу его (create_bot {platform:\"TELEGRAM\", token}).");
|
|
920
|
+
}
|
|
921
|
+
let bot;
|
|
922
|
+
try {
|
|
923
|
+
bot = await api("/api/bots", { method: "POST", body: { token, platform } });
|
|
924
|
+
} catch (e) {
|
|
925
|
+
// 400 приходит строкой-причиной (неверный токен, бот уже подключён) — её и показываем.
|
|
926
|
+
if (e.status === 400) throw new Error(`Бот не подключён: ${typeof e.data === "string" && e.data ? e.data : "токен не принят"}. Проверь токен (${platform === "MAX" ? "MasterBot в MAX" : "@BotFather → /token"}).`);
|
|
927
|
+
throw e;
|
|
928
|
+
}
|
|
929
|
+
if (a.name && bot?.id) {
|
|
930
|
+
try { bot = await api(`/api/bots/${bot.id}`, { method: "PATCH", body: { name: a.name } }); }
|
|
931
|
+
catch (e) { return okResult({ ...bot, warning: `Бот подключён, но имя не задано: ${(e.message || "").split("\n")[0]}` }); }
|
|
932
|
+
}
|
|
933
|
+
return okResult(bot);
|
|
934
|
+
}
|
|
935
|
+
case "bot_stop": return okResult(await api(`/api/bots/${a.botId}/stop`, { method: "POST" }));
|
|
936
|
+
case "bot_resume": return okResult(await api(`/api/bots/${a.botId}/resume`, { method: "POST" }));
|
|
937
|
+
// ---- Подключения ----
|
|
938
|
+
case "connect_integration": {
|
|
939
|
+
const provider = normProvider(a.provider);
|
|
940
|
+
if (provider === "INSTAGRAM") return instagramAnswer();
|
|
941
|
+
if (provider === "TELEGRAM" || provider === "MAX") {
|
|
942
|
+
return okResult(`${provider === "MAX" ? "MAX" : "Telegram"} подключается как бот, а не интеграция: вызови create_bot {platform:"${provider}", token}.`);
|
|
943
|
+
}
|
|
944
|
+
if (provider === "GOOGLE_SHEETS") {
|
|
945
|
+
const r = await api(`/api/bots/google/auth-url${qs({ returnPath: "/bots/integrations" })}`, { method: "POST" });
|
|
946
|
+
let connected = [];
|
|
947
|
+
try { const ids = await api("/api/bots/google/identities"); connected = Array.isArray(ids) ? ids : []; } catch { /* список не обязателен */ }
|
|
948
|
+
if (!r?.authUrl) throw new Error(`Сервер не вернул ссылку авторизации Google. Подключи в кабинете: ${INTEGRATIONS_PAGE}`);
|
|
949
|
+
return linkResult("Google Таблицы: вход через Google (OAuth)", r.authUrl,
|
|
950
|
+
"Открой ссылку, выбери Google-аккаунт и разреши доступ к таблицам — затем таблица выбирается в узле сценария «Google Таблицы». Ссылка одноразовая и живёт недолго: если истекла, вызови connect_integration ещё раз.",
|
|
951
|
+
{ connectedGoogleAccounts: connected });
|
|
952
|
+
}
|
|
953
|
+
const spec = PROVIDER_FIELDS[provider];
|
|
954
|
+
if (!spec) throw new Error(`Неизвестный provider «${a.provider}». Бывают: ${Object.keys(PROVIDER_FIELDS).join(", ")}, GOOGLE_SHEETS, INSTAGRAM. Каталог: ${CONNECT_PAGE}`);
|
|
955
|
+
const creds = a.creds && typeof a.creds === "object" ? Object.fromEntries(
|
|
956
|
+
Object.entries(a.creds).filter(([, v]) => v != null && String(v).trim() !== "").map(([k, v]) => [k, String(v).trim()])) : {};
|
|
957
|
+
const missing = Object.keys(spec.fields).filter((k) => !creds[k]);
|
|
958
|
+
if (missing.length && (!a.connectionId || Object.keys(creds).length)) {
|
|
959
|
+
return okResult({
|
|
960
|
+
connected: false,
|
|
961
|
+
provider,
|
|
962
|
+
need: Object.fromEntries(missing.map((k) => [k, spec.fields[k]])),
|
|
963
|
+
instruction: `Для ${spec.name} не хватает полей creds: ${missing.join(", ")}. Попроси их у пользователя и вызови connect_integration ещё раз. Или пусть подключит сам в кабинете: ${CONNECT_PAGE}`,
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
const title = a.title || spec.name;
|
|
967
|
+
try {
|
|
968
|
+
const saved = a.connectionId
|
|
969
|
+
? await api(`/api/bots/integrations/${a.connectionId}`, { method: "PUT", body: { title: a.title, creds: Object.keys(creds).length ? creds : undefined } })
|
|
970
|
+
: await api("/api/bots/integrations", { method: "POST", body: { provider, title, creds } });
|
|
971
|
+
return okResult({ connected: true, connectionId: saved?.id, ...saved, hint: "connectionId ставь в действия сценария (amocrm_send, bitrix24_call, getcourse_send, yametrika_event, оплата ЮKassa) и в site_lead_settings.amoConnectionId." });
|
|
972
|
+
} catch (e) {
|
|
973
|
+
if (e.status === 400) throw new Error(`${spec.name} не подключён: ${bodyReason(e.data) || "креды не приняты"}. Проверь поля: ${Object.entries(spec.fields).map(([k, v]) => `${k} — ${v}`).join("; ")}.`);
|
|
974
|
+
throw e;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
case "disconnect_integration":
|
|
978
|
+
await api(`/api/bots/integrations/${a.connectionId}`, { method: "DELETE" });
|
|
979
|
+
return okResult(`🗑️ Подключение ${a.connectionId} удалено.`);
|
|
980
|
+
// ---- Рассылки ----
|
|
981
|
+
case "broadcast_list": {
|
|
982
|
+
if (a.botId) return okResult(await api(`/api/bots/${a.botId}/broadcasts`));
|
|
983
|
+
const page = await api(`/api/bots/broadcasts${qs({ page: a.page, size: a.size, group: a.group })}`);
|
|
984
|
+
let counts = null;
|
|
985
|
+
try { counts = await api("/api/bots/broadcasts/counts"); } catch { /* счётчики не обязательны */ }
|
|
986
|
+
return okResult({ counts, page });
|
|
987
|
+
}
|
|
988
|
+
case "broadcast_get": return okResult(await api(`/api/bots/broadcasts/${a.broadcastId}`));
|
|
989
|
+
case "broadcast_preview": {
|
|
990
|
+
const ids = botIdsOf(a);
|
|
991
|
+
await assertBroadcastBots(ids);
|
|
992
|
+
const filter = { tagsAll: strList(a.tagsAll), tagsNone: strList(a.tagsNone) };
|
|
993
|
+
const perBot = [];
|
|
994
|
+
for (const id of ids) {
|
|
995
|
+
const r = await api(`/api/bots/${id}/broadcasts/preview`, { method: "POST", body: filter });
|
|
996
|
+
perBot.push({ botId: id, count: r?.count ?? null, limit: r?.limit ?? null });
|
|
997
|
+
}
|
|
998
|
+
return okResult({ total: perBot.reduce((s, r) => s + (Number(r.count) || 0), 0), perBot, filter });
|
|
999
|
+
}
|
|
1000
|
+
case "broadcast_send": {
|
|
1001
|
+
if (a.graphId) {
|
|
1002
|
+
const ids = botIdsOf(a);
|
|
1003
|
+
if (ids.length !== 1) throw new Error("Рассылка по сценарию — ровно один botId (сценарий принадлежит боту).");
|
|
1004
|
+
await assertBroadcastBots(ids);
|
|
1005
|
+
if (!a.name) throw new Error("Передай name рассылки.");
|
|
1006
|
+
const b = await api(`/api/bots/${ids[0]}/broadcasts`, { method: "POST", body: {
|
|
1007
|
+
name: a.name, audienceFilter: { tagsAll: strList(a.tagsAll), tagsNone: strList(a.tagsNone) },
|
|
1008
|
+
graphId: a.graphId, entryNodeId: a.entryNodeId || undefined, scheduledAt: toInstant(a.scheduledAt, "scheduledAt") } });
|
|
1009
|
+
return okResult({ broadcastIds: [b?.id], status: b?.status, scheduledAt: b?.scheduledAt ?? null, broadcast: b });
|
|
1010
|
+
}
|
|
1011
|
+
let draft = null;
|
|
1012
|
+
if (a.draftId) draft = await api(`/api/bots/broadcasts/drafts/${a.draftId}`);
|
|
1013
|
+
const pick = (k) => (a[k] !== undefined ? a[k] : draft?.[k]);
|
|
1014
|
+
const ids = botIdsOf({ botIds: a.botIds ?? (a.botId ? undefined : draft?.botIds), botId: a.botId });
|
|
1015
|
+
await assertBroadcastBots(ids);
|
|
1016
|
+
const name = String(pick("name") || "").trim();
|
|
1017
|
+
if (!name) throw new Error("Передай name рассылки (видно только тебе в списке рассылок).");
|
|
1018
|
+
const body = {
|
|
1019
|
+
name, botIds: ids,
|
|
1020
|
+
tagsAll: strList(a.tagsAll !== undefined ? a.tagsAll : draft?.audienceFilter?.tagsAll),
|
|
1021
|
+
tagsNone: strList(a.tagsNone !== undefined ? a.tagsNone : draft?.audienceFilter?.tagsNone),
|
|
1022
|
+
messages: normalizeBroadcastMessages(pick("messages")),
|
|
1023
|
+
scheduledAt: toInstant(pick("scheduledAt"), "scheduledAt"),
|
|
1024
|
+
};
|
|
1025
|
+
if (body.scheduledAt && new Date(body.scheduledAt).getTime() <= Date.now()) {
|
|
1026
|
+
// Прошедшее время бэкенд молча считает «сейчас» — для черновика с устаревшей датой это сюрприз.
|
|
1027
|
+
if (a.scheduledAt !== undefined) throw new Error(`scheduledAt ${body.scheduledAt} уже прошло — укажи будущее время или не передавай его (отправить сейчас).`);
|
|
1028
|
+
body.scheduledAt = null;
|
|
1029
|
+
}
|
|
1030
|
+
const r = await api("/api/bots/broadcasts/direct", { method: "POST", body });
|
|
1031
|
+
let draftDeleted = false;
|
|
1032
|
+
if (a.draftId) { try { await api(`/api/bots/broadcasts/drafts/${a.draftId}`, { method: "DELETE" }); draftDeleted = true; } catch { /* не критично */ } }
|
|
1033
|
+
return okResult({ ...r, scheduledAt: body.scheduledAt, ...(a.draftId ? { draftDeleted } : {}) });
|
|
1034
|
+
}
|
|
1035
|
+
case "broadcast_cancel":
|
|
1036
|
+
await api(`/api/bots/broadcasts/${a.broadcastId}/cancel`, { method: "POST" });
|
|
1037
|
+
return okResult(`⏹️ Рассылка ${a.broadcastId} отменяется (CANCELLING → CANCELLED).`);
|
|
1038
|
+
case "broadcast_recurring": {
|
|
1039
|
+
const base = "/api/bots/broadcasts/recurring";
|
|
1040
|
+
if (a.action === "list") return okResult(await api(base));
|
|
1041
|
+
if (a.action === "stop") {
|
|
1042
|
+
if (!a.ruleId) throw new Error("Передай ruleId (broadcast_recurring action=list).");
|
|
1043
|
+
await api(`${base}/${a.ruleId}/stop`, { method: "POST" });
|
|
1044
|
+
return okResult(`⏹️ Правило ${a.ruleId} остановлено — новых прогонов не будет.`);
|
|
1045
|
+
}
|
|
1046
|
+
if (a.action === "create") {
|
|
1047
|
+
const ids = botIdsOf(a);
|
|
1048
|
+
await assertBroadcastBots(ids);
|
|
1049
|
+
if (!a.name) throw new Error("Передай name.");
|
|
1050
|
+
const recurrence = String(a.recurrence || "").toUpperCase();
|
|
1051
|
+
if (!["DAILY", "MONTHLY", "YEARLY"].includes(recurrence)) throw new Error("recurrence: DAILY | MONTHLY | YEARLY.");
|
|
1052
|
+
const firstRunAt = toInstant(a.firstRunAt, "firstRunAt");
|
|
1053
|
+
if (!firstRunAt || new Date(firstRunAt).getTime() <= Date.now()) throw new Error("firstRunAt — время первого запуска в будущем (ISO 8601, без пояса — московское).");
|
|
1054
|
+
return okResult(await api(base, { method: "POST", body: {
|
|
1055
|
+
name: a.name, botIds: ids, tagsAll: strList(a.tagsAll), tagsNone: strList(a.tagsNone),
|
|
1056
|
+
messages: normalizeBroadcastMessages(a.messages), recurrence, firstRunAt } }));
|
|
1057
|
+
}
|
|
1058
|
+
throw new Error("action: list | create | stop.");
|
|
1059
|
+
}
|
|
1060
|
+
case "broadcast_drafts": {
|
|
1061
|
+
const base = "/api/bots/broadcasts/drafts";
|
|
1062
|
+
if (a.action === "list") return okResult(await api(base));
|
|
1063
|
+
if (a.action === "create" || a.action === "update") {
|
|
1064
|
+
if (a.action === "update" && !a.draftId) throw new Error("Передай draftId (broadcast_drafts action=list).");
|
|
1065
|
+
// update: PUT заменяет черновик целиком — непереданные поля берём из текущего, чтобы правка текста не стёрла ботов.
|
|
1066
|
+
const cur = a.action === "update" ? (await api(`${base}/${a.draftId}`)) || {} : {};
|
|
1067
|
+
const has = (k) => a[k] !== undefined;
|
|
1068
|
+
const body = {
|
|
1069
|
+
name: has("name") ? a.name : cur.name,
|
|
1070
|
+
botIds: has("botIds") || has("botId") ? botIdsOf(a) : (cur.botIds || []),
|
|
1071
|
+
tagsAll: strList(has("tagsAll") ? a.tagsAll : cur.audienceFilter?.tagsAll),
|
|
1072
|
+
tagsNone: strList(has("tagsNone") ? a.tagsNone : cur.audienceFilter?.tagsNone),
|
|
1073
|
+
messages: normalizeBroadcastMessages(has("messages") ? a.messages : cur.messages, false),
|
|
1074
|
+
scheduledAt: toInstant(has("scheduledAt") ? a.scheduledAt : cur.scheduledAt, "scheduledAt"),
|
|
1075
|
+
};
|
|
1076
|
+
if (a.action === "create") return okResult(await api(base, { method: "POST", body }));
|
|
1077
|
+
return okResult(await api(`${base}/${a.draftId}`, { method: "PUT", body }));
|
|
1078
|
+
}
|
|
1079
|
+
if (!a.draftId) throw new Error("Передай draftId (broadcast_drafts action=list).");
|
|
1080
|
+
if (a.action === "get") return okResult(await api(`${base}/${a.draftId}`));
|
|
1081
|
+
if (a.action === "delete") { await api(`${base}/${a.draftId}`, { method: "DELETE" }); return okResult(`🗑️ Черновик ${a.draftId} удалён.`); }
|
|
1082
|
+
throw new Error("action: list | get | create | update | delete.");
|
|
1083
|
+
}
|
|
1084
|
+
case "broadcast_duplicate":
|
|
1085
|
+
return okResult(await api(`/api/bots/broadcasts/${a.broadcastId}/duplicate`, { method: "POST" }));
|
|
1086
|
+
case "site_templates": {
|
|
1087
|
+
const r = await api("/api/bots/pages/templates");
|
|
1088
|
+
let templates = Array.isArray(r?.templates) ? r.templates : [];
|
|
1089
|
+
if (a.category) templates = templates.filter((t) => t?.category === a.category);
|
|
1090
|
+
return okResult({ categories: r?.categories ?? [], templates });
|
|
1091
|
+
}
|
|
689
1092
|
default:
|
|
690
1093
|
throw new Error(`Неизвестный инструмент: ${params && params.name}`);
|
|
691
1094
|
}
|