@retensy/mcp 0.12.0 → 0.13.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "retensy-mcp",
3
3
  "displayName": "Retensy MCP",
4
- "version": "0.12.0",
4
+ "version": "0.12.1",
5
5
  "description": "MCP-сервер + скилл для Retensy Bots: сборка и публикация воронок ботов (Telegram/MAX/Instagram) и публикация статей блога в Markdown — всё одним токеном zmcp_.",
6
6
  "author": { "name": "retensy", "url": "https://bots.retensy.com" },
7
7
  "homepage": "https://bots.retensy.com/bots",
package/package.json CHANGED
@@ -1,21 +1,51 @@
1
1
  {
2
2
  "name": "@retensy/mcp",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "MCP server to build and publish Telegram, MAX and Instagram bot funnels/automations — and publish Markdown blog articles — in the Retensy Bots service. Zero dependencies.",
5
5
  "type": "module",
6
- "bin": { "retensy-mcp": "src/index.mjs" },
6
+ "bin": {
7
+ "retensy-mcp": "src/index.mjs"
8
+ },
7
9
  "main": "src/index.mjs",
8
- "publishConfig": { "access": "public" },
9
- "files": ["src", "skills", ".mcp.json", ".claude-plugin", "README.md", "LICENSE"],
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "files": [
14
+ "src",
15
+ "skills",
16
+ ".mcp.json",
17
+ ".claude-plugin",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
10
21
  "scripts": {
11
22
  "start": "node src/index.mjs",
12
23
  "check": "node --check src/index.mjs",
13
24
  "test": "node scripts/smoke.mjs && node scripts/telemetry.test.mjs && node scripts/live-edit-422.test.mjs",
14
25
  "validate": "node skills/build-bot-funnel/validate.mjs"
15
26
  },
16
- "engines": { "node": ">=18" },
17
- "keywords": ["mcp", "telegram", "max", "instagram", "bot", "funnel", "automation", "articles", "blog", "markdown", "retensy", "claude-code", "model-context-protocol"],
18
- "repository": { "type": "git", "url": "git+https://github.com/retensy/retensy-mcp.git" },
27
+ "engines": {
28
+ "node": ">=18"
29
+ },
30
+ "keywords": [
31
+ "mcp",
32
+ "telegram",
33
+ "max",
34
+ "instagram",
35
+ "bot",
36
+ "funnel",
37
+ "automation",
38
+ "articles",
39
+ "blog",
40
+ "markdown",
41
+ "retensy",
42
+ "claude-code",
43
+ "model-context-protocol"
44
+ ],
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "git+https://github.com/retensy/retensy-mcp.git"
48
+ },
19
49
  "homepage": "https://bots.retensy.com/bots",
20
50
  "license": "MIT"
21
51
  }
@@ -33,7 +33,7 @@ description: Собрать воронку (сценарий) бота для с
33
33
  - Пакет `ACTIONS` — до 30 действий (метки, профиль, HTTP, уведомления, интеграции GetCourse/amoCRM/Битрикс24/Google Sheets/Я.Метрика, приём оплат, модерация группы). Список — в schema.md. **Интеграции РАБОТАЮТ** (это не заглушки), но каждой нужен `connectionId` — возьми его через **`list_integrations`**; без него действие упадёт «не выбрано подключение». Для Google Таблиц вместо `connectionId` нужен `googleEmail` (аккаунт подключается в вебе).
34
34
  - `AI_REPLY` («AI-агент») — два режима, поле `mode` (`"simple"`/не задан, либо `"agent"`). Платный узел в обоих режимах (`PREMIUM_NODE_FORBIDDEN` на бесплатном тарифе) и недоступен в Instagram-графах (нет в IG-allowlist). Тратит месячный AI-бюджет тарифа — он в токенах, не в рублях.
35
35
  - **`mode` не задан или `"simple"`** — ответ модели по `systemPrompt`/`userPromptTemplate` (обязателен, иначе выход `error`). Исчерпание бюджета — не ошибка: узел шлёт `quotaFallbackText` (если задан) и идёт дальше по `next`, как обычно.
