zaytsv-bot-graph-mcp 0.4.2 → 0.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zaytsv-bot-graph-mcp",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "MCP server to build and publish Telegram bot funnels in the zaytsv /bots service. Zero dependencies.",
5
5
  "type": "module",
6
6
  "bin": { "zaytsv-bot-graph-mcp": "src/index.mjs" },
@@ -38,9 +38,15 @@ description: Собрать воронку (сценарий) Telegram-бота
38
38
  - `update_graph(graphId, nodes, edges, canvasMeta)` → залить узлы/рёбра.
39
39
  - `dry_run(graphId, kind:"command", value:"start")` → прогнать стартовую ветку, проверить `runStatus`.
40
40
  - `publish_graph(graphId)` → если вернулись `errors[]`, разобрать по `code`/`nodeId`, починить узлы, обновить, опубликовать снова.
41
- - Управление сценариями: `clone_graph` (безопасно править поверх опубликованного), `rename_graph`, `set_active_graph` (переключить живой граф), `delete_graph` (активный нельзя — сначала переключи).
41
+ - Управление сценариями: `clone_graph`, `rename_graph`, `set_active_graph` (переключить живой граф), `delete_graph` (активный нельзя — сначала переключи).
42
42
  Если MCP не подключён — отдай готовый `import.json` и подскажи: /bots → граф → **Импорт**.
43
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
+
44
50
  6. **Отчитайся**: сколько узлов/веток, какие тексты помечены на проверку, ссылка/ id графа.
45
51
 
46
52
  ## Важные ограничения
@@ -106,6 +106,21 @@
106
106
 
107
107
  > **Платформа MAX.** Боты конструктора умеют работать и в мессенджере MAX. Там не поддерживаются `SUBSCRIBED`/`NOT_SUBSCRIBED` (нет членства в каналах) и reply-клавиатуры; публикация такого графа на MAX-бот вернёт **мягкие предупреждения** (не блокирует). Для Telegram-ботов всё работает как описано.
108
108
 
109
+ ## Кросс-платформенное копирование (Telegram ⇄ MAX)
110
+
111
+ Инструмент `copy_graph` копирует граф в **другого бота** пользователя (`targetBotId`), в т.ч. на другую платформу. Формат графа один и тот же; различается лишь платформа бота-получателя. При копировании в MAX-бот несовместимые узлы **адаптируются**, отчёт — в `notes[]`:
112
+
113
+ | code | severity | что значит |
114
+ |---|---|---|
115
+ | `MAX_CONTACT_AS_TEXT` | TRANSFORM | вопрос с `inputKind=CONTACT` переписан в `inputKind=TEXT` + `validator=PHONE` (в MAX нет кнопки «поделиться контактом»; иначе узел стал бы тупиком) |
116
+ | `MAX_SUBSCRIBED_ALWAYS_NO` | MANUAL | условие `SUBSCRIBED` оставлено как есть, но в MAX всегда «не подписан» — проверьте ветвление вручную |
117
+ | `MAX_VOICE_AS_AUDIO` | INFO | голосовое уйдёт обычным аудио |
118
+ | `MAX_VIDEO_NOTE_AS_VIDEO` | INFO | кружок уйдёт обычным видео |
119
+ | `MAX_GALLERY_AS_ATTACHMENTS` | INFO | галерея уйдёт одним сообщением с вложениями |
120
+ | `MAX_DELETE_NOOP` | INFO | действие «удалить сообщение» в MAX игнорируется |
121
+
122
+ `preview: true` возвращает только `notes[]` (без копирования). Копирование в Telegram-бота (или в бота той же платформы) — точная копия, `notes[]` пустой. Новый граф создаётся как `DRAFT` с именем «… (copy)». Копировать в тот же бот нельзя (для дублирования — `clone_graph`).
123
+
109
124
  ## Выходные хэндлы (`sourceHandle`) — шпаргалка
110
125
  | Узел | Хэндлы |
111
126
  |---|---|
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.2";
24
+ const VERSION = "0.5.0";
25
25
  const BASE = (process.env.ZAYTSV_BASE_URL || "https://zaytsv.ru").replace(/\/+$/, "");
26
26
  const CONFIG_DIR = path.join(os.homedir(), ".zaytsv-bot-graph");
27
27
  const TOKEN_FILE = path.join(CONFIG_DIR, "token");
