zaytsv-bot-graph-mcp 0.3.0 → 0.4.2

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": "zaytsv-bot-graph",
3
3
  "displayName": "Zaytsv Bot Graph",
4
- "version": "0.2.0",
4
+ "version": "0.4.0",
5
5
  "description": "MCP-сервер + скилл для сборки и публикации воронок Telegram-ботов в сервисе zaytsv /bots: из текстового описания → валидный граф → заливка и публикация по API.",
6
6
  "author": { "name": "zaytsv", "url": "https://zaytsv.ru" },
7
7
  "homepage": "https://zaytsv.ru/bots",
package/.mcp.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "args": ["${CLAUDE_PLUGIN_ROOT}/src/index.mjs"],
6
6
  "env": {
7
7
  "ZAYTSV_BASE_URL": "https://zaytsv.ru",
8
- "ZAYTSV_MCP_TOKEN": "${ZAYTSV_MCP_TOKEN}"
8
+ "ZAYTSV_MCP_TOKEN": "${ZAYTSV_MCP_TOKEN:-}"
9
9
  }
10
10
  }
11
11
  }
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
+ - 🤖 **15 инструментов сборки/публикации**: `list_bots`, `list_graphs`, `list_channels`, `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
 
@@ -83,12 +83,19 @@ MCP-сервер (+ скилл для Claude Code) для **сборки и пу
83
83
  | `set_token` | сохранить присланный токен `zmcp_…` (без env/рестарта) |
84
84
  | `list_bots` | список ботов |
85
85
  | `list_graphs(botId)` | графы (сценарии) бота |
86
+ | `list_channels(botId)` | каналы/группы, подключённые к боту (chatId для условия SUBSCRIBED) |
86
87
  | `get_graph(graphId)` | получить граф |
87
88
  | `create_graph(botId, name)` | создать пустой граф (DRAFT) |
88
89
  | `update_graph(graphId, graph\|nodes,edges)` | залить узлы/рёбра (PUT) |
89
90
  | `dry_run(graphId, kind, value)` | прогон без публикации |
90
91
  | `publish_graph(graphId)` | публикация (вернёт `errors[]` при провале) |
91
92
  | `import_funnel(botId, name, graph)` | всё за раз: create → update → dry-run → publish |
93
+ | `list_templates()` | готовые шаблоны воронок |
94
+ | `create_graph_from_template(botId, templateId, name)` | граф из шаблона (DRAFT) |
95
+ | `clone_graph(graphId)` | копия графа в новый DRAFT |
96
+ | `rename_graph(graphId, name)` | переименовать сценарий |
97
+ | `set_active_graph(botId, graphId)` | переключить активный (живой) граф бота |
98
+ | `delete_graph(graphId)` | удалить граф (активный — нельзя, 409) |
92
99
 
93
100
  ---
94
101
 
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.2",
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" },
@@ -14,7 +14,7 @@
14
14
  },
15
15
  "engines": { "node": ">=18" },
16
16
  "keywords": ["mcp", "telegram", "bot", "funnel", "zaytsv", "claude-code", "model-context-protocol"],
17
- "repository": { "type": "git", "url": "https://github.com/skiddgoddamn/zaytsv-bot-graph-mcp.git" },
17
+ "repository": { "type": "git", "url": "git+https://github.com/skiddgoddamn/zaytsv-bot-graph-mcp.git" },
18
18
  "homepage": "https://zaytsv.ru/bots",
19
19
  "license": "MIT"
20
20
  }
@@ -20,21 +20,25 @@ 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` у такой `CALLBACK`-кнопки `value` ОБЯЗАТЕЛЬНО пустой (`""`): бот сам сгенерит `callback_data`, непустой `value` ломает переход (`NO_MATCH`). Цвет кнопки — только `""`/`#34C759`/`#FF3B30`. Условия `CONDITION` — `yes`/`no`; вопрос (`ASK_QUESTION` или `SEND_MESSAGE` с `awaitReply:true`) — `valid`/`invalid`.
24
+ - **Не добавляй узлы `END`** ветка завершается сама на узле без исходящих рёбер; явный «конец сценария» убран из редактора.
25
+ - `SEND_MESSAGE` умеет быть и сообщением, и **вопросом** (`awaitReply:true` + `saveTo`/`inputKind`/`validator`). У кнопок-ссылок (`kind:"URL"`) есть флаг `track:true` — на такой шаг ссылается условие `LINK_CLICKED`.
26
+ - `CONDITION` умеет: теги, переменные, UTM-метки, имя/email/телефон из профиля, @username, подписку на канал (`SUBSCRIBED`), клик по ссылке шага (`LINK_CLICKED`), дату/время/день недели. Полная таблица `kind`/`op` — в schema.md.
27
+ - Пакет `ACTIONS` — до 30 действий (метки, профиль, HTTP, уведомления, интеграции GetCourse/amoCRM/Google Sheets/Я.Метрика, модерация группы). Список — в schema.md.
25
28
 
26
29
  4. **Проверь локально** перед заливкой:
27
30
  ```bash
28
31
  node "<путь к скиллу>/validate.mjs" <путь к import.json>
29
32
  ```