36
- - **`mode: "agent"`** — поиск по подключённой базе знаний (`knowledgeBaseId`), ветки-действия (`actions[]`, каждому — своё ребро `action_<id>`), извлечение переменных из разговора (`extract[]`, значения — слова клиента, не проверенный факт) и готовая сводка на действии в `{{var.ai_summary}}` без отдельной настройки. Без найденных в базе фрагментов (нет базы, либо релевантность ниже порога) агент в ЛЮБОМ режиме (строгом и нет) уходит в `unknown` — модель даже не вызывается; нестрогий режим лишь разрешает модели дополнять УЖЕ найденные фрагменты общими знаниями (кроме цен/сроков/обещаний), а не отвечать вовсе без базы. **У узла нет выхода `next`** — только `answered`/`action_<id>`/`unknown`/`budget_exhausted`/`error` (`error` — сбой модели/поиска, ИИ не настроен, headless-вебхук или исчерпанный бюджет без ветки `budget_exhausted`). Исчерпание бюджета — своя ветка `budget_exhausted` (если подключена) либо `error`, `quotaFallbackText` отправляется в обоих случаях. Если собираешь агента этим скиллом — обязательно веди себя по этому списку выходов, старый одиночный `AI_REPLY` без `mode` для агента не годится. `systemPrompt`: мягкая норма 10 000 символов (у бэкенда для неё нет отдельного предупреждения, только `validate.mjs` этого скилла warn'ит явно), жёсткий потолок 30 000 (`AGENT_PROMPT_TOO_LONG`, блокирует публикацию) — платформа сама добавляет защиту от инъекций/вытягивания инструкции, запрет обещать несделанное и формат под мессенджер, дублировать это в `systemPrompt` не нужно. Подробности всех полей и кодов ошибок (`AGENT_*`) — в schema.md/validation.md.
36
+ - **`mode: "agent"`** — поиск по подключённой базе знаний (`knowledgeBaseId`), ветки-действия (`actions[]`, каждому — своё ребро `action_<id>`), извлечение переменных из разговора (`extract[]`, значения — слова клиента, не проверенный факт) и готовая сводка на действии в `{{var.ai_summary}}` без отдельной настройки. Без найденных в базе фрагментов (нет базы, либо релевантность ниже порога) агент в ЛЮБОМ режиме (строгом и нет) уходит в `unknown` — модель даже не вызывается; исключение — узел с непустым `actions[]`: он и без фрагментов зовёт модель, чтобы та могла выбрать действие («запишите меня на завтра» слов из базы не содержит), а её ответ без фрагментов в любом режиме (и в нестрогом тоже) уходит в `unknown`; нестрогий режим лишь разрешает модели дополнять УЖЕ найденные фрагменты общими знаниями (кроме цен/сроков/обещаний), а не отвечать вовсе без базы. **У узла нет выхода `next`** — только `answered`/`action_<id>`/`unknown`/`budget_exhausted`/`error` (`error` — сбой модели/поиска, ИИ не настроен, headless-вебхук или исчерпанный бюджет без ветки `budget_exhausted`). Исчерпание бюджета — своя ветка `budget_exhausted` (если подключена) либо `error`, `quotaFallbackText` отправляется в обоих случаях. Если собираешь агента этим скиллом — обязательно веди себя по этому списку выходов, старый одиночный `AI_REPLY` без `mode` для агента не годится. `systemPrompt`: мягкая норма 10 000 символов (у бэкенда для неё нет отдельного предупреждения, только `validate.mjs` этого скилла warn'ит явно), жёсткий потолок 30 000 (`AGENT_PROMPT_TOO_LONG`, блокирует публикацию) — платформа сама добавляет защиту от инъекций/вытягивания инструкции, запрет обещать несделанное и формат под мессенджер, дублировать это в `systemPrompt` не нужно. Подробности всех полей и кодов ошибок (`AGENT_*`) — в schema.md/validation.md.
37
37
  - **Приём оплат** — действие `issue_invoice` внутри `ACTIONS` (НЕ отдельный узел: `YOOKASSA_PAYMENT` устарел). Деньги идут на кассу владельца бота; `connectionId` — подключение `provider=YOOKASSA` из `list_integrations`. Два режима, выбирай по задаче:
38
38
  - **дождаться оплаты прямо в диалоге** — протяни от блока ветку `paid` (появятся выходы `paid`/`timeout`); сценарий встанет на паузу до подтверждения кассой;
39
39
  - **не ждать** — ветку `paid` не рисуй, блок просто пришлёт счёт и пойдёт по `next`; выдать доступ потом сможет отдельный сценарий с триггером `TRIGGER_PAYMENT`.
@@ -54,16 +54,16 @@ description: Собрать воронку (сценарий) бота для с
54
54
  - `list_bots` → выбрать `botId` (или `create_graph` в существующем боте).
55
55
  - `create_graph(botId, name)` → получить `graphId`. Либо стартуй с готовой основы: `list_templates` → `create_graph_from_template(botId, templateId, name)`.
56
56
  - `update_graph(graphId, nodes, edges, canvasMeta)` → залить узлы/рёбра.
57
- - `dry_run(graphId, kind:"command", value:"start")` → прогнать стартовую ветку, проверить `runStatus`.
57
+ - `dry_run(graphId, kind:"command", value:"start")` → прогнать стартовую ветку, проверить `runStatus`. `dry_run` ничего не делает снаружи: внешние запросы, CRM, таблицы, письма, уведомления, счета и ответы ИИ пропускаются (у шага `skipped: "dry-run"`), переменные из их ответов остаются пустыми — это проверяется только живым прогоном.
58
58
  - `publish_graph(graphId)` → при отказе инструмент вернёт ошибку `HTTP 422` со ВСЕМИ причинами построчно (`code@nodeId: message`) — разобрать, починить узлы, обновить, опубликовать снова.
59
59
  - Управление сценариями: `clone_graph`, `rename_graph`, `set_active_graph` (переключить живой граф), `delete_graph` (активный нельзя — сначала переключи).
60
60
  Если MCP не подключён — отдай готовый `import.json` и подскажи: /bots → граф → **Импорт**.
61
61
 
62
62
  5b. **Правка СУЩЕСТВУЮЩЕГО / живого сценария — по умолчанию `edit_graph_live`, а НЕ clone+publish.**
63
- - Когда пользователь просит «поправь сценарий X» (особенно если он уже открыт в редакторе или опубликован) — правь **ТОТ ЖЕ `graphId`** через **`edit_graph_live(graphId, nodes, edges)`**. Он сам снимает авто-бэкап предыдущего состояния (один rolling-граф «🔙 Авто-бэкап») и делает PUT на месте — **id не меняется**. Живой сценарий бота — граф с `isActive: true` в `list_graphs` (статус `PUBLISHED`; после `publish_graph` черновика это `publishedGraphId`, а **не** id черновика) — правь его id, иначе правка ляжет в черновик и до бота не дойдёт.
63
+ - Когда пользователь просит «поправь сценарий X» (особенно если он уже открыт в редакторе или опубликован) — правь **ТОТ ЖЕ `graphId`** через **`edit_graph_live(graphId, nodes, edges)`**. Он сам снимает авто-бэкап предыдущего состояния (один rolling-граф «🔙 Авто-бэкап») и делает PUT на месте — **id не меняется**. Живой сценарий бота (бот-сценария) — граф с `isActive: true` в `list_graphs` (статус `PUBLISHED`; после `publish_graph` черновика это `publishedGraphId`, а **не** id черновика) — правь его id, иначе правка ляжет в черновик и до бота не дойдёт. Вебхук-сценарий отдельного опубликованного графа не имеет — правь его по его же id.
64
64
  - Почему так: бэкенд при PUT/публикации шлёт `external_update` в WS-комнату → открытые редакторы перечитывают граф **вживую** (юзеру не надо перезаходить). Бот читает активный граф **заново из БД на каждое сообщение** → правка живого PUBLISHED-графа применяется **сразу, без отдельной публикации**.
65
- - `clone_graph`+`publish_graph` НЕ создают новый живой граф и НЕ песочница: публикация копирует клон (как любой черновик) в id ТЕКУЩЕГО опубликованного графа бота и затирает его содержимое без бэкапа — вместе с живыми правками, сделанными после прошлой публикации; `set_active_graph` на прежний id после этого вернёт уже новое содержимое, а открытые редакторы живого графа об этом не узнают. Клон годится, чтобы спокойно собрать крупный рискованный рефактор в черновике, но перед его публикацией сохрани живой граф: `get_graph(graphId, saveToFile)` — откат = залить файл обратно через `edit_graph_live`/`update_graph` с `graphFile`.
66
- - ⚠️ PUT **активного** (PUBLISHED) графа сервер проверяет как `publish`: валидатор, платные блоки, лимит блоков тарифа, платформа. Ошибка → `HTTP 422` со всеми `code@nodeId`, граф **не сохранён**, бот работает на прежней версии. Черновик (`DRAFT`) PUT сохраняет без проверок, и на бота он не влияет: `publish_graph` черновика копирует его в отдельный `PUBLISHED`-граф (`publishedGraphId`), а сам черновик остаётся `DRAFT`. Всё равно прогоняй `validate.mjs` + `dry_run` заранее. Откат: версии активного не хранятся — опубликовать граф «🔙 Авто-бэкап» (или скопировать его содержимое обратно).
65
+ - `clone_graph`+`publish_graph` НЕ создают новый живой граф и НЕ песочница: публикация копирует клон (как любой черновик) в id ТЕКУЩЕГО опубликованного графа бота и затирает его содержимое без бэкапа — вместе с живыми правками, сделанными после прошлой публикации; `set_active_graph` на прежний id после этого вернёт уже новое содержимое; открытые редакторы живого графа перечитают его (у кого есть несохранённые правки — получат просьбу обновить страницу). Клон годится, чтобы спокойно собрать крупный рискованный рефактор в черновике, но перед его публикацией сохрани живой граф: `get_graph(graphId, saveToFile)` — откат = залить файл обратно через `edit_graph_live`/`update_graph` с `graphFile`.
66
+ - ⚠️ PUT **активного** (PUBLISHED) графа сервер проверяет как `publish`: валидатор, платные блоки, лимит блоков тарифа, платформа. Ошибка → `HTTP 422` со всеми `code@nodeId`, граф **не сохранён**, бот работает на прежней версии. Черновик (`DRAFT`) PUT сохраняет без проверок (кроме размера: больше 4 МБ → 422 `GRAPH_TOO_LARGE`), и на бота он не влияет: `publish_graph` черновика копирует его в отдельный `PUBLISHED`-граф (`publishedGraphId`), а сам черновик остаётся `DRAFT`. Всё равно прогоняй `validate.mjs` + `dry_run` заранее. Откат: версии активного не хранятся — опубликовать граф «🔙 Авто-бэкап» (или скопировать его содержимое обратно).
67
67
 
68
68
  6. **Отчитайся**: сколько узлов/веток, какие тексты помечены на проверку, ссылка/ id графа.
69
69
 
@@ -71,6 +71,8 @@ description: Собрать воронку (сценарий) бота для с
71
71
  - **Источник = текст** (этот режим). Если просят распознать с приватной Miro-доски — самый надёжный путь: CSV-экспорт из Miro; либо запуск залогиненного Chrome пользователя и съёмка экрана (headless WebGL-холст Miro не отдаёт). Это отдельный сценарий, не основной для этого скилла.
72
72
  - **Авторизация MCP** — персональный токен (создаётся в вебе на `/bots/mcp-tokens`, формат `zmcp_…`, полный доступ). Если инструмент вернул «нет токена» или ошибку доступа — **вызови `setup`**, объясни пользователю шаги, попроси прислать токен и сохрани его через **`set_token`** (применяется сразу, без env/рестарта). Также работают env `RETENSY_MCP_TOKEN` и session-cookie.
73
73
  - Бэкенд читает плоские поля `config.text`/`config.photoUrl`; редактор берёт текст из первой карточки `type:"text"`. Поэтому **всегда заполняй и `text`, и `cards`**.
74
+ - **Размер графа ≤ 4 МБ** (JSON `{nodes, edges, canvasMeta}` в UTF-8): больше — `HTTP 422 GRAPH_TOO_LARGE` на `update_graph`/`edit_graph_live`/`patch_graph`/`import_funnel`, и у черновика тоже; граф не сохранён. `validate.mjs` проверяет заранее.
75
+ - **Переменные подписчика:** строка ≤ 65 536 символов (длиннее обрезается), список/объект длиннее не сохраняется; все переменные одного подписчика вместе с именами ≤ 262 144 символов — запись сверх лимита не сохраняется. Большой ответ API не клади в переменную целиком — вытаскивай нужное через `extract`.
74
76
 
75
77
  ## Если возможности не хватает — она фиксируется автоматически
76
78
 
@@ -41,7 +41,7 @@
41
41
  **Ставь хотя бы один фильтр:** без них сценарий будет запускаться на КАЖДУЮ оплату в кассе.
42
42
  В контексте: `{{payment.id}}`, `{{payment.amount}}`, `{{payment.status}}`,
43
43
  `{{payment.description}}`, `{{payment.currency}}`, плюс весь ответ ЮKassa как `body`.
44
- Запуск — один раз на платёж: повторные уведомления ЮKassa о той же оплате сценарий не перезапускают.
44
+ Запуск — один раз на платёж: повторные уведомления ЮKassa о той же оплате сценарий не перезапускают. Сюда же приходит оплата счёта, пришедшая уже после `timeout` блока со счётом, — в диалоге того подписчика (пока бот выключен, оплата ждёт повтора ЮKassa).
45
45
  - **Счёт выставлял бот** (действие `issue_invoice`) → подписчик определяется САМ: счёт
46
46
  помнит его в `metadata` платежа. Прогон идёт в обычной сессии — можно прямо ставить
47
47
  «Отправить сообщение» и «Добавить метку», указывать получателя и разбирать ответ кассы
@@ -129,6 +129,10 @@ IG-боты не поддерживают команды (`/start`). Вход
129
129
  - **`TOMORROW`** («Отправить завтра»): `{ "kind":"TOMORROW", "time":"18:00" }` — завтра в указанное время `HH:mm` (МСК), относительно момента, когда пользователь дошёл до узла.
130
130
  - **`UNTIL`** («Отправить в»): `{ "kind":"UNTIL", "isoTimestamp":"2026-06-25T15:00:00Z" }` — конкретный момент в ISO-8601 (UTC). ⚠️ Рантайм читает только `isoTimestamp`; пары `isoDate`+`time` НЕ работают.
131
131
  - `SCHEDULE` — `{ "isoDate":"2026-06-25", "time":"18:00", "timezone":"Europe/Moscow" }`. Выходы `scheduled` / `past`.
132
+ - Прогон без пауз (между `DELAY`, `ASK_QUESTION`/`awaitReply`, ожиданием оплаты) ограничен **5 минутами**: дольше — рантайм обрывает его с ошибкой шага `run deadline exceeded` (прогон `FAILED`, пауза подписчика снята). Длинные цепочки `external_request` / `CALL_WEBHOOK` / `AI_REPLY` разноси `DELAY` — после паузы начинается новый прогон.
133
+ - Массовый одинаковый момент срабатывания (`TOMORROW`/`UNTIL`/`SCHEDULE` у тысяч подписчиков одного бота) расходится постепенно: до 10 срабатываний на бота в секунду, время растёт с аудиторией (≈100 с на 1000 подписчиков, ≈17 мин на 10 000), другие боты не ждут. Не рассчитывай, что все получат сообщение в одну секунду.
134
+ - Если Telegram вернул боту 429 (`retry after N`, пауза не дольше часа), срабатывания `DELAY`/`SCHEDULE` и рассылки этого бота (и по `BROADCAST_FILTER`, и прямые) ждут конца паузы; после такой паузы рассылка продолжается без потерь и без повтора уже доставленного. Это только про паузу 429: если сервис перезапустился посреди отправки, получатель, на котором её прервали, через ≤15 мин может получить рассылку (или прогон сценария) повторно. Ответ подписчику в живом диалоге отправляется сразу, без ожидания; если Telegram откажет снова, шаг уходит в ветку «Ошибка», если она проведена, иначе дальше по обычному выходу.
135
+ - `AI_REPLY` при сбое провайдера (5xx, таймаут) уходит в `error` примерно через минуту — не рассчитывай на долгое ожидание ответа модели.
132
136
 
133
137
  ### Состояние / действия
134
138
  - `SET_VARIABLE` (`{ "key":"name", "value":"..." }`), `ADD_TAG`/`REMOVE_TAG` (`{ "tag":"lead" }`), `FORMULA` (`{ "expression":"...", "saveTo":"name" }`)
@@ -142,7 +146,7 @@ IG-боты не поддерживают команды (`/start`). Вход
142
146
  ветвиться по коду ответа надо следующим блоком `SWITCH`. **Платное действие** (как `CALL_WEBHOOK`):
143
147
  на бесплатном тарифе публикация падает с `PREMIUM_NODE_FORBIDDEN`.
144
148
  Ещё есть `subscriber_webhook` — `url` + `method`/`headersJson`/`bodyTemplate`
145
- - **уведомления**: `notify` (`text`), `subscriber_email` (`email`,`text`), `agent_chat`
149
+ - **уведомления**: `notify` (`text`) — владельцу бота в бот уведомлений из его профиля (Telegram/MAX); не подключён → действие не удалось, блок уходит в `error`; `subscriber_email` (`email`,`text`), `agent_chat`
146
150
  - **бот/шаг**: `stop_bot`, `delete_step_message`, `cancel_payment_subscription`
147
151
  - **Google Таблицы (работает)**: `gsheets_send` — дописать строку-заявку в таблицу: `{ "kind":"gsheets_send", "googleEmail":"me@gmail.com", "spreadsheetId":"<id таблицы>", "sheetName":"Лист1", "cells":["{{from.first_name}}","{{var.phone}}","{{var.email}}"] }`. `cells` — значения по порядку (шаблоны), бот дописывает их строкой в конец листа. Google-аккаунт подключается В ВЕБЕ (`/bots` → у действия кнопка «Подключить Google»), НЕ через MCP — у пользователя уже должен быть подключён `googleEmail`. Нужны `googleEmail` + `spreadsheetId` + непустой `cells[]` (иначе `ACTION_GSHEETS_INCOMPLETE`).
148
152
  Остальные четыре действия с таблицами тоже РАБОТАЮТ и тоже требуют `googleEmail` + `spreadsheetId`:
@@ -186,7 +190,7 @@ IG-боты не поддерживают команды (`/start`). Вход
186
190
  прижимается к диапазону **500…30000 мс**.
187
191
  - `saveStatusTo` / `saveBodyTo` — имена переменных для HTTP-кода и тела ответа целиком.
188
192
  Пишутся ВСЕГДА, в том числе на `error`: при сетевой ошибке код `0` и пустое тело (чтобы в
189
- переменной не осталось значение прошлого прогона). Тело длиннее 64 КБ обрезается.
193
+ переменной не осталось значение прошлого прогона). Тело длиннее 65 536 символов обрезается — как любое строковое значение переменной (список/объект длиннее, например из `extract`, не сохраняется); все переменные подписчика вместе с именами — до 262 144 символов, запись сверх лимита не сохраняется (переменная прежняя).
190
194
  Это штатный способ разветвиться по коду ответа: `saveStatusTo` → `SWITCH`.
191
195
  - `extract` — разбор JSON-тела по JsonPath в переменные (работает только на валидном JSON).
192
196
  - `AI_REPLY` — ответ модели. Два режима, переключатель — поле `mode` (`"simple"`/не задано, либо
@@ -206,7 +210,8 @@ IG-боты не поддерживают команды (`/start`). Вход
206
210
  пустой, если прогона ещё не было). Температура задаётся глобально на сервере — поле
207
211
  `temperature` в конфиге рантайм не читает.
208
212
 
209
- **`mode: "agent"`** — поиск по базе знаний + ветки-действия + память диалога на этом узле
213
+ **`mode: "agent"`** — поиск по базе знаний + ветки-действия + память диалога на этом узле. Температура
214
+ агента своя, серверная (`botbuilder.ai.agent-temperature`, по умолчанию 0.3), `temperature` узла не читается
210
215
  (спека `docs/superpowers/specs/2026-09-21-ai-agent-node-design.md`, раздел 10). У узла-агента
211
216
  **нет выхода `next`** — вместо него именованные ветки ниже.
212
217
  ```json
@@ -225,12 +230,16 @@ IG-боты не поддерживают команды (`/start`). Вход
225
230
  - **Без найденных в базе фрагментов агент уходит в `unknown` в ЛЮБОМ режиме** — если хитов нет
226
231
  или лучший скор ниже `minScore`, до вызова модели дело не доходит вообще (нестрогий режим
227
232
  агента без базы или без совпадений не «отвечает по общим знаниям», он так же уходит в
228
- `unknown`). `strict` решает только то, что делает модель, когда релевантные фрагменты уже
233
+ `unknown`). **Исключение — узел с непустым `actions[]`:** он и без фрагментов зовёт модель
234
+ (платно), чтобы та могла выбрать действие — «запишите меня на завтра» слов из базы не содержит;
235
+ её ответ без фрагментов в любом режиме (и в нестрогом) уходит в `unknown` — только действие. Узел только с
236
+ `extract[]` выходит рано, как раньше. `strict` решает только то, что делает модель, когда релевантные фрагменты уже
229
237
  нашлись: в строгом режиме — отвечает исключительно по ним; без строгого режима — может
230
238
  дополнить их общими знаниями модели, но не про цены/сроки/обещания — это всегда только из
231
239
  базы и инструкции.
232
240
  - `directAnswer` (по умолчанию `false`) — прямой ответ готовой парой вопрос-ответ БЕЗ обращения
233
- к модели (бесплатно), когда совпадение по базе ≥ `directAnswerThreshold`. Работает только для
241
+ к модели (бесплатно), когда совпадение по базе ≥ `directAnswerThreshold`. Пара ищется по вектору
242
+ своего вопроса, так что порог 0.88 проходят почти дословные формулировки. Работает только для
234
243
  документов-пар вопрос-ответ (не для кусков файлов/сайта) — кусок PDF, отданный дословно,
235
244
  выглядел бы поломкой.
236
245
  - `minScore` — порог релевантности фрагмента для решения «есть о чём отвечать»; ниже — выход
@@ -298,7 +307,7 @@ IG-боты не поддерживают команды (`/start`). Вход
298
307
  - Переменные после шага: `{{var.payment_url}}`, `{{var.payment_id}}`.
299
308
  - Повторный проход по счёту: пока попытка ЭТОГО шага жива (не оплачена, таймаут не сработал, другой счёт с
300
309
  `paid` её не сменил) — та же ссылка (тот же платёж), даже если между проходами подписчик ушёл в другую
301
- ветку; после оплаты или таймаута — новый счёт. Оплата, пришедшая после таймаута, сценарий не продолжает.
310
+ ветку; после оплаты или таймаута — новый счёт. Оплата, пришедшая после таймаута, ветку `paid` не продолжает, зато запускает сценарии владельца с `TRIGGER_PAYMENT` в диалоге этого подписчика (один раз на платёж; бот выключен — ЮKassa повторяет уведомление, запуск после включения бота) — для опоздавших заведи такой сценарий. Если таймаут был пропущен, пока бот был выключен, повторный проход сперва спрашивает ЮKassa: платёж оплачен — шаг сразу идёт по `paid` (нового счёта нет); статус не узнать — та же ссылка; не оплачен — новый счёт.
302
311
  - Без ветки `paid` ожидания нет: каждый проход по такому счёту — новый платёж с новой ссылкой.
303
312
  - Действие бесплатное (в отличие от `external_request`).
304
313
  - ⚠️ Чтобы `paid` вообще срабатывал, владелец должен вписать адрес уведомлений из карточки
@@ -1,9 +1,14 @@
1
1
  # Правила валидатора (GraphValidator) — чтобы граф публиковался
2
2
 
3
- Источник истины: `retensyBackend/.../service/bot/GraphValidator.java`. При `publish` и при PUT **активного** графа бэкенд отвечает `HTTP 422` с `errors: [{ nodeId, code, message }]` — MCP показывает их все построчно (`code@nodeId: message`). Черновик PUT сохраняет без проверок. Ниже — что проверяется и как не нарваться.
3
+ Источник истины: `retensyBackend/.../service/bot/GraphValidator.java`. При `publish` и при PUT **активного** графа бэкенд отвечает `HTTP 422` с `errors: [{ nodeId, code, message }]` — MCP показывает их все построчно (`code@nodeId: message`). Черновик PUT сохраняет без проверок — кроме размера: граф больше 4 МБ не сохраняется ни черновиком, ни активным (`GRAPH_TOO_LARGE`). Ниже — что проверяется и как не нарваться.
4
4
 
5
5
  ## Коды ошибок и условия
6
6
 
7
+ ### `GRAPH_TOO_LARGE` — «Сценарий больше 4 МБ»
8
+ Граф хранится одним документом. Если JSON `{nodes, edges, canvasMeta}` в UTF-8 длиннее 4 194 304 байт, `update_graph`,
9
+ `edit_graph_live`, `patch_graph` и `import_funnel` получают `HTTP 422 GRAPH_TOO_LARGE`, граф не сохранён — **и у черновика
10
+ тоже**. Сократи тексты или раздели сценарий на несколько. `validate.mjs` это проверяет.
11
+
7
12
  ### `SEND_NO_TEXT` — «Пустое сообщение — добавьте текст или картинку»
8
13
  `SEND_MESSAGE` считается пустым, если **`config.text` пустой/из пробелов И `config.photoUrl` пустой**.
9
14
  - Бэкенд читает плоское `config.text` (НЕ `cards`).
@@ -73,8 +78,8 @@
73
78
 
74
79
  ## Жизненный цикл
75
80
  - Статусы графа: `DRAFT` / `PUBLISHED`. Публикация заменяет активную опубликованную версию.
76
- - Правка активного (`PUBLISHED`) графа применяется к боту сразу и проверяется как публикация (плюс платные блоки и лимит блоков тарифа): ошибки → 422, граф не сохранён. `DRAFT` — черновик: PUT сохраняет его без проверок, на бота он не влияет. `publish_graph` черновика копирует его в отдельный `PUBLISHED`-граф (`publishedGraphId`), черновик остаётся `DRAFT` — живые правки делай по `publishedGraphId` (в `list_graphs` у него `isActive: true`).
77
- - Перед публикацией полезно прогнать `dry_run` (kind `command`/`callback`/`text`) — поймать рантайм-проблемы стартовой ветки.
81
+ - Правка активного (`PUBLISHED`) графа применяется к боту сразу и проверяется как публикация (плюс платные блоки и лимит блоков тарифа): ошибки → 422, граф не сохранён. `DRAFT` — черновик: PUT сохраняет его без проверок (кроме размера > 4 МБ → `GRAPH_TOO_LARGE`), на бота он не влияет. `publish_graph` черновика копирует его в отдельный `PUBLISHED`-граф (`publishedGraphId`), черновик остаётся `DRAFT` — живые правки бот-сценария делай по `publishedGraphId` (в `list_graphs` у него `isActive: true`; `list_graphs` показывает только сценарии самого бота); вебхук-сценарий правится по своему id.
82
+ - Перед публикацией полезно прогнать `dry_run` (kind `command`/`callback`/`text`) — поймать рантайм-проблемы стартовой ветки. Внешних действий `dry_run` не выполняет (шаги с `skipped: "dry-run"`): ответ ИИ, счёт и запись в CRM проверяются только живым прогоном.
78
83
 
79
84
  ## Платформенные правила — Instagram
80
85
 
@@ -119,7 +124,7 @@ Instagram доставляет сообщения только в течение
119
124
  ---
120
125
 
121
126
  ## Локальная проверка
122
- `node validate.mjs <import.json>` повторяет ключевые проверки: пустые сообщения с учётом `cardsToLegacy`, висячие рёбра, дубли id, достижимость от триггеров, длину текста, HTML-безопасность (эвристика по тегам), режим «Вопрос» (`awaitReply`→`saveTo`/`regex`), конфиг `DELAY`/`SCHEDULE`/`FORMULA`/`ACTIONS`/`AI_REPLY`(оба режима, включая `AGENT_*`)/`PAYMENT_LINK`/триггеров, условия `CONDITION` (вкл. `LINK_CLICKED` со ссылкой на отслеживаемый шаг). Гонять перед каждой заливкой.
127
+ `node validate.mjs <import.json>` повторяет ключевые проверки: пустые сообщения с учётом `cardsToLegacy`, висячие рёбра, дубли id, достижимость от триггеров, длину текста, HTML-безопасность (эвристика по тегам), режим «Вопрос» (`awaitReply`→`saveTo`/`regex`), конфиг `DELAY`/`SCHEDULE`/`FORMULA`/`ACTIONS`/`AI_REPLY`(оба режима, включая `AGENT_*`)/`PAYMENT_LINK`/триггеров, условия `CONDITION` (вкл. `LINK_CLICKED` со ссылкой на отслеживаемый шаг), размер графа (≤ 4 МБ). Гонять перед каждой заливкой.
123
128
 
124
129
  Для IG-ботов передавать `--platform=INSTAGRAM`:
125
130
  ```bash
@@ -27,6 +27,13 @@ const nodes = Array.isArray(g.nodes) ? g.nodes : [];
27
27
  const edges = Array.isArray(g.edges) ? g.edges : [];
28
28
  const errors = [];
29
29
  const warns = [];
30
+ // Потолок размера графа — зеркало TgGraphController.MAX_GRAPH_BYTES (аудит data#24): PUT больше 4 МБ → HTTP 422
31
+ // GRAPH_TOO_LARGE, и у черновика тоже. Байты UTF-8 того же JSON {nodes, edges, canvasMeta}.
32
+ const MAX_GRAPH_BYTES = 4 * 1024 * 1024;
33
+ const graphBytes = Buffer.byteLength(JSON.stringify({ nodes, edges, canvasMeta: g.canvasMeta ?? {} }), "utf8");
34
+ if (graphBytes > MAX_GRAPH_BYTES) {
35
+ errors.push(`GRAPH_TOO_LARGE: граф ${graphBytes} байт > ${MAX_GRAPH_BYTES} (4 МБ) — сократи тексты или раздели на несколько сценариев.`);
36
+ }
30
37
 
31
38
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
32
39
  const VAR_RE = /^[a-z_][a-z0-9_]{0,63}$/;
@@ -0,0 +1,47 @@
1
+ ---
2
+ name: build-site
3
+ description: Собрать сайт или лендинг из блоков в сервисе retensy (раздел «Страницы») — обложка, тексты, преимущества, галерея, форма заявки, попап, несколько страниц — и опубликовать. Использовать, когда пользователь просит «сделать сайт», «лендинг», «страницу с формой заявки», «сайт для бизнеса».
4
+ ---
5
+
6
+ # Сайт из блоков (retensy «Страницы»)
7
+
8
+ Сайт — JSON-модель: тема, общие шапка и подвал, страницы с блоками, попапы. Правится операциями `site_edit`
9
+ (всё или ничего, с проверкой схемы на сервере), публикуется `site_publish`. Тот же сайт пользователь потом правит
10
+ мышкой в кабинете — модель общая.
11
+
12
+ ## Порядок
13
+
14
+ 1. `site_schema` — какие блоки и поля бывают (`model`) и какие операции есть (`ops`).
15
+ 2. `site_create {title}` → `id`. Для правки существующего — `site_list`, `site_get {siteId}`.
16
+ 3. Первый `site_edit` с `init`: `starter` — готовый лендинг (шапка, обложка, текст, преимущества, форма, подвал) или
17
+ `blank` — пустая главная. Заполни тексты, не выдумывай факты о бизнесе — спрашивай.
18
+ 4. Картинки — `site_upload_asset {siteId, path|url}` → `assets/…` в поля `image`, `logo`, `icon`, `style.bg.image`.
19
+ 5. `site_get` — проверь модель, `site_publish` — сайт открыт по `url`. Заявки из форм — `site_leads`.
20
+
21
+ ## Правила модели
22
+
23
+ - Значения по экранам — `{d, t?, m?}` (десктоп ≥1024, планшет 640–1023, телефон <640); без `t`/`m` берётся больший.
24
+ - `props`, `style`, `theme` в операциях — JSON Merge Patch: объекты сливаются, `null` удаляет ключ, массивы
25
+ (кнопки, ссылки, поля формы, фото) передаются целиком.
26
+ - Ссылки (`action`): `{kind:"url", href}` (https/tel/mailto/tg/#якорь), `{kind:"page", pageId}`,
27
+ `{kind:"anchor", blockId}`, `{kind:"popup", popupId}`.
28
+ - Текст с разметкой (Rich): только `<b> <i> <u> <s> <br> <a href> <span style="color:#…">`.
29
+ - Цвета — `#rrggbb`. Шрифты: inter, montserrat, roboto, pt-sans, pt-serif, rubik, oswald.
30
+ - `revision` из ответа передавай в следующий `site_edit` — сервер не даст перезаписать правки из кабинета (409).
31
+
32
+ ## Пример: лендинг кофейни с попапом заявки
33
+
34
+ ```json
35
+ [
36
+ {"op": "set_theme", "theme": {"colors": {"primary": "#b5651d"}, "fonts": {"heading": "pt-serif", "body": "inter"}}},
37
+ {"op": "add_popup", "name": "Бронь столика"},
38
+ {"op": "add_block", "container": "<id главной>", "type": "cover", "after": -1,
39
+ "props": {"title": "Кофе, ради которого приходят", "subtitle": "Обжариваем сами, с 8:00 до 22:00",
40
+ "buttons": [{"label": "Забронировать столик", "style": "primary", "action": {"kind": "popup", "popupId": "<id из results>"}}],
41
+ "height": {"d": "screen", "m": "auto"}},
42
+ "style": {"bg": {"image": "assets/cafe-x1y2.jpg", "overlay": 0.5}, "textColor": "#ffffff"}}
43
+ ]
44
+ ```
45
+
46
+ Порядок важен: id созданного попапа приходит в `results` — если он нужен в той же правке, сделай два вызова
47
+ `site_edit` (сначала `add_popup`, потом блок с кнопкой).
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.12.0";
30
+ const VERSION = "0.12.1";
31
31
  const PKG_NAME = "@retensy/mcp";
32
32
  const BASE = (process.env.RETENSY_BASE_URL || "https://bots.retensy.com").replace(/\/+$/, "");
33
33
  const CONFIG_DIR = path.join(os.homedir(), ".retensy-bot-graph");
@@ -330,6 +330,47 @@ async function uploadMedia({ filePath, url, filename }) {
330
330
  return data;
331
331
  }
332
332
 
333
+ // Ассет сайта из блоков: POST /api/bots/pages/{id}/upload (multipart, dir=assets). Бэкенд принимает имена только из
334
+ // [A-Za-z0-9._@()+- ], поэтому имя приводим к латинице с коротким суффиксом.
335
+ async function uploadSiteAsset(siteId, { filePath, url }) {
336
+ if (!isAuthed()) throw new Error(NO_AUTH_HELP);
337
+ if (!siteId) throw new Error("Передай siteId.");
338
+ let bytes, name, mime;
339
+ if (filePath) {
340
+ const abs = path.resolve(String(filePath).replace(/^~(?=$|[/\\])/, os.homedir()));
341
+ try { bytes = fs.readFileSync(abs); } catch { throw new Error(`Файл не найден: ${abs}`); }
342
+ name = path.basename(abs);
343
+ mime = guessMime(name);
344
+ } else if (url) {
345
+ const r = await fetch(url);
346
+ if (!r.ok) throw new Error(`Не удалось скачать файл по url (HTTP ${r.status}).`);
347
+ bytes = Buffer.from(await r.arrayBuffer());
348
+ try { name = path.basename(new URL(url).pathname) || "file"; } catch { name = "file"; }
349
+ mime = r.headers.get("content-type") || guessMime(name);
350
+ } else {
351
+ throw new Error("Передай path (локальный файл) ИЛИ url.");
352
+ }
353
+ const ext = path.extname(name).toLowerCase().replace(/[^.a-z0-9]/g, "").slice(0, 9);
354
+ const base = path.basename(name, path.extname(name)).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "file";
355
+ const safe = `${base}-${Math.random().toString(36).slice(2, 6)}${ext}`;
356
+ const headers = {};
357
+ const token = getToken(); const cookie = getCookie();
358
+ if (token) headers.Authorization = `Bearer ${token}`;
359
+ else if (cookie) headers.Cookie = cookie;
360
+ const fd = new FormData();
361
+ fd.append("files", new Blob([bytes], { type: mime }), safe);
362
+ fd.append("dir", "assets");
363
+ const res = await fetch(`${BASE}/api/bots/pages/${siteId}/upload`, { method: "POST", headers, body: fd });
364
+ const text = await res.text();
365
+ let data = null; try { data = text ? JSON.parse(text) : null; } catch { data = text; }
366
+ if (!res.ok) {
367
+ if (res.status === 401 || res.status === 403) throw new Error(`Доступ отклонён (HTTP ${res.status}). Токен невалиден/отозван — создай новый на ${TOKENS_PAGE}.`);
368
+ if (res.status === 402) throw new Error("Лимит хранилища тарифа исчерпан (HTTP 402).");
369
+ throw httpError("POST", `/api/bots/pages/${siteId}/upload`, res.status, data);
370
+ }
371
+ return { asset: `assets/${safe}`, sizeBytes: bytes.length };
372
+ }
373
+
333
374
  const okResult = (obj) => ({ content: [{ type: "text", text: typeof obj === "string" ? obj : JSON.stringify(obj, null, 2) }] });
334
375
  const errResult = (e) => ({ isError: true, content: [{ type: "text", text: "❌ " + (e?.message || String(e)) }] });
335
376
 
@@ -369,13 +410,13 @@ const TOOLS = [
369
410
  { name: "setup", description: "Показать статус авторизации и пошаговую инструкцию подключения. Вызывай первым, если пользователь не знает, что делать, или при ошибке доступа.", inputSchema: { type: "object", properties: {} } },
370
411
  { name: "set_token", description: "Сохранить персональный токен (zmcp_...), который пользователь создал на /bots/mcp-tokens. Применяется сразу, без рестарта.", inputSchema: { type: "object", properties: { token: { type: "string", description: "Секрет токена, начинается с zmcp_" } }, required: ["token"] } },
371
412
  { name: "list_bots", description: "Список ботов пользователя (id, имя, статус).", inputSchema: { type: "object", properties: {} } },
372
- { name: "list_graphs", description: "Список графов (сценариев) бота.", inputSchema: { type: "object", properties: { botId: { type: "string" } }, required: ["botId"] } },
413
+ { name: "list_graphs", description: "Список сценариев САМОГО бота (без узлов). Вебхук-сценарии, которые лишь отвечают через этого бота, сюда не входят — их публикуют в вебе, в «Сценариях» автора.", inputSchema: { type: "object", properties: { botId: { type: "string" } }, required: ["botId"] } },
373
414
  { name: "list_channels", description: "Список каналов/групп, подключённых к боту (chatId, title, type, статус бота, дата). chatId — числовой id для условия SUBSCRIBED («Подписан на канал»).", inputSchema: { type: "object", properties: { botId: { type: "string" } }, required: ["botId"] } },
374
415
  { name: "list_integrations", description: "Список подключённых сервисов пользователя (GET /api/bots/integrations): {id, provider, title, hint, createdAt}. **id отсюда — это `connectionId`**, обязательное поле действий amocrm_send/amocrm_update/bitrix24_call/getcourse_send/getcourse_order/yametrika_event. Без него действие упадёт «не выбрано подключение». Креды не отдаются — только маскированный hint. Read-only.", inputSchema: { type: "object", properties: {} } },
375
416
  { name: "get_graph", description: "Получить граф по graphId. Для БОЛЬШИХ графов (десятки узлов JSON может превысить лимит токенов) используй summary:true (компактная сводка: id/type/title/позиции + рёбра) или saveToFile (записать полный граф на диск и вернуть сводку+путь — потом правь файл и заливай через update_graph/edit_graph_live с graphFile).", inputSchema: { type: "object", properties: { graphId: { type: "string" }, summary: { type: "boolean", description: "true = вернуть компактную сводку без объёмных text/cards/buttons" }, saveToFile: { type: "string", description: "Путь: записать полный граф (JSON) на диск, вернуть сводку + путь" } }, required: ["graphId"] } },
376
417
  { name: "create_graph", description: "Создать пустой граф (DRAFT) в боте. Возвращает граф с id.", inputSchema: { type: "object", properties: { botId: { type: "string" }, name: { type: "string" } }, required: ["botId", "name"] } },
377
- { name: "update_graph", description: "Залить узлы/рёбра в граф (PUT, сырой replace без бэкапа). Для правок СУЩЕСТВУЮЩЕГО/живого сценария используй edit_graph_live. Активный (PUBLISHED) граф сервер проверяет как публикацию: при ошибках HTTP 422 со всеми code@nodeId, граф НЕ сохранён. Черновик сохраняется без проверок. Принимает graphFile (путь к локальному файлу — НЕ нужно слать граф инлайном, удобно для больших графов), graph-контейнер или nodes/edges.", inputSchema: { type: "object", properties: { graphId: { type: "string" }, graphFile: { type: "string", description: "Путь к локальному JSON графа (контейнер retensy-bot-graph или {nodes,edges}); поддерживается ~" }, graph: { type: "object" }, nodes: { type: "array" }, edges: { type: "array" }, canvasMeta: { type: "object" }, name: { type: "string" } }, required: ["graphId"] } },
378
- { name: "edit_graph_live", description: "РЕКОМЕНДОВАННЫЙ способ правки СУЩЕСТВУЮЩЕГО (часто живого/опубликованного) сценария: редактирует ТОТ ЖЕ graphId НА МЕСТЕ (id не меняется) и сначала снимает авто-бэкап текущего состояния в один rolling-граф «🔙 Авто-бэкап». НЕ клонирует и НЕ создаёт новый активный граф. Открытые редакторы перечитают граф вживую (external_update), бот применит изменения сразу (читает активный граф заново из БД). Используй ВМЕСТО clone+publish, когда нужно поправить сценарий, который уже открыт/в проде. ВАЖНО: правку активного графа сервер проверяет как публикацию (валидатор, платные блоки, лимит блоков тарифа, платформа) — при ошибках HTTP 422 со всеми code@nodeId, граф НЕ изменён, бот работает на прежней версии. Прогоняй offline validate.mjs и dry_run заранее, чтобы не ловить 422. Живой граф бота — с isActive:true в list_graphs (после publish_graph черновика — publishedGraphId, не id черновика); правка черновика до бота не доходит.", inputSchema: { type: "object", properties: { graphId: { type: "string" }, graph: { type: "object" }, nodes: { type: "array" }, edges: { type: "array" }, canvasMeta: { type: "object" }, name: { type: "string" }, graphFile: { type: "string", description: "Путь к локальному JSON графа (вместо инлайн-передачи); поддерживается ~" }, backup: { type: "boolean", description: "Снимать авто-бэкап предыдущего состояния перед правкой (по умолчанию true)." } }, required: ["graphId"] } },
418
+ { name: "update_graph", description: "Залить узлы/рёбра в граф (PUT, сырой replace без бэкапа). Для правок СУЩЕСТВУЮЩЕГО/живого сценария используй edit_graph_live. Активный (PUBLISHED) граф сервер проверяет как публикацию: при ошибках HTTP 422 со всеми code@nodeId, граф НЕ сохранён. Черновик сохраняется без проверок публикации, кроме размера: граф больше 4 МБ → HTTP 422 GRAPH_TOO_LARGE, не сохранён. Принимает graphFile (путь к локальному файлу — НЕ нужно слать граф инлайном, удобно для больших графов), graph-контейнер или nodes/edges.", inputSchema: { type: "object", properties: { graphId: { type: "string" }, graphFile: { type: "string", description: "Путь к локальному JSON графа (контейнер retensy-bot-graph или {nodes,edges}); поддерживается ~" }, graph: { type: "object" }, nodes: { type: "array" }, edges: { type: "array" }, canvasMeta: { type: "object" }, name: { type: "string" } }, required: ["graphId"] } },
419
+ { name: "edit_graph_live", description: "РЕКОМЕНДОВАННЫЙ способ правки СУЩЕСТВУЮЩЕГО (часто живого/опубликованного) сценария: редактирует ТОТ ЖЕ graphId НА МЕСТЕ (id не меняется) и сначала снимает авто-бэкап текущего состояния в один rolling-граф «🔙 Авто-бэкап». НЕ клонирует и НЕ создаёт новый активный граф. Открытые редакторы перечитают граф вживую (external_update), бот применит изменения сразу (читает активный граф заново из БД). Используй ВМЕСТО clone+publish, когда нужно поправить сценарий, который уже открыт/в проде. ВАЖНО: правку активного графа сервер проверяет как публикацию (валидатор, платные блоки, лимит блоков тарифа, платформа) — при ошибках HTTP 422 со всеми code@nodeId, граф НЕ изменён, бот работает на прежней версии. Прогоняй offline validate.mjs и dry_run заранее, чтобы не ловить 422. Живой граф бота — с isActive:true в list_graphs — для бот-сценария (после publish_graph черновика — publishedGraphId, не id черновика); правка черновика до бота не доходит.", inputSchema: { type: "object", properties: { graphId: { type: "string" }, graph: { type: "object" }, nodes: { type: "array" }, edges: { type: "array" }, canvasMeta: { type: "object" }, name: { type: "string" }, graphFile: { type: "string", description: "Путь к локальному JSON графа (вместо инлайн-передачи); поддерживается ~" }, backup: { type: "boolean", description: "Снимать авто-бэкап предыдущего состояния перед правкой (по умолчанию true)." } }, required: ["graphId"] } },
379
420
  { name: "patch_graph", description: "Точечная правка БОЛЬШОГО/живого графа без отправки графа целиком: сервер сам берёт граф по graphId, делает строковые замены в его JSON, проверяет валидность и заливает обратно НА МЕСТЕ (с авто-бэкапом). Идеально, когда граф слишком велик, чтобы передавать его целиком через update_graph/edit_graph_live — напр. сменить id канала в условиях SUBSCRIBED, ссылки кнопок, тексты. replacements: [{find, replace}] — заменяются ВСЕ вхождения; делай find максимально специфичным, чтобы не задеть лишнее. preview=true — только показать число совпадений, ничего не сохраняя. Бот применит изменения сразу только у опубликованного графа (читает активный граф заново из БД); патч черновика до бота не доходит. Результат для активного графа сервер проверяет как публикацию: ошибки → HTTP 422 со всеми code@nodeId, граф не изменён.", inputSchema: { type: "object", properties: { graphId: { type: "string" }, replacements: { type: "array", items: { type: "object", properties: { find: { type: "string" }, replace: { type: "string" } }, required: ["find", "replace"] } }, preview: { type: "boolean", description: "true = только отчёт о числе совпадений, без сохранения" }, backup: { type: "boolean", description: "снять авто-бэкап предыдущего состояния перед правкой (по умолчанию true)" } }, required: ["graphId", "replacements"] } },
380
421
  { name: "dry_run", description: "Прогнать сценарий без публикации. kind: command|callback|text.", inputSchema: { type: "object", properties: { graphId: { type: "string" }, kind: { type: "string", enum: ["command", "callback", "text"] }, value: { type: "string" }, fromUsername: { type: "string" }, presetVariables: { type: "object" }, presetTags: { type: "array", items: { type: "string" } } }, required: ["graphId", "kind", "value"] } },
381
422
  { name: "publish_graph", description: "Опубликовать граф. Вернёт publishedGraphId; при отказе проверок — ошибка HTTP 422 со всеми причинами построчно (code@nodeId: message). Сценарий-вебхук (источник WEBHOOK) этим инструментом не публикуется — HTTP 409, его публикуют в вебе.", inputSchema: { type: "object", properties: { graphId: { type: "string" } }, required: ["graphId"] } },
@@ -393,6 +434,14 @@ const TOOLS = [
393
434
  { name: "graph_analytics", description: "Аналитика прохождения сценария по узлам (GET /api/bots/graphs/{graphId}/analytics): сколько пользователей дошло до каждого узла — видно, где отваливается воронка. Read-only.", inputSchema: { type: "object", properties: { graphId: { type: "string" } }, required: ["graphId"] } },
394
435
  { name: "list_bot_users", description: "Пользователи (подписчики/лиды) бота, постранично (GET /api/bots/{botId}/users). Опц. page (с 0), size (по умолч. 25), query (поиск по имени/username/id). Read-only.", inputSchema: { type: "object", properties: { botId: { type: "string" }, page: { type: "number" }, size: { type: "number" }, query: { type: "string" } }, required: ["botId"] } },
395
436
  { name: "list_links", description: "Стартовые (трекинговые) ссылки бота с UTM (GET /api/bots/{botId}/links): code, метки, число стартов. Это точки входа в воронку. Read-only.", inputSchema: { type: "object", properties: { botId: { type: "string" } }, required: ["botId"] } },
437
+ { name: "site_list", description: "Сайты пользователя (раздел «Страницы», GET /api/bots/pages): id, title, mode (BLOCKS — сайт из блоков, CODE — файлы/Mini App), url (основной адрес), publishedRevision. Read-only.", inputSchema: { type: "object", properties: {} } },
438
+ { name: "site_create", description: "Создать сайт из блоков (POST /api/bots/pages, mode=BLOCKS). Возвращает id. Дальше: site_edit (init=starter — стартовый лендинг, init=blank — пустая главная) → site_publish. slug — «название» в адресе pages.retensy.com/<id>/<slug>/ (необязательно, по умолчанию транслит title).", inputSchema: { type: "object", properties: { title: { type: "string" }, slug: { type: "string" } }, required: ["title"] } },
439
+ { name: "site_get", description: "Модель сайта из блоков (GET /api/bots/pages/{siteId}/document): revision, draft (SiteModel: theme, globals.header/footer, pages[].blocks[], popups[]) — id страниц/блоков/попапов нужны для site_edit. draft=null — сайт пуст (первый site_edit создаст его). saveToFile — записать модель на диск и вернуть путь.", inputSchema: { type: "object", properties: { siteId: { type: "string" }, saveToFile: { type: "string" } }, required: ["siteId"] } },
440
+ { name: "site_schema", description: "JSON Schema модели сайта (model) и операций правки (ops) — какие блоки и поля бывают (GET /api/bots/pages/schema). Читай перед первой правкой.", inputSchema: { type: "object", properties: {} } },
441
+ { name: "site_edit", description: "Правка сайта операциями — всё или ничего (POST /api/bots/pages/{siteId}/document/ops). ops: add_page{title} · update_page{pageId,patch} · remove_page{pageId} · move_page{pageId,delta} · add_block{container: id страницы|попапа, type, after?, variant?, props?, style?} · update_block{blockId, props?, style?, variant?} · move_block{blockId,delta} · duplicate_block{blockId} · remove_block{blockId} · set_global{slot: header|footer, on} · set_theme{theme} · set_settings{settings} · add_popup{name} · update_popup{popupId,name?,width?} · remove_popup{popupId}. props/style/theme — JSON Merge Patch (null удаляет ключ, массивы заменяются целиком). Типы блоков: header, cover, text, image, gallery, buttons, features, form, video, html, spacer, footer. Значения по экранам: {d, t?, m?} (десктоп/планшет/телефон). revision — защита от перезаписи (409, если сайт изменили); init (starter|blank) — с чего начать пустой сайт. Ответ: новая revision и results[] с id созданного. Ошибки — HTTP 422 с путями.", inputSchema: { type: "object", properties: { siteId: { type: "string" }, ops: { type: "array", items: { type: "object" } }, revision: { type: "number" }, init: { type: "string", enum: ["starter", "blank"] } }, required: ["siteId", "ops"] } },
442
+ { name: "site_publish", description: "Опубликовать черновик сайта (POST /api/bots/pages/{siteId}/publish): рендер в статику, адрес начинает отдавать новую версию. Ошибки проверки — HTTP 422 с путями. Возвращает publishedRevision и url.", inputSchema: { type: "object", properties: { siteId: { type: "string" } }, required: ["siteId"] } },
443
+ { name: "site_upload_asset", description: "Загрузить картинку/видео в сайт (POST /api/bots/pages/{siteId}/upload, папка assets). Передай path (локальный файл) ИЛИ url. Возвращает asset — строку вида assets/<имя> для полей image/logo/icon/style.bg.image.", inputSchema: { type: "object", properties: { siteId: { type: "string" }, path: { type: "string" }, url: { type: "string" } }, required: ["siteId"] } },
444
+ { name: "site_leads", description: "Заявки из форм сайта (GET /api/bots/pages/{siteId}/leads): поля, UTM, статус доставки. page (с 0), size (до 100). Read-only.", inputSchema: { type: "object", properties: { siteId: { type: "string" }, page: { type: "number" }, size: { type: "number" } }, required: ["siteId"] } },
396
445
  { name: "article_list", description: "Список СВОИХ статей блога retensy (GET /api/articles/my): id, slug, title, viewCount, даты. id нужен для article_update, slug — публичный адрес /articles/{slug}. Read-only.", inputSchema: { type: "object", properties: {} } },
397
446
  { name: "article_get", description: "Получить статью блога по slug (GET /api/articles/by-slug/{slug}) — публичное чтение, в т.ч. чужие. Возвращает title, content (Markdown), excerpt, coverImage, viewCount.", inputSchema: { type: "object", properties: { slug: { type: "string", description: "slug статьи (часть адреса /articles/{slug})" } }, required: ["slug"] } },
398
447
  { name: "article_publish", description: "Опубликовать НОВУЮ статью блога retensy (POST /api/articles). content — Markdown (как README на GitHub: заголовки, списки, таблицы, код, картинки по URL). title необязателен: если не передать, заголовком станет первая строка вида «# Заголовок», и она убирается из текста. Обложку можно задать явно через cover (URL картинки) — иначе берётся первая картинка из текста; excerpt (SEO-описание) тоже можно задать явно, иначе генерируется из текста. Возвращает статью с id и slug + публичный URL.", inputSchema: { type: "object", properties: { title: { type: "string", description: "Заголовок (необязателен, если content начинается с «# ...»)" }, content: { type: "string", description: "Тело статьи в Markdown" }, cover: { type: "string", description: "URL обложки (coverImage/OG). Если не задан — берётся первая картинка из текста." }, excerpt: { type: "string", description: "Краткое SEO-описание (≤160 симв). Если не задан — генерируется из текста." } }, required: ["content"] } },
@@ -474,7 +523,7 @@ async function handleCall(params) {
474
523
  // его копия (publishedGraphId). Иначе агент решит, что поправил бота, а правка легла в черновик.
475
524
  steps.push(saved?.status === "PUBLISHED"
476
525
  ? `правка применена НА МЕСТЕ к ${a.graphId} (id не изменился; редакторы и бот подхватят live)`
477
- : `сохранено в черновик ${a.graphId}: до бота НЕ доходит — живые правки делай по id опубликованного графа (isActive:true в list_graphs; после publish_graph черновика — publishedGraphId)`);
526
+ : `сохранено в черновик ${a.graphId}: до бота НЕ доходит — живые правки делай по id опубликованного графа (isActive:true в list_graphs — для бот-сценария; после publish_graph черновика — publishedGraphId)`);
478
527
  return okResult({ graphId: a.graphId, backupGraphId, inPlace: true, status: saved?.status ?? null, nodes: Array.isArray(saved?.nodes) ? saved.nodes.length : null, edges: Array.isArray(saved?.edges) ? saved.edges.length : null, steps });
479
528
  }
480
529
  case "patch_graph": {
@@ -594,6 +643,36 @@ async function handleCall(params) {
594
643
  return okResult(await api(`/api/bots/${a.botId}/users${qs.length ? `?${qs.join("&")}` : ""}`));
595
644
  }
596
645
  case "list_links": return okResult(await api(`/api/bots/${a.botId}/links`));
646
+ case "site_list": return okResult(await api("/api/bots/pages"));
647
+ case "site_create": {
648
+ if (!a.title) throw new Error("Передай title сайта.");
649
+ return okResult(await api("/api/bots/pages", { method: "POST", body: { title: a.title, slug: a.slug || undefined, mode: "BLOCKS" } }));
650
+ }
651
+ case "site_get": {
652
+ const doc = await api(`/api/bots/pages/${a.siteId}/document`);
653
+ if (a.saveToFile) {
654
+ const abs = path.resolve(String(a.saveToFile).replace(/^~(?=$|[/\\])/, os.homedir()));
655
+ fs.writeFileSync(abs, JSON.stringify(doc, null, 2));
656
+ return okResult({ revision: doc?.revision, publishedRevision: doc?.publishedRevision, savedTo: abs });
657
+ }
658
+ return okResult(doc);
659
+ }
660
+ case "site_schema": return okResult(await api("/api/bots/pages/schema"));
661
+ case "site_edit": {
662
+ if (!Array.isArray(a.ops) || !a.ops.length) throw new Error("Передай ops — массив операций (см. site_schema).");
663
+ return okResult(await api(`/api/bots/pages/${a.siteId}/document/ops`, { method: "POST", body: { ops: a.ops, revision: a.revision, init: a.init } }));
664
+ }
665
+ case "site_publish": {
666
+ const r = await api(`/api/bots/pages/${a.siteId}/publish`, { method: "POST" });
667
+ return okResult({ publishedRevision: r?.publishedRevision, url: r?.page?.url });
668
+ }
669
+ case "site_upload_asset": return okResult(await uploadSiteAsset(a.siteId, { filePath: a.path, url: a.url }));
670
+ case "site_leads": {
671
+ const qs = [];
672
+ if (a.page != null) qs.push(`page=${encodeURIComponent(a.page)}`);
673
+ if (a.size != null) qs.push(`size=${encodeURIComponent(a.size)}`);
674
+ return okResult(await api(`/api/bots/pages/${a.siteId}/leads${qs.length ? `?${qs.join("&")}` : ""}`));
675
+ }
597
676
  case "article_list": return okResult(await api("/api/articles/my"));
598
677
  case "article_get": return okResult(await api(`/api/articles/by-slug/${encodeURIComponent(a.slug)}`));
599
678
  case "article_publish": {