zaytsv-bot-graph-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +12 -0
- package/.claude-plugin/plugin.json +12 -0
- package/.mcp.json +12 -0
- package/LICENSE +21 -0
- package/README.md +120 -0
- package/package.json +20 -0
- package/skills/build-bot-funnel/SKILL.md +49 -0
- package/skills/build-bot-funnel/reference/schema.md +81 -0
- package/skills/build-bot-funnel/reference/validation.md +47 -0
- package/skills/build-bot-funnel/validate.mjs +147 -0
- package/src/index.mjs +149 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "zaytsv",
|
|
3
|
+
"owner": { "name": "zaytsv", "url": "https://zaytsv.ru" },
|
|
4
|
+
"description": "Плагины zaytsv для Claude Code.",
|
|
5
|
+
"plugins": [
|
|
6
|
+
{
|
|
7
|
+
"name": "zaytsv-bot-graph",
|
|
8
|
+
"source": "./",
|
|
9
|
+
"description": "Сборка и публикация воронок Telegram-ботов (zaytsv /bots) из описания через MCP + скилл."
|
|
10
|
+
}
|
|
11
|
+
]
|
|
12
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "zaytsv-bot-graph",
|
|
3
|
+
"displayName": "Zaytsv Bot Graph",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "MCP-сервер + скилл для сборки и публикации воронок Telegram-ботов в сервисе zaytsv /bots: из текстового описания → валидный граф → заливка и публикация по API.",
|
|
6
|
+
"author": { "name": "zaytsv", "url": "https://zaytsv.ru" },
|
|
7
|
+
"homepage": "https://zaytsv.ru/bots",
|
|
8
|
+
"repository": "https://github.com/skiddgoddamn/zaytsv-bot-graph-mcp",
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"keywords": ["mcp", "telegram", "bot", "funnel", "zaytsv", "no-code", "claude-code"],
|
|
11
|
+
"mcpServers": "./.mcp.json"
|
|
12
|
+
}
|
package/.mcp.json
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 zaytsv
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# zaytsv-bot-graph-mcp
|
|
2
|
+
|
|
3
|
+
[](https://github.com/skiddgoddamn/zaytsv-bot-graph-mcp/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/zaytsv-bot-graph-mcp)
|
|
5
|
+
[](https://nodejs.org)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
MCP-сервер (+ скилл для Claude Code) для **сборки и публикации воронок Telegram-ботов** в сервисе [zaytsv `/bots`](https://zaytsv.ru/bots): из текстового описания → валидный граф сценария → заливка и публикация через API.
|
|
9
|
+
|
|
10
|
+
- 🤖 **8 инструментов**: `list_bots`, `list_graphs`, `get_graph`, `create_graph`, `update_graph`, `dry_run`, `publish_graph`, `import_funnel`.
|
|
11
|
+
- 🧠 **Скилл `build-bot-funnel`**: учит агента собирать корректный граф (типы узлов, ветки, кнопки, задержки) и проверять его перед публикацией.
|
|
12
|
+
- 📦 **Без зависимостей** — чистый Node ≥18, ставится и запускается сразу.
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Установка
|
|
17
|
+
|
|
18
|
+
### Вариант A — как плагин Claude Code (рекомендуется)
|
|
19
|
+
|
|
20
|
+
```text
|
|
21
|
+
/plugin marketplace add skiddgoddamn/zaytsv-bot-graph-mcp
|
|
22
|
+
/plugin install zaytsv-bot-graph@zaytsv
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Подтянутся и MCP-сервер `bot-graph`, и скилл `build-bot-funnel`. Проверить: `/mcp` и `/plugin`.
|
|
26
|
+
|
|
27
|
+
### Вариант B — как обычный MCP-сервер (Claude Code / Cursor / Windsurf / любой MCP-клиент)
|
|
28
|
+
|
|
29
|
+
Через `npx` без установки. Пример конфига (`.mcp.json` / настройки клиента):
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{
|
|
33
|
+
"mcpServers": {
|
|
34
|
+
"bot-graph": {
|
|
35
|
+
"command": "npx",
|
|
36
|
+
"args": ["-y", "zaytsv-bot-graph-mcp"],
|
|
37
|
+
"env": {
|
|
38
|
+
"ZAYTSV_BASE_URL": "https://zaytsv.ru",
|
|
39
|
+
"ZAYTSV_MCP_TOKEN": "zmcp_ваш_токен"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
См. также [`examples/.mcp.json`](examples/.mcp.json).
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## Авторизация — персональный токен
|
|
51
|
+
|
|
52
|
+
Токен даёт **полный доступ** к управлению твоими ботами (как вход в аккаунт).
|
|
53
|
+
|
|
54
|
+
1. Залогинься на https://zaytsv.ru → открой **`/bots/mcp-tokens`**.
|
|
55
|
+
2. Создай токен → скопируй секрет `zmcp_...` (показывается один раз).
|
|
56
|
+
3. Передай его серверу через `ZAYTSV_MCP_TOKEN`:
|
|
57
|
+
- как `env` в `.mcp.json` (Вариант B), **или**
|
|
58
|
+
- переменной окружения (Вариант A, плагин): PowerShell `setx ZAYTSV_MCP_TOKEN "zmcp_..."`, bash `export ZAYTSV_MCP_TOKEN="zmcp_..."`.
|
|
59
|
+
|
|
60
|
+
Отозвать токен можно там же — доступ блокируется мгновенно.
|
|
61
|
+
|
|
62
|
+
> Дев-окружение: `ZAYTSV_BASE_URL=http://localhost:8066`.
|
|
63
|
+
> Fallback без токена: `ZAYTSV_SESSION_COOKIE` = значение куки `SESSION` из браузера.
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## Использование
|
|
68
|
+
|
|
69
|
+
Опиши воронку словами — агент соберёт граф и (через MCP) опубликует:
|
|
70
|
+
|
|
71
|
+
> «Собери бота: `/start` → приветствие с кнопкой подписки на канал → вопрос с 3 кнопками (бизнес / эксперт / просто смотрю) → для каждой свою цепочку из 2 сообщений с задержкой 1 день → финал с регистрацией на вебинар. Залей в бота и опубликуй.»
|
|
72
|
+
|
|
73
|
+
Под капотом скилл соберёт `nodes/edges`, прогонит локальную проверку и вызовет `import_funnel` → создаст граф, зальёт узлы, прогонит `dry-run /start`, опубликует. При ошибках публикации — разберёт по `code`/`nodeId`, починит, повторит.
|
|
74
|
+
|
|
75
|
+
### Инструменты
|
|
76
|
+
|
|
77
|
+
| Tool | Назначение |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `list_bots` | список ботов |
|
|
80
|
+
| `list_graphs(botId)` | графы (сценарии) бота |
|
|
81
|
+
| `get_graph(graphId)` | получить граф |
|
|
82
|
+
| `create_graph(botId, name)` | создать пустой граф (DRAFT) |
|
|
83
|
+
| `update_graph(graphId, graph\|nodes,edges)` | залить узлы/рёбра (PUT) |
|
|
84
|
+
| `dry_run(graphId, kind, value)` | прогон без публикации |
|
|
85
|
+
| `publish_graph(graphId)` | публикация (вернёт `errors[]` при провале) |
|
|
86
|
+
| `import_funnel(botId, name, graph)` | всё за раз: create → update → dry-run → publish |
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## Формат графа и проверка
|
|
91
|
+
|
|
92
|
+
Граф — контейнер `zaytsv-bot-graph` (`nodes[]` + `edges[]`). Полная схема узлов/хэндлов и правила валидатора — в скилле:
|
|
93
|
+
- [`skills/build-bot-funnel/reference/schema.md`](skills/build-bot-funnel/reference/schema.md)
|
|
94
|
+
- [`skills/build-bot-funnel/reference/validation.md`](skills/build-bot-funnel/reference/validation.md)
|
|
95
|
+
|
|
96
|
+
Локальная проверка графа перед заливкой:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
node skills/build-bot-funnel/validate.mjs path/to/import.json
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## Разработка
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
git clone https://github.com/skiddgoddamn/zaytsv-bot-graph-mcp
|
|
108
|
+
cd zaytsv-bot-graph-mcp
|
|
109
|
+
ZAYTSV_MCP_TOKEN=zmcp_... node src/index.mjs # стартует stdio MCP-сервер
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Зависимостей нет — это голый JSON-RPC по stdio (протокол MCP `2024-11-05`).
|
|
113
|
+
|
|
114
|
+
## Безопасность
|
|
115
|
+
|
|
116
|
+
Токен = доступ к аккаунту по API. Не коммить его; держи в `env`. В конфигах храни ссылку `${ZAYTSV_MCP_TOKEN}`, не само значение.
|
|
117
|
+
|
|
118
|
+
## Лицензия
|
|
119
|
+
|
|
120
|
+
MIT — см. [LICENSE](LICENSE).
|
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "zaytsv-bot-graph-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server to build and publish Telegram bot funnels in the zaytsv /bots service. Zero dependencies.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": { "zaytsv-bot-graph-mcp": "src/index.mjs" },
|
|
7
|
+
"main": "src/index.mjs",
|
|
8
|
+
"files": ["src", "skills", ".mcp.json", ".claude-plugin", "README.md", "LICENSE"],
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node src/index.mjs",
|
|
11
|
+
"check": "node --check src/index.mjs",
|
|
12
|
+
"test": "node scripts/smoke.mjs",
|
|
13
|
+
"validate": "node skills/build-bot-funnel/validate.mjs"
|
|
14
|
+
},
|
|
15
|
+
"engines": { "node": ">=18" },
|
|
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" },
|
|
18
|
+
"homepage": "https://zaytsv.ru/bots",
|
|
19
|
+
"license": "MIT"
|
|
20
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: build-bot-funnel
|
|
3
|
+
description: Собрать воронку (сценарий) Telegram-бота для сервиса /bots из текстового описания — сгенерировать валидный граф формата zaytsv-bot-graph, проверить его и (через MCP bot-graph) залить в бота, прогнать dry-run и опубликовать. Использовать, когда пользователь описывает воронку/прогрев/сценарий бота словами и просит «собрать», «сделать граф», «залить в бота», «опубликовать сценарий».
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Сборка воронки бота (zaytsv /bots)
|
|
7
|
+
|
|
8
|
+
Превращает **текстовое описание воронки** в валидный граф `zaytsv-bot-graph` и (опционально) публикует его через MCP-сервер `bot-graph`.
|
|
9
|
+
|
|
10
|
+
## Когда применять
|
|
11
|
+
Пользователь описывает сценарий бота словами: приветствие → подписка → вопрос с кнопками → ветки → задержки → вебинар и т.п. Либо просит залить/опубликовать готовую воронку.
|
|
12
|
+
|
|
13
|
+
## Рабочий процесс (делай по шагам)
|
|
14
|
+
|
|
15
|
+
1. **Уточни вход.** Если описание неполное, спроси кратко: точка(и) входа (команда `/start` и т.п.), тексты сообщений, кнопки и куда они ведут, развилки/условия, задержки, финал. Не выдумывай маркетинговые тексты — проси у пользователя или помечай `[ВСТАВЬ ТЕКСТ]` только если он сам разрешил.
|
|
16
|
+
|
|
17
|
+
2. **Спланируй граф.** Нарисуй в голове дерево: триггеры → сообщения → ветки. Каждой кнопке-выбору — свой выходной хэндл (`btn_0`, `btn_1`…). Подробности типов узлов и хэндлов: [reference/schema.md](reference/schema.md).
|
|
18
|
+
|
|
19
|
+
3. **Сгенерируй JSON** формата `zaytsv-bot-graph` (см. схему). Требования к корректности (иначе не опубликуется) — в [reference/validation.md](reference/validation.md). Главное:
|
|
20
|
+
- У каждого `SEND_MESSAGE` непустой `config.text` (и продублируй в `cards[0].text`).
|
|
21
|
+
- id узлов и рёбер — валидные UUID, уникальные.
|
|
22
|
+
- Есть хотя бы один корневой `TRIGGER_*`; все нелистовые узлы достижимы от триггера; синхронных циклов нет (цикл только через `ASK_QUESTION`/`DELAY`/`SCHEDULE`).
|
|
23
|
+
- Кнопки-выборы разведены по хэндлам `btn_N`; условия — `yes`/`no`; вопрос — `valid`/`invalid`.
|
|
24
|
+
|
|
25
|
+
4. **Проверь локально** перед заливкой:
|
|
26
|
+
```bash
|
|
27
|
+
node "<путь к скиллу>/validate.mjs" <путь к import.json>
|
|
28
|
+
```
|
|
29
|
+
Скрипт ловит пустые сообщения, висячие рёбра, дубли id, недостижимые узлы, превышение 4096, кривые DELAY. Исправь всё, что он покажет.
|
|
30
|
+
|
|
31
|
+
5. **Залей и опубликуй через MCP `bot-graph`** (если он подключён и пользователь просит публикацию):
|
|
32
|
+
- `list_bots` → выбрать `botId` (или `create_graph` в существующем боте).
|
|
33
|
+
- `create_graph(botId, name)` → получить `graphId`.
|
|
34
|
+
- `update_graph(graphId, nodes, edges, canvasMeta)` → залить узлы/рёбра.
|
|
35
|
+
- `dry_run(graphId, kind:"command", value:"start")` → прогнать стартовую ветку, проверить `runStatus`.
|
|
36
|
+
- `publish_graph(graphId)` → если вернулись `errors[]`, разобрать по `code`/`nodeId`, починить узлы, обновить, опубликовать снова.
|
|
37
|
+
Если MCP не подключён — отдай готовый `import.json` и подскажи: /bots → граф → **Импорт**.
|
|
38
|
+
|
|
39
|
+
6. **Отчитайся**: сколько узлов/веток, какие тексты помечены на проверку, ссылка/ id графа.
|
|
40
|
+
|
|
41
|
+
## Важные ограничения
|
|
42
|
+
- **Источник = текст** (этот режим). Если просят распознать с приватной Miro-доски — самый надёжный путь: CSV-экспорт из Miro; либо запуск залогиненного Chrome пользователя и съёмка экрана (headless WebGL-холст Miro не отдаёт). Это отдельный сценарий, не основной для этого скилла.
|
|
43
|
+
- **Авторизация MCP** — персональный токен `ZAYTSV_MCP_TOKEN` (создаётся в вебе на `/bots/mcp-tokens`, формат `zmcp_…`, полный доступ). Fallback — session-cookie. См. README MCP-сервера.
|
|
44
|
+
- Бэкенд читает плоские поля `config.text`/`config.photoUrl`; редактор берёт текст из первой карточки `type:"text"`. Поэтому **всегда заполняй и `text`, и `cards`**.
|
|
45
|
+
|
|
46
|
+
## Файлы скилла
|
|
47
|
+
- [reference/schema.md](reference/schema.md) — формат графа, типы узлов, конфиги, хэндлы.
|
|
48
|
+
- [reference/validation.md](reference/validation.md) — правила валидатора бэкенда (коды ошибок) и `cardsToLegacy`.
|
|
49
|
+
- `validate.mjs` — оффлайн-проверка графа перед заливкой.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Формат графа `zaytsv-bot-graph`
|
|
2
|
+
|
|
3
|
+
## Контейнер импорта
|
|
4
|
+
```json
|
|
5
|
+
{
|
|
6
|
+
"format": "zaytsv-bot-graph",
|
|
7
|
+
"version": 1,
|
|
8
|
+
"name": "Название воронки",
|
|
9
|
+
"nodes": [ /* TgNode[] */ ],
|
|
10
|
+
"edges": [ /* TgEdge[] */ ],
|
|
11
|
+
"canvasMeta": {}
|
|
12
|
+
}
|
|
13
|
+
```
|
|
14
|
+
При заливке через MCP в `update_graph` передаются только `nodes`, `edges`, `canvasMeta`, `name`.
|
|
15
|
+
|
|
16
|
+
## Узел (TgNode)
|
|
17
|
+
```json
|
|
18
|
+
{ "id": "<uuid>", "type": "<NodeType>", "config": { ... }, "position": { "x": 0, "y": 0 } }
|
|
19
|
+
```
|
|
20
|
+
- `id` — валидный UUID (8-4-4-4-12), уникальный.
|
|
21
|
+
- `position` — раскладка на холсте (слева направо: шаг x ≈ 420; ветки разносим по y).
|
|
22
|
+
|
|
23
|
+
## Ребро (TgEdge) — «стрелка»
|
|
24
|
+
```json
|
|
25
|
+
{ "id": "<uuid|строка>", "sourceNodeId": "<uuid>", "sourceHandle": "next", "targetNodeId": "<uuid>" }
|
|
26
|
+
```
|
|
27
|
+
`sourceHandle` — какой выход узла используется (см. ниже).
|
|
28
|
+
|
|
29
|
+
## Типы узлов (NodeType) и их config
|
|
30
|
+
|
|
31
|
+
### Триггеры (точки входа, корневые)
|
|
32
|
+
- `TRIGGER_COMMAND` — `{ "isRoot": true, "command": "start" }` (команда без `/`). Первый — с `isRoot:true`.
|
|
33
|
+
- `TRIGGER_CALLBACK` — `{ "matchMode": "EQUALS"|"STARTS_WITH", "value": "<callback_data>" }`
|
|
34
|
+
- `TRIGGER_TEXT` — `{ "matchMode": "ANY"|"EQUALS"|"CONTAINS"|"REGEX", "value": "..." }`
|
|
35
|
+
- `BROADCAST_FILTER` — режим рассылки (если есть — единственный триггер).
|
|
36
|
+
|
|
37
|
+
### Сообщения
|
|
38
|
+
- `SEND_MESSAGE` —
|
|
39
|
+
```json
|
|
40
|
+
{ "_title": "Заголовок узла", "parseMode": "PLAIN"|"HTML"|"MARKDOWN",
|
|
41
|
+
"text": "Текст сообщения",
|
|
42
|
+
"cards": [ { "id": "c1", "type": "text", "text": "Текст сообщения" } ],
|
|
43
|
+
"buttons": [ [ { "text": "Кнопка", "kind": "CALLBACK"|"URL", "value": "data|url" } ] ] }
|
|
44
|
+
```
|
|
45
|
+
ВСЕГДА заполняй и `text`, и `cards[0].text` одинаково. `buttons` — массив рядов (каждый ряд — массив кнопок).
|
|
46
|
+
- `SEND_PHOTO` — `{ "photoUrl": "https://...", "text": "подпись (необязательно)" }`
|
|
47
|
+
|
|
48
|
+
### Логика / ветвление
|
|
49
|
+
- `CONDITION` — выходы `yes` / `no`. `{ "match":"ALL"|"ANY", "conditions":[ { "kind":"...", "op":"...", "key":"...", "value":"..." } ] }`
|
|
50
|
+
- `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` — `{}` (конец ветки).
|
|
53
|
+
|
|
54
|
+
### Тайминги
|
|
55
|
+
- `DELAY` — пауза:
|
|
56
|
+
- фиксированная: `{ "kind":"FIXED", "durationSec": 86400 }`
|
|
57
|
+
- до даты/времени: `{ "kind":"UNTIL", "isoDate":"2026-06-25", "time":"18:00" }`
|
|
58
|
+
- `SCHEDULE` — `{ "isoDate":"2026-06-25", "time":"18:00", "timezone":"Europe/Moscow" }`. Выходы `scheduled` / `past`.
|
|
59
|
+
|
|
60
|
+
### Состояние / действия
|
|
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":"..." } ] }`
|
|
63
|
+
|
|
64
|
+
### Внешнее / прочее
|
|
65
|
+
- `CALL_WEBHOOK` — `{ "url":"https://...", "method":"POST", "bodyTemplate":"{...}", "timeoutMs":5000 }`. Выходы `ok` / `error`.
|
|
66
|
+
- `AI_REPLY`, `PAYMENT_LINK`.
|
|
67
|
+
|
|
68
|
+
## Выходные хэндлы (`sourceHandle`) — шпаргалка
|
|
69
|
+
| Узел | Хэндлы |
|
|
70
|
+
|---|---|
|
|
71
|
+
| обычный поток | `next` |
|
|
72
|
+
| кнопки сообщения (`buttons`) | `btn_0`, `btn_1`, … (по индексу кнопки) |
|
|
73
|
+
| `CONDITION` | `yes`, `no` |
|
|
74
|
+
| `BRANCH` | `case_<id>`, `default` |
|
|
75
|
+
| `ASK_QUESTION` | `valid`, `invalid` |
|
|
76
|
+
| `CALL_WEBHOOK` | `ok`, `error` |
|
|
77
|
+
| `SCHEDULE` | `scheduled`, `past` |
|
|
78
|
+
| `DELAY` | `next` |
|
|
79
|
+
|
|
80
|
+
## Подстановки в тексте
|
|
81
|
+
`{{from.first_name}}`, `{{from.username}}`, `{{var.<имя>}}`, либо `{Имя}` как плейсхолдер. Имена переменных/меток: `[a-z_][a-z0-9_]{0,63}` (var) и `[a-z0-9_-]{1,64}` (tag).
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Правила валидатора (GraphValidator) — чтобы граф публиковался
|
|
2
|
+
|
|
3
|
+
Источник истины: `zaytsvBackend/.../service/bot/GraphValidator.java`. При `publish` бэкенд возвращает `errors: [{ nodeId, code, message }]`. Ниже — что проверяется и как не нарваться.
|
|
4
|
+
|
|
5
|
+
## Коды ошибок и условия
|
|
6
|
+
|
|
7
|
+
### `SEND_NO_TEXT` — «Пустое сообщение — добавьте текст или картинку»
|
|
8
|
+
`SEND_MESSAGE` считается пустым, если **`config.text` пустой/из пробелов И `config.photoUrl` пустой**.
|
|
9
|
+
- Бэкенд читает плоское `config.text` (НЕ `cards`).
|
|
10
|
+
- Но редактор при сохранении пересобирает `text` из карточек через `cardsToLegacy`: берёт **первую карточку `type:"text"` с непустым `text`** (или `image.url`→`photoUrl`).
|
|
11
|
+
- ⇒ ВСЕГДА заполняй и `config.text`, и `cards[0]` (тип `text`, непустой `text`). Тогда оба пути дают непустой текст.
|
|
12
|
+
- Кнопок недостаточно: сообщение только с `buttons` — пустое.
|
|
13
|
+
- Пробелы/таб/перенос строки = пусто (`.isBlank()`).
|
|
14
|
+
|
|
15
|
+
### Длина текста
|
|
16
|
+
- `text` ≤ 4096; если есть `photoUrl` (подпись) — ≤ 1024.
|
|
17
|
+
- При `parseMode:"HTML"` — текст должен быть безопасным HTML (без неразрешённых тегов). Если не уверен — `PLAIN`.
|
|
18
|
+
|
|
19
|
+
### Триггеры и достижимость
|
|
20
|
+
- Нужен **хотя бы один корневой `TRIGGER_*`** без входящих рёбер.
|
|
21
|
+
- Если есть `BROADCAST_FILTER` — он должен быть единственным триггером.
|
|
22
|
+
- **Все нелистовые узлы достижимы** от какого-либо триггера. Недостижимый узел = ошибка.
|
|
23
|
+
|
|
24
|
+
### Циклы
|
|
25
|
+
- **Синхронных циклов нет.** Цикл допустим только если проходит через `ASK_QUESTION`, `DELAY` или `SCHEDULE` (узлы, которые «ждут»).
|
|
26
|
+
|
|
27
|
+
### Per-node конфиг
|
|
28
|
+
- `TRIGGER_COMMAND`: `command` обязателен.
|
|
29
|
+
- `TRIGGER_CALLBACK`: `value` обязателен; `matchMode` ∈ {EQUALS, STARTS_WITH}.
|
|
30
|
+
- `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
|
+
- `CALL_WEBHOOK`: `url` обязателен, http(s)://.
|
|
34
|
+
- `DELAY`: `FIXED` с `durationSec>0`, либо `UNTIL` с `isoDate`+`time`.
|
|
35
|
+
- Метки: `[a-z0-9_-]{1,64}`. Переменные: `[a-z_][a-z0-9_]{0,63}`.
|
|
36
|
+
|
|
37
|
+
## Рёбра
|
|
38
|
+
- `sourceNodeId` и `targetNodeId` должны указывать на существующие узлы (нет «висячих»).
|
|
39
|
+
- `sourceHandle` должен соответствовать типу узла-источника (см. schema.md).
|
|
40
|
+
- id узлов и рёбер — уникальны; id узлов — валидные UUID (Jackson десериализует `UUID`).
|
|
41
|
+
|
|
42
|
+
## Жизненный цикл
|
|
43
|
+
- Статусы графа: `DRAFT` / `PUBLISHED`. Публикация заменяет активную опубликованную версию.
|
|
44
|
+
- Перед публикацией полезно прогнать `dry_run` (kind `command`/`callback`/`text`) — поймать рантайм-проблемы стартовой ветки.
|
|
45
|
+
|
|
46
|
+
## Локальная проверка
|
|
47
|
+
`node validate.mjs <import.json>` повторяет ключевые проверки (пустые сообщения с учётом `cardsToLegacy`, висячие рёбра, дубли id, достижимость от триггеров, длину текста, базовый конфиг DELAY/триггеров). Гонять перед каждой заливкой.
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Оффлайн-валидатор графа zaytsv-bot-graph. Без зависимостей.
|
|
3
|
+
// Использование: node validate.mjs <path/to/import.json>
|
|
4
|
+
// Повторяет ключевые правила GraphValidator + cardsToLegacy редактора.
|
|
5
|
+
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
|
|
8
|
+
const path = process.argv[2];
|
|
9
|
+
if (!path) { console.error("Usage: node validate.mjs <import.json>"); process.exit(2); }
|
|
10
|
+
|
|
11
|
+
let g;
|
|
12
|
+
try { g = JSON.parse(readFileSync(path, "utf8")); }
|
|
13
|
+
catch (e) { console.error("❌ Не удалось прочитать/распарсить JSON:", e.message); process.exit(2); }
|
|
14
|
+
|
|
15
|
+
const nodes = Array.isArray(g.nodes) ? g.nodes : [];
|
|
16
|
+
const edges = Array.isArray(g.edges) ? g.edges : [];
|
|
17
|
+
const errors = [];
|
|
18
|
+
const warns = [];
|
|
19
|
+
|
|
20
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
21
|
+
const VAR_RE = /^[a-z_][a-z0-9_]{0,63}$/;
|
|
22
|
+
|
|
23
|
+
// cardsToLegacy: текст = первая text-карточка с непустым text (или image.url -> photoUrl)
|
|
24
|
+
function cardsToLegacy(cards) {
|
|
25
|
+
cards = Array.isArray(cards) ? cards : [];
|
|
26
|
+
const firstImage = cards.find((c) => c && c.type === "image" && c.url);
|
|
27
|
+
const firstText = cards.find((c) => c && c.type === "text" && c.text);
|
|
28
|
+
const firstMedia = cards.find((c) => c && ["image", "video", "audio", "file"].includes(c.type) && c.text);
|
|
29
|
+
let text, photoUrl;
|
|
30
|
+
if (firstImage) { photoUrl = firstImage.url; text = firstImage.text || (firstText && firstText.text) || ""; }
|
|
31
|
+
else if (firstText) { text = firstText.text; }
|
|
32
|
+
else if (firstMedia) { text = firstMedia.text; }
|
|
33
|
+
if (!text && !photoUrl) text = "";
|
|
34
|
+
return { text: text || "", photoUrl: photoUrl || "" };
|
|
35
|
+
}
|
|
36
|
+
const blank = (s) => !s || !String(s).trim();
|
|
37
|
+
|
|
38
|
+
// --- id / структура ---
|
|
39
|
+
const ids = new Set();
|
|
40
|
+
for (const n of nodes) {
|
|
41
|
+
if (!n || !n.id) { errors.push(`Узел без id: ${JSON.stringify(n).slice(0, 80)}`); continue; }
|
|
42
|
+
if (ids.has(n.id)) errors.push(`Дубль id узла: ${n.id}`);
|
|
43
|
+
ids.add(n.id);
|
|
44
|
+
if (!UUID_RE.test(n.id)) warns.push(`id узла не UUID (бэкенд десериализует UUID): ${n.id} «${n.config?._title || n.type}»`);
|
|
45
|
+
}
|
|
46
|
+
const edgeIds = new Set();
|
|
47
|
+
for (const e of edges) {
|
|
48
|
+
if (e.id && edgeIds.has(e.id)) errors.push(`Дубль id ребра: ${e.id}`);
|
|
49
|
+
if (e.id) edgeIds.add(e.id);
|
|
50
|
+
if (!ids.has(e.sourceNodeId)) errors.push(`Висячее ребро ${e.id}: нет sourceNodeId ${e.sourceNodeId}`);
|
|
51
|
+
if (!ids.has(e.targetNodeId)) errors.push(`Висячее ребро ${e.id}: нет targetNodeId ${e.targetNodeId}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// --- per-node ---
|
|
55
|
+
const triggers = nodes.filter((n) => String(n.type).startsWith("TRIGGER") || n.type === "BROADCAST_FILTER");
|
|
56
|
+
if (triggers.length === 0) errors.push("Нет ни одного триггера (TRIGGER_*) — graph не опубликуется.");
|
|
57
|
+
const broadcast = nodes.filter((n) => n.type === "BROADCAST_FILTER");
|
|
58
|
+
if (broadcast.length && triggers.length > broadcast.length) errors.push("BROADCAST_FILTER должен быть единственным триггером.");
|
|
59
|
+
|
|
60
|
+
for (const n of nodes) {
|
|
61
|
+
const c = n.config || {};
|
|
62
|
+
const who = `«${c._title || n.id}» (${n.type})`;
|
|
63
|
+
switch (n.type) {
|
|
64
|
+
case "SEND_MESSAGE": {
|
|
65
|
+
const flat = !blank(c.text) || !blank(c.photoUrl);
|
|
66
|
+
const lg = Array.isArray(c.cards) && c.cards.length ? cardsToLegacy(c.cards) : { text: c.text || "", photoUrl: c.photoUrl || "" };
|
|
67
|
+
if (!flat) errors.push(`SEND_NO_TEXT: ${who} — пустой config.text/photoUrl.`);
|
|
68
|
+
else if (blank(lg.text) && blank(lg.photoUrl)) errors.push(`SEND_NO_TEXT: ${who} — после cardsToLegacy текст пуст (нет text-карточки с содержимым).`);
|
|
69
|
+
const t = String(c.text || "");
|
|
70
|
+
const lim = !blank(c.photoUrl) ? 1024 : 4096;
|
|
71
|
+
if (t.length > lim) errors.push(`Текст ${who} = ${t.length} > ${lim}.`);
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
case "SEND_PHOTO":
|
|
75
|
+
if (blank(c.photoUrl)) errors.push(`${who}: нужен photoUrl.`);
|
|
76
|
+
break;
|
|
77
|
+
case "TRIGGER_COMMAND":
|
|
78
|
+
if (blank(c.command)) errors.push(`${who}: нужен command.`);
|
|
79
|
+
break;
|
|
80
|
+
case "TRIGGER_CALLBACK":
|
|
81
|
+
if (blank(c.value)) errors.push(`${who}: нужен value.`);
|
|
82
|
+
if (c.matchMode && !["EQUALS", "STARTS_WITH"].includes(c.matchMode)) errors.push(`${who}: matchMode ∈ {EQUALS,STARTS_WITH}.`);
|
|
83
|
+
break;
|
|
84
|
+
case "TRIGGER_TEXT":
|
|
85
|
+
if (c.matchMode && c.matchMode !== "ANY" && blank(c.value)) errors.push(`${who}: для matchMode≠ANY нужен value.`);
|
|
86
|
+
break;
|
|
87
|
+
case "ASK_QUESTION":
|
|
88
|
+
if (blank(c.promptText)) errors.push(`${who}: нужен promptText.`);
|
|
89
|
+
if (c.saveTo && !VAR_RE.test(c.saveTo)) errors.push(`${who}: saveTo «${c.saveTo}» не матчит [a-z_][a-z0-9_]{0,63}.`);
|
|
90
|
+
break;
|
|
91
|
+
case "CALL_WEBHOOK":
|
|
92
|
+
if (blank(c.url) || !/^https?:\/\//.test(c.url)) errors.push(`${who}: url обязателен и http(s)://.`);
|
|
93
|
+
break;
|
|
94
|
+
case "DELAY":
|
|
95
|
+
if (c.kind === "FIXED") { if (!(Number(c.durationSec) > 0)) errors.push(`${who}: FIXED требует durationSec>0.`); }
|
|
96
|
+
else if (c.kind === "UNTIL") { if (blank(c.isoDate) || blank(c.time)) errors.push(`${who}: UNTIL требует isoDate и time.`); }
|
|
97
|
+
else errors.push(`${who}: kind ∈ {FIXED,UNTIL}.`);
|
|
98
|
+
break;
|
|
99
|
+
case "BRANCH":
|
|
100
|
+
if (!Array.isArray(c.cases) || c.cases.length === 0) errors.push(`${who}: нужен хотя бы один case.`);
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// --- достижимость от триггеров ---
|
|
106
|
+
const adj = {};
|
|
107
|
+
for (const e of edges) (adj[e.sourceNodeId] = adj[e.sourceNodeId] || []).push(e.targetNodeId);
|
|
108
|
+
const reach = new Set();
|
|
109
|
+
const stack = triggers.map((t) => t.id);
|
|
110
|
+
while (stack.length) { const x = stack.pop(); if (reach.has(x)) continue; reach.add(x); (adj[x] || []).forEach((y) => stack.push(y)); }
|
|
111
|
+
for (const n of nodes) {
|
|
112
|
+
if (String(n.type).startsWith("TRIGGER") || n.type === "BROADCAST_FILTER") continue;
|
|
113
|
+
if (!reach.has(n.id)) errors.push(`Недостижимый узел от триггера: «${n.config?._title || n.id}» (${n.type}).`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// --- синхронные циклы (цикл без DELAY/ASK_QUESTION/SCHEDULE = ошибка) ---
|
|
117
|
+
const waitTypes = new Set(["ASK_QUESTION", "DELAY", "SCHEDULE"]);
|
|
118
|
+
const typeById = Object.fromEntries(nodes.map((n) => [n.id, n.type]));
|
|
119
|
+
const color = {}; // 0=white,1=gray,2=black
|
|
120
|
+
function dfs(u, pathHasWaitAtEdgeInto) {
|
|
121
|
+
color[u] = 1;
|
|
122
|
+
for (const v of adj[u] || []) {
|
|
123
|
+
if (color[v] === 1) {
|
|
124
|
+
// нашли цикл u->...->v; ок только если в цикле есть wait-узел
|
|
125
|
+
// упрощённо: если ни u, ни v не wait — предупредим как потенциальный синхронный цикл
|
|
126
|
+
if (!waitTypes.has(typeById[u]) && !waitTypes.has(typeById[v]))
|
|
127
|
+
warns.push(`Возможный синхронный цикл рядом с ${v} — убедись, что в петле есть DELAY/ASK_QUESTION/SCHEDULE.`);
|
|
128
|
+
} else if (color[v] !== 2) {
|
|
129
|
+
dfs(v);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
color[u] = 2;
|
|
133
|
+
}
|
|
134
|
+
for (const n of nodes) if (!color[n.id]) dfs(n.id);
|
|
135
|
+
|
|
136
|
+
// --- отчёт ---
|
|
137
|
+
const byType = {};
|
|
138
|
+
nodes.forEach((n) => (byType[n.type] = (byType[n.type] || 0) + 1));
|
|
139
|
+
console.log(`Граф: ${nodes.length} узлов, ${edges.length} рёбер, ${triggers.length} триггеров`);
|
|
140
|
+
console.log(`Типы: ${JSON.stringify(byType)}`);
|
|
141
|
+
if (warns.length) { console.log(`\n⚠️ Предупреждения (${warns.length}):`); warns.forEach((w) => console.log(" • " + w)); }
|
|
142
|
+
if (errors.length) {
|
|
143
|
+
console.log(`\n❌ Ошибки (${errors.length}) — публикация не пройдёт:`);
|
|
144
|
+
errors.forEach((e) => console.log(" • " + e));
|
|
145
|
+
process.exit(1);
|
|
146
|
+
}
|
|
147
|
+
console.log("\n✅ Валидация пройдена — граф готов к заливке/публикации.");
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* zaytsv-bot-graph-mcp — MCP-сервер для сборки и публикации графов Telegram-ботов
|
|
4
|
+
* через API сервиса zaytsv /bots. Без внешних зависимостей (голый JSON-RPC по stdio),
|
|
5
|
+
* поэтому работает сразу, без npm install — в Claude Code, Cursor, Windsurf и т.п.
|
|
6
|
+
*
|
|
7
|
+
* Авторизация: персональный токен (PAT) Bearer; fallback — session-cookie.
|
|
8
|
+
*
|
|
9
|
+
* ENV:
|
|
10
|
+
* ZAYTSV_MCP_TOKEN персональный токен "zmcp_..." (создаётся в вебе: /bots/mcp-tokens)
|
|
11
|
+
* ZAYTSV_BASE_URL база API. По умолчанию https://zaytsv.ru (дев: http://localhost:8066)
|
|
12
|
+
* ZAYTSV_SESSION_COOKIE (fallback) значение куки SESSION из браузера
|
|
13
|
+
* ZAYTSV_COOKIE (fallback) полная строка Cookie
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createInterface } from "node:readline";
|
|
17
|
+
|
|
18
|
+
const VERSION = "0.1.0";
|
|
19
|
+
const BASE = (process.env.ZAYTSV_BASE_URL || "https://zaytsv.ru").replace(/\/+$/, "");
|
|
20
|
+
const TOKEN = (process.env.ZAYTSV_MCP_TOKEN || "").trim();
|
|
21
|
+
const COOKIE =
|
|
22
|
+
process.env.ZAYTSV_COOKIE ||
|
|
23
|
+
(process.env.ZAYTSV_SESSION_COOKIE ? `SESSION=${process.env.ZAYTSV_SESSION_COOKIE}` : "");
|
|
24
|
+
|
|
25
|
+
function authHeaders() {
|
|
26
|
+
const h = { "Content-Type": "application/json" };
|
|
27
|
+
if (TOKEN) h.Authorization = `Bearer ${TOKEN}`;
|
|
28
|
+
else if (COOKIE) h.Cookie = COOKIE;
|
|
29
|
+
return h;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function api(path, { method = "GET", body } = {}) {
|
|
33
|
+
if (!TOKEN && !COOKIE) {
|
|
34
|
+
throw new Error("Не задана авторизация: установи ZAYTSV_MCP_TOKEN (рекомендуется) или ZAYTSV_SESSION_COOKIE в env.");
|
|
35
|
+
}
|
|
36
|
+
const res = await fetch(`${BASE}${path}`, {
|
|
37
|
+
method,
|
|
38
|
+
headers: authHeaders(),
|
|
39
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
40
|
+
});
|
|
41
|
+
const text = await res.text();
|
|
42
|
+
let data = null;
|
|
43
|
+
try { data = text ? JSON.parse(text) : null; } catch { data = text; }
|
|
44
|
+
if (!res.ok) {
|
|
45
|
+
const msg = typeof data === "string" ? data : JSON.stringify(data);
|
|
46
|
+
throw new Error(`${method} ${path} → HTTP ${res.status}. ${(msg || "").slice(0, 600)}`);
|
|
47
|
+
}
|
|
48
|
+
return data;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const okResult = (obj) => ({ content: [{ type: "text", text: typeof obj === "string" ? obj : JSON.stringify(obj, null, 2) }] });
|
|
52
|
+
const errResult = (e) => ({ isError: true, content: [{ type: "text", text: "❌ " + (e?.message || String(e)) }] });
|
|
53
|
+
|
|
54
|
+
function extractGraph(g) {
|
|
55
|
+
if (!g || typeof g !== "object") throw new Error("graph должен быть объектом (контейнер zaytsv-bot-graph или {nodes,edges}).");
|
|
56
|
+
const nodes = g.nodes ?? g.graph?.nodes;
|
|
57
|
+
const edges = g.edges ?? g.graph?.edges;
|
|
58
|
+
if (!Array.isArray(nodes) || !Array.isArray(edges)) throw new Error("В graph нет массивов nodes[] и edges[].");
|
|
59
|
+
return { name: g.name, nodes, edges, canvasMeta: g.canvasMeta ?? {} };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const TOOLS = [
|
|
63
|
+
{ name: "list_bots", description: "Список ботов пользователя (id, имя, статус).", inputSchema: { type: "object", properties: {} } },
|
|
64
|
+
{ name: "list_graphs", description: "Список графов (сценариев) бота.", inputSchema: { type: "object", properties: { botId: { type: "string" } }, required: ["botId"] } },
|
|
65
|
+
{ name: "get_graph", description: "Получить граф целиком по graphId.", inputSchema: { type: "object", properties: { graphId: { type: "string" } }, required: ["graphId"] } },
|
|
66
|
+
{ name: "create_graph", description: "Создать пустой граф (DRAFT) в боте. Возвращает граф с id.", inputSchema: { type: "object", properties: { botId: { type: "string" }, name: { type: "string" } }, required: ["botId", "name"] } },
|
|
67
|
+
{ 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"] } },
|
|
68
|
+
{ 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"] } },
|
|
69
|
+
{ name: "publish_graph", description: "Опубликовать граф. Вернёт publishedGraphId или errors[] (code, nodeId, message).", inputSchema: { type: "object", properties: { graphId: { type: "string" } }, required: ["graphId"] } },
|
|
70
|
+
{ 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"] } },
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
async function handleCall(params) {
|
|
74
|
+
const a = (params && params.arguments) || {};
|
|
75
|
+
switch (params && params.name) {
|
|
76
|
+
case "list_bots": return okResult(await api("/api/tg/bots"));
|
|
77
|
+
case "list_graphs": return okResult(await api(`/api/tg/bots/${a.botId}/graphs`));
|
|
78
|
+
case "get_graph": return okResult(await api(`/api/tg/graphs/${a.graphId}`));
|
|
79
|
+
case "create_graph": return okResult(await api(`/api/tg/bots/${a.botId}/graphs`, { method: "POST", body: { name: a.name } }));
|
|
80
|
+
case "update_graph": {
|
|
81
|
+
const src = a.graph ? extractGraph(a.graph) : { nodes: a.nodes, edges: a.edges, canvasMeta: a.canvasMeta ?? {}, name: a.name };
|
|
82
|
+
if (!Array.isArray(src.nodes) || !Array.isArray(src.edges)) throw new Error("Нужны nodes[] и edges[] (в graph или отдельно).");
|
|
83
|
+
const payload = { nodes: src.nodes, edges: src.edges, canvasMeta: src.canvasMeta ?? {} };
|
|
84
|
+
if (a.name ?? src.name) payload.name = a.name ?? src.name;
|
|
85
|
+
return okResult(await api(`/api/tg/graphs/${a.graphId}`, { method: "PUT", body: payload }));
|
|
86
|
+
}
|
|
87
|
+
case "dry_run":
|
|
88
|
+
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 } }));
|
|
89
|
+
case "publish_graph":
|
|
90
|
+
return okResult(await api(`/api/tg/graphs/${a.graphId}/publish`, { method: "POST" }));
|
|
91
|
+
case "import_funnel": {
|
|
92
|
+
const src = extractGraph(a.graph);
|
|
93
|
+
const steps = [];
|
|
94
|
+
const created = await api(`/api/tg/bots/${a.botId}/graphs`, { method: "POST", body: { name: a.name || src.name || "Воронка" } });
|
|
95
|
+
const graphId = created.id;
|
|
96
|
+
steps.push(`создан граф ${graphId}`);
|
|
97
|
+
await api(`/api/tg/graphs/${graphId}`, { method: "PUT", body: { nodes: src.nodes, edges: src.edges, canvasMeta: src.canvasMeta ?? {}, name: a.name || src.name } });
|
|
98
|
+
steps.push(`залито узлов: ${src.nodes.length}, рёбер: ${src.edges.length}`);
|
|
99
|
+
if (a.dryRun !== false) {
|
|
100
|
+
const dr = await api(`/api/tg/graphs/${graphId}/dry-run`, { method: "POST", body: { kind: "command", value: "start" } });
|
|
101
|
+
steps.push(`dry-run /start: runStatus=${dr.runStatus}`);
|
|
102
|
+
}
|
|
103
|
+
if (a.publish !== false) {
|
|
104
|
+
const pub = await api(`/api/tg/graphs/${graphId}/publish`, { method: "POST" });
|
|
105
|
+
if (pub.errors && pub.errors.length) {
|
|
106
|
+
steps.push(`❌ публикация не прошла, ошибок: ${pub.errors.length}`);
|
|
107
|
+
return okResult({ graphId, steps, publishErrors: pub.errors });
|
|
108
|
+
}
|
|
109
|
+
steps.push(`✅ опубликовано: publishedGraphId=${pub.publishedGraphId}`);
|
|
110
|
+
return okResult({ graphId, publishedGraphId: pub.publishedGraphId, steps });
|
|
111
|
+
}
|
|
112
|
+
return okResult({ graphId, steps });
|
|
113
|
+
}
|
|
114
|
+
default:
|
|
115
|
+
throw new Error(`Неизвестный инструмент: ${params && params.name}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---- JSON-RPC stdio (MCP) ----
|
|
120
|
+
function send(msg) { process.stdout.write(JSON.stringify(msg) + "\n"); }
|
|
121
|
+
|
|
122
|
+
const rl = createInterface({ input: process.stdin });
|
|
123
|
+
rl.on("line", async (line) => {
|
|
124
|
+
line = line.trim();
|
|
125
|
+
if (!line) return;
|
|
126
|
+
let req;
|
|
127
|
+
try { req = JSON.parse(line); } catch { return; }
|
|
128
|
+
const { id, method, params } = req;
|
|
129
|
+
try {
|
|
130
|
+
if (method === "initialize") {
|
|
131
|
+
send({ jsonrpc: "2.0", id, result: { protocolVersion: "2024-11-05", capabilities: { tools: {} }, serverInfo: { name: "zaytsv-bot-graph", version: VERSION } } });
|
|
132
|
+
} else if (method === "tools/list") {
|
|
133
|
+
send({ jsonrpc: "2.0", id, result: { tools: TOOLS } });
|
|
134
|
+
} else if (method === "tools/call") {
|
|
135
|
+
let result;
|
|
136
|
+
try { result = await handleCall(params); } catch (e) { result = errResult(e); }
|
|
137
|
+
send({ jsonrpc: "2.0", id, result });
|
|
138
|
+
} else if (method === "ping") {
|
|
139
|
+
send({ jsonrpc: "2.0", id, result: {} });
|
|
140
|
+
} else if (id !== undefined && id !== null) {
|
|
141
|
+
// неизвестный метод с id — корректный JSON-RPC error; нотификации игнорируем
|
|
142
|
+
send({ jsonrpc: "2.0", id, error: { code: -32601, message: `Method not found: ${method}` } });
|
|
143
|
+
}
|
|
144
|
+
} catch (e) {
|
|
145
|
+
if (id !== undefined && id !== null) send({ jsonrpc: "2.0", id, error: { code: -32603, message: String(e?.message || e) } });
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
process.stderr.write(`[zaytsv-bot-graph] MCP ${VERSION}. BASE=${BASE}. Авторизация: ${TOKEN ? "токен (Bearer)" : COOKIE ? "cookie" : "НЕ задана"}.\n`);
|