30
- Скрипт ловит пустые сообщения, висячие рёбра, дубли id, недостижимые узлы, превышение 4096, кривые DELAY. Исправь всё, что он покажет.
33
+ Скрипт ловит пустые сообщения, висячие рёбра, дубли и не-UUID id узлов/рёбер, недостижимые узлы, превышение 4096, кривые DELAY (вкл. `duration`+`unit`), непустой `value` у кнопок-выборов с ребром `btn_N`, неподдерживаемые цвета. Исправь всё, что он покажет.
31
34
 
32
35
  5. **Залей и опубликуй через MCP `bot-graph`** (если он подключён и пользователь просит публикацию):
33
36
  - `list_bots` → выбрать `botId` (или `create_graph` в существующем боте).
34
- - `create_graph(botId, name)` → получить `graphId`.
37
+ - `create_graph(botId, name)` → получить `graphId`. Либо стартуй с готовой основы: `list_templates` → `create_graph_from_template(botId, templateId, name)`.
35
38
  - `update_graph(graphId, nodes, edges, canvasMeta)` → залить узлы/рёбра.
36
39
  - `dry_run(graphId, kind:"command", value:"start")` → прогнать стартовую ветку, проверить `runStatus`.
37
40
  - `publish_graph(graphId)` → если вернулись `errors[]`, разобрать по `code`/`nodeId`, починить узлы, обновить, опубликовать снова.
41
+ - Управление сценариями: `clone_graph` (безопасно править поверх опубликованного), `rename_graph`, `set_active_graph` (переключить живой граф), `delete_graph` (активный нельзя — сначала переключи).
38
42
  Если MCP не подключён — отдай готовый `import.json` и подскажи: /bots → граф → **Импорт**.
39
43
 
40
44
  6. **Отчитайся**: сколько узлов/веток, какие тексты помечены на проверку, ссылка/ id графа.
@@ -22,9 +22,10 @@
22
22
 
23
23
  ## Ребро (TgEdge) — «стрелка»
24
24
  ```json
25
- { "id": "<uuid|строка>", "sourceNodeId": "<uuid>", "sourceHandle": "next", "targetNodeId": "<uuid>" }
25
+ { "id": "<uuid>", "sourceNodeId": "<uuid>", "sourceHandle": "next", "targetNodeId": "<uuid>" }
26
26
  ```
27
27
  `sourceHandle` — какой выход узла используется (см. ниже).
28
+ - `id`, `sourceNodeId`, `targetNodeId` — **валидные UUID** (бэкенд десериализует их как `java.util.UUID`; короткая строка вроде `"m1"` → HTTP 400 при заливке). `id` уникален среди рёбер.
28
29
 
29
30
  ## Типы узлов (NodeType) и их config
30
31
 
