zaytsv-bot-graph-mcp 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  MCP-сервер (+ скилл для Claude Code) для **сборки и публикации воронок Telegram-ботов** в сервисе [zaytsv `/bots`](https://zaytsv.ru/bots): из текстового описания → валидный граф сценария → заливка и публикация через API.
9
9
 
10
- - 🤖 **8 инструментов**: `list_bots`, `list_graphs`, `get_graph`, `create_graph`, `update_graph`, `dry_run`, `publish_graph`, `import_funnel`.
10
+ - 🤖 **14 инструментов сборки/публикации**: `list_bots`, `list_graphs`, `get_graph`, `create_graph`, `update_graph`, `dry_run`, `publish_graph`, `import_funnel`, `list_templates`, `create_graph_from_template`, `clone_graph`, `rename_graph`, `set_active_graph`, `delete_graph` (+ `setup`/`set_token`).
11
11
  - 🧠 **Скилл `build-bot-funnel`**: учит агента собирать корректный граф (типы узлов, ветки, кнопки, задержки) и проверять его перед публикацией.
12
12
  - 📦 **Без зависимостей** — чистый Node ≥18, ставится и запускается сразу.
13
13
 
@@ -89,6 +89,12 @@ MCP-сервер (+ скилл для Claude Code) для **сборки и пу
89
89
  | `dry_run(graphId, kind, value)` | прогон без публикации |
90
90
  | `publish_graph(graphId)` | публикация (вернёт `errors[]` при провале) |
91
91
  | `import_funnel(botId, name, graph)` | всё за раз: create → update → dry-run → publish |
92
+ | `list_templates()` | готовые шаблоны воронок |
93
+ | `create_graph_from_template(botId, templateId, name)` | граф из шаблона (DRAFT) |
94
+ | `clone_graph(graphId)` | копия графа в новый DRAFT |
95
+ | `rename_graph(graphId, name)` | переименовать сценарий |
96
+ | `set_active_graph(botId, graphId)` | переключить активный (живой) граф бота |
97
+ | `delete_graph(graphId)` | удалить граф (активный — нельзя, 409) |
92
98
 
93
99
  ---
94
100
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zaytsv-bot-graph-mcp",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "MCP server to build and publish Telegram bot funnels in the zaytsv /bots service. Zero dependencies.",
5
5
  "type": "module",
6
6
  "bin": { "zaytsv-bot-graph-mcp": "src/index.mjs" },
@@ -20,8 +20,10 @@ description: Собрать воронку (сценарий) Telegram-бота
20
20
  - У каждого `SEND_MESSAGE` непустой `config.text` (и продублируй в `cards[0].text`).
21
21
  - id узлов и рёбер — валидные UUID, уникальные.
22
22
  - Есть хотя бы один корневой `TRIGGER_*`; все нелистовые узлы достижимы от триггера; синхронных циклов нет (цикл только через `ASK_QUESTION`/`DELAY`/`SCHEDULE`).
23
- - Кнопки-выборы разведены по хэндлам `btn_N`; условия `CONDITION` — `yes`/`no`; вопрос — `valid`/`invalid`.
24
- - `CONDITION` умеет: теги, переменные, UTM-метки, имя/email/телефон из профиля, @username, подписку на канал (`SUBSCRIBED`), дату/время/день недели. Полная таблица `kind`/`op` — в schema.md.
23
+ - Кнопки-выборы разведены по хэндлам `btn_N`; условия `CONDITION` — `yes`/`no`; вопрос (`ASK_QUESTION` или `SEND_MESSAGE` с `awaitReply:true`) — `valid`/`invalid`.
24
+ - `SEND_MESSAGE` умеет быть и сообщением, и **вопросом** (`awaitReply:true` + `saveTo`/`inputKind`/`validator`). У кнопок-ссылок (`kind:"URL"`) есть флаг `track:true` — на такой шаг ссылается условие `LINK_CLICKED`.
25
+ - `CONDITION` умеет: теги, переменные, UTM-метки, имя/email/телефон из профиля, @username, подписку на канал (`SUBSCRIBED`), клик по ссылке шага (`LINK_CLICKED`), дату/время/день недели. Полная таблица `kind`/`op` — в schema.md.
26
+ - Пакет `ACTIONS` — до 30 действий (метки, профиль, HTTP, уведомления, интеграции GetCourse/amoCRM/Google Sheets/Я.Метрика, модерация группы). Список — в schema.md.
25
27
 
26
28
  4. **Проверь локально** перед заливкой:
27
29
  ```bash
@@ -31,10 +33,11 @@ description: Собрать воронку (сценарий) Telegram-бота
31
33
 
32
34
  5. **Залей и опубликуй через MCP `bot-graph`** (если он подключён и пользователь просит публикацию):
33
35
  - `list_bots` → выбрать `botId` (или `create_graph` в существующем боте).
34
- - `create_graph(botId, name)` → получить `graphId`.
36
+ - `create_graph(botId, name)` → получить `graphId`. Либо стартуй с готовой основы: `list_templates` → `create_graph_from_template(botId, templateId, name)`.
35
37
  - `update_graph(graphId, nodes, edges, canvasMeta)` → залить узлы/рёбра.
36
38
  - `dry_run(graphId, kind:"command", value:"start")` → прогнать стартовую ветку, проверить `runStatus`.
37
39
  - `publish_graph(graphId)` → если вернулись `errors[]`, разобрать по `code`/`nodeId`, починить узлы, обновить, опубликовать снова.
40
+ - Управление сценариями: `clone_graph` (безопасно править поверх опубликованного), `rename_graph`, `set_active_graph` (переключить живой граф), `delete_graph` (активный нельзя — сначала переключи).
38
41
  Если MCP не подключён — отдай готовый `import.json` и подскажи: /bots → граф → **Импорт**.
39
42
 
40
43
  6. **Отчитайся**: сколько узлов/веток, какие тексты помечены на проверку, ссылка/ id графа.
@@ -40,15 +40,18 @@
40
40
  { "_title": "Заголовок узла", "parseMode": "PLAIN"|"HTML"|"MARKDOWN",
41
41
  "text": "Текст сообщения",
42
42
  "cards": [ { "id": "c1", "type": "text", "text": "Текст сообщения" } ],
43
- "buttons": [ [ { "text": "Кнопка", "kind": "CALLBACK"|"URL", "value": "data|url" } ] ] }
43
+ "buttons": [ [ { "text": "Кнопка", "kind": "CALLBACK"|"URL", "value": "data|url", "track": true } ] ] }
44
44
  ```
45
45
  ВСЕГДА заполняй и `text`, и `cards[0].text` одинаково. `buttons` — массив рядов (каждый ряд — массив кнопок).
46
- - `SEND_PHOTO` — `{ "photoUrl": "https://...", "text": "подпись (необязательно)" }`
46
+ - `parseMode:"HTML"` (дефолт редактора) текст должен быть **безопасным Telegram-HTML**: разрешены только `b,strong,i,em,u,ins,s,strike,del,code,pre,a[href],tg-spoiler,br`. Любой другой тег/атрибут → ошибка публикации `HTML_NOT_SAFE`. Не уверен — ставь `PLAIN`.
47
+ - **Кнопки-ссылки (`kind:"URL"`)** могут иметь `"track": true` — клики считаются, и на такой шаг можно сослаться из условия `LINK_CLICKED` (см. ниже). У `CALLBACK`-кнопок флаг не нужен.
48
+ - **Режим «Вопрос» (`awaitReply: true`)** — сообщение задаёт вопрос и ждёт ответ (паркуется как `ASK_QUESTION`). Доп. поля: `"saveTo":"name"` (обязателен, `[a-z_][a-z0-9_]{0,63}`), `"inputKind":"TEXT"|"PHOTO"|"DOCUMENT"|"CONTACT"|"LOCATION"`, `"validator":"ANY"|"PHONE"|"EMAIL"|"REGEX"`, `"regex":"..."`, `"retryText":"..."`, `"maxAttempts":3`. Выходы — `valid` / `invalid` (как у `ASK_QUESTION`), плюс `btn_N` для кнопок.
49
+ - `SEND_PHOTO` — `{ "photoUrl": "https://...", "caption": "подпись (необязательно, ≤1024)" }`
47
50
 
48
51
  ### Логика / ветвление
49
52
  - `CONDITION` — проверка условий, выходы `yes` / `no`. `{ "match":"ALL"|"ANY", "conditions":[ { "kind":"...", "op":"...", "key":"...", "value":"..." } ] }`. `match:"ALL"` — все условия истинны; `"ANY"` — хотя бы одно. Полный список `kind`/`op`/полей — в разделе [«Условия CONDITION»](#условия-condition).
50
53
  - `BRANCH` — `{ "cases":[ {"id":"c1","label":"...","expression":"var.x=='a'"} ], "hasDefault": false, "abTest": false }`. Выходы: `case_<id>` (+ `default`).
51
- - `ASK_QUESTION` — вопрос со сбором ответа. `{ "promptText":"...","saveTo":"name","validator":"ANY"|"PHONE"|"EMAIL"|"REGEX","regex":"...","retryText":"...","maxAttempts":3 }`. Выходы `valid` / `invalid`.
54
+ - `ASK_QUESTION` — вопрос со сбором ответа. `{ "promptText":"...","saveTo":"name","inputKind":"TEXT"|"PHOTO"|"DOCUMENT"|"CONTACT"|"LOCATION","validator":"ANY"|"PHONE"|"EMAIL"|"REGEX","regex":"...","retryText":"...","maxAttempts":3 }`. `inputKind` (по умолчанию `TEXT`) — что ждём в ответ (`CONTACT` → телефон, `LOCATION` → `lat,lon`, `PHOTO`/`DOCUMENT` → file_id). Выходы `valid` / `invalid`.
52
55
  - `END` — `{}` (конец ветки).
53
56
 
54
57
  ### Тайминги
@@ -58,8 +61,15 @@
58
61
  - `SCHEDULE` — `{ "isoDate":"2026-06-25", "time":"18:00", "timezone":"Europe/Moscow" }`. Выходы `scheduled` / `past`.
59
62
 
60
63
  ### Состояние / действия
61
- - `SET_VARIABLE`, `ADD_TAG`, `REMOVE_TAG`, `FORMULA` (`{ "expression":"...", "saveTo":"name" }`)
62
- - `ACTIONS` — пакет действий: `{ "actions":[ { "kind":"add_tag","tag":"lead" }, { "kind":"notify","text":"..." }, { "kind":"set_field","key":"phone","value":"{{var.phone}}" }, { "kind":"external_request","url":"...","method":"POST","body":"..." } ] }`
64
+ - `SET_VARIABLE` (`{ "key":"name", "value":"..." }`), `ADD_TAG`/`REMOVE_TAG` (`{ "tag":"lead" }`), `FORMULA` (`{ "expression":"...", "saveTo":"name" }`)
65
+ - `ACTIONS` — непустой пакет действий `{ "actions":[ { "kind":"...", ...поля } ] }`. Допустимые `kind` (иначе ошибка `ACTION_UNKNOWN_KIND`):
66
+ - **метки/автоворонки**: `add_tag`, `remove_tag`, `autoflow_add`, `autoflow_remove` — поле `tag` (`[a-z0-9_-]{1,64}`)
67
+ - **профиль**: `set_field` — `key` (`[a-z_][a-z0-9_]{0,63}`) + `value`; `subscribe`, `unsubscribe`
68
+ - **HTTP**: `external_request`, `subscriber_webhook` — `url` (http/https) + `method`/`headersJson`/`bodyTemplate`
69
+ - **уведомления**: `notify` (`text`), `subscriber_email` (`email`,`text`), `agent_chat`
70
+ - **бот/шаг**: `stop_bot`, `delete_step_message`, `cancel_payment_subscription`
71
+ - **интеграции**: `getcourse_send`, `getcourse_order`, `amocrm_send`, `amocrm_update`, `yametrika_event`, `gsheets_send`, `gsheets_get`, `gsheets_update`, `gsheets_write_cell`, `gsheets_read_cell`
72
+ - **модерация группы**: `group_unban`, `group_kick`, `group_approve`, `group_decline`
63
73
 
64
74
  ### Внешнее / прочее
65
75
  - `CALL_WEBHOOK` — `{ "url":"https://...", "method":"POST", "bodyTemplate":"{...}", "timeoutMs":5000 }`. Выходы `ok` / `error`.
@@ -79,6 +89,7 @@
79
89
  | `PHONE` | `EQUALS`, `CONTAINS`, `NOT_EMPTY`, `EMPTY` | `value` | телефон из профиля |
80
90
  | `USERNAME` | `EQUALS`, `CONTAINS` | `value` | @username пользователя Telegram |
81
91
  | `SUBSCRIBED` | `SUBSCRIBED`, `NOT_SUBSCRIBED` | `key` — **числовой** id канала/группы (напр. `-1001234567890`) | подписан ли пользователь на канал бота |
92
+ | `LINK_CLICKED` | `CLICKED`, `NOT_CLICKED` | `key` — **`id` узла-шага** с отслеживаемой URL-кнопкой (тот же UUID, что у `SEND_MESSAGE`) | кликал ли пользователь по ссылке этого шага |
82
93
  | `CURRENT_DATE` | `BEFORE`, `AFTER`, `EQUALS` | `value` — дата `YYYY-MM-DD` | сегодняшнюю дату (МСК) |
83
94
  | `CURRENT_TIME` | `BETWEEN` | `value`, `value2` — время `HH:mm` | текущее время в интервале (через полночь — если `value`>`value2`) |
84
95
  | `DAY_OF_WEEK` | `IN` | `days` — массив из `MON`,`TUE`,`WED`,`THU`,`FRI`,`SAT`,`SUN` | день недели (МСК) |
@@ -87,14 +98,19 @@
87
98
 
88
99
  **`SUBSCRIBED`** работает только если бот **админ** в канале/группе и канал «привязан» (бот узнаёт о членстве через хук `my_chat_member` — добавь бота в канал админом). `key` должен парситься в число, иначе условие = `false`. Профильные поля (`NAME`/`EMAIL`/`PHONE`) и UTM заполняются по ходу воронки (`ASK_QUESTION`→`saveTo`, диплинк-клик с UTM).
89
100
 
101
+ **`LINK_CLICKED`** проверяет факт клика по URL-кнопке конкретного шага. Чтобы условие работало: у нужного `SEND_MESSAGE` хотя бы одна кнопка `kind:"URL"` с `"track": true`, а в условии `key` = `id` этого узла-шага. Клик фиксируется через публичный редирект бота, поэтому условие имеет смысл ставить **после** `DELAY`/`ASK_QUESTION` (дай пользователю время кликнуть).
102
+
103
+ > **Платформа MAX.** Боты конструктора умеют работать и в мессенджере MAX. Там не поддерживаются `SUBSCRIBED`/`NOT_SUBSCRIBED` (нет членства в каналах) и reply-клавиатуры; публикация такого графа на MAX-бот вернёт **мягкие предупреждения** (не блокирует). Для Telegram-ботов всё работает как описано.
104
+
90
105
  ## Выходные хэндлы (`sourceHandle`) — шпаргалка
91
106
  | Узел | Хэндлы |
92
107
  |---|---|
93
108
  | обычный поток | `next` |
94
- | кнопки сообщения (`buttons`) | `btn_0`, `btn_1`, … (по индексу кнопки) |
109
+ | кнопки сообщения (`buttons`) | `btn_0`, `btn_1`, … (по индексу кнопки, плоско по всем рядам) |
95
110
  | `CONDITION` | `yes`, `no` |
96
111
  | `BRANCH` | `case_<id>`, `default` |
97
112
  | `ASK_QUESTION` | `valid`, `invalid` |
113
+ | `SEND_MESSAGE` с `awaitReply:true` | `valid`, `invalid` (+ `btn_N` для кнопок) |
98
114
  | `CALL_WEBHOOK` | `ok`, `error` |
99
115
  | `SCHEDULE` | `scheduled`, `past` |
100
116
  | `DELAY` | `next` |
@@ -13,8 +13,12 @@
13
13
  - Пробелы/таб/перенос строки = пусто (`.isBlank()`).
14
14
 
15
15
  ### Длина текста
16
- - `text` ≤ 4096; если есть `photoUrl` (подпись) — ≤ 1024.
17
- - При `parseMode:"HTML"` — текст должен быть безопасным HTML (без неразрешённых тегов). Если не уверен — `PLAIN`.
16
+ - `text` ≤ 4096; если есть `photoUrl` (подпись) — ≤ 1024 (`SEND_TOO_LONG`).
17
+ - При `parseMode:"HTML"` — текст должен быть безопасным Telegram-HTML, иначе `HTML_NOT_SAFE`. Разрешены только `b,strong,i,em,u,ins,s,strike,del,code,pre,a[href],tg-spoiler,br`. Если не уверен — `PLAIN`.
18
+
19
+ ### `SEND_MESSAGE` в режиме «Вопрос» (`awaitReply:true`)
20
+ - `saveTo` обязателен и матчит `[a-z_][a-z0-9_]{0,63}` (`SEND_BAD_SAVE_TO`).
21
+ - При `validator:"REGEX"` — `regex` непустой и компилируется (`SEND_BAD_REGEX`).
18
22
 
19
23
  ### Триггеры и достижимость
20
24
  - Нужен **хотя бы один корневой `TRIGGER_*`** без входящих рёбер.
@@ -28,11 +32,17 @@
28
32
  - `TRIGGER_COMMAND`: `command` обязателен.
29
33
  - `TRIGGER_CALLBACK`: `value` обязателен; `matchMode` ∈ {EQUALS, STARTS_WITH}.
30
34
  - `TRIGGER_TEXT`: `matchMode` ∈ {ANY,EQUALS,CONTAINS,REGEX}; для не-ANY нужен `value`.
31
- - `ASK_QUESTION`: `promptText` обязателен; `saveTo` ∈ `[a-z_][a-z0-9_]{0,63}`.
32
- - `BRANCH`: ≥1 case; выражения валидны (или `abTest:true`).
33
- - `CONDITION`: `match` ∈ {ALL, ANY} (`CONDITION_BAD_MATCH`); ≥1 элемент в `conditions` (`CONDITION_EMPTY`). Для `kind:"TAG"` — `value` ∈ `[a-z0-9_-]{1,64}` (`CONDITION_BAD_TAG`); для `kind:"VARIABLE"` — `key` ∈ `[a-z_][a-z0-9_]{0,63}` (`CONDITION_BAD_KEY`). Остальные `kind` (UTM/NAME/EMAIL/PHONE/USERNAME/SUBSCRIBED/CURRENT_*/DAY_OF_WEEK) бэкенд проверяет в рантайме — некорректный `kind`/`op` или нечисловой `key` у `SUBSCRIBED` молча дают `false` (выход `no`), публикацию не блокируют. Список `kind`/`op` — в schema.md.
34
- - `CALL_WEBHOOK`: `url` обязателен, http(s)://.
35
- - `DELAY`: `FIXED` с `durationSec>0`, либо `UNTIL` с `isoDate`+`time`.
35
+ - `ASK_QUESTION`: `promptText` обязателен (`ASK_NO_PROMPT`); `saveTo` ∈ `[a-z_][a-z0-9_]{0,63}` (`ASK_BAD_SAVE_TO`); при `validator:"REGEX"` — валидный `regex` (`ASK_BAD_REGEX`).
36
+ - `BRANCH`: ≥1 case (`BRANCH_NO_CASES`); вне `abTest` — непустое валидное `expression` (`BRANCH_EMPTY_EXPR`/`BRANCH_BAD_EXPR`) и подключённое ребро `case_<id>` (`BRANCH_CASE_UNCONNECTED`; `disabled:true` — если ветка намеренно пустая). В `abTest:true` рёбра нужны только у первых двух case.
37
+ - `CONDITION`: `match` ∈ {ALL, ANY} (`CONDITION_BAD_MATCH`); ≥1 элемент в `conditions` (`CONDITION_EMPTY`). Для `kind:"TAG"` — `value` ∈ `[a-z0-9_-]{1,64}` (`CONDITION_BAD_TAG`); для `kind:"VARIABLE"` — `key` ∈ `[a-z_][a-z0-9_]{0,63}` (`CONDITION_BAD_KEY`). Остальные `kind` (UTM/NAME/EMAIL/PHONE/USERNAME/SUBSCRIBED/LINK_CLICKED/CURRENT_*/DAY_OF_WEEK) бэкенд проверяет в рантайме — некорректный `kind`/`op`, нечисловой `key` у `SUBSCRIBED` или `key` у `LINK_CLICKED` без отслеживаемой кнопки молча дают `false` (выход `no`), публикацию не блокируют. Список `kind`/`op` — в schema.md.
38
+ - `CALL_WEBHOOK`: `url` обязателен (`WEBHOOK_NO_URL`), http(s):// (`WEBHOOK_BAD_SCHEME`).
39
+ - `DELAY`: `kind` ∈ {FIXED,UNTIL} (`DELAY_BAD_KIND`); `FIXED` `durationSec>0` (`DELAY_BAD_DURATION`).
40
+ - `SET_VARIABLE`: `key` ∈ `[a-z_][a-z0-9_]{0,63}` (`VAR_BAD_KEY`). `ADD_TAG`/`REMOVE_TAG`: `tag` ∈ `[a-z0-9_-]{1,64}` (`TAG_BAD_NAME`).
41
+ - `FORMULA`: `expression` непустой (`FORMULA_NO_EXPRESSION`); `saveTo` ∈ var-формат (`FORMULA_BAD_SAVE_TO`).
42
+ - `SCHEDULE`: `isoDate` = валидная `YYYY-MM-DD` (`SCHEDULE_BAD_DATE`); `time` = `HH:mm` (`SCHEDULE_BAD_TIME`).
43
+ - `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`).
44
+ - `AI_REPLY`: `userPromptTemplate` непустой (`AI_NO_PROMPT`); `temperature` ∈ [0.0, 2.0] (`AI_BAD_TEMPERATURE`); нужен `sendToUser:true` ИЛИ `saveTo` (`AI_NO_OUTPUT`).
45
+ - `PAYMENT_LINK`: `paymentUrl` обязателен (`PAY_NO_URL`), http(s):// или `{{var.x}}` (`PAY_BAD_SCHEME`).
36
46
  - Метки: `[a-z0-9_-]{1,64}`. Переменные: `[a-z_][a-z0-9_]{0,63}`.
37
47
 
38
48
  ## Рёбра
@@ -45,4 +55,4 @@
45
55
  - Перед публикацией полезно прогнать `dry_run` (kind `command`/`callback`/`text`) — поймать рантайм-проблемы стартовой ветки.
46
56
 
47
57
  ## Локальная проверка
48
- `node validate.mjs <import.json>` повторяет ключевые проверки (пустые сообщения с учётом `cardsToLegacy`, висячие рёбра, дубли id, достижимость от триггеров, длину текста, базовый конфиг DELAY/триггеров). Гонять перед каждой заливкой.
58
+ `node validate.mjs <import.json>` повторяет ключевые проверки: пустые сообщения с учётом `cardsToLegacy`, висячие рёбра, дубли id, достижимость от триггеров, длину текста, HTML-безопасность (эвристика по тегам), режим «Вопрос» (`awaitReply`→`saveTo`/`regex`), конфиг `DELAY`/`SCHEDULE`/`FORMULA`/`ACTIONS`/`AI_REPLY`/`PAYMENT_LINK`/триггеров, условия `CONDITION` (вкл. `LINK_CLICKED` со ссылкой на отслеживаемый шаг). Гонять перед каждой заливкой.
@@ -31,11 +31,31 @@ const COND_OPS = {
31
31
  PHONE: ["EQUALS", "CONTAINS", "NOT_EMPTY", "EMPTY"],
32
32
  USERNAME: ["EQUALS", "CONTAINS"],
33
33
  SUBSCRIBED: ["SUBSCRIBED", "NOT_SUBSCRIBED"],
34
+ LINK_CLICKED: ["CLICKED", "NOT_CLICKED"],
34
35
  CURRENT_DATE: ["BEFORE", "AFTER", "EQUALS"],
35
36
  CURRENT_TIME: ["BETWEEN"],
36
37
  DAY_OF_WEEK: ["IN"],
37
38
  };
38
39
 
40
+ // ACTIONS: допустимые kind (зеркало GraphValidator.KNOWN_ACTION_KINDS)
41
+ const ACTION_KINDS = new Set([
42
+ "add_tag", "remove_tag", "set_field", "stop_bot", "delete_step_message",
43
+ "subscribe", "unsubscribe", "autoflow_add", "autoflow_remove",
44
+ "subscriber_webhook", "external_request", "notify",
45
+ "subscriber_email", "agent_chat", "cancel_payment_subscription",
46
+ "getcourse_send", "getcourse_order", "amocrm_send", "amocrm_update",
47
+ "yametrika_event", "gsheets_send", "gsheets_get", "gsheets_update",
48
+ "gsheets_write_cell", "gsheets_read_cell",
49
+ "group_unban", "group_kick", "group_approve", "group_decline",
50
+ ]);
51
+
52
+ // Telegram-safe HTML — разрешённые теги (эвристика; бэкенд использует jsoup-clean)
53
+ const HTML_OK_TAGS = new Set([
54
+ "b", "strong", "i", "em", "u", "ins", "s", "strike", "del",
55
+ "code", "pre", "a", "tg-spoiler", "br",
56
+ ]);
57
+ const isHttp = (u) => /^https?:\/\//.test(String(u || ""));
58
+
39
59
  // cardsToLegacy: текст = первая text-карточка с непустым text (или image.url -> photoUrl)
40
60
  function cardsToLegacy(cards) {
41
61
  cards = Array.isArray(cards) ? cards : [];
@@ -84,11 +104,26 @@ for (const n of nodes) {
84
104
  else if (blank(lg.text) && blank(lg.photoUrl)) errors.push(`SEND_NO_TEXT: ${who} — после cardsToLegacy текст пуст (нет text-карточки с содержимым).`);
85
105
  const t = String(c.text || "");
86
106
  const lim = !blank(c.photoUrl) ? 1024 : 4096;
87
- if (t.length > lim) errors.push(`Текст ${who} = ${t.length} > ${lim}.`);
107
+ if (t.length > lim) errors.push(`SEND_TOO_LONG: ${who} = ${t.length} > ${lim}.`);
108
+ // HTML-безопасность (эвристика по тегам)
109
+ if (!blank(t) && String(c.parseMode || "").toUpperCase() === "HTML") {
110
+ const bad = [...t.matchAll(/<\/?([a-zA-Z][a-zA-Z0-9-]*)\b[^>]*>/g)]
111
+ .map((m) => m[1].toLowerCase()).filter((tag) => !HTML_OK_TAGS.has(tag));
112
+ if (bad.length) errors.push(`HTML_NOT_SAFE: ${who} — неразрешённые теги: ${[...new Set(bad)].join(", ")}. Разрешено: ${[...HTML_OK_TAGS].join(",")}.`);
113
+ }
114
+ // режим «Вопрос»
115
+ if (c.awaitReply === true) {
116
+ if (!c.saveTo || !VAR_RE.test(c.saveTo)) errors.push(`SEND_BAD_SAVE_TO: ${who} — awaitReply требует saveTo ∈ [a-z_][a-z0-9_]{0,63}.`);
117
+ if (c.validator === "REGEX") {
118
+ if (blank(c.regex)) errors.push(`SEND_BAD_REGEX: ${who} — REGEX-валидатор требует непустой regex.`);
119
+ else { try { new RegExp(c.regex); } catch (e) { errors.push(`SEND_BAD_REGEX: ${who} — ${e.message}.`); } }
120
+ }
121
+ }
88
122
  break;
89
123
  }
90
124
  case "SEND_PHOTO":
91
- if (blank(c.photoUrl)) errors.push(`${who}: нужен photoUrl.`);
125
+ if (blank(c.photoUrl)) errors.push(`PHOTO_NO_URL: ${who} нужен photoUrl.`);
126
+ if (!blank(c.caption) && String(c.caption).length > 1024) errors.push(`PHOTO_CAPTION_TOO_LONG: ${who} — подпись > 1024.`);
92
127
  break;
93
128
  case "TRIGGER_COMMAND":
94
129
  if (blank(c.command)) errors.push(`${who}: нужен command.`);
@@ -132,9 +167,57 @@ for (const n of nodes) {
132
167
  else if (cond.op && !COND_OPS[kind].includes(cond.op)) warns.push(`${who}: op «${cond.op}» не из {${COND_OPS[kind].join(",")}} для ${kind} — рантайм даст false.`);
133
168
  if (kind === "SUBSCRIBED" && !/^-?\d+$/.test(String(cond.key || "").trim())) warns.push(`${who}: SUBSCRIBED.key должен быть числовым id канала — иначе false (и бот должен быть админом канала).`);
134
169
  if (kind === "UTM" && !cond.key) warns.push(`${who}: UTM без key — рантайм даст false (ожидается source/medium/campaign/content/term).`);
170
+ if (kind === "LINK_CLICKED") {
171
+ const target = nodes.find((x) => x.id === cond.key);
172
+ const tracked = target && (((target.config || {}).buttons) || []).flat().some((b) => b && b.kind === "URL" && b.track === true);
173
+ if (!cond.key || !target) warns.push(`${who}: LINK_CLICKED.key должен быть id шага с отслеживаемой URL-кнопкой — иначе false.`);
174
+ else if (!tracked) warns.push(`${who}: у шага «${(target.config || {})._title || target.id}» из LINK_CLICKED.key нет URL-кнопки с track:true — рантайм даст false.`);
175
+ }
176
+ });
177
+ break;
178
+ }
179
+ case "SET_VARIABLE":
180
+ if (!VAR_RE.test(String(c.key || ""))) errors.push(`VAR_BAD_KEY: ${who} — key ∈ [a-z_][a-z0-9_]{0,63}.`);
181
+ break;
182
+ case "ADD_TAG":
183
+ case "REMOVE_TAG":
184
+ if (!TAG_RE.test(String(c.tag || ""))) errors.push(`TAG_BAD_NAME: ${who} — tag ∈ [a-z0-9_-]{1,64}.`);
185
+ break;
186
+ case "FORMULA":
187
+ if (blank(c.expression)) errors.push(`FORMULA_NO_EXPRESSION: ${who} — нужен expression.`);
188
+ if (!VAR_RE.test(String(c.saveTo || ""))) errors.push(`FORMULA_BAD_SAVE_TO: ${who} — saveTo ∈ [a-z_][a-z0-9_]{0,63}.`);
189
+ break;
190
+ case "SCHEDULE": {
191
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(String(c.isoDate || "")) || isNaN(Date.parse(c.isoDate))) errors.push(`SCHEDULE_BAD_DATE: ${who} — isoDate = YYYY-MM-DD.`);
192
+ if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(String(c.time || ""))) errors.push(`SCHEDULE_BAD_TIME: ${who} — time = HH:mm.`);
193
+ break;
194
+ }
195
+ case "ACTIONS": {
196
+ if (!Array.isArray(c.actions) || c.actions.length === 0) { errors.push(`ACTIONS_EMPTY: ${who} — нужен непустой actions[].`); break; }
197
+ c.actions.forEach((a, i) => {
198
+ if (!a || typeof a !== "object") { errors.push(`${who}: действие #${i + 1} — не объект.`); return; }
199
+ if (!ACTION_KINDS.has(a.kind)) { errors.push(`ACTION_UNKNOWN_KIND: ${who} — неизвестный kind «${a.kind}» (#${i + 1}).`); return; }
200
+ if (["add_tag", "remove_tag", "autoflow_add", "autoflow_remove"].includes(a.kind) && !TAG_RE.test(String(a.tag || "")))
201
+ errors.push(`ACTION_BAD_TAG: ${who} — ${a.kind}.tag ∈ [a-z0-9_-]{1,64}.`);
202
+ if (a.kind === "set_field" && !VAR_RE.test(String(a.key || "")))
203
+ errors.push(`ACTION_BAD_KEY: ${who} — set_field.key ∈ [a-z_][a-z0-9_]{0,63}.`);
204
+ if (["subscriber_webhook", "external_request"].includes(a.kind) && !isHttp(a.url))
205
+ errors.push(`ACTION_BAD_URL: ${who} — ${a.kind}.url должен быть http(s)://.`);
135
206
  });
136
207
  break;
137
208
  }
