@retensy/mcp 0.21.2 → 0.23.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 +36 -6
- package/package.json +2 -2
- package/skills/build-bot-funnel/SKILL.md +1 -0
- package/skills/build-bot-funnel/reference/schema.md +5 -1
- package/skills/build-bot-funnel/reference/validation.md +8 -0
- package/skills/build-bot-funnel/validate.mjs +2 -2
- package/src/index.mjs +506 -24
package/README.md
CHANGED
|
@@ -9,7 +9,11 @@ MCP-сервер (+ скилл для Claude Code) для **сборки и пу
|
|
|
9
9
|
|
|
10
10
|
- 🤖 **Сценарии ботов**: `list_bots`, `list_graphs`, `list_channels`, `get_graph`, `create_graph`, `update_graph`, `edit_graph_live`, `patch_graph`, `dry_run`, `publish_graph`, `import_funnel`, `list_templates`, `create_graph_from_template`, `clone_graph`, `copy_graph`, `rename_graph`, `set_active_graph`, `delete_graph`, `upload_file`, `list_files`, `delete_file`, `graph_analytics`, `list_bot_users`, `list_links` (+ `setup`/`set_token`).
|
|
11
11
|
- 🔌 **Боты и сервисы**: `create_bot` (Telegram/MAX по токену), `bot_stop`/`bot_resume`, `connect_integration`/`disconnect_integration` (amoCRM, Битрикс24, GetCourse, Я.Метрика, ЮKassa; Google Таблицы — ссылкой на вход Google; креды хранятся зашифрованными и не возвращаются), `integration_catalog`/`integration_status`/`integration_test` (каталог Integration Core и живая проверка подключения), `integration_ingress_url` (URL ингресса «Внешних событий» — Tilda, JivoSite, WooCommerce, amoCRM, Битрикс24, GetCourse, CloudPayments, Robokassa, Т-Банк, Prodamus — для `TRIGGER_WEBHOOK`), `channel_post` (пост в канал Telegram/MAX).
|
|
12
|
-
- 📣 **Рассылки**: `broadcast_preview` (размер аудитории), `broadcast_send` (сейчас или по расписанию, сразу по нескольким ботам; сообщения или запуск
|
|
12
|
+
- 📣 **Рассылки**: `broadcast_preview` (размер аудитории), `broadcast_send` (сейчас или по расписанию, сразу по нескольким ботам; сообщения или запуск сценария; полный фильтр аудитории `audienceFilter`, дедуп `dedup` и распределённый запуск `stagger` по ботам), `broadcast_list`/`broadcast_get`/`broadcast_cancel`, повторяющиеся (`broadcast_recurring`), черновики (`broadcast_drafts`, `broadcast_duplicate`), мульти-бот группы (`broadcast_group` — preview/list/get/pause/resume/cancel) + скилл `send-broadcast`.
|
|
13
|
+
- 👥 **Аудитория подписчиков**: `audience_search`/`audience_fields` — кросс-бот поиск и фильтр (теги, даты, статус, условия по переменным), `audience_tag_edit`/`audience_variable_edit` — массовое редактирование тегов/переменных, `audience_export` — потоковая выгрузка выборки в CSV/XLSX, `dialog_search` — поиск по «Диалогам» (включая текст сообщений).
|
|
14
|
+
- 📤 **Миграция подписчиков** из других конструкторов: `migration_presets`/`migration_preview`/`migration_commit` — generic CSV column-mapper для BotHelp/SaleBot/Senler/Leadteh/Smartsender/ManyChat (переносятся только подписчики — ни один из них не публикует документированный формат экспорта воронки).
|
|
15
|
+
- 📅 **Запись на слоты**: `booking_calendar_*` (буферы до/после брони, горизонт записи, типы встреч своей длительности, форма клиента, публичная страница `/book/<slug>`), `booking_confirm`/`booking_reject` — ручное подтверждение брони (`confirmationMode:"MANUAL"`).
|
|
16
|
+
- 📰 **Публикации и VK**: `publications_usage` — месячная квота (общая для Telegram/MAX/Instagram/VK), `vk_status`/`vk_connect`/`vk_disconnect`/`vk_post` — подключение VK-сообщества токеном и публикация на стену.
|
|
13
17
|
- 🔗 **Ссылка вместо отказа**: что нельзя сделать через API (вход через Google/Instagram, оплата тарифа, вход в аккаунт) — инструмент возвращает прямую ссылку и одну строку, что сделать.
|
|
14
18
|
- 📝 **Статьи блога** (тот же токен `zmcp_…`): `article_publish`, `article_update`, `article_list`, `article_get` — публикация статей в Markdown (как README на GitHub) в раздел **/articles**.
|
|
15
19
|
- 🧠 **ИИ-агенты и база знаний**: `agent_list`/`agent_get`/`agent_create`/`agent_update`/`agent_publish`/`agent_health`/`agent_test_chat`/`agent_unanswered`, `kb_docs`/`kb_add_qa`/`kb_add_text`/`kb_add_site`/`kb_reindex` — настройка ИИ-агента и его базы знаний (раздел «ИИ-агенты»).
|
|
@@ -134,7 +138,7 @@ MCP-сервер (+ скилл для Claude Code) для **сборки и пу
|
|
|
134
138
|
| `site_create(title, slug?, template?)` | новый сайт из блоков → `id`; `template` (`starter`/`blank`/`mini-landing`) — сразу черновик из шаблона; число сайтов ограничено тарифом — сверх лимита HTTP 402 с причиной и ссылкой на смену тарифа |
|
|
135
139
|
| `site_get(siteId, saveToFile?)` | модель сайта (`revision`, `draft`, `versions[]` публикаций) |
|
|
136
140
|
| `site_schema()` | JSON Schema модели и операций — читать перед правкой |
|
|
137
|
-
| `site_edit(siteId, ops[]?, revision?, init?)` | правка операциями (`init`: `starter`/`blank`/`mini-landing`; только `init` — черновик из шаблона), всё или ничего: страницы и папки, блоки, Zero-элементы, код блока (`get/set/add_block_code`), дизайны и их кадры, шаблоны (`add_template`), тема,
|
|
141
|
+
| `site_edit(siteId, ops[]?, revision?, init?)` | правка операциями (`init`: `starter`/`blank`/`mini-landing`; только `init` — черновик из шаблона), всё или ничего: страницы и папки, блоки, Zero-элементы, код блока (`get/set/add_block_code`), дизайны и их кадры, шаблоны (`add_template`), тема, попапы; пиксели аналитики (GA4/GTM/Meta/VK/Яндекс.Метрика) — тоже сюда, `set_settings {analytics: {...}}` (отдельного инструмента нет — те же `settings.analytics` модели сайта, валидируются форматом id на сервере) |
|
|
138
142
|
| `site_templates(category?, full?)` | библиотека шаблонов блоков для `add_template` |
|
|
139
143
|
| `site_publish(siteId)` | опубликовать черновик → `url` |
|
|
140
144
|
| `site_rollback(siteId, revision)` | вернуть прошлую публикацию |
|
|
@@ -145,11 +149,12 @@ MCP-сервер (+ скилл для Claude Code) для **сборки и пу
|
|
|
145
149
|
| `broadcast_list(botId?, group?, page?, size?)` | рассылки + счётчики разделов (черновики/запланированные/отправленные/повторы) |
|
|
146
150
|
| `broadcast_get(broadcastId)` | рассылка целиком: статус, счётчики, сообщения, причины ошибок |
|
|
147
151
|
| `broadcast_preview(botIds, tagsAll?, tagsNone?)` | сколько подписчиков получат рассылку |
|
|
148
|
-
| `broadcast_send(name, botIds, messages \| graphId, tagsAll
|
|
149
|
-
| `broadcast_cancel(broadcastId)` | отменить запланированную/идущую |
|
|
150
|
-
| `broadcast_recurring(action, …)` | повторяющиеся рассылки: list / create (DAILY·MONTHLY·YEARLY) / stop |
|
|
152
|
+
| `broadcast_send(name, botIds, messages \| graphId, tagsAll?/tagsNone? или audienceFilter?, scheduledAt?, draftId?, dedup?, stagger?)` | отправить сейчас / запланировать; по нескольким ботам; или запуск сценария; `dedup`/`stagger` → мульти-бот группа (`broadcast_group`) вместо прямых рассылок |
|
|
153
|
+
| `broadcast_cancel(broadcastId)` | отменить запланированную/идущую (для группы — `broadcast_group action:"cancel"`) |
|
|
154
|
+
| `broadcast_recurring(action, …)` | повторяющиеся рассылки: list / create (DAILY·MONTHLY·YEARLY, те же `audienceFilter`/`dedup`/`stagger`) / stop |
|
|
151
155
|
| `broadcast_drafts(action, …)` | черновики: list / get / create / update / delete |
|
|
152
156
|
| `broadcast_duplicate(broadcastId` или `draftId)` | копия рассылки или черновика — новый черновик «… (копия)» |
|
|
157
|
+
| `broadcast_group(action, …)` | мульти-бот группа с дедупом/распределённым запуском: `preview` (read-only оценка) / `list` / `get` / `pause` / `resume` / `cancel` (каскадом на уже запущенные боты) |
|
|
153
158
|
| `article_list()` | свои статьи блога (id, slug, title, просмотры) |
|
|
154
159
|
| `article_get(slug)` | статья по slug (Markdown content, excerpt, обложка) |
|
|
155
160
|
| `article_publish(content, title?, cover?, excerpt?)` | новая статья (Markdown; title из `# ...`, если не задан; обложка из `cover`-URL или 1-й картинки → OG; `excerpt` явно или авто) → id, slug, URL |
|
|
@@ -194,7 +199,32 @@ MCP-сервер (+ скилл для Claude Code) для **сборки и пу
|
|
|
194
199
|
| `booking_calendar_create(name, zone?, slotMinutes?, hours?, exceptions?, botId?)` | новый календарь: окна `{day:1–7, from:"HH:mm", to:"HH:mm"}`, исключения `{date, from?, to?}` |
|
|
195
200
|
| `booking_calendar_update(calendarId, …поля)` / `booking_calendar_delete(calendarId, confirm:true)` | правка (поля накладываются на текущие) / удаление |
|
|
196
201
|
| `booking_slots(calendarId, from?, to?, limit?)` | свободные слоты `[{at, label}]`, даты ГГГГ-ММ-ДД в зоне календаря |
|
|
197
|
-
| `booking_list(calendarId, from?)` / `booking_create(calendarId, slotAt, name?, phone?)` / `booking_cancel(calendarId, bookingId)` | брони: список / ручная запись (занятый слот — ошибка «занят») / отмена |
|
|
202
|
+
| `booking_list(calendarId, from?, pendingOnly?)` / `booking_create(calendarId, slotAt, name?, phone?, email?, comment?, customAnswers?, meetingTypeId?)` / `booking_cancel(calendarId, bookingId)` | брони: список (или только ждущие подтверждения) / ручная запись (занятый слот — ошибка «занят») / отмена |
|
|
203
|
+
| `booking_confirm(calendarId, bookingId)` / `booking_reject(calendarId, bookingId)` | подтвердить/отклонить бронь при `confirmationMode:"MANUAL"` (прочие поля календаря: `breaks`, `meetingTypes`, буферы, `horizonDays`, `intakeFields`, `notifyOwner`, `slug`+`enabled` — публичная ссылка `/book/<slug>` — в `booking_calendar_create`/`_update`) |
|
|
204
|
+
| `audience_fields(botId, search?, limit?)` | ключи переменных (с выведенным типом) и теги бота — для построителя фильтра |
|
|
205
|
+
| `audience_search(botId?, botIds?, filter?, q?, sort?, dir?, page?, size?)` | фильтр+сортировка подписчиков: один бот или кросс-бот (без `botId`); `filter` — полный `AudienceFilter` (теги any/all/none, диапазоны дат, `blocked`, `variableConditions`) |
|
|
206
|
+
| `audience_tag_edit(botId, chatIds, add?, remove?)` / `audience_variable_edit(botId, chatIds, set?, unset?)` | массово добавить/снять теги / установить/снять переменные у выбранных подписчиков |
|
|
207
|
+
| `audience_export(botId?, botIds?, filter?, q?, format, savePath?)` | потоковая выгрузка ТЕКУЩЕЙ отфильтрованной выборки, `csv`\|`xlsx` (xlsx требует `savePath` — бинарный формат), потолок 100000 строк |
|
|
208
|
+
| `dialog_search(q, limit?)` | кросс-бот поиск по «Диалогам» — имя/username/id/текст сообщения |
|
|
209
|
+
| `publications_usage(botId)` | месячная квота публикаций владельца (общая для Telegram/MAX/Instagram/VK) |
|
|
210
|
+
| `vk_status()` / `vk_connect(communityId, accessToken)` / `vk_disconnect(accountId)` | подключённые VK-сообщества / подключить токеном сообщества (проверяется живым вызовом VK) / отключить |
|
|
211
|
+
| `vk_post(accountId, text?, mediaUrl?)` | пост на стену VK (только текст/фото — без видео), списывает ту же квоту публикаций |
|
|
212
|
+
| `migration_presets()` | каталог источников миграции подписчиков (BotHelp/SaleBot/Senler/Leadteh/Smartsender/ManyChat/универсальный CSV) |
|
|
213
|
+
| `migration_preview(botId, path, platform, mapping?)` | пробный прогон CSV (ничего не пишет): без `mapping` — определение заголовков/подсказка сопоставления, с `mapping` — полный dry-run (новые/существующие, превышение лимита) |
|
|
214
|
+
| `migration_commit(botId, path, platform, mapping)` | реальный импорт подписчиков тем же `mapping`, что подтвердил `migration_preview` |
|
|
215
|
+
| `chain_list()` / `chain_get(chainId)` | цепочки публикаций («Публикации» → «Цепочки», автопостинг source → destinations[]) / одна цепочка целиком |
|
|
216
|
+
| `chain_create(chain)` / `chain_update(chainId, chain)` | создать / изменить цепочку целиком (`chain`: `name`, `source` TELEGRAM\|VK\|RSS, `destinations[]` TELEGRAM\|MAX\|VK\|INSTAGRAM, `steps[]` FILTER\|TEXT_TRANSFORM\|AI_ADAPT\|UTM\|DELAY, `approvalMode?`, `utmDefaults?`); HTTP 409 — прямой цикл с другой цепочкой |
|
|
217
|
+
| `chain_delete(chainId, confirm:true)` | удалить цепочку (теряется история заданий) |
|
|
218
|
+
| `chain_enable(chainId)` / `chain_pause(chainId)` | включить / поставить на паузу приём новых постов источника |
|
|
219
|
+
| `chain_runs(chainId, page?, size?)` | история заданий цепочки, новые сверху (бэкенд без постраничной выдачи — `page`/`size`, если переданы, режут список в MCP) |
|
|
220
|
+
| `chain_approve(jobId)` / `chain_reject(jobId)` | согласовать / отклонить задание, ждущее подтверждения (`approvalMode` AI_ONLY/ALL) |
|
|
221
|
+
| `chain_retry(jobId)` | повторить проваленное задание вручную, минуя бэкофф |
|
|
222
|
+
| `site_analytics(siteId, from?, to?)` | KPI+тренд сайта: просмотры, клики (только с `goalName`), заявки, топ-страницы/источники (по умолчанию последние 30 дней) |
|
|
223
|
+
| `site_analytics_events(siteId, from?, to?, page?, size?)` | сырые события сайта постранично (`PAGE_VIEW`\|`CLICK`\|`FORM_SUBMIT`, UTM) |
|
|
224
|
+
| `bot_subscriber_analytics(botId, from, to)` | новые подписчики/активность бота по дням (дашборд аналитики, диапазон до 92 дней) |
|
|
225
|
+
| `scenario_timeseries(graphId, from, to)` | дневной ряд прогонов сценария `{ok, failed, other}` (по узлам — `graph_analytics`) |
|
|
226
|
+
| `agent_tool_test(agentId, toolId, params?)` | проверить инструмент агента (`AMO_LOOKUP`/`SHEETS_LOOKUP`/`HTTP_API`) напрямую, без модели — бюджет ИИ не тратится |
|
|
227
|
+
| `site_lead_file_download(siteId, leadId, fileId)` | метаданные вложения заявки + ссылка на скачивание (сам файл через MCP не передаётся — HEAD-запрос, не GET) |
|
|
198
228
|
|
|
199
229
|
### Рассылки
|
|
200
230
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@retensy/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"description": "MCP server to build and publish Telegram and MAX bot funnels/automations, broadcasts, service integrations, block-based websites (incl. free-layout Zero blocks, designs, templates, custom domains, form leads) and Markdown blog articles in the Retensy Bots service. Zero dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"scripts": {
|
|
22
22
|
"start": "node src/index.mjs",
|
|
23
23
|
"check": "node --check src/index.mjs",
|
|
24
|
-
"test": "node scripts/smoke.mjs && node scripts/telemetry.test.mjs && node scripts/live-edit-422.test.mjs && node scripts/broadcast-connect.test.mjs && node scripts/integration-core.test.mjs && node scripts/site-init.test.mjs && node scripts/ai-agents.test.mjs && node scripts/battery-tools.test.mjs && node scripts/parity-tools.test.mjs && node scripts/parity-2.test.mjs && node scripts/validate-graph.test.mjs && node scripts/call-scenario.test.mjs",
|
|
24
|
+
"test": "node scripts/smoke.mjs && node scripts/telemetry.test.mjs && node scripts/live-edit-422.test.mjs && node scripts/broadcast-connect.test.mjs && node scripts/integration-core.test.mjs && node scripts/site-init.test.mjs && node scripts/ai-agents.test.mjs && node scripts/battery-tools.test.mjs && node scripts/parity-tools.test.mjs && node scripts/parity-2.test.mjs && node scripts/validate-graph.test.mjs && node scripts/call-scenario.test.mjs && node scripts/audience-publications.test.mjs && node scripts/chains-analytics.test.mjs",
|
|
25
25
|
"validate": "node skills/build-bot-funnel/validate.mjs"
|
|
26
26
|
},
|
|
27
27
|
"engines": {
|
|
@@ -41,6 +41,7 @@ description: Собрать воронку (сценарий) бота для с
|
|
|
41
41
|
- **`TRIGGER_PAYMENT` («оплата прошла»)** — если счёт выставлял бот, подписчик определяется САМ (счёт помнит его в metadata платежа): можно сразу слать сообщение и вешать метку, разбирать ответ кассы не нужно. Если оплата пришла мимо бота — прогон headless, получателя задавай через `config.target`. Ставь фильтр `minAmount`/`descriptionContains`, иначе сценарий сработает на любую оплату в кассе.
|
|
42
42
|
- **`TRIGGER_SCHEDULE` («По расписанию»)** — `{cron:"0 9 * * 1-5", timezone}`: headless-прогон без чата (отправка — только с `config.target`), для сводок, эскалаций, SLA. Журнал — `scenario_runs`.
|
|
43
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
|
+
- **Переиспользуемые подсценарии — `call_scenario`** (действие в `ACTIONS`, ставь ПОСЛЕДНИМ в блоке): `graphId` — сценарий ЭТОГО ЖЕ бота (`list_graphs`), `mode: "goto"` (по умолчанию, уйти без возврата) или `"call"` (вызвать и вернуться — после `END`/конца цели движок продолжает вызывающего с места после блока). Общая глубина `goto`+`call` в одной цепочке — не больше 5 переходов/вызовов подряд. Коды ошибок и рантайм-поведение — в validation.md.
|
|
44
45
|
- **Статус прогона `PARTIAL`** («завершён с ошибками») — сценарий дошёл до конца, но действие упало (шаг `ok:false`, `error` «КОД: …»). Проверяй `bot_user_runs`/`scenario_runs`, а ответы CRM/HTTP — `integration_calls`.
|
|
45
46
|
- **`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` на заявки не срабатывает.
|
|
46
47
|
|
|
@@ -179,7 +179,11 @@ IG-боты не поддерживают команды (`/start`). Вход
|
|
|
179
179
|
- **передача оператору**: `agent_chat` (без полей) — «позвать человека»: диалог встаёт на паузу (бот и ИИ молчат, входящие копятся в «Диалогах»), владельцу уходит уведомление; без бота уведомлений действие помечается `NOT_CONFIGURED`, пауза всё равно включается. Вернуть боту — кнопка в «Диалогах» или MCP `dialog_handoff {active:false}`. В dry-run и headless-прогонах пропускается.
|
|
180
180
|
- **срок ответа оператора (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}}`.
|
|
181
181
|
- **запись на слоты** (календарь — `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}}`), освобождает слот.
|
|
182
|
-
- **переход в другой сценарий**: `call_scenario` — `graphId` (сценарий ЭТОГО ЖЕ бота того же владельца, id из `list_graphs`; черновик тоже можно), опц. `nodeId` (стартовый блок цели; по умолч. триггер `/start` → первый триггер → первый блок без входящих рёбер — так стартует подсценарий без триггера), `variables: [{ "key":"plan", "value":"{{var.x}}" }]` (ключи `[a-z][a-z0-9_]{0,63}`, служебные `_*` запрещены — `ACTION_BAD_KEY`, в рантайме пропускаются, значения — шаблоны; сохраняются подписчику ДО перехода), `mode` —
|
|
182
|
+
- **переход в другой сценарий**: `call_scenario` — `graphId` (сценарий ЭТОГО ЖЕ бота того же владельца, id из `list_graphs`; черновик тоже можно), опц. `nodeId` (стартовый блок цели; по умолч. триггер `/start` → первый триггер → первый блок без входящих рёбер — так стартует подсценарий без триггера), `variables: [{ "key":"plan", "value":"{{var.x}}" }]` (ключи `[a-z][a-z0-9_]{0,63}`, служебные `_*` запрещены — `ACTION_BAD_KEY`, в рантайме пропускаются, значения — шаблоны; сохраняются подписчику ДО перехода), `mode` — `"goto"` (по умолч.) или `"call"`. Ставь ПОСЛЕДНИМ в блоке для обоих режимов (`CALL_SCENARIO_NOT_LAST`), не на себя (`CALL_SCENARIO_SELF`), только `goto`/`call` (иначе `CALL_SCENARIO_BAD_MODE`), без цели — `CALL_SCENARIO_NO_TARGET`, чужой/несуществующий/вебхук-сценарий — `CALL_SCENARIO_FORBIDDEN` при публикации.
|
|
183
|
+
- `mode:"goto"` (по умолчанию) — подписчик уходит в целевой сценарий отдельным прогоном, текущий прогон завершается `OK`; ответы на вопросы и кнопки цели доходят до неё, хотя активный сценарий бота другой (`session.currentGraphId`). Возврата нет — для «вернуться» нужно либо `mode:"call"`, либо свой `call_scenario` обратно в конце цели (сработает, только если между ними был ответ подписчика/задержка, иначе `CALL_SCENARIO_RECURSION`).
|
|
184
|
+
- `mode:"call"` — «вызвать и вернуться»: перед переходом движок кладёт возвратный кадр (вызывающий graphId + узел, на который вести после — следующий по ребру `next` этого же блока, либо ничего, если ребра нет) в `session.callStack` (персистентно, переживает рестарт сервера). Когда цель доходит до `END` или до конца графа (рёбер больше нет), движок снимает кадр и продолжает вызывающего С ЭТОГО МЕСТА новым прогоном — удобно для переиспользуемых подсценариев («спросить телефон», «проверить оплату») без копипасты.
|
|
185
|
+
- Общий счётчик глубины/цикла — ОБЩИЙ для `goto` и `call` в одной цепочке: не больше 5 переходов/вызовов подряд (`CALL_SCENARIO_DEPTH`), повтор графа, уже бывшего в цепочке — `CALL_SCENARIO_RECURSION` (для `call` цепочка — это персистентный `callStack`, не только текущий тик).
|
|
186
|
+
- В рантайме отказ = шаг `ok=false`, ошибка `CODE: call_scenario — …`, блок в `error` (нет ребра — `next`): `CALL_SCENARIO_RECURSION`, `CALL_SCENARIO_DEPTH`, `CALL_SCENARIO_NOT_FOUND`, `CALL_SCENARIO_FORBIDDEN`, `CALL_SCENARIO_NO_ENTRY`, `CALL_SCENARIO_NO_SUBSCRIBER` (headless-вебхук), `NOT_CONFIGURED` (бэкенд в режиме развязанных сценариев — переход недоступен). Сбой более раннего действия блока не прячется переходом: шаг `ok=false` с кодом того сбоя, прогон `PARTIAL`, переход/вызов при этом выполняется. Цель-черновик исполняется как есть — правки черновика сразу видят подписчики, уже перешедшие в него.
|
|
183
187
|
- **контакт заявки**: `lead_link_contact` — `phone`/`email` (по умолч. `{{var.phone}}`/`{{var.email}}`) → склейка с единым контактом владельца по телефону E.164 или email, `{{var.contact_id}}`.
|
|
184
188
|
- **платный доступ / подписки** (бот должен быть админом группы/канала): `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`.
|
|
185
189
|
- **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`).
|
|
@@ -47,6 +47,14 @@
|
|
|
47
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
48
|
- `TRIGGER_SCHEDULE`: `cron` ровно из 5 полей, не чаще раза в минуту (`SCHEDULE_TRIGGER_BAD_CRON`); `timezone` — известная зона (`SCHEDULE_TRIGGER_BAD_TIMEZONE`).
|
|
49
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` их оффлайн не знает и выдаёт предупреждение, а не ошибку.
|
|
50
|
+
- `ACTIONS` с действием `call_scenario` (переход/вызов другого сценария того же бота того же владельца), зеркало `GraphValidator.validateScenarioCalls`:
|
|
51
|
+
- должно быть **последним** действием в блоке, для обоих `mode` (`CALL_SCENARIO_NOT_LAST`);
|
|
52
|
+
- `graphId` обязателен — сценарий этого же бота, `list_graphs` (`CALL_SCENARIO_NO_TARGET`); не сам текущий граф (`CALL_SCENARIO_SELF`, сравнение без учёта регистра);
|
|
53
|
+
- `mode` — `"goto"` (по умолчанию) или `"call"`, другое значение → `CALL_SCENARIO_BAD_MODE`;
|
|
54
|
+
- `variables[].key` ∈ `[a-z][a-z0-9_]{0,63}`, служебные `_*` запрещены → `ACTION_BAD_KEY`;
|
|
55
|
+
- чужой/несуществующий/вебхук-сценарий-цель → `CALL_SCENARIO_FORBIDDEN` (проверяется при публикации, не офлайн-валидатором).
|
|
56
|
+
- В рантайме (оба `mode` делят один и тот же счётчик глубины/цикла): не больше **5** переходов/вызовов подряд в одной цепочке (`CALL_SCENARIO_DEPTH`); граф, уже бывший в этой цепочке — `CALL_SCENARIO_RECURSION` (для `mode:"call"` цепочка — персистентный `session.callStack`, не только текущий тик); прочие рантайм-коды: `CALL_SCENARIO_NOT_FOUND`, `CALL_SCENARIO_NO_ENTRY`, `CALL_SCENARIO_NO_SUBSCRIBER` (headless-вебхук), `NOT_CONFIGURED` (бэкенд в режиме развязанных сценариев).
|
|
57
|
+
- `mode:"goto"` — текущий прогон завершается `OK`, подписчик продолжает в цели отдельным прогоном, возврата нет. `mode:"call"` — перед переходом в `session.callStack` кладётся кадр (вызывающий граф + узел после блока по ребру `next`, если есть); когда цель доходит до `END`/конца графа, кадр снимается и вызывающий продолжается с этого места новым прогоном.
|
|
50
58
|
- `ACTIONS` с действием `issue_invoice` (счёт на кассу владельца):
|
|
51
59
|
- `connectionId` обязателен (`YK_NO_CONNECTION`) — подключение с `provider=YOOKASSA` из `list_integrations`;
|
|
52
60
|
- `amount` обязателен (`YK_NO_AMOUNT`), число > 0 или шаблон `{{var.x}}` (`YK_BAD_AMOUNT`);
|
|
@@ -403,8 +403,8 @@ for (const n of nodes) {
|
|
|
403
403
|
// Зеркало GraphValidator.validateScenarioCalls. Владение целью (сценарий этого же бота) проверит publish_graph.
|
|
404
404
|
if (i !== c.actions.length - 1)
|
|
405
405
|
errors.push(`CALL_SCENARIO_NOT_LAST: ${who} — переход в сценарий должен быть последним действием в блоке.`);
|
|
406
|
-
if (a.mode != null && String(a.mode)
|
|
407
|
-
errors.push(`CALL_SCENARIO_BAD_MODE: ${who} —
|
|
406
|
+
if (a.mode != null && !["goto", "call"].includes(String(a.mode)))
|
|
407
|
+
errors.push(`CALL_SCENARIO_BAD_MODE: ${who} — mode: «goto» (перейти) или «call» (вызвать и вернуться).`);
|
|
408
408
|
(Array.isArray(a.variables) ? a.variables : []).forEach((v, j) => {
|
|
409
409
|
const key = String(v?.key ?? "");
|
|
410
410
|
if (!VAR_RE.test(key) || key.startsWith("_"))
|
package/src/index.mjs
CHANGED
|
@@ -27,7 +27,7 @@ import os from "node:os";
|
|
|
27
27
|
import fs from "node:fs";
|
|
28
28
|
import path from "node:path";
|
|
29
29
|
|
|
30
|
-
const VERSION = "0.
|
|
30
|
+
const VERSION = "0.23.0";
|
|
31
31
|
/** С чего начать пустой сайт (init у /document/ops; на сайте с черновиком игнорируется). */
|
|
32
32
|
const SITE_INITS = ["starter", "blank", "mini-landing"];
|
|
33
33
|
/** Безвредная операция, когда нужен только init: бэкенд не принимает пустой ops[]. */
|
|
@@ -414,6 +414,78 @@ async function uploadSiteAsset(siteId, { filePath, url }) {
|
|
|
414
414
|
return { asset: `assets/${safe}`, sizeBytes: bytes.length };
|
|
415
415
|
}
|
|
416
416
|
|
|
417
|
+
// POST с бинарным (не JSON) ответом — audience_export format=xlsx (zip/binary; res.text() испортил бы байты UTF-8-декодом).
|
|
418
|
+
// Ошибки — тот же разбор, что у api(), код не шарим напрямую (api() не отдаёт сырые байты при res.ok).
|
|
419
|
+
async function apiBinaryPost(path_, body) {
|
|
420
|
+
if (!isAuthed()) throw new Error(NO_AUTH_HELP);
|
|
421
|
+
const res = await fetch(`${BASE}${path_}`, { method: "POST", headers: authHeaders(), body: JSON.stringify(body) });
|
|
422
|
+
if (!res.ok) {
|
|
423
|
+
const text = await res.text().catch(() => "");
|
|
424
|
+
let data = null; try { data = text ? JSON.parse(text) : null; } catch { data = text; }
|
|
425
|
+
if (res.status === 401) throw new Error(`Доступ отклонён (HTTP 401). Токен невалиден/отозван — создай новый на ${TOKENS_PAGE}.`);
|
|
426
|
+
if (res.status === 403) throw new Error(`Доступ отклонён (HTTP 403)${bodyReason(data) ? `: ${bodyReason(data)}` : ""}.`);
|
|
427
|
+
if (res.status === 402) throw paymentError(data);
|
|
428
|
+
throw httpError("POST", path_, res.status, data);
|
|
429
|
+
}
|
|
430
|
+
return Buffer.from(await res.arrayBuffer());
|
|
431
|
+
}
|
|
432
|
+
// HEAD, не GET — заявки могут нести бинарные вложения; нам нужны только заголовки (имя/тип/размер),
|
|
433
|
+
// без скачивания байтов файла в MCP-ответ (см. site_lead_file_download). Spring отвечает на @GetMapping
|
|
434
|
+
// заголовками без тела и для HEAD — метод контроллера при этом всё равно выполняется целиком (читает файл
|
|
435
|
+
// с диска/объектного хранилища), просто DispatcherServlet не отдаёт тело — доступ/404 проверяются так же, как у GET.
|
|
436
|
+
async function siteLeadFileHead(siteId, leadId, fileId) {
|
|
437
|
+
if (!isAuthed()) throw new Error(NO_AUTH_HELP);
|
|
438
|
+
const path_ = `/api/bots/pages/${siteId}/leads/${leadId}/files/${fileId}`;
|
|
439
|
+
const res = await fetch(`${BASE}${path_}`, { method: "HEAD", headers: authHeaders() });
|
|
440
|
+
if (!res.ok) {
|
|
441
|
+
if (res.status === 401) throw new Error(`Доступ отклонён (HTTP 401). Токен невалиден/отозван — создай новый на ${TOKENS_PAGE}.`);
|
|
442
|
+
if (res.status === 403) throw new Error(`Доступ отклонён (HTTP 403). Нет прав на заявки этого сайта.`);
|
|
443
|
+
if (res.status === 404) throw new Error("Файл не найден: не принадлежит этой заявке/сайту, либо заявка/файл удалены.");
|
|
444
|
+
throw httpError("HEAD", path_, res.status, null);
|
|
445
|
+
}
|
|
446
|
+
const disp = res.headers.get("content-disposition") || "";
|
|
447
|
+
const m = disp.match(/filename="([^"]*)"/);
|
|
448
|
+
const len = res.headers.get("content-length");
|
|
449
|
+
return {
|
|
450
|
+
fileId, siteId, leadId,
|
|
451
|
+
fileName: m ? m[1] : null,
|
|
452
|
+
contentType: res.headers.get("content-type") || null,
|
|
453
|
+
sizeBytes: len ? Number(len) : null,
|
|
454
|
+
downloadUrl: `${BASE}${path_}`,
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
function saveBinary(buf, savePath) {
|
|
458
|
+
const abs = path.resolve(String(savePath).replace(/^~(?=$|[/\\])/, os.homedir()));
|
|
459
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
460
|
+
fs.writeFileSync(abs, buf);
|
|
461
|
+
return okResult({ saved: abs, bytes: buf.length });
|
|
462
|
+
}
|
|
463
|
+
// Миграция подписчиков (multipart: file + platform + mapping?) — свой fetch, как у uploadMedia/uploadSiteAsset:
|
|
464
|
+
// api() ставит Content-Type:application/json, multipart сам задаёт boundary через FormData.
|
|
465
|
+
async function migrationMultipart(path_, { filePath, platform, mapping }) {
|
|
466
|
+
if (!isAuthed()) throw new Error(NO_AUTH_HELP);
|
|
467
|
+
if (!filePath) throw new Error("Передай path — локальный CSV-файл.");
|
|
468
|
+
const abs = path.resolve(String(filePath).replace(/^~(?=$|[/\\])/, os.homedir()));
|
|
469
|
+
let bytes;
|
|
470
|
+
try { bytes = fs.readFileSync(abs); } catch { throw new Error(`Файл не найден: ${abs}`); }
|
|
471
|
+
const headers = {};
|
|
472
|
+
const token = getToken(); const cookie = getCookie();
|
|
473
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
474
|
+
else if (cookie) headers.Cookie = cookie;
|
|
475
|
+
const fd = new FormData();
|
|
476
|
+
fd.append("file", new Blob([bytes], { type: "text/csv" }), path.basename(abs));
|
|
477
|
+
fd.append("platform", platform);
|
|
478
|
+
if (mapping != null) fd.append("mapping", typeof mapping === "string" ? mapping : JSON.stringify(mapping));
|
|
479
|
+
const res = await fetch(`${BASE}${path_}`, { method: "POST", headers, body: fd });
|
|
480
|
+
const text = await res.text();
|
|
481
|
+
let data = null; try { data = text ? JSON.parse(text) : null; } catch { data = text; }
|
|
482
|
+
if (!res.ok) {
|
|
483
|
+
if (res.status === 401 || res.status === 403) throw new Error(`Доступ отклонён (HTTP ${res.status}). Токен невалиден/отозван — создай новый на ${TOKENS_PAGE}.`);
|
|
484
|
+
throw httpError("POST", path_, res.status, data);
|
|
485
|
+
}
|
|
486
|
+
return data;
|
|
487
|
+
}
|
|
488
|
+
|
|
417
489
|
const okResult = (obj) => ({ content: [{ type: "text", text: typeof obj === "string" ? obj : JSON.stringify(obj, null, 2) }] });
|
|
418
490
|
const errResult = (e) => ({ isError: true, content: [{ type: "text", text: "❌ " + (e?.message || String(e)) }] });
|
|
419
491
|
|
|
@@ -486,6 +558,15 @@ function dateArg(v, label) {
|
|
|
486
558
|
return s;
|
|
487
559
|
}
|
|
488
560
|
const HHMM_RE = /^([01]\d|2[0-3]):[0-5]\d$|^24:00$/;
|
|
561
|
+
// hours и breaks (BookingCalendar) — одна и та же форма {day: 1–7, from: "HH:mm", to: "HH:mm"}.
|
|
562
|
+
function weekHoursArg(list, label) {
|
|
563
|
+
if (!Array.isArray(list)) throw new Error(`${label} — массив [{day: 1–7, from: "HH:mm", to: "HH:mm"}].`);
|
|
564
|
+
list.forEach((h, i) => {
|
|
565
|
+
if (!Number.isInteger(h?.day) || h.day < 1 || h.day > 7) throw new Error(`${label}[${i}].day — от 1 (пн) до 7 (вс).`);
|
|
566
|
+
if (!HHMM_RE.test(String(h.from)) || !HHMM_RE.test(String(h.to))) throw new Error(`${label}[${i}]: from/to — время HH:mm.`);
|
|
567
|
+
});
|
|
568
|
+
return list.map((h) => ({ day: h.day, from: h.from, to: h.to }));
|
|
569
|
+
}
|
|
489
570
|
// Поля календаря записи (BookingCalendar): переданные накладываются на base (для PUT — текущий календарь целиком).
|
|
490
571
|
function calendarBody(a, base = {}) {
|
|
491
572
|
const b = { ...base };
|
|
@@ -497,14 +578,7 @@ function calendarBody(a, base = {}) {
|
|
|
497
578
|
if (!Number.isInteger(n) || n < 5 || n > 1440) throw new Error("slotMinutes — целое от 5 до 1440.");
|
|
498
579
|
b.slotMinutes = n;
|
|
499
580
|
}
|
|
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
|
-
}
|
|
581
|
+
if (a.hours != null) b.hours = weekHoursArg(a.hours, "hours");
|
|
508
582
|
if (a.exceptions != null) {
|
|
509
583
|
if (!Array.isArray(a.exceptions)) throw new Error("exceptions — массив [{date: \"ГГГГ-ММ-ДД\", from?, to?}].");
|
|
510
584
|
b.exceptions = a.exceptions.map((e, i) => {
|
|
@@ -514,6 +588,60 @@ function calendarBody(a, base = {}) {
|
|
|
514
588
|
return closed ? { date } : { date, from: e.from, to: e.to };
|
|
515
589
|
});
|
|
516
590
|
}
|
|
591
|
+
// ---- Поля из wave/audit-1014 (booking follow-up): буферы/горизонт/типы встреч/форма клиента/подтверждение/публичная ссылка ----
|
|
592
|
+
if (a.description !== undefined) {
|
|
593
|
+
const d = a.description === null ? "" : String(a.description).trim();
|
|
594
|
+
if (d.length > 500) throw new Error("description — до 500 символов.");
|
|
595
|
+
b.description = d;
|
|
596
|
+
}
|
|
597
|
+
if (a.breaks != null) b.breaks = weekHoursArg(a.breaks, "breaks");
|
|
598
|
+
if (a.meetingTypes != null) {
|
|
599
|
+
if (!Array.isArray(a.meetingTypes)) throw new Error("meetingTypes — массив [{id?, name, durationMinutes: 5–1440}].");
|
|
600
|
+
b.meetingTypes = a.meetingTypes.map((m, i) => {
|
|
601
|
+
const name = String(m?.name ?? "").trim();
|
|
602
|
+
if (!name) throw new Error(`meetingTypes[${i}].name обязателен.`);
|
|
603
|
+
const dur = Number(m?.durationMinutes);
|
|
604
|
+
if (!Number.isInteger(dur) || dur < 5 || dur > 1440) throw new Error(`meetingTypes[${i}].durationMinutes — целое от 5 до 1440.`);
|
|
605
|
+
const id = m?.id ? uuidArg(m.id, `meetingTypes[${i}].id`) : crypto.randomUUID();
|
|
606
|
+
return { id, name, durationMinutes: dur };
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
const intRange = (v, label, lo, hi) => {
|
|
610
|
+
const n = Number(v);
|
|
611
|
+
if (!Number.isInteger(n) || n < lo || n > hi) throw new Error(`${label} — целое от ${lo} до ${hi}.`);
|
|
612
|
+
return n;
|
|
613
|
+
};
|
|
614
|
+
if (a.bufferBeforeMinutes != null) b.bufferBeforeMinutes = intRange(a.bufferBeforeMinutes, "bufferBeforeMinutes", 0, 240);
|
|
615
|
+
if (a.bufferAfterMinutes != null) b.bufferAfterMinutes = intRange(a.bufferAfterMinutes, "bufferAfterMinutes", 0, 240);
|
|
616
|
+
if (a.minNoticeMinutes != null) b.minNoticeMinutes = intRange(a.minNoticeMinutes, "minNoticeMinutes", 0, 10080);
|
|
617
|
+
if (a.horizonDays != null) b.horizonDays = intRange(a.horizonDays, "horizonDays", 1, 366);
|
|
618
|
+
if (a.intakeFields != null) {
|
|
619
|
+
if (!Array.isArray(a.intakeFields)) throw new Error("intakeFields — массив [{key, label, type: text|phone|email|textarea, required?}].");
|
|
620
|
+
const IF_TYPES = ["text", "phone", "email", "textarea"];
|
|
621
|
+
b.intakeFields = a.intakeFields.map((f, i) => {
|
|
622
|
+
const key = String(f?.key ?? "").trim();
|
|
623
|
+
if (!/^[A-Za-z0-9_]{1,64}$/.test(key)) throw new Error(`intakeFields[${i}].key — латиница/цифры/_, получено: ${key || "пусто"}.`);
|
|
624
|
+
const type = String(f?.type ?? "text").trim().toLowerCase();
|
|
625
|
+
if (!IF_TYPES.includes(type)) throw new Error(`intakeFields[${i}].type: ${IF_TYPES.join(" | ")}.`);
|
|
626
|
+
return { key, label: String(f?.label ?? key).trim(), type, required: f?.required === true };
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
if (a.confirmationMode != null) b.confirmationMode = enumArg(a.confirmationMode, "confirmationMode", ["AUTO", "MANUAL"]);
|
|
630
|
+
if (a.pendingHoldHours != null) b.pendingHoldHours = intRange(a.pendingHoldHours, "pendingHoldHours", 1, 720);
|
|
631
|
+
if (a.notifyOwner != null) {
|
|
632
|
+
if (typeof a.notifyOwner !== "boolean") throw new Error("notifyOwner — true или false.");
|
|
633
|
+
b.notifyOwner = a.notifyOwner;
|
|
634
|
+
}
|
|
635
|
+
if (a.slug !== undefined) {
|
|
636
|
+
const s = a.slug === null ? null : String(a.slug).trim().toLowerCase();
|
|
637
|
+
if (s && !/^[a-z0-9]([a-z0-9-]{1,58}[a-z0-9])?$/.test(s)) throw new Error("slug — 3–60 символов: латиница/цифры/дефис, не начинается и не кончается дефисом.");
|
|
638
|
+
b.slug = s || null;
|
|
639
|
+
}
|
|
640
|
+
if (a.enabled != null) {
|
|
641
|
+
if (typeof a.enabled !== "boolean") throw new Error("enabled — true или false.");
|
|
642
|
+
if (a.enabled && !(b.slug || base.slug)) throw new Error("enabled:true требует slug (задай его тем же вызовом или раньше).");
|
|
643
|
+
b.enabled = a.enabled;
|
|
644
|
+
}
|
|
517
645
|
return b;
|
|
518
646
|
}
|
|
519
647
|
// Компактная сводка графа (без объёмных text/cards/buttons) — чтобы не упираться в лимит токенов
|
|
@@ -593,6 +721,33 @@ function toInstant(v, field) {
|
|
|
593
721
|
return d.toISOString();
|
|
594
722
|
}
|
|
595
723
|
|
|
724
|
+
// feature broadcast-dispatch: дедуп + распределённый запуск по ботам (BroadcastGroup). Оба необязательны;
|
|
725
|
+
// бэкенд сам провалидирует форму целиком — здесь только базовые проверки до запроса.
|
|
726
|
+
function staggerArg(v) {
|
|
727
|
+
if (v == null) return undefined;
|
|
728
|
+
if (typeof v !== "object" || Array.isArray(v)) throw new Error("stagger — объект {startAt?, timezone?, intervalMinutes?, botOrder?, maxPerInterval?}.");
|
|
729
|
+
const out = {};
|
|
730
|
+
if (v.startAt != null) out.startAt = toInstant(v.startAt, "stagger.startAt");
|
|
731
|
+
if (v.timezone != null) out.timezone = String(v.timezone);
|
|
732
|
+
const interval = v.intervalMinutes == null ? 0 : Number(v.intervalMinutes);
|
|
733
|
+
if (!Number.isInteger(interval) || interval < 0) throw new Error("stagger.intervalMinutes — целое ≥ 0.");
|
|
734
|
+
out.intervalMinutes = interval;
|
|
735
|
+
if (v.botOrder != null) out.botOrder = enumArg(v.botOrder, "stagger.botOrder", ["AS_LISTED", "SIZE_ASC", "SIZE_DESC"]);
|
|
736
|
+
if (v.maxPerInterval != null) {
|
|
737
|
+
const n = Number(v.maxPerInterval);
|
|
738
|
+
if (!Number.isInteger(n) || n < 1) throw new Error("stagger.maxPerInterval — целое положительное.");
|
|
739
|
+
out.maxPerInterval = n;
|
|
740
|
+
}
|
|
741
|
+
return out;
|
|
742
|
+
}
|
|
743
|
+
// AudienceFilter (feature audience) передаётся как есть — полную форму и валидацию держит бэкенд;
|
|
744
|
+
// если задан, tagsAll/tagsNone того же вызова игнорируются (как в контракте AudienceFilter).
|
|
745
|
+
function audienceFilterArg(v) {
|
|
746
|
+
if (v == null) return undefined;
|
|
747
|
+
if (typeof v !== "object" || Array.isArray(v)) throw new Error("audienceFilter — объект (tagsAll/tagsAny/tagsNone, subscribedFrom/To, lastActivityFrom/To, blocked, variableConditions, conditionsCombinator).");
|
|
748
|
+
return v;
|
|
749
|
+
}
|
|
750
|
+
|
|
596
751
|
/** У Instagram-ботов рассылок нет (окно 24 ч Meta) — отказываем до запроса. */
|
|
597
752
|
async function assertBroadcastBots(botIds) {
|
|
598
753
|
if (!botIds.length) throw new Error("Передай botIds — id ботов (list_bots), по которым рассылать.");
|
|
@@ -665,6 +820,51 @@ function instagramAnswer() {
|
|
|
665
820
|
"Instagram-аккаунт нельзя подключить по API или токену: только через кнопку «Подключить Instagram» в кабинете (/bots/instagram) — она откроет вход через Instagram. Нужен Instagram-аккаунт типа Business или Creator (обычный личный профиль не подойдёт). Открой ссылку и пройди вход — после этого аккаунт появится в list_bots.");
|
|
666
821
|
}
|
|
667
822
|
|
|
823
|
+
// Доп. поля BookingCalendar (wave/audit-1014: буферы, горизонт, типы встреч, форма клиента,
|
|
824
|
+
// подтверждение, публичная ссылка) — общие между booking_calendar_create и booking_calendar_update.
|
|
825
|
+
const BOOKING_CAL_EXTRA_PROPS = {
|
|
826
|
+
description: { type: "string", description: "До 500 символов." },
|
|
827
|
+
breaks: { type: "array", description: "Повторяющиеся перерывы (обед и т.п.), та же форма, что hours — вычитаются из рабочих окон этого дня недели", items: { type: "object", properties: { day: { type: "number" }, from: { type: "string" }, to: { type: "string" } }, required: ["day", "from", "to"] } },
|
|
828
|
+
meetingTypes: { type: "array", description: "Типы встреч своей длительности; пусто — «простой» режим (slotMinutes)", items: { type: "object", properties: { id: { type: "string", description: "id существующего типа — изменить его; без id — создать новый" }, name: { type: "string" }, durationMinutes: { type: "number", description: "5–1440" } }, required: ["name", "durationMinutes"] } },
|
|
829
|
+
bufferBeforeMinutes: { type: "number", description: "Буфер до брони, 0–240 мин (по умолчанию 0)" },
|
|
830
|
+
bufferAfterMinutes: { type: "number", description: "Буфер после брони, 0–240 мин (по умолчанию 0)" },
|
|
831
|
+
minNoticeMinutes: { type: "number", description: "Не раньше чем через N минут от «сейчас», 0–10080 (по умолчанию 0)" },
|
|
832
|
+
horizonDays: { type: "number", description: "Горизонт записи в днях от сегодня, 1–366 (по умолчанию 62)" },
|
|
833
|
+
intakeFields: { type: "array", description: "Поля формы клиента (описательно — не ограничивает, что принимает booking_create/публичная форма)", items: { type: "object", properties: { key: { type: "string", description: "латиница/цифры/_" }, label: { type: "string" }, type: { type: "string", enum: ["text", "phone", "email", "textarea"] }, required: { type: "boolean" } }, required: ["key", "label", "type"] } },
|
|
834
|
+
confirmationMode: { type: "string", enum: ["AUTO", "MANUAL"], description: "AUTO — бронь сразу активна (по умолчанию); MANUAL — ждёт booking_confirm/booking_reject" },
|
|
835
|
+
pendingHoldHours: { type: "number", description: "MANUAL: сколько часов неподтверждённая бронь держит слот, 1–720 (по умолчанию 24)" },
|
|
836
|
+
notifyOwner: { type: "boolean", description: "Уведомлять владельца о новой брони (по умолчанию true)" },
|
|
837
|
+
slug: { type: ["string", "null"], description: "Публичная ссылка /book/<slug>: 3–60 символов, латиница/цифры/дефис, глобально уникален; null — убрать" },
|
|
838
|
+
enabled: { type: "boolean", description: "Включить публичную страницу записи (требует slug)" },
|
|
839
|
+
};
|
|
840
|
+
|
|
841
|
+
// AudienceFilter (feature audience) и StaggerConfig (feature broadcast-dispatch) — общие JSON Schema куски
|
|
842
|
+
// для нескольких инструментов (audience_search/audience_export/broadcast_send/broadcast_recurring/broadcast_group).
|
|
843
|
+
const AUDIENCE_FILTER_SCHEMA = { type: "object", description: "Полный фильтр аудитории: теги any/all/none, диапазоны дат, статус, условия по переменным (вместо/вместе с tagsAll/tagsNone верхнего уровня — если задан, они игнорируются).", properties: {
|
|
844
|
+
tagsAll: { type: "array", items: { type: "string" }, description: "Есть ВСЕ эти теги" },
|
|
845
|
+
tagsAny: { type: "array", items: { type: "string" }, description: "Есть ХОТЯ БЫ ОДИН из этих тегов" },
|
|
846
|
+
tagsNone: { type: "array", items: { type: "string" }, description: "Нет НИ ОДНОГО из этих тегов" },
|
|
847
|
+
subscribedFrom: { type: "string", description: "ISO — дата подписки от (старые сессии без даты не попадут под фильтр)" },
|
|
848
|
+
subscribedTo: { type: "string" },
|
|
849
|
+
lastActivityFrom: { type: "string", description: "ISO — последняя активность от" },
|
|
850
|
+
lastActivityTo: { type: "string" },
|
|
851
|
+
blocked: { type: ["boolean", "null"], description: "true — заблокировал бота, false — активен, null/не задано — любой" },
|
|
852
|
+
variableConditions: { type: "array", items: { type: "object", properties: {
|
|
853
|
+
key: { type: "string" }, type: { type: "string", enum: ["TEXT", "NUMBER", "DATE", "BOOL"] },
|
|
854
|
+
op: { type: "string", enum: ["EQ", "NEQ", "CONTAINS", "GT", "LT", "BETWEEN", "BEFORE", "AFTER", "IS", "EMPTY", "NOT_EMPTY"] },
|
|
855
|
+
valueText: { type: "string" }, valueNumber: { type: "number" }, valueNumber2: { type: "number" },
|
|
856
|
+
valueDate: { type: "string" }, valueDate2: { type: "string" }, valueBool: { type: "boolean" },
|
|
857
|
+
}, required: ["key", "op"] } },
|
|
858
|
+
conditionsCombinator: { type: "string", enum: ["AND", "OR"], description: "Как сочетать variableConditions между собой (остальное — всегда AND)" },
|
|
859
|
+
} };
|
|
860
|
+
const STAGGER_SCHEMA = { type: "object", description: "«Распределить отправку по ботам»: когда и с каким интервалом запускать каждого следующего. Не задано — все боты стартуют вместе (на первом тике планировщика, если включён dedup).", properties: {
|
|
861
|
+
startAt: { type: "string", description: "Момент запуска первого бота по dispatch-порядку, ISO 8601 (без пояса — московское)" },
|
|
862
|
+
timezone: { type: "string", description: "IANA-зона — только для отображения расписания" },
|
|
863
|
+
intervalMinutes: { type: "number", description: "Пауза между стартами соседних ботов/порций, минуты, ≥ 0 (0 — все сразу)" },
|
|
864
|
+
botOrder: { type: "string", enum: ["AS_LISTED", "SIZE_ASC", "SIZE_DESC"], description: "Порядок запуска ботов (по умолчанию AS_LISTED — как в botIds)" },
|
|
865
|
+
maxPerInterval: { type: "number", description: "Потолок получателей на один запуск бота — сверх него уходит следующей порцией (waveIndex+1) тем же интервалом позже; никто не теряется" },
|
|
866
|
+
} };
|
|
867
|
+
|
|
668
868
|
const TOOLS = [
|
|
669
869
|
{ name: "setup", description: "Показать статус авторизации и пошаговую инструкцию подключения. Вызывай первым, если пользователь не знает, что делать, или при ошибке доступа. Токен (zmcp_...) определяет АККАУНТ, а не один проект: доступ к каждому боту/сайту — это твоя текущая роль в нём (владелец или приглашённый соредактор), и она проверяется заново при каждом вызове.", inputSchema: { type: "object", properties: {} } },
|
|
670
870
|
{ name: "set_token", description: "Сохранить персональный токен (zmcp_...), который пользователь создал на /bots/mcp-tokens. Применяется сразу, без рестарта. Токен определяет аккаунт целиком, не один проект — список доступных проектов (свои + те, куда пригласили) виден в list_bots/site_list. Если владелец проекта уберёт тебя из соредакторов, доступ к этому проекту исчезнет сразу же, без повторной настройки токена.", inputSchema: { type: "object", properties: { token: { type: "string", description: "Секрет токена, начинается с zmcp_" } }, required: ["token"] } },
|
|
@@ -755,11 +955,14 @@ const TOOLS = [
|
|
|
755
955
|
"text — Telegram-HTML: <b> <i> <u> <s> <code> <pre> <blockquote> <tg-spoiler> <a href=\"https://…\">, перенос строки — \\n (не <br>); прочее экранируется. buttons — до 8 URL-кнопок [{text, url}] (callback-кнопок в рассылке нет). Можно строкой — это TEXT. Медиа — сначала upload_file, потом его url. " +
|
|
756
956
|
"По сценарию (graphId): POST /api/bots/{botId}/broadcasts — один botId, сценарий самого бота, entryNodeId? — с какого узла начать. " +
|
|
757
957
|
"draftId — отправить черновик (поля черновика, переданные аргументы их перекрывают); после отправки черновик удаляется. " +
|
|
758
|
-
"scheduledAt — ISO 8601 (без пояса — московское время); пусто — сразу. Немедленная резервирует квоту получателей тарифа, отложенная считает аудиторию в момент отправки. Рассылки только на платном тарифе — HTTP 402 со ссылкой. У Instagram-ботов рассылок нет.
|
|
759
|
-
|
|
760
|
-
|
|
958
|
+
"scheduledAt — ISO 8601 (без пояса — московское время); пусто — сразу. Немедленная резервирует квоту получателей тарифа, отложенная считает аудиторию в момент отправки. Рассылки только на платном тарифе — HTTP 402 со ссылкой. У Instagram-ботов рассылок нет. " +
|
|
959
|
+
"audienceFilter — полный фильтр аудитории вместо tagsAll/tagsNone (см. свойство ниже; если задан, tagsAll/tagsNone игнорируются). dedup:true — «отправить каждому человеку только один раз» среди нескольких botIds (ключ — platform+userId/chatId приватного чата; человек уходит ПЕРВОМУ боту по порядку botIds, где у него активная сессия). stagger — «распределить по ботам» (см. свойство ниже). Любое из dedup/stagger → ответ вместо {broadcastIds, totalAudience} отдаёт {group:{id, status, perBot[]...}, isGroup:true} — статус/прогресс дальше смотри broadcast_group {action:\"get\", groupId}. Без них — как раньше: {broadcastIds, totalAudience}.",
|
|
960
|
+
inputSchema: { type: "object", properties: { name: { type: "string" }, botIds: { type: "array", items: { type: "string" } }, botId: { type: "string" }, messages: { type: "array", items: {} }, tagsAll: { type: "array", items: { type: "string" } }, tagsNone: { type: "array", items: { type: "string" } }, audienceFilter: AUDIENCE_FILTER_SCHEMA, scheduledAt: { type: "string", description: "ISO 8601, напр. 2026-10-06T10:00:00+03:00; пусто — сразу" }, graphId: { type: "string", description: "рассылка запуском сценария бота вместо сообщений" }, entryNodeId: { type: "string" }, draftId: { type: "string" }, dedup: { type: "boolean", description: "Не отправлять дважды одному человеку среди нескольких botIds (мульти-бот)" }, stagger: STAGGER_SCHEMA } } },
|
|
961
|
+
{ name: "broadcast_cancel", description: "Отменить рассылку (POST /api/bots/broadcasts/{id}/cancel): запланированная не уйдёт, идущая остановится (статус CANCELLING → CANCELLED). Уже завершённую отменить нельзя — HTTP 409. Для группы (dedup/stagger) — broadcast_group {action:\"cancel\"}.", inputSchema: { type: "object", properties: { broadcastId: { type: "string" } }, required: ["broadcastId"] } },
|
|
962
|
+
{ name: "broadcast_recurring", description: "Повторяющиеся рассылки (/api/bots/broadcasts/recurring). action: list — правила (активные и остановленные); create {name, botIds[], messages[], tagsAll?/tagsNone? или audienceFilter?, recurrence: DAILY|MONTHLY|YEARLY, firstRunAt, dedup?, stagger?} — сообщения и фильтр как в broadcast_send; dedup/stagger заданы — КАЖДЫЙ прогон правила порождает свою отдельную группу (своя дедупликация с нуля, аудитория у повтора может быть другой); firstRunAt — первый запуск в будущем (ISO 8601, без пояса — московское), дальше в то же время суток/число (Москва); stop {ruleId} — остановить правило (уже отправленные прогоны не трогаются). Нужен платный тариф (402 со ссылкой).", inputSchema: { type: "object", properties: { action: { type: "string", enum: ["list", "create", "stop"] }, ruleId: { type: "string" }, name: { type: "string" }, botIds: { type: "array", items: { type: "string" } }, botId: { type: "string" }, messages: { type: "array", items: {} }, tagsAll: { type: "array", items: { type: "string" } }, tagsNone: { type: "array", items: { type: "string" } }, audienceFilter: AUDIENCE_FILTER_SCHEMA, recurrence: { type: "string", enum: ["DAILY", "MONTHLY", "YEARLY"] }, firstRunAt: { type: "string" }, dedup: { type: "boolean" }, stagger: STAGGER_SCHEMA }, required: ["action"] } },
|
|
761
963
|
{ 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"] } },
|
|
762
964
|
{ 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" } } } },
|
|
965
|
+
{ name: "broadcast_group", description: "Мульти-бот группа рассылки (dedup/stagger, создаётся broadcast_send/broadcast_recurring). action: preview {botIds[], tagsAll?/tagsNone? или audienceFilter?, dedup?, stagger?} — POST /api/bots/broadcasts/group-preview, read-only: {totalRecipients, uniqueRecipients, perBot:[{botId, username, rawCount, dedupedCount, waves, lastWaveStartAt, plannedStartAt}], limit, scheduleEndsAt}; list — твои группы (новые сверху); get {groupId} — GET .../group/{id}: полная BroadcastGroup {id, status: SCHEDULED|RUNNING|PAUSED|DISPATCHED|CANCELLED, perBot:[{botId, waveIndex, totalWaves, status: PENDING|CLAIMED|DISPATCHED|FAILED|SKIPPED, broadcastId, recipientsRaw, recipientsSkippedDedup, recipientsPlanned, error}]...} — перBot[].broadcastId (после DISPATCHED) смотри прогресс через broadcast_get; pause {groupId} — новые запуски не стартуют, уже идущие (DISPATCHED) боты продолжают слать; resume {groupId} — снимает паузу у просроченных PENDING; cancel {groupId} — PENDING становятся SKIPPED, уже DISPATCHED переводятся в CANCELLING как обычная рассылка. pause/resume/cancel — 409, если группа не в подходящем состоянии; 403 — чужая группа.", inputSchema: { type: "object", properties: { action: { type: "string", enum: ["preview", "list", "get", "pause", "resume", "cancel"] }, groupId: { type: "string" }, botIds: { type: "array", items: { type: "string" } }, tagsAll: { type: "array", items: { type: "string" } }, tagsNone: { type: "array", items: { type: "string" } }, audienceFilter: AUDIENCE_FILTER_SCHEMA, dedup: { type: "boolean" }, stagger: STAGGER_SCHEMA }, required: ["action"] } },
|
|
763
966
|
// ---- Сайты: библиотека шаблонов ----
|
|
764
967
|
// ---- Сайт-виджет, база знаний, подписчик ----
|
|
765
968
|
{ 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"] } },
|
|
@@ -783,13 +986,15 @@ const TOOLS = [
|
|
|
783
986
|
// ---- Запись на слоты (бронирование) ----
|
|
784
987
|
{ 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: {} } },
|
|
785
988
|
{ name: "booking_calendar_get", description: "Календарь записи по id (GET /api/bots/booking/calendars/{calendarId}). Read-only.", inputSchema: { type: "object", properties: { calendarId: { type: "string" } }, required: ["calendarId"] } },
|
|
786
|
-
{ 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"] } },
|
|
787
|
-
{ 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"] } },
|
|
989
|
+
{ name: "booking_calendar_create", description: "Создать календарь записи (POST /api/bots/booking/calendars): сетка слотов по рабочим часам в зоне календаря, в слоте — одна бронь. Без hours свободных слотов не будет. Доп. поля (буферы, горизонт, типы встреч, форма клиента, подтверждение владельцем, публичная ссылка /book/<slug>) — см. свойства ниже.", 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: "Необязательно: только для этого бота (иначе — все боты владельца)" }, ...BOOKING_CAL_EXTRA_PROPS }, required: ["name"] } },
|
|
990
|
+
{ name: "booking_calendar_update", description: "Изменить календарь (PUT /api/bots/booking/calendars/{calendarId}): переданные поля заменяют текущие (hours/exceptions/breaks/meetingTypes/intakeFields — целиком), остальные сохраняются. Существующие брони не трогаются.", 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: "Необязательно: только для этого бота (иначе — все боты владельца)" }, ...BOOKING_CAL_EXTRA_PROPS }, required: ["calendarId"] } },
|
|
788
991
|
{ 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"] } },
|
|
789
|
-
{ 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"] } },
|
|
790
|
-
{ 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"] } },
|
|
791
|
-
{ name: "booking_create", description: "Записать вручную на слот (POST /api/bots/booking/calendars/{calendarId}/bookings
|
|
992
|
+
{ 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). meetingTypeId — у календаря с типами встреч выбрать нужный (меняет шаг сетки); неизвестный id — как без него (slotMinutes). Read-only.", inputSchema: { type: "object", properties: { calendarId: { type: "string" }, from: { type: "string" }, to: { type: "string" }, limit: { type: "number" }, meetingTypeId: { type: "string", description: "id из meetingTypes календаря" } }, required: ["calendarId"] } },
|
|
993
|
+
{ name: "booking_list", description: "Брони календаря от даты from (ГГГГ-ММ-ДД, по умолчанию сегодня), включая отменённые, до 500 (GET /api/bots/booking/calendars/{calendarId}/bookings). pendingOnly:true — игнорирует from, только брони MANUAL-календаря, ждущие confirm/reject (без потолка 500). Read-only.", inputSchema: { type: "object", properties: { calendarId: { type: "string" }, from: { type: "string" }, pendingOnly: { type: "boolean" } }, required: ["calendarId"] } },
|
|
994
|
+
{ name: "booking_create", description: "Записать вручную на слот (POST /api/bots/booking/calendars/{calendarId}/bookings): slotAt — at из booking_slots (ISO-8601 с зоной). Слот занят → понятная ошибка «занят», слота нет в сетке → ошибка 400. email/comment/customAnswers — доп. поля формы клиента (intakeFields календаря — только подсказка для UI, не ограничивает, что можно передать здесь); meetingTypeId — у календаря с типами встреч. MANUAL confirmationMode — бронь создаётся с confirmed:false, слот уже занят; подтвердить/отклонить — booking_confirm/booking_reject.", inputSchema: { type: "object", properties: { calendarId: { type: "string" }, slotAt: { type: "string" }, name: { type: "string" }, phone: { type: "string" }, email: { type: "string" }, comment: { type: "string" }, customAnswers: { type: "object", additionalProperties: { type: "string" }, description: "Доп. поля формы: {key: значение}, ключи — intakeFields календаря" }, meetingTypeId: { type: "string" } }, required: ["calendarId", "slotAt"] } },
|
|
792
995
|
{ 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"] } },
|
|
996
|
+
{ name: "booking_confirm", description: "Подтвердить бронь, ждущую решения владельца (MANUAL confirmationMode) — POST .../bookings/{bookingId}/confirm. Клиенту уходит уведомление, если бронь пришла из чата бота. HTTP 404 — бронь не этого календаря, 409 — уже не ждёт решения (подтверждена/отменена/истёк pendingHoldHours).", inputSchema: { type: "object", properties: { calendarId: { type: "string" }, bookingId: { type: "string" } }, required: ["calendarId", "bookingId"] } },
|
|
997
|
+
{ name: "booking_reject", description: "Отклонить бронь, ждущую решения владельца (MANUAL confirmationMode) — POST .../bookings/{bookingId}/reject: освобождает слот, клиенту уходит «отклонено владельцем» (в отличие от booking_cancel — то работает на любой активной брони и ничего не присылает клиенту). HTTP 404/409 — как у booking_confirm.", inputSchema: { type: "object", properties: { calendarId: { type: "string" }, bookingId: { type: "string" } }, required: ["calendarId", "bookingId"] } },
|
|
793
998
|
{ 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" } } } },
|
|
794
999
|
// ---- Паритет платформы, часть 2: бот, база, агент, заявки, подписчики, каналы, ссылки, аналитика ----
|
|
795
1000
|
{ 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"] } },
|
|
@@ -809,6 +1014,44 @@ const TOOLS = [
|
|
|
809
1014
|
{ 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"] } },
|
|
810
1015
|
{ 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"] } },
|
|
811
1016
|
{ 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"] } },
|
|
1017
|
+
// ---- Аудитория (кросс-бот поиск/фильтр подписчиков, теги/переменные массово, экспорт) ----
|
|
1018
|
+
{ name: "audience_fields", description: "Поля для построителя фильтра (GET /api/bots/{botId}/audience/fields): variables — [{key, type: TEXT|NUMBER|DATE|BOOL}] (тип выведен по значению в сэмпле из последних 500 сессий — не точная схема), tags — список тегов. search — подстрока (и по ключам переменных, и по тегам), limit — до 200 каждого списка. Read-only.", inputSchema: { type: "object", properties: { botId: { type: "string" }, search: { type: "string" }, limit: { type: "number" } }, required: ["botId"] } },
|
|
1019
|
+
{ name: "audience_search", description: "Фильтр+сортировка+поиск подписчиков (замена постраничного перебора list_bot_users под сложный фильтр). botId — один бот (POST /api/bots/{botId}/users/search); без botId — кросс-бот по всем доступным (POST /api/bots/subscribers/search, опц. botIds[] — подмножество, чужие id тихо отбрасываются). filter — AudienceFilter (см. свойство ниже), q — текстовый поиск (имя/username/id), sort ∈ updatedAt|activity|lastActivity|chatId|username|tgUsername|createdAt|subscribedAt|status|blocked|botId|bot, dir — asc|desc, page (с 0), size (до 200, по умолч. 25). Кросс-бот ответ — те же поля + botId/botName. Read-only.", inputSchema: { type: "object", properties: { botId: { type: "string" }, botIds: { type: "array", items: { type: "string" } }, filter: AUDIENCE_FILTER_SCHEMA, q: { type: "string" }, sort: { type: "string" }, dir: { type: "string", enum: ["asc", "desc"] }, page: { type: "number" }, size: { type: "number" } } } },
|
|
1020
|
+
{ name: "audience_tag_edit", description: "Массово добавить/снять теги у выбранных подписчиков бота (POST /api/bots/{botId}/users/tags): chatIds[] (до 10000, из audience_search/list_bot_users), add?/remove? — списки тегов; remove применяется ДО add (тег в обоих спискaх — останется). Возвращает {matched}.", inputSchema: { type: "object", properties: { botId: { type: "string" }, chatIds: { type: "array", items: { type: "string" } }, add: { type: "array", items: { type: "string" } }, remove: { type: "array", items: { type: "string" } } }, required: ["botId", "chatIds"] } },
|
|
1021
|
+
{ name: "audience_variable_edit", description: "Массово установить/снять custom-переменные у выбранных подписчиков (POST /api/bots/{botId}/users/variables): chatIds[] (до 1000 — читается документ целиком, потолок ниже, чем у audience_tag_edit), set? — {ключ: значение}, unset? — список ключей. Служебные ключи и ключи с «.»/ведущим «$» отклоняются (400). Каждая запись проходит тот же потолок переменных подписчика (одно значение ≤65536, сумма ≤262144 символов) — отклонённые видны в ответе {matched, rejected[] (до 50 по имени), rejectedCount}, остальные записи применяются как обычно.", inputSchema: { type: "object", properties: { botId: { type: "string" }, chatIds: { type: "array", items: { type: "string" } }, set: { type: "object" }, unset: { type: "array", items: { type: "string" } } }, required: ["botId", "chatIds"] } },
|
|
1022
|
+
{ name: "audience_export", description: "Потоковая выгрузка ТЕКУЩЕЙ отфильтрованной выборки подписчиков (не только одной страницы) — CSV или XLSX. botId — один бот (POST /api/bots/{botId}/users/export); без botId — кросс-бот (POST /api/bots/subscribers/export, опц. botIds[], доп. колонка «бот»). filter — AudienceFilter, q — текстовый поиск. Потолок 100000 строк — больше → HTTP 422 (сузь фильтр). format csv — текст (savePath необязателен, иначе вернётся строкой); format xlsx — ВСЕГДА требует savePath (бинарный формат, в чат не возвращается).", inputSchema: { type: "object", properties: { botId: { type: "string" }, botIds: { type: "array", items: { type: "string" } }, filter: AUDIENCE_FILTER_SCHEMA, q: { type: "string" }, format: { type: "string", enum: ["csv", "xlsx"] }, savePath: { type: "string", description: "Обязательно для xlsx; для csv — необязательно (иначе выгрузка возвращается текстом)" } } } },
|
|
1023
|
+
{ name: "dialog_search", description: "Кросс-бот поиск по «Диалогам» (GET /api/bots/dialogs/search): q ищет по имени/username/id подписчика, названию бота И тексту сообщений (а не только карточке подписчика, в отличие от list_bot_users). limit — 1–60 (по умолчанию 40), объединённые результаты, свежие сверху, не постраничный — для большой выгрузки используй audience_search/audience_export. Только боты, доступные тебе. Read-only.", inputSchema: { type: "object", properties: { q: { type: "string" }, limit: { type: "number" } }, required: ["q"] } },
|
|
1024
|
+
// ---- Публикации: квота и VK ----
|
|
1025
|
+
{ name: "publications_usage", description: "Месячная квота публикаций (GET /api/bots/{botId}/publications/usage) — ОДНА на владельца бота, общая для всех его ботов и платформ (Telegram/MAX channel_post, Instagram, VK — любой публикующий путь списывает её же). {used, limit, unlimited, planCode}; limit=-1/unlimited:true — без лимита. Read-only.", inputSchema: { type: "object", properties: { botId: { type: "string" } }, required: ["botId"] } },
|
|
1026
|
+
{ name: "vk_status", description: "Подключённые VK-сообщества/страницы аккаунта (GET /api/bots/vk/status) — подключение АККАУНТА, не бота: видно во всех ботах владельца. {connected, accounts: [{id, platform:\"vk\", label, enabled, isDefault, hasCredentials}]}. Read-only.", inputSchema: { type: "object", properties: {} } },
|
|
1027
|
+
{ name: "vk_connect", description: "Подключить VK-сообщество вручную токеном (POST /api/bots/vk/connect-token): communityId, accessToken — токен сообщества с правами wall (и photos, если нужны фото-посты). Токен проверяется живым вызовом VK API перед сохранением — невалидный/без прав → HTTP 422 с причиной от VK, токен не сохраняется и не эхуется. Альтернатива — вход через VK ID (OAuth) в кабинете «Публикации»: по API недоступен, это браузерный флоу.", inputSchema: { type: "object", properties: { communityId: { type: "string" }, accessToken: { type: "string" } }, required: ["communityId", "accessToken"] } },
|
|
1028
|
+
{ name: "vk_disconnect", description: "Отключить VK-сообщество/страницу (DELETE /api/bots/vk/{accountId}); accountId — из vk_status. 404 — не найден/чужой.", inputSchema: { type: "object", properties: { accountId: { type: "string" } }, required: ["accountId"] } },
|
|
1029
|
+
{ name: "vk_post", description: "Опубликовать пост на стену подключённого VK-сообщества/страницы (POST /api/bots/vk/post): accountId (vk_status), text и/или mediaUrl (ТОЛЬКО фото — JPEG/PNG из upload_file; видео/другие типы отклоняются HTTP 422). Списывает publications_usage (резерв до отправки, откат при провале VK). HTTP 402 — квота исчерпана; 422 — VK отклонил (текст ошибки VK как есть).", inputSchema: { type: "object", properties: { accountId: { type: "string" }, text: { type: "string" }, mediaUrl: { type: "string", description: "url из upload_file — только изображение" } }, required: ["accountId"] } },
|
|
1030
|
+
// ---- Миграция подписчиков из других конструкторов ----
|
|
1031
|
+
{ name: "migration_presets", description: "Каталог поддерживаемых источников миграции (GET /api/bots/migration/presets): BOTHELP|SALEBOT|SENLER|LEADTEH|SMARTSENDER|MANYCHAT|GENERIC — у каждого note (как экспортировать оттуда) и guessed*Headers (подсказки для автосопоставления колонок). Переносятся ТОЛЬКО подписчики (funnelImportSupported всегда false — ни один из этих билдеров не публикует документированный формат экспорта сценария; свои воронки переносятся через import_funnel/create_graph с retensy-bot-graph JSON, это другой формат). Read-only.", inputSchema: { type: "object", properties: {} } },
|
|
1032
|
+
{ name: "migration_preview", description: "Пробный прогон CSV-импорта подписчиков, НИЧЕГО не пишет в базу (POST /api/bots/{botId}/migration/subscribers/preview, multipart). path — локальный CSV (≤10 МБ, ≤20000 строк данных), platform — id из migration_presets. Без mapping — режим определения: headers, sampleRaw (до 5 строк), suggestedMapping (угадан по подсказкам пресета — только подсказка, не контракт). С mapping ({userIdColumn (обязательно — заголовок колонки с числовым chatId), usernameColumn?, tagsColumn?, tagsDelimiter? (по умолч. \"|;,\"), variableColumns?}) — полный dry-run: validRows, invalidRows (причины, до 20), newSubscribers/existingSubscribers (дедуп по chatId против текущих подписчиков бота), overCapacity (сверх лимита тарифа), remainingCapacity (-1 — безлимит), sample (до 10 строк как они импортируются), totalRowsInFile/truncated.", inputSchema: { type: "object", properties: { botId: { type: "string" }, path: { type: "string", description: "Локальный путь к CSV-файлу" }, platform: { type: "string", enum: ["BOTHELP", "SALEBOT", "SENLER", "LEADTEH", "SMARTSENDER", "MANYCHAT", "GENERIC"] }, mapping: { type: "object", description: "MigrationColumnMapping — передай ПОСЛЕ того, как проверил suggestedMapping/detect-режимом", properties: { userIdColumn: { type: "string" }, usernameColumn: { type: "string" }, tagsColumn: { type: "string" }, tagsDelimiter: { type: "string" }, variableColumns: { type: "array", items: { type: "string" } } } } }, required: ["botId", "path", "platform"] } },
|
|
1033
|
+
{ name: "migration_commit", description: "Реальный импорт подписчиков из CSV тем же mapping, что подтвердил migration_preview (POST /api/bots/{botId}/migration/subscribers/commit, multipart) — тот же путь дедупа по chatId/лимита тарифа/«никогда не перезаписывать существующее», что у bot_users_import. Возвращает {totalRowsInFile, processedRows, truncated, result: ImportUsersResponse}.", inputSchema: { type: "object", properties: { botId: { type: "string" }, path: { type: "string" }, platform: { type: "string", enum: ["BOTHELP", "SALEBOT", "SENLER", "LEADTEH", "SMARTSENDER", "MANYCHAT", "GENERIC"] }, mapping: { type: "object", properties: { userIdColumn: { type: "string" }, usernameColumn: { type: "string" }, tagsColumn: { type: "string" }, tagsDelimiter: { type: "string" }, variableColumns: { type: "array", items: { type: "string" } } }, required: ["userIdColumn"] } }, required: ["botId", "path", "platform", "mapping"] } },
|
|
1034
|
+
// ---- Публикации: цепочки (автопостинг «Цепочки публикаций») ----
|
|
1035
|
+
{ name: "chain_list", description: "Цепочки публикаций пользователя (GET /api/bots/chains): автоматический репост source → destinations[] с шагами обработки (steps[]) и UTM. Цепочка привязана к владельцу, не к конкретному боту. Read-only.", inputSchema: { type: "object", properties: {} } },
|
|
1036
|
+
{ name: "chain_get", description: "Цепочка по id (GET /api/bots/chains/{id}). 404 — не своя/не найдена. Read-only.", inputSchema: { type: "object", properties: { chainId: { type: "string" } }, required: ["chainId"] } },
|
|
1037
|
+
{ name: "chain_create", description: "Создать цепочку публикаций (POST /api/bots/chains): chain — {name, enabled?, approvalMode?: NONE|AI_ONLY|ALL, source: {type: TELEGRAM|VK|RSS, botId?, chatId?, accountId?, rssUrl?}, destinations: [{type: TELEGRAM|MAX|VK|INSTAGRAM, botId?, chatId?, accountId?, utmOverride?}], steps?: [{type: FILTER|TEXT_TRANSFORM|AI_ADAPT|UTM|DELAY, ...поля шага}], utmDefaults?}. TELEGRAM-источник — свой канал бота (bot_channel_list/list_channels), VK-источник/назначение — подключённый аккаунт (vk_status). INSTAGRAM в destinations принимается моделью, но публикация требует медиа и подключённый Instagram-аккаунт — без них задание уходит в SKIPPED с причиной, не FAILED. HTTP 400 — не хватает полей или источник/назначение не найдены/не ваши; HTTP 409 — прямой цикл с другой вашей цепочкой (A→B при уже существующей B→A).", inputSchema: { type: "object", properties: { chain: { type: "object", description: "PublishChain draft — см. описание инструмента" } }, required: ["chain"] } },
|
|
1038
|
+
{ name: "chain_update", description: "Изменить цепочку целиком (PUT /api/bots/chains/{id}): chain — PublishChain draft (та же форма, что в chain_create), заменяет текущую конфигурацию полностью — сначала прочитай chain_get и поменяй нужное. Те же 400/409, что у chain_create.", inputSchema: { type: "object", properties: { chainId: { type: "string" }, chain: { type: "object", description: "PublishChain draft целиком" } }, required: ["chainId", "chain"] } },
|
|
1039
|
+
{ name: "chain_delete", description: "УДАЛИТЬ цепочку публикаций (DELETE /api/bots/chains/{id}). История заданий (chain_runs) теряется вместе с ней. Необратимо: только по явной просьбе, с confirm:true.", inputSchema: { type: "object", properties: { chainId: { type: "string" }, confirm: { type: "boolean" } }, required: ["chainId", "confirm"] } },
|
|
1040
|
+
{ name: "chain_enable", description: "Включить цепочку (POST /api/bots/chains/{id}/enable) — начинает принимать новые посты источника (TELEGRAM — сразу по событию, VK/RSS — со следующего опроса, раз в 3 мин).", inputSchema: { type: "object", properties: { chainId: { type: "string" } }, required: ["chainId"] } },
|
|
1041
|
+
{ name: "chain_pause", description: "Поставить цепочку на паузу (POST /api/bots/chains/{id}/pause) — перестаёт принимать новые посты источника (VK/RSS — опрос останавливается, TELEGRAM — события игнорируются); история и настройки сохраняются.", inputSchema: { type: "object", properties: { chainId: { type: "string" } }, required: ["chainId"] } },
|
|
1042
|
+
{ name: "chain_runs", description: "История заданий цепочки (GET /api/bots/chains/{id}/runs), новые сверху: [{id, status: PENDING|RUNNING|DONE|FAILED|AWAITING_APPROVAL|SKIPPED, originId, destinationId, lastError, reviewedBy, reviewedAt…}]. Бэкенд отдаёт всю историю без постраничной выдачи на сервере — page (с 0)/size (по умолчанию 20) режут список уже здесь, в MCP, чтобы не упираться в лимит токенов на цепочке с долгой историей; без них — список целиком. Read-only.", inputSchema: { type: "object", properties: { chainId: { type: "string" }, page: { type: "number" }, size: { type: "number" } }, required: ["chainId"] } },
|
|
1043
|
+
{ name: "chain_approve", description: "Согласовать задание, ждущее подтверждения (approvalMode AI_ONLY/ALL) — POST /api/bots/chains/jobs/{jobId}/approve: AWAITING_APPROVAL → PENDING, подхватится ближайшим тиком исполнения (раз в 15 сек). jobId — из chain_runs. HTTP 400 — задание сейчас не ждёт согласования.", inputSchema: { type: "object", properties: { jobId: { type: "string" } }, required: ["jobId"] } },
|
|
1044
|
+
{ name: "chain_reject", description: "Отклонить задание, ждущее подтверждения — POST /api/bots/chains/jobs/{jobId}/reject: AWAITING_APPROVAL → SKIPPED, публикации не будет. jobId — из chain_runs. HTTP 400 — задание сейчас не ждёт согласования.", inputSchema: { type: "object", properties: { jobId: { type: "string" } }, required: ["jobId"] } },
|
|
1045
|
+
{ name: "chain_retry", description: "Повторить проваленное задание вручную, минуя бэкофф (POST /api/bots/chains/jobs/{jobId}/retry): сбрасывает lastError, задание подхватится ближайшим тиком исполнения. jobId — из chain_runs (status FAILED).", inputSchema: { type: "object", properties: { jobId: { type: "string" } }, required: ["jobId"] } },
|
|
1046
|
+
// ---- Аналитика (дашборд «/bots/analytics»: сайт/бот/сценарий/рассылка) ----
|
|
1047
|
+
{ name: "site_analytics", description: "KPI+тренд сайта (GET /api/bots/pages/{siteId}/analytics?from&to): {pageViews, clicks, formSubmits, uniqueVisitors, leads, topPages, topReferrers, utmSources, trend:[{day,pageViews,clicks,formSubmits,uniqueVisitors}]}. from/to — ГГГГ-ММ-ДД, по умолчанию последние 30 дней UTC. uniqueVisitors — сумма дневных уников (не дедуп по всему периоду, это приближение). clicks считает только элементы с goalName, заданным в редакторе сайта (data-rs-track), а не любой клик. Read-only.", inputSchema: { type: "object", properties: { siteId: { type: "string" }, from: { type: "string", description: "ГГГГ-ММ-ДД" }, to: { type: "string", description: "ГГГГ-ММ-ДД" } }, required: ["siteId"] } },
|
|
1048
|
+
{ name: "site_analytics_events", description: "Сырые события аналитики сайта постранично (GET /api/bots/pages/{siteId}/analytics/events?from&to&page&size): {items:[{id, type: PAGE_VIEW|CLICK|FORM_SUBMIT, objectId, visitorId, page, referrer, utmSource/Medium/Campaign/Content/Term, props, createdAt}], total}. from/to — ГГГГ-ММ-ДД (по умолчанию последние 30 дней), page (с 0), size (по умолчанию 50). Read-only.", inputSchema: { type: "object", properties: { siteId: { type: "string" }, from: { type: "string" }, to: { type: "string" }, page: { type: "number" }, size: { type: "number" } }, required: ["siteId"] } },
|
|
1049
|
+
{ name: "bot_subscriber_analytics", description: "Новые подписчики/активность бота по дням (GET /api/bots/{botId}/analytics/subscribers?from&to): {newSubscribers, active, total, legacyExcluded, trend:[{day,newSubscribers,active}]}. from/to обязательны — ГГГГ-ММ-ДД, диапазон обрезается сервером до 92 дней. legacyExcluded — сессии без даты создания (заведены до этой фичи): не входят в newSubscribers/trend, но учтены в total. active — по updatedAt в диапазоне (одна сессия может посчитаться в несколько дней). Read-only.", inputSchema: { type: "object", properties: { botId: { type: "string" }, from: { type: "string", description: "ГГГГ-ММ-ДД" }, to: { type: "string", description: "ГГГГ-ММ-ДД" } }, required: ["botId", "from", "to"] } },
|
|
1050
|
+
{ name: "scenario_timeseries", description: "Дневной ряд прогонов сценария (GET /api/bots/graphs/{graphId}/analytics/timeseries?from&to): [{day, ok, failed, other}] — по одной строке на каждый день диапазона (пустые дни — нулями), other = PARKED/NO_MATCH/PARTIAL (не отброшены). from/to обязательны — ГГГГ-ММ-ДД, диапазон обрезается до 92 дней. Разбивку по узлам сценария (не по дням) даёт graph_analytics — это разные срезы одних данных. Read-only.", inputSchema: { type: "object", properties: { graphId: { type: "string" }, from: { type: "string", description: "ГГГГ-ММ-ДД" }, to: { type: "string", description: "ГГГГ-ММ-ДД" } }, required: ["graphId", "from", "to"] } },
|
|
1051
|
+
// ---- ИИ-агенты: инструменты (вызов внешних систем моделью) ----
|
|
1052
|
+
{ name: "agent_tool_test", description: "Проверить инструмент агента напрямую, БЕЗ модели (POST /api/bots/agents/{agentId}/tools/{toolId}/test): params — аргументы вызова (AMO_LOOKUP: query ИЛИ leadId; SHEETS_LOOKUP: value; HTTP_API: имена из httpParams этого инструмента). Проверяет только конфиг инструмента (подключение amoCRM/таблицу Sheets/URL) — модель не вызывается, бюджет ИИ не тратится. {ok:true, result} при успехе; ошибка конфига/внешнего сервиса — HTTP 400 {ok:false, error} с причиной на русском (никогда не 500). toolId — id инструмента из agent_get → tools[].id (настраивается через agent_update {patch:{tools:[...]}})." , inputSchema: { type: "object", properties: { agentId: { type: "string" }, toolId: { type: "string" }, params: { type: "object", description: "Аргументы вызова инструмента (см. описание)" } }, required: ["agentId", "toolId"] } },
|
|
1053
|
+
// ---- Заявки сайта: файлы вложений ----
|
|
1054
|
+
{ name: "site_lead_file_download", description: "Метаданные и ссылка на скачивание файла из заявки сайта (GET /api/bots/pages/{siteId}/leads/{leadId}/files/{fileId}, владелец/ADMIN сайта). Байты файла НЕ передаются через MCP (может быть большим/бинарным) — инструмент делает HEAD-запрос, чтобы подтвердить, что файл принадлежит этой заявке (404 иначе), и вернуть {fileId, fileName, contentType, sizeBytes, downloadUrl}. downloadUrl отдаёт файл только с тем же токеном, что у этого MCP (заголовок Authorization: Bearer zmcp_...), — скачай curl'ом с этим заголовком или открой заявку в кабинете (сайт → «Заявки»). Список файлов заявки с теми же полями уже есть в site_leads (поле files[] каждой заявки) — этот инструмент нужен, только когда нужна именно прямая ссылка на скачивание одного файла.", inputSchema: { type: "object", properties: { siteId: { type: "string" }, leadId: { type: "string" }, fileId: { type: "string" } }, required: ["siteId", "leadId", "fileId"] } },
|
|
812
1055
|
];
|
|
813
1056
|
|
|
814
1057
|
async function handleCall(params) {
|
|
@@ -1182,10 +1425,16 @@ async function handleCall(params) {
|
|
|
1182
1425
|
if (!Number.isInteger(n) || n < 1 || n > 500) throw new Error("limit — целое от 1 до 500.");
|
|
1183
1426
|
q.limit = n;
|
|
1184
1427
|
}
|
|
1428
|
+
if (a.meetingTypeId) q.meetingTypeId = uuidArg(a.meetingTypeId, "meetingTypeId");
|
|
1185
1429
|
return okResult(await api(`/api/bots/booking/calendars/${uuidArg(a.calendarId, "calendarId")}/slots${qs(q)}`));
|
|
1186
1430
|
}
|
|
1187
1431
|
case "booking_list": {
|
|
1188
|
-
const q =
|
|
1432
|
+
const q = {};
|
|
1433
|
+
if (a.from) q.from = dateArg(a.from, "from");
|
|
1434
|
+
if (a.pendingOnly != null) {
|
|
1435
|
+
if (typeof a.pendingOnly !== "boolean") throw new Error("pendingOnly — true или false.");
|
|
1436
|
+
q.pendingOnly = a.pendingOnly;
|
|
1437
|
+
}
|
|
1189
1438
|
return okResult(await api(`/api/bots/booking/calendars/${uuidArg(a.calendarId, "calendarId")}/bookings${qs(q)}`));
|
|
1190
1439
|
}
|
|
1191
1440
|
case "booking_create": {
|
|
@@ -1194,8 +1443,13 @@ async function handleCall(params) {
|
|
|
1194
1443
|
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))) {
|
|
1195
1444
|
throw new Error(`slotAt — ISO-8601 с зоной (at из booking_slots), например 2026-10-12T15:00:00+03:00; получено: ${slotAt || "пусто"}.`);
|
|
1196
1445
|
}
|
|
1446
|
+
const body = { slotAt, name: a.name, phone: a.phone };
|
|
1447
|
+
if (a.email !== undefined) body.email = a.email;
|
|
1448
|
+
if (a.comment !== undefined) body.comment = a.comment;
|
|
1449
|
+
if (a.customAnswers !== undefined) body.customAnswers = a.customAnswers;
|
|
1450
|
+
if (a.meetingTypeId) body.meetingTypeId = uuidArg(a.meetingTypeId, "meetingTypeId");
|
|
1197
1451
|
try {
|
|
1198
|
-
return okResult(await api(`/api/bots/booking/calendars/${id}/bookings`, { method: "POST", body
|
|
1452
|
+
return okResult(await api(`/api/bots/booking/calendars/${id}/bookings`, { method: "POST", body }));
|
|
1199
1453
|
} catch (e) {
|
|
1200
1454
|
if (e.status === 409) throw new Error(`Слот ${slotAt} уже занят — выбери другой из booking_slots.`);
|
|
1201
1455
|
throw e;
|
|
@@ -1207,6 +1461,20 @@ async function handleCall(params) {
|
|
|
1207
1461
|
await api(`/api/bots/booking/calendars/${id}/bookings/${bookingId}/cancel`, { method: "POST" });
|
|
1208
1462
|
return okResult(`✅ Бронь ${bookingId} отменена, слот свободен.`);
|
|
1209
1463
|
}
|
|
1464
|
+
case "booking_confirm":
|
|
1465
|
+
case "booking_reject": {
|
|
1466
|
+
const id = uuidArg(a.calendarId, "calendarId");
|
|
1467
|
+
const bookingId = uuidArg(a.bookingId, "bookingId");
|
|
1468
|
+
const verb = params.name === "booking_confirm" ? "confirm" : "reject";
|
|
1469
|
+
try {
|
|
1470
|
+
await api(`/api/bots/booking/calendars/${id}/bookings/${bookingId}/${verb}`, { method: "POST" });
|
|
1471
|
+
} catch (e) {
|
|
1472
|
+
if (e.status === 404) throw new Error(`Бронь ${bookingId} не найдена в календаре ${id}.`);
|
|
1473
|
+
if (e.status === 409) throw new Error(`Бронь ${bookingId} уже не ждёт решения (подтверждена, отменена или истёк срок ожидания).`);
|
|
1474
|
+
throw e;
|
|
1475
|
+
}
|
|
1476
|
+
return okResult(verb === "confirm" ? `✅ Бронь ${bookingId} подтверждена.` : `❌ Бронь ${bookingId} отклонена, слот свободен.`);
|
|
1477
|
+
}
|
|
1210
1478
|
// ---- Паритет платформы ----
|
|
1211
1479
|
case "integration_calls": {
|
|
1212
1480
|
const q = {};
|
|
@@ -1391,6 +1659,101 @@ async function handleCall(params) {
|
|
|
1391
1659
|
await api(`/api/bots/${botId}/sessions/${chatId}`, { method: "DELETE" });
|
|
1392
1660
|
return okResult(`♻️ Сессия подписчика ${chatId} сброшена: при следующем сообщении он начнёт как новый.`);
|
|
1393
1661
|
}
|
|
1662
|
+
// ---- Аудитория ----
|
|
1663
|
+
case "audience_fields": {
|
|
1664
|
+
const botId = uuidArg(a.botId, "botId");
|
|
1665
|
+
const q = {};
|
|
1666
|
+
if (a.search) q.search = a.search;
|
|
1667
|
+
if (a.limit != null) {
|
|
1668
|
+
const n = Number(a.limit);
|
|
1669
|
+
if (!Number.isInteger(n) || n < 1 || n > 200) throw new Error("limit — целое от 1 до 200.");
|
|
1670
|
+
q.limit = n;
|
|
1671
|
+
}
|
|
1672
|
+
return okResult(await api(`/api/bots/${botId}/audience/fields${qs(q)}`));
|
|
1673
|
+
}
|
|
1674
|
+
case "audience_search": {
|
|
1675
|
+
const filter = audienceFilterArg(a.filter);
|
|
1676
|
+
const body = { filter, q: a.q || undefined, sort: a.sort || undefined, dir: a.dir || undefined, page: a.page, size: a.size };
|
|
1677
|
+
if (a.botId) return okResult(await api(`/api/bots/${uuidArg(a.botId, "botId")}/users/search`, { method: "POST", body }));
|
|
1678
|
+
const ids = Array.isArray(a.botIds) ? a.botIds.map((id) => uuidArg(id, "botIds[]")) : undefined;
|
|
1679
|
+
return okResult(await api("/api/bots/subscribers/search", { method: "POST", body: { ...body, botIds: ids } }));
|
|
1680
|
+
}
|
|
1681
|
+
case "audience_tag_edit": {
|
|
1682
|
+
const botId = uuidArg(a.botId, "botId");
|
|
1683
|
+
const chatIds = (Array.isArray(a.chatIds) ? a.chatIds : []).map((c) => chatIdArg(c));
|
|
1684
|
+
if (!chatIds.length) throw new Error("Передай chatIds — список числовых id подписчиков (audience_search/list_bot_users).");
|
|
1685
|
+
const add = strList(a.add), remove = strList(a.remove);
|
|
1686
|
+
if (!add.length && !remove.length) throw new Error("Передай add и/или remove — список тегов.");
|
|
1687
|
+
return okResult(await api(`/api/bots/${botId}/users/tags`, { method: "POST", body: { chatIds, add, remove } }));
|
|
1688
|
+
}
|
|
1689
|
+
case "audience_variable_edit": {
|
|
1690
|
+
const botId = uuidArg(a.botId, "botId");
|
|
1691
|
+
const chatIds = (Array.isArray(a.chatIds) ? a.chatIds : []).map((c) => chatIdArg(c));
|
|
1692
|
+
if (!chatIds.length) throw new Error("Передай chatIds — список числовых id подписчиков (audience_search/list_bot_users).");
|
|
1693
|
+
const set = a.set && typeof a.set === "object" && !Array.isArray(a.set) ? a.set : undefined;
|
|
1694
|
+
const unset = strList(a.unset);
|
|
1695
|
+
if ((!set || !Object.keys(set).length) && !unset.length) throw new Error("Передай set (объект {ключ: значение}) и/или unset (список ключей).");
|
|
1696
|
+
return okResult(await api(`/api/bots/${botId}/users/variables`, { method: "POST", body: { chatIds, set, unset } }));
|
|
1697
|
+
}
|
|
1698
|
+
case "audience_export": {
|
|
1699
|
+
const format = String(a.format || "csv").toLowerCase();
|
|
1700
|
+
if (!["csv", "xlsx"].includes(format)) throw new Error("format: csv | xlsx.");
|
|
1701
|
+
if (format === "xlsx" && !a.savePath) throw new Error("xlsx — бинарный формат, передай savePath (куда сохранить файл).");
|
|
1702
|
+
const filter = audienceFilterArg(a.filter);
|
|
1703
|
+
if (a.botId) {
|
|
1704
|
+
const botId = uuidArg(a.botId, "botId");
|
|
1705
|
+
const body = { filter, q: a.q || undefined, format };
|
|
1706
|
+
if (format === "xlsx") return saveBinary(await apiBinaryPost(`/api/bots/${botId}/users/export`, body), a.savePath);
|
|
1707
|
+
return exportResult(await api(`/api/bots/${botId}/users/export`, { method: "POST", body }), a.savePath);
|
|
1708
|
+
}
|
|
1709
|
+
const ids = Array.isArray(a.botIds) ? a.botIds.map((id) => uuidArg(id, "botIds[]")) : undefined;
|
|
1710
|
+
const body = { botIds: ids, filter, q: a.q || undefined, format };
|
|
1711
|
+
if (format === "xlsx") return saveBinary(await apiBinaryPost("/api/bots/subscribers/export", body), a.savePath);
|
|
1712
|
+
return exportResult(await api("/api/bots/subscribers/export", { method: "POST", body }), a.savePath);
|
|
1713
|
+
}
|
|
1714
|
+
case "dialog_search": {
|
|
1715
|
+
const q = String(a.q || "").trim();
|
|
1716
|
+
if (!q) throw new Error("Передай q — текст поиска (имя/username/id/текст сообщения).");
|
|
1717
|
+
const query = { q };
|
|
1718
|
+
if (a.limit != null) {
|
|
1719
|
+
const n = Number(a.limit);
|
|
1720
|
+
if (!Number.isInteger(n) || n < 1 || n > 60) throw new Error("limit — целое от 1 до 60.");
|
|
1721
|
+
query.limit = n;
|
|
1722
|
+
}
|
|
1723
|
+
return okResult(await api(`/api/bots/dialogs/search${qs(query)}`));
|
|
1724
|
+
}
|
|
1725
|
+
// ---- Публикации: квота, VK ----
|
|
1726
|
+
case "publications_usage": return okResult(await api(`/api/bots/${uuidArg(a.botId, "botId")}/publications/usage`));
|
|
1727
|
+
case "vk_status": return okResult(await api("/api/bots/vk/status"));
|
|
1728
|
+
case "vk_connect": {
|
|
1729
|
+
const communityId = String(a.communityId || "").trim();
|
|
1730
|
+
const accessToken = String(a.accessToken || "").trim();
|
|
1731
|
+
if (!communityId || !accessToken) throw new Error("Передай communityId и accessToken.");
|
|
1732
|
+
return okResult(await api("/api/bots/vk/connect-token", { method: "POST", body: { communityId, accessToken } }));
|
|
1733
|
+
}
|
|
1734
|
+
case "vk_disconnect": {
|
|
1735
|
+
const accountId = idArg(a.accountId, "accountId");
|
|
1736
|
+
await api(`/api/bots/vk/${accountId}`, { method: "DELETE" });
|
|
1737
|
+
return okResult(`🗑️ VK-аккаунт ${accountId} отключён.`);
|
|
1738
|
+
}
|
|
1739
|
+
case "vk_post": {
|
|
1740
|
+
const accountId = idArg(a.accountId, "accountId");
|
|
1741
|
+
const text = a.text == null ? "" : String(a.text);
|
|
1742
|
+
const mediaUrl = a.mediaUrl ? String(a.mediaUrl) : undefined;
|
|
1743
|
+
if (!text.trim() && !mediaUrl) throw new Error("Пустой пост: передай text и/или mediaUrl (файл из upload_file, только фото).");
|
|
1744
|
+
return okResult(await api("/api/bots/vk/post", { method: "POST", body: { accountId, text, mediaUrl } }));
|
|
1745
|
+
}
|
|
1746
|
+
// ---- Миграция подписчиков ----
|
|
1747
|
+
case "migration_presets": return okResult(await api("/api/bots/migration/presets"));
|
|
1748
|
+
case "migration_preview":
|
|
1749
|
+
case "migration_commit": {
|
|
1750
|
+
const botId = uuidArg(a.botId, "botId");
|
|
1751
|
+
const platform = enumArg(a.platform, "platform", ["BOTHELP", "SALEBOT", "SENLER", "LEADTEH", "SMARTSENDER", "MANYCHAT", "GENERIC"]);
|
|
1752
|
+
const commit = params.name === "migration_commit";
|
|
1753
|
+
if (commit && !a.mapping) throw new Error("Передай mapping — сначала проверь его через migration_preview (suggestedMapping или свой).");
|
|
1754
|
+
const suffix = commit ? "commit" : "preview";
|
|
1755
|
+
return okResult(await migrationMultipart(`/api/bots/${botId}/migration/subscribers/${suffix}`, { filePath: a.path, platform, mapping: a.mapping }));
|
|
1756
|
+
}
|
|
1394
1757
|
// ---- Боты ----
|
|
1395
1758
|
case "create_bot": {
|
|
1396
1759
|
const platform = String(a.platform || "").trim().toUpperCase();
|
|
@@ -1542,8 +1905,9 @@ async function handleCall(params) {
|
|
|
1542
1905
|
if (ids.length !== 1) throw new Error("Рассылка по сценарию — ровно один botId (сценарий принадлежит боту).");
|
|
1543
1906
|
await assertBroadcastBots(ids);
|
|
1544
1907
|
if (!a.name) throw new Error("Передай name рассылки.");
|
|
1908
|
+
const audienceFilter = audienceFilterArg(a.audienceFilter) || { tagsAll: strList(a.tagsAll), tagsNone: strList(a.tagsNone) };
|
|
1545
1909
|
const b = await api(`/api/bots/${ids[0]}/broadcasts`, { method: "POST", body: {
|
|
1546
|
-
name: a.name, audienceFilter
|
|
1910
|
+
name: a.name, audienceFilter,
|
|
1547
1911
|
graphId: a.graphId, entryNodeId: a.entryNodeId || undefined, scheduledAt: toInstant(a.scheduledAt, "scheduledAt") } });
|
|
1548
1912
|
return okResult({ broadcastIds: [b?.id], status: b?.status, scheduledAt: b?.scheduledAt ?? null, broadcast: b });
|
|
1549
1913
|
}
|
|
@@ -1554,12 +1918,19 @@ async function handleCall(params) {
|
|
|
1554
1918
|
await assertBroadcastBots(ids);
|
|
1555
1919
|
const name = String(pick("name") || "").trim();
|
|
1556
1920
|
if (!name) throw new Error("Передай name рассылки (видно только тебе в списке рассылок).");
|
|
1921
|
+
const audienceFilter = audienceFilterArg(a.audienceFilter);
|
|
1922
|
+
const dedup = a.dedup === true;
|
|
1923
|
+
const stagger = staggerArg(a.stagger);
|
|
1557
1924
|
const body = {
|
|
1558
1925
|
name, botIds: ids,
|
|
1559
|
-
|
|
1560
|
-
|
|
1926
|
+
...(audienceFilter ? { audienceFilter } : {
|
|
1927
|
+
tagsAll: strList(a.tagsAll !== undefined ? a.tagsAll : draft?.audienceFilter?.tagsAll),
|
|
1928
|
+
tagsNone: strList(a.tagsNone !== undefined ? a.tagsNone : draft?.audienceFilter?.tagsNone),
|
|
1929
|
+
}),
|
|
1561
1930
|
messages: normalizeBroadcastMessages(pick("messages")),
|
|
1562
1931
|
scheduledAt: toInstant(pick("scheduledAt"), "scheduledAt"),
|
|
1932
|
+
...(dedup ? { dedup } : {}),
|
|
1933
|
+
...(stagger ? { stagger } : {}),
|
|
1563
1934
|
};
|
|
1564
1935
|
if (body.scheduledAt && new Date(body.scheduledAt).getTime() <= Date.now()) {
|
|
1565
1936
|
// Прошедшее время бэкенд молча считает «сейчас» — для черновика с устаревшей датой это сюрприз.
|
|
@@ -1569,6 +1940,8 @@ async function handleCall(params) {
|
|
|
1569
1940
|
const r = await api("/api/bots/broadcasts/direct", { method: "POST", body });
|
|
1570
1941
|
let draftDeleted = false;
|
|
1571
1942
|
if (a.draftId) { try { await api(`/api/bots/broadcasts/drafts/${a.draftId}`, { method: "DELETE" }); draftDeleted = true; } catch { /* не критично */ } }
|
|
1943
|
+
// dedup/stagger → бэкенд создаёт BroadcastGroup вместо прямых TgBroadcast (другая форма ответа: id/perBot[]/status вместо broadcastIds/totalAudience).
|
|
1944
|
+
if (dedup || stagger) return okResult({ group: r, isGroup: true, ...(a.draftId ? { draftDeleted } : {}) });
|
|
1572
1945
|
return okResult({ ...r, scheduledAt: body.scheduledAt, ...(a.draftId ? { draftDeleted } : {}) });
|
|
1573
1946
|
}
|
|
1574
1947
|
case "broadcast_cancel":
|
|
@@ -1590,9 +1963,12 @@ async function handleCall(params) {
|
|
|
1590
1963
|
if (!["DAILY", "MONTHLY", "YEARLY"].includes(recurrence)) throw new Error("recurrence: DAILY | MONTHLY | YEARLY.");
|
|
1591
1964
|
const firstRunAt = toInstant(a.firstRunAt, "firstRunAt");
|
|
1592
1965
|
if (!firstRunAt || new Date(firstRunAt).getTime() <= Date.now()) throw new Error("firstRunAt — время первого запуска в будущем (ISO 8601, без пояса — московское).");
|
|
1966
|
+
const audienceFilter = audienceFilterArg(a.audienceFilter);
|
|
1593
1967
|
return okResult(await api(base, { method: "POST", body: {
|
|
1594
|
-
name: a.name, botIds: ids,
|
|
1595
|
-
|
|
1968
|
+
name: a.name, botIds: ids,
|
|
1969
|
+
...(audienceFilter ? { audienceFilter } : { tagsAll: strList(a.tagsAll), tagsNone: strList(a.tagsNone) }),
|
|
1970
|
+
messages: normalizeBroadcastMessages(a.messages), recurrence, firstRunAt,
|
|
1971
|
+
...(a.dedup === true ? { dedup: true } : {}), ...(a.stagger != null ? { stagger: staggerArg(a.stagger) } : {}) } }));
|
|
1596
1972
|
}
|
|
1597
1973
|
throw new Error("action: list | create | stop.");
|
|
1598
1974
|
}
|
|
@@ -1624,12 +2000,118 @@ async function handleCall(params) {
|
|
|
1624
2000
|
if (a.draftId) return okResult(await api(`/api/bots/broadcasts/drafts/${a.draftId}/duplicate`, { method: "POST" }));
|
|
1625
2001
|
if (!a.broadcastId) throw new Error("Передай broadcastId или draftId.");
|
|
1626
2002
|
return okResult(await api(`/api/bots/broadcasts/${a.broadcastId}/duplicate`, { method: "POST" }));
|
|
2003
|
+
case "broadcast_group": {
|
|
2004
|
+
const base = "/api/bots/broadcasts/group";
|
|
2005
|
+
if (a.action === "list") return okResult(await api(base));
|
|
2006
|
+
if (a.action === "preview") {
|
|
2007
|
+
const ids = botIdsOf(a);
|
|
2008
|
+
if (!ids.length) throw new Error("Передай botIds (1–20 ботов).");
|
|
2009
|
+
await assertBroadcastBots(ids);
|
|
2010
|
+
const filter = audienceFilterArg(a.audienceFilter);
|
|
2011
|
+
const body = { botIds: ids, ...(filter ? { audienceFilter: filter } : { tagsAll: strList(a.tagsAll), tagsNone: strList(a.tagsNone) }) };
|
|
2012
|
+
if (a.dedup === true) body.dedup = true;
|
|
2013
|
+
const stagger = staggerArg(a.stagger);
|
|
2014
|
+
if (stagger) body.stagger = stagger;
|
|
2015
|
+
return okResult(await api(`${base}-preview`, { method: "POST", body }));
|
|
2016
|
+
}
|
|
2017
|
+
const groupId = uuidArg(a.groupId, "groupId");
|
|
2018
|
+
if (a.action === "get") return okResult(await api(`${base}/${groupId}`));
|
|
2019
|
+
if (["pause", "resume", "cancel"].includes(a.action)) {
|
|
2020
|
+
try {
|
|
2021
|
+
await api(`${base}/${groupId}/${a.action}`, { method: "POST" });
|
|
2022
|
+
} catch (e) {
|
|
2023
|
+
if (e.status === 409) throw new Error(`Группа ${groupId} сейчас не в состоянии для действия «${a.action}» (см. broadcast_group {action:"get"}).`);
|
|
2024
|
+
throw e;
|
|
2025
|
+
}
|
|
2026
|
+
const verbs = { pause: "⏸️ поставлена на паузу", resume: "▶️ возобновлена", cancel: "⏹️ отменяется" };
|
|
2027
|
+
return okResult(`Группа ${groupId} ${verbs[a.action]}.`);
|
|
2028
|
+
}
|
|
2029
|
+
throw new Error("action: preview | list | get | pause | resume | cancel.");
|
|
2030
|
+
}
|
|
1627
2031
|
case "site_templates": {
|
|
1628
2032
|
const r = await api("/api/bots/pages/templates");
|
|
1629
2033
|
let templates = Array.isArray(r?.templates) ? r.templates : [];
|
|
1630
2034
|
if (a.category) templates = templates.filter((t) => t?.category === a.category);
|
|
1631
2035
|
return okResult({ categories: r?.categories ?? [], templates });
|
|
1632
2036
|
}
|
|
2037
|
+
// ---- Публикации: цепочки ----
|
|
2038
|
+
case "chain_list": return okResult(await api("/api/bots/chains"));
|
|
2039
|
+
case "chain_get": return okResult(await api(`/api/bots/chains/${uuidArg(a.chainId, "chainId")}`));
|
|
2040
|
+
case "chain_create": {
|
|
2041
|
+
if (!a.chain || typeof a.chain !== "object") throw new Error("Передай chain — объект PublishChain (см. описание chain_create).");
|
|
2042
|
+
return okResult(await api("/api/bots/chains", { method: "POST", body: a.chain }));
|
|
2043
|
+
}
|
|
2044
|
+
case "chain_update": {
|
|
2045
|
+
const chainId = uuidArg(a.chainId, "chainId");
|
|
2046
|
+
if (!a.chain || typeof a.chain !== "object") throw new Error("Передай chain — объект PublishChain целиком (сначала прочитай chain_get).");
|
|
2047
|
+
return okResult(await api(`/api/bots/chains/${chainId}`, { method: "PUT", body: a.chain }));
|
|
2048
|
+
}
|
|
2049
|
+
case "chain_delete": {
|
|
2050
|
+
const chainId = uuidArg(a.chainId, "chainId");
|
|
2051
|
+
if (a.confirm !== true) throw new Error("Удаление цепочки необратимо (теряется история заданий). Спроси пользователя и повтори с confirm:true.");
|
|
2052
|
+
await api(`/api/bots/chains/${chainId}`, { method: "DELETE" });
|
|
2053
|
+
return okResult(`🗑️ Цепочка ${chainId} удалена.`);
|
|
2054
|
+
}
|
|
2055
|
+
case "chain_enable": return okResult(await api(`/api/bots/chains/${uuidArg(a.chainId, "chainId")}/enable`, { method: "POST" }));
|
|
2056
|
+
case "chain_pause": return okResult(await api(`/api/bots/chains/${uuidArg(a.chainId, "chainId")}/pause`, { method: "POST" }));
|
|
2057
|
+
case "chain_runs": {
|
|
2058
|
+
const chainId = uuidArg(a.chainId, "chainId");
|
|
2059
|
+
const list = await api(`/api/bots/chains/${chainId}/runs`);
|
|
2060
|
+
// Бэкенд не умеет page/size на этом эндпоинте — режем список здесь же, в MCP (см. описание инструмента).
|
|
2061
|
+
if (!Array.isArray(list) || (a.page == null && a.size == null)) return okResult(list);
|
|
2062
|
+
const size = a.size != null ? Math.max(1, Number(a.size) || 20) : 20;
|
|
2063
|
+
const page = a.page != null ? Math.max(0, Number(a.page) || 0) : 0;
|
|
2064
|
+
const start = page * size;
|
|
2065
|
+
return okResult({ content: list.slice(start, start + size), totalElements: list.length, page, size });
|
|
2066
|
+
}
|
|
2067
|
+
case "chain_approve": return okResult(await api(`/api/bots/chains/jobs/${uuidArg(a.jobId, "jobId")}/approve`, { method: "POST" }));
|
|
2068
|
+
case "chain_reject": return okResult(await api(`/api/bots/chains/jobs/${uuidArg(a.jobId, "jobId")}/reject`, { method: "POST" }));
|
|
2069
|
+
case "chain_retry": {
|
|
2070
|
+
const jobId = uuidArg(a.jobId, "jobId");
|
|
2071
|
+
await api(`/api/bots/chains/jobs/${jobId}/retry`, { method: "POST" });
|
|
2072
|
+
return okResult(`🔁 Задание ${jobId} поставлено на повтор.`);
|
|
2073
|
+
}
|
|
2074
|
+
// ---- Аналитика (дашборд) ----
|
|
2075
|
+
case "site_analytics": {
|
|
2076
|
+
const siteId = uuidArg(a.siteId, "siteId");
|
|
2077
|
+
const qs = [];
|
|
2078
|
+
if (a.from) qs.push(`from=${encodeURIComponent(dateArg(a.from, "from"))}`);
|
|
2079
|
+
if (a.to) qs.push(`to=${encodeURIComponent(dateArg(a.to, "to"))}`);
|
|
2080
|
+
return okResult(await api(`/api/bots/pages/${siteId}/analytics${qs.length ? `?${qs.join("&")}` : ""}`));
|
|
2081
|
+
}
|
|
2082
|
+
case "site_analytics_events": {
|
|
2083
|
+
const siteId = uuidArg(a.siteId, "siteId");
|
|
2084
|
+
const qs = [];
|
|
2085
|
+
if (a.from) qs.push(`from=${encodeURIComponent(dateArg(a.from, "from"))}`);
|
|
2086
|
+
if (a.to) qs.push(`to=${encodeURIComponent(dateArg(a.to, "to"))}`);
|
|
2087
|
+
if (a.page != null) qs.push(`page=${encodeURIComponent(a.page)}`);
|
|
2088
|
+
if (a.size != null) qs.push(`size=${encodeURIComponent(a.size)}`);
|
|
2089
|
+
return okResult(await api(`/api/bots/pages/${siteId}/analytics/events${qs.length ? `?${qs.join("&")}` : ""}`));
|
|
2090
|
+
}
|
|
2091
|
+
case "bot_subscriber_analytics": {
|
|
2092
|
+
const botId = uuidArg(a.botId, "botId");
|
|
2093
|
+
const from = dateArg(a.from, "from"), to = dateArg(a.to, "to");
|
|
2094
|
+
return okResult(await api(`/api/bots/${botId}/analytics/subscribers?from=${from}&to=${to}`));
|
|
2095
|
+
}
|
|
2096
|
+
case "scenario_timeseries": {
|
|
2097
|
+
const graphId = uuidArg(a.graphId, "graphId");
|
|
2098
|
+
const from = dateArg(a.from, "from"), to = dateArg(a.to, "to");
|
|
2099
|
+
return okResult(await api(`/api/bots/graphs/${graphId}/analytics/timeseries?from=${from}&to=${to}`));
|
|
2100
|
+
}
|
|
2101
|
+
// ---- ИИ-агенты: инструменты ----
|
|
2102
|
+
case "agent_tool_test": {
|
|
2103
|
+
if (!a.agentId) throw new Error("Передай agentId.");
|
|
2104
|
+
const toolId = idArg(a.toolId, "toolId");
|
|
2105
|
+
return okResult(await api(`/api/bots/agents/${a.agentId}/tools/${toolId}/test`, { method: "POST", body: { params: a.params || {} } }));
|
|
2106
|
+
}
|
|
2107
|
+
// ---- Заявки сайта: файлы ----
|
|
2108
|
+
case "site_lead_file_download": {
|
|
2109
|
+
const siteId = uuidArg(a.siteId, "siteId");
|
|
2110
|
+
const leadId = uuidArg(a.leadId, "leadId");
|
|
2111
|
+
const fileId = uuidArg(a.fileId, "fileId");
|
|
2112
|
+
const meta = await siteLeadFileHead(siteId, leadId, fileId);
|
|
2113
|
+
return okResult({ ...meta, authHint: "Скачать: curl -H \"Authorization: Bearer <твой zmcp_-токен>\" \"<downloadUrl>\" -o файл — либо открой заявку в кабинете (сайт → «Заявки»)." });
|
|
2114
|
+
}
|
|
1633
2115
|
default:
|
|
1634
2116
|
throw new Error(`Неизвестный инструмент: ${params && params.name}`);
|
|
1635
2117
|
}
|