@@ -40,26 +41,39 @@
40
41
  { "_title": "Заголовок узла", "parseMode": "PLAIN"|"HTML"|"MARKDOWN",
41
42
  "text": "Текст сообщения",
42
43
  "cards": [ { "id": "c1", "type": "text", "text": "Текст сообщения" } ],
43
- "buttons": [ [ { "text": "Кнопка", "kind": "CALLBACK"|"URL", "value": "data|url" } ] ] }
44
+ "buttons": [ [ { "text": "Кнопка", "kind": "CALLBACK"|"URL", "value": "<url для URL; ПУСТО для CALLBACK>", "color": "", "track": true } ] ] }
44
45
  ```
45
46
  ВСЕГДА заполняй и `text`, и `cards[0].text` одинаково. `buttons` — массив рядов (каждый ряд — массив кнопок).
46
- - `SEND_PHOTO` — `{ "photoUrl": "https://...", "text": "подпись (необязательно)" }`
47
+ - `parseMode:"HTML"` (дефолт редактора) текст должен быть **безопасным Telegram-HTML**: разрешены только `b,strong,i,em,u,ins,s,strike,del,code,pre,a[href],tg-spoiler,br`. Любой другой тег/атрибут → ошибка публикации `HTML_NOT_SAFE`. Не уверен — ставь `PLAIN`.
48
+ - **Кнопки-выборы (`kind:"CALLBACK"`)**: `value` ОСТАВЛЯЙ ПУСТЫМ (`""`). Бот сам сгенерит `callback_data` вида `n:<id узла>:<индекс>`, а переход задаётся ребром `btn_N` от кнопки. **Непустой `value`** трактуется как legacy-`callback_data` для отдельного узла `TRIGGER_CALLBACK` (у такой кнопки ребра `btn_N` быть не должно) — если поставить `value` обычной кнопке-выбору, переход по `btn_N` **сломается** (нажатие → `NO_MATCH`).
49
+ - **Кнопки-ссылки (`kind:"URL"`)**: `value` = URL. Могут иметь `"track": true` — клики считаются, и на такой шаг можно сослаться из условия `LINK_CLICKED` (см. ниже).
50
+ - **`color`** (опционально, и у CALLBACK, и у URL) — только стили, которые рендерит Telegram (как в основном боте / pengrad `ButtonStyle`): `""`=по умолчанию, `"#2EA6FF"`=primary (синий), `"#34C759"`=success (зелёный), `"#FF3B30"`=danger (красный). Других цветов нет.
51
+ - **Режим «Вопрос» (`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` для кнопок.
52
+ - `SEND_PHOTO` — `{ "photoUrl": "https://...", "caption": "подпись (необязательно, ≤1024)" }`
47
53
 
48
54
  ### Логика / ветвление
49
55
  - `CONDITION` — проверка условий, выходы `yes` / `no`. `{ "match":"ALL"|"ANY", "conditions":[ { "kind":"...", "op":"...", "key":"...", "value":"..." } ] }`. `match:"ALL"` — все условия истинны; `"ANY"` — хотя бы одно. Полный список `kind`/`op`/полей — в разделе [«Условия CONDITION»](#условия-condition).
50
56
  - `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`.
52
- - `END` — `{}` (конец ветки).
57
+ - `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`.
58
+ - `END` — `{}` (конец ветки). **Не добавляй `END`**: ветка и так завершается на узле без исходящих рёбер; явный «конец сценария» бесполезен и убран из палитры редактора. Тип оставлен лишь для совместимости со старыми графами.
53
59
 
54
60
  ### Тайминги
55
- - `DELAY` — пауза:
56
- - фиксированная: `{ "kind":"FIXED", "durationSec": 86400 }`
57
- - до даты/времени: `{ "kind":"UNTIL", "isoDate":"2026-06-25", "time":"18:00" }`
61
+ - `DELAY` — пауза. Три вида (`kind`):
62
+ - **`FIXED`** («Отправить через»): `{ "kind":"FIXED", "durationSec": 86400 }` — `durationSec` в **секундах** (60 = 1 мин, 3600 = 1 час, 86400 = 1 сутки). Редактор также пишет `{ "kind":"FIXED", "duration": 24, "unit":"MINUTES"|"HOURS"|"DAYS" }` (минуты/часы/дни — чтобы не вбивать большие числа). Для генерации проще `durationSec`.
63
+ - **`TOMORROW`** («Отправить завтра»): `{ "kind":"TOMORROW", "time":"18:00" }` — завтра в указанное время `HH:mm` (МСК), относительно момента, когда пользователь дошёл до узла.
64
+ - **`UNTIL`** («Отправить в»): `{ "kind":"UNTIL", "isoTimestamp":"2026-06-25T15:00:00Z" }` — конкретный момент в ISO-8601 (UTC). ⚠️ Рантайм читает только `isoTimestamp`; пары `isoDate`+`time` НЕ работают.
58
65
  - `SCHEDULE` — `{ "isoDate":"2026-06-25", "time":"18:00", "timezone":"Europe/Moscow" }`. Выходы `scheduled` / `past`.
59
66
 
60
67
  ### Состояние / действия
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":"..." } ] }`
68
+ - `SET_VARIABLE` (`{ "key":"name", "value":"..." }`), `ADD_TAG`/`REMOVE_TAG` (`{ "tag":"lead" }`), `FORMULA` (`{ "expression":"...", "saveTo":"name" }`)
69
+ - `ACTIONS` — непустой пакет действий `{ "actions":[ { "kind":"...", ...поля } ] }`. Допустимые `kind` (иначе ошибка `ACTION_UNKNOWN_KIND`):
70
+ - **метки/автоворонки**: `add_tag`, `remove_tag`, `autoflow_add`, `autoflow_remove` — поле `tag` (`[a-z0-9_-]{1,64}`)
71
+ - **профиль**: `set_field` — `key` (`[a-z_][a-z0-9_]{0,63}`) + `value`; `subscribe`, `unsubscribe`
72
+ - **HTTP**: `external_request`, `subscriber_webhook` — `url` (http/https) + `method`/`headersJson`/`bodyTemplate`
73
+ - **уведомления**: `notify` (`text`), `subscriber_email` (`email`,`text`), `agent_chat`
74
+ - **бот/шаг**: `stop_bot`, `delete_step_message`, `cancel_payment_subscription`
75
+ - **интеграции**: `getcourse_send`, `getcourse_order`, `amocrm_send`, `amocrm_update`, `yametrika_event`, `gsheets_send`, `gsheets_get`, `gsheets_update`, `gsheets_write_cell`, `gsheets_read_cell`
76
+ - **модерация группы**: `group_unban`, `group_kick`, `group_approve`, `group_decline`
63
77
 
64
78
  ### Внешнее / прочее
65
79
  - `CALL_WEBHOOK` — `{ "url":"https://...", "method":"POST", "bodyTemplate":"{...}", "timeoutMs":5000 }`. Выходы `ok` / `error`.
@@ -78,7 +92,8 @@
78
92
  | `EMAIL` | `EQUALS`, `CONTAINS`, `NOT_EMPTY`, `EMPTY` | `value` | email из профиля |
79
93
  | `PHONE` | `EQUALS`, `CONTAINS`, `NOT_EMPTY`, `EMPTY` | `value` | телефон из профиля |
80
94
  | `USERNAME` | `EQUALS`, `CONTAINS` | `value` | @username пользователя Telegram |
81
- | `SUBSCRIBED` | `SUBSCRIBED`, `NOT_SUBSCRIBED` | `key` — **числовой** id канала/группы (напр. `-1001234567890`) | подписан ли пользователь на канал бота |
95
+ | `SUBSCRIBED` | `SUBSCRIBED`, `NOT_SUBSCRIBED` | `key` — **числовой** id канала/группы (напр. `-1001234567890`); узнать числовой id подключённых каналов: `list_channels(botId)` | подписан ли пользователь на канал бота |
96
+ | `LINK_CLICKED` | `CLICKED`, `NOT_CLICKED` | `key` — **`id` узла-шага** с отслеживаемой URL-кнопкой (тот же UUID, что у `SEND_MESSAGE`) | кликал ли пользователь по ссылке этого шага |
82
97
  | `CURRENT_DATE` | `BEFORE`, `AFTER`, `EQUALS` | `value` — дата `YYYY-MM-DD` | сегодняшнюю дату (МСК) |