209
+ case "AI_REPLY": {
210
+ if (blank(c.userPromptTemplate)) errors.push(`AI_NO_PROMPT: ${who} — нужен userPromptTemplate.`);
211
+ if (typeof c.temperature === "number" && (c.temperature < 0 || c.temperature > 2)) errors.push(`AI_BAD_TEMPERATURE: ${who} — temperature ∈ [0.0, 2.0].`);
212
+ if (c.sendToUser !== true && blank(c.saveTo)) errors.push(`AI_NO_OUTPUT: ${who} — нужен sendToUser:true или saveTo.`);
213
+ break;
214
+ }
215
+ case "PAYMENT_LINK": {
216
+ const u = String(c.paymentUrl || "");
217
+ if (blank(u)) errors.push(`PAY_NO_URL: ${who} — нужен paymentUrl.`);
218
+ else if (!isHttp(u) && !u.startsWith("{{")) errors.push(`PAY_BAD_SCHEME: ${who} — paymentUrl = http(s):// или {{var.x}}.`);
219
+ break;
220
+ }
138
221
  }
139
222
  }
140
223
 
package/src/index.mjs CHANGED
@@ -21,7 +21,7 @@ import os from "node:os";
21
21
  import fs from "node:fs";
22
22
  import path from "node:path";
