@retensy/mcp 0.18.0 → 0.20.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 CHANGED
@@ -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, name?)` | подключить бота Telegram/MAX по токену (Instagram → ссылка на кабинет) |
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) |
@@ -166,6 +167,34 @@ MCP-сервер (+ скилл для Claude Code) для **сборки и пу
166
167
  | `kb_add_text(kbId, title, text)` | добавить источник «Текст/инструкция» |
167
168
  | `kb_add_site(kbId, url, schedule?)` | добавить сайт обходом страниц; `schedule`: `NEVER`\|`DAILY`\|`WEEKLY`\|`MONTHLY` |
168
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
+ | `bot_rename(botId, name)` / `bot_change_token(botId, token)` | переименовать бота (пустое имя — `@username`) / сменить токен после перевыпуска |
185
+ | `bot_channel_list(botId)` / `bot_channel_add(botId, token, name?)` / `bot_channel_delete(botId, channelId, confirm:true)` | дополнительные Telegram-боты (мультиканальность); каналы/группы для постинга — `list_channels` |
186
+ | `link_create(botId, name?, targetNodeId?)` / `link_delete(linkId, confirm:true)` | стартовая ссылка (диплинк на узел) / удаление; список — `list_links` |
187
+ | `utm_sources(botId)` / `ab_results(graphId, branchNodeId, period?)` | UTM-источники подписчиков / итоги A/B-развилки (`period`: `24h`, `7d`, `30d`) |
188
+ | `kb_delete(kbId, confirm:true)` | удалить базу знаний целиком (база агента — только вместе с агентом) |
189
+ | `agent_unpublish(agentId)` / `agent_delete(agentId, confirm:true)` | снять агента с публикации / удалить (409 `AGENT_IN_USE`, пока подключён к сценариям) |
190
+ | `site_leads_mark_read(siteId)` / `site_lead_delete(siteId, leadId, confirm:true)` / `site_leads_export(siteId, savePath?)` | заявки сайта: всё прочитано / удалить заявку / CSV |
191
+ | `bot_users_export(botId, format?, savePath?)` | выгрузка подписчиков `csv`\|`json` (с `savePath` — в файл) |
192
+ | `bot_user_reset(botId, chatId, confirm:true)` | сбросить сессию подписчика: позиция, метки, переменные (для повторного теста воронки) |
193
+ | `booking_calendar_list()` / `booking_calendar_get(calendarId)` | календари записи (id → `calendarId` действий сценария `booking_*`) |
194
+ | `booking_calendar_create(name, zone?, slotMinutes?, hours?, exceptions?, botId?)` | новый календарь: окна `{day:1–7, from:"HH:mm", to:"HH:mm"}`, исключения `{date, from?, to?}` |
195
+ | `booking_calendar_update(calendarId, …поля)` / `booking_calendar_delete(calendarId, confirm:true)` | правка (поля накладываются на текущие) / удаление |
196
+ | `booking_slots(calendarId, from?, to?, limit?)` | свободные слоты `[{at, label}]`, даты ГГГГ-ММ-ДД в зоне календаря |
197
+ | `booking_list(calendarId, from?)` / `booking_create(calendarId, slotAt, name?, phone?)` / `booking_cancel(calendarId, bookingId)` | брони: список / ручная запись (занятый слот — ошибка «занят») / отмена |
169
198
 
170
199
  ### Рассылки
171
200
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retensy/mcp",
3
- "version": "0.18.0",
3
+ "version": "0.20.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/parity-2.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. **Проверь локально** перед заливкой:
@@ -36,12 +36,15 @@
36
36
  - `TRIGGER_CALLBACK` — `{ "matchMode": "EQUALS"|"STARTS_WITH", "value": "<callback_data>" }`
37
37
  - `TRIGGER_TEXT` — `{ "matchMode": "ANY"|"EQUALS"|"CONTAINS"|"REGEX", "value": "..." }`
38
38
  - `BROADCAST_FILTER` — режим рассылки (если есть — единственный триггер).
39
- - `TRIGGER_PAYMENT` — в кассе владельца прошла оплата.
39
+ - `TRIGGER_PAYMENT` («Оплата прошла») — в кассе владельца прошла оплата: ЮKassa или подключённые через
40
+ `connect_integration` CloudPayments, Robokassa, Т-Банк, Prodamus, bePaid (подтверждённая оплата этих касс
41
+ нормализуется к тому же виду).
40
42
  `{ "minAmount": "1000", "descriptionContains": "курс" }` — оба фильтра необязательны.
41
43
  **Ставь хотя бы один фильтр:** без них сценарий будет запускаться на КАЖДУЮ оплату в кассе.
42
44
  В контексте: `{{payment.id}}`, `{{payment.amount}}`, `{{payment.status}}`,
43
- `{{payment.description}}`, `{{payment.currency}}`, плюс весь ответ ЮKassa как `body`.
44
- Запуск — один раз на платёж: повторные уведомления ЮKassa о той же оплате сценарий не перезапускают. Сюда же приходит оплата счёта, пришедшая уже после `timeout` блока со счётом, — в диалоге того подписчика (пока бот выключен, оплата ждёт повтора ЮKassa).
45
+ `{{payment.description}}`, `{{payment.currency}}`, `{{payment.provider}}` (какая касса), плюс весь документ платежа
46
+ как `body`.
47
+ Запуск — один раз на платёж: повторные уведомления кассы о той же оплате сценарий не перезапускают. Сюда же приходит оплата счёта, пришедшая уже после `timeout` блока со счётом, — в диалоге того подписчика (пока бот выключен, оплата ждёт повтора ЮKassa).
45
48
  - **Счёт выставлял бот** (действие `issue_invoice`) → подписчик определяется САМ: счёт
46
49
  помнит его в `metadata` платежа. Прогон идёт в обычной сессии — можно прямо ставить
47
50
  «Отправить сообщение» и «Добавить метку», указывать получателя и разбирать ответ кассы
@@ -79,7 +82,9 @@
79
82
  (`integration_ingress_url`) ведёт на обработчик конкретного провайдера, и запрос принимается только
80
83
  в формате, который он понимает (подпись/HMAC сервиса проверяется на бэке). Переменные события —
81
84
  `{{body.<ключ>}}`, ключи и примеры — в `inbound.events[].vars` того же события каталога.
82
- `event: "<key>"` с `paymentSucceeded:true` (кассы ЮKassa/CloudPayments/Robokassa/Т-Банк/Prodamus) помимо
85
+ amoCRM шлёт пачку сущностей одним запросом (`leads[add][0..n]`, `leads[status]`, `contacts[add]`) — сценарий
86
+ запускается по КАЖДОЙ сущности отдельно (порядок: сделки добавлены → смена этапа → контакты), не только по первой.
87
+ `event: "<key>"` с `paymentSucceeded:true` (кассы ЮKassa/CloudPayments/Robokassa/Т-Банк/Prodamus/bePaid) помимо
83
88
  этого же `{{body.<ключ>}}`-прогона ЕЩЁ запускает узел «Оплата прошла» ваших графов — headless (без чата)
84
89
  либо в чате подписчика, если платёжную ссылку выдавало действие `*_link`/`issue_invoice` с привязкой
85
90
  к нему. Тестовый/demo-платёж (`test_mode`/`demo_mode` в переменных) прогоняет только обычный вебхук —
@@ -90,6 +95,11 @@
90
95
  `{{var.<имя>}}`, всегда `{{var.name}}`/`{{var.email}}`/`{{var.phone}}` (по имени поля, иначе по подписи),
91
96
  UTM `{{var.utm_*}}`. Прогон без чата: узлы отправки — только с `config.target`. Работает и в сценарии
92
97
  без бота (источник WEBHOOK — тогда `TRIGGER_WEBHOOK` не обязателен), и в сценарии бота; не для Instagram.
98
+ - `TRIGGER_SCHEDULE` — `{ "cron": "0 9 * * 1-5", "timezone": "Europe/Moscow" }` — запуск сценария по расписанию:
99
+ cron из 5 полей (минута час день месяц день-недели), не чаще раза в минуту; зона по умолчанию `Europe/Moscow`.
100
+ Прогон **headless** (без чата): узлы отправки — только с явным `config.target`, действия, которым нужен
101
+ подписчик, пропускаются; время срабатывания — `{{schedule.at}}`. Запуски, пропущенные во время простоя сервиса,
102
+ не догоняются. Работает в опубликованном активном сценарии (бота или вебхук-сценарии). Журнал — `scenario_runs`.
93
103
  - ~~`TRIGGER_COMMENT`~~ — **мёртвый тип**: рантайм нигде не выставляет `event="comment"`, сработать
94
104
  он не может. Убран из палитры редактора. Комментарии Instagram — `TRIGGER_IG_COMMENT`.
95
105
 
@@ -145,8 +155,8 @@ IG-боты не поддерживают команды (`/start`). Вход
145
155
  - `DELAY` — пауза. Три вида (`kind`):
146
156
  - **`FIXED`** («Отправить через»): `{ "kind":"FIXED", "durationSec": 86400 }` — `durationSec` в **секундах** (60 = 1 мин, 3600 = 1 час, 86400 = 1 сутки). Редактор также пишет `{ "kind":"FIXED", "duration": 24, "unit":"MINUTES"|"HOURS"|"DAYS" }` (минуты/часы/дни — чтобы не вбивать большие числа). Для генерации проще `durationSec`.
147
157
  - **`TOMORROW`** («Отправить завтра»): `{ "kind":"TOMORROW", "time":"18:00" }` — завтра в указанное время `HH:mm` (МСК), относительно момента, когда пользователь дошёл до узла.
148
- - **`UNTIL`** («Отправить в»): `{ "kind":"UNTIL", "isoTimestamp":"2026-06-25T15:00:00Z" }` — конкретный момент в ISO-8601 (UTC). ⚠️ Рантайм читает только `isoTimestamp`; пары `isoDate`+`time` НЕ работают.
149
- - `SCHEDULE` — `{ "isoDate":"2026-06-25", "time":"18:00", "timezone":"Europe/Moscow" }`. Выходы `scheduled` / `past`.
158
+ - **`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`.
159
+ - `SCHEDULE` — `{ "isoDate":"2026-06-25", "time":"18:00", "timezone":"Europe/Moscow" }`. Выходы `scheduled` / `past`. `isoDate` может быть и ISO дата-временем или шаблоном (`"{{var.slot_at}}"`, тогда `time` не нужен) + `offset` как у `DELAY UNTIL`.
150
160
  - Прогон без пауз (между `DELAY`, `ASK_QUESTION`/`awaitReply`, ожиданием оплаты) ограничен **5 минутами**: дольше — рантайм обрывает его с ошибкой шага `run deadline exceeded` (прогон `FAILED`, пауза подписчика снята). Длинные цепочки `external_request` / `CALL_WEBHOOK` / `AI_REPLY` разноси `DELAY` — после паузы начинается новый прогон.
151
161
  - Массовый одинаковый момент срабатывания (`TOMORROW`/`UNTIL`/`SCHEDULE` у тысяч подписчиков одного бота) расходится постепенно: до 10 срабатываний на бота в секунду, время растёт с аудиторией (≈100 с на 1000 подписчиков, ≈17 мин на 10 000), другие боты не ждут. Не рассчитывай, что все получат сообщение в одну секунду.
152
162
  - Если Telegram вернул боту 429 (`retry after N`, пауза не дольше часа), срабатывания `DELAY`/`SCHEDULE` и рассылки этого бота (и по `BROADCAST_FILTER`, и прямые) ждут конца паузы; после такой паузы рассылка продолжается без потерь и без повтора уже доставленного. Это только про паузу 429: если сервис перезапустился посреди отправки, получатель, на котором её прервали, через ≤15 мин может получить рассылку (или прогон сценария) повторно. Ответ подписчику в живом диалоге отправляется сразу, без ожидания; если Telegram откажет снова, шаг уходит в ветку «Ошибка», если она проведена, иначе дальше по обычному выходу.
@@ -164,7 +174,19 @@ IG-боты не поддерживают команды (`/start`). Вход
164
174
  ветвиться по коду ответа надо следующим блоком `SWITCH`. **Платное действие** (как `CALL_WEBHOOK`):
165
175
  на бесплатном тарифе публикация падает с `PREMIUM_NODE_FORBIDDEN`.
166
176
  Ещё есть `subscriber_webhook` — `url` + `method`/`headersJson`/`bodyTemplate`
167
- - **уведомления**: `notify` (`text`) — владельцу бота в бот уведомлений из его профиля (Telegram/MAX); не подключён → действие не удалось, блок уходит в `error`; `subscriber_email` (`email`,`text`), `agent_chat`
177
+ - **уведомления**: `notify` (`text`) — владельцу бота в бот уведомлений из его профиля (Telegram/MAX); не подключён → действие не удалось, блок уходит в `error`; `subscriber_email` (`email`,`text`)
178
+ - **передача оператору**: `agent_chat` (без полей) — «позвать человека»: диалог встаёт на паузу (бот и ИИ молчат, входящие копятся в «Диалогах»), владельцу уходит уведомление; без бота уведомлений действие помечается `NOT_CONFIGURED`, пауза всё равно включается. Вернуть боту — кнопка в «Диалогах» или MCP `dialog_handoff {active:false}`. В dry-run и headless-прогонах пропускается.
179
+ - **срок ответа оператора (SLA)**: `sla_check` — опц. `maxLevel` (0 — без потолка), `timezone` (для `sla_at`, по умолч. Europe/Moscow). Ставь первым в блоке `ACTIONS` после `DELAY` на срок ответа, ниже в том же блоке — эскалация (`add_tag`, `notify`). Оператор не писал в чат после `agent_chat` → `sla_breached`=`1`, `sla_level` +1, `sla_at` — ISO со смещением, действия блока выполняются; ответил / диалог у бота / тот же таймер повторно → `sla_breached`=`0`, остальные действия блока пропускаются. Новая передача — уровни с 1. Ветвиться дальше — `CONDITION` по `{{var.sla_level}}`.
180
+ - **запись на слоты** (календарь — `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}}`), освобождает слот.
181
+ - **контакт заявки**: `lead_link_contact` — `phone`/`email` (по умолч. `{{var.phone}}`/`{{var.email}}`) → склейка с единым контактом владельца по телефону E.164 или email, `{{var.contact_id}}`.
182
+ - **платный доступ / подписки** (бот должен быть админом группы/канала): `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`.
183
+ - **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`).
184
+ - **Integration Core: поля и переменные-результаты** (все — с `connectionId`; пустое/неверное обязательное поле → действие не вызывает сервис, блок уходит в `error`):
185
+ - `ydelivery_price` (Яндекс Доставка) — ВСЕ поля обязательны: `fromLat`, `fromLon`, `toLat`, `toLon`, `fromAddress`, `toAddress` (город, улица, дом — без квартиры/подъезда), `weightKg`, `lengthCm`, `widthCm`, `heightCm` (габариты упаковки в см, > 0, запятая допустима). Без адресов или габаритов — ошибка (заглушек нет). → `ydelivery_price`, `ydelivery_currency`, `ydelivery_eta` (ISO-дедлайн).
186
+ - `cdek_tariff` (СДЭК) — `fromCity`, `toCity`, `weightGrams`, `tariffCode` → `cdek_price` (только доставка), **`cdek_total_sum`** (итог с доп. услугами, 2 знака — его показывай клиенту), `cdek_days_min`, `cdek_days_max`; `cdek_order_status` — `cdekNumber` → `cdek_status`.
187
+ - `moysklad_find_product` — `search` → `moysklad_product_name`, `moysklad_product_price`, **`moysklad_product_id`**; **`moysklad_product_stock`** — остаток: `productId` (обяз., UUID товара/модификации, обычно `{{var.moysklad_product_id}}`), опц. `stockType` (`stock` по умолч., `freeStock`, `quantity`, `reserve`, `inTransit`) → `moysklad_stock` (товар без движений — `0`). Дальше — `CONDITION` по `{{var.moysklad_stock}}`.
188
+ - **`unisender_send_sms`** — одиночное SMS: `phone` (с кодом страны), `sender` (альфа-имя 3–11 символов, зарегистрированное в Unisender), `text` (до 1000 символов) — все обязательны → `unisender_sms_id`. `unisender_subscribe` → `unisender_person_id`.
189
+ - `rest_api_call` — в `body` (JSON) `{{var.x}}` сам ставит кавычки строкам; число или boolean в переменной уходит своим типом (`{"qty": {{var.qty}}}` → `{"qty": 5}`). Тип — по значению переменной, не по виду строки: введённый текст из цифр (телефон) остаётся строкой.
168
190
  - **бот/шаг**: `stop_bot`, `delete_step_message`, `cancel_payment_subscription`
