zaytsv-bot-graph-mcp 0.4.0 → 0.4.3
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 +2 -1
- package/package.json +2 -2
- package/skills/build-bot-funnel/SKILL.md +10 -3
- package/skills/build-bot-funnel/reference/schema.md +12 -8
- package/skills/build-bot-funnel/reference/validation.md +3 -2
- package/skills/build-bot-funnel/validate.mjs +28 -4
- package/src/index.mjs +39 -4
|
@@ -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,6 +83,7 @@ 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) |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zaytsv-bot-graph-mcp",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
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,7 +20,8 @@ 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
|
|
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`** — ветка завершается сама на узле без исходящих рёбер; явный «конец сценария» убран из редактора.
|
|
24
25
|
- `SEND_MESSAGE` умеет быть и сообщением, и **вопросом** (`awaitReply:true` + `saveTo`/`inputKind`/`validator`). У кнопок-ссылок (`kind:"URL"`) есть флаг `track:true` — на такой шаг ссылается условие `LINK_CLICKED`.
|
|
25
26
|
- `CONDITION` умеет: теги, переменные, UTM-метки, имя/email/телефон из профиля, @username, подписку на канал (`SUBSCRIBED`), клик по ссылке шага (`LINK_CLICKED`), дату/время/день недели. Полная таблица `kind`/`op` — в schema.md.
|
|
26
27
|
- Пакет `ACTIONS` — до 30 действий (метки, профиль, HTTP, уведомления, интеграции GetCourse/amoCRM/Google Sheets/Я.Метрика, модерация группы). Список — в schema.md.
|
|
@@ -29,7 +30,7 @@ description: Собрать воронку (сценарий) Telegram-бота
|
|
|
29
30
|
```bash
|
|
30
31
|
node "<путь к скиллу>/validate.mjs" <путь к import.json>
|
|
31
32
|
```
|
|
32
|
-
Скрипт ловит пустые сообщения, висячие рёбра, дубли id
|
|
33
|
+
Скрипт ловит пустые сообщения, висячие рёбра, дубли и не-UUID id узлов/рёбер, недостижимые узлы, превышение 4096, кривые DELAY (вкл. `duration`+`unit`), непустой `value` у кнопок-выборов с ребром `btn_N`, неподдерживаемые цвета. Исправь всё, что он покажет.
|
|
33
34
|
|
|
34
35
|
5. **Залей и опубликуй через MCP `bot-graph`** (если он подключён и пользователь просит публикацию):
|
|
35
36
|
- `list_bots` → выбрать `botId` (или `create_graph` в существующем боте).
|
|
@@ -37,9 +38,15 @@ description: Собрать воронку (сценарий) Telegram-бота
|
|
|
37
38
|
- `update_graph(graphId, nodes, edges, canvasMeta)` → залить узлы/рёбра.
|
|
38
39
|
- `dry_run(graphId, kind:"command", value:"start")` → прогнать стартовую ветку, проверить `runStatus`.
|
|
39
40
|
- `publish_graph(graphId)` → если вернулись `errors[]`, разобрать по `code`/`nodeId`, починить узлы, обновить, опубликовать снова.
|
|
40
|
-
- Управление сценариями: `clone_graph
|
|
41
|
+
- Управление сценариями: `clone_graph`, `rename_graph`, `set_active_graph` (переключить живой граф), `delete_graph` (активный нельзя — сначала переключи).
|
|
41
42
|
Если MCP не подключён — отдай готовый `import.json` и подскажи: /bots → граф → **Импорт**.
|
|
42
43
|
|
|
44
|
+
5b. **Правка СУЩЕСТВУЮЩЕГО / живого сценария — по умолчанию `edit_graph_live`, а НЕ clone+publish.**
|
|
45
|
+
- Когда пользователь просит «поправь сценарий X» (особенно если он уже открыт в редакторе или опубликован) — правь **ТОТ ЖЕ `graphId`** через **`edit_graph_live(graphId, nodes, edges)`**. Он сам снимает авто-бэкап предыдущего состояния (один rolling-граф «🔙 Авто-бэкап») и делает PUT на месте — **id не меняется**.
|
|
46
|
+
- Почему так: бэкенд при PUT/публикации шлёт `external_update` в WS-комнату → открытые редакторы перечитывают граф **вживую** (юзеру не надо перезаходить). Бот читает активный граф **заново из БД на каждое сообщение** → правка живого PUBLISHED-графа применяется **сразу, без отдельной публикации**.
|
|
47
|
+
- `clone_graph`+`publish_graph` каждый раз плодят НОВЫЙ id и переключают активный → юзер вынужден открывать новый граф. Так делай только для крупного рискованного рефактора, где нужна изолированная песочница.
|
|
48
|
+
- ⚠️ PUT не валидирует (валидирует только `publish`) → перед `edit_graph_live` живого графа **обязательно** прогони `validate.mjs` + `dry_run`. Откат: опубликовать граф «🔙 Авто-бэкап» (или скопировать его содержимое обратно).
|
|
49
|
+
|
|
43
50
|
6. **Отчитайся**: сколько узлов/веток, какие тексты помечены на проверку, ссылка/ id графа.
|
|
44
51
|
|
|
45
52
|
## Важные ограничения
|
|
@@ -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,11 +41,13 @@
|
|
|
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`.
|
|
47
|
-
-
|
|
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 (красный). Других цветов нет.
|
|
48
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` для кнопок.
|
|
49
52
|
- `SEND_PHOTO` — `{ "photoUrl": "https://...", "caption": "подпись (необязательно, ≤1024)" }`
|
|
50
53
|
|
|
@@ -52,12 +55,13 @@
|
|
|
52
55
|
- `CONDITION` — проверка условий, выходы `yes` / `no`. `{ "match":"ALL"|"ANY", "conditions":[ { "kind":"...", "op":"...", "key":"...", "value":"..." } ] }`. `match:"ALL"` — все условия истинны; `"ANY"` — хотя бы одно. Полный список `kind`/`op`/полей — в разделе [«Условия CONDITION»](#условия-condition).
|
|
53
56
|
- `BRANCH` — `{ "cases":[ {"id":"c1","label":"...","expression":"var.x=='a'"} ], "hasDefault": false, "abTest": false }`. Выходы: `case_<id>` (+ `default`).
|
|
54
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`.
|
|
55
|
-
- `END` — `{}` (конец ветки).
|
|
58
|
+
- `END` — `{}` (конец ветки). **Не добавляй `END`**: ветка и так завершается на узле без исходящих рёбер; явный «конец сценария» бесполезен и убран из палитры редактора. Тип оставлен лишь для совместимости со старыми графами.
|
|
56
59
|
|
|
57
60
|
### Тайминги
|
|
58
|
-
- `DELAY` —
|
|
59
|
-
-
|
|
60
|
-
-
|
|
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` НЕ работают.
|
|
61
65
|
- `SCHEDULE` — `{ "isoDate":"2026-06-25", "time":"18:00", "timezone":"Europe/Moscow" }`. Выходы `scheduled` / `past`.
|
|
62
66
|
|
|
63
67
|
### Состояние / действия
|
|
@@ -88,7 +92,7 @@
|
|
|
88
92
|
| `EMAIL` | `EQUALS`, `CONTAINS`, `NOT_EMPTY`, `EMPTY` | `value` | email из профиля |
|
|
89
93
|
| `PHONE` | `EQUALS`, `CONTAINS`, `NOT_EMPTY`, `EMPTY` | `value` | телефон из профиля |
|
|
90
94
|
| `USERNAME` | `EQUALS`, `CONTAINS` | `value` | @username пользователя Telegram |
|
|
91
|
-
| `SUBSCRIBED` | `SUBSCRIBED`, `NOT_SUBSCRIBED` | `key` — **числовой** id канала/группы (напр. `-1001234567890`) | подписан ли пользователь на канал бота |
|
|
95
|
+
| `SUBSCRIBED` | `SUBSCRIBED`, `NOT_SUBSCRIBED` | `key` — **числовой** id канала/группы (напр. `-1001234567890`); узнать числовой id подключённых каналов: `list_channels(botId)` | подписан ли пользователь на канал бота |
|
|
92
96
|
| `LINK_CLICKED` | `CLICKED`, `NOT_CLICKED` | `key` — **`id` узла-шага** с отслеживаемой URL-кнопкой (тот же UUID, что у `SEND_MESSAGE`) | кликал ли пользователь по ссылке этого шага |
|
|
93
97
|
| `CURRENT_DATE` | `BEFORE`, `AFTER`, `EQUALS` | `value` — дата `YYYY-MM-DD` | сегодняшнюю дату (МСК) |
|
|
94
98
|
| `CURRENT_TIME` | `BETWEEN` | `value`, `value2` — время `HH:mm` | текущее время в интервале (через полночь — если `value`>`value2`) |
|
|
@@ -36,7 +36,7 @@
|
|
|
36
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
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
38
|
- `CALL_WEBHOOK`: `url` обязателен (`WEBHOOK_NO_URL`), http(s):// (`WEBHOOK_BAD_SCHEME`).
|
|
39
|
-
- `DELAY`: `kind` ∈ {FIXED,UNTIL} (`DELAY_BAD_KIND`)
|
|
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
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
41
|
- `FORMULA`: `expression` непустой (`FORMULA_NO_EXPRESSION`); `saveTo` ∈ var-формат (`FORMULA_BAD_SAVE_TO`).
|
|
42
42
|
- `SCHEDULE`: `isoDate` = валидная `YYYY-MM-DD` (`SCHEDULE_BAD_DATE`); `time` = `HH:mm` (`SCHEDULE_BAD_TIME`).
|
|
@@ -48,7 +48,8 @@
|
|
|
48
48
|
## Рёбра
|
|
49
49
|
- `sourceNodeId` и `targetNodeId` должны указывать на существующие узлы (нет «висячих»).
|
|
50
50
|
- `sourceHandle` должен соответствовать типу узла-источника (см. schema.md).
|
|
51
|
-
- 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, без тела).
|
|
52
53
|
|
|
53
54
|
## Жизненный цикл
|
|
54
55
|
- Статусы графа: `DRAFT` / `PUBLISHED`. Публикация заменяет активную опубликованную версию.
|
|
@@ -77,12 +77,13 @@ for (const n of nodes) {
|
|
|
77
77
|
if (!n || !n.id) { errors.push(`Узел без id: ${JSON.stringify(n).slice(0, 80)}`); continue; }
|
|
78
78
|
if (ids.has(n.id)) errors.push(`Дубль id узла: ${n.id}`);
|
|
79
79
|
ids.add(n.id);
|
|
80
|
-
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}»`);
|
|
81
81
|
}
|
|
82
82
|
const edgeIds = new Set();
|
|
83
83
|
for (const e of edges) {
|
|
84
84
|
if (e.id && edgeIds.has(e.id)) errors.push(`Дубль id ребра: ${e.id}`);
|
|
85
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}`);
|
|
86
87
|
if (!ids.has(e.sourceNodeId)) errors.push(`Висячее ребро ${e.id}: нет sourceNodeId ${e.sourceNodeId}`);
|
|
87
88
|
if (!ids.has(e.targetNodeId)) errors.push(`Висячее ребро ${e.id}: нет targetNodeId ${e.targetNodeId}`);
|
|
88
89
|
}
|
|
@@ -119,6 +120,28 @@ for (const n of nodes) {
|
|
|
119
120
|
else { try { new RegExp(c.regex); } catch (e) { errors.push(`SEND_BAD_REGEX: ${who} — ${e.message}.`); } }
|
|
120
121
|
}
|
|
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
|
+
}
|
|
122
145
|
break;
|
|
123
146
|
}
|
|
124
147
|
case "SEND_PHOTO":
|
|
@@ -143,9 +166,10 @@ for (const n of nodes) {
|
|
|
143
166
|
if (blank(c.url) || !/^https?:\/\//.test(c.url)) errors.push(`${who}: url обязателен и http(s)://.`);
|
|
144
167
|
break;
|
|
145
168
|
case "DELAY":
|
|
146
|
-
if (c.kind === "FIXED") { if (!(Number(c.durationSec) > 0)) errors.push(`${who}: FIXED требует durationSec>0.`); }
|
|
147
|
-
else if (c.kind === "
|
|
148
|
-
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}.`);
|
|
149
173
|
break;
|
|
150
174
|
case "BRANCH":
|
|
151
175
|
if (!Array.isArray(c.cases) || c.cases.length === 0) errors.push(`${who}: нужен хотя бы один case.`);
|
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.4.
|
|
24
|
+
const VERSION = "0.4.3";
|
|
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,9 +102,11 @@ 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
|
-
{ 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"] } },
|
|
108
|
+
{ name: "update_graph", description: "Залить узлы/рёбра в граф (PUT, сырой replace без бэкапа). Для правок СУЩЕСТВУЮЩЕГО/живого сценария используй edit_graph_live. Принимает 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"] } },
|
|
109
|
+
{ name: "edit_graph_live", description: "РЕКОМЕНДОВАННЫЙ способ правки СУЩЕСТВУЮЩЕГО (часто живого/опубликованного) сценария: редактирует ТОТ ЖЕ graphId НА МЕСТЕ (id не меняется) и сначала снимает авто-бэкап текущего состояния в один rolling-граф «🔙 Авто-бэкап». НЕ клонирует и НЕ создаёт новый активный граф. Открытые редакторы перечитают граф вживую (external_update), бот применит изменения сразу (читает активный граф заново из БД). Используй ВМЕСТО clone+publish, когда нужно поправить сценарий, который уже открыт/в проде. ВАЖНО: PUT не валидирует — перед вызовом прогони offline validate.mjs и dry_run.", inputSchema: { type: "object", properties: { graphId: { type: "string" }, graph: { type: "object" }, nodes: { type: "array" }, edges: { type: "array" }, canvasMeta: { type: "object" }, name: { type: "string" }, backup: { type: "boolean", description: "Снимать авто-бэкап предыдущего состояния перед правкой (по умолчанию true)." } }, required: ["graphId"] } },
|
|
104
110
|
{ 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
111
|
{ name: "publish_graph", description: "Опубликовать граф. Вернёт publishedGraphId или errors[] (code, nodeId, message).", inputSchema: { type: "object", properties: { graphId: { type: "string" } }, required: ["graphId"] } },
|
|
106
112
|
{ 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"] } },
|
|
@@ -128,14 +134,19 @@ async function handleCall(params) {
|
|
|
128
134
|
if (!t) throw new Error("Передай token — секрет вида zmcp_..., который ты создал на " + TOKENS_PAGE);
|
|
129
135
|
saveToken(t);
|
|
130
136
|
const warn = t.startsWith("zmcp_") ? "" : "\n⚠️ Обычно токен начинается с «zmcp_» — проверь, что скопирован весь секрет.";
|
|
137
|
+
const envTok = (process.env.ZAYTSV_MCP_TOKEN || "").trim();
|
|
138
|
+
const envWarn = envTok && !envTok.startsWith("${") && envTok !== t
|
|
139
|
+
? "\n⚠️ В окружении задан другой ZAYTSV_MCP_TOKEN — он имеет приоритет над файлом. Убери/обнови env, иначе сохранённый токен не будет использоваться."
|
|
140
|
+
: "";
|
|
131
141
|
// лёгкая проверка валидности
|
|
132
142
|
let check = "";
|
|
133
143
|
try { const bots = await api("/api/tg/bots"); check = `\nПроверка: доступно ботов — ${Array.isArray(bots) ? bots.length : "?"}.`; }
|
|
134
144
|
catch (e) { check = `\n⚠️ Токен сохранён, но проверка не прошла: ${(e.message || "").split("\n")[0]}`; }
|
|
135
|
-
return okResult(`✅ Токен сохранён (${TOKEN_FILE}). Применяется сразу.${warn}${check}`);
|
|
145
|
+
return okResult(`✅ Токен сохранён (${TOKEN_FILE}). Применяется сразу.${warn}${envWarn}${check}`);
|
|
136
146
|
}
|
|
137
147
|
case "list_bots": return okResult(await api("/api/tg/bots"));
|
|
138
148
|
case "list_graphs": return okResult(await api(`/api/tg/bots/${a.botId}/graphs`));
|
|
149
|
+
case "list_channels": return okResult(await api(`/api/tg/bots/${a.botId}/linked-chats`));
|
|
139
150
|
case "get_graph": return okResult(await api(`/api/tg/graphs/${a.graphId}`));
|
|
140
151
|
case "create_graph": return okResult(await api(`/api/tg/bots/${a.botId}/graphs`, { method: "POST", body: { name: a.name } }));
|
|
141
152
|
case "update_graph": {
|
|
@@ -145,6 +156,30 @@ async function handleCall(params) {
|
|
|
145
156
|
if (a.name ?? src.name) payload.name = a.name ?? src.name;
|
|
146
157
|
return okResult(await api(`/api/tg/graphs/${a.graphId}`, { method: "PUT", body: payload }));
|
|
147
158
|
}
|
|
159
|
+
case "edit_graph_live": {
|
|
160
|
+
const src = a.graph ? extractGraph(a.graph) : { nodes: a.nodes, edges: a.edges, canvasMeta: a.canvasMeta ?? {}, name: a.name };
|
|
161
|
+
if (!Array.isArray(src.nodes) || !Array.isArray(src.edges)) throw new Error("Нужны nodes[] и edges[] (в graph или отдельно).");
|
|
162
|
+
const steps = [];
|
|
163
|
+
let backupGraphId = null;
|
|
164
|
+
if (a.backup !== false) {
|
|
165
|
+
// снимок ТЕКУЩЕГО (до правки) состояния в один rolling-граф «🔙 Авто-бэкап» (один на бота, перезаписывается)
|
|
166
|
+
const current = await api(`/api/tg/graphs/${a.graphId}`);
|
|
167
|
+
const botId = current.botId;
|
|
168
|
+
const BACKUP_NAME = "🔙 Авто-бэкап (предыдущее состояние)";
|
|
169
|
+
const graphs = await api(`/api/tg/bots/${botId}/graphs`);
|
|
170
|
+
let backup = (Array.isArray(graphs) ? graphs : [])
|
|
171
|
+
.find((g) => g.name === BACKUP_NAME && g.status === "DRAFT" && g.id !== a.graphId);
|
|
172
|
+
if (!backup) backup = await api(`/api/tg/bots/${botId}/graphs`, { method: "POST", body: { name: BACKUP_NAME } });
|
|
173
|
+
backupGraphId = backup.id;
|
|
174
|
+
await api(`/api/tg/graphs/${backup.id}`, { method: "PUT", body: { nodes: current.nodes ?? [], edges: current.edges ?? [], canvasMeta: current.canvasMeta ?? {}, name: BACKUP_NAME } });
|
|
175
|
+
steps.push(`бэкап предыдущего состояния → ${backup.id} (DRAFT «${BACKUP_NAME}»)`);
|
|
176
|
+
}
|
|
177
|
+
const payload = { nodes: src.nodes, edges: src.edges, canvasMeta: src.canvasMeta ?? {} };
|
|
178
|
+
if (a.name ?? src.name) payload.name = a.name ?? src.name;
|
|
179
|
+
const saved = await api(`/api/tg/graphs/${a.graphId}`, { method: "PUT", body: payload });
|
|
180
|
+
steps.push(`правка применена НА МЕСТЕ к ${a.graphId} (id не изменился; редакторы и бот подхватят live)`);
|
|
181
|
+
return okResult({ graphId: a.graphId, backupGraphId, inPlace: true, status: saved?.status ?? null, nodes: Array.isArray(saved?.nodes) ? saved.nodes.length : null, edges: Array.isArray(saved?.edges) ? saved.edges.length : null, steps });
|
|
182
|
+
}
|
|
148
183
|
case "dry_run":
|
|
149
184
|
return okResult(await api(`/api/tg/graphs/${a.graphId}/dry-run`, { method: "POST", body: { kind: a.kind, value: a.value, fromUsername: a.fromUsername, presetVariables: a.presetVariables, presetTags: a.presetTags } }));
|
|
150
185
|
case "publish_graph":
|