83
98
  | `CURRENT_TIME` | `BETWEEN` | `value`, `value2` — время `HH:mm` | текущее время в интервале (через полночь — если `value`>`value2`) |
84
99
  | `DAY_OF_WEEK` | `IN` | `days` — массив из `MON`,`TUE`,`WED`,`THU`,`FRI`,`SAT`,`SUN` | день недели (МСК) |
@@ -87,14 +102,19 @@
87
102
 
88
103
  **`SUBSCRIBED`** работает только если бот **админ** в канале/группе и канал «привязан» (бот узнаёт о членстве через хук `my_chat_member` — добавь бота в канал админом). `key` должен парситься в число, иначе условие = `false`. Профильные поля (`NAME`/`EMAIL`/`PHONE`) и UTM заполняются по ходу воронки (`ASK_QUESTION`→`saveTo`, диплинк-клик с UTM).
89
104
 
105
+ **`LINK_CLICKED`** проверяет факт клика по URL-кнопке конкретного шага. Чтобы условие работало: у нужного `SEND_MESSAGE` хотя бы одна кнопка `kind:"URL"` с `"track": true`, а в условии `key` = `id` этого узла-шага. Клик фиксируется через публичный редирект бота, поэтому условие имеет смысл ставить **после** `DELAY`/`ASK_QUESTION` (дай пользователю время кликнуть).
106
+
107
+ > **Платформа MAX.** Боты конструктора умеют работать и в мессенджере MAX. Там не поддерживаются `SUBSCRIBED`/`NOT_SUBSCRIBED` (нет членства в каналах) и reply-клавиатуры; публикация такого графа на MAX-бот вернёт **мягкие предупреждения** (не блокирует). Для Telegram-ботов всё работает как описано.
108
+
90
109
  ## Выходные хэндлы (`sourceHandle`) — шпаргалка
91
110
  | Узел | Хэндлы |
92
111
  |---|---|
93
112
  | обычный поток | `next` |
94
- | кнопки сообщения (`buttons`) | `btn_0`, `btn_1`, … (по индексу кнопки) |
113
+ | кнопки сообщения (`buttons`) | `btn_0`, `btn_1`, … (по индексу кнопки, плоско по всем рядам) |
95
114
  | `CONDITION` | `yes`, `no` |
96
115
  | `BRANCH` | `case_<id>`, `default` |
97
116
  | `ASK_QUESTION` | `valid`, `invalid` |
117
+ | `SEND_MESSAGE` с `awaitReply:true` | `valid`, `invalid` (+ `btn_N` для кнопок) |
98
118
  | `CALL_WEBHOOK` | `ok`, `error` |
99
119
  | `SCHEDULE` | `scheduled`, `past` |
100
120
  | `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,21 +32,28 @@
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,TOMORROW} (`DELAY_BAD_KIND`). `FIXED` `durationSec>0` (секунды) ИЛИ `duration>0` (+`unit` ∈ {MINUTES,HOURS,DAYS}), иначе `DELAY_BAD_DURATION`. `TOMORROW` — `time` = `HH:mm` (`DELAY_BAD_TIME`). `UNTIL` `isoTimestamp` (ISO-8601 UTC; рантайм читает только его, не `isoDate`+`time`).
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
  ## Рёбра
39
49
  - `sourceNodeId` и `targetNodeId` должны указывать на существующие узлы (нет «висячих»).
40
50
  - `sourceHandle` должен соответствовать типу узла-источника (см. schema.md).
41
- - id узлов и рёбер уникальны; id узлов валидные UUID (Jackson десериализует `UUID`).
51
+ - **Кнопка-выбор ↔ ребро `btn_N`**: у `CALLBACK`-кнопки, от которой идёт ребро `btn_N`, `value` ДОЛЖЕН быть пустым (`""`). Тогда бот рендерит навигационный `callback_data` `n:<id узла>:<индекс>` и маршрутизирует по `btn_N`. Непустой `value` рендерится как есть, не матчит навигационный формат → нажатие уходит в `NO_MATCH` (кнопка «не работает»). Непустой `value` уместен только для legacy-кнопки под отдельный узел `TRIGGER_CALLBACK` (без ребра `btn_N`).
52
+ - id узлов и рёбер — уникальны и **оба должны быть валидными UUID**: Jackson десериализует и `TgNode.id`, и `TgEdge.id`/`sourceNodeId`/`targetNodeId` как `java.util.UUID`. Короткая строка (`"m1"`, `"e1"`) → весь PUT падает с HTTP 400 (generic, без тела).
42
53
 
43
54
  ## Жизненный цикл
44
55
  - Статусы графа: `DRAFT` / `PUBLISHED`. Публикация заменяет активную опубликованную версию.
45
56
  - Перед публикацией полезно прогнать `dry_run` (kind `command`/`callback`/`text`) — поймать рантайм-проблемы стартовой ветки.
46
57
 
47
58
  ## Локальная проверка
48
- `node validate.mjs <import.json>` повторяет ключевые проверки (пустые сообщения с учётом `cardsToLegacy`, висячие рёбра, дубли id, достижимость от триггеров, длину текста, базовый конфиг DELAY/триггеров). Гонять перед каждой заливкой.
59
+ `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 : [];
@@ -57,12 +77,13 @@ for (const n of nodes) {
57
77
  if (!n || !n.id) { errors.push(`Узел без id: ${JSON.stringify(n).slice(0, 80)}`); continue; }
58
78
  if (ids.has(n.id)) errors.push(`Дубль id узла: ${n.id}`);
59
79
  ids.add(n.id);
60
- if (!UUID_RE.test(n.id)) warns.push(`id узла не UUID (бэкенд десериализует UUID): ${n.id} «${n.config?._title || n.type}»`);
80
+ if (!UUID_RE.test(n.id)) errors.push(`id узла не UUID бэкенд десериализует UUID, PUT даст HTTP 400: ${n.id} «${n.config?._title || n.type}»`);
61
81
  }