@@ -105,7 +105,8 @@ const TOOLS = [
105
105
  { name: "list_channels", description: "Список каналов/групп, подключённых к боту (chatId, title, type, статус бота, дата). chatId — числовой id для условия SUBSCRIBED («Подписан на канал»).", inputSchema: { type: "object", properties: { botId: { type: "string" } }, required: ["botId"] } },
106
106
  { name: "get_graph", description: "Получить граф целиком по graphId.", inputSchema: { type: "object", properties: { graphId: { type: "string" } }, required: ["graphId"] } },
107
107
  { name: "create_graph", description: "Создать пустой граф (DRAFT) в боте. Возвращает граф с id.", inputSchema: { type: "object", properties: { botId: { type: "string" }, name: { type: "string" } }, required: ["botId", "name"] } },
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"] } },
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"] } },
109
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"] } },
110
111
  { name: "publish_graph", description: "Опубликовать граф. Вернёт publishedGraphId или errors[] (code, nodeId, message).", inputSchema: { type: "object", properties: { graphId: { type: "string" } }, required: ["graphId"] } },
111
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"] } },
@@ -113,6 +114,7 @@ const TOOLS = [
113
114
  { 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
115
  { name: "rename_graph", description: "Переименовать сценарий (работает и для опубликованных — имя не влияет на исполнение).", inputSchema: { type: "object", properties: { graphId: { type: "string" }, name: { type: "string" } }, required: ["graphId", "name"] } },
115
116
  { name: "clone_graph", description: "Склонировать граф в новый DRAFT «… (copy)» — безопасно итерировать поверх опубликованного.", inputSchema: { type: "object", properties: { graphId: { type: "string" } }, required: ["graphId"] } },
117
+ { name: "copy_graph", description: "Скопировать граф в ДРУГОГО бота (в т.ч. на другую платформу Telegram⇄MAX). Возвращает {graphId, sourcePlatform, targetPlatform, notes[]}. notes[] помечают, что адаптировано (severity=TRANSFORM, напр. вопрос-контакт → ввод телефона текстом), что требует ручной правки (MANUAL, напр. условие SUBSCRIBED в MAX) и особенности платформы (INFO). preview=true — только проверка совместимости, без копирования. Тот же бот запрещён (для дублирования есть clone_graph).", inputSchema: { type: "object", properties: { graphId: { type: "string" }, targetBotId: { type: "string", description: "id бота-получателя (см. list_bots)" }, preview: { type: "boolean", description: "true = только отчёт о совместимости, ничего не сохраняется" } }, required: ["graphId", "targetBotId"] } },
116
118
  { name: "delete_graph", description: "Удалить граф. Активный (опубликованный и назначенный боту) удалить нельзя — будет 409; сначала переключи активный через set_active_graph.", inputSchema: { type: "object", properties: { graphId: { type: "string" } }, required: ["graphId"] } },
117
119
  { name: "set_active_graph", description: "Назначить, какой опубликованный граф активен у бота (переключение живого сценария без перепубликации).", inputSchema: { type: "object", properties: { botId: { type: "string" }, graphId: { type: "string" } }, required: ["botId", "graphId"] } },
118
120
  ];
@@ -155,6 +157,30 @@ async function handleCall(params) {
155
157
  if (a.name ?? src.name) payload.name = a.name ?? src.name;
156
158
  return okResult(await api(`/api/tg/graphs/${a.graphId}`, { method: "PUT", body: payload }));
157
159
  }
160
+ case "edit_graph_live": {
161
+ const src = a.graph ? extractGraph(a.graph) : { nodes: a.nodes, edges: a.edges, canvasMeta: a.canvasMeta ?? {}, name: a.name };
162
+ if (!Array.isArray(src.nodes) || !Array.isArray(src.edges)) throw new Error("Нужны nodes[] и edges[] (в graph или отдельно).");
163
+ const steps = [];
164
+ let backupGraphId = null;
165
+ if (a.backup !== false) {
166
+ // снимок ТЕКУЩЕГО (до правки) состояния в один rolling-граф «🔙 Авто-бэкап» (один на бота, перезаписывается)
167
+ const current = await api(`/api/tg/graphs/${a.graphId}`);
168
+ const botId = current.botId;
169
+ const BACKUP_NAME = "🔙 Авто-бэкап (предыдущее состояние)";
170
+ const graphs = await api(`/api/tg/bots/${botId}/graphs`);
171
+ let backup = (Array.isArray(graphs) ? graphs : [])
172
+ .find((g) => g.name === BACKUP_NAME && g.status === "DRAFT" && g.id !== a.graphId);
173
+ if (!backup) backup = await api(`/api/tg/bots/${botId}/graphs`, { method: "POST", body: { name: BACKUP_NAME } });
174
+ backupGraphId = backup.id;
175
+ await api(`/api/tg/graphs/${backup.id}`, { method: "PUT", body: { nodes: current.nodes ?? [], edges: current.edges ?? [], canvasMeta: current.canvasMeta ?? {}, name: BACKUP_NAME } });
176
+ steps.push(`бэкап предыдущего состояния → ${backup.id} (DRAFT «${BACKUP_NAME}»)`);
177
+ }
178
+ const payload = { nodes: src.nodes, edges: src.edges, canvasMeta: src.canvasMeta ?? {} };
179
+ if (a.name ?? src.name) payload.name = a.name ?? src.name;
180
+ const saved = await api(`/api/tg/graphs/${a.graphId}`, { method: "PUT", body: payload });
181
+ steps.push(`правка применена НА МЕСТЕ к ${a.graphId} (id не изменился; редакторы и бот подхватят live)`);
182
+ 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 });
183
+ }
158
184
  case "dry_run":
159
185
  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 } }));
160
186
  case "publish_graph":
@@ -189,6 +215,8 @@ async function handleCall(params) {
189
215
  return okResult(await api(`/api/tg/graphs/${a.graphId}/rename`, { method: "PATCH", body: { name: a.name } }));
190
216
  case "clone_graph":
191
217
  return okResult(await api(`/api/tg/graphs/${a.graphId}/clone`, { method: "POST" }));
218
+ case "copy_graph":
219
+ return okResult(await api(`/api/tg/graphs/${a.graphId}/copy`, { method: "POST", body: { targetBotId: a.targetBotId, preview: a.preview === true } }));
192
220
  case "delete_graph":
193
221
  await api(`/api/tg/graphs/${a.graphId}`, { method: "DELETE" });
194
222
  return okResult(`🗑️ Граф ${a.graphId} удалён.`);