23
23
 
24
- const VERSION = "0.3.0";
24
+ const VERSION = "0.4.0";
25
25
  const BASE = (process.env.ZAYTSV_BASE_URL || "https://zaytsv.ru").replace(/\/+$/, "");
26
26
  const CONFIG_DIR = path.join(os.homedir(), ".zaytsv-bot-graph");
27
27
  const TOKEN_FILE = path.join(CONFIG_DIR, "token");
@@ -104,6 +104,12 @@ const TOOLS = [
104
104
  { 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"] } },
105
105
  { name: "publish_graph", description: "Опубликовать граф. Вернёт publishedGraphId или errors[] (code, nodeId, message).", inputSchema: { type: "object", properties: { graphId: { type: "string" } }, required: ["graphId"] } },
106
106
  { name: "import_funnel", description: "Всё за раз: создать граф, залить узлы/рёбра, (опц.) dry-run /start, опубликовать.", inputSchema: { type: "object", properties: { botId: { type: "string" }, name: { type: "string" }, graph: { type: "object" }, dryRun: { type: "boolean" }, publish: { type: "boolean" } }, required: ["botId", "graph"] } },
107
+ { name: "list_templates", description: "Список готовых шаблонов воронок (id, имя, описание). Можно стартовать граф из шаблона вместо сборки с нуля.", inputSchema: { type: "object", properties: {} } },
108
+ { name: "create_graph_from_template", description: "Создать граф (DRAFT) из шаблона (см. list_templates). Возвращает граф с id — дальше правь через update_graph.", inputSchema: { type: "object", properties: { botId: { type: "string" }, templateId: { type: "string" }, name: { type: "string" } }, required: ["botId", "templateId"] } },
109
+ { name: "rename_graph", description: "Переименовать сценарий (работает и для опубликованных — имя не влияет на исполнение).", inputSchema: { type: "object", properties: { graphId: { type: "string" }, name: { type: "string" } }, required: ["graphId", "name"] } },
110
+ { name: "clone_graph", description: "Склонировать граф в новый DRAFT «… (copy)» — безопасно итерировать поверх опубликованного.", inputSchema: { type: "object", properties: { graphId: { type: "string" } }, required: ["graphId"] } },
111
+ { name: "delete_graph", description: "Удалить граф. Активный (опубликованный и назначенный боту) удалить нельзя — будет 409; сначала переключи активный через set_active_graph.", inputSchema: { type: "object", properties: { graphId: { type: "string" } }, required: ["graphId"] } },
112
+ { name: "set_active_graph", description: "Назначить, какой опубликованный граф активен у бота (переключение живого сценария без перепубликации).", inputSchema: { type: "object", properties: { botId: { type: "string" }, graphId: { type: "string" } }, required: ["botId", "graphId"] } },
107
113
  ];
108
114
 
109
115
  async function handleCall(params) {
@@ -166,6 +172,19 @@ async function handleCall(params) {
166
172
  }
167
173
  return okResult({ graphId, steps });
168
174
  }
175
+ case "list_templates": return okResult(await api("/api/tg/graph-templates"));
176
+ case "create_graph_from_template":
177
+ return okResult(await api(`/api/tg/bots/${a.botId}/graphs/from-template`, { method: "POST", body: { templateId: a.templateId, name: a.name } }));
178
+ case "rename_graph":
179
+ return okResult(await api(`/api/tg/graphs/${a.graphId}/rename`, { method: "PATCH", body: { name: a.name } }));
180
+ case "clone_graph":
181
+ return okResult(await api(`/api/tg/graphs/${a.graphId}/clone`, { method: "POST" }));
182
+ case "delete_graph":
183
+ await api(`/api/tg/graphs/${a.graphId}`, { method: "DELETE" });
184
+ return okResult(`🗑️ Граф ${a.graphId} удалён.`);
185
+ case "set_active_graph":
186
+ await api(`/api/tg/bots/${a.botId}/active-graph`, { method: "POST", body: { graphId: a.graphId } });
187
+ return okResult(`✅ Активный граф бота ${a.botId} → ${a.graphId}.`);
169
188
  default:
170
189
  throw new Error(`Неизвестный инструмент: ${params && params.name}`);
171
190
  }