62
82
  const edgeIds = new Set();
63
83
  for (const e of edges) {
64
84
  if (e.id && edgeIds.has(e.id)) errors.push(`Дубль id ребра: ${e.id}`);
65
85
  if (e.id) edgeIds.add(e.id);
86
+ if (!e.id || !UUID_RE.test(e.id)) errors.push(`id ребра не UUID — бэкенд десериализует UUID, PUT даст HTTP 400: ${e.id}`);
66
87
  if (!ids.has(e.sourceNodeId)) errors.push(`Висячее ребро ${e.id}: нет sourceNodeId ${e.sourceNodeId}`);
67
88
  if (!ids.has(e.targetNodeId)) errors.push(`Висячее ребро ${e.id}: нет targetNodeId ${e.targetNodeId}`);
68
89
  }
@@ -84,11 +105,48 @@ for (const n of nodes) {
84
105
  else if (blank(lg.text) && blank(lg.photoUrl)) errors.push(`SEND_NO_TEXT: ${who} — после cardsToLegacy текст пуст (нет text-карточки с содержимым).`);
85
106
  const t = String(c.text || "");
86
107
  const lim = !blank(c.photoUrl) ? 1024 : 4096;
87
- if (t.length > lim) errors.push(`Текст ${who} = ${t.length} > ${lim}.`);
108
+ if (t.length > lim) errors.push(`SEND_TOO_LONG: ${who} = ${t.length} > ${lim}.`);
109
+ // HTML-безопасность (эвристика по тегам)
110
+ if (!blank(t) && String(c.parseMode || "").toUpperCase() === "HTML") {
111
+ const bad = [...t.matchAll(/<\/?([a-zA-Z][a-zA-Z0-9-]*)\b[^>]*>/g)]
112
+ .map((m) => m[1].toLowerCase()).filter((tag) => !HTML_OK_TAGS.has(tag));
113
+ if (bad.length) errors.push(`HTML_NOT_SAFE: ${who} — неразрешённые теги: ${[...new Set(bad)].join(", ")}. Разрешено: ${[...HTML_OK_TAGS].join(",")}.`);
114
+ }
115
+ // режим «Вопрос»
116
+ if (c.awaitReply === true) {
117
+ if (!c.saveTo || !VAR_RE.test(c.saveTo)) errors.push(`SEND_BAD_SAVE_TO: ${who} — awaitReply требует saveTo ∈ [a-z_][a-z0-9_]{0,63}.`);
118
+ if (c.validator === "REGEX") {
119
+ if (blank(c.regex)) errors.push(`SEND_BAD_REGEX: ${who} — REGEX-валидатор требует непустой regex.`);
120
+ else { try { new RegExp(c.regex); } catch (e) { errors.push(`SEND_BAD_REGEX: ${who} — ${e.message}.`); } }
121
+ }
122
+ }
123
+ // Кнопки-выборы: если от кнопки идёт ребро btn_N, value ДОЛЖЕН быть пустым —
124
+ // бот сам генерит callback_data n:<id>:<idx>; непустой value → нажатие даёт NO_MATCH.
125
+ {
126
+ const btnIdx = new Set(
127
+ edges.filter((e) => e.sourceNodeId === n.id && /^btn_\d+$/.test(String(e.sourceHandle || "")))
128
+ .map((e) => Number(String(e.sourceHandle).slice(4)))
129
+ );
130
+ (Array.isArray(c.buttons) ? c.buttons : []).flat().forEach((b, i) => {
131
+ if (!b) return;
132
+ if (String(b.kind) === "CALLBACK") {
133
+ const hasVal = !blank(b.value);
134
+ if (hasVal && btnIdx.has(i)) errors.push(`BTN_VALUE_WITH_EDGE: ${who} — кнопка-выбор #${i} («${b.text || ""}») имеет ребро btn_${i} и непустой value «${b.value}»; оставь value пустым, иначе нажатие даст NO_MATCH.`);
135
+ else if (hasVal) warns.push(`${who}: у CALLBACK-кнопки #${i} непустой value «${b.value}» без ребра btn_${i} — это legacy-кнопка под TRIGGER_CALLBACK; для кнопки-выбора нужен пустой value + ребро btn_${i}.`);
136
+ }
137
+ if (b.color) {
138
+ const cc = String(b.color).trim();
139
+ const okColor = ["", "primary", "success", "danger"].includes(cc.toLowerCase())
140
+ || ["#2EA6FF", "#34C759", "#FF3B30"].includes(cc.toUpperCase());
141
+ if (!okColor) warns.push(`${who}: стиль кнопки #${i} «${b.color}» Telegram не рендерит — только ""(дефолт)/primary(#2EA6FF)/success(#34C759)/danger(#FF3B30).`);
142
+ }
143
+ });
144
+ }
88
145
  break;
89
146
  }
