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.
- package/.claude-plugin/plugin.json +1 -1
- package/.mcp.json +1 -1
- package/README.md +8 -1
- package/package.json +2 -2
- package/skills/build-bot-funnel/SKILL.md +8 -4
- package/skills/build-bot-funnel/reference/schema.md +32 -12
- package/skills/build-bot-funnel/reference/validation.md +20 -9
- package/skills/build-bot-funnel/validate.mjs +113 -6
- package/src/index.mjs +32 -3
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zaytsv-bot-graph",
|
|
3
3
|
"displayName": "Zaytsv Bot Graph",
|
|
4
|
-
"version": "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
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
|
-
- 🤖 **
|
|
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
|
+
"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
|
|
24
|
-
-
|
|
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
|
|
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
|
|
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": "
|
|
44
|
+
"buttons": [ [ { "text": "Кнопка", "kind": "CALLBACK"|"URL", "value": "<url для URL; ПУСТО для CALLBACK>", "color": "", "track": true } ] ] }
|
|
44
45
|
```
|
|
45
46
|
ВСЕГДА заполняй и `text`, и `cards[0].text` одинаково. `buttons` — массив рядов (каждый ряд — массив кнопок).
|
|
46
|
-
- `
|
|
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
|
-
-
|
|
57
|
-
-
|
|
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
|
|
62
|
-
- `ACTIONS` — пакет
|
|
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
|
|
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`
|
|
32
|
-
- `BRANCH`: ≥1 case;
|
|
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
|
|
34
|
-
- `CALL_WEBHOOK`: `url`
|
|
35
|
-
- `DELAY`: `FIXED`
|
|
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
|
|
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>` повторяет ключевые
|
|
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))
|
|
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(
|
|
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(
|
|
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 === "
|
|
113
|
-
else errors.push(`${who}:
|
|
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.
|
|
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
|
-
|
|
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
|
}
|