@retensy/mcp 0.17.0 → 0.19.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 +26 -2
- package/package.json +2 -2
- package/skills/build-bot-funnel/SKILL.md +3 -0
- package/skills/build-bot-funnel/reference/schema.md +28 -6
- package/skills/build-bot-funnel/reference/validation.md +4 -3
- package/skills/build-bot-funnel/validate.mjs +45 -5
- package/src/index.mjs +281 -20
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
MCP-сервер (+ скилл для Claude Code) для **сборки и публикации воронок/автоматизаций ботов (Telegram, MAX и Instagram)** в сервисе [retensy `/bots`](https://bots.retensy.com/bots): из текстового описания → валидный граф сценария → заливка и публикация через API.
|
|
9
9
|
|
|
10
10
|
- 🤖 **Сценарии ботов**: `list_bots`, `list_graphs`, `list_channels`, `get_graph`, `create_graph`, `update_graph`, `edit_graph_live`, `patch_graph`, `dry_run`, `publish_graph`, `import_funnel`, `list_templates`, `create_graph_from_template`, `clone_graph`, `copy_graph`, `rename_graph`, `set_active_graph`, `delete_graph`, `upload_file`, `list_files`, `delete_file`, `graph_analytics`, `list_bot_users`, `list_links` (+ `setup`/`set_token`).
|
|
11
|
-
- 🔌 **Боты и сервисы**: `create_bot` (Telegram/MAX по токену), `bot_stop`/`bot_resume`, `connect_integration`/`disconnect_integration` (amoCRM, Битрикс24, GetCourse, Я.Метрика, ЮKassa; Google Таблицы — ссылкой на вход Google; креды хранятся зашифрованными и не возвращаются), `integration_catalog`/`integration_status`/`integration_test` (каталог Integration Core и живая проверка подключения), `channel_post` (пост в канал Telegram/MAX).
|
|
11
|
+
- 🔌 **Боты и сервисы**: `create_bot` (Telegram/MAX по токену), `bot_stop`/`bot_resume`, `connect_integration`/`disconnect_integration` (amoCRM, Битрикс24, GetCourse, Я.Метрика, ЮKassa; Google Таблицы — ссылкой на вход Google; креды хранятся зашифрованными и не возвращаются), `integration_catalog`/`integration_status`/`integration_test` (каталог Integration Core и живая проверка подключения), `integration_ingress_url` (URL ингресса «Внешних событий» — Tilda, JivoSite, WooCommerce, amoCRM, Битрикс24, GetCourse, CloudPayments, Robokassa, Т-Банк, Prodamus — для `TRIGGER_WEBHOOK`), `channel_post` (пост в канал Telegram/MAX).
|
|
12
12
|
- 📣 **Рассылки**: `broadcast_preview` (размер аудитории), `broadcast_send` (сейчас или по расписанию, сразу по нескольким ботам; сообщения или запуск сценария), `broadcast_list`/`broadcast_get`/`broadcast_cancel`, повторяющиеся (`broadcast_recurring`), черновики (`broadcast_drafts`, `broadcast_duplicate`) + скилл `send-broadcast`.
|
|
13
13
|
- 🔗 **Ссылка вместо отказа**: что нельзя сделать через API (вход через Google/Facebook, оплата тарифа, вход в аккаунт) — инструмент возвращает прямую ссылку и одну строку, что сделать.
|
|
14
14
|
- 📝 **Статьи блога** (тот же токен `zmcp_…`): `article_publish`, `article_update`, `article_list`, `article_get` — публикация статей в Markdown (как README на GitHub) в раздел **/articles**.
|
|
@@ -97,7 +97,8 @@ MCP-сервер (+ скилл для Claude Code) для **сборки и пу
|
|
|
97
97
|
| `setup` | статус авторизации + пошаговая инструкция подключения |
|
|
98
98
|
| `set_token` | сохранить присланный токен `zmcp_…` (без env/рестарта) |
|
|
99
99
|
| `list_bots` | список ботов |
|
|
100
|
-
| `create_bot(platform, token
|
|
100
|
+
| `create_bot(platform, token?, name?)` | подключить бота Telegram/MAX по токену; `WEB` — чат-виджет для сайта без токена → botId, key, snippet (Instagram → ссылка на кабинет) |
|
|
101
|
+
| `web_widget_snippet(botId)` | код вставки чат-виджета на сайт |
|
|
101
102
|
| `bot_stop(botId)` / `bot_resume(botId)` | остановить / запустить бота |
|
|
102
103
|
| `list_graphs(botId)` | графы (сценарии) бота |
|
|
103
104
|
| `list_channels(botId)` | каналы/группы, подключённые к боту (chatId для условия SUBSCRIBED) |
|
|
@@ -107,6 +108,7 @@ MCP-сервер (+ скилл для Claude Code) для **сборки и пу
|
|
|
107
108
|
| `integration_catalog()` | каталог Integration Core: поля подключения (`configSchema`) и действия (`actions[].kind`) сервисов |
|
|
108
109
|
| `integration_status(connectionId)` | статус подключения: `OK` / `NEEDS_REAUTH` / `ERROR` / `UNKNOWN`, последняя проверка и ошибка |
|
|
109
110
|
| `integration_test(connectionId)` | живая проверка ключа во внешнем сервисе от имени владельца (без побочных эффектов) |
|
|
111
|
+
| `integration_ingress_url(connectionId, rotate?)` | URL ингресса для триггера «Внешние события» (`TRIGGER_WEBHOOK` с `provider`+`connectionId`+`event`); `rotate:true` — выпустить новый, старый отключается сразу |
|
|
110
112
|
| `channel_post(botId, chatId, text?, mediaUrl?)` | разовый пост в канал/группу Telegram или MAX (раздел «Публикации»); файл — из `upload_file` |
|
|
111
113
|
| `get_graph(graphId, [summary], [saveToFile])` | получить граф; `summary:true` — компактная сводка (id/type/title + рёбра), `saveToFile` — записать полный JSON на диск (для больших графов, чтобы не упереться в лимит токенов) |
|
|
112
114
|
| `create_graph(botId, name)` | создать пустой граф (DRAFT) |
|
|
@@ -165,6 +167,28 @@ MCP-сервер (+ скилл для Claude Code) для **сборки и пу
|
|
|
165
167
|
| `kb_add_text(kbId, title, text)` | добавить источник «Текст/инструкция» |
|
|
166
168
|
| `kb_add_site(kbId, url, schedule?)` | добавить сайт обходом страниц; `schedule`: `NEVER`\|`DAILY`\|`WEEKLY`\|`MONTHLY` |
|
|
167
169
|
| `kb_reindex(kbId, docId, headerRow?)` | переиндексировать файл из оригинала («Повторить»); `headerRow` — для таблиц, если шапка определилась неверно |
|
|
170
|
+
| `kb_list()` / `kb_create(name)` | базы знаний: список / новая (база агента создаётся с ним — `kbId` в `agent_get`) (id → `knowledgeBaseId` узла `AI_REPLY mode:"agent"`) |
|
|
171
|
+
| `kb_delete_doc(kbId, docId)` | удалить документ из базы знаний вместе с фрагментами |
|
|
172
|
+
| `bot_user_get(botId, chatId)` | карточка подписчика: теги, переменные, ai_summary |
|
|
173
|
+
| `bot_user_runs(botId, chatId)` | журнал запусков с шагами — проверка, что сценарий реально выполнил действия (CRM, уведомление, HTTP) |
|
|
174
|
+
| `dialog_messages(botId, chatId)` / `dialog_reply(botId, chatId, text)` | переписка с подписчиком / ответ оператора (уходит реальному человеку) |
|
|
175
|
+
| `dialog_handoff(botId, chatId, active)` | передать диалог оператору (бот и ИИ молчат) / вернуть боту ¹ |
|
|
176
|
+
| `bot_users_import(botId, rows)` | добавить подписчикам метки и новые поля (`rows: [{chatId, tags?, variables?}]`; существующие поля не перезаписываются) |
|
|
177
|
+
| `bot_runs(botId` или `runId)` | журнал прогонов бота / один прогон с шагами (статус `PARTIAL` — «завершён с ошибками») |
|
|
178
|
+
| `scenario_runs(graphId, page?, size?)` | журнал прогонов одного сценария, включая headless (вебхук, заявки сайта, `TRIGGER_SCHEDULE`) ¹ |
|
|
179
|
+
| `bot_delete(botId, confirm:true)` | удалить бота навсегда (только по явной просьбе) |
|
|
180
|
+
| `web_widget_settings(botId, settings?)` | вид чат-виджета: прочитать / изменить (поля накладываются на текущие) |
|
|
181
|
+
| `integration_update(connectionId, title?, creds?)` | переименовать подключение или заменить ключи |
|
|
182
|
+
| `integration_calls(connectionId?, ok?, limit?)` | журнал вызовов внешних сервисов из сценариев (ошибки, попытки, runId) ¹ |
|
|
183
|
+
| `site_lead_status(siteId, leadId, status)` | статус заявки сайта: `NEW`\|`IN_PROGRESS`\|`DONE`\|`REJECTED` (фильтр — `site_leads(status)`) ¹ |
|
|
184
|
+
|
|
185
|
+
| `booking_calendar_list()` / `booking_calendar_get(calendarId)` | календари записи (id → `calendarId` действий сценария `booking_*`) ¹ |
|
|
186
|
+
| `booking_calendar_create(name, zone?, slotMinutes?, hours?, exceptions?, botId?)` | новый календарь: окна `{day:1–7, from:"HH:mm", to:"HH:mm"}`, исключения `{date, from?, to?}` ¹ |
|
|
187
|
+
| `booking_calendar_update(calendarId, …поля)` / `booking_calendar_delete(calendarId, confirm:true)` | правка (поля накладываются на текущие) / удаление ¹ |
|
|
188
|
+
| `booking_slots(calendarId, from?, to?, limit?)` | свободные слоты `[{at, label}]`, даты ГГГГ-ММ-ДД в зоне календаря ¹ |
|
|
189
|
+
| `booking_list(calendarId, from?)` / `booking_create(calendarId, slotAt, name?, phone?)` / `booking_cancel(calendarId, bookingId)` | брони: список / ручная запись (занятый слот — ошибка «занят») / отмена ¹ |
|
|
190
|
+
|
|
191
|
+
¹ — нужен бэкенд с веткой `feat/battery-completion` (до деплоя вернёт 404).
|
|
168
192
|
|
|
169
193
|
### Рассылки
|
|
170
194
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@retensy/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.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",
|
|
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/validate-graph.test.mjs",
|
|
25
25
|
"validate": "node skills/build-bot-funnel/validate.mjs"
|
|
26
26
|
},
|
|
27
27
|
"engines": {
|
|
@@ -39,6 +39,9 @@ description: Собрать воронку (сценарий) бота для с
|
|
|
39
39
|
- **не ждать** — ветку `paid` не рисуй, блок просто пришлёт счёт и пойдёт по `next`; выдать доступ потом сможет отдельный сценарий с триггером `TRIGGER_PAYMENT`.
|
|
40
40
|
Счёт обязан быть **последним** действием в блоке и только одним (`INVOICE_NOT_LAST`, `INVOICE_DUPLICATE`).
|
|
41
41
|
- **`TRIGGER_PAYMENT` («оплата прошла»)** — если счёт выставлял бот, подписчик определяется САМ (счёт помнит его в metadata платежа): можно сразу слать сообщение и вешать метку, разбирать ответ кассы не нужно. Если оплата пришла мимо бота — прогон headless, получателя задавай через `config.target`. Ставь фильтр `minAmount`/`descriptionContains`, иначе сценарий сработает на любую оплату в кассе.
|
|
42
|
+
- **`TRIGGER_SCHEDULE` («По расписанию»)** — `{cron:"0 9 * * 1-5", timezone}`: headless-прогон без чата (отправка — только с `config.target`), для сводок, эскалаций, SLA. Журнал — `scenario_runs`.
|
|
43
|
+
- **Запись на слоты и напоминание**: календарь создаётся `booking_calendar_create`; в `ACTIONS` — `booking_slots` (список в `{{var.slots}}`) → вопрос → `booking_book` (ветка `taken` — слот занят) → `DELAY UNTIL {{var.slot_at}}` с `offset:"-24h"` → напоминание; `booking_cancel` освобождает слот. **Подписки**: `invite_link_create`/`invite_link_revoke`, `subscription_extend`/`subscription_check`, `yookassa_charge_saved`. **Передача оператору** — `agent_chat` (бот замолкает до «Вернуть боту»). Поля — в schema.md.
|
|
44
|
+
- **Статус прогона `PARTIAL`** («завершён с ошибками») — сценарий дошёл до конца, но действие упало (шаг `ok:false`, `error` «КОД: …»). Проверяй `bot_user_runs`/`scenario_runs`, а ответы CRM/HTTP — `integration_calls`.
|
|
42
45
|
- **`TRIGGER_SITE_FORM` («Заявка с сайта»)** — `{siteId, formId}`: посетитель отправил форму на ОПУБЛИКОВАННОМ сайте владельца (`site_list` → id сайта, id формы — из `site_get`). Пустой/отсутствующий `formId` = любая форма сайта. Поля формы — `{{var.<имя поля>}}`; имя, почта, телефон всегда доступны как `{{var.name}}`, `{{var.email}}`, `{{var.phone}}` (если поле названо иначе — находятся по подписи), UTM — `{{var.utm_source}}` и т.п. Чата с посетителем нет: узлы отправки работают только с явным `config.target`, CRM/таблицы/вебхуки — как обычно. Обычно это сценарий без бота (источник WEBHOOK, как вебхук-сценарий) — ему `TRIGGER_WEBHOOK` не нужен; можно и в сценарии бота. Чужой сайт → `SITE_FORM_FOREIGN_SITE`, без сайта → `SITE_FORM_NO_SITE`. Одна заявка — один запуск; `TRIGGER_ANY_UPDATE` на заявки не срабатывает.
|
|
43
46
|
|
|
44
47
|
4. **Проверь локально** перед заливкой:
|
|
@@ -72,12 +72,29 @@
|
|
|
72
72
|
если не подошёл ни один конкретный триггер. Удобен как «ничего не понял» / отладка.
|
|
73
73
|
- `TRIGGER_WEBHOOK` — `{}`, точка входа сценария с источником WEBHOOK (не бот). Такие сценарии
|
|
74
74
|
создаются в вебе; у графа бота этот триггер не сработает.
|
|
75
|
+
- **Внешние события интеграции** — тот же `TRIGGER_WEBHOOK`, но с заполненными
|
|
76
|
+
`{ "provider": "<provider-ключ из integration_catalog, нижний регистр>", "connectionId": "<id из list_integrations>",
|
|
77
|
+
"event": "<events[].key нужного события из inbound.events того же provider в integration_catalog>" }`.
|
|
78
|
+
Без `provider`+`connectionId` узел — обычный вебхук с произвольным телом; с ними URL ингресса
|
|
79
|
+
(`integration_ingress_url`) ведёт на обработчик конкретного провайдера, и запрос принимается только
|
|
80
|
+
в формате, который он понимает (подпись/HMAC сервиса проверяется на бэке). Переменные события —
|
|
81
|
+
`{{body.<ключ>}}`, ключи и примеры — в `inbound.events[].vars` того же события каталога.
|
|
82
|
+
`event: "<key>"` с `paymentSucceeded:true` (кассы ЮKassa/CloudPayments/Robokassa/Т-Банк/Prodamus) помимо
|
|
83
|
+
этого же `{{body.<ключ>}}`-прогона ЕЩЁ запускает узел «Оплата прошла» ваших графов — headless (без чата)
|
|
84
|
+
либо в чате подписчика, если платёжную ссылку выдавало действие `*_link`/`issue_invoice` с привязкой
|
|
85
|
+
к нему. Тестовый/demo-платёж (`test_mode`/`demo_mode` в переменных) прогоняет только обычный вебхук —
|
|
86
|
+
«Оплата прошла» на нём не срабатывает.
|
|
75
87
|
- `TRIGGER_SITE_FORM` — `{ "siteId": "<uuid сайта>", "formId": "<id формы>" }` — заявка с формы
|
|
76
88
|
опубликованного сайта владельца. `formId` пустой/нет = любая форма сайта. `siteId` обязателен
|
|
77
89
|
(`SITE_FORM_NO_SITE`), сайт должен быть свой (`SITE_FORM_FOREIGN_SITE`). Переменные: поля формы
|
|
78
90
|
`{{var.<имя>}}`, всегда `{{var.name}}`/`{{var.email}}`/`{{var.phone}}` (по имени поля, иначе по подписи),
|
|
79
91
|
UTM `{{var.utm_*}}`. Прогон без чата: узлы отправки — только с `config.target`. Работает и в сценарии
|
|
80
92
|
без бота (источник WEBHOOK — тогда `TRIGGER_WEBHOOK` не обязателен), и в сценарии бота; не для Instagram.
|
|
93
|
+
- `TRIGGER_SCHEDULE` — `{ "cron": "0 9 * * 1-5", "timezone": "Europe/Moscow" }` — запуск сценария по расписанию:
|
|
94
|
+
cron из 5 полей (минута час день месяц день-недели), не чаще раза в минуту; зона по умолчанию `Europe/Moscow`.
|
|
95
|
+
Прогон **headless** (без чата): узлы отправки — только с явным `config.target`, действия, которым нужен
|
|
96
|
+
подписчик, пропускаются; время срабатывания — `{{schedule.at}}`. Запуски, пропущенные во время простоя сервиса,
|
|
97
|
+
не догоняются. Работает в опубликованном активном сценарии (бота или вебхук-сценарии). Журнал — `scenario_runs`.
|
|
81
98
|
- ~~`TRIGGER_COMMENT`~~ — **мёртвый тип**: рантайм нигде не выставляет `event="comment"`, сработать
|
|
82
99
|
он не может. Убран из палитры редактора. Комментарии Instagram — `TRIGGER_IG_COMMENT`.
|
|
83
100
|
|
|
@@ -133,8 +150,8 @@ IG-боты не поддерживают команды (`/start`). Вход
|
|
|
133
150
|
- `DELAY` — пауза. Три вида (`kind`):
|
|
134
151
|
- **`FIXED`** («Отправить через»): `{ "kind":"FIXED", "durationSec": 86400 }` — `durationSec` в **секундах** (60 = 1 мин, 3600 = 1 час, 86400 = 1 сутки). Редактор также пишет `{ "kind":"FIXED", "duration": 24, "unit":"MINUTES"|"HOURS"|"DAYS" }` (минуты/часы/дни — чтобы не вбивать большие числа). Для генерации проще `durationSec`.
|
|
135
152
|
- **`TOMORROW`** («Отправить завтра»): `{ "kind":"TOMORROW", "time":"18:00" }` — завтра в указанное время `HH:mm` (МСК), относительно момента, когда пользователь дошёл до узла.
|
|
136
|
-
- **`UNTIL`** («Отправить в»): `{ "kind":"UNTIL", "isoTimestamp":"2026-06-25T15:00:00Z" }` — конкретный момент в ISO-8601
|
|
137
|
-
- `SCHEDULE` — `{ "isoDate":"2026-06-25", "time":"18:00", "timezone":"Europe/Moscow" }`. Выходы `scheduled` / `past`.
|
|
153
|
+
- **`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`.
|
|
154
|
+
- `SCHEDULE` — `{ "isoDate":"2026-06-25", "time":"18:00", "timezone":"Europe/Moscow" }`. Выходы `scheduled` / `past`. `isoDate` может быть и ISO дата-временем или шаблоном (`"{{var.slot_at}}"`, тогда `time` не нужен) + `offset` как у `DELAY UNTIL`.
|
|
138
155
|
- Прогон без пауз (между `DELAY`, `ASK_QUESTION`/`awaitReply`, ожиданием оплаты) ограничен **5 минутами**: дольше — рантайм обрывает его с ошибкой шага `run deadline exceeded` (прогон `FAILED`, пауза подписчика снята). Длинные цепочки `external_request` / `CALL_WEBHOOK` / `AI_REPLY` разноси `DELAY` — после паузы начинается новый прогон.
|
|
139
156
|
- Массовый одинаковый момент срабатывания (`TOMORROW`/`UNTIL`/`SCHEDULE` у тысяч подписчиков одного бота) расходится постепенно: до 10 срабатываний на бота в секунду, время растёт с аудиторией (≈100 с на 1000 подписчиков, ≈17 мин на 10 000), другие боты не ждут. Не рассчитывай, что все получат сообщение в одну секунду.
|
|
140
157
|
- Если Telegram вернул боту 429 (`retry after N`, пауза не дольше часа), срабатывания `DELAY`/`SCHEDULE` и рассылки этого бота (и по `BROADCAST_FILTER`, и прямые) ждут конца паузы; после такой паузы рассылка продолжается без потерь и без повтора уже доставленного. Это только про паузу 429: если сервис перезапустился посреди отправки, получатель, на котором её прервали, через ≤15 мин может получить рассылку (или прогон сценария) повторно. Ответ подписчику в живом диалоге отправляется сразу, без ожидания; если Telegram откажет снова, шаг уходит в ветку «Ошибка», если она проведена, иначе дальше по обычному выходу.
|
|
@@ -152,7 +169,12 @@ IG-боты не поддерживают команды (`/start`). Вход
|
|
|
152
169
|
ветвиться по коду ответа надо следующим блоком `SWITCH`. **Платное действие** (как `CALL_WEBHOOK`):
|
|
153
170
|
на бесплатном тарифе публикация падает с `PREMIUM_NODE_FORBIDDEN`.
|
|
154
171
|
Ещё есть `subscriber_webhook` — `url` + `method`/`headersJson`/`bodyTemplate`
|
|
155
|
-
- **уведомления**: `notify` (`text`) — владельцу бота в бот уведомлений из его профиля (Telegram/MAX); не подключён → действие не удалось, блок уходит в `error`; `subscriber_email` (`email`,`text`)
|
|
172
|
+
- **уведомления**: `notify` (`text`) — владельцу бота в бот уведомлений из его профиля (Telegram/MAX); не подключён → действие не удалось, блок уходит в `error`; `subscriber_email` (`email`,`text`)
|
|
173
|
+
- **передача оператору**: `agent_chat` (без полей) — «позвать человека»: диалог встаёт на паузу (бот и ИИ молчат, входящие копятся в «Диалогах»), владельцу уходит уведомление; без бота уведомлений действие помечается `NOT_CONFIGURED`, пауза всё равно включается. Вернуть боту — кнопка в «Диалогах» или MCP `dialog_handoff {active:false}`. В dry-run и headless-прогонах пропускается.
|
|
174
|
+
- **запись на слоты** (календарь — `booking_calendar_create`/`booking_calendar_list`): `booking_slots` — `calendarId`, опц. `days` (1–62, по умолч. 7), `limit` (1–50, по умолч. 10), `saveTo` (по умолч. `slots`) → переменные `slots` (нумерованный список для сообщения), `slots_json`, `slots_count`; `booking_book` — `calendarId`, `slotAt` (по умолч. `{{var.slot_at}}`; можно номер из списка «2», ISO-время или подпись слота), опц. `slotsVar`, `name`/`phone` (по умолч. `{{var.name}}`/`{{var.phone}}`) → `booking_id`, `slot_at` (ISO с зоной — для напоминания `DELAY UNTIL {{var.slot_at}} offset -24h`), `slot_label`, `booking_status`; занятый слот → выход блока **`taken`** (если ребро нарисовано, иначе `error`); `booking_cancel` — `bookingId` (по умолч. `{{var.booking_id}}`), освобождает слот.
|
|
175
|
+
- **контакт заявки**: `lead_link_contact` — `phone`/`email` (по умолч. `{{var.phone}}`/`{{var.email}}`) → склейка с единым контактом владельца по телефону E.164 или email, `{{var.contact_id}}`.
|
|
176
|
+
- **платный доступ / подписки** (бот должен быть админом группы/канала): `invite_link_create` — персональная ссылка на одно вступление: опц. `expireHours` (по умолч. 24), `name`, `saveTo` (по умолч. `invite_link`), `send` (false — не отправлять), `text`; `invite_link_revoke` — `link` (по умолч. `{{var.invite_link}}`); `subscription_extend` — `period` (`1mo`, `30d`, `1y`, `2w`, `12h`), опц. `var` (по умолч. `sub_until`), `timezone` — продлевает от max(сейчас, текущий срок); `subscription_check` — опц. `var`, `saveTo` (по умолч. `sub_active`) → `true`/`false`; `yookassa_charge_saved` — рекуррентное списание по сохранённому способу оплаты: `connectionId` (ЮKassa), `amount`, `description`, опц. `paymentMethodId` (по умолч. `{{var.yk_payment_method_id}}`), `periodKey` (по умолч. `{{var.sub_until}}` — защита от двойного списания за период). Доступ продлевает не списание, а сценарий `TRIGGER_PAYMENT` → `subscription_extend`.
|
|
177
|
+
- **Meta Conversions API** (Integration Core): `meta_capi_event` — `connectionId`, `eventName` (Purchase/Lead/…), опц. `dealId`, `stage`, `eventId`, `email`/`phone` (сырые — хешируются SHA-256), `value`, `currency`, `actionSource`. Поля других провайдеров — в `integration_catalog` (`actions[].inputs`).
|
|
156
178
|
- **бот/шаг**: `stop_bot`, `delete_step_message`, `cancel_payment_subscription`
|
|
157
179
|
- **Google Таблицы (работает)**: `gsheets_send` — дописать строку-заявку в таблицу: `{ "kind":"gsheets_send", "googleEmail":"me@gmail.com", "spreadsheetId":"<id таблицы>", "sheetName":"Лист1", "cells":["{{from.first_name}}","{{var.phone}}","{{var.email}}"] }`. `cells` — значения по порядку (шаблоны), бот дописывает их строкой в конец листа. Google-аккаунт подключается В ВЕБЕ (`/bots` → у действия кнопка «Подключить Google»), НЕ через MCP — у пользователя уже должен быть подключён `googleEmail`. Нужны `googleEmail` + `spreadsheetId` + непустой `cells[]` (иначе `ACTION_GSHEETS_INCOMPLETE`).
|
|
158
180
|
Остальные четыре действия с таблицами тоже РАБОТАЮТ и тоже требуют `googleEmail` + `spreadsheetId`:
|
|
@@ -183,7 +205,7 @@ IG-боты не поддерживают команды (`/start`). Вход
|
|
|
183
205
|
(`ClientId`|`Yclid`, по умолчанию `ClientId`), **`idValue`** (обязателен после рендера —
|
|
184
206
|
пустой обрывает действие), опц. `target`, `price`, `currency` (по умолчанию `RUB`),
|
|
185
207
|
`dateTime` (unix-секунды, по умолчанию «сейчас»).
|
|
186
|
-
-
|
|
208
|
+
- **Единственное НЕ интегрированное действие**: `cancel_payment_subscription` —
|
|
187
209
|
принимается как no-op с пометкой `integration_not_connected`.
|
|
188
210
|
- **модерация группы**: `group_unban`, `group_kick`, `group_approve`, `group_decline`
|
|
189
211
|
|
|
@@ -433,11 +455,11 @@ node validate.mjs graph.json --platform=INSTAGRAM
|
|
|
433
455
|
| `ASK_QUESTION` | `valid`, `invalid` |
|
|
434
456
|
| `SEND_MESSAGE` с `awaitReply:true` | `valid`, `invalid` (+ `btn_N` для кнопок) |
|
|
435
457
|
| `CALL_WEBHOOK` | `ok`, `error` |
|
|
436
|
-
| `ACTIONS` | `next`, плюс `error` — если внутри есть действие, которое может упасть (внешний запрос, CRM, Таблицы) |
|
|
458
|
+
| `ACTIONS` | `next`, плюс `error` — если внутри есть действие, которое может упасть (внешний запрос, CRM, Таблицы); `taken` — слот `booking_book` занят |
|
|
437
459
|
| `SWITCH` | `case_<id>`, `default` |
|
|
438
460
|
| `STOP_AND_ERROR` | выходов нет (терминатор) |
|
|
439
461
|
| `SCHEDULE` | `scheduled`, `past` |
|
|
440
|
-
| `DELAY` | `next` |
|
|
462
|
+
| `DELAY` | `next`; у `UNTIL` ещё `late` (момент уже прошёл) и `error` (шаблон не разобрался как дата) |
|
|
441
463
|
| `AI_REPLY` (`mode` не задан/`simple`) | `next`, `error` |
|
|
442
464
|
| `AI_REPLY` (`mode:"agent"`) | `answered`, `action_<id>` (по числу `actions[]`), `unknown`, `budget_exhausted`, `error` — **выхода `next` нет** |
|
|
443
465
|
|
|
@@ -41,11 +41,12 @@
|
|
|
41
41
|
- `BRANCH`: ≥1 case (`BRANCH_NO_CASES`); вне `abTest` — непустое валидное `expression` (`BRANCH_EMPTY_EXPR`/`BRANCH_BAD_EXPR`) и подключённое ребро `case_<id>` (`BRANCH_CASE_UNCONNECTED`; `disabled:true` — если ветка намеренно пустая). В `abTest:true` рёбра нужны только у первых двух case.
|
|
42
42
|
- `CONDITION`: `match` ∈ {ALL, ANY} (`CONDITION_BAD_MATCH`); ≥1 элемент в `conditions` (`CONDITION_EMPTY`). Для `kind:"TAG"` — `value` ∈ `[a-z0-9_-]{1,64}` (`CONDITION_BAD_TAG`); для `kind:"VARIABLE"` — `key` ∈ `[a-z_][a-z0-9_]{0,63}` (`CONDITION_BAD_KEY`). Остальные `kind` (UTM/NAME/EMAIL/PHONE/USERNAME/SUBSCRIBED/LINK_CLICKED/CURRENT_*/DAY_OF_WEEK) бэкенд проверяет в рантайме — некорректный `kind`/`op`, нечисловой `key` у `SUBSCRIBED` или `key` у `LINK_CLICKED` без отслеживаемой кнопки молча дают `false` (выход `no`), публикацию не блокируют. Список `kind`/`op` — в schema.md.
|
|
43
43
|
- `CALL_WEBHOOK`: `url` обязателен (`WEBHOOK_NO_URL`), http(s):// (`WEBHOOK_BAD_SCHEME`).
|
|
44
|
-
- `DELAY`: `kind` ∈ {FIXED,UNTIL,TOMORROW} (`DELAY_BAD_KIND`). `FIXED` — `durationSec>0` (секунды) ИЛИ `duration>0` (+`unit` ∈ {MINUTES,HOURS,DAYS}), иначе `DELAY_BAD_DURATION`. `TOMORROW` — `time` = `HH:mm` (`DELAY_BAD_TIME`). `UNTIL` — `isoTimestamp
|
|
44
|
+
- `DELAY`: `kind` ∈ {FIXED,UNTIL,TOMORROW} (`DELAY_BAD_KIND`). `FIXED` — `durationSec>0` (секунды) ИЛИ `duration>0` (+`unit` ∈ {MINUTES,HOURS,DAYS}), иначе `DELAY_BAD_DURATION`. `TOMORROW` — `time` = `HH:mm` (`DELAY_BAD_TIME`). `UNTIL` — `isoTimestamp`: ISO-8601 или шаблон `{{var.x}}` (`DELAY_BAD_TIMESTAMP`; рантайм читает только его, не `isoDate`+`time`); опц. `offset` вида `-24h`/`-30m`/`+1d` (`DELAY_BAD_OFFSET`) и `timezone` (`DELAY_BAD_TIMEZONE`).
|
|
45
45
|
- `SET_VARIABLE`: `key` ∈ `[a-z_][a-z0-9_]{0,63}` (`VAR_BAD_KEY`). `ADD_TAG`/`REMOVE_TAG`: `tag` ∈ `[a-z0-9_-]{1,64}` (`TAG_BAD_NAME`).
|
|
46
46
|
- `FORMULA`: `expression` непустой (`FORMULA_NO_EXPRESSION`); `saveTo` ∈ var-формат (`FORMULA_BAD_SAVE_TO`).
|
|
47
|
-
- `SCHEDULE`: `isoDate` =
|
|
48
|
-
- `
|
|
47
|
+
- `SCHEDULE`: `isoDate` = `YYYY-MM-DD`, ISO-8601 дата-время или шаблон `{{var.x}}` (`SCHEDULE_BAD_DATE`); `time` = `HH:mm` — обязателен при голой дате (`SCHEDULE_BAD_TIME`); опц. `offset` (`SCHEDULE_BAD_OFFSET`), `timezone` (`SCHEDULE_BAD_TIMEZONE`).
|
|
48
|
+
- `TRIGGER_SCHEDULE`: `cron` ровно из 5 полей, не чаще раза в минуту (`SCHEDULE_TRIGGER_BAD_CRON`); `timezone` — известная зона (`SCHEDULE_TRIGGER_BAD_TIMEZONE`).
|
|
49
|
+
- `ACTIONS`: непустой `actions[]` (`ACTIONS_EMPTY`); каждый `kind` — из допустимого списка (`ACTION_UNKNOWN_KIND`, список — в schema.md); per-kind: `tag` (`ACTION_BAD_TAG`), `set_field.key` (`ACTION_BAD_KEY`), `url` у `external_request`/`subscriber_webhook` (`ACTION_BAD_URL`), `calendarId` у `booking_slots`/`booking_book` (`ACTION_BOOKING_NO_CALENDAR`) и их `saveTo` (`ACTION_BAD_KEY`). Действия Integration Core (`actions[].kind` из `integration_catalog`, например `meta_capi_event`) бэкенд узнаёт по реестру провайдеров; `validate.mjs` их оффлайн не знает и выдаёт предупреждение, а не ошибку.
|
|
49
50
|
- `ACTIONS` с действием `issue_invoice` (счёт на кассу владельца):
|
|
50
51
|
- `connectionId` обязателен (`YK_NO_CONNECTION`) — подключение с `provider=YOOKASSA` из `list_integrations`;
|
|
51
52
|
- `amount` обязателен (`YK_NO_AMOUNT`), число > 0 или шаблон `{{var.x}}` (`YK_BAD_AMOUNT`);
|
|
@@ -70,11 +70,25 @@ const ACTION_KINDS = new Set([
|
|
|
70
70
|
"subscribe", "unsubscribe", "autoflow_add", "autoflow_remove",
|
|
71
71
|
"subscriber_webhook", "external_request", "notify",
|
|
72
72
|
"subscriber_email", "agent_chat", "cancel_payment_subscription", "issue_invoice",
|
|
73
|
-
"getcourse_send", "getcourse_order", "amocrm_send", "amocrm_update",
|
|
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",
|
|
76
76
|
"group_unban", "group_kick", "group_approve", "group_decline",
|
|
77
|
+
"invite_link_create", "invite_link_revoke", "subscription_extend", "subscription_check",
|
|
78
|
+
"yookassa_charge_saved",
|
|
79
|
+
"booking_slots", "booking_book", "booking_cancel", "lead_link_contact",
|
|
80
|
+
// Integration Core (провайдеры из integration_catalog) — бэкенд узнаёт их через реестр, здесь — известные:
|
|
81
|
+
"meta_capi_event",
|
|
77
82
|
]);
|
|
83
|
+
// Смещение времени DELAY UNTIL / SCHEDULE: -24h, -30m, +1d (зеркало FlowTime.OFFSET)
|
|
84
|
+
const OFFSET_RE = /^([+-])?\s*\d{1,6}\s*[smhdw]$/i;
|
|
85
|
+
function checkTimeExtras(c, prefix, who) {
|
|
86
|
+
if (!blank(c.offset) && !OFFSET_RE.test(String(c.offset).trim())) errors.push(`${prefix}_BAD_OFFSET: ${who} — offset вида -24h, -30m, +1d (единицы s/m/h/d/w).`);
|
|
87
|
+
if (!blank(c.timezone) && !validZone(String(c.timezone).trim())) errors.push(`${prefix}_BAD_TIMEZONE: ${who} — неизвестный часовой пояс «${c.timezone}».`);
|
|
88
|
+
}
|
|
89
|
+
function validZone(z) {
|
|
90
|
+
try { new Intl.DateTimeFormat("en", { timeZone: z }); return true; } catch { return false; }
|
|
91
|
+
}
|
|
78
92
|
|
|
79
93
|
// Telegram-safe HTML — разрешённые теги (эвристика; бэкенд использует jsoup-clean)
|
|
80
94
|
const HTML_OK_TAGS = new Set([
|
|
@@ -232,7 +246,11 @@ for (const n of nodes) {
|
|
|
232
246
|
case "DELAY":
|
|
233
247
|
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).`); }
|
|
234
248
|
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.`); }
|
|
235
|
-
else if (c.kind === "UNTIL") {
|
|
249
|
+
else if (c.kind === "UNTIL") {
|
|
250
|
+
if (blank(c.isoTimestamp)) errors.push(`${who}: UNTIL требует isoTimestamp (ISO-8601 или шаблон {{var.x}}). isoDate+time рантайм НЕ читает.`);
|
|
251
|
+
else if (!String(c.isoTimestamp).includes("{{") && isNaN(Date.parse(c.isoTimestamp))) errors.push(`DELAY_BAD_TIMESTAMP: ${who} — isoTimestamp не ISO-8601 и не шаблон {{var.x}}.`);
|
|
252
|
+
checkTimeExtras(c, "DELAY", who);
|
|
253
|
+
}
|
|
236
254
|
else errors.push(`${who}: kind ∈ {FIXED,TOMORROW,UNTIL}.`);
|
|
237
255
|
break;
|
|
238
256
|
case "BRANCH":
|
|
@@ -297,8 +315,20 @@ for (const n of nodes) {
|
|
|
297
315
|
if (!VAR_RE.test(String(c.saveTo || ""))) errors.push(`FORMULA_BAD_SAVE_TO: ${who} — saveTo ∈ [a-z_][a-z0-9_]{0,63}.`);
|
|
298
316
|
break;
|
|
299
317
|
case "SCHEDULE": {
|
|
300
|
-
|
|
301
|
-
|
|
318
|
+
// Зеркало GraphValidator.validateSchedule (G2): YYYY-MM-DD (+ time), ISO-8601 дата-время или шаблон {{var.x}}.
|
|
319
|
+
const iso = String(c.isoDate ?? "").trim();
|
|
320
|
+
const template = iso.includes("{{");
|
|
321
|
+
const dateOnly = /^\d{4}-\d{2}-\d{2}$/.test(iso);
|
|
322
|
+
if (!iso || (!template && isNaN(Date.parse(iso)))) errors.push(`SCHEDULE_BAD_DATE: ${who} — isoDate = YYYY-MM-DD, ISO-8601 дата-время или шаблон {{var.x}}.`);
|
|
323
|
+
if ((!blank(c.time) || (dateOnly && !template)) && !/^([01]\d|2[0-3]):[0-5]\d$/.test(String(c.time || ""))) errors.push(`SCHEDULE_BAD_TIME: ${who} — time = HH:mm.`);
|
|
324
|
+
checkTimeExtras(c, "SCHEDULE", who);
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
case "TRIGGER_SCHEDULE": {
|
|
328
|
+
// Зеркало GraphValidator.validateScheduleTrigger: cron из 5 полей, известная зона.
|
|
329
|
+
const parts = String(c.cron ?? "").trim().split(/\s+/).filter(Boolean);
|
|
330
|
+
if (parts.length !== 5) errors.push(`SCHEDULE_TRIGGER_BAD_CRON: ${who} — cron из 5 полей: минута час день месяц день-недели (например «0 9 * * 1-5»).`);
|
|
331
|
+
if (!blank(c.timezone) && !validZone(String(c.timezone).trim())) errors.push(`SCHEDULE_TRIGGER_BAD_TIMEZONE: ${who} — неизвестный часовой пояс «${c.timezone}».`);
|
|
302
332
|
break;
|
|
303
333
|
}
|
|
304
334
|
case "ACTIONS": {
|
|
@@ -316,7 +346,17 @@ for (const n of nodes) {
|
|
|
316
346
|
}
|
|
317
347
|
c.actions.forEach((a, i) => {
|
|
318
348
|
if (!a || typeof a !== "object") { errors.push(`${who}: действие #${i + 1} — не объект.`); return; }
|
|
319
|
-
if (
|
|
349
|
+
if (blank(a.kind)) { errors.push(`ACTION_UNKNOWN_KIND: ${who} — у действия #${i + 1} нет kind.`); return; }
|
|
350
|
+
if (!ACTION_KINDS.has(a.kind)) {
|
|
351
|
+
// Действия Integration Core (actions[].kind из integration_catalog) бэкенд узнаёт по реестру провайдеров —
|
|
352
|
+
// оффлайн их не проверить; опечатку поймает publish_graph (ACTION_UNKNOWN_KIND).
|
|
353
|
+
warns.push(`${who}: kind «${a.kind}» (#${i + 1}) не из встроенного списка — допустим, только если он есть в actions[].kind провайдера в integration_catalog.`);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
if (["booking_slots", "booking_book"].includes(a.kind) && blank(a.calendarId))
|
|
357
|
+
errors.push(`ACTION_BOOKING_NO_CALENDAR: ${who} — ${a.kind} требует calendarId (booking_calendar_list).`);
|
|
358
|
+
if (["booking_slots", "booking_book"].includes(a.kind) && !blank(a.saveTo) && !VAR_RE.test(String(a.saveTo)))
|
|
359
|
+
errors.push(`ACTION_BAD_KEY: ${who} — ${a.kind}.saveTo ∈ [a-z_][a-z0-9_]{0,63}.`);
|
|
320
360
|
if (["add_tag", "remove_tag", "autoflow_add", "autoflow_remove"].includes(a.kind) && !TAG_RE.test(String(a.tag || "")))
|
|
321
361
|
errors.push(`ACTION_BAD_TAG: ${who} — ${a.kind}.tag ∈ [a-z0-9_-]{1,64}.`);
|
|
322
362
|
if (a.kind === "set_field" && !VAR_RE.test(String(a.key || "")))
|
package/src/index.mjs
CHANGED
|
@@ -27,7 +27,7 @@ import os from "node:os";
|
|
|
27
27
|
import fs from "node:fs";
|
|
28
28
|
import path from "node:path";
|
|
29
29
|
|
|
30
|
-
const VERSION = "0.
|
|
30
|
+
const VERSION = "0.19.0";
|
|
31
31
|
/** С чего начать пустой сайт (init у /document/ops; на сайте с черновиком игнорируется). */
|
|
32
32
|
const SITE_INITS = ["starter", "blank", "mini-landing"];
|
|
33
33
|
/** Безвредная операция, когда нужен только init: бэкенд не принимает пустой ops[]. */
|
|
@@ -59,10 +59,8 @@ function saveToken(token) {
|
|
|
59
59
|
}
|
|
60
60
|
function isAuthed() { return !!(getToken() || getCookie()); }
|
|
61
61
|
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
// ============================================================================
|
|
65
|
-
// ЗАЧЕМ: если клиент пытается сделать что-то, чего сервер не умеет (неизвестный
|
|
62
|
+
// =====================================================================// Отчёты о неудачах + проверка обновлений
|
|
63
|
+
// =====================================================================// ЗАЧЕМ: если клиент пытается сделать что-то, чего сервер не умеет (неизвестный
|
|
66
64
|
// инструмент, отказ публикации, ошибка API) — мы хотим об этом узнать и добавить
|
|
67
65
|
// поддержку. Отчёт уходит на webhook АНОНИМНО и БЕЗ СЕКРЕТОВ.
|
|
68
66
|
//
|
|
@@ -264,12 +262,12 @@ function httpError(method, path_, status, data) {
|
|
|
264
262
|
return err;
|
|
265
263
|
}
|
|
266
264
|
|
|
267
|
-
async function api(path_, { method = "GET", body } = {}) {
|
|
265
|
+
async function api(path_, { method = "GET", body, rawBody } = {}) {
|
|
268
266
|
if (!isAuthed()) throw new Error(NO_AUTH_HELP);
|
|
269
267
|
const res = await fetch(`${BASE}${path_}`, {
|
|
270
268
|
method,
|
|
271
269
|
headers: authHeaders(),
|
|
272
|
-
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
270
|
+
body: rawBody ?? (body !== undefined ? JSON.stringify(body) : undefined),
|
|
273
271
|
});
|
|
274
272
|
const text = await res.text();
|
|
275
273
|
let data = null;
|
|
@@ -443,6 +441,81 @@ function resolveGraphInput(a) {
|
|
|
443
441
|
if (a.graph) return extractGraph(a.graph);
|
|
444
442
|
return { nodes: a.nodes, edges: a.edges, canvasMeta: a.canvasMeta ?? {}, name: a.name };
|
|
445
443
|
}
|
|
444
|
+
// Сервер хранит id узлов/рёбер как UUID: иначе PUT падал голым HTTP 400 без причины.
|
|
445
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
446
|
+
function assertGraphIds(g) {
|
|
447
|
+
const bad = [];
|
|
448
|
+
for (const n of g.nodes || []) if (!UUID_RE.test(String(n?.id))) bad.push(`узел ${n?.id}`);
|
|
449
|
+
for (const e of g.edges || []) {
|
|
450
|
+
for (const k of ["id", "sourceNodeId", "targetNodeId"]) if (!UUID_RE.test(String(e?.[k]))) bad.push(`ребро ${e?.id ?? "?"}.${k}=${e?.[k]}`);
|
|
451
|
+
}
|
|
452
|
+
if (bad.length) throw new Error(`id узлов и рёбер должны быть UUID (crypto.randomUUID()). Не UUID: ${bad.slice(0, 10).join(", ")}${bad.length > 10 ? ` и ещё ${bad.length - 10}` : ""}.`);
|
|
453
|
+
}
|
|
454
|
+
// chatId бывает больше 2^53 (виджет, MAX) — принимаем строкой, пропускаем только цифры (и минус у групп).
|
|
455
|
+
function pageQs(a) {
|
|
456
|
+
const q = [];
|
|
457
|
+
if (a.page != null) q.push(`page=${encodeURIComponent(a.page)}`);
|
|
458
|
+
if (a.size != null) q.push(`size=${encodeURIComponent(a.size)}`);
|
|
459
|
+
return q.length ? `?${q.join("&")}` : "";
|
|
460
|
+
}
|
|
461
|
+
function chatIdArg(v) {
|
|
462
|
+
const s = String(v ?? "").trim();
|
|
463
|
+
if (!/^-?\d{1,20}$/.test(s)) throw new Error(`chatId — число (из list_bot_users), получено: ${s || "пусто"}.`);
|
|
464
|
+
return s;
|
|
465
|
+
}
|
|
466
|
+
function uuidArg(v, label) {
|
|
467
|
+
const s = String(v ?? "").trim();
|
|
468
|
+
if (!UUID_RE.test(s)) throw new Error(`${label} — UUID (например, из list_bots), получено: ${s || "пусто"}.`);
|
|
469
|
+
return s;
|
|
470
|
+
}
|
|
471
|
+
// id из Mongo/строковые ключи: только безопасный сегмент пути, без «/», «..», пробелов.
|
|
472
|
+
function idArg(v, label) {
|
|
473
|
+
const s = String(v ?? "").trim();
|
|
474
|
+
if (!/^[A-Za-z0-9_-]{1,64}$/.test(s)) throw new Error(`${label} — id из соответствующего списка, получено: ${s || "пусто"}.`);
|
|
475
|
+
return s;
|
|
476
|
+
}
|
|
477
|
+
function enumArg(v, label, allowed) {
|
|
478
|
+
const s = String(v ?? "").trim().toUpperCase();
|
|
479
|
+
if (!allowed.includes(s)) throw new Error(`${label}: ${allowed.join(" | ")}, получено: ${s || "пусто"}.`);
|
|
480
|
+
return s;
|
|
481
|
+
}
|
|
482
|
+
const LEAD_STATUSES = ["NEW", "IN_PROGRESS", "DONE", "REJECTED"];
|
|
483
|
+
function dateArg(v, label) {
|
|
484
|
+
const s = String(v ?? "").trim();
|
|
485
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(s) || Number.isNaN(Date.parse(s))) throw new Error(`${label} — дата ГГГГ-ММ-ДД, получено: ${s || "пусто"}.`);
|
|
486
|
+
return s;
|
|
487
|
+
}
|
|
488
|
+
const HHMM_RE = /^([01]\d|2[0-3]):[0-5]\d$|^24:00$/;
|
|
489
|
+
// Поля календаря записи (BookingCalendar): переданные накладываются на base (для PUT — текущий календарь целиком).
|
|
490
|
+
function calendarBody(a, base = {}) {
|
|
491
|
+
const b = { ...base };
|
|
492
|
+
if (a.name != null) b.name = String(a.name).trim();
|
|
493
|
+
if (a.zone != null) b.zone = String(a.zone).trim();
|
|
494
|
+
if (a.botId !== undefined) b.botId = a.botId === null || a.botId === "" ? null : uuidArg(a.botId, "botId");
|
|
495
|
+
if (a.slotMinutes != null) {
|
|
496
|
+
const n = Number(a.slotMinutes);
|
|
497
|
+
if (!Number.isInteger(n) || n < 5 || n > 1440) throw new Error("slotMinutes — целое от 5 до 1440.");
|
|
498
|
+
b.slotMinutes = n;
|
|
499
|
+
}
|
|
500
|
+
if (a.hours != null) {
|
|
501
|
+
if (!Array.isArray(a.hours)) throw new Error("hours — массив [{day: 1–7, from: \"HH:mm\", to: \"HH:mm\"}].");
|
|
502
|
+
a.hours.forEach((h, i) => {
|
|
503
|
+
if (!Number.isInteger(h?.day) || h.day < 1 || h.day > 7) throw new Error(`hours[${i}].day — от 1 (пн) до 7 (вс).`);
|
|
504
|
+
if (!HHMM_RE.test(String(h.from)) || !HHMM_RE.test(String(h.to))) throw new Error(`hours[${i}]: from/to — время HH:mm.`);
|
|
505
|
+
});
|
|
506
|
+
b.hours = a.hours.map((h) => ({ day: h.day, from: h.from, to: h.to }));
|
|
507
|
+
}
|
|
508
|
+
if (a.exceptions != null) {
|
|
509
|
+
if (!Array.isArray(a.exceptions)) throw new Error("exceptions — массив [{date: \"ГГГГ-ММ-ДД\", from?, to?}].");
|
|
510
|
+
b.exceptions = a.exceptions.map((e, i) => {
|
|
511
|
+
const date = dateArg(e?.date, `exceptions[${i}].date`);
|
|
512
|
+
const closed = !e.from && !e.to;
|
|
513
|
+
if (!closed && (!HHMM_RE.test(String(e.from)) || !HHMM_RE.test(String(e.to)))) throw new Error(`exceptions[${i}]: from/to — время HH:mm или оба пусты (выходной).`);
|
|
514
|
+
return closed ? { date } : { date, from: e.from, to: e.to };
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
return b;
|
|
518
|
+
}
|
|
446
519
|
// Компактная сводка графа (без объёмных text/cards/buttons) — чтобы не упираться в лимит токенов
|
|
447
520
|
// на больших графах. Узлы: id/type/title/позиция; рёбра: id/from/handle/to.
|
|
448
521
|
function graphSummary(g) {
|
|
@@ -451,10 +524,8 @@ function graphSummary(g) {
|
|
|
451
524
|
return { graphId: g?.id, name: g?.name, status: g?.status, version: g?.version, counts: { nodes: nodes.length, edges: edges.length }, nodes, edges };
|
|
452
525
|
}
|
|
453
526
|
|
|
454
|
-
//
|
|
455
|
-
//
|
|
456
|
-
// ============================================================================
|
|
457
|
-
// Бэкенд на 400 отдаёт только статус (без причины), поэтому правила TgBroadcastController.validateDirectMessage
|
|
527
|
+
// =====================================================================// Рассылки
|
|
528
|
+
// =====================================================================// Бэкенд на 400 отдаёт только статус (без причины), поэтому правила TgBroadcastController.validateDirectMessage
|
|
458
529
|
// повторены здесь — агент получает понятную ошибку до запроса, а не голый «HTTP 400».
|
|
459
530
|
const BC_TYPES = ["TEXT", "PHOTO", "VIDEO", "AUDIO", "FILE", "VOICE", "VIDEONOTE", "GALLERY"];
|
|
460
531
|
const BC_MEDIA = new Set(["PHOTO", "VIDEO", "AUDIO", "FILE", "VOICE", "VIDEONOTE"]);
|
|
@@ -543,10 +614,8 @@ function qs(params) {
|
|
|
543
614
|
return parts.length ? `?${parts.join("&")}` : "";
|
|
544
615
|
}
|
|
545
616
|
|
|
546
|
-
//
|
|
547
|
-
//
|
|
548
|
-
// ============================================================================
|
|
549
|
-
/** IA v2: каталог и подключения — раздел «Интеграции». Старые /bots/connect|integrations остаются в кабинете (Instagram — там). */
|
|
617
|
+
// =====================================================================// Подключения сервисов
|
|
618
|
+
// =====================================================================/** IA v2: каталог и подключения — раздел «Интеграции». Старые /bots/connect|integrations остаются в кабинете (Instagram — там). */
|
|
550
619
|
const CONNECT_PAGE = `${BASE}/integrations`;
|
|
551
620
|
const INTEGRATIONS_PAGE = CONNECT_PAGE;
|
|
552
621
|
const LEGACY_CONNECT_PAGE = `${BASE}/bots/connect`;
|
|
@@ -591,7 +660,7 @@ const TOOLS = [
|
|
|
591
660
|
{ name: "list_bots", description: "Список ботов пользователя (id, имя, статус).", inputSchema: { type: "object", properties: {} } },
|
|
592
661
|
{ name: "list_graphs", description: "Список сценариев САМОГО бота (без узлов). Вебхук-сценарии, которые лишь отвечают через этого бота, сюда не входят — их публикуют в вебе, в «Сценариях» автора.", inputSchema: { type: "object", properties: { botId: { type: "string" } }, required: ["botId"] } },
|
|
593
662
|
{ name: "list_channels", description: "Список каналов/групп, подключённых к боту (chatId, title, type, статус бота, дата). chatId — числовой id для условия SUBSCRIBED («Подписан на канал»).", inputSchema: { type: "object", properties: { botId: { type: "string" } }, required: ["botId"] } },
|
|
594
|
-
{ name: "integration_catalog", description: "Каталог сервисов Integration Core (GET /api/integrations/catalog): [{provider, name, category, authType, configSchema: [{key, label, hint, secret}] — поля подключения, actions: [{kind, label, inputs}] — действия для сценария и coreDelivery, healthCheck — умеет ли integration_test}]. 404 — Integration Core выключен в сервисе. Read-only.", inputSchema: { type: "object", properties: {} } },
|
|
663
|
+
{ name: "integration_catalog", description: "Каталог сервисов Integration Core (GET /api/integrations/catalog): [{provider, name, category, authType, configSchema: [{key, label, hint, secret}] — поля подключения, actions: [{kind, label, inputs}] — действия для сценария и coreDelivery, healthCheck — умеет ли integration_test, inbound: {events: [{key, label, vars: [{key, label, example}], paymentSucceeded}]} | null — провайдер умеет только исходящие действия}]. inbound.events — события для узла TRIGGER_WEBHOOK с заполненным config.provider (см. integration_ingress_url): events[].key идёт в config.event, vars[].key — переменные {{body.<key>}} в сценарии. paymentSucceeded:true — событие сверх обычного вебхука ещё и заводит узел «Оплата прошла» (headless или в чате подписчика, если платёжная ссылка была привязана к нему действием *_link). 404 — Integration Core выключен в сервисе. Read-only.", inputSchema: { type: "object", properties: {} } },
|
|
595
664
|
{ name: "integration_status", description: "Статус подключения (GET /api/integrations/{id}/status): {status: UNKNOWN | OK | NEEDS_REAUTH (ключ отозван/устарел — обнови через connect_integration) | ERROR, lastCheckedAt, lastError, supported}. Только своё подключение (чужое — 403). Read-only, внешний сервис не вызывает.", inputSchema: { type: "object", properties: { connectionId: { type: "string", description: "id из list_integrations" } }, required: ["connectionId"] } },
|
|
596
665
|
{ name: "integration_test", description: "Проверить подключение (POST /api/integrations/{id}/test): выполняет ЖИВУЮ проверку ключа во внешнем сервисе от имени владельца (без побочных эффектов — ничего не создаёт) и обновляет статус. Ответ как у integration_status; supported:false — сервис проверку не умеет. Только своё подключение (чужое — 403); 404 — Integration Core выключен.", inputSchema: { type: "object", properties: { connectionId: { type: "string", description: "id из list_integrations" } }, required: ["connectionId"] } },
|
|
597
666
|
{ name: "channel_post", description: "Разовый пост от имени бота в канал/группу Telegram или MAX (раздел «Публикации», POST /api/bots/{botId}/linked-chats/{chatId}/post). Бот должен быть администратором канала (list_channels). text и/или mediaUrl — файл из upload_file (тип фото/видео/документ берётся из файла; чужие ссылки не принимаются). Лимиты: Telegram — 4096 символов текста, 1024 подписи к файлу; MAX — 4000. Ответ {ok, messageId?}. Публикует сразу — подтверди текст с пользователем.", inputSchema: { type: "object", properties: { botId: { type: "string" }, chatId: { type: "number", description: "chatId из list_channels" }, text: { type: "string" }, mediaUrl: { type: "string", description: "url из upload_file" } }, required: ["botId", "chatId"] } },
|
|
@@ -601,7 +670,7 @@ const TOOLS = [
|
|
|
601
670
|
{ 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"] } },
|
|
602
671
|
{ name: "edit_graph_live", description: "РЕКОМЕНДОВАННЫЙ способ правки СУЩЕСТВУЮЩЕГО (часто живого/опубликованного) сценария: редактирует ТОТ ЖЕ graphId НА МЕСТЕ (id не меняется) и сначала снимает авто-бэкап текущего состояния в один rolling-граф «🔙 Авто-бэкап». НЕ клонирует и НЕ создаёт новый активный граф. Открытые редакторы перечитают граф вживую (external_update), бот применит изменения сразу (читает активный граф заново из БД). Используй ВМЕСТО clone+publish, когда нужно поправить сценарий, который уже открыт/в проде. ВАЖНО: правку активного графа сервер проверяет как публикацию (валидатор, платные блоки, лимит блоков тарифа, платформа) — при ошибках HTTP 422 со всеми code@nodeId, граф НЕ изменён, бот работает на прежней версии. Прогоняй offline validate.mjs и dry_run заранее, чтобы не ловить 422. Живой граф бота — с isActive:true в list_graphs — для бот-сценария (после publish_graph черновика — publishedGraphId, не id черновика); правка черновика до бота не доходит.", inputSchema: { type: "object", properties: { graphId: { type: "string" }, graph: { type: "object" }, nodes: { type: "array" }, edges: { type: "array" }, canvasMeta: { type: "object" }, name: { type: "string" }, graphFile: { type: "string", description: "Путь к локальному JSON графа (вместо инлайн-передачи); поддерживается ~" }, backup: { type: "boolean", description: "Снимать авто-бэкап предыдущего состояния перед правкой (по умолчанию true)." } }, required: ["graphId"] } },
|
|
603
672
|
{ 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"] } },
|
|
604
|
-
{ name: "dry_run", description: "Прогнать сценарий без публикации. kind: command|callback|text.", inputSchema: { type: "object", properties: { graphId: { type: "string" }, kind: { type: "string", enum: ["command", "callback", "text"] }, value: { type: "string" }, fromUsername: { type: "string" }, presetVariables: { type: "object" }, presetTags: { type: "array", items: { type: "string" } } }, required: ["graphId", "kind", "value"] } },
|
|
673
|
+
{ 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"] } },
|
|
605
674
|
{ name: "publish_graph", description: "Опубликовать граф. Вернёт publishedGraphId; при отказе проверок — ошибка HTTP 422 со всеми причинами построчно (code@nodeId: message). Сценарий-вебхук (источник WEBHOOK) этим инструментом не публикуется — HTTP 409, его публикуют в вебе.", inputSchema: { type: "object", properties: { graphId: { type: "string" } }, required: ["graphId"] } },
|
|
606
675
|
{ 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"] } },
|
|
607
676
|
{ name: "list_templates", description: "Список готовых шаблонов воронок (id, имя, описание). Можно стартовать граф из шаблона вместо сборки с нуля.", inputSchema: { type: "object", properties: {} } },
|
|
@@ -638,7 +707,7 @@ const TOOLS = [
|
|
|
638
707
|
{ 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"] } },
|
|
639
708
|
{ 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"] } },
|
|
640
709
|
{ name: "site_lead_settings", description: "Куда доставлять заявки из форм сайта (/api/bots/pages/{siteId}/lead-settings). Без settings — прочитать: {settings, scenarios (вебхук-сценарии), amoConnections, coreConnections: [{id, name, provider}] — подключения для доставки «Интеграция», без кредов}. С settings — сохранить ЦЕЛИКОМ (сначала прочитай и поменяй нужное): {notifyBot: в бот уведомлений из профиля, notifyEmail: письмо на почту аккаунта, webhookUrl?: POST JSON на ваш адрес, scenarioId?: вебхук-сценарий, который запускает заявка, amoConnectionId?: сделка в amoCRM, coreDelivery?: {connectionId: id из coreConnections, kind: действие сервиса из integration_catalog (actions[].kind, например amocrm_send), params?: {поле действия: шаблон}} | null}. Пустые params заполнятся из заявки (имя, телефон, почта, текст); в шаблонах — {{var.name}}, {{var.phone}}, {{var.email}}, {{var.<имя поля>}}, {{var.lead_text}} (текст заявки). Чужое подключение или неизвестный kind — 400. Запуск сценариев по заявке — триггер TRIGGER_SITE_FORM (скилл build-bot-funnel).", inputSchema: { type: "object", properties: { siteId: { type: "string" }, settings: { type: "object", properties: { notifyBot: { type: "boolean" }, notifyEmail: { type: "boolean" }, webhookUrl: { type: "string" }, scenarioId: { type: "string" }, amoConnectionId: { type: "string" }, coreDelivery: { type: ["object", "null"], properties: { connectionId: { type: "string" }, kind: { type: "string" }, params: { type: "object", additionalProperties: { type: "string" } } } } } } }, required: ["siteId"] } },
|
|
641
|
-
{ name: "site_leads", description: "Заявки из форм сайта (GET /api/bots/pages/{siteId}/leads): поля, UTM, статус
|
|
710
|
+
{ name: "site_leads", description: "Заявки из форм сайта (GET /api/bots/pages/{siteId}/leads): поля, UTM, статус доставки и обработки. page (с 0), size (до 100), status — фильтр NEW|IN_PROGRESS|DONE|REJECTED. Read-only.", inputSchema: { type: "object", properties: { siteId: { type: "string" }, page: { type: "number" }, size: { type: "number" }, status: { type: "string", enum: ["NEW", "IN_PROGRESS", "DONE", "REJECTED"] } }, required: ["siteId"] } },
|
|
642
711
|
{ 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: {} } },
|
|
643
712
|
{ 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"] } },
|
|
644
713
|
{ 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"] } },
|
|
@@ -658,12 +727,13 @@ const TOOLS = [
|
|
|
658
727
|
{ 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"] } },
|
|
659
728
|
{ 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"] } },
|
|
660
729
|
// ---- Боты ----
|
|
661
|
-
{ 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"] } },
|
|
730
|
+
{ name: "create_bot", description: "Подключить бота по токену (POST /api/bots): platform TELEGRAM (токен от @BotFather) или MAX (токен от MasterBot в MAX). Вебхук настраивается сам; name — отображаемое имя (иначе @username). Возвращает бота с id. Число ботов ограничено тарифом — HTTP 402 со ссылкой на смену тарифа. platform WEB — чат-виджет для сайта без токена (POST /api/bots/web): вернёт botId, key и snippet — код вставки на сайт (у существующего виджета — web_widget_snippet). platform INSTAGRAM по токену не подключается (только вход через Facebook в кабинете, сейчас выключен) — инструмент вернёт ссылку на кабинет вместо ошибки.", inputSchema: { type: "object", properties: { platform: { type: "string", enum: ["TELEGRAM", "MAX", "WEB", "INSTAGRAM"] }, token: { type: "string", description: "Токен бота: 123456789:AA… (Telegram) или токен MAX" }, name: { type: "string" } }, required: ["platform"] } },
|
|
662
731
|
{ name: "bot_stop", description: "Остановить бота (POST /api/bots/{botId}/stop): снимает вебхук, бот перестаёт отвечать, сценарии и подписчики сохраняются. Запуск обратно — bot_resume.", inputSchema: { type: "object", properties: { botId: { type: "string" } }, required: ["botId"] } },
|
|
663
732
|
{ name: "bot_resume", description: "Запустить остановленного бота или бота, приостановленного лимитом тарифа (POST /api/bots/{botId}/resume). Если лимит ботов тарифа исчерпан — HTTP 402 со ссылкой на смену тарифа.", inputSchema: { type: "object", properties: { botId: { type: "string" } }, required: ["botId"] } },
|
|
664
733
|
// ---- Подключения ----
|
|
665
734
|
{ 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}. Остальные ~25 сервисов (smsru, retailcrm, cloudpayments, rest_api, yandex_market и т.д.) берутся из каталога Integration Core (integration_catalog) — provider передавай его provider-ключом в НИЖНЕМ регистре, нужные creds — из configSchema каждого провайдера (hint подскажет, где взять; поле с «необязательно»/optional в hint можно не слать). Без нужных creds вернёт, какие поля и где их взять. connectionId — обновить креды/название существующего подключения (PUT). Сервисы со входом через браузер не падают, а возвращают ссылку для пользователя: GOOGLE_SHEETS → ссылка согласия Google (OAuth; после неё таблицы выбираются в узле «Google Таблицы»), INSTAGRAM → кабинет (вход через Facebook, сейчас выключен). TELEGRAM/MAX — это боты: используй create_bot. Провайдер каталога со статусом COMING_SOON/IN_DEVELOPMENT подключить нельзя (вернёт ошибку). Если каталог недоступен (офлайн/выключен) — работают только 5 легаси-провайдеров выше. Креды хранятся в сервисе зашифрованными и НИКОГДА не возвращаются — ни здесь, ни в list_integrations (только маска hint); в отчёты не попадают. Проверить ключ после подключения — integration_test.", inputSchema: { type: "object", properties: { provider: { type: "string", description: "AMOCRM | BITRIX24 | GETCOURSE | YAMETRIKA | YOOKASSA | GOOGLE_SHEETS | INSTAGRAM, либо провайдер из integration_catalog (его ключ в нижнем регистре, напр. smsru, retailcrm, cloudpayments)" }, title: { type: "string", description: "Название подключения в кабинете (например «amoCRM продажи»)" }, creds: { type: "object", description: "Поля провайдера: для легаси — см. описание, для остальных — ключи из configSchema провайдера в integration_catalog" }, connectionId: { type: "string", description: "id существующего подключения (list_integrations) — обновить его" } }, required: ["provider"] } },
|
|
666
735
|
{ name: "disconnect_integration", description: "Удалить подключение сервиса по id из list_integrations (DELETE /api/bots/integrations/{id}). Действия сценария с этим connectionId перестанут работать.", inputSchema: { type: "object", properties: { connectionId: { type: "string" } }, required: ["connectionId"] } },
|
|
736
|
+
{ name: "integration_ingress_url", description: "URL ингресса для триггера «Внешние события» (POST /api/bots/integrations/{id}/ingress-url, лениво создаёт токен при первом вызове; rotate:true — /ingress-url/rotate: старый URL сразу перестаёт принимать запросы). Вставь этот URL в сервис провайдера (CRM/кассу/форму) как webhook/notification/result URL, чтобы его события доходили до сценария. В сценарии добейся узла TRIGGER_WEBHOOK с config = {provider: provider-ключ в нижнем регистре (из integration_catalog), connectionId: id из list_integrations, event: events[].key из inbound.events того же provider в каталоге} — именно эти три поля конструктор и бэкенд читают для триггера интеграции (GraphValidator.validateWebhookTrigger / IngressFanout); без provider+connectionId узел ведёт себя как обычный TRIGGER_WEBHOOK с произвольным адресом. Только своё подключение (чужое — 403).", inputSchema: { type: "object", properties: { connectionId: { type: "string", description: "id из list_integrations" }, rotate: { type: "boolean", description: "true — выпустить новый URL, старый сразу отключится" } }, required: ["connectionId"] } },
|
|
667
737
|
// ---- Рассылки ----
|
|
668
738
|
{ 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" } } } },
|
|
669
739
|
{ 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"] } },
|
|
@@ -680,6 +750,35 @@ const TOOLS = [
|
|
|
680
750
|
{ 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"] } },
|
|
681
751
|
{ name: "broadcast_duplicate", description: "Копия как новый черновик «<имя> (копия)»: исходник не меняется, время отправки и статистика не переносятся. broadcastId — прямая рассылка (POST /api/bots/broadcasts/{id}/duplicate: бот, фильтр, сообщения; по сценарию — HTTP 409); draftId — черновик (POST /api/bots/broadcasts/drafts/{id}/duplicate). Дальше broadcast_drafts update / broadcast_send {draftId}.", inputSchema: { type: "object", properties: { broadcastId: { type: "string" }, draftId: { type: "string" } } } },
|
|
682
752
|
// ---- Сайты: библиотека шаблонов ----
|
|
753
|
+
// ---- Сайт-виджет, база знаний, подписчик ----
|
|
754
|
+
{ name: "web_widget_snippet", description: "Код вставки чат-виджета на сайт (GET /api/bots/web/{botId}/snippet) — для бота с platform WEB (create_bot {platform:\"WEB\"}). Read-only.", inputSchema: { type: "object", properties: { botId: { type: "string" } }, required: ["botId"] } },
|
|
755
|
+
{ name: "kb_list", description: "Базы знаний пользователя (GET /api/bots/kb): id, name, chunkCount. id — knowledgeBaseId для узла AI_REPLY mode:\"agent\". Read-only.", inputSchema: { type: "object", properties: {} } },
|
|
756
|
+
{ name: "kb_create", description: "Создать пустую базу знаний (POST /api/bots/kb, name ≤ 120 символов). Возвращает id. Наполнение — kb_add_qa / kb_add_site; подключение к сценарию — knowledgeBaseId в узле AI_REPLY mode:\"agent\". Повторный вызов создаёт ВТОРУЮ базу — сначала проверь kb_list.", inputSchema: { type: "object", properties: { name: { type: "string" } }, required: ["name"] } },
|
|
757
|
+
{ name: "kb_delete_doc", description: "Удалить документ из базы знаний вместе с его фрагментами (DELETE /api/bots/kb/{kbId}/docs/{docId}). Необратимо.", inputSchema: { type: "object", properties: { kbId: { type: "string" }, docId: { type: "string" } }, required: ["kbId", "docId"] } },
|
|
758
|
+
{ name: "bot_user_get", description: "Карточка подписчика (GET /api/bots/{botId}/users/{chatId}): теги, переменные (поля, ответы, ai_summary), блокировка, счётчики. chatId — из list_bot_users. Read-only.", inputSchema: { type: "object", properties: { botId: { type: "string" }, chatId: { type: "string", description: "числовой chatId (строкой — без потери точности)" } }, required: ["botId", "chatId"] } },
|
|
759
|
+
{ name: "bot_user_runs", description: "Журнал запусков сценариев подписчика (GET /api/bots/{botId}/users/{chatId}/runs): триггер, статус, шаги с результатом (ветка ИИ-агента, ok/ошибка каждого действия — CRM, уведомление, HTTP). Так проверяют, что сценарий реально сделал нужное. Статус прогона: OK, FAILED (прерван) или PARTIAL — «завершён с ошибками»: дошёл до конца, но хотя бы одно действие упало (у шага ok:false, error «КОД: …»). Read-only.", inputSchema: { type: "object", properties: { botId: { type: "string" }, chatId: { type: "string" }, page: { type: "number" }, size: { type: "number" } }, required: ["botId", "chatId"] } },
|
|
760
|
+
{ name: "dialog_messages", description: "Переписка с подписчиком, свежие сверху (GET /api/bots/{botId}/users/{chatId}/messages, page/size). Read-only.", inputSchema: { type: "object", properties: { botId: { type: "string" }, chatId: { type: "string" }, page: { type: "number" }, size: { type: "number" } }, required: ["botId", "chatId"] } },
|
|
761
|
+
{ name: "dialog_reply", description: "ОТПРАВИТЬ сообщение подписчику от имени оператора (POST /api/bots/{botId}/users/{chatId}/messages) — как ответ из раздела «Диалоги». Уходит РЕАЛЬНОМУ человеку: только по явной просьбе пользователя. Бот при этом не останавливается.", inputSchema: { type: "object", properties: { botId: { type: "string" }, chatId: { type: "string" }, text: { type: "string" } }, required: ["botId", "chatId", "text"] } },
|
|
762
|
+
// ---- Паритет платформы: журнал вызовов, оператор, заявки, виджет, подписчики, бот ----
|
|
763
|
+
{ name: "integration_calls", description: "Журнал вызовов внешних сервисов из сценариев (GET /api/bots/integrations/calls): время, подключение, действие, ok/ошибка, код, попытки, correlationId (= runId прогона). Фильтры: connectionId (из list_integrations), ok (true — только успешные, false — только упавшие), limit (1–200). Так проверяют, что CRM/таблица/HTTP реально получили данные. Read-only. Требует бэкенд feat/battery-completion.", inputSchema: { type: "object", properties: { connectionId: { type: "string" }, ok: { type: "boolean" }, limit: { type: "number" } } } },
|
|
764
|
+
{ name: "dialog_handoff", description: "Передача диалога оператору (POST /api/bots/{botId}/users/{chatId}/handoff {active}): active:true — бот и ИИ молчат, входящие копятся в «Диалогах», отвечает человек (dialog_reply); active:false — «Вернуть боту». Текущее состояние — поле handoff в bot_user_get. Требует бэкенд feat/battery-completion.", inputSchema: { type: "object", properties: { botId: { type: "string" }, chatId: { type: "string" }, active: { type: "boolean" } }, required: ["botId", "chatId", "active"] } },
|
|
765
|
+
{ name: "site_lead_status", description: "Статус заявки сайта (PATCH /api/bots/pages/{siteId}/leads/{leadId} {status}): NEW → IN_PROGRESS → DONE | REJECTED. leadId — из site_leads. Недопустимый переход → ошибка 409. Требует бэкенд feat/battery-completion.", inputSchema: { type: "object", properties: { siteId: { type: "string" }, leadId: { type: "string" }, status: { type: "string", enum: ["NEW", "IN_PROGRESS", "DONE", "REJECTED"] } }, required: ["siteId", "leadId", "status"] } },
|
|
766
|
+
{ name: "integration_update", description: "Изменить подключение (PUT /api/bots/integrations/{connectionId}): title — новое название; creds — новые ключи доступа (заменяют старые; поля — как в connect_integration для этого провайдера). Передай хотя бы одно.", inputSchema: { type: "object", properties: { connectionId: { type: "string" }, title: { type: "string" }, creds: { type: "object", additionalProperties: { type: "string" } } }, required: ["connectionId"] } },
|
|
767
|
+
{ name: "web_widget_settings", description: "Вид чат-виджета сайта (бот platform WEB). Без settings — прочитать (GET /api/bots/web/{botId}/settings: settings, brandingRemovable, snippet). С settings — изменить (PUT): переданные поля накладываются на текущие, остальные сохраняются; сервер проверяет значения (уходят в разметку чужих сайтов).", inputSchema: { type: "object", properties: { botId: { type: "string" }, settings: { type: "object", description: "Поля настроек виджета для изменения (имена — как в ответе чтения)" } }, required: ["botId"] } },
|
|
768
|
+
{ name: "bot_users_import", description: "Добавить подписчикам метки и поля (POST /api/bots/{botId}/users/import): rows — [{chatId, username?, tags?: [строки], variables?: {ключ: значение}}], до 10 000 строк. Метки ДОБАВЛЯЮТСЯ; переменная пишется, только если у подписчика её ещё нет (существующие не перезаписываются); новый chatId создаёт подписчика (в пределах лимита тарифа). Снять метку или перезаписать поле через API нельзя.", inputSchema: { type: "object", properties: { botId: { type: "string" }, rows: { type: "array", items: { type: "object", properties: { chatId: { type: "string" }, username: { type: "string" }, tags: { type: "array", items: { type: "string" } }, variables: { type: "object" } }, required: ["chatId"] } } }, required: ["botId", "rows"] } },
|
|
769
|
+
{ name: "bot_runs", description: "Журнал прогонов сценариев бота. Без runId — список свежих прогонов (GET /api/bots/{botId}/runs, page/size); с runId — один прогон с шагами (GET /api/bots/runs/{runId}, в т.ч. headless-прогоны вебхук-сценариев и расписаний). По подписчику — bot_user_runs, по сценарию — scenario_runs. Статус прогона: OK, FAILED (прерван) или PARTIAL — «завершён с ошибками»: дошёл до конца, но хотя бы одно действие упало (у шага ok:false, error «КОД: …»). Read-only.", inputSchema: { type: "object", properties: { botId: { type: "string" }, runId: { type: "string" }, page: { type: "number" }, size: { type: "number" } } } },
|
|
770
|
+
{ name: "scenario_runs", description: "Журнал прогонов одного сценария (GET /api/bots/graphs/{graphId}/runs, page/size), включая headless-прогоны без чата — вебхук-сценарии, заявки сайта, TRIGGER_SCHEDULE. Доступ — как к просмотру графа. Шаги прогона — bot_runs {runId}. Статус прогона: OK, FAILED (прерван) или PARTIAL — «завершён с ошибками»: дошёл до конца, но хотя бы одно действие упало (у шага ok:false, error «КОД: …»). Read-only. Требует бэкенд feat/battery-completion.", inputSchema: { type: "object", properties: { graphId: { type: "string" }, page: { type: "number" }, size: { type: "number" } }, required: ["graphId"] } },
|
|
771
|
+
{ name: "bot_delete", description: "УДАЛИТЬ бота навсегда (DELETE /api/bots/{botId}) вместе с каналами, сценариями, подписчиками, журналами, рассылками и ссылками. Только владелец. Необратимо: вызывай только по явной просьбе пользователя и с confirm:true. Временно выключить — bot_stop.", inputSchema: { type: "object", properties: { botId: { type: "string" }, confirm: { type: "boolean", description: "true — пользователь явно подтвердил удаление" } }, required: ["botId", "confirm"] } },
|
|
772
|
+
// ---- Запись на слоты (бронирование) ----
|
|
773
|
+
{ name: "booking_calendar_list", description: "Календари записи пользователя (GET /api/bots/booking/calendars): id, name, zone, slotMinutes, hours, exceptions, botId. id — calendarId для действий сценария booking_slots/booking_book/booking_cancel. Read-only.", inputSchema: { type: "object", properties: {} } },
|
|
774
|
+
{ name: "booking_calendar_get", description: "Календарь записи по id (GET /api/bots/booking/calendars/{calendarId}). Read-only.", inputSchema: { type: "object", properties: { calendarId: { type: "string" } }, required: ["calendarId"] } },
|
|
775
|
+
{ name: "booking_calendar_create", description: "Создать календарь записи (POST /api/bots/booking/calendars): сетка слотов по рабочим часам в зоне календаря, в слоте — одна бронь. Без hours свободных слотов не будет.", inputSchema: { type: "object", properties: { name: { type: "string", description: "Название (до 100 символов)" }, zone: { type: "string", description: "Часовой пояс IANA, по умолчанию Europe/Moscow" }, slotMinutes: { type: "number", description: "Длительность слота, 5–1440 мин (по умолчанию 60)" }, hours: { type: "array", description: "Рабочие окна: day 1=пн…7=вс, from/to \"HH:mm\"; несколько окон в день — перерыв", items: { type: "object", properties: { day: { type: "number" }, from: { type: "string" }, to: { type: "string" } }, required: ["day", "from", "to"] } }, exceptions: { type: "array", description: "Исключения на даты: {date:\"ГГГГ-ММ-ДД\"} — выходной, с from/to — только это окно", items: { type: "object", properties: { date: { type: "string" }, from: { type: "string" }, to: { type: "string" } }, required: ["date"] } }, botId: { type: "string", description: "Необязательно: только для этого бота (иначе — все боты владельца)" } }, required: ["name"] } },
|
|
776
|
+
{ name: "booking_calendar_update", description: "Изменить календарь (PUT /api/bots/booking/calendars/{calendarId}): переданные поля заменяют текущие (hours/exceptions — целиком), остальные сохраняются. Существующие брони не трогаются.", inputSchema: { type: "object", properties: { calendarId: { type: "string" }, name: { type: "string", description: "Название (до 100 символов)" }, zone: { type: "string", description: "Часовой пояс IANA, по умолчанию Europe/Moscow" }, slotMinutes: { type: "number", description: "Длительность слота, 5–1440 мин (по умолчанию 60)" }, hours: { type: "array", description: "Рабочие окна: day 1=пн…7=вс, from/to \"HH:mm\"; несколько окон в день — перерыв", items: { type: "object", properties: { day: { type: "number" }, from: { type: "string" }, to: { type: "string" } }, required: ["day", "from", "to"] } }, exceptions: { type: "array", description: "Исключения на даты: {date:\"ГГГГ-ММ-ДД\"} — выходной, с from/to — только это окно", items: { type: "object", properties: { date: { type: "string" }, from: { type: "string" }, to: { type: "string" } }, required: ["date"] } }, botId: { type: "string", description: "Необязательно: только для этого бота (иначе — все боты владельца)" } }, required: ["calendarId"] } },
|
|
777
|
+
{ name: "booking_calendar_delete", description: "УДАЛИТЬ календарь записи (DELETE /api/bots/booking/calendars/{calendarId}). Сценарии с его calendarId перестанут записывать. Только по явной просьбе, с confirm:true.", inputSchema: { type: "object", properties: { calendarId: { type: "string" }, confirm: { type: "boolean" } }, required: ["calendarId", "confirm"] } },
|
|
778
|
+
{ name: "booking_slots", description: "Свободные слоты календаря (GET /api/bots/booking/calendars/{calendarId}/slots): [{at — ISO-8601 с зоной, label — «пн 12.10 15:00»}]. from/to — даты ГГГГ-ММ-ДД в зоне календаря (по умолчанию сегодня … +6 дней), limit — 1–500 (по умолчанию 50). Read-only.", inputSchema: { type: "object", properties: { calendarId: { type: "string" }, from: { type: "string" }, to: { type: "string" }, limit: { type: "number" } }, required: ["calendarId"] } },
|
|
779
|
+
{ name: "booking_list", description: "Брони календаря от даты from (ГГГГ-ММ-ДД, по умолчанию сегодня), включая отменённые, до 500 (GET /api/bots/booking/calendars/{calendarId}/bookings). Read-only.", inputSchema: { type: "object", properties: { calendarId: { type: "string" }, from: { type: "string" } }, required: ["calendarId"] } },
|
|
780
|
+
{ name: "booking_create", description: "Записать вручную на слот (POST /api/bots/booking/calendars/{calendarId}/bookings {slotAt, name, phone}): slotAt — at из booking_slots (ISO-8601 с зоной). Слот занят → понятная ошибка «занят», слота нет в сетке → ошибка 400.", inputSchema: { type: "object", properties: { calendarId: { type: "string" }, slotAt: { type: "string" }, name: { type: "string" }, phone: { type: "string" } }, required: ["calendarId", "slotAt"] } },
|
|
781
|
+
{ name: "booking_cancel", description: "Отменить бронь и освободить слот (POST /api/bots/booking/calendars/{calendarId}/bookings/{bookingId}/cancel). Повторная отмена — без ошибки. bookingId — из booking_list.", inputSchema: { type: "object", properties: { calendarId: { type: "string" }, bookingId: { type: "string" } }, required: ["calendarId", "bookingId"] } },
|
|
683
782
|
{ 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" } } } },
|
|
684
783
|
];
|
|
685
784
|
|
|
@@ -748,6 +847,7 @@ async function handleCall(params) {
|
|
|
748
847
|
case "update_graph": {
|
|
749
848
|
const src = resolveGraphInput(a);
|
|
750
849
|
if (!Array.isArray(src.nodes) || !Array.isArray(src.edges)) throw new Error("Нужны nodes[] и edges[] (через graphFile, graph или nodes/edges).");
|
|
850
|
+
assertGraphIds(src);
|
|
751
851
|
const payload = { nodes: src.nodes, edges: src.edges, canvasMeta: src.canvasMeta ?? {} };
|
|
752
852
|
if (a.name ?? src.name) payload.name = a.name ?? src.name;
|
|
753
853
|
return okResult(await api(`/api/bots/graphs/${a.graphId}`, { method: "PUT", body: payload }));
|
|
@@ -755,6 +855,7 @@ async function handleCall(params) {
|
|
|
755
855
|
case "edit_graph_live": {
|
|
756
856
|
const src = resolveGraphInput(a);
|
|
757
857
|
if (!Array.isArray(src.nodes) || !Array.isArray(src.edges)) throw new Error("Нужны nodes[] и edges[] (через graphFile, graph или nodes/edges).");
|
|
858
|
+
assertGraphIds(src);
|
|
758
859
|
const steps = [];
|
|
759
860
|
let backupGraphId = null;
|
|
760
861
|
if (a.backup !== false) {
|
|
@@ -840,6 +941,7 @@ async function handleCall(params) {
|
|
|
840
941
|
}
|
|
841
942
|
case "import_funnel": {
|
|
842
943
|
const src = a.graphFile ? extractGraph(readGraphFile(a.graphFile)) : extractGraph(a.graph);
|
|
944
|
+
assertGraphIds(src);
|
|
843
945
|
const steps = [];
|
|
844
946
|
const created = await api(`/api/bots/${a.botId}/graphs`, { method: "POST", body: { name: a.name || src.name || "Воронка" } });
|
|
845
947
|
const graphId = created.id;
|
|
@@ -933,6 +1035,7 @@ async function handleCall(params) {
|
|
|
933
1035
|
const qs = [];
|
|
934
1036
|
if (a.page != null) qs.push(`page=${encodeURIComponent(a.page)}`);
|
|
935
1037
|
if (a.size != null) qs.push(`size=${encodeURIComponent(a.size)}`);
|
|
1038
|
+
if (a.status) qs.push(`status=${enumArg(a.status, "status", LEAD_STATUSES)}`);
|
|
936
1039
|
return okResult(await api(`/api/bots/pages/${a.siteId}/leads${qs.length ? `?${qs.join("&")}` : ""}`));
|
|
937
1040
|
}
|
|
938
1041
|
case "site_rollback": {
|
|
@@ -983,6 +1086,13 @@ async function handleCall(params) {
|
|
|
983
1086
|
if (!a.question || !String(a.question).trim()) throw new Error("Передай question.");
|
|
984
1087
|
return okResult(await api(`/api/bots/agents/${a.agentId}/test-chat`, { method: "POST", body: { question: a.question, history: a.history } }));
|
|
985
1088
|
}
|
|
1089
|
+
// ---- Сайт-виджет, база знаний, подписчик ----
|
|
1090
|
+
case "web_widget_snippet": return okResult(await api(`/api/bots/web/${a.botId}/snippet`));
|
|
1091
|
+
case "kb_list": return okResult(await api("/api/bots/kb"));
|
|
1092
|
+
case "kb_create": {
|
|
1093
|
+
if (!a.name || !String(a.name).trim()) throw new Error("Передай name — название базы знаний.");
|
|
1094
|
+
return okResult(await api("/api/bots/kb", { method: "POST", body: { name: String(a.name).trim() } }));
|
|
1095
|
+
}
|
|
986
1096
|
case "kb_docs": return okResult(await api(`/api/bots/kb/${a.kbId}/docs`));
|
|
987
1097
|
case "kb_add_qa": {
|
|
988
1098
|
if (!Array.isArray(a.pairs) || a.pairs.length === 0) throw new Error("Передай pairs — непустой массив [{question, answer}].");
|
|
@@ -1001,11 +1111,155 @@ async function handleCall(params) {
|
|
|
1001
1111
|
const qs = a.days != null ? `?days=${encodeURIComponent(a.days)}` : "";
|
|
1002
1112
|
return okResult(await api(`/api/bots/agents/${a.agentId}/unanswered${qs}`));
|
|
1003
1113
|
}
|
|
1114
|
+
case "kb_delete_doc": {
|
|
1115
|
+
await api(`/api/bots/kb/${a.kbId}/docs/${a.docId}`, { method: "DELETE" });
|
|
1116
|
+
return okResult(`🗑️ Документ ${a.docId} удалён из базы ${a.kbId}.`);
|
|
1117
|
+
}
|
|
1118
|
+
case "bot_user_get": return okResult(await api(`/api/bots/${a.botId}/users/${chatIdArg(a.chatId)}`));
|
|
1119
|
+
case "bot_user_runs": return okResult(await api(`/api/bots/${a.botId}/users/${chatIdArg(a.chatId)}/runs${pageQs(a)}`));
|
|
1120
|
+
case "dialog_messages": return okResult(await api(`/api/bots/${a.botId}/users/${chatIdArg(a.chatId)}/messages${pageQs(a)}`));
|
|
1121
|
+
case "dialog_reply": {
|
|
1122
|
+
const text = String(a.text ?? "").trim();
|
|
1123
|
+
if (!text) throw new Error("Передай text — текст ответа.");
|
|
1124
|
+
return okResult(await api(`/api/bots/${a.botId}/users/${chatIdArg(a.chatId)}/messages`, { method: "POST", body: { text } }));
|
|
1125
|
+
}
|
|
1126
|
+
// ---- Запись на слоты ----
|
|
1127
|
+
case "booking_calendar_list": return okResult(await api("/api/bots/booking/calendars"));
|
|
1128
|
+
case "booking_calendar_get": return okResult(await api(`/api/bots/booking/calendars/${uuidArg(a.calendarId, "calendarId")}`));
|
|
1129
|
+
case "booking_calendar_create": {
|
|
1130
|
+
const body = calendarBody(a);
|
|
1131
|
+
if (!body.name) throw new Error("Передай name — название календаря.");
|
|
1132
|
+
return okResult(await api("/api/bots/booking/calendars", { method: "POST", body }));
|
|
1133
|
+
}
|
|
1134
|
+
case "booking_calendar_update": {
|
|
1135
|
+
const path_ = `/api/bots/booking/calendars/${uuidArg(a.calendarId, "calendarId")}`;
|
|
1136
|
+
const { calendarId, ...fields } = a;
|
|
1137
|
+
if (!Object.keys(fields).length) throw new Error("Передай хотя бы одно поле: name, zone, slotMinutes, hours, exceptions, botId.");
|
|
1138
|
+
const body = calendarBody(fields, await api(path_)); // PUT заменяет календарь целиком — накладываем на текущий
|
|
1139
|
+
return okResult(await api(path_, { method: "PUT", body }));
|
|
1140
|
+
}
|
|
1141
|
+
case "booking_calendar_delete": {
|
|
1142
|
+
const id = uuidArg(a.calendarId, "calendarId");
|
|
1143
|
+
if (a.confirm !== true) throw new Error("Удаление календаря необратимо. Спроси пользователя и повтори с confirm:true.");
|
|
1144
|
+
await api(`/api/bots/booking/calendars/${id}`, { method: "DELETE" });
|
|
1145
|
+
return okResult(`🗑️ Календарь ${id} удалён.`);
|
|
1146
|
+
}
|
|
1147
|
+
case "booking_slots": {
|
|
1148
|
+
const q = {};
|
|
1149
|
+
if (a.from) q.from = dateArg(a.from, "from");
|
|
1150
|
+
if (a.to) q.to = dateArg(a.to, "to");
|
|
1151
|
+
if (a.limit != null) {
|
|
1152
|
+
const n = Number(a.limit);
|
|
1153
|
+
if (!Number.isInteger(n) || n < 1 || n > 500) throw new Error("limit — целое от 1 до 500.");
|
|
1154
|
+
q.limit = n;
|
|
1155
|
+
}
|
|
1156
|
+
return okResult(await api(`/api/bots/booking/calendars/${uuidArg(a.calendarId, "calendarId")}/slots${qs(q)}`));
|
|
1157
|
+
}
|
|
1158
|
+
case "booking_list": {
|
|
1159
|
+
const q = a.from ? { from: dateArg(a.from, "from") } : {};
|
|
1160
|
+
return okResult(await api(`/api/bots/booking/calendars/${uuidArg(a.calendarId, "calendarId")}/bookings${qs(q)}`));
|
|
1161
|
+
}
|
|
1162
|
+
case "booking_create": {
|
|
1163
|
+
const id = uuidArg(a.calendarId, "calendarId");
|
|
1164
|
+
const slotAt = String(a.slotAt ?? "").trim();
|
|
1165
|
+
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/.test(slotAt) || Number.isNaN(Date.parse(slotAt))) {
|
|
1166
|
+
throw new Error(`slotAt — ISO-8601 с зоной (at из booking_slots), например 2026-10-12T15:00:00+03:00; получено: ${slotAt || "пусто"}.`);
|
|
1167
|
+
}
|
|
1168
|
+
try {
|
|
1169
|
+
return okResult(await api(`/api/bots/booking/calendars/${id}/bookings`, { method: "POST", body: { slotAt, name: a.name, phone: a.phone } }));
|
|
1170
|
+
} catch (e) {
|
|
1171
|
+
if (e.status === 409) throw new Error(`Слот ${slotAt} уже занят — выбери другой из booking_slots.`);
|
|
1172
|
+
throw e;
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
case "booking_cancel": {
|
|
1176
|
+
const id = uuidArg(a.calendarId, "calendarId");
|
|
1177
|
+
const bookingId = uuidArg(a.bookingId, "bookingId");
|
|
1178
|
+
await api(`/api/bots/booking/calendars/${id}/bookings/${bookingId}/cancel`, { method: "POST" });
|
|
1179
|
+
return okResult(`✅ Бронь ${bookingId} отменена, слот свободен.`);
|
|
1180
|
+
}
|
|
1181
|
+
// ---- Паритет платформы ----
|
|
1182
|
+
case "integration_calls": {
|
|
1183
|
+
const q = {};
|
|
1184
|
+
if (a.connectionId != null && a.connectionId !== "") q.connectionId = idArg(a.connectionId, "connectionId");
|
|
1185
|
+
if (a.ok != null) {
|
|
1186
|
+
if (typeof a.ok !== "boolean") throw new Error("ok — true или false.");
|
|
1187
|
+
q.ok = a.ok;
|
|
1188
|
+
}
|
|
1189
|
+
if (a.limit != null) {
|
|
1190
|
+
const n = Number(a.limit);
|
|
1191
|
+
if (!Number.isInteger(n) || n < 1 || n > 200) throw new Error("limit — целое от 1 до 200.");
|
|
1192
|
+
q.limit = n;
|
|
1193
|
+
}
|
|
1194
|
+
return okResult(await api(`/api/bots/integrations/calls${qs(q)}`));
|
|
1195
|
+
}
|
|
1196
|
+
case "dialog_handoff": {
|
|
1197
|
+
if (typeof a.active !== "boolean") throw new Error("active — true (передать оператору) или false (вернуть боту).");
|
|
1198
|
+
const res = await api(`/api/bots/${uuidArg(a.botId, "botId")}/users/${chatIdArg(a.chatId)}/handoff`, { method: "POST", body: { active: a.active } });
|
|
1199
|
+
return okResult(res ?? (a.active ? "✅ Диалог передан оператору: бот молчит." : "✅ Диалог возвращён боту."));
|
|
1200
|
+
}
|
|
1201
|
+
case "site_lead_status": {
|
|
1202
|
+
const status = enumArg(a.status, "status", LEAD_STATUSES);
|
|
1203
|
+
try {
|
|
1204
|
+
return okResult(await api(`/api/bots/pages/${uuidArg(a.siteId, "siteId")}/leads/${uuidArg(a.leadId, "leadId")}`, { method: "PATCH", body: { status } }));
|
|
1205
|
+
} catch (e) {
|
|
1206
|
+
if (e.status === 409) throw new Error(`Недопустимый переход статуса заявки в ${status}: NEW → IN_PROGRESS → DONE | REJECTED.`);
|
|
1207
|
+
throw e;
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
case "integration_update": {
|
|
1211
|
+
const body = {};
|
|
1212
|
+
if (a.title != null) {
|
|
1213
|
+
const t = String(a.title).trim();
|
|
1214
|
+
if (!t) throw new Error("title не может быть пустым.");
|
|
1215
|
+
body.title = t;
|
|
1216
|
+
}
|
|
1217
|
+
if (a.creds != null) {
|
|
1218
|
+
if (typeof a.creds !== "object" || Array.isArray(a.creds) || !Object.keys(a.creds).length) throw new Error("creds — непустой объект {поле: значение}.");
|
|
1219
|
+
body.creds = Object.fromEntries(Object.entries(a.creds).map(([k, v]) => [k, String(v)]));
|
|
1220
|
+
}
|
|
1221
|
+
if (!Object.keys(body).length) throw new Error("Передай title и/или creds.");
|
|
1222
|
+
return okResult(await api(`/api/bots/integrations/${idArg(a.connectionId, "connectionId")}`, { method: "PUT", body }));
|
|
1223
|
+
}
|
|
1224
|
+
case "web_widget_settings": {
|
|
1225
|
+
const path_ = `/api/bots/web/${uuidArg(a.botId, "botId")}/settings`;
|
|
1226
|
+
if (a.settings == null) return okResult(await api(path_));
|
|
1227
|
+
if (typeof a.settings !== "object" || Array.isArray(a.settings)) throw new Error("settings — объект с полями настроек.");
|
|
1228
|
+
const cur = await api(path_);
|
|
1229
|
+
const merged = { ...(cur?.settings ?? {}), ...a.settings };
|
|
1230
|
+
return okResult(await api(path_, { method: "PUT", body: merged }));
|
|
1231
|
+
}
|
|
1232
|
+
case "bot_users_import": {
|
|
1233
|
+
if (!Array.isArray(a.rows) || !a.rows.length) throw new Error("Передай rows — непустой массив [{chatId, tags?, variables?}].");
|
|
1234
|
+
if (a.rows.length > 10000) throw new Error("rows — не больше 10 000 строк за вызов.");
|
|
1235
|
+
// chatId бывает > 2^53: в JSON кладём числом прямо из строки, без Number().
|
|
1236
|
+
const rows = a.rows.map((r, i) => {
|
|
1237
|
+
const cid = String(r?.chatId ?? "").trim();
|
|
1238
|
+
if (!/^[1-9]\d{0,18}$/.test(cid)) throw new Error(`rows[${i}].chatId — положительное число, получено: ${cid || "пусто"}.`);
|
|
1239
|
+
if (r.tags != null && !Array.isArray(r.tags)) throw new Error(`rows[${i}].tags — массив строк.`);
|
|
1240
|
+
if (r.variables != null && (typeof r.variables !== "object" || Array.isArray(r.variables))) throw new Error(`rows[${i}].variables — объект.`);
|
|
1241
|
+
return { chatId: `@@CID${cid}@@`, username: r.username, tags: r.tags, variables: r.variables };
|
|
1242
|
+
});
|
|
1243
|
+
const raw = JSON.stringify({ rows }).replace(/"@@CID(\d+)@@"/g, "$1");
|
|
1244
|
+
return okResult(await api(`/api/bots/${uuidArg(a.botId, "botId")}/users/import`, { method: "POST", rawBody: raw }));
|
|
1245
|
+
}
|
|
1246
|
+
case "bot_runs": {
|
|
1247
|
+
if (a.runId) return okResult(await api(`/api/bots/runs/${idArg(a.runId, "runId")}`));
|
|
1248
|
+
return okResult(await api(`/api/bots/${uuidArg(a.botId, "botId")}/runs${pageQs(a)}`));
|
|
1249
|
+
}
|
|
1250
|
+
case "scenario_runs": return okResult(await api(`/api/bots/graphs/${uuidArg(a.graphId, "graphId")}/runs${pageQs(a)}`));
|
|
1251
|
+
case "bot_delete": {
|
|
1252
|
+
const botId = uuidArg(a.botId, "botId");
|
|
1253
|
+
if (a.confirm !== true) throw new Error("Удаление бота необратимо. Спроси пользователя и повтори с confirm:true.");
|
|
1254
|
+
await api(`/api/bots/${botId}`, { method: "DELETE" });
|
|
1255
|
+
return okResult(`🗑️ Бот ${botId} удалён вместе со сценариями и подписчиками.`);
|
|
1256
|
+
}
|
|
1004
1257
|
// ---- Боты ----
|
|
1005
1258
|
case "create_bot": {
|
|
1006
1259
|
const platform = String(a.platform || "").trim().toUpperCase();
|
|
1007
1260
|
if (platform === "INSTAGRAM") return instagramAnswer();
|
|
1008
|
-
if (platform
|
|
1261
|
+
if (platform === "WEB") return okResult(await api("/api/bots/web", { method: "POST", body: { name: a.name || undefined } }));
|
|
1262
|
+
if (platform !== "TELEGRAM" && platform !== "MAX") throw new Error("platform: TELEGRAM | MAX | WEB (Instagram подключается только в кабинете).");
|
|
1009
1263
|
const token = String(a.token || "").trim();
|
|
1010
1264
|
if (!token) {
|
|
1011
1265
|
return okResult(platform === "MAX"
|
|
@@ -1114,6 +1368,13 @@ async function handleCall(params) {
|
|
|
1114
1368
|
case "disconnect_integration":
|
|
1115
1369
|
await api(`/api/bots/integrations/${a.connectionId}`, { method: "DELETE" });
|
|
1116
1370
|
return okResult(`🗑️ Подключение ${a.connectionId} удалено.`);
|
|
1371
|
+
case "integration_ingress_url": {
|
|
1372
|
+
const id = String(a.connectionId || "").trim();
|
|
1373
|
+
if (!id) throw new Error("Передай connectionId — id подключения из list_integrations.");
|
|
1374
|
+
const suffix = a.rotate ? "/rotate" : "";
|
|
1375
|
+
const r = await api(`/api/bots/integrations/${encodeURIComponent(id)}/ingress-url${suffix}`, { method: "POST" });
|
|
1376
|
+
return okResult({ ...r, usage: "Вставь url в сервис провайдера как webhook/notification/result URL. В узле TRIGGER_WEBHOOK сценария задай config.provider + config.connectionId + config.event (ключ события из integration_catalog → inbound.events)." });
|
|
1377
|
+
}
|
|
1117
1378
|
// ---- Рассылки ----
|
|
1118
1379
|
case "broadcast_list": {
|
|
1119
1380
|
if (a.botId) return okResult(await api(`/api/bots/${a.botId}/broadcasts`));
|