@retensy/mcp 0.29.0 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -3
- package/package.json +2 -2
- package/skills/build-bot-funnel/SKILL.md +3 -0
- package/skills/build-bot-funnel/reference/schema.md +27 -1
- package/skills/build-bot-funnel/reference/validation.md +11 -0
- package/skills/build-bot-funnel/validate.mjs +54 -2
- package/skills/send-broadcast/SKILL.md +3 -1
- package/src/index.mjs +90 -8
package/README.md
CHANGED
|
@@ -50,7 +50,7 @@ MCP-сервер (+ скилл для Claude Code) для **сборки и пу
|
|
|
50
50
|
|
|
51
51
|
- 🤖 **Сценарии ботов**: `list_bots`, `list_graphs`, `list_channels`, `get_graph`, `create_graph`, `update_graph`, `edit_graph_live`, `patch_graph`, `dry_run`, `publish_graph`, `unpublish_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`).
|
|
52
52
|
- 🔌 **Боты и сервисы**: `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 и живая проверка подключения), `integration_ingress_url` (URL ингресса «Внешних событий» — Tilda, JivoSite, WooCommerce, amoCRM, Битрикс24, GetCourse, CloudPayments, Robokassa, Т-Банк, Prodamus — для `TRIGGER_WEBHOOK`), `channel_post` (пост в канал Telegram/MAX).
|
|
53
|
-
- 📣 **Рассылки**: `broadcast_preview` (
|
|
53
|
+
- 📣 **Рассылки**: `broadcast_preview` (оценка охвата до отправки), `broadcast_send` (сейчас или по расписанию, сразу по нескольким ботам; сообщения или запуск сценария; полный фильтр аудитории `audienceFilter` или сохранённый сегмент `segmentId`, дедуп `dedup`, распределённый запуск `stagger` по ботам, `localTime` - в HH:mm по часам каждого подписчика), `broadcast_test` (тест себе), `audience_segments` (сохранённые сегменты), `bot_delivery_window` (тихие часы бота), `broadcast_list`/`broadcast_get`/`broadcast_cancel`, повторяющиеся (`broadcast_recurring`), черновики (`broadcast_drafts`, `broadcast_duplicate`), мульти-бот группы (`broadcast_group` — preview/list/get/pause/resume/cancel) + скилл `send-broadcast`.
|
|
54
54
|
- 👥 **Аудитория подписчиков**: `audience_search`/`audience_fields` — кросс-бот поиск и фильтр (теги, даты, статус, условия по переменным), `audience_tag_edit`/`audience_variable_edit` — массовое редактирование тегов/переменных, `audience_export` — потоковая выгрузка выборки в CSV/XLSX, `dialog_search` — поиск по «Диалогам» (включая текст сообщений).
|
|
55
55
|
- 📤 **Миграция подписчиков** из других конструкторов: `migration_presets`/`migration_preview`/`migration_commit` — generic CSV column-mapper для BotHelp/SaleBot/Senler/Leadteh/Smartsender/ManyChat (переносятся только подписчики — ни один из них не публикует документированный формат экспорта воронки).
|
|
56
56
|
- 📅 **Запись на слоты**: `booking_calendar_*` (буферы до/после брони, горизонт записи, типы встреч своей длительности, форма клиента, публичная страница `/book/<slug>`), `booking_confirm`/`booking_reject` — ручное подтверждение брони (`confirmationMode:"MANUAL"`).
|
|
@@ -206,6 +206,8 @@ MCP-сервер (+ скилл для Claude Code) для **сборки и пу
|
|
|
206
206
|
| `dry_run(graphId, kind, value)` | прогон без публикации |
|
|
207
207
|
| `publish_graph(graphId, secondary?)` | публикация (вернёт `errors[]` при провале); `secondary: true` — дополнительный сценарий рядом с основным (запускают VK-лендинги и ссылки с `targetGraphId`) |
|
|
208
208
|
| `unpublish_graph(graphId)` | снять дополнительный сценарий с публикации (основной — 409) |
|
|
209
|
+
| `graph_versions_list(graphId, [version], [compareTo])` | история версий сценария (снимки при публикации, правках живого графа, ручные); с `version:N` — сравнение с текущим графом или другой версией (`compareTo`). Право `read` |
|
|
210
|
+
| `graph_version_rollback(graphId, version)` | откат сценария на версию = публикация снимка (проверки как у `edit_graph_live`, текущее состояние сохраняется отдельной версией). Право `write:bots` |
|
|
209
211
|
| `import_funnel(botId, name, graphFile\|graph)` | всё за раз: create → update → dry-run → publish |
|
|
210
212
|
| `list_templates()` | готовые шаблоны воронок |
|
|
211
213
|
| `create_graph_from_template(botId, templateId, name)` | граф из шаблона (DRAFT) |
|
|
@@ -233,8 +235,11 @@ MCP-сервер (+ скилл для Claude Code) для **сборки и пу
|
|
|
233
235
|
| `site_lead_settings(siteId, settings?)` | куда доставлять заявки: бот уведомлений, почта, вебхук, вебхук-сценарий, amoCRM, «Интеграция» (`coreDelivery {connectionId, kind, params}` из `coreConnections`) |
|
|
234
236
|
| `broadcast_list(botId?, group?, page?, size?)` | рассылки + счётчики разделов (черновики/запланированные/отправленные/повторы) |
|
|
235
237
|
| `broadcast_get(broadcastId)` | рассылка целиком: статус, счётчики, сообщения, причины ошибок |
|
|
236
|
-
| `broadcast_preview(botIds, tagsAll?, tagsNone?)` | сколько подписчиков получат рассылку |
|
|
237
|
-
| `broadcast_send(name, botIds, messages \| graphId, tagsAll?/tagsNone? или audienceFilter?, scheduledAt?, draftId?, dedup?, stagger?)` | отправить сейчас / запланировать; по нескольким ботам; или запуск сценария; `dedup`/`stagger` → мульти-бот группа (`broadcast_group`) вместо прямых рассылок |
|
|
238
|
+
| `broadcast_preview(botIds, tagsAll?, tagsNone?, audienceFilter?, segmentId?)` | оценка охвата: сколько подписчиков получат рассылку |
|
|
239
|
+
| `broadcast_send(name, botIds, messages \| graphId, tagsAll?/tagsNone? или audienceFilter?, scheduledAt?, draftId?, segmentId?, localTime?, dedup?, stagger?)` | отправить сейчас / запланировать; по нескольким ботам; или запуск сценария; `dedup`/`stagger` → мульти-бот группа (`broadcast_group`) вместо прямых рассылок |
|
|
240
|
+
| `audience_segments(action, botId, …)` | сохранённые сегменты аудитории: list / create {name, audienceFilter} / delete {segmentId}; дальше `segmentId` в `broadcast_send`/`broadcast_preview` |
|
|
241
|
+
| `bot_delivery_window(action, botId, …)` | тихие часы бота (по умолчанию выкл): get / set {enabled, quietFrom, quietTo, timezone?}; рассылки в окне переносятся на его конец, пояс - подписчика (переменная `timezone`), иначе бота |
|
|
242
|
+
| `broadcast_test(botId, messages, chatId?)` | тест рассылки себе: на привязанный Telegram владельца или в чат подписчика; мимо квоты и очереди |
|
|
238
243
|
| `broadcast_cancel(broadcastId)` | отменить запланированную/идущую (для группы — `broadcast_group action:"cancel"`) |
|
|
239
244
|
| `broadcast_recurring(action, …)` | повторяющиеся рассылки: list / create (DAILY·MONTHLY·YEARLY, те же `audienceFilter`/`dedup`/`stagger`) / stop |
|
|
240
245
|
| `broadcast_drafts(action, …)` | черновики: list / get / create / update / delete |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@retensy/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.0",
|
|
4
4
|
"description": "MCP server to build and publish Telegram and MAX bot funnels/automations, broadcasts, service integrations, block-based websites (incl. free-layout Zero blocks, designs, templates, custom domains, form leads) and Markdown blog articles in the Retensy Bots service. Zero dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -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 && node scripts/broadcast-connect.test.mjs && node scripts/integration-core.test.mjs && node scripts/site-init.test.mjs && node scripts/ai-agents.test.mjs && node scripts/battery-tools.test.mjs && node scripts/parity-tools.test.mjs && node scripts/parity-2.test.mjs && node scripts/validate-graph.test.mjs && node scripts/call-scenario.test.mjs && node scripts/audience-publications.test.mjs && node scripts/chains-analytics.test.mjs && node scripts/platform-decisions.test.mjs && node scripts/token-scopes.test.mjs && node scripts/secondary-graphs.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 && node scripts/site-init.test.mjs && node scripts/ai-agents.test.mjs && node scripts/battery-tools.test.mjs && node scripts/parity-tools.test.mjs && node scripts/parity-2.test.mjs && node scripts/validate-graph.test.mjs && node scripts/call-scenario.test.mjs && node scripts/audience-publications.test.mjs && node scripts/chains-analytics.test.mjs && node scripts/platform-decisions.test.mjs && node scripts/token-scopes.test.mjs && node scripts/secondary-graphs.test.mjs && node scripts/graph-versions.test.mjs",
|
|
25
25
|
"validate": "node skills/build-bot-funnel/validate.mjs"
|
|
26
26
|
},
|
|
27
27
|
"engines": {
|
|
@@ -26,6 +26,7 @@ description: Собрать воронку (сценарий) бота для с
|
|
|
26
26
|
- id узлов и рёбер — валидные UUID, уникальные.
|
|
27
27
|
- Есть хотя бы один корневой `TRIGGER_*`; все нелистовые узлы достижимы от триггера; синхронных циклов нет (цикл только через `ASK_QUESTION`/`DELAY`/`SCHEDULE`).
|
|
28
28
|
- Кнопки-выборы разведены по хэндлам `btn_N` — у такой `CALLBACK`-кнопки `value` ОБЯЗАТЕЛЬНО пустой (`""`): бот сам сгенерит `callback_data`, непустой `value` ломает переход (`NO_MATCH`). Цвет кнопки — только `""`/`#34C759`/`#FF3B30`. Условия `CONDITION` — `yes`/`no`; вопрос (`ASK_QUESTION` или `SEND_MESSAGE` с `awaitReply:true`) — `valid`/`invalid`.
|
|
29
|
+
- Подписчику нужно выдавать **случайный текст из набора целиком** (история, цитата, совет) → узел `RANDOM_TEXT` (`texts[]`, режимы `send`/`variable`, `noRepeat`), а не база знаний агента: база режется на фрагменты по ~800 знаков, и агент видит только найденные куски.
|
|
29
30
|
- **Не добавляй узлы `END`** — ветка завершается сама на узле без исходящих рёбер; явный «конец сценария» убран из редактора.
|
|
30
31
|
- `SEND_MESSAGE` умеет быть и сообщением, и **вопросом** (`awaitReply:true` + `saveTo`/`inputKind`/`validator`). У кнопок-ссылок (`kind:"URL"`) есть флаг `track:true` — на такой шаг ссылается условие `LINK_CLICKED`.
|
|
31
32
|
- **Медиа** (фото/видео/аудио/документ/кружок/галерея): загрузи файл инструментом `upload_file` (локальный `path` или `url` для перезаливки) → получишь публичный URL → вставь его в медиа-карточку `SEND_MESSAGE` (`url`, у `gallery` — `urls[]`) или в `SEND_PHOTO.photoUrl`. Уже загруженное — `list_files`. Бинарь в графе не хранится, только ссылки. Подробности — в schema.md.
|
|
@@ -37,6 +38,7 @@ description: Собрать воронку (сценарий) бота для с
|
|
|
37
38
|
- **Приём оплат** — действие `issue_invoice` внутри `ACTIONS` (НЕ отдельный узел: `YOOKASSA_PAYMENT` устарел). Деньги идут на кассу владельца бота; `connectionId` — подключение `provider=YOOKASSA` из `list_integrations`. Два режима, выбирай по задаче:
|
|
38
39
|
- **дождаться оплаты прямо в диалоге** — протяни от блока ветку `paid` (появятся выходы `paid`/`timeout`); сценарий встанет на паузу до подтверждения кассой;
|
|
39
40
|
- **не ждать** — ветку `paid` не рисуй, блок просто пришлёт счёт и пойдёт по `next`; выдать доступ потом сможет отдельный сценарий с триггером `TRIGGER_PAYMENT`.
|
|
41
|
+
- **Оплата звёздами Telegram (Stars, XTR)** — действие `issue_stars_invoice` внутри `ACTIONS`, ТОЛЬКО для Telegram-ботов (на MAX/VK/Instagram/WEB публикация даст `STARS_TELEGRAM_ONLY`). Кассы и `connectionId` нет: `{ "kind":"issue_stars_invoice", "amount":"150", "title":"Курс", "description":"Доступ", "timeoutMinutes":60 }` (`amount` — целое 1…10000, `title` до 32 знаков). Выходы и правила те же, что у `issue_invoice`: `paid`/`timeout`, счёт последним в блоке, один счёт на блок (ЮKassa и звёзды вместе тоже нельзя). После оплаты в переменных `{{var.stars_charge_id}}` (нужен для возврата), `{{var.stars_amount}}`, `{{var.stars_paid_at}}`; история и возврат — в кабинете «Платежи звёздами» (/bots/stars). Разрешение `pre_checkout_query` включается автоматически при публикации.
|
|
40
42
|
Счёт обязан быть **последним** действием в блоке и только одним (`INVOICE_NOT_LAST`, `INVOICE_DUPLICATE`).
|
|
41
43
|
- **`TRIGGER_PAYMENT` («оплата прошла»)** — если счёт выставлял бот, подписчик определяется САМ (счёт помнит его в metadata платежа): можно сразу слать сообщение и вешать метку, разбирать ответ кассы не нужно. Если оплата пришла мимо бота — прогон headless, получателя задавай через `config.target`. Ставь фильтр `minAmount`/`descriptionContains`, иначе сценарий сработает на любую оплату в кассе.
|
|
42
44
|
- **`TRIGGER_SCHEDULE` («По расписанию»)** — `{cron:"0 9 * * 1-5", timezone}`: headless-прогон без чата (отправка — только с `config.target`), для сводок, эскалаций, SLA. Журнал — `scenario_runs`.
|
|
@@ -70,6 +72,7 @@ description: Собрать воронку (сценарий) бота для с
|
|
|
70
72
|
- Почему так: бэкенд при PUT/публикации шлёт `external_update` в WS-комнату → открытые редакторы перечитывают граф **вживую** (юзеру не надо перезаходить). Бот читает активный граф **заново из БД на каждое сообщение** → правка живого PUBLISHED-графа применяется **сразу, без отдельной публикации**.
|
|
71
73
|
- **Дополнительный сценарий** (отдельный живой граф под VK-лендинг/рекламную ссылку, основной не трогается): `publish_graph(graphId, secondary: true)` публикует граф НА МЕСТЕ (`publishedGraphId` = тот же id, снимка нет, `active` false, `activeGraphId` бота не меняется); проверки те же (422), нужен основной сценарий у бота (`SECONDARY_NO_MAIN`), основной сам дополнительным не делается (`SECONDARY_IS_MAIN`). Его запускают только `vk_landing_create/update` с `targetGraphId` (ответ показывает `targetGraphStatus`: MAIN/SECONDARY/NOT_PUBLISHED/MISSING) и `link_create` с `targetGraphId` (+`targetNodeId` узла в нём). Подписчик стартует с его /start (или первого триггера/блока); ответы на вопросы, кнопки, задержки продолжаются в нём, команды/ключевые слова — по основному, любой старт (/start, /start <код> не на дополнительный, VK «Начать») ведёт в основной (снимая ожидание вопроса). Нельзя в дополнительном: TRIGGER_SCHEDULE, TRIGGER_SITE_FORM, TRIGGER_TG_EVENT, TRIGGER_COMMENT, TRIGGER_WEBHOOK, TRIGGER_IG_* → 422 `SECONDARY_TRIGGER_UNSUPPORTED` (и при правке вживую); в режиме decoupled → `SECONDARY_UNSUPPORTED`. TRIGGER_PAYMENT в дополнительном — только для подписчика, который сейчас в нём. Клон основного при первой публикации дополнительным получает новые id совпавших узлов (рёбра/конфиги/ссылки на шаги переносятся) — после publish перечитай граф (`get_graph`), старые nodeId недействительны: PUT/`edit_graph_live` живого дополнительного со старыми id узлов основного → HTTP 409 `GRAPH_STALE`. Ссылки «Перейти в сценарий» (graphId другого графа) и текст при перенумерации не меняются. Подписчик возвращается в основной на блоке END дополнительного или по команде/ключевому слову основного; «Оплата прошла» в дополнительном отменяет основной для этой оплаты, только если его триггер реально сработал. Цель-черновик/чужая/основной — подписчик идёт в основной, как раньше. Правки дополнительного — тем же id через `edit_graph_live` (вживую, как основной). Снять — `unpublish_graph(graphId)`; «сделать основным» — `set_active_graph`. В `list_graphs` у него `secondary: true`. Публикация основного (`publish_graph` без `secondary`) дополнительные не удаляет. Дополнительный занимает место в лимите сценариев тарифа.
|
|
72
74
|
- `clone_graph`+`publish_graph` НЕ создают новый живой граф и НЕ песочница: публикация копирует клон (как любой черновик) в id ТЕКУЩЕГО опубликованного графа бота и затирает его содержимое без бэкапа — вместе с живыми правками, сделанными после прошлой публикации; `set_active_graph` на прежний id после этого вернёт уже новое содержимое; открытые редакторы живого графа перечитают его (у кого есть несохранённые правки — получат просьбу обновить страницу). Клон годится, чтобы спокойно собрать крупный рискованный рефактор в черновике, но перед его публикацией сохрани живой граф: `get_graph(graphId, saveToFile)` — откат = залить файл обратно через `edit_graph_live`/`update_graph` с `graphFile`.
|
|
75
|
+
- **История версий и откат** (сервер ведёт сам, у опубликованного/живого графа): снимок при каждой публикации (`PUBLISH`), при правке живого сценария не чаще раза в 10 минут (`AUTO` — состояние ДО правки), по кнопке «Сохранить версию» в редакторе (`MANUAL`) и при откате (`ROLLBACK`); хранится последние 50 + все публикации/откаты за 90 дней. `graph_versions_list(graphId)` — список (number, reason, автор, время, число узлов); `graph_versions_list(graphId, version:N)` — сравнение версии N с текущим графом (сколько узлов added/removed/changed, менялись ли рёбра). `graph_version_rollback(graphId, version:N)` — откат = публикация снимка: у живого графа бот переходит на версию СРАЗУ, проверки как у `edit_graph_live` (при ошибках 422 граф не тронут), текущее состояние сервер сам сохраняет отдельной версией (`savedCurrentAs`), поэтому откат обратим. Перед крупной правкой живого графа посмотри `graph_versions_list` — есть ли свежая точка возврата; откат делай только с согласия пользователя. У черновика истории нет.
|
|
73
76
|
- ⚠️ PUT **активного** (PUBLISHED) графа сервер проверяет как `publish`: валидатор, платные блоки, лимит блоков тарифа, платформа. Ошибка → `HTTP 422` со всеми `code@nodeId`, граф **не сохранён**, бот работает на прежней версии. Черновик (`DRAFT`) PUT сохраняет без проверок (кроме размера: больше 4 МБ → 422 `GRAPH_TOO_LARGE`), и на бота он не влияет: `publish_graph` черновика копирует его в отдельный `PUBLISHED`-граф (`publishedGraphId`), а сам черновик остаётся `DRAFT`. Всё равно прогоняй `validate.mjs` + `dry_run` заранее. Откат: версии активного не хранятся — опубликовать граф «🔙 Авто-бэкап» (или скопировать его содержимое обратно).
|
|
74
77
|
|
|
75
78
|
6. **Отчитайся**: сколько узлов/веток, какие тексты помечены на проверку, ссылка/ id графа.
|
|
@@ -175,6 +175,17 @@ IG-боты не поддерживают команды (`/start`). Вход
|
|
|
175
175
|
(`DELETE_BAD_MODE`), `NODE` без существующего узла (`DELETE_NODE_MISSING`) или с узлом, который ничего не шлёт
|
|
176
176
|
(`DELETE_NODE_NOT_SENDER`), пауза не число (`DELETE_BAD_DELAY`) или от 24 часов (`DELETE_DELAY_TOO_LONG`),
|
|
177
177
|
`NEXT` без паузы (`DELETE_NEXT_NO_DELAY`). Instagram узел не поддерживает.
|
|
178
|
+
- `RANDOM_TEXT` — **случайный текст из списка**, целиком. Нужен, когда клиент хочет выдавать подписчику
|
|
179
|
+
случайную историю/цитату/совет из набора дословно (база знаний агента для этого не годится: она режется
|
|
180
|
+
на фрагменты, и агент видит только найденные куски). `{ "texts":["Текст 1","Текст 2"], "mode":"send"|"variable",
|
|
181
|
+
"saveTo":"story", "noRepeat":false, "split":true, "parseMode":"HTML"? }`. `mode:"send"` (по умолчанию) отправляет
|
|
182
|
+
текст подписчику; текст длиннее лимита канала (TG 4096, MAX 4000, IG 1000) делится на сообщения по абзацам
|
|
183
|
+
(`split:false` - не делить, лишнее обрежется). `mode:"variable"` кладёт текст в `{{var.<saveTo>}}` (saveTo обязателен,
|
|
184
|
+
до 65 536 знаков), дальше его вставляют в любой `SEND_MESSAGE`. `noRepeat:true` - не повторять тексты у подписчика,
|
|
185
|
+
пока не выданы все, потом круг заново. В текстах работают `{{from.first_name}}` и `{{var.x}}`. Потолки: до 500 текстов,
|
|
186
|
+
до 65 536 знаков каждый, вместе до 1 500 000 (сценарий в целом ≤ 4 МБ). Выходы: `next`, `error` (пустой список, отказ
|
|
187
|
+
отправки, переменная не сохранилась). Не для Instagram (`IG_NODE_UNSUPPORTED`). Коды валидатора: `RANDOM_TEXT_NO_TEXTS`,
|
|
188
|
+
`RANDOM_TEXT_TOO_MANY`, `RANDOM_TEXT_TOO_LONG`, `RANDOM_TEXT_TOTAL_TOO_LONG`, `RANDOM_TEXT_BAD_SAVE_TO`.
|
|
178
189
|
- `ASK_QUESTION` — вопрос со сбором ответа. `{ "promptText":"...","saveTo":"name","inputKind":"TEXT"|"PHOTO"|"DOCUMENT"|"CONTACT"|"LOCATION","validator":"ANY"|"PHONE"|"EMAIL"|"REGEX","regex":"...","retryText":"...","maxAttempts":3 }`. `inputKind` (по умолчанию `TEXT`) — что ждём в ответ (`CONTACT` → телефон: в Telegram показывается кнопка «Поделиться номером», в MAX/Instagram кнопки нет — номер вводится вручную и принимается как телефон на всех платформах; `LOCATION` → `lat,lon`, `PHOTO`/`DOCUMENT` → file_id). Выходы `valid` / `invalid`.
|
|
179
190
|
- `END` — `{}` (конец ветки). **Не добавляй `END`**: ветка и так завершается на узле без исходящих рёбер; явный «конец сценария» бесполезен и убран из палитры редактора. Тип оставлен лишь для совместимости со старыми графами.
|
|
180
191
|
|
|
@@ -183,6 +194,7 @@ IG-боты не поддерживают команды (`/start`). Вход
|
|
|
183
194
|
- **`FIXED`** («Отправить через»): `{ "kind":"FIXED", "durationSec": 86400 }` — `durationSec` в **секундах** (60 = 1 мин, 3600 = 1 час, 86400 = 1 сутки). Редактор также пишет `{ "kind":"FIXED", "duration": 24, "unit":"MINUTES"|"HOURS"|"DAYS" }` (минуты/часы/дни — чтобы не вбивать большие числа). Для генерации проще `durationSec`.
|
|
184
195
|
- **`TOMORROW`** («Отправить завтра»): `{ "kind":"TOMORROW", "time":"18:00" }` — завтра в указанное время `HH:mm` (МСК), относительно момента, когда пользователь дошёл до узла.
|
|
185
196
|
- **`UNTIL`** («Отправить в»): `{ "kind":"UNTIL", "isoTimestamp":"2026-06-25T15:00:00Z" }` — конкретный момент в ISO-8601. ⚠️ Рантайм читает только `isoTimestamp`; пары `isoDate`+`time` НЕ работают. Можно шаблон из переменной и смещение: `{ "kind":"UNTIL", "isoTimestamp":"{{var.slot_at}}", "offset":"-24h", "timezone":"Europe/Moscow" }` — «за сутки до записи» (`offset`: `-30m`, `+1d`, единицы s/m/h/d/w; дата без зоны читается в `timezone`, по умолчанию Москва). Момент уже прошёл → выход `late`, если ребро нарисовано, иначе шаг выполняется сразу (`snapshot.lateBy`). Шаблон не разобрался как дата → выход `error`.
|
|
197
|
+
- **Окно отправки для `DELAY`** (любой `kind`): `"windowFrom":"09:00", "windowTo":"21:00"` (HH:mm, окно может переходить через полночь) и/или `"days":["MON","TUE",...]` - если момент пробуждения вне окна/дня, он переносится на начало ближайшего окна. `"windowTz":"subscriber"` - считать окно по поясу подписчика (его переменная `timezone`/`tz`: IANA `Europe/Moscow` или `UTC+3`; если её нет - `timezone` узла, иначе Москва); без `windowTz` - Москва; допустим и явный пояс (`"Asia/Yekaterinburg"`). Тихие часы всего бота для рассылок настраивает `bot_delivery_window`.
|
|
186
198
|
- `SCHEDULE` — `{ "isoDate":"2026-06-25", "time":"18:00", "timezone":"Europe/Moscow" }`. Выходы `scheduled` / `past`. `isoDate` может быть и ISO дата-временем или шаблоном (`"{{var.slot_at}}"`, тогда `time` не нужен) + `offset` как у `DELAY UNTIL`.
|
|
187
199
|
- Прогон без пауз (между `DELAY`, `ASK_QUESTION`/`awaitReply`, ожиданием оплаты) ограничен **5 минутами**: дольше — рантайм обрывает его с ошибкой шага `run deadline exceeded` (прогон `FAILED`, пауза подписчика снята). Длинные цепочки `external_request` / `CALL_WEBHOOK` / `AI_REPLY` разноси `DELAY` — после паузы начинается новый прогон.
|
|
188
200
|
- Массовый одинаковый момент срабатывания (`TOMORROW`/`UNTIL`/`SCHEDULE` у тысяч подписчиков одного бота) расходится постепенно: до 10 срабатываний на бота в секунду, время растёт с аудиторией (≈100 с на 1000 подписчиков, ≈17 мин на 10 000), другие боты не ждут. Не рассчитывай, что все получат сообщение в одну секунду.
|
|
@@ -253,7 +265,8 @@ IG-боты не поддерживают команды (`/start`). Вход
|
|
|
253
265
|
`dateTime` (unix-секунды, по умолчанию «сейчас»).
|
|
254
266
|
- **Единственное НЕ интегрированное действие**: `cancel_payment_subscription` —
|
|
255
267
|
принимается как no-op с пометкой `integration_not_connected`.
|
|
256
|
-
- **модерация группы**: `group_unban`, `group_kick`, `group_approve`, `group_decline`
|
|
268
|
+
- **модерация группы**: `group_unban`, `group_kick`, `group_approve`, `group_decline`. Поля: `chatId` (числовой id, `@username` или `{{var.*}}`; пусто — единственный привязанный к боту канал/группа), `userId` (пусто — текущий подписчик).
|
|
269
|
+
**`group_kick` — «выгнать или забанить»**: `mode:"kick"` — выгнать (Telegram: ban + сразу unban, человек может вернуться по ссылке-приглашению), `mode:"ban"` — забанить; опц. `untilDays` (1..366, только для `ban`; пусто = навсегда). Без `mode` — как раньше, бан навсегда. Бот должен быть админом чата с правом блокировать. Нет прав / человека нет в чате → выход «Ошибка» блока, сценарий не падает. MAX: «выгнать» и бан навсегда, бан на срок недоступен. VK: только `mode:"kick"` из беседы сообщества (`chatId` — номер беседы, бот админ беседы), бан и разбан недоступны. Dry-run ничего не вызывает. Цепочка «выгнал → удалил сообщение с инвайтом»: блок «Действия» с `group_kick` после отправки ссылки, затем `DELETE_MESSAGE` с `mode:"NODE"` на узел со ссылкой.
|
|
257
270
|
|
|
258
271
|
### Внешнее / прочее
|
|
259
272
|
- `CALL_WEBHOOK` — `{ "url":"https://...", "method":"POST", "headersJson":"{\"X-Key\":\"…\"}",
|
|
@@ -358,6 +371,18 @@ IG-боты не поддерживают команды (`/start`). Вход
|
|
|
358
371
|
- `YOOKASSA_PAYMENT` — узел оплаты. **В новых сценариях его не ставь**: выставление счёта — это
|
|
359
372
|
действие, поэтому оно живёт действием `issue_invoice` внутри `ACTIONS` (см. ниже), а узла больше
|
|
360
373
|
нет в палитре. Тип жив в рантайме только ради графов, где он уже стоит.
|
|
374
|
+
- Действие **`issue_stars_invoice`** внутри `ACTIONS` — счёт в **Telegram Stars (XTR)**, только для
|
|
375
|
+
Telegram-ботов (на MAX/VK/Instagram/WEB — `STARS_TELEGRAM_ONLY`). Кассы нет, `connectionId` не нужен.
|
|
376
|
+
`{ "kind":"issue_stars_invoice", "amount":"150", "title":"Доступ к курсу", "description":"Что получит покупатель", "timeoutMinutes":60 }`.
|
|
377
|
+
- `amount` обязателен: целое число звёзд 1…10000 или шаблон `{{var.price}}` (`STARS_NO_AMOUNT`/`STARS_BAD_AMOUNT`).
|
|
378
|
+
- `title` обязателен, до 32 знаков (`STARS_NO_TITLE`/`STARS_TITLE_TOO_LONG`); `description` необязателен, до 255 (`STARS_DESC_TOO_LONG`), по умолчанию = title.
|
|
379
|
+
- Выходы, ожидание, `timeoutMinutes` и правила «последний/один счёт» (`INVOICE_NOT_LAST`/`INVOICE_DUPLICATE`)
|
|
380
|
+
те же, что у `issue_invoice`; в блоке не может быть вместе ЮKassa-счёта и счёта в звёздах.
|
|
381
|
+
- После оплаты: `{{var.stars_charge_id}}` (telegram_payment_charge_id, нужен для возврата), `{{var.stars_amount}}`,
|
|
382
|
+
`{{var.stars_paid_at}}`; `{{var.payment_id}}` при выставлении счёта = внутренний invoice payload.
|
|
383
|
+
- Платёж идемпотентен по `charge_id`; оплата после таймаута ветку `paid` не запускает, но видна в истории.
|
|
384
|
+
Возврат — из кабинета (`refundStarPayment`), через MCP не делается.
|
|
385
|
+
- Повторный проход по счёту выставляет НОВЫЙ счёт, прежний перестаёт приниматься (в отличие от ЮKassa, где та же ссылка).
|
|
361
386
|
- Действие **`issue_invoice`** внутри `ACTIONS` — настоящий счёт на **кассу владельца бота**
|
|
362
387
|
(деньги идут на его магазин, не на счёт сервиса) и, если нужно, ожидание оплаты.
|
|
363
388
|
`{ "kind":"issue_invoice", "connectionId":"<id подключения ЮKassa>", "amount":"990",
|
|
@@ -502,6 +527,7 @@ node validate.mjs graph.json --platform=INSTAGRAM
|
|
|
502
527
|
| `CALL_WEBHOOK` | `ok`, `error` |
|
|
503
528
|
| `ACTIONS` | `next`, плюс `error` — если внутри есть действие, которое может упасть (внешний запрос, CRM, Таблицы); `taken` — слот `booking_book` занят |
|
|
504
529
|
| `SWITCH` | `case_<id>`, `default` |
|
|
530
|
+
| `RANDOM_TEXT` | `next`, `error` |
|
|
505
531
|
| `STOP_AND_ERROR` | выходов нет (терминатор) |
|
|
506
532
|
| `DELETE_MESSAGE` | `next` (всегда, даже если удалить не вышло) |
|
|
507
533
|
| `SCHEDULE` | `scheduled`, `past` |
|
|
@@ -17,6 +17,12 @@
|
|
|
17
17
|
- Кнопок недостаточно: сообщение только с `buttons` — пустое.
|
|
18
18
|
- Пробелы/таб/перенос строки = пусто (`.isBlank()`).
|
|
19
19
|
|
|
20
|
+
### `RANDOM_TEXT` — «Случайный текст»
|
|
21
|
+
- `config.texts[]` — хотя бы один непустой текст (`RANDOM_TEXT_NO_TEXTS`); не больше 500 (`RANDOM_TEXT_TOO_MANY`),
|
|
22
|
+
каждый ≤ 65 536 знаков (`RANDOM_TEXT_TOO_LONG`), вместе ≤ 1 500 000 (`RANDOM_TEXT_TOTAL_TOO_LONG`) - иначе сценарий не влезет в 4 МБ.
|
|
23
|
+
- `mode:"variable"` требует `saveTo` по `[a-z_][a-z0-9_]{0,63}` (`RANDOM_TEXT_BAD_SAVE_TO`).
|
|
24
|
+
- Для IG-ботов тип не разрешён (`IG_NODE_UNSUPPORTED`).
|
|
25
|
+
|
|
20
26
|
### Длина текста
|
|
21
27
|
- `text` ≤ 4096; если есть `photoUrl` (подпись) — ≤ 1024 (`SEND_TOO_LONG`).
|
|
22
28
|
- При `parseMode:"HTML"` — текст должен быть безопасным Telegram-HTML, иначе `HTML_NOT_SAFE`. Разрешены только `b,strong,i,em,u,ins,s,strike,del,code,pre,a[href],tg-spoiler,br`. Если не уверен — `PLAIN`.
|
|
@@ -63,6 +69,11 @@
|
|
|
63
69
|
- **счёт должен быть ПОСЛЕДНИМ действием в блоке** (`INVOICE_NOT_LAST`) и только один (`INVOICE_DUPLICATE`).
|
|
64
70
|
Причина: при наличии ветки `paid` блок встаёт на паузу до вебхука кассы, а возобновиться
|
|
65
71
|
с середины списка движок не умеет — действия после счёта молча не выполнились бы.
|
|
72
|
+
- `ACTIONS` с действием `issue_stars_invoice` (счёт в Telegram Stars):
|
|
73
|
+
- `amount` обязателен (`STARS_NO_AMOUNT`), целое 1…10000 или шаблон (`STARS_BAD_AMOUNT`);
|
|
74
|
+
- `title` обязателен (`STARS_NO_TITLE`), не длиннее 32 (`STARS_TITLE_TOO_LONG`); `description` до 255 (`STARS_DESC_TOO_LONG`);
|
|
75
|
+
- только Telegram: на MAX/VK/Instagram/WEB — `STARS_TELEGRAM_ONLY` (при публикации; офлайн `validate.mjs --platform=MAX|INSTAGRAM` тоже);
|
|
76
|
+
- `timeoutMinutes` ∈ [1, 1440]; последний в блоке и единственный счёт (`INVOICE_NOT_LAST`/`INVOICE_DUPLICATE` считают оба вида счетов вместе).
|
|
66
77
|
- `AI_REPLY` (`mode` не задан или `"simple"`): `userPromptTemplate` непустой (`AI_NO_PROMPT`); `temperature` ∈ [0.0, 2.0] (`AI_BAD_TEMPERATURE`); нужен `sendToUser:true` ИЛИ `saveTo` (`AI_NO_OUTPUT`).
|
|
67
78
|
- `AI_REPLY` (`mode: "agent"`) — отдельный набор проверок, старые (`AI_NO_PROMPT` и т.п.) не применяются:
|
|
68
79
|
- `strict` (по умолчанию `true`) без `knowledgeBaseId` → `AGENT_NO_KNOWLEDGE_BASE` (в строгом режиме без базы агент ответит «не знаю» на всё).
|
|
@@ -69,7 +69,7 @@ const ACTION_KINDS = new Set([
|
|
|
69
69
|
"add_tag", "remove_tag", "set_field", "stop_bot", "delete_step_message",
|
|
70
70
|
"subscribe", "unsubscribe", "autoflow_add", "autoflow_remove",
|
|
71
71
|
"subscriber_webhook", "external_request", "notify",
|
|
72
|
-
"subscriber_email", "agent_chat", "cancel_payment_subscription", "issue_invoice",
|
|
72
|
+
"subscriber_email", "agent_chat", "cancel_payment_subscription", "issue_invoice", "issue_stars_invoice",
|
|
73
73
|
"getcourse_send", "getcourse_order", "amocrm_send", "amocrm_update", "bitrix24_call",
|
|
74
74
|
"yametrika_event", "gsheets_send", "gsheets_get", "gsheets_update",
|
|
75
75
|
"gsheets_write_cell", "gsheets_read_cell",
|
|
@@ -96,6 +96,11 @@ function validZone(z) {
|
|
|
96
96
|
try { new Intl.DateTimeFormat("en", { timeZone: z }); return true; } catch { return false; }
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
// Пояс окна DELAY: IANA-имя или смещение UTC+3 / +03:00 (зеркало DeliveryTime.parseZone)
|
|
100
|
+
function validWindowZone(z) {
|
|
101
|
+
return /^(?:UTC|GMT)?\s*[+-]\s*\d{1,2}(?::?\d{2})?$/i.test(String(z).trim()) || validZone(String(z).trim());
|
|
102
|
+
}
|
|
103
|
+
|
|
99
104
|
// Telegram-safe HTML — разрешённые теги (эвристика; бэкенд использует jsoup-clean)
|
|
100
105
|
const HTML_OK_TAGS = new Set([
|
|
101
106
|
"b", "strong", "i", "em", "u", "ins", "s", "strike", "del",
|
|
@@ -250,6 +255,7 @@ for (const n of nodes) {
|
|
|
250
255
|
if (blank(c.url) || !/^https?:\/\//.test(c.url)) errors.push(`${who}: url обязателен и http(s)://.`);
|
|
251
256
|
break;
|
|
252
257
|
case "DELAY":
|
|
258
|
+
if (!blank(c.windowTz) && String(c.windowTz).trim().toLowerCase() !== "subscriber" && !validWindowZone(c.windowTz)) errors.push(`DELAY_BAD_WINDOW_TZ: ${who} - windowTz = "subscriber" или пояс (Europe/Moscow, UTC+3).`);
|
|
253
259
|
if (c.kind === "FIXED") { if (!(Number(c.durationSec) > 0) && !(Number(c.duration) > 0)) errors.push(`${who}: FIXED требует durationSec>0 (или duration>0 + unit MINUTES|HOURS|DAYS).`); }
|
|
254
260
|
else if (c.kind === "TOMORROW") { if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(String(c.time || ""))) errors.push(`${who}: TOMORROW требует time = HH:mm.`); }
|
|
255
261
|
else if (c.kind === "UNTIL") {
|
|
@@ -360,6 +366,16 @@ for (const n of nodes) {
|
|
|
360
366
|
case "REMOVE_TAG":
|
|
361
367
|
if (!TAG_RE.test(String(c.tag || ""))) errors.push(`TAG_BAD_NAME: ${who} — tag ∈ [a-z0-9_-]{1,64}.`);
|
|
362
368
|
break;
|
|
369
|
+
case "RANDOM_TEXT": {
|
|
370
|
+
// Зеркало GraphValidator.validateRandomText: потолки 500 текстов / 65 536 на текст / 1 500 000 суммарно.
|
|
371
|
+
const texts = Array.isArray(c.texts) ? c.texts.filter((x) => typeof x === "string" && x.trim()) : [];
|
|
372
|
+
if (texts.length === 0) errors.push(`RANDOM_TEXT_NO_TEXTS: ${who} — нужен хотя бы один непустой текст в config.texts[].`);
|
|
373
|
+
if (texts.length > 500) errors.push(`RANDOM_TEXT_TOO_MANY: ${who} — текстов больше 500.`);
|
|
374
|
+
if (texts.some((x) => x.length > 65536)) errors.push(`RANDOM_TEXT_TOO_LONG: ${who} — текст длиннее 65 536 символов.`);
|
|
375
|
+
if (texts.reduce((a, x) => a + x.length, 0) > 1500000) errors.push(`RANDOM_TEXT_TOTAL_TOO_LONG: ${who} — тексты вместе длиннее 1 500 000 символов.`);
|
|
376
|
+
if (c.mode === "variable" && !VAR_RE.test(String(c.saveTo || ""))) errors.push(`RANDOM_TEXT_BAD_SAVE_TO: ${who} — в режиме variable saveTo ∈ [a-z_][a-z0-9_]{0,63}.`);
|
|
377
|
+
break;
|
|
378
|
+
}
|
|
363
379
|
case "FORMULA":
|
|
364
380
|
if (blank(c.expression)) errors.push(`FORMULA_NO_EXPRESSION: ${who} — нужен expression.`);
|
|
365
381
|
if (!VAR_RE.test(String(c.saveTo || ""))) errors.push(`FORMULA_BAD_SAVE_TO: ${who} — saveTo ∈ [a-z_][a-z0-9_]{0,63}.`);
|
|
@@ -386,7 +402,7 @@ for (const n of nodes) {
|
|
|
386
402
|
// Счёт ставит блок на паузу до вебхука кассы, а возобновиться с середины списка движок
|
|
387
403
|
// не умеет — значит действия после счёта молча не выполнятся. Зеркало GraphValidator.
|
|
388
404
|
{
|
|
389
|
-
const invIdx = c.actions.map((a, i) => (a && a.kind === "issue_invoice" ? i : -1)).filter((i) => i >= 0);
|
|
405
|
+
const invIdx = c.actions.map((a, i) => (a && (a.kind === "issue_invoice" || a.kind === "issue_stars_invoice") ? i : -1)).filter((i) => i >= 0);
|
|
390
406
|
if (invIdx.length > 1) {
|
|
391
407
|
errors.push(`INVOICE_DUPLICATE: ${who} — в блоке больше одного счёта, непонятно какой ждать.`);
|
|
392
408
|
}
|
|
@@ -428,6 +444,23 @@ for (const n of nodes) {
|
|
|
428
444
|
if (!Number.isFinite(t) || t < 1 || t > 1440) errors.push(`YK_BAD_TIMEOUT: ${who} — timeoutMinutes ∈ [1, 1440].`);
|
|
429
445
|
}
|
|
430
446
|
}
|
|
447
|
+
if (a.kind === "issue_stars_invoice") {
|
|
448
|
+
// Зеркало GraphValidator.validateStarsInvoice: Telegram Stars (XTR), только Telegram-боты.
|
|
449
|
+
const amount = String(a.amount == null ? "" : a.amount).trim();
|
|
450
|
+
if (blank(amount)) errors.push(`STARS_NO_AMOUNT: ${who} — нужна amount (целое число звёзд).`);
|
|
451
|
+
else if (!amount.includes("{{") && !(/^\d+$/.test(amount) && Number(amount) >= 1 && Number(amount) <= 10000))
|
|
452
|
+
errors.push(`STARS_BAD_AMOUNT: ${who} — amount должна быть целым числом звёзд от 1 до 10000.`);
|
|
453
|
+
if (blank(a.title)) errors.push(`STARS_NO_TITLE: ${who} — нужно title (название товара, до 32 знаков).`);
|
|
454
|
+
else if (!String(a.title).includes("{{") && String(a.title).trim().length > 32)
|
|
455
|
+
errors.push(`STARS_TITLE_TOO_LONG: ${who} — title не длиннее 32 символов.`);
|
|
456
|
+
if (!blank(a.description) && !String(a.description).includes("{{") && String(a.description).trim().length > 255)
|
|
457
|
+
errors.push(`STARS_DESC_TOO_LONG: ${who} — description не длиннее 255 символов.`);
|
|
458
|
+
if (platform !== "TELEGRAM") errors.push(`STARS_TELEGRAM_ONLY: ${who} — оплата звёздами доступна только в Telegram-ботах.`);
|
|
459
|
+
if (a.timeoutMinutes != null) {
|
|
460
|
+
const t = Number(a.timeoutMinutes);
|
|
461
|
+
if (!Number.isFinite(t) || t < 1 || t > 1440) errors.push(`YK_BAD_TIMEOUT: ${who} — timeoutMinutes ∈ [1, 1440].`);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
431
464
|
if (a.kind === "external_request") {
|
|
432
465
|
// Имя переменной бэкенд не чистит: кривое имя запишется, а {{var.<имя>}} его не найдёт.
|
|
433
466
|
for (const field of ["saveStatusTo", "saveBodyTo"]) {
|
|
@@ -461,6 +494,25 @@ for (const n of nodes) {
|
|
|
461
494
|
}
|
|
462
495
|
if (a.kind === "gsheets_send" && (!a.googleEmail || !a.spreadsheetId || !Array.isArray(a.cells) || a.cells.length === 0))
|
|
463
496
|
errors.push(`ACTION_GSHEETS_INCOMPLETE: ${who} — gsheets_send требует googleEmail + spreadsheetId + непустой cells[] (Google-аккаунт подключается в вебе /bots, не через MCP).`);
|
|
497
|
+
if (a.kind === "group_kick") {
|
|
498
|
+
// «Выгнать или забанить» (зеркало GraphValidator.validateGroupKick)
|
|
499
|
+
if (!blank(a.mode) && a.mode !== "kick" && a.mode !== "ban")
|
|
500
|
+
errors.push(`GROUP_KICK_BAD_MODE: ${who} — group_kick.mode ∈ kick | ban.`);
|
|
501
|
+
if (!blank(a.untilDays)) {
|
|
502
|
+
const u = String(a.untilDays).trim();
|
|
503
|
+
if (a.mode === "kick")
|
|
504
|
+
errors.push(`GROUP_KICK_UNTIL_WITH_KICK: ${who} — untilDays в режиме kick не действует: человек может вернуться сразу.`);
|
|
505
|
+
else if (!u.includes("{{")) {
|
|
506
|
+
const d = Number(u.replace(",", "."));
|
|
507
|
+
if (!(d >= 1 && d <= 366 && Number.isInteger(d))) errors.push(`GROUP_KICK_BAD_UNTIL: ${who} — untilDays: число дней от 1 до 366 (пусто = навсегда).`);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
if (!blank(a.chatId)) {
|
|
511
|
+
const c = String(a.chatId).trim();
|
|
512
|
+
if (!c.includes("{{") && !/^(-?\d{1,20}|@[A-Za-z0-9_]{4,64}|https:\/\/t\.me\/[A-Za-z0-9_]{4,64})$/.test(c))
|
|
513
|
+
errors.push(`GROUP_KICK_BAD_CHAT: ${who} — chatId: числовой id, @username или шаблон {{var.*}}.`);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
464
516
|
if (a.kind === "send_email") {
|
|
465
517
|
// «Отправить письмо» (TASK-4): один получатель — email не массив, список адресов сюда не идёт.
|
|
466
518
|
if (blank(a.email)) errors.push(`ACTION_SEND_EMAIL_NO_RECIPIENT: ${who} — send_email требует email (адрес или {{var.*}}).`);
|
|
@@ -11,11 +11,13 @@ description: Сделать рассылку подписчикам ботов r
|
|
|
11
11
|
сценарий с триггером.
|
|
12
12
|
2. Собери сообщения (до 5) и уточни у пользователя: кому (теги), когда, по каким ботам. Тексты не выдумывай.
|
|
13
13
|
3. Медиа — `upload_file {path|url}` → `url` в `mediaUrl`/`mediaUrls`.
|
|
14
|
-
4. `broadcast_preview {botIds, tagsAll?, tagsNone?}` — назови пользователю число получателей и **дождись
|
|
14
|
+
4. `broadcast_preview {botIds, tagsAll?, tagsNone? | audienceFilter? | segmentId?}` — оцени охват, назови пользователю число получателей и **дождись
|
|
15
15
|
подтверждения**: рассылку не вернуть, а получатели списываются с месячной квоты тарифа.
|
|
16
16
|
5. `broadcast_send {name, botIds, messages, tagsAll?, tagsNone?, scheduledAt?}` → `{broadcastIds, totalAudience}`.
|
|
17
17
|
Не уверен в тексте — сначала `broadcast_drafts {action: "create", …}`: пользователь увидит черновик в кабинете,
|
|
18
18
|
отправка — `broadcast_send {draftId}` (черновик после отправки удаляется).
|
|
19
|
+
Повторно используемую аудиторию сохрани `audience_segments {action:"create", botId, name, audienceFilter}` и дальше передавай `segmentId`. Перед отправкой можно прислать тест себе: `broadcast_test {botId, messages}` (на привязанный Telegram владельца, без квоты).
|
|
20
|
+
«В 10:00 по времени каждого» - `localTime:"10:00"` (пояс из переменной `timezone` подписчика, иначе пояс бота; не с `dedup`/`stagger`). Ночью не беспокоить - `bot_delivery_window {action:"set", botId, enabled:true, quietFrom:"22:00", quietTo:"09:00"}`: рассылки в окне переносятся на его конец.
|
|
19
21
|
6. Статус — `broadcast_get {broadcastId}` (с причинами ошибок) / `broadcast_list`; остановить — `broadcast_cancel`.
|
|
20
22
|
Повторить или взять за основу — `broadcast_duplicate {broadcastId | draftId}` → черновик «… (копия)».
|
|
21
23
|
|
package/src/index.mjs
CHANGED
|
@@ -27,7 +27,9 @@ import os from "node:os";
|
|
|
27
27
|
import fs from "node:fs";
|
|
28
28
|
import path from "node:path";
|
|
29
29
|
|
|
30
|
-
const VERSION =
|
|
30
|
+
const VERSION = JSON.parse(
|
|
31
|
+
fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8")
|
|
32
|
+
).version;
|
|
31
33
|
/** С чего начать пустой сайт (init у /document/ops; на сайте с черновиком игнорируется). */
|
|
32
34
|
const SITE_INITS = ["starter", "blank", "mini-landing"];
|
|
33
35
|
/** Безвредная операция, когда нужен только init: бэкенд не принимает пустой ops[]. */
|
|
@@ -668,6 +670,12 @@ function uuidArg(v, label) {
|
|
|
668
670
|
if (!UUID_RE.test(s)) throw new Error(`${label} — UUID (например, из list_bots), получено: ${s || "пусто"}.`);
|
|
669
671
|
return s;
|
|
670
672
|
}
|
|
673
|
+
/** Номер версии сценария — целое ≥ 1 (приходит числом или строкой из JSON-клиента). */
|
|
674
|
+
function versionArg(v, label) {
|
|
675
|
+
const n = typeof v === "string" && /^\d+$/.test(v.trim()) ? Number(v) : v;
|
|
676
|
+
if (!Number.isInteger(n) || n < 1) throw new Error(`${label} — номер версии, целое ≥ 1 (см. graph_versions_list).`);
|
|
677
|
+
return n;
|
|
678
|
+
}
|
|
671
679
|
/** Тело vk_landing_create/vk_landing_update — одна форма на оба (PUT полностью заменяет поля). */
|
|
672
680
|
function landingBody(a) {
|
|
673
681
|
return {
|
|
@@ -890,6 +898,24 @@ function audienceFilterArg(v) {
|
|
|
890
898
|
return v;
|
|
891
899
|
}
|
|
892
900
|
|
|
901
|
+
/** "HH:mm" (24ч) или ошибка; пусто -> undefined. */
|
|
902
|
+
function hhmmArg(v, field) {
|
|
903
|
+
if (v == null || v === "") return undefined;
|
|
904
|
+
const s = String(v).trim();
|
|
905
|
+
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(s)) throw new Error(`${field}: формат HH:mm (24 часа), например 10:00.`);
|
|
906
|
+
return s;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/** Фильтр аудитории из segmentId (сохранённый сегмент бота) или audienceFilter; segmentId главнее. */
|
|
910
|
+
async function segmentFilterArg(a, botId) {
|
|
911
|
+
if (!a.segmentId) return audienceFilterArg(a.audienceFilter);
|
|
912
|
+
if (!botId) throw new Error("segmentId: передай botId (сегменты общие для ботов владельца, читаются через любой его бот).");
|
|
913
|
+
const list = await api(`/api/bots/${botId}/segments`);
|
|
914
|
+
const seg = (Array.isArray(list) ? list : []).find((s) => String(s.id) === String(a.segmentId));
|
|
915
|
+
if (!seg) throw new Error(`Сегмент ${a.segmentId} не найден (audience_segments action=list).`);
|
|
916
|
+
return seg.filter;
|
|
917
|
+
}
|
|
918
|
+
|
|
893
919
|
/** У Instagram-ботов рассылок нет (окно 24 ч Meta) — отказываем до запроса. */
|
|
894
920
|
async function assertBroadcastBots(botIds) {
|
|
895
921
|
if (!botIds.length) throw new Error("Передай botIds — id ботов (list_bots), по которым рассылать.");
|
|
@@ -1025,6 +1051,8 @@ const TOOLS = [
|
|
|
1025
1051
|
{ name: "patch_graph", description: "Точечная правка БОЛЬШОГО/живого графа без отправки графа целиком: сервер сам берёт граф по graphId, делает строковые замены в его JSON, проверяет валидность и заливает обратно НА МЕСТЕ (с авто-бэкапом). Идеально, когда граф слишком велик, чтобы передавать его целиком через update_graph/edit_graph_live — напр. сменить id канала в условиях SUBSCRIBED, ссылки кнопок, тексты. replacements: [{find, replace}] — заменяются ВСЕ вхождения; делай find максимально специфичным, чтобы не задеть лишнее. preview=true — только показать число совпадений, ничего не сохраняя. Бот применит изменения сразу только у опубликованного графа (читает активный граф заново из БД); патч черновика до бота не доходит. Результат для активного графа сервер проверяет как публикацию: ошибки → HTTP 422 со всеми code@nodeId, граф не изменён.", inputSchema: { type: "object", properties: { graphId: { type: "string" }, replacements: { type: "array", items: { type: "object", properties: { find: { type: "string" }, replace: { type: "string" } }, required: ["find", "replace"] } }, preview: { type: "boolean", description: "true = только отчёт о числе совпадений, без сохранения" }, backup: { type: "boolean", description: "снять авто-бэкап предыдущего состояния перед правкой (по умолчанию true)" } }, required: ["graphId", "replacements"] } },
|
|
1026
1052
|
{ name: "dry_run", description: "Прогнать сценарий без публикации. kind: command|callback|text. Внешние действия (HTTP, CRM, таблицы, письма, ИИ) пропускаются (skipped: dry-run). Статус прогона: OK, FAILED (прерван) или PARTIAL — «завершён с ошибками»: дошёл до конца, но хотя бы одно действие упало (у шага ok:false, error «КОД: …»).", inputSchema: { type: "object", properties: { graphId: { type: "string" }, kind: { type: "string", enum: ["command", "callback", "text"] }, value: { type: "string" }, fromUsername: { type: "string" }, presetVariables: { type: "object" }, presetTags: { type: "array", items: { type: "string" } } }, required: ["graphId", "kind", "value"] } },
|
|
1027
1053
|
{ name: "publish_graph", description: "Опубликовать граф. У бота ОДИН активный сценарий: публикация черновика заменяет содержимое активного (id живого графа сохраняется); прежняя активная версия, которой нет ни в одном черновике бота (правки вживую), сохраняется черновиком «… (была активной до дата время)». Вернёт publishedGraphId; при отказе проверок — ошибка HTTP 422 со всеми причинами построчно (code@nodeId: message). Сценарий-вебхук (источник WEBHOOK) этим инструментом не публикуется — HTTP 409, его публикуют в вебе. secondary:true — «опубликовать как ДОПОЛНИТЕЛЬНЫЙ сценарий»: граф становится живым НА МЕСТЕ (publishedGraphId = graphId, снимка нет) рядом с основным, основной сценарий и activeGraphId НЕ меняются; его запускают только VK-лендинги (targetGraphId) и ссылки link_create с targetGraphId. Те же проверки (422), у бота должен быть основной (SECONDARY_NO_MAIN), основной сам дополнительным не делается (SECONDARY_IS_MAIN); триггеры расписания/заявки с сайта/событий канала/комментариев/Instagram/вебхука — 422 SECONDARY_TRIGGER_UNSUPPORTED; режим decoupled — SECONDARY_UNSUPPORTED. У клона основного совпавшие id узлов при первой такой публикации меняются — перечитай граф. Снять — unpublish_graph.", inputSchema: { type: "object", properties: { graphId: { type: "string" }, secondary: { type: "boolean", description: "true — дополнительный сценарий рядом с основным (для лендингов/ссылок); по умолчанию false — основной" } }, required: ["graphId"] } },
|
|
1054
|
+
{ name: "graph_versions_list", description: "История версий сценария (GET /api/bots/graphs/{graphId}/versions): снимки узлов/рёбер, новые первыми — number, reason (PUBLISH публикация | MANUAL сохранено вручную | AUTO снимок состояния ДО правки живого сценария, не чаще раза в 10 мин | ROLLBACK откат), authorName, createdAt, note, restoredFrom, nodeCount, edgeCount, sizeBytes. Версии ведёт ОПУБЛИКОВАННЫЙ (живой) граф — для бот-сценария это publishedGraphId / граф с isActive:true из list_graphs, у черновика история пуста. Хранится последние 50 версий + все публикации и откаты за 90 дней. С version:N вернёт сравнение версии N с текущим графом (compareTo:'current', по умолчанию) или с другой версией (compareTo: номер): сколько узлов added/removed/changed, изменились ли рёбра, и список затронутых узлов — полный снимок не отдаётся, его смотрят в редакторе («История» → «Просмотр»). Нужно право read.", inputSchema: { type: "object", properties: { graphId: { type: "string" }, version: { type: "integer", description: "Номер версии — вернуть сравнение вместо списка" }, compareTo: { description: "С чем сравнить version: 'current' (по умолчанию) или номер другой версии", oneOf: [{ type: "string", enum: ["current"] }, { type: "integer" }] } }, required: ["graphId"] } },
|
|
1055
|
+
{ name: "graph_version_rollback", description: "Откатить сценарий на версию (POST /api/bots/graphs/{graphId}/versions/{version}/rollback). Откат = публикация снимка: у ЖИВОГО графа бот переходит на ту версию сразу, и она проходит те же проверки, что edit_graph_live (валидатор, платные блоки, лимит блоков, платформа) — при ошибках HTTP 422 со всеми code@nodeId, граф НЕ изменён. Состояние ДО отката сервер сам сохраняет отдельной версией (savedCurrentAs), результат — версией reason ROLLBACK (newVersion), так что откат обратим тем же инструментом. Узлы получают ТЕ ЖЕ id, что были в снимке. Это действие на живом боте: перед вызовом покажи пользователю graph_versions_list (version:N — что изменится) и получи согласие. Нужно право write:bots.", inputSchema: { type: "object", properties: { graphId: { type: "string" }, version: { type: "integer", description: "Номер версии из graph_versions_list" } }, required: ["graphId", "version"] } },
|
|
1028
1056
|
{ name: "unpublish_graph", description: "Снять ДОПОЛНИТЕЛЬНЫЙ сценарий с публикации (POST /api/bots/graphs/{graphId}/unpublish): он снова черновик, лендинги и ссылки на него ведут в основной сценарий, подписчики в нём возвращаются в основной, его задержки отменяются. Основной сценарий так не снимается — HTTP 409.", inputSchema: { type: "object", properties: { graphId: { type: "string" } }, required: ["graphId"] } },
|
|
1029
1057
|
{ name: "import_funnel", description: "Всё за раз: создать граф, залить узлы/рёбра, (опц.) dry-run /start, опубликовать. Граф можно передать инлайном (graph) или файлом (graphFile).", inputSchema: { type: "object", properties: { botId: { type: "string" }, name: { type: "string" }, graph: { type: "object" }, graphFile: { type: "string", description: "Путь к локальному JSON графа вместо инлайн graph; поддерживается ~" }, dryRun: { type: "boolean" }, publish: { type: "boolean" } }, required: ["botId"] } },
|
|
1030
1058
|
{ name: "list_templates", description: "Список готовых шаблонов воронок (id, имя, описание). Можно стартовать граф из шаблона вместо сборки с нуля.", inputSchema: { type: "object", properties: {} } },
|
|
@@ -1076,7 +1104,7 @@ const TOOLS = [
|
|
|
1076
1104
|
{ name: "agent_test_chat", description: "Проверить ответ агента в песочнице без отправки клиенту (POST /api/bots/agents/{agentId}/test-chat): question (1–1000 символов), history — необязательная история диалога [{role: client|agent, text}], до 12 реплик. ТРАТИТ бюджет ИИ, как настоящий ответ — не вызывай массово.", inputSchema: { type: "object", properties: { agentId: { type: "string" }, question: { type: "string" }, history: { type: "array", items: { type: "object", properties: { role: { type: "string", enum: ["client", "agent"] }, text: { type: "string" } } } } }, required: ["agentId", "question"] } },
|
|
1077
1105
|
{ name: "kb_docs", description: "Документы базы знаний агента (GET /api/bots/kb/{kbId}/docs): источник (FILE/QA/SITE), статус индексации, число фрагментов. Только верхний уровень — у сайта страницы видны счётчиком. Read-only.", inputSchema: { type: "object", properties: { kbId: { type: "string", description: "kbId агента (agent_get)" } }, required: ["kbId"] } },
|
|
1078
1106
|
{ name: "kb_add_qa", description: "Добавить пары вопрос-ответ в базу знаний (POST /api/bots/kb/{kbId}/docs/qa): pairs — [{question, answer}], до 200 пар за раз (вопрос ≤500 символов, ответ ≤4000). Каждая пара — отдельный фрагмент для поиска.", inputSchema: { type: "object", properties: { kbId: { type: "string" }, pairs: { type: "array", items: { type: "object", properties: { question: { type: "string" }, answer: { type: "string" } }, required: ["question", "answer"] } } }, required: ["kbId", "pairs"] } },
|
|
1079
|
-
{ name: "kb_add_text", description: "Добавить источник «Текст/инструкция» в базу знаний (POST /api/bots/kb/{kbId}/docs/text): title (≤120 символов), text (≤100 000 символов). Индексация уходит в фон — документ появится в kb_docs со статусом PENDING → READY.", inputSchema: { type: "object", properties: { kbId: { type: "string" }, title: { type: "string" }, text: { type: "string" } }, required: ["kbId", "title", "text"] } },
|
|
1107
|
+
{ name: "kb_add_text", description: "Добавить источник «Текст/инструкция» в базу знаний (POST /api/bots/kb/{kbId}/docs/text): title (≤120 символов), text (≤100 000 символов). Индексация уходит в фон — документ появится в kb_docs со статусом PENDING → READY. Агент видит найденные фрагменты (~800 знаков; текст до 2500 знаков хранится одним куском), а не документ целиком; чтобы выдавать текст дословно, используй узел сценария RANDOM_TEXT.", inputSchema: { type: "object", properties: { kbId: { type: "string" }, title: { type: "string" }, text: { type: "string" } }, required: ["kbId", "title", "text"] } },
|
|
1080
1108
|
{ name: "kb_add_site", description: "Добавить сайт в базу знаний обходом страниц (POST /api/bots/kb/{kbId}/docs/site): url (http/https, не внутренняя сеть), schedule — расписание повторного обхода: NEVER|DAILY|WEEKLY|MONTHLY (по умолчанию NEVER). Обход уходит в фон; документ появится в kb_docs со статусом PENDING.", inputSchema: { type: "object", properties: { kbId: { type: "string" }, url: { type: "string" }, schedule: { type: "string", enum: ["NEVER", "DAILY", "WEEKLY", "MONTHLY"] } }, required: ["kbId", "url"] } },
|
|
1081
1109
|
{ name: "kb_reindex", description: "Переиндексировать документ-файл базы знаний из сохранённого оригинала (POST /api/bots/kb/{kbId}/docs/{docId}/reindex) — «Повторить» после ошибки. headerRow — необязательно, для табличных файлов: номер строки с шапкой (1..50), если автоопределение ошиблось. Только для источника FILE.", inputSchema: { type: "object", properties: { kbId: { type: "string" }, docId: { type: "string" }, headerRow: { type: "number" } }, required: ["kbId", "docId"] } },
|
|
1082
1110
|
{ name: "agent_unanswered", description: "Вопросы без ответа агента за период (GET /api/bots/agents/{agentId}/unanswered): days — 7|30|90 (по умолчанию 30). Группы вопросов, на которые агент не нашёл ответ в базе знаний — подсказка, что туда добавить. Read-only.", inputSchema: { type: "object", properties: { agentId: { type: "string" }, days: { type: "number", enum: [7, 30, 90] } }, required: ["agentId"] } },
|
|
@@ -1097,7 +1125,7 @@ const TOOLS = [
|
|
|
1097
1125
|
// ---- Рассылки ----
|
|
1098
1126
|
{ 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" } } } },
|
|
1099
1127
|
{ name: "broadcast_get", description: "Рассылка целиком по id (GET /api/bots/broadcasts/{id}): статус, счётчики отправки, фильтр аудитории, сообщения, время. При failedJobs > 0 — ещё errors: топ-5 причин ошибок [{error, count}] (GET …/{id}/errors; детали живут 30 дней). Read-only.", inputSchema: { type: "object", properties: { broadcastId: { type: "string" } }, required: ["broadcastId"] } },
|
|
1100
|
-
{ name: "broadcast_preview", description: "
|
|
1128
|
+
{ name: "broadcast_preview", description: "Оценить охват до отправки: сколько подписчиков получат рассылку (POST /api/bots/{botId}/broadcasts/preview) — по каждому боту и всего. Фильтр: tagsAll — есть ВСЕ эти теги, tagsNone — нет НИ ОДНОГО; без тегов — все подписчики бота; либо полный audienceFilter; либо segmentId — сохранённый сегмент (audience_segments). Лимит — 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" } }, audienceFilter: AUDIENCE_FILTER_SCHEMA, segmentId: { type: "string", description: "id сохранённого сегмента (audience_segments action=list); главнее audienceFilter и тегов" } } } },
|
|
1101
1129
|
{ name: "broadcast_send", description: "Отправить рассылку сейчас или запланировать (scheduledAt). " +
|
|
1102
1130
|
"Прямая (по умолчанию, POST /api/bots/broadcasts/direct): name, botIds[] (1–20 ботов ОДНОГО владельца; по каждому создаётся своя рассылка), messages[] (1–5), tagsAll?/tagsNone? (фильтр по тегам). " +
|
|
1103
1131
|
"Сообщение: {type, text?, mediaUrl?, mediaUrls?, buttons?}; type: TEXT (text обязателен, до 4096) · PHOTO | VIDEO | AUDIO | FILE | VOICE (mediaUrl обязателен, text — подпись до 1024) · VIDEONOTE (кружок: mediaUrl, без текста) · GALLERY (mediaUrls: 2–10 картинок, подпись, БЕЗ кнопок). " +
|
|
@@ -1105,8 +1133,12 @@ const TOOLS = [
|
|
|
1105
1133
|
"По сценарию (graphId): POST /api/bots/{botId}/broadcasts — один botId, сценарий самого бота, entryNodeId? — с какого узла начать. " +
|
|
1106
1134
|
"draftId — отправить черновик (поля черновика, переданные аргументы их перекрывают); после отправки черновик удаляется. " +
|
|
1107
1135
|
"scheduledAt — ISO 8601 (без пояса — московское время); пусто — сразу. Немедленная резервирует квоту получателей тарифа, отложенная считает аудиторию в момент отправки. Рассылки только на платном тарифе — HTTP 402 со ссылкой. У Instagram-ботов рассылок нет. " +
|
|
1108
|
-
"audienceFilter — полный фильтр аудитории вместо tagsAll/tagsNone (см. свойство ниже; если задан, tagsAll/tagsNone игнорируются). dedup:true — «отправить каждому человеку только один раз» среди нескольких botIds (ключ — platform+userId/chatId приватного чата; человек уходит ПЕРВОМУ боту по порядку botIds, где у него активная сессия). stagger — «распределить по ботам» (см. свойство ниже). Любое из dedup/stagger → ответ вместо {broadcastIds, totalAudience} отдаёт {group:{id, status, perBot[]...}, isGroup:true} — статус/прогресс дальше смотри broadcast_group {action:\"get\", groupId}. Без них — как раньше: {broadcastIds, totalAudience}."
|
|
1109
|
-
|
|
1136
|
+
"audienceFilter — полный фильтр аудитории вместо tagsAll/tagsNone (см. свойство ниже; если задан, tagsAll/tagsNone игнорируются). dedup:true — «отправить каждому человеку только один раз» среди нескольких botIds (ключ — platform+userId/chatId приватного чата; человек уходит ПЕРВОМУ боту по порядку botIds, где у него активная сессия). stagger — «распределить по ботам» (см. свойство ниже). Любое из dedup/stagger → ответ вместо {broadcastIds, totalAudience} отдаёт {group:{id, status, perBot[]...}, isGroup:true} — статус/прогресс дальше смотри broadcast_group {action:\"get\", groupId}. Без них — как раньше: {broadcastIds, totalAudience}. " +
|
|
1137
|
+
"segmentId — сохранённый сегмент аудитории вместо audienceFilter (audience_segments). localTime \"HH:mm\" — слать КАЖДОМУ подписчику в это время по его часам (пояс из переменной timezone подписчика, иначе пояс бота; первый такой момент не раньше отправки/scheduledAt); не сочетается с dedup/stagger. Тихие часы бота (bot_delivery_window) применяются к рассылкам всегда: попавшие в окно получатели ждут его конца.",
|
|
1138
|
+
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" } }, audienceFilter: AUDIENCE_FILTER_SCHEMA, scheduledAt: { type: "string", description: "ISO 8601, напр. 2026-10-06T10:00:00+03:00; пусто — сразу" }, graphId: { type: "string", description: "рассылка запуском сценария бота вместо сообщений" }, entryNodeId: { type: "string" }, draftId: { type: "string" }, segmentId: { type: "string", description: "id сохранённого сегмента аудитории (audience_segments); главнее audienceFilter и тегов" }, localTime: { type: "string", description: "HH:mm — слать каждому в это время по его часам (не с dedup/stagger)" }, dedup: { type: "boolean", description: "Не отправлять дважды одному человеку среди нескольких botIds (мульти-бот)" }, stagger: STAGGER_SCHEMA } } },
|
|
1139
|
+
{ name: "audience_segments", description: "Сохранённые сегменты аудитории рассылок (имя + фильтр; /api/bots/{botId}/segments; общие для всех ботов владельца, до 50). action: list — сегменты {id, name, filter}; create {name, audienceFilter | tagsAll/tagsNone} — сохранить фильтр под именем (до 60 символов); delete {segmentId}. Использовать: broadcast_send / broadcast_preview {segmentId}. botId — любой твой бот.", inputSchema: { type: "object", properties: { action: { type: "string", enum: ["list", "create", "delete"] }, botId: { type: "string" }, name: { type: "string" }, audienceFilter: AUDIENCE_FILTER_SCHEMA, tagsAll: { type: "array", items: { type: "string" } }, tagsNone: { type: "array", items: { type: "string" } }, segmentId: { type: "string" } }, required: ["action", "botId"] } },
|
|
1140
|
+
{ name: "bot_delivery_window", description: "Тихие часы бота (/api/bots/{botId}/delivery-window): в окно quietFrom..quietTo (HH:mm, может переходить через полночь, например 22:00..09:00) рассылки не уходят, а переносятся на конец окна. Время считается по поясу подписчика (переменная timezone/tz), если неизвестен — по поясу бота timezone (IANA или UTC+3, пусто = Москва). По умолчанию выключено. action: get · set {enabled, quietFrom?, quietTo?, timezone?} (enabled:true требует quietFrom и quietTo, они должны различаться; enabled:false выключает, время сохраняется).", inputSchema: { type: "object", properties: { action: { type: "string", enum: ["get", "set"] }, botId: { type: "string" }, enabled: { type: "boolean" }, quietFrom: { type: "string", description: "HH:mm" }, quietTo: { type: "string", description: "HH:mm" }, timezone: { type: "string", description: "IANA (Europe/Moscow) или UTC+3" } }, required: ["action", "botId"] } },
|
|
1141
|
+
{ name: "broadcast_test", description: "Отправить тест рассылки себе (POST /api/bots/{botId}/broadcasts/test): те же сообщения, что у broadcast_send, на твой привязанный Telegram (бот должен быть Telegram, а ты — нажать /start у этого бота; иначе 422 owner_chat_not_linked или 502 от Telegram) либо в чат подписчика chatId этого бота (так тестируют MAX/VK). Мимо квоты, очереди и тихих часов, подписчикам ничего не уходит.", inputSchema: { type: "object", properties: { botId: { type: "string" }, messages: { type: "array", items: {} }, chatId: { type: "number", description: "чат подписчика этого бота; пусто — привязанный Telegram владельца" } }, required: ["botId", "messages"] } },
|
|
1110
1142
|
{ name: "broadcast_cancel", description: "Отменить рассылку (POST /api/bots/broadcasts/{id}/cancel): запланированная не уйдёт, идущая остановится (статус CANCELLING → CANCELLED). Уже завершённую отменить нельзя — HTTP 409. Для группы (dedup/stagger) — broadcast_group {action:\"cancel\"}.", inputSchema: { type: "object", properties: { broadcastId: { type: "string" } }, required: ["broadcastId"] } },
|
|
1111
1143
|
{ name: "broadcast_recurring", description: "Повторяющиеся рассылки (/api/bots/broadcasts/recurring). action: list — правила (активные и остановленные); create {name, botIds[], messages[], tagsAll?/tagsNone? или audienceFilter?, recurrence: DAILY|MONTHLY|YEARLY, firstRunAt, dedup?, stagger?} — сообщения и фильтр как в broadcast_send; dedup/stagger заданы — КАЖДЫЙ прогон правила порождает свою отдельную группу (своя дедупликация с нуля, аудитория у повтора может быть другой); 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" } }, audienceFilter: AUDIENCE_FILTER_SCHEMA, recurrence: { type: "string", enum: ["DAILY", "MONTHLY", "YEARLY"] }, firstRunAt: { type: "string" }, dedup: { type: "boolean" }, stagger: STAGGER_SCHEMA }, required: ["action"] } },
|
|
1112
1144
|
{ 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"] } },
|
|
@@ -1763,6 +1795,15 @@ async function handleCall(params) {
|
|
|
1763
1795
|
await api(`/api/bots/${botId}/channels/${channelId}`, { method: "DELETE" });
|
|
1764
1796
|
return okResult(`🗑️ Канал ${channelId} отключён от бота ${botId}.`);
|
|
1765
1797
|
}
|
|
1798
|
+
case "graph_versions_list": {
|
|
1799
|
+
const graphId = uuidArg(a.graphId, "graphId");
|
|
1800
|
+
if (a.version == null) return okResult(await api(`/api/bots/graphs/${graphId}/versions`));
|
|
1801
|
+
const n = versionArg(a.version, "version");
|
|
1802
|
+
const to = a.compareTo == null || a.compareTo === "current" ? "current" : versionArg(a.compareTo, "compareTo");
|
|
1803
|
+
return okResult(await api(`/api/bots/graphs/${graphId}/versions/${n}/compare?to=${to}`));
|
|
1804
|
+
}
|
|
1805
|
+
case "graph_version_rollback":
|
|
1806
|
+
return okResult(await api(`/api/bots/graphs/${uuidArg(a.graphId, "graphId")}/versions/${versionArg(a.version, "version")}/rollback`, { method: "POST" }));
|
|
1766
1807
|
case "unpublish_graph":
|
|
1767
1808
|
return okResult(await api(`/api/bots/graphs/${uuidArg(a.graphId, "graphId")}/unpublish`, { method: "POST" }));
|
|
1768
1809
|
case "link_create": {
|
|
@@ -2119,7 +2160,7 @@ async function handleCall(params) {
|
|
|
2119
2160
|
case "broadcast_preview": {
|
|
2120
2161
|
const ids = botIdsOf(a);
|
|
2121
2162
|
await assertBroadcastBots(ids);
|
|
2122
|
-
const filter = { tagsAll: strList(a.tagsAll), tagsNone: strList(a.tagsNone) };
|
|
2163
|
+
const filter = (await segmentFilterArg(a, ids[0])) || { tagsAll: strList(a.tagsAll), tagsNone: strList(a.tagsNone) };
|
|
2123
2164
|
const perBot = [];
|
|
2124
2165
|
for (const id of ids) {
|
|
2125
2166
|
const r = await api(`/api/bots/${id}/broadcasts/preview`, { method: "POST", body: filter });
|
|
@@ -2133,7 +2174,7 @@ async function handleCall(params) {
|
|
|
2133
2174
|
if (ids.length !== 1) throw new Error("Рассылка по сценарию — ровно один botId (сценарий принадлежит боту).");
|
|
2134
2175
|
await assertBroadcastBots(ids);
|
|
2135
2176
|
if (!a.name) throw new Error("Передай name рассылки.");
|
|
2136
|
-
const audienceFilter =
|
|
2177
|
+
const audienceFilter = (await segmentFilterArg(a, ids[0])) || { tagsAll: strList(a.tagsAll), tagsNone: strList(a.tagsNone) };
|
|
2137
2178
|
const b = await api(`/api/bots/${ids[0]}/broadcasts`, { method: "POST", body: {
|
|
2138
2179
|
name: a.name, audienceFilter,
|
|
2139
2180
|
graphId: a.graphId, entryNodeId: a.entryNodeId || undefined, scheduledAt: toInstant(a.scheduledAt, "scheduledAt") } });
|
|
@@ -2146,9 +2187,11 @@ async function handleCall(params) {
|
|
|
2146
2187
|
await assertBroadcastBots(ids);
|
|
2147
2188
|
const name = String(pick("name") || "").trim();
|
|
2148
2189
|
if (!name) throw new Error("Передай name рассылки (видно только тебе в списке рассылок).");
|
|
2149
|
-
const audienceFilter =
|
|
2190
|
+
const audienceFilter = await segmentFilterArg(a, ids[0]);
|
|
2150
2191
|
const dedup = a.dedup === true;
|
|
2151
2192
|
const stagger = staggerArg(a.stagger);
|
|
2193
|
+
const localTime = hhmmArg(a.localTime, "localTime");
|
|
2194
|
+
if (localTime && (dedup || stagger)) throw new Error("localTime не сочетается с dedup/stagger: время группы задаёт stagger.startAt.");
|
|
2152
2195
|
const body = {
|
|
2153
2196
|
name, botIds: ids,
|
|
2154
2197
|
...(audienceFilter ? { audienceFilter } : {
|
|
@@ -2159,6 +2202,7 @@ async function handleCall(params) {
|
|
|
2159
2202
|
scheduledAt: toInstant(pick("scheduledAt"), "scheduledAt"),
|
|
2160
2203
|
...(dedup ? { dedup } : {}),
|
|
2161
2204
|
...(stagger ? { stagger } : {}),
|
|
2205
|
+
...(localTime ? { localTime } : {}),
|
|
2162
2206
|
};
|
|
2163
2207
|
if (body.scheduledAt && new Date(body.scheduledAt).getTime() <= Date.now()) {
|
|
2164
2208
|
// Прошедшее время бэкенд молча считает «сейчас» — для черновика с устаревшей датой это сюрприз.
|
|
@@ -2172,6 +2216,44 @@ async function handleCall(params) {
|
|
|
2172
2216
|
if (dedup || stagger) return okResult({ group: r, isGroup: true, ...(a.draftId ? { draftDeleted } : {}) });
|
|
2173
2217
|
return okResult({ ...r, scheduledAt: body.scheduledAt, ...(a.draftId ? { draftDeleted } : {}) });
|
|
2174
2218
|
}
|
|
2219
|
+
case "audience_segments": {
|
|
2220
|
+
const base = `/api/bots/${a.botId}/segments`;
|
|
2221
|
+
if (!a.botId) throw new Error("Передай botId (любой твой бот, list_bots).");
|
|
2222
|
+
if (a.action === "list") return okResult(await api(base));
|
|
2223
|
+
if (a.action === "create") {
|
|
2224
|
+
const name = String(a.name || "").trim();
|
|
2225
|
+
if (!name || name.length > 60) throw new Error("name: 1–60 символов.");
|
|
2226
|
+
const filter = audienceFilterArg(a.audienceFilter) || { tagsAll: strList(a.tagsAll), tagsNone: strList(a.tagsNone) };
|
|
2227
|
+
return okResult(await api(base, { method: "POST", body: { name, filter } }));
|
|
2228
|
+
}
|
|
2229
|
+
if (a.action === "delete") {
|
|
2230
|
+
if (!a.segmentId) throw new Error("Передай segmentId (audience_segments action=list).");
|
|
2231
|
+
await api(`${base}/${a.segmentId}`, { method: "DELETE" });
|
|
2232
|
+
return okResult(`Сегмент ${a.segmentId} удалён.`);
|
|
2233
|
+
}
|
|
2234
|
+
throw new Error("action: list | create | delete.");
|
|
2235
|
+
}
|
|
2236
|
+
case "bot_delivery_window": {
|
|
2237
|
+
if (!a.botId) throw new Error("Передай botId (list_bots).");
|
|
2238
|
+
const path = `/api/bots/${a.botId}/delivery-window`;
|
|
2239
|
+
if (a.action === "get") return okResult(await api(path));
|
|
2240
|
+
if (a.action === "set") {
|
|
2241
|
+
if (typeof a.enabled !== "boolean") throw new Error("enabled: true/false.");
|
|
2242
|
+
const quietFrom = hhmmArg(a.quietFrom, "quietFrom");
|
|
2243
|
+
const quietTo = hhmmArg(a.quietTo, "quietTo");
|
|
2244
|
+
if (a.enabled && (!quietFrom || !quietTo)) throw new Error("enabled:true требует quietFrom и quietTo (HH:mm).");
|
|
2245
|
+
if (quietFrom && quietFrom === quietTo) throw new Error("quietFrom и quietTo должны различаться.");
|
|
2246
|
+
const body = { enabled: a.enabled, quietFrom: quietFrom ?? null, quietTo: quietTo ?? null, timezone: a.timezone ? String(a.timezone).trim() : null };
|
|
2247
|
+
return okResult(await api(path, { method: "PUT", body }));
|
|
2248
|
+
}
|
|
2249
|
+
throw new Error("action: get | set.");
|
|
2250
|
+
}
|
|
2251
|
+
case "broadcast_test": {
|
|
2252
|
+
if (!a.botId) throw new Error("Передай botId (list_bots).");
|
|
2253
|
+
const body = { messages: normalizeBroadcastMessages(a.messages), chatId: a.chatId == null ? null : Number(a.chatId) };
|
|
2254
|
+
if (body.chatId != null && !Number.isFinite(body.chatId)) throw new Error("chatId — число.");
|
|
2255
|
+
return okResult(await api(`/api/bots/${a.botId}/broadcasts/test`, { method: "POST", body }));
|
|
2256
|
+
}
|
|
2175
2257
|
case "broadcast_cancel":
|
|
2176
2258
|
await api(`/api/bots/broadcasts/${a.broadcastId}/cancel`, { method: "POST" });
|
|
2177
2259
|
return okResult(`⏹️ Рассылка ${a.broadcastId} отменяется (CANCELLING → CANCELLED).`);
|