90
147
  case "SEND_PHOTO":
91
- if (blank(c.photoUrl)) errors.push(`${who}: нужен photoUrl.`);
148
+ if (blank(c.photoUrl)) errors.push(`PHOTO_NO_URL: ${who} нужен photoUrl.`);
149
+ if (!blank(c.caption) && String(c.caption).length > 1024) errors.push(`PHOTO_CAPTION_TOO_LONG: ${who} — подпись > 1024.`);
92
150
  break;
93
151
  case "TRIGGER_COMMAND":
94
152
  if (blank(c.command)) errors.push(`${who}: нужен command.`);
@@ -108,9 +166,10 @@ for (const n of nodes) {
108
166
  if (blank(c.url) || !/^https?:\/\//.test(c.url)) errors.push(`${who}: url обязателен и http(s)://.`);
109
167
  break;
110
168
  case "DELAY":
111
- if (c.kind === "FIXED") { if (!(Number(c.durationSec) > 0)) errors.push(`${who}: FIXED требует durationSec>0.`); }
112
- else if (c.kind === "UNTIL") { if (blank(c.isoDate) || blank(c.time)) errors.push(`${who}: UNTIL требует isoDate и time.`); }
113
- else errors.push(`${who}: kind {FIXED,UNTIL}.`);
169
+ if (c.kind === "FIXED") { if (!(Number(c.durationSec) > 0) && !(Number(c.duration) > 0)) errors.push(`${who}: FIXED требует durationSec>0 (или duration>0 + unit MINUTES|HOURS|DAYS).`); }
170
+ else if (c.kind === "TOMORROW") { if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(String(c.time || ""))) errors.push(`${who}: TOMORROW требует time = HH:mm.`); }
171
+ else if (c.kind === "UNTIL") { if (blank(c.isoTimestamp)) errors.push(`${who}: UNTIL требует isoTimestamp (ISO-8601 UTC). isoDate+time рантайм НЕ читает.`); }
172
+ else errors.push(`${who}: kind ∈ {FIXED,TOMORROW,UNTIL}.`);
114
173
  break;
115
174
  case "BRANCH":
116
175
  if (!Array.isArray(c.cases) || c.cases.length === 0) errors.push(`${who}: нужен хотя бы один case.`);
@@ -132,9 +191,57 @@ for (const n of nodes) {
132
191
  else if (cond.op && !COND_OPS[kind].includes(cond.op)) warns.push(`${who}: op «${cond.op}» не из {${COND_OPS[kind].join(",")}} для ${kind} — рантайм даст false.`);
133
192
  if (kind === "SUBSCRIBED" && !/^-?\d+$/.test(String(cond.key || "").trim())) warns.push(`${who}: SUBSCRIBED.key должен быть числовым id канала — иначе false (и бот должен быть админом канала).`);
134
193
  if (kind === "UTM" && !cond.key) warns.push(`${who}: UTM без key — рантайм даст false (ожидается source/medium/campaign/content/term).`);
194
+ if (kind === "LINK_CLICKED") {
195
+ const target = nodes.find((x) => x.id === cond.key);
196
+ const tracked = target && (((target.config || {}).buttons) || []).flat().some((b) => b && b.kind === "URL" && b.track === true);
197
+ if (!cond.key || !target) warns.push(`${who}: LINK_CLICKED.key должен быть id шага с отслеживаемой URL-кнопкой — иначе false.`);
198
+ else if (!tracked) warns.push(`${who}: у шага «${(target.config || {})._title || target.id}» из LINK_CLICKED.key нет URL-кнопки с track:true — рантайм даст false.`);
199
+ }
135
200
  });
136
201
  break;
137
202
  }