169
191
  - **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`).
170
192
  Остальные четыре действия с таблицами тоже РАБОТАЮТ и тоже требуют `googleEmail` + `spreadsheetId`:
@@ -195,7 +217,7 @@ IG-боты не поддерживают команды (`/start`). Вход
195
217
  (`ClientId`|`Yclid`, по умолчанию `ClientId`), **`idValue`** (обязателен после рендера —
196
218
  пустой обрывает действие), опц. `target`, `price`, `currency` (по умолчанию `RUB`),
197
219
  `dateTime` (unix-секунды, по умолчанию «сейчас»).
198
- - **Единственные НЕ интегрированные действия**: `agent_chat` и `cancel_payment_subscription` —
220
+ - **Единственное НЕ интегрированное действие**: `cancel_payment_subscription` —
199
221
  принимается как no-op с пометкой `integration_not_connected`.
200
222
  - **модерация группы**: `group_unban`, `group_kick`, `group_approve`, `group_decline`
201
223
 
@@ -445,11 +467,11 @@ node validate.mjs graph.json --platform=INSTAGRAM
445
467
  | `ASK_QUESTION` | `valid`, `invalid` |
446
468
  | `SEND_MESSAGE` с `awaitReply:true` | `valid`, `invalid` (+ `btn_N` для кнопок) |
447
469
  | `CALL_WEBHOOK` | `ok`, `error` |
448
- | `ACTIONS` | `next`, плюс `error` — если внутри есть действие, которое может упасть (внешний запрос, CRM, Таблицы) |
470
+ | `ACTIONS` | `next`, плюс `error` — если внутри есть действие, которое может упасть (внешний запрос, CRM, Таблицы); `taken` — слот `booking_book` занят |
449
471
  | `SWITCH` | `case_<id>`, `default` |
450
472
  | `STOP_AND_ERROR` | выходов нет (терминатор) |
451
473
  | `SCHEDULE` | `scheduled`, `past` |
452
- | `DELAY` | `next` |
474
+ | `DELAY` | `next`; у `UNTIL` ещё `late` (момент уже прошёл) и `error` (шаблон не разобрался как дата) |
453
475
  | `AI_REPLY` (`mode` не задан/`simple`) | `next`, `error` |
454
476
  | `AI_REPLY` (`mode:"agent"`) | `answered`, `action_<id>` (по числу `actions[]`), `unknown`, `budget_exhausted`, `error` — **выхода `next` нет** |
455
477
 
@@ -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` (ISO-8601 UTC; рантайм читает только его, не `isoDate`+`time`).
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` = валидная `YYYY-MM-DD` (`SCHEDULE_BAD_DATE`); `time` = `HH:mm` (`SCHEDULE_BAD_TIME`).
48
- - `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`).
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", "sla_check",
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") { if (blank(c.isoTimestamp)) errors.push(`${who}: UNTIL требует isoTimestamp (ISO-8601 UTC). isoDate+time рантайм НЕ читает.`); }
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
- if (!/^\d{4}-\d{2}-\d{2}$/.test(String(c.isoDate || "")) || isNaN(Date.parse(c.isoDate))) errors.push(`SCHEDULE_BAD_DATE: ${who} — isoDate = YYYY-MM-DD.`);
301
- if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(String(c.time || ""))) errors.push(`SCHEDULE_BAD_TIME: ${who} — time = HH:mm.`);
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 (!ACTION_KINDS.has(a.kind)) { errors.push(`ACTION_UNKNOWN_KIND: ${who} — неизвестный kind «${a.kind}» (#${i + 1}).`); return; }
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.18.0";
30
+ const VERSION = "0.20.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,18 @@ 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
+ // Выгрузка (CSV/JSON): с savePath — в локальный файл (большие выгрузки не тащим в контекст), без — текстом.
618
+ function exportResult(data, savePath) {
619
+ const text = data == null ? "" : typeof data === "string" ? data : JSON.stringify(data, null, 2);
620
+ if (!savePath) return okResult(text || "Пусто: выгружать нечего.");
621
+ const abs = path.resolve(String(savePath).replace(/^~(?=$|[/\\])/, os.homedir()));
622
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
623
+ fs.writeFileSync(abs, text);
624
+ return okResult({ saved: abs, bytes: Buffer.byteLength(text) });
625
+ }
626
+
627
+ // =====================================================================// Подключения сервисов
628
+ // =====================================================================/** IA v2: каталог и подключения — раздел «Интеграции». Старые /bots/connect|integrations остаются в кабинете (Instagram — там). */
550
629
  const CONNECT_PAGE = `${BASE}/integrations`;
551
630
  const INTEGRATIONS_PAGE = CONNECT_PAGE;
552
631
  const LEGACY_CONNECT_PAGE = `${BASE}/bots/connect`;
@@ -601,7 +680,7 @@ const TOOLS = [
601
680
  { 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
681
  { 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
682
  { 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"] } },
683
+ { 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
684
  { name: "publish_graph", description: "Опубликовать граф. Вернёт publishedGraphId; при отказе проверок — ошибка HTTP 422 со всеми причинами построчно (code@nodeId: message). Сценарий-вебхук (источник WEBHOOK) этим инструментом не публикуется — HTTP 409, его публикуют в вебе.", inputSchema: { type: "object", properties: { graphId: { type: "string" } }, required: ["graphId"] } },
606
685
  { 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
686
  { name: "list_templates", description: "Список готовых шаблонов воронок (id, имя, описание). Можно стартовать граф из шаблона вместо сборки с нуля.", inputSchema: { type: "object", properties: {} } },
@@ -638,7 +717,7 @@ const TOOLS = [
638
717
  { 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
718
  { 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
719
  { 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, статус доставки. page (с 0), size (до 100). Read-only.", inputSchema: { type: "object", properties: { siteId: { type: "string" }, page: { type: "number" }, size: { type: "number" } }, required: ["siteId"] } },
720
+ { 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
721
  { 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
722
  { 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
723
  { 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,7 +737,7 @@ const TOOLS = [
658
737
  { 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
738
  { 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
739
  // ---- Боты ----
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"] } },
740
+ { 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
741
  { name: "bot_stop", description: "Остановить бота (POST /api/bots/{botId}/stop): снимает вебхук, бот перестаёт отвечать, сценарии и подписчики сохраняются. Запуск обратно — bot_resume.", inputSchema: { type: "object", properties: { botId: { type: "string" } }, required: ["botId"] } },
663
742
  { name: "bot_resume", description: "Запустить остановленного бота или бота, приостановленного лимитом тарифа (POST /api/bots/{botId}/resume). Если лимит ботов тарифа исчерпан — HTTP 402 со ссылкой на смену тарифа.", inputSchema: { type: "object", properties: { botId: { type: "string" } }, required: ["botId"] } },
664
743
  // ---- Подключения ----
@@ -681,7 +760,54 @@ const TOOLS = [
681
760
  { 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"] } },
682
761
  { 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" } } } },
683
762
  // ---- Сайты: библиотека шаблонов ----
763
+ // ---- Сайт-виджет, база знаний, подписчик ----
764
+ { 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"] } },
765
+ { name: "kb_list", description: "Базы знаний пользователя (GET /api/bots/kb): id, name, chunkCount. id — knowledgeBaseId для узла AI_REPLY mode:\"agent\". Read-only.", inputSchema: { type: "object", properties: {} } },
766
+ { 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"] } },
767
+ { 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"] } },
768
+ { 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"] } },
769
+ { 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"] } },
770
+ { 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"] } },
771
+ { 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"] } },
772
+ // ---- Паритет платформы: журнал вызовов, оператор, заявки, виджет, подписчики, бот ----
773
+ { 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.", inputSchema: { type: "object", properties: { connectionId: { type: "string" }, ok: { type: "boolean" }, limit: { type: "number" } } } },
774
+ { name: "dialog_handoff", description: "Передача диалога оператору (POST /api/bots/{botId}/users/{chatId}/handoff {active}): active:true — бот и ИИ молчат, входящие копятся в «Диалогах», отвечает человек (dialog_reply); active:false — «Вернуть боту». Текущее состояние — поле handoff в bot_user_get.", inputSchema: { type: "object", properties: { botId: { type: "string" }, chatId: { type: "string" }, active: { type: "boolean" } }, required: ["botId", "chatId", "active"] } },
775
+ { name: "site_lead_status", description: "Статус заявки сайта (PATCH /api/bots/pages/{siteId}/leads/{leadId} {status}): NEW → IN_PROGRESS → DONE | REJECTED. leadId — из site_leads. Недопустимый переход → ошибка 409.", inputSchema: { type: "object", properties: { siteId: { type: "string" }, leadId: { type: "string" }, status: { type: "string", enum: ["NEW", "IN_PROGRESS", "DONE", "REJECTED"] } }, required: ["siteId", "leadId", "status"] } },
776
+ { 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"] } },
777
+ { 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"] } },
778
+ { 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"] } },
779
+ { 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" } } } },
780
+ { 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.", inputSchema: { type: "object", properties: { graphId: { type: "string" }, page: { type: "number" }, size: { type: "number" } }, required: ["graphId"] } },
781
+ { 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"] } },
782
+ // ---- Запись на слоты (бронирование) ----
783
+ { 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: {} } },
784
+ { name: "booking_calendar_get", description: "Календарь записи по id (GET /api/bots/booking/calendars/{calendarId}). Read-only.", inputSchema: { type: "object", properties: { calendarId: { type: "string" } }, required: ["calendarId"] } },
785
+ { 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"] } },
786
+ { 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"] } },
787
+ { 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"] } },
788
+ { 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"] } },
789
+ { 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"] } },
790
+ { 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"] } },
791
+ { 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"] } },
684
792
  { 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" } } } },
793
+ // ---- Паритет платформы, часть 2: бот, база, агент, заявки, подписчики, каналы, ссылки, аналитика ----
794
+ { name: "bot_rename", description: "Переименовать бота (PATCH /api/bots/{botId} {name}): отображаемое имя в кабинете, до 250 символов. Пустое name сбрасывает имя на @username. Имя в самом Telegram/MAX не меняется.", inputSchema: { type: "object", properties: { botId: { type: "string" }, name: { type: "string" } }, required: ["botId", "name"] } },
795
+ { name: "bot_change_token", description: "Сменить токен бота (POST /api/bots/{botId}/token {token}): сервер проверяет токен и перезапускает приём сообщений; сценарии, подписчики и ссылки остаются. Нужен, если токен перевыпущен в @BotFather (/revoke) или MAX. Неверный или чужой токен → ошибка с причиной. Владелец или ADMIN.", inputSchema: { type: "object", properties: { botId: { type: "string" }, token: { type: "string", description: "Новый токен бота" } }, required: ["botId", "token"] } },
796
+ { name: "bot_channel_list", description: "Дополнительные Telegram-боты (мультиканальность) этого бота (GET /api/bots/{botId}/channels): id, username, name, active. Это НЕ каналы/группы для постинга — те в list_channels. Read-only.", inputSchema: { type: "object", properties: { botId: { type: "string" } }, required: ["botId"] } },
797
+ { name: "bot_channel_add", description: "Добавить к боту ещё одного Telegram-бота как канал (POST /api/bots/{botId}/channels {token, name?}): он отвечает теми же сценариями. Токен проверяется в Telegram; токен, уже подключённый где-то ещё, не принимается.", inputSchema: { type: "object", properties: { botId: { type: "string" }, token: { type: "string" }, name: { type: "string" } }, required: ["botId", "token"] } },
798
+ { name: "bot_channel_delete", description: "Отключить дополнительного Telegram-бота от бота (DELETE /api/bots/{botId}/channels/{channelId}). channelId — из bot_channel_list. Только по явной просьбе, с confirm:true.", inputSchema: { type: "object", properties: { botId: { type: "string" }, channelId: { type: "string" }, confirm: { type: "boolean" } }, required: ["botId", "channelId", "confirm"] } },
799
+ { name: "link_create", description: "Создать стартовую ссылку бота (POST /api/bots/{botId}/links {name, targetNodeId?}): code для t.me/<бот>?start=<code>, счётчик стартов. targetNodeId — узел сценария, на который ведёт диплинк; для одного узла повторный вызов вернёт существующую ссылку.", inputSchema: { type: "object", properties: { botId: { type: "string" }, name: { type: "string" }, targetNodeId: { type: "string" } }, required: ["botId"] } },
800
+ { name: "link_delete", description: "Удалить стартовую ссылку (DELETE /api/bots/links/{linkId}); id — из list_links. Разосланные ссылки перестанут считаться. Только по явной просьбе, с confirm:true.", inputSchema: { type: "object", properties: { linkId: { type: "string" }, confirm: { type: "boolean" } }, required: ["linkId", "confirm"] } },
801
+ { name: "utm_sources", description: "UTM-источники подписчиков бота (GET /api/bots/{botId}/utm-sources): по каким меткам utm_* люди входили в сценарии и сколько их. Read-only.", inputSchema: { type: "object", properties: { botId: { type: "string" } }, required: ["botId"] } },
802
+ { name: "ab_results", description: "Результаты A/B-теста (GET /api/bots/graphs/{graphId}/ab-results?branchNodeId&period): статистика по вариантам узла-развилки. branchNodeId — id узла A/B в графе; period — «24h», «7d», «30d» (по умолчанию 7d). Read-only.", inputSchema: { type: "object", properties: { graphId: { type: "string" }, branchNodeId: { type: "string" }, period: { type: "string" } }, required: ["graphId", "branchNodeId"] } },
803
+ { name: "kb_delete", description: "УДАЛИТЬ базу знаний целиком со всеми документами (DELETE /api/bots/kb/{kbId}). База ИИ-агента так не удаляется (ошибка KB_OWNED_BY_AGENT) — удаляй агента. Необратимо: только по явной просьбе, с confirm:true.", inputSchema: { type: "object", properties: { kbId: { type: "string" }, confirm: { type: "boolean" } }, required: ["kbId", "confirm"] } },
804
+ { name: "agent_unpublish", description: "Снять ИИ-агента с публикации (POST /api/bots/agents/{agentId}/unpublish): агент возвращается в черновик и перестаёт отвечать. В ответе — agent и сценарии, которые сейчас на нём работают (их стоит проверить).", inputSchema: { type: "object", properties: { agentId: { type: "string" } }, required: ["agentId"] } },
805
+ { name: "agent_delete", description: "УДАЛИТЬ ИИ-агента (DELETE /api/bots/agents/{agentId}). Пока агент или его база подключены к сценариям — ошибка AGENT_IN_USE со списком сценариев: сначала отключи его там. Необратимо: только по явной просьбе, с confirm:true.", inputSchema: { type: "object", properties: { agentId: { type: "string" }, confirm: { type: "boolean" } }, required: ["agentId", "confirm"] } },
806
+ { name: "site_leads_mark_read", description: "Отметить все заявки сайта прочитанными (POST /api/bots/pages/{siteId}/leads/read): обнуляет счётчик unread из site_leads. Статусы обработки не меняются (это site_lead_status).", inputSchema: { type: "object", properties: { siteId: { type: "string" } }, required: ["siteId"] } },
807
+ { name: "site_lead_delete", description: "УДАЛИТЬ заявку сайта (DELETE /api/bots/pages/{siteId}/leads/{leadId}); leadId — из site_leads. Необратимо: только по явной просьбе, с confirm:true.", inputSchema: { type: "object", properties: { siteId: { type: "string" }, leadId: { type: "string" }, confirm: { type: "boolean" } }, required: ["siteId", "leadId", "confirm"] } },
808
+ { name: "site_leads_export", description: "Все заявки сайта в CSV (GET /api/bots/pages/{siteId}/leads.csv). savePath — сохранить в локальный файл и вернуть путь (для больших выгрузок); без него CSV возвращается текстом. Read-only.", inputSchema: { type: "object", properties: { siteId: { type: "string" }, savePath: { type: "string" } }, required: ["siteId"] } },
809
+ { name: "bot_users_export", description: "Выгрузка всех подписчиков бота (GET /api/bots/{botId}/users/export?format=csv|json): chatId, имя, метки, переменные. savePath — сохранить в локальный файл и вернуть путь; без него данные возвращаются текстом. Read-only.", inputSchema: { type: "object", properties: { botId: { type: "string" }, format: { type: "string", enum: ["csv", "json"] }, savePath: { type: "string" } }, required: ["botId"] } },
810
+ { name: "bot_user_reset", description: "Сбросить сессию подписчика (DELETE /api/bots/{botId}/sessions/{chatId}): стираются его позиция в сценарии, метки и переменные, и он пропадает из list_bot_users; при следующем сообщении начнёт как новый. Для повторного прохождения воронки при тесте. Необратимо: только по явной просьбе, с confirm:true.", inputSchema: { type: "object", properties: { botId: { type: "string" }, chatId: { type: "string" }, confirm: { type: "boolean" } }, required: ["botId", "chatId", "confirm"] } },
685
811
  ];
686
812
 
687
813
  async function handleCall(params) {
@@ -749,6 +875,7 @@ async function handleCall(params) {
749
875
  case "update_graph": {
750
876
  const src = resolveGraphInput(a);
751
877
  if (!Array.isArray(src.nodes) || !Array.isArray(src.edges)) throw new Error("Нужны nodes[] и edges[] (через graphFile, graph или nodes/edges).");
878
+ assertGraphIds(src);
752
879
  const payload = { nodes: src.nodes, edges: src.edges, canvasMeta: src.canvasMeta ?? {} };
753
880
  if (a.name ?? src.name) payload.name = a.name ?? src.name;
754
881
  return okResult(await api(`/api/bots/graphs/${a.graphId}`, { method: "PUT", body: payload }));
@@ -756,6 +883,7 @@ async function handleCall(params) {
756
883
  case "edit_graph_live": {
757
884
  const src = resolveGraphInput(a);
758
885
  if (!Array.isArray(src.nodes) || !Array.isArray(src.edges)) throw new Error("Нужны nodes[] и edges[] (через graphFile, graph или nodes/edges).");
886
+ assertGraphIds(src);
759
887
  const steps = [];
760
888
  let backupGraphId = null;
761
889
  if (a.backup !== false) {
@@ -841,6 +969,7 @@ async function handleCall(params) {
841
969
  }
842
970
  case "import_funnel": {
843
971
  const src = a.graphFile ? extractGraph(readGraphFile(a.graphFile)) : extractGraph(a.graph);
972
+ assertGraphIds(src);
844
973
  const steps = [];
845
974
  const created = await api(`/api/bots/${a.botId}/graphs`, { method: "POST", body: { name: a.name || src.name || "Воронка" } });
846
975
  const graphId = created.id;
@@ -934,6 +1063,7 @@ async function handleCall(params) {
934
1063
  const qs = [];
935
1064
  if (a.page != null) qs.push(`page=${encodeURIComponent(a.page)}`);
936
1065
  if (a.size != null) qs.push(`size=${encodeURIComponent(a.size)}`);
1066
+ if (a.status) qs.push(`status=${enumArg(a.status, "status", LEAD_STATUSES)}`);
937
1067
  return okResult(await api(`/api/bots/pages/${a.siteId}/leads${qs.length ? `?${qs.join("&")}` : ""}`));
938
1068
  }
939
1069
  case "site_rollback": {
@@ -984,6 +1114,13 @@ async function handleCall(params) {
984
1114
  if (!a.question || !String(a.question).trim()) throw new Error("Передай question.");
985
1115
  return okResult(await api(`/api/bots/agents/${a.agentId}/test-chat`, { method: "POST", body: { question: a.question, history: a.history } }));
986
1116
  }
1117
+ // ---- Сайт-виджет, база знаний, подписчик ----
1118
+ case "web_widget_snippet": return okResult(await api(`/api/bots/web/${a.botId}/snippet`));
1119
+ case "kb_list": return okResult(await api("/api/bots/kb"));
1120
+ case "kb_create": {
1121
+ if (!a.name || !String(a.name).trim()) throw new Error("Передай name — название базы знаний.");
1122
+ return okResult(await api("/api/bots/kb", { method: "POST", body: { name: String(a.name).trim() } }));
1123
+ }
987
1124
  case "kb_docs": return okResult(await api(`/api/bots/kb/${a.kbId}/docs`));
988
1125
  case "kb_add_qa": {
989
1126
  if (!Array.isArray(a.pairs) || a.pairs.length === 0) throw new Error("Передай pairs — непустой массив [{question, answer}].");
@@ -1002,11 +1139,263 @@ async function handleCall(params) {
1002
1139
  const qs = a.days != null ? `?days=${encodeURIComponent(a.days)}` : "";
1003
1140
  return okResult(await api(`/api/bots/agents/${a.agentId}/unanswered${qs}`));
1004
1141
  }
1142
+ case "kb_delete_doc": {
1143
+ await api(`/api/bots/kb/${a.kbId}/docs/${a.docId}`, { method: "DELETE" });
1144
+ return okResult(`🗑️ Документ ${a.docId} удалён из базы ${a.kbId}.`);
1145
+ }
1146
+ case "bot_user_get": return okResult(await api(`/api/bots/${a.botId}/users/${chatIdArg(a.chatId)}`));
1147
+ case "bot_user_runs": return okResult(await api(`/api/bots/${a.botId}/users/${chatIdArg(a.chatId)}/runs${pageQs(a)}`));
1148
+ case "dialog_messages": return okResult(await api(`/api/bots/${a.botId}/users/${chatIdArg(a.chatId)}/messages${pageQs(a)}`));
1149
+ case "dialog_reply": {
1150
+ const text = String(a.text ?? "").trim();
1151
+ if (!text) throw new Error("Передай text — текст ответа.");
1152
+ return okResult(await api(`/api/bots/${a.botId}/users/${chatIdArg(a.chatId)}/messages`, { method: "POST", body: { text } }));
1153
+ }
1154
+ // ---- Запись на слоты ----
1155
+ case "booking_calendar_list": return okResult(await api("/api/bots/booking/calendars"));
1156
+ case "booking_calendar_get": return okResult(await api(`/api/bots/booking/calendars/${uuidArg(a.calendarId, "calendarId")}`));
1157
+ case "booking_calendar_create": {
1158
+ const body = calendarBody(a);
1159
+ if (!body.name) throw new Error("Передай name — название календаря.");
1160
+ return okResult(await api("/api/bots/booking/calendars", { method: "POST", body }));
1161
+ }
1162
+ case "booking_calendar_update": {
1163
+ const path_ = `/api/bots/booking/calendars/${uuidArg(a.calendarId, "calendarId")}`;
1164
+ const { calendarId, ...fields } = a;
1165
+ if (!Object.keys(fields).length) throw new Error("Передай хотя бы одно поле: name, zone, slotMinutes, hours, exceptions, botId.");
1166
+ const body = calendarBody(fields, await api(path_)); // PUT заменяет календарь целиком — накладываем на текущий
1167
+ return okResult(await api(path_, { method: "PUT", body }));
1168
+ }
1169
+ case "booking_calendar_delete": {
1170
+ const id = uuidArg(a.calendarId, "calendarId");
1171
+ if (a.confirm !== true) throw new Error("Удаление календаря необратимо. Спроси пользователя и повтори с confirm:true.");
1172
+ await api(`/api/bots/booking/calendars/${id}`, { method: "DELETE" });
1173
+ return okResult(`🗑️ Календарь ${id} удалён.`);
1174
+ }
1175
+ case "booking_slots": {
1176
+ const q = {};
1177
+ if (a.from) q.from = dateArg(a.from, "from");
1178
+ if (a.to) q.to = dateArg(a.to, "to");
1179
+ if (a.limit != null) {
1180
+ const n = Number(a.limit);
1181
+ if (!Number.isInteger(n) || n < 1 || n > 500) throw new Error("limit — целое от 1 до 500.");
1182
+ q.limit = n;
1183
+ }
1184
+ return okResult(await api(`/api/bots/booking/calendars/${uuidArg(a.calendarId, "calendarId")}/slots${qs(q)}`));
1185
+ }
1186
+ case "booking_list": {
1187
+ const q = a.from ? { from: dateArg(a.from, "from") } : {};
1188
+ return okResult(await api(`/api/bots/booking/calendars/${uuidArg(a.calendarId, "calendarId")}/bookings${qs(q)}`));
1189
+ }
1190
+ case "booking_create": {
1191
+ const id = uuidArg(a.calendarId, "calendarId");
1192
+ const slotAt = String(a.slotAt ?? "").trim();
1193
+ 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))) {
1194
+ throw new Error(`slotAt — ISO-8601 с зоной (at из booking_slots), например 2026-10-12T15:00:00+03:00; получено: ${slotAt || "пусто"}.`);
1195
+ }
1196
+ try {
1197
+ return okResult(await api(`/api/bots/booking/calendars/${id}/bookings`, { method: "POST", body: { slotAt, name: a.name, phone: a.phone } }));
1198
+ } catch (e) {
1199
+ if (e.status === 409) throw new Error(`Слот ${slotAt} уже занят — выбери другой из booking_slots.`);
1200
+ throw e;
1201
+ }
1202
+ }
1203
+ case "booking_cancel": {
1204
+ const id = uuidArg(a.calendarId, "calendarId");
1205
+ const bookingId = uuidArg(a.bookingId, "bookingId");
1206
+ await api(`/api/bots/booking/calendars/${id}/bookings/${bookingId}/cancel`, { method: "POST" });
1207
+ return okResult(`✅ Бронь ${bookingId} отменена, слот свободен.`);
1208
+ }
1209
+ // ---- Паритет платформы ----
1210
+ case "integration_calls": {
1211
+ const q = {};
1212
+ if (a.connectionId != null && a.connectionId !== "") q.connectionId = idArg(a.connectionId, "connectionId");
1213
+ if (a.ok != null) {
1214
+ if (typeof a.ok !== "boolean") throw new Error("ok — true или false.");
1215
+ q.ok = a.ok;
1216
+ }
1217
+ if (a.limit != null) {
1218
+ const n = Number(a.limit);
1219
+ if (!Number.isInteger(n) || n < 1 || n > 200) throw new Error("limit — целое от 1 до 200.");
1220
+ q.limit = n;
1221
+ }
1222
+ return okResult(await api(`/api/bots/integrations/calls${qs(q)}`));
1223
+ }
1224
+ case "dialog_handoff": {
1225
+ if (typeof a.active !== "boolean") throw new Error("active — true (передать оператору) или false (вернуть боту).");
1226
+ const res = await api(`/api/bots/${uuidArg(a.botId, "botId")}/users/${chatIdArg(a.chatId)}/handoff`, { method: "POST", body: { active: a.active } });
1227
+ return okResult(res ?? (a.active ? "✅ Диалог передан оператору: бот молчит." : "✅ Диалог возвращён боту."));
1228
+ }
1229
+ case "site_lead_status": {
1230
+ const status = enumArg(a.status, "status", LEAD_STATUSES);
1231
+ try {
1232
+ return okResult(await api(`/api/bots/pages/${uuidArg(a.siteId, "siteId")}/leads/${uuidArg(a.leadId, "leadId")}`, { method: "PATCH", body: { status } }));
1233
+ } catch (e) {
1234
+ if (e.status === 409) throw new Error(`Недопустимый переход статуса заявки в ${status}: NEW → IN_PROGRESS → DONE | REJECTED.`);
1235
+ throw e;
1236
+ }
1237
+ }
1238
+ case "integration_update": {
1239
+ const body = {};
1240
+ if (a.title != null) {
1241
+ const t = String(a.title).trim();
1242
+ if (!t) throw new Error("title не может быть пустым.");
1243
+ body.title = t;
1244
+ }
1245
+ if (a.creds != null) {
1246
+ if (typeof a.creds !== "object" || Array.isArray(a.creds) || !Object.keys(a.creds).length) throw new Error("creds — непустой объект {поле: значение}.");
1247
+ body.creds = Object.fromEntries(Object.entries(a.creds).map(([k, v]) => [k, String(v)]));
1248
+ }
1249
+ if (!Object.keys(body).length) throw new Error("Передай title и/или creds.");
1250
+ return okResult(await api(`/api/bots/integrations/${idArg(a.connectionId, "connectionId")}`, { method: "PUT", body }));
1251
+ }
1252
+ case "web_widget_settings": {
1253
+ const path_ = `/api/bots/web/${uuidArg(a.botId, "botId")}/settings`;
1254
+ if (a.settings == null) return okResult(await api(path_));
1255
+ if (typeof a.settings !== "object" || Array.isArray(a.settings)) throw new Error("settings — объект с полями настроек.");
1256
+ const cur = await api(path_);
1257
+ const merged = { ...(cur?.settings ?? {}), ...a.settings };
1258
+ return okResult(await api(path_, { method: "PUT", body: merged }));
1259
+ }
1260
+ case "bot_users_import": {
1261
+ if (!Array.isArray(a.rows) || !a.rows.length) throw new Error("Передай rows — непустой массив [{chatId, tags?, variables?}].");
1262
+ if (a.rows.length > 10000) throw new Error("rows — не больше 10 000 строк за вызов.");
1263
+ // chatId бывает > 2^53: в JSON кладём числом прямо из строки, без Number().
1264
+ const rows = a.rows.map((r, i) => {
1265
+ const cid = String(r?.chatId ?? "").trim();
1266
+ if (!/^[1-9]\d{0,18}$/.test(cid)) throw new Error(`rows[${i}].chatId — положительное число, получено: ${cid || "пусто"}.`);
1267
+ if (r.tags != null && !Array.isArray(r.tags)) throw new Error(`rows[${i}].tags — массив строк.`);
1268
+ if (r.variables != null && (typeof r.variables !== "object" || Array.isArray(r.variables))) throw new Error(`rows[${i}].variables — объект.`);
1269
+ return { chatId: `@@CID${cid}@@`, username: r.username, tags: r.tags, variables: r.variables };
1270
+ });
1271
+ const raw = JSON.stringify({ rows }).replace(/"@@CID(\d+)@@"/g, "$1");
1272
+ return okResult(await api(`/api/bots/${uuidArg(a.botId, "botId")}/users/import`, { method: "POST", rawBody: raw }));
1273
+ }
1274
+ case "bot_runs": {
1275
+ if (a.runId) return okResult(await api(`/api/bots/runs/${idArg(a.runId, "runId")}`));
1276
+ return okResult(await api(`/api/bots/${uuidArg(a.botId, "botId")}/runs${pageQs(a)}`));
1277
+ }
1278
+ case "scenario_runs": return okResult(await api(`/api/bots/graphs/${uuidArg(a.graphId, "graphId")}/runs${pageQs(a)}`));
1279
+ case "bot_delete": {
1280
+ const botId = uuidArg(a.botId, "botId");
1281
+ if (a.confirm !== true) throw new Error("Удаление бота необратимо. Спроси пользователя и повтори с confirm:true.");
1282
+ await api(`/api/bots/${botId}`, { method: "DELETE" });
1283
+ return okResult(`🗑️ Бот ${botId} удалён вместе со сценариями и подписчиками.`);
1284
+ }
1285
+ // ---- Паритет платформы, часть 2 ----
1286
+ case "bot_rename": {
1287
+ if (typeof a.name !== "string") throw new Error("name — строка (пустая сбрасывает имя на @username).");
1288
+ return okResult(await api(`/api/bots/${uuidArg(a.botId, "botId")}`, { method: "PATCH", body: { name: a.name } }));
1289
+ }
1290
+ case "bot_change_token": {
1291
+ const botId = uuidArg(a.botId, "botId");
1292
+ const token = String(a.token ?? "").trim();
1293
+ if (!token) throw new Error("Передай token — новый токен бота.");
1294
+ try {
1295
+ return okResult(await api(`/api/bots/${botId}/token`, { method: "POST", body: { token } }));
1296
+ } catch (e) {
1297
+ // 400 — строка-причина (неверный токен, уже подключён к другому боту); сам токен в ошибку не кладём.
1298
+ if (e.status === 400) throw new Error(`Токен не принят: ${typeof e.data === "string" && e.data ? e.data : "проверь токен"}.`);
1299
+ throw e;
1300
+ }
1301
+ }
1302
+ case "bot_channel_list": return okResult(await api(`/api/bots/${uuidArg(a.botId, "botId")}/channels`));
1303
+ case "bot_channel_add": {
1304
+ const botId = uuidArg(a.botId, "botId");
1305
+ const token = String(a.token ?? "").trim();
1306
+ if (!token) throw new Error("Передай token — токен дополнительного Telegram-бота.");
1307
+ try {
1308
+ return okResult(await api(`/api/bots/${botId}/channels`, { method: "POST", body: { token, name: a.name || undefined } }));
1309
+ } catch (e) {
1310
+ if (e.status === 400) throw new Error(`Канал не добавлен: ${typeof e.data === "string" && e.data ? e.data : "токен не принят"}.`);
1311
+ throw e;
1312
+ }
1313
+ }
1314
+ case "bot_channel_delete": {
1315
+ const botId = uuidArg(a.botId, "botId");
1316
+ const channelId = uuidArg(a.channelId, "channelId");
1317
+ if (a.confirm !== true) throw new Error("Отключение канала необратимо. Спроси пользователя и повтори с confirm:true.");
1318
+ await api(`/api/bots/${botId}/channels/${channelId}`, { method: "DELETE" });
1319
+ return okResult(`🗑️ Канал ${channelId} отключён от бота ${botId}.`);
1320
+ }
1321
+ case "link_create": {
1322
+ const body = { name: a.name || undefined };
1323
+ if (a.targetNodeId) body.targetNodeId = uuidArg(a.targetNodeId, "targetNodeId");
1324
+ return okResult(await api(`/api/bots/${uuidArg(a.botId, "botId")}/links`, { method: "POST", body }));
1325
+ }
1326
+ case "link_delete": {
1327
+ const linkId = uuidArg(a.linkId, "linkId");
1328
+ if (a.confirm !== true) throw new Error("Удаление ссылки необратимо. Спроси пользователя и повтори с confirm:true.");
1329
+ await api(`/api/bots/links/${linkId}`, { method: "DELETE" });
1330
+ return okResult(`🗑️ Ссылка ${linkId} удалена.`);
1331
+ }
1332
+ case "utm_sources": return okResult(await api(`/api/bots/${uuidArg(a.botId, "botId")}/utm-sources`));
1333
+ case "ab_results": {
1334
+ const q = { branchNodeId: uuidArg(a.branchNodeId, "branchNodeId") };
1335
+ if (a.period != null && a.period !== "") {
1336
+ const p = String(a.period).trim();
1337
+ if (!/^\d{1,4}[hd]$/.test(p)) throw new Error(`period — число с h или d (24h, 7d, 30d), получено: ${p}.`);
1338
+ q.period = p;
1339
+ }
1340
+ return okResult(await api(`/api/bots/graphs/${uuidArg(a.graphId, "graphId")}/ab-results${qs(q)}`));
1341
+ }
1342
+ case "kb_delete": {
1343
+ const kbId = idArg(a.kbId, "kbId");
1344
+ if (a.confirm !== true) throw new Error("Удаление базы знаний необратимо. Спроси пользователя и повтори с confirm:true.");
1345
+ try {
1346
+ await api(`/api/bots/kb/${kbId}`, { method: "DELETE" });
1347
+ } catch (e) {
1348
+ if (e.status === 409) throw new Error("Это база ИИ-агента (KB_OWNED_BY_AGENT): отдельно не удаляется — удали агента (agent_delete).");
1349
+ throw e;
1350
+ }
1351
+ return okResult(`🗑️ База знаний ${kbId} удалена со всеми документами.`);
1352
+ }
1353
+ case "agent_unpublish": return okResult(await api(`/api/bots/agents/${idArg(a.agentId, "agentId")}/unpublish`, { method: "POST" }));
1354
+ case "agent_delete": {
1355
+ const agentId = idArg(a.agentId, "agentId");
1356
+ if (a.confirm !== true) throw new Error("Удаление агента необратимо. Спроси пользователя и повтори с confirm:true.");
1357
+ try {
1358
+ await api(`/api/bots/agents/${agentId}`, { method: "DELETE" });
1359
+ } catch (e) {
1360
+ if (e.status === 409) {
1361
+ const refs = Array.isArray(e.data?.scenarios) ? e.data.scenarios.map((s) => s?.graphName || s?.graphId).filter(Boolean) : [];
1362
+ throw new Error(`Агент подключён к сценариям (AGENT_IN_USE)${refs.length ? `: ${refs.join(", ")}` : ""}. Сначала отключи его в них.`);
1363
+ }
1364
+ throw e;
1365
+ }
1366
+ return okResult(`🗑️ Агент ${agentId} удалён.`);
1367
+ }
1368
+ case "site_leads_mark_read": {
1369
+ const siteId = uuidArg(a.siteId, "siteId");
1370
+ await api(`/api/bots/pages/${siteId}/leads/read`, { method: "POST" });
1371
+ return okResult(`✅ Все заявки сайта ${siteId} отмечены прочитанными.`);
1372
+ }
1373
+ case "site_lead_delete": {
1374
+ const siteId = uuidArg(a.siteId, "siteId");
1375
+ const leadId = uuidArg(a.leadId, "leadId");
1376
+ if (a.confirm !== true) throw new Error("Удаление заявки необратимо. Спроси пользователя и повтори с confirm:true.");
1377
+ await api(`/api/bots/pages/${siteId}/leads/${leadId}`, { method: "DELETE" });
1378
+ return okResult(`🗑️ Заявка ${leadId} удалена.`);
1379
+ }
1380
+ case "site_leads_export": return exportResult(await api(`/api/bots/pages/${uuidArg(a.siteId, "siteId")}/leads.csv`), a.savePath);
1381
+ case "bot_users_export": {
1382
+ const format = String(a.format || "csv").trim().toLowerCase();
1383
+ if (format !== "csv" && format !== "json") throw new Error(`format: csv | json, получено: ${format}.`);
1384
+ return exportResult(await api(`/api/bots/${uuidArg(a.botId, "botId")}/users/export?format=${format}`), a.savePath);
1385
+ }
1386
+ case "bot_user_reset": {
1387
+ const botId = uuidArg(a.botId, "botId");
1388
+ const chatId = chatIdArg(a.chatId);
1389
+ if (a.confirm !== true) throw new Error("Сброс сессии стирает метки и переменные подписчика. Спроси пользователя и повтори с confirm:true.");
1390
+ await api(`/api/bots/${botId}/sessions/${chatId}`, { method: "DELETE" });
1391
+ return okResult(`♻️ Сессия подписчика ${chatId} сброшена: при следующем сообщении он начнёт как новый.`);
1392
+ }
1005
1393
  // ---- Боты ----
1006
1394
  case "create_bot": {
1007
1395
  const platform = String(a.platform || "").trim().toUpperCase();
1008
1396
  if (platform === "INSTAGRAM") return instagramAnswer();
1009
- if (platform !== "TELEGRAM" && platform !== "MAX") throw new Error("platform: TELEGRAM | MAX (Instagram подключается только в кабинете).");
1397
+ if (platform === "WEB") return okResult(await api("/api/bots/web", { method: "POST", body: { name: a.name || undefined } }));
1398
+ if (platform !== "TELEGRAM" && platform !== "MAX") throw new Error("platform: TELEGRAM | MAX | WEB (Instagram подключается только в кабинете).");
1010
1399
  const token = String(a.token || "").trim();
1011
1400
  if (!token) {
1012
1401
  return okResult(platform === "MAX"