203
+ case "SET_VARIABLE":
204
+ if (!VAR_RE.test(String(c.key || ""))) errors.push(`VAR_BAD_KEY: ${who} — key ∈ [a-z_][a-z0-9_]{0,63}.`);
205
+ break;
206
+ case "ADD_TAG":
207
+ case "REMOVE_TAG":
208
+ if (!TAG_RE.test(String(c.tag || ""))) errors.push(`TAG_BAD_NAME: ${who} — tag ∈ [a-z0-9_-]{1,64}.`);
209
+ break;
210
+ case "FORMULA":
211
+ if (blank(c.expression)) errors.push(`FORMULA_NO_EXPRESSION: ${who} — нужен expression.`);
212
+ if (!VAR_RE.test(String(c.saveTo || ""))) errors.push(`FORMULA_BAD_SAVE_TO: ${who} — saveTo ∈ [a-z_][a-z0-9_]{0,63}.`);
213
+ break;
214
+ case "SCHEDULE": {
215
+ 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.`);
216
+ if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(String(c.time || ""))) errors.push(`SCHEDULE_BAD_TIME: ${who} — time = HH:mm.`);
217
+ break;
218
+ }
219
+ case "ACTIONS": {
220
+ if (!Array.isArray(c.actions) || c.actions.length === 0) { errors.push(`ACTIONS_EMPTY: ${who} — нужен непустой actions[].`); break; }
221
+ c.actions.forEach((a, i) => {
222
+ if (!a || typeof a !== "object") { errors.push(`${who}: действие #${i + 1} — не объект.`); return; }
223
+ if (!ACTION_KINDS.has(a.kind)) { errors.push(`ACTION_UNKNOWN_KIND: ${who} — неизвестный kind «${a.kind}» (#${i + 1}).`); return; }
224
+ if (["add_tag", "remove_tag", "autoflow_add", "autoflow_remove"].includes(a.kind) && !TAG_RE.test(String(a.tag || "")))
225
+ errors.push(`ACTION_BAD_TAG: ${who} — ${a.kind}.tag ∈ [a-z0-9_-]{1,64}.`);
226
+ if (a.kind === "set_field" && !VAR_RE.test(String(a.key || "")))
227
+ errors.push(`ACTION_BAD_KEY: ${who} — set_field.key ∈ [a-z_][a-z0-9_]{0,63}.`);
228
+ if (["subscriber_webhook", "external_request"].includes(a.kind) && !isHttp(a.url))
229
+ errors.push(`ACTION_BAD_URL: ${who} — ${a.kind}.url должен быть http(s)://.`);
230
+ });
231
+ break;
232
+ }
233
+ case "AI_REPLY": {
234
+ if (blank(c.userPromptTemplate)) errors.push(`AI_NO_PROMPT: ${who} — нужен userPromptTemplate.`);
235
+ if (typeof c.temperature === "number" && (c.temperature < 0 || c.temperature > 2)) errors.push(`AI_BAD_TEMPERATURE: ${who} — temperature ∈ [0.0, 2.0].`);
236
+ if (c.sendToUser !== true && blank(c.saveTo)) errors.push(`AI_NO_OUTPUT: ${who} — нужен sendToUser:true или saveTo.`);
237
+ break;
238
+ }
239
+ case "PAYMENT_LINK": {
240
+ const u = String(c.paymentUrl || "");
241
+ if (blank(u)) errors.push(`PAY_NO_URL: ${who} — нужен paymentUrl.`);
242
+ else if (!isHttp(u) && !u.startsWith("{{")) errors.push(`PAY_BAD_SCHEME: ${who} — paymentUrl = http(s):// или {{var.x}}.`);
243
+ break;
244
+ }
138
245
  }
139
246
  }
140
247
 
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.2";
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");
@@ -31,7 +31,11 @@ function readFileToken() {
31
31
  try { return fs.readFileSync(TOKEN_FILE, "utf8").trim(); } catch { return ""; }
32
32
  }
33
33
  function getToken() {
34
- return (process.env.ZAYTSV_MCP_TOKEN || "").trim() || readFileToken();
34
+ // Если переменная не задана, Claude Code отдаёт шаблон "${ZAYTSV_MCP_TOKEN}" литералом
35
+ // такой env нельзя считать токеном, иначе он перекрывает файл из set_token (вечный 401).
36
+ const env = (process.env.ZAYTSV_MCP_TOKEN || "").trim();
37
+ if (env && !env.startsWith("${")) return env;
38
+ return readFileToken();
35
39
  }
36
40
  function getCookie() {
37
41
  return process.env.ZAYTSV_COOKIE ||
@@ -98,12 +102,19 @@ const TOOLS = [
98
102
  { name: "set_token", description: "Сохранить персональный токен (zmcp_...), который пользователь создал на /bots/mcp-tokens. Применяется сразу, без рестарта.", inputSchema: { type: "object", properties: { token: { type: "string", description: "Секрет токена, начинается с zmcp_" } }, required: ["token"] } },
99
103
  { name: "list_bots", description: "Список ботов пользователя (id, имя, статус).", inputSchema: { type: "object", properties: {} } },
100
104
  { name: "list_graphs", description: "Список графов (сценариев) бота.", inputSchema: { type: "object", properties: { botId: { type: "string" } }, required: ["botId"] } },
105
+ { name: "list_channels", description: "Список каналов/групп, подключённых к боту (chatId, title, type, статус бота, дата). chatId — числовой id для условия SUBSCRIBED («Подписан на канал»).", inputSchema: { type: "object", properties: { botId: { type: "string" } }, required: ["botId"] } },
101
106
  { name: "get_graph", description: "Получить граф целиком по graphId.", inputSchema: { type: "object", properties: { graphId: { type: "string" } }, required: ["graphId"] } },
102
107
  { name: "create_graph", description: "Создать пустой граф (DRAFT) в боте. Возвращает граф с id.", inputSchema: { type: "object", properties: { botId: { type: "string" }, name: { type: "string" } }, required: ["botId", "name"] } },
103
108
  { name: "update_graph", description: "Залить узлы/рёбра в граф (PUT). Принимает graph-контейнер или nodes/edges.", inputSchema: { type: "object", properties: { graphId: { type: "string" }, graph: { type: "object" }, nodes: { type: "array" }, edges: { type: "array" }, canvasMeta: { type: "object" }, name: { type: "string" } }, required: ["graphId"] } },
104
109
  { 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
110
  { name: "publish_graph", description: "Опубликовать граф. Вернёт publishedGraphId или errors[] (code, nodeId, message).", inputSchema: { type: "object", properties: { graphId: { type: "string" } }, required: ["graphId"] } },
106
111
  { 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"] } },
112
+ { name: "list_templates", description: "Список готовых шаблонов воронок (id, имя, описание). Можно стартовать граф из шаблона вместо сборки с нуля.", inputSchema: { type: "object", properties: {} } },
113
+ { 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"] } },
114
+ { name: "rename_graph", description: "Переименовать сценарий (работает и для опубликованных — имя не влияет на исполнение).", inputSchema: { type: "object", properties: { graphId: { type: "string" }, name: { type: "string" } }, required: ["graphId", "name"] } },
115
+ { name: "clone_graph", description: "Склонировать граф в новый DRAFT «… (copy)» — безопасно итерировать поверх опубликованного.", inputSchema: { type: "object", properties: { graphId: { type: "string" } }, required: ["graphId"] } },
116
+ { name: "delete_graph", description: "Удалить граф. Активный (опубликованный и назначенный боту) удалить нельзя — будет 409; сначала переключи активный через set_active_graph.", inputSchema: { type: "object", properties: { graphId: { type: "string" } }, required: ["graphId"] } },
117
+ { name: "set_active_graph", description: "Назначить, какой опубликованный граф активен у бота (переключение живого сценария без перепубликации).", inputSchema: { type: "object", properties: { botId: { type: "string" }, graphId: { type: "string" } }, required: ["botId", "graphId"] } },
107
118
  ];
108
119
 
109
120
  async function handleCall(params) {
@@ -122,14 +133,19 @@ async function handleCall(params) {
122
133
  if (!t) throw new Error("Передай token — секрет вида zmcp_..., который ты создал на " + TOKENS_PAGE);
123
134
  saveToken(t);
124
135
  const warn = t.startsWith("zmcp_") ? "" : "\n⚠️ Обычно токен начинается с «zmcp_» — проверь, что скопирован весь секрет.";
136
+ const envTok = (process.env.ZAYTSV_MCP_TOKEN || "").trim();
137
+ const envWarn = envTok && !envTok.startsWith("${") && envTok !== t
138
+ ? "\n⚠️ В окружении задан другой ZAYTSV_MCP_TOKEN — он имеет приоритет над файлом. Убери/обнови env, иначе сохранённый токен не будет использоваться."
139
+ : "";
125
140
  // лёгкая проверка валидности
126
141
  let check = "";
127
142
  try { const bots = await api("/api/tg/bots"); check = `\nПроверка: доступно ботов — ${Array.isArray(bots) ? bots.length : "?"}.`; }
128
143
  catch (e) { check = `\n⚠️ Токен сохранён, но проверка не прошла: ${(e.message || "").split("\n")[0]}`; }
129
- return okResult(`✅ Токен сохранён (${TOKEN_FILE}). Применяется сразу.${warn}${check}`);
144
+ return okResult(`✅ Токен сохранён (${TOKEN_FILE}). Применяется сразу.${warn}${envWarn}${check}`);
130
145
  }
131
146
  case "list_bots": return okResult(await api("/api/tg/bots"));
132
147
  case "list_graphs": return okResult(await api(`/api/tg/bots/${a.botId}/graphs`));
148
+ case "list_channels": return okResult(await api(`/api/tg/bots/${a.botId}/linked-chats`));
133
149
  case "get_graph": return okResult(await api(`/api/tg/graphs/${a.graphId}`));
134
150
  case "create_graph": return okResult(await api(`/api/tg/bots/${a.botId}/graphs`, { method: "POST", body: { name: a.name } }));
135
151
  case "update_graph": {
@@ -166,6 +182,19 @@ async function handleCall(params) {
166
182
  }
167
183
  return okResult({ graphId, steps });
168
184
  }
185
+ case "list_templates": return okResult(await api("/api/tg/graph-templates"));
186
+ case "create_graph_from_template":
187
+ return okResult(await api(`/api/tg/bots/${a.botId}/graphs/from-template`, { method: "POST", body: { templateId: a.templateId, name: a.name } }));
188
+ case "rename_graph":
189
+ return okResult(await api(`/api/tg/graphs/${a.graphId}/rename`, { method: "PATCH", body: { name: a.name } }));
190
+ case "clone_graph":
191
+ return okResult(await api(`/api/tg/graphs/${a.graphId}/clone`, { method: "POST" }));
192
+ case "delete_graph":
193
+ await api(`/api/tg/graphs/${a.graphId}`, { method: "DELETE" });
194
+ return okResult(`🗑️ Граф ${a.graphId} удалён.`);
195
+ case "set_active_graph":
196
+ await api(`/api/tg/bots/${a.botId}/active-graph`, { method: "POST", body: { graphId: a.graphId } });
197
+ return okResult(`✅ Активный граф бота ${a.botId} → ${a.graphId}.`);
169
198
  default:
170
199
  throw new Error(`Неизвестный инструмент: ${params && params.name}`);
171
200
  }