@retensy/mcp 0.11.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 +159 -0
- package/package.json +21 -0
- package/skills/build-bot-funnel/SKILL.md +70 -0
- package/skills/build-bot-funnel/reference/schema.md +193 -0
- package/skills/build-bot-funnel/reference/validation.md +107 -0
- package/skills/build-bot-funnel/validate.mjs +398 -0
- package/src/index.mjs +424 -0
package/src/index.mjs
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* retensy-mcp — MCP-сервер для сборки и публикации воронок ботов
|
|
4
|
+
* (Telegram, MAX, Instagram) через API сервиса retensy /bots.
|
|
5
|
+
* Без внешних зависимостей (голый JSON-RPC по stdio).
|
|
6
|
+
*
|
|
7
|
+
* Авторизация (в порядке приоритета):
|
|
8
|
+
* 1) env RETENSY_MCP_TOKEN — персональный токен "zmcp_..."
|
|
9
|
+
* 2) файл ~/.retensy-bot-graph/token (заполняется инструментом set_token)
|
|
10
|
+
* 3) session-cookie (RETENSY_SESSION_COOKIE / RETENSY_COOKIE) — fallback
|
|
11
|
+
*
|
|
12
|
+
* Если токена нет — инструменты не падают с сухой ошибкой, а возвращают пошаговую
|
|
13
|
+
* инструкцию; есть инструменты `setup` (статус + как подключить) и `set_token`
|
|
14
|
+
* (пользователь присылает токен в чат — агент сохраняет его в конфиг, без рестарта).
|
|
15
|
+
*
|
|
16
|
+
* ENV:
|
|
17
|
+
* RETENSY_MCP_TOKEN, RETENSY_BASE_URL, RETENSY_SESSION_COOKIE, RETENSY_COOKIE
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { createInterface } from "node:readline";
|
|
21
|
+
import os from "node:os";
|
|
22
|
+
import fs from "node:fs";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
|
|
25
|
+
const VERSION = "0.11.0";
|
|
26
|
+
const BASE = (process.env.RETENSY_BASE_URL || "https://bots.retensy.com").replace(/\/+$/, "");
|
|
27
|
+
const CONFIG_DIR = path.join(os.homedir(), ".retensy-bot-graph");
|
|
28
|
+
const TOKEN_FILE = path.join(CONFIG_DIR, "token");
|
|
29
|
+
const TOKENS_PAGE = `${BASE}/bots/mcp-tokens`;
|
|
30
|
+
|
|
31
|
+
function readFileToken() {
|
|
32
|
+
try { return fs.readFileSync(TOKEN_FILE, "utf8").trim(); } catch { return ""; }
|
|
33
|
+
}
|
|
34
|
+
function getToken() {
|
|
35
|
+
// Если переменная не задана, Claude Code отдаёт шаблон "${RETENSY_MCP_TOKEN}" литералом —
|
|
36
|
+
// такой env нельзя считать токеном, иначе он перекрывает файл из set_token (вечный 401).
|
|
37
|
+
const env = (process.env.RETENSY_MCP_TOKEN || "").trim();
|
|
38
|
+
if (env && !env.startsWith("${")) return env;
|
|
39
|
+
return readFileToken();
|
|
40
|
+
}
|
|
41
|
+
function getCookie() {
|
|
42
|
+
return process.env.RETENSY_COOKIE ||
|
|
43
|
+
(process.env.RETENSY_SESSION_COOKIE ? `SESSION=${process.env.RETENSY_SESSION_COOKIE}` : "");
|
|
44
|
+
}
|
|
45
|
+
function saveToken(token) {
|
|
46
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
47
|
+
fs.writeFileSync(TOKEN_FILE, token.trim() + "\n", { mode: 0o600 });
|
|
48
|
+
try { fs.chmodSync(TOKEN_FILE, 0o600); } catch { /* windows */ }
|
|
49
|
+
}
|
|
50
|
+
function isAuthed() { return !!(getToken() || getCookie()); }
|
|
51
|
+
|
|
52
|
+
const NO_AUTH_HELP =
|
|
53
|
+
"Нет доступа к retensy /bots — не настроена авторизация.\n\n" +
|
|
54
|
+
`Как подключить (помоги пользователю по шагам):\n` +
|
|
55
|
+
`1) Открой ${TOKENS_PAGE} и войди в аккаунт.\n` +
|
|
56
|
+
`2) Нажми «Создать токен», скопируй секрет (вид zmcp_...). Он показывается один раз.\n` +
|
|
57
|
+
`3) Пришли токен сюда — и я вызову инструмент set_token, чтобы сохранить его. (Или задай env RETENSY_MCP_TOKEN.)\n\n` +
|
|
58
|
+
"Подсказка ассистенту: попроси пользователя выполнить шаги 1–2 и прислать токен, затем вызови set_token с этим значением.";
|
|
59
|
+
|
|
60
|
+
function authHeaders() {
|
|
61
|
+
const h = { "Content-Type": "application/json" };
|
|
62
|
+
const token = getToken();
|
|
63
|
+
const cookie = getCookie();
|
|
64
|
+
if (token) h.Authorization = `Bearer ${token}`;
|
|
65
|
+
else if (cookie) h.Cookie = cookie;
|
|
66
|
+
return h;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function api(path_, { method = "GET", body } = {}) {
|
|
70
|
+
if (!isAuthed()) throw new Error(NO_AUTH_HELP);
|
|
71
|
+
const res = await fetch(`${BASE}${path_}`, {
|
|
72
|
+
method,
|
|
73
|
+
headers: authHeaders(),
|
|
74
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
75
|
+
});
|
|
76
|
+
const text = await res.text();
|
|
77
|
+
let data = null;
|
|
78
|
+
try { data = text ? JSON.parse(text) : null; } catch { data = text; }
|
|
79
|
+
if (!res.ok) {
|
|
80
|
+
if (res.status === 401 || res.status === 403) {
|
|
81
|
+
throw new Error(`Доступ отклонён (HTTP ${res.status}). Токен невалиден, отозван или истёк.\n` +
|
|
82
|
+
`Создай новый на ${TOKENS_PAGE} и пришли мне — я сохраню через set_token.`);
|
|
83
|
+
}
|
|
84
|
+
const msg = typeof data === "string" ? data : JSON.stringify(data);
|
|
85
|
+
throw new Error(`${method} ${path_} → HTTP ${res.status}. ${(msg || "").slice(0, 600)}`);
|
|
86
|
+
}
|
|
87
|
+
return data;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// MIME по расширению — уходит как Content-Type части multipart, бэкенд по нему определяет тип медиа.
|
|
91
|
+
const MIME_BY_EXT = {
|
|
92
|
+
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp", ".gif": "image/gif",
|
|
93
|
+
".mp4": "video/mp4", ".webm": "video/webm", ".mov": "video/quicktime",
|
|
94
|
+
".mp3": "audio/mpeg", ".ogg": "audio/ogg", ".oga": "audio/ogg", ".wav": "audio/wav", ".m4a": "audio/mp4",
|
|
95
|
+
".pdf": "application/pdf", ".zip": "application/zip", ".doc": "application/msword", ".txt": "text/plain",
|
|
96
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
97
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
98
|
+
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
99
|
+
};
|
|
100
|
+
const guessMime = (name) => MIME_BY_EXT[path.extname(String(name || "")).toLowerCase()] || "application/octet-stream";
|
|
101
|
+
|
|
102
|
+
// Загрузка файла в библиотеку /bots/files (POST /api/bots/media, multipart). Свой fetch:
|
|
103
|
+
// у api() Content-Type=application/json, для multipart его ставить нельзя (fetch сам задаёт boundary).
|
|
104
|
+
async function uploadMedia({ filePath, url, filename }) {
|
|
105
|
+
if (!isAuthed()) throw new Error(NO_AUTH_HELP);
|
|
106
|
+
let bytes, name, mime;
|
|
107
|
+
if (filePath) {
|
|
108
|
+
const abs = path.resolve(String(filePath).replace(/^~(?=$|[/\\])/, os.homedir()));
|
|
109
|
+
try { bytes = fs.readFileSync(abs); } catch { throw new Error(`Файл не найден: ${abs}`); }
|
|
110
|
+
name = filename || path.basename(abs);
|
|
111
|
+
mime = guessMime(name);
|
|
112
|
+
} else if (url) {
|
|
113
|
+
const r = await fetch(url);
|
|
114
|
+
if (!r.ok) throw new Error(`Не удалось скачать файл по url (HTTP ${r.status}).`);
|
|
115
|
+
bytes = Buffer.from(await r.arrayBuffer());
|
|
116
|
+
let base = "file"; try { base = path.basename(new URL(url).pathname) || "file"; } catch { /* ignore */ }
|
|
117
|
+
name = filename || base;
|
|
118
|
+
mime = r.headers.get("content-type") || guessMime(name);
|
|
119
|
+
} else {
|
|
120
|
+
throw new Error("Передай path (локальный файл) ИЛИ url (ссылку для перезаливки).");
|
|
121
|
+
}
|
|
122
|
+
const headers = {};
|
|
123
|
+
const token = getToken(); const cookie = getCookie();
|
|
124
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
125
|
+
else if (cookie) headers.Cookie = cookie;
|
|
126
|
+
const fd = new FormData();
|
|
127
|
+
fd.append("file", new Blob([bytes], { type: mime }), name);
|
|
128
|
+
const res = await fetch(`${BASE}/api/bots/media`, { method: "POST", headers, body: fd });
|
|
129
|
+
const text = await res.text();
|
|
130
|
+
let data = null; try { data = text ? JSON.parse(text) : null; } catch { data = text; }
|
|
131
|
+
if (!res.ok) {
|
|
132
|
+
if (res.status === 401 || res.status === 403) throw new Error(`Доступ отклонён (HTTP ${res.status}). Токен невалиден/отозван — создай новый на ${TOKENS_PAGE}.`);
|
|
133
|
+
if (res.status === 402) throw new Error("Лимит хранилища тарифа исчерпан (HTTP 402). Удали ненужные файлы (delete_file) или подними тариф на /bots/subscription.");
|
|
134
|
+
if (res.status === 413) throw new Error("Файл больше 50 МБ (HTTP 413) — лимит Telegram для видео/документов.");
|
|
135
|
+
const msg = typeof data === "string" ? data : JSON.stringify(data);
|
|
136
|
+
throw new Error(`POST /api/bots/media → HTTP ${res.status}. ${(msg || "").slice(0, 600)}`);
|
|
137
|
+
}
|
|
138
|
+
return data;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const okResult = (obj) => ({ content: [{ type: "text", text: typeof obj === "string" ? obj : JSON.stringify(obj, null, 2) }] });
|
|
142
|
+
const errResult = (e) => ({ isError: true, content: [{ type: "text", text: "❌ " + (e?.message || String(e)) }] });
|
|
143
|
+
|
|
144
|
+
function extractGraph(g) {
|
|
145
|
+
if (!g || typeof g !== "object") throw new Error("graph должен быть объектом (контейнер retensy-bot-graph или {nodes,edges}).");
|
|
146
|
+
const nodes = g.nodes ?? g.graph?.nodes;
|
|
147
|
+
const edges = g.edges ?? g.graph?.edges;
|
|
148
|
+
if (!Array.isArray(nodes) || !Array.isArray(edges)) throw new Error("В graph нет массивов nodes[] и edges[].");
|
|
149
|
+
return { name: g.name, nodes, edges, canvasMeta: g.canvasMeta ?? {} };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Прочитать граф из локального файла (поддерживается ~). MCP исполняется на машине пользователя,
|
|
153
|
+
// поэтому большой граф можно не передавать инлайном, а сослаться файлом — без обрезания/ошибок.
|
|
154
|
+
function readGraphFile(p) {
|
|
155
|
+
const abs = path.resolve(String(p).replace(/^~(?=$|[/\\])/, os.homedir()));
|
|
156
|
+
let raw;
|
|
157
|
+
try { raw = fs.readFileSync(abs, "utf8"); } catch { throw new Error(`Файл графа не найден: ${abs}`); }
|
|
158
|
+
let obj;
|
|
159
|
+
try { obj = JSON.parse(raw); } catch (e) { throw new Error(`Файл графа — невалидный JSON: ${abs}. ${e?.message || e}`); }
|
|
160
|
+
return obj;
|
|
161
|
+
}
|
|
162
|
+
// Источник графа для пишущих инструментов: graphFile (путь) > graph (контейнер) > nodes/edges.
|
|
163
|
+
function resolveGraphInput(a) {
|
|
164
|
+
if (a.graphFile) return extractGraph(readGraphFile(a.graphFile));
|
|
165
|
+
if (a.graph) return extractGraph(a.graph);
|
|
166
|
+
return { nodes: a.nodes, edges: a.edges, canvasMeta: a.canvasMeta ?? {}, name: a.name };
|
|
167
|
+
}
|
|
168
|
+
// Компактная сводка графа (без объёмных text/cards/buttons) — чтобы не упираться в лимит токенов
|
|
169
|
+
// на больших графах. Узлы: id/type/title/позиция; рёбра: id/from/handle/to.
|
|
170
|
+
function graphSummary(g) {
|
|
171
|
+
const nodes = (g?.nodes || []).map((n) => ({ id: n.id, type: n.type, title: n.config?._title || n.config?.title || null, x: n.position?.x, y: n.position?.y }));
|
|
172
|
+
const edges = (g?.edges || []).map((e) => ({ id: e.id, from: e.sourceNodeId, h: e.sourceHandle, to: e.targetNodeId }));
|
|
173
|
+
return { graphId: g?.id, name: g?.name, status: g?.status, version: g?.version, counts: { nodes: nodes.length, edges: edges.length }, nodes, edges };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const TOOLS = [
|
|
177
|
+
{ name: "setup", description: "Показать статус авторизации и пошаговую инструкцию подключения. Вызывай первым, если пользователь не знает, что делать, или при ошибке доступа.", inputSchema: { type: "object", properties: {} } },
|
|
178
|
+
{ name: "set_token", description: "Сохранить персональный токен (zmcp_...), который пользователь создал на /bots/mcp-tokens. Применяется сразу, без рестарта.", inputSchema: { type: "object", properties: { token: { type: "string", description: "Секрет токена, начинается с zmcp_" } }, required: ["token"] } },
|
|
179
|
+
{ name: "list_bots", description: "Список ботов пользователя (id, имя, статус).", inputSchema: { type: "object", properties: {} } },
|
|
180
|
+
{ name: "list_graphs", description: "Список графов (сценариев) бота.", inputSchema: { type: "object", properties: { botId: { type: "string" } }, required: ["botId"] } },
|
|
181
|
+
{ name: "list_channels", description: "Список каналов/групп, подключённых к боту (chatId, title, type, статус бота, дата). chatId — числовой id для условия SUBSCRIBED («Подписан на канал»).", inputSchema: { type: "object", properties: { botId: { type: "string" } }, required: ["botId"] } },
|
|
182
|
+
{ name: "get_graph", description: "Получить граф по graphId. Для БОЛЬШИХ графов (десятки узлов JSON может превысить лимит токенов) используй summary:true (компактная сводка: id/type/title/позиции + рёбра) или saveToFile (записать полный граф на диск и вернуть сводку+путь — потом правь файл и заливай через update_graph/edit_graph_live с graphFile).", inputSchema: { type: "object", properties: { graphId: { type: "string" }, summary: { type: "boolean", description: "true = вернуть компактную сводку без объёмных text/cards/buttons" }, saveToFile: { type: "string", description: "Путь: записать полный граф (JSON) на диск, вернуть сводку + путь" } }, required: ["graphId"] } },
|
|
183
|
+
{ name: "create_graph", description: "Создать пустой граф (DRAFT) в боте. Возвращает граф с id.", inputSchema: { type: "object", properties: { botId: { type: "string" }, name: { type: "string" } }, required: ["botId", "name"] } },
|
|
184
|
+
{ name: "update_graph", description: "Залить узлы/рёбра в граф (PUT, сырой replace без бэкапа). Для правок СУЩЕСТВУЮЩЕГО/живого сценария используй edit_graph_live. Принимает graphFile (путь к локальному файлу — НЕ нужно слать граф инлайном, удобно для больших графов), graph-контейнер или nodes/edges.", inputSchema: { type: "object", properties: { graphId: { type: "string" }, graphFile: { type: "string", description: "Путь к локальному JSON графа (контейнер retensy-bot-graph или {nodes,edges}); поддерживается ~" }, graph: { type: "object" }, nodes: { type: "array" }, edges: { type: "array" }, canvasMeta: { type: "object" }, name: { type: "string" } }, required: ["graphId"] } },
|
|
185
|
+
{ 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" }, graphFile: { type: "string", description: "Путь к локальному JSON графа (вместо инлайн-передачи); поддерживается ~" }, backup: { type: "boolean", description: "Снимать авто-бэкап предыдущего состояния перед правкой (по умолчанию true)." } }, required: ["graphId"] } },
|
|
186
|
+
{ name: "patch_graph", description: "Точечная правка БОЛЬШОГО/живого графа без отправки графа целиком: сервер сам берёт граф по graphId, делает строковые замены в его JSON, проверяет валидность и заливает обратно НА МЕСТЕ (с авто-бэкапом). Идеально, когда граф слишком велик, чтобы передавать его целиком через update_graph/edit_graph_live — напр. сменить id канала в условиях SUBSCRIBED, ссылки кнопок, тексты. replacements: [{find, replace}] — заменяются ВСЕ вхождения; делай find максимально специфичным, чтобы не задеть лишнее. preview=true — только показать число совпадений, ничего не сохраняя. Бот применит изменения сразу (читает активный граф заново из БД).", inputSchema: { type: "object", properties: { graphId: { type: "string" }, replacements: { type: "array", items: { type: "object", properties: { find: { type: "string" }, replace: { type: "string" } }, required: ["find", "replace"] } }, preview: { type: "boolean", description: "true = только отчёт о числе совпадений, без сохранения" }, backup: { type: "boolean", description: "снять авто-бэкап предыдущего состояния перед правкой (по умолчанию true)" } }, required: ["graphId", "replacements"] } },
|
|
187
|
+
{ 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"] } },
|
|
188
|
+
{ name: "publish_graph", description: "Опубликовать граф. Вернёт publishedGraphId или errors[] (code, nodeId, message).", inputSchema: { type: "object", properties: { graphId: { type: "string" } }, required: ["graphId"] } },
|
|
189
|
+
{ name: "import_funnel", description: "Всё за раз: создать граф, залить узлы/рёбра, (опц.) dry-run /start, опубликовать. Граф можно передать инлайном (graph) или файлом (graphFile).", inputSchema: { type: "object", properties: { botId: { type: "string" }, name: { type: "string" }, graph: { type: "object" }, graphFile: { type: "string", description: "Путь к локальному JSON графа вместо инлайн graph; поддерживается ~" }, dryRun: { type: "boolean" }, publish: { type: "boolean" } }, required: ["botId"] } },
|
|
190
|
+
{ name: "list_templates", description: "Список готовых шаблонов воронок (id, имя, описание). Можно стартовать граф из шаблона вместо сборки с нуля.", inputSchema: { type: "object", properties: {} } },
|
|
191
|
+
{ 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"] } },
|
|
192
|
+
{ name: "rename_graph", description: "Переименовать сценарий (работает и для опубликованных — имя не влияет на исполнение).", inputSchema: { type: "object", properties: { graphId: { type: "string" }, name: { type: "string" } }, required: ["graphId", "name"] } },
|
|
193
|
+
{ name: "clone_graph", description: "Склонировать граф в новый DRAFT «… (copy)» — безопасно итерировать поверх опубликованного.", inputSchema: { type: "object", properties: { graphId: { type: "string" } }, required: ["graphId"] } },
|
|
194
|
+
{ name: "copy_graph", description: "Скопировать граф в ДРУГОГО бота (в т.ч. на другую платформу). Возвращает {graphId, sourcePlatform, targetPlatform, notes[]}. notes[] помечают, что адаптировано (severity=TRANSFORM, напр. вопрос-контакт → ввод телефона текстом), что требует ручной правки (MANUAL, напр. условие SUBSCRIBED в MAX) и особенности платформы (INFO). Авто-адаптация узлов реализована для Telegram⇄MAX; при копировании в/из Instagram-бота граф копируется без трансформаций — несовместимые узлы будут отмечены при публикации (IG-allowlist). 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"] } },
|
|
195
|
+
{ name: "delete_graph", description: "Удалить граф. Активный (опубликованный и назначенный боту) удалить нельзя — будет 409; сначала переключи активный через set_active_graph.", inputSchema: { type: "object", properties: { graphId: { type: "string" } }, required: ["graphId"] } },
|
|
196
|
+
{ name: "set_active_graph", description: "Назначить, какой опубликованный граф активен у бота (переключение живого сценария без перепубликации).", inputSchema: { type: "object", properties: { botId: { type: "string" }, graphId: { type: "string" } }, required: ["botId", "graphId"] } },
|
|
197
|
+
{ name: "upload_file", description: "Загрузить файл в библиотеку /bots/files (POST /api/bots/media) и получить публичный URL для вставки в сценарий. Передай path (локальный файл) ИЛИ url (перезалить файл по ссылке в своё хранилище). Возвращает {id, url, mediaType, sizeBytes, originalName}. Полученный url ставь в медиа-карточку SEND_MESSAGE (image/video/audio/file/voice/videonote → поле url; gallery → urls[]) или в SEND_PHOTO.photoUrl. Лимит 50 МБ; типы: image/video/audio/pdf/zip/doc(x)/xlsx/pptx/txt (SVG запрещён); при нехватке места — HTTP 402.", inputSchema: { type: "object", properties: { path: { type: "string", description: "Путь к локальному файлу (поддерживается ~)" }, url: { type: "string", description: "Ссылка на файл — будет скачан и перезалит в /bots/files" }, filename: { type: "string", description: "Переопределить имя файла (необязательно)" } } } },
|
|
198
|
+
{ name: "list_files", description: "Список файлов в библиотеке /bots/files (GET /api/bots/media) + использовано/лимит байт. Бери готовые url отсюда, чтобы не загружать одно и то же повторно.", inputSchema: { type: "object", properties: {} } },
|
|
199
|
+
{ name: "delete_file", description: "Удалить файл из библиотеки /bots/files по id (DELETE /api/bots/media/{id}). Освобождает место в хранилище тарифа.", inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] } },
|
|
200
|
+
{ name: "graph_analytics", description: "Аналитика прохождения сценария по узлам (GET /api/bots/graphs/{graphId}/analytics): сколько пользователей дошло до каждого узла — видно, где отваливается воронка. Read-only.", inputSchema: { type: "object", properties: { graphId: { type: "string" } }, required: ["graphId"] } },
|
|
201
|
+
{ name: "list_bot_users", description: "Пользователи (подписчики/лиды) бота, постранично (GET /api/bots/{botId}/users). Опц. page (с 0), size (по умолч. 25), query (поиск по имени/username/id). Read-only.", inputSchema: { type: "object", properties: { botId: { type: "string" }, page: { type: "number" }, size: { type: "number" }, query: { type: "string" } }, required: ["botId"] } },
|
|
202
|
+
{ name: "list_links", description: "Стартовые (трекинговые) ссылки бота с UTM (GET /api/bots/{botId}/links): code, метки, число стартов. Это точки входа в воронку. Read-only.", inputSchema: { type: "object", properties: { botId: { type: "string" } }, required: ["botId"] } },
|
|
203
|
+
{ name: "article_list", description: "Список СВОИХ статей блога retensy (GET /api/articles/my): id, slug, title, viewCount, даты. id нужен для article_update, slug — публичный адрес /articles/{slug}. Read-only.", inputSchema: { type: "object", properties: {} } },
|
|
204
|
+
{ name: "article_get", description: "Получить статью блога по slug (GET /api/articles/by-slug/{slug}) — публичное чтение, в т.ч. чужие. Возвращает title, content (Markdown), excerpt, coverImage, viewCount.", inputSchema: { type: "object", properties: { slug: { type: "string", description: "slug статьи (часть адреса /articles/{slug})" } }, required: ["slug"] } },
|
|
205
|
+
{ name: "article_publish", description: "Опубликовать НОВУЮ статью блога retensy (POST /api/articles). content — Markdown (как README на GitHub: заголовки, списки, таблицы, код, картинки по URL). title необязателен: если не передать, заголовком станет первая строка вида «# Заголовок», и она убирается из текста. Обложку можно задать явно через cover (URL картинки) — иначе берётся первая картинка из текста; excerpt (SEO-описание) тоже можно задать явно, иначе генерируется из текста. Возвращает статью с id и slug + публичный URL.", inputSchema: { type: "object", properties: { title: { type: "string", description: "Заголовок (необязателен, если content начинается с «# ...»)" }, content: { type: "string", description: "Тело статьи в Markdown" }, cover: { type: "string", description: "URL обложки (coverImage/OG). Если не задан — берётся первая картинка из текста." }, excerpt: { type: "string", description: "Краткое SEO-описание (≤160 симв). Если не задан — генерируется из текста." } }, required: ["content"] } },
|
|
206
|
+
{ name: "article_update", description: "Обновить СВОЮ статью по id (PUT /api/articles/{id}; id бери из article_list). content — Markdown; title необязателен (как в article_publish, иначе берётся из «# ...»). Только владелец — чужую вернёт 403.", inputSchema: { type: "object", properties: { id: { type: "string", description: "id статьи из article_list" }, title: { type: "string" }, content: { type: "string", description: "Новое тело в Markdown" } }, required: ["id", "content"] } },
|
|
207
|
+
];
|
|
208
|
+
|
|
209
|
+
async function handleCall(params) {
|
|
210
|
+
const a = (params && params.arguments) || {};
|
|
211
|
+
switch (params && params.name) {
|
|
212
|
+
case "setup": {
|
|
213
|
+
if (isAuthed()) {
|
|
214
|
+
const via = getToken() ? "персональный токен" : "session-cookie";
|
|
215
|
+
return okResult(`✅ Авторизация настроена (${via}). База API: ${BASE}.\n` +
|
|
216
|
+
`Можно собирать и публиковать ботов: list_bots, create_graph, import_funnel и др.`);
|
|
217
|
+
}
|
|
218
|
+
return okResult(NO_AUTH_HELP);
|
|
219
|
+
}
|
|
220
|
+
case "set_token": {
|
|
221
|
+
const t = (a.token || "").trim();
|
|
222
|
+
if (!t) throw new Error("Передай token — секрет вида zmcp_..., который ты создал на " + TOKENS_PAGE);
|
|
223
|
+
saveToken(t);
|
|
224
|
+
const warn = t.startsWith("zmcp_") ? "" : "\n⚠️ Обычно токен начинается с «zmcp_» — проверь, что скопирован весь секрет.";
|
|
225
|
+
const envTok = (process.env.RETENSY_MCP_TOKEN || "").trim();
|
|
226
|
+
const envWarn = envTok && !envTok.startsWith("${") && envTok !== t
|
|
227
|
+
? "\n⚠️ В окружении задан другой RETENSY_MCP_TOKEN — он имеет приоритет над файлом. Убери/обнови env, иначе сохранённый токен не будет использоваться."
|
|
228
|
+
: "";
|
|
229
|
+
// лёгкая проверка валидности
|
|
230
|
+
let check = "";
|
|
231
|
+
try { const bots = await api("/api/bots"); check = `\nПроверка: доступно ботов — ${Array.isArray(bots) ? bots.length : "?"}.`; }
|
|
232
|
+
catch (e) { check = `\n⚠️ Токен сохранён, но проверка не прошла: ${(e.message || "").split("\n")[0]}`; }
|
|
233
|
+
return okResult(`✅ Токен сохранён (${TOKEN_FILE}). Применяется сразу.${warn}${envWarn}${check}`);
|
|
234
|
+
}
|
|
235
|
+
case "list_bots": return okResult(await api("/api/bots"));
|
|
236
|
+
case "list_graphs": return okResult(await api(`/api/bots/${a.botId}/graphs`));
|
|
237
|
+
case "list_channels": return okResult(await api(`/api/bots/${a.botId}/linked-chats`));
|
|
238
|
+
case "get_graph": {
|
|
239
|
+
const g = await api(`/api/bots/graphs/${a.graphId}`);
|
|
240
|
+
if (a.saveToFile) {
|
|
241
|
+
const abs = path.resolve(String(a.saveToFile).replace(/^~(?=$|[/\\])/, os.homedir()));
|
|
242
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
243
|
+
fs.writeFileSync(abs, JSON.stringify(g, null, 2));
|
|
244
|
+
return okResult({ savedTo: abs, ...graphSummary(g), note: "Полный граф записан в файл; здесь — сводка. Правь файл и заливай через update_graph/edit_graph_live с graphFile." });
|
|
245
|
+
}
|
|
246
|
+
if (a.summary) return okResult(graphSummary(g));
|
|
247
|
+
return okResult(g);
|
|
248
|
+
}
|
|
249
|
+
case "create_graph": return okResult(await api(`/api/bots/${a.botId}/graphs`, { method: "POST", body: { name: a.name } }));
|
|
250
|
+
case "update_graph": {
|
|
251
|
+
const src = resolveGraphInput(a);
|
|
252
|
+
if (!Array.isArray(src.nodes) || !Array.isArray(src.edges)) throw new Error("Нужны nodes[] и edges[] (через graphFile, graph или nodes/edges).");
|
|
253
|
+
const payload = { nodes: src.nodes, edges: src.edges, canvasMeta: src.canvasMeta ?? {} };
|
|
254
|
+
if (a.name ?? src.name) payload.name = a.name ?? src.name;
|
|
255
|
+
return okResult(await api(`/api/bots/graphs/${a.graphId}`, { method: "PUT", body: payload }));
|
|
256
|
+
}
|
|
257
|
+
case "edit_graph_live": {
|
|
258
|
+
const src = resolveGraphInput(a);
|
|
259
|
+
if (!Array.isArray(src.nodes) || !Array.isArray(src.edges)) throw new Error("Нужны nodes[] и edges[] (через graphFile, graph или nodes/edges).");
|
|
260
|
+
const steps = [];
|
|
261
|
+
let backupGraphId = null;
|
|
262
|
+
if (a.backup !== false) {
|
|
263
|
+
// снимок ТЕКУЩЕГО (до правки) состояния в один rolling-граф «🔙 Авто-бэкап» (один на бота, перезаписывается)
|
|
264
|
+
const current = await api(`/api/bots/graphs/${a.graphId}`);
|
|
265
|
+
const botId = current.botId;
|
|
266
|
+
const BACKUP_NAME = "🔙 Авто-бэкап (предыдущее состояние)";
|
|
267
|
+
const graphs = await api(`/api/bots/${botId}/graphs`);
|
|
268
|
+
let backup = (Array.isArray(graphs) ? graphs : [])
|
|
269
|
+
.find((g) => g.name === BACKUP_NAME && g.status === "DRAFT" && g.id !== a.graphId);
|
|
270
|
+
if (!backup) backup = await api(`/api/bots/${botId}/graphs`, { method: "POST", body: { name: BACKUP_NAME } });
|
|
271
|
+
backupGraphId = backup.id;
|
|
272
|
+
await api(`/api/bots/graphs/${backup.id}`, { method: "PUT", body: { nodes: current.nodes ?? [], edges: current.edges ?? [], canvasMeta: current.canvasMeta ?? {}, name: BACKUP_NAME } });
|
|
273
|
+
steps.push(`бэкап предыдущего состояния → ${backup.id} (DRAFT «${BACKUP_NAME}»)`);
|
|
274
|
+
}
|
|
275
|
+
const payload = { nodes: src.nodes, edges: src.edges, canvasMeta: src.canvasMeta ?? {} };
|
|
276
|
+
if (a.name ?? src.name) payload.name = a.name ?? src.name;
|
|
277
|
+
const saved = await api(`/api/bots/graphs/${a.graphId}`, { method: "PUT", body: payload });
|
|
278
|
+
steps.push(`правка применена НА МЕСТЕ к ${a.graphId} (id не изменился; редакторы и бот подхватят live)`);
|
|
279
|
+
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 });
|
|
280
|
+
}
|
|
281
|
+
case "patch_graph": {
|
|
282
|
+
const reps = Array.isArray(a.replacements) ? a.replacements : [];
|
|
283
|
+
if (!reps.length) throw new Error("Передай replacements: [{find, replace}] — хотя бы одну замену.");
|
|
284
|
+
for (const r of reps) {
|
|
285
|
+
if (!r || typeof r.find !== "string" || typeof r.replace !== "string") throw new Error("Каждая замена — объект {find:string, replace:string}.");
|
|
286
|
+
if (r.find === "") throw new Error("find не может быть пустой строкой.");
|
|
287
|
+
}
|
|
288
|
+
const current = await api(`/api/bots/graphs/${a.graphId}`);
|
|
289
|
+
let json = JSON.stringify(current);
|
|
290
|
+
const report = [];
|
|
291
|
+
for (const r of reps) {
|
|
292
|
+
const matches = json.split(r.find).length - 1;
|
|
293
|
+
if (matches > 0) json = json.split(r.find).join(r.replace);
|
|
294
|
+
report.push({ find: r.find, replace: r.replace, matches });
|
|
295
|
+
}
|
|
296
|
+
let patched;
|
|
297
|
+
try { patched = JSON.parse(json); }
|
|
298
|
+
catch (e) { throw new Error("После замен JSON графа стал невалидным — правка ОТМЕНЕНА, граф не тронут. Сделай find более специфичным. " + (e?.message || "")); }
|
|
299
|
+
const total = report.reduce((s, r) => s + r.matches, 0);
|
|
300
|
+
if (a.preview === true) return okResult({ preview: true, graphId: a.graphId, totalMatches: total, replacements: report });
|
|
301
|
+
if (total === 0) return okResult({ graphId: a.graphId, changed: false, note: "Ни одна замена не совпала — граф не изменён.", replacements: report });
|
|
302
|
+
let backupGraphId = null;
|
|
303
|
+
if (a.backup !== false) {
|
|
304
|
+
const botId = current.botId;
|
|
305
|
+
const BACKUP_NAME = "🔙 Авто-бэкап (предыдущее состояние)";
|
|
306
|
+
const graphs = await api(`/api/bots/${botId}/graphs`);
|
|
307
|
+
let backup = (Array.isArray(graphs) ? graphs : [])
|
|
308
|
+
.find((g) => g.name === BACKUP_NAME && g.status === "DRAFT" && g.id !== a.graphId);
|
|
309
|
+
if (!backup) backup = await api(`/api/bots/${botId}/graphs`, { method: "POST", body: { name: BACKUP_NAME } });
|
|
310
|
+
backupGraphId = backup.id;
|
|
311
|
+
await api(`/api/bots/graphs/${backup.id}`, { method: "PUT", body: { nodes: current.nodes ?? [], edges: current.edges ?? [], canvasMeta: current.canvasMeta ?? {}, name: BACKUP_NAME } });
|
|
312
|
+
}
|
|
313
|
+
const payload = { nodes: patched.nodes ?? [], edges: patched.edges ?? [], canvasMeta: patched.canvasMeta ?? {} };
|
|
314
|
+
if (patched.name) payload.name = patched.name;
|
|
315
|
+
const saved = await api(`/api/bots/graphs/${a.graphId}`, { method: "PUT", body: payload });
|
|
316
|
+
return okResult({ graphId: a.graphId, changed: true, totalMatches: total, replacements: report, backupGraphId, inPlace: true, status: saved?.status ?? null, nodes: Array.isArray(saved?.nodes) ? saved.nodes.length : null });
|
|
317
|
+
}
|
|
318
|
+
case "dry_run":
|
|
319
|
+
return okResult(await api(`/api/bots/graphs/${a.graphId}/dry-run`, { method: "POST", body: { kind: a.kind, value: a.value, fromUsername: a.fromUsername, presetVariables: a.presetVariables, presetTags: a.presetTags } }));
|
|
320
|
+
case "publish_graph":
|
|
321
|
+
return okResult(await api(`/api/bots/graphs/${a.graphId}/publish`, { method: "POST" }));
|
|
322
|
+
case "import_funnel": {
|
|
323
|
+
const src = a.graphFile ? extractGraph(readGraphFile(a.graphFile)) : extractGraph(a.graph);
|
|
324
|
+
const steps = [];
|
|
325
|
+
const created = await api(`/api/bots/${a.botId}/graphs`, { method: "POST", body: { name: a.name || src.name || "Воронка" } });
|
|
326
|
+
const graphId = created.id;
|
|
327
|
+
steps.push(`создан граф ${graphId}`);
|
|
328
|
+
await api(`/api/bots/graphs/${graphId}`, { method: "PUT", body: { nodes: src.nodes, edges: src.edges, canvasMeta: src.canvasMeta ?? {}, name: a.name || src.name } });
|
|
329
|
+
steps.push(`залито узлов: ${src.nodes.length}, рёбер: ${src.edges.length}`);
|
|
330
|
+
if (a.dryRun !== false) {
|
|
331
|
+
const dr = await api(`/api/bots/graphs/${graphId}/dry-run`, { method: "POST", body: { kind: "command", value: "start" } });
|
|
332
|
+
steps.push(`dry-run /start: runStatus=${dr.runStatus}`);
|
|
333
|
+
}
|
|
334
|
+
if (a.publish !== false) {
|
|
335
|
+
const pub = await api(`/api/bots/graphs/${graphId}/publish`, { method: "POST" });
|
|
336
|
+
if (pub.errors && pub.errors.length) {
|
|
337
|
+
steps.push(`❌ публикация не прошла, ошибок: ${pub.errors.length}`);
|
|
338
|
+
return okResult({ graphId, steps, publishErrors: pub.errors });
|
|
339
|
+
}
|
|
340
|
+
steps.push(`✅ опубликовано: publishedGraphId=${pub.publishedGraphId}`);
|
|
341
|
+
return okResult({ graphId, publishedGraphId: pub.publishedGraphId, steps });
|
|
342
|
+
}
|
|
343
|
+
return okResult({ graphId, steps });
|
|
344
|
+
}
|
|
345
|
+
case "list_templates": return okResult(await api("/api/bots/graph-templates"));
|
|
346
|
+
case "create_graph_from_template":
|
|
347
|
+
return okResult(await api(`/api/bots/${a.botId}/graphs/from-template`, { method: "POST", body: { templateId: a.templateId, name: a.name } }));
|
|
348
|
+
case "rename_graph":
|
|
349
|
+
return okResult(await api(`/api/bots/graphs/${a.graphId}/rename`, { method: "PATCH", body: { name: a.name } }));
|
|
350
|
+
case "clone_graph":
|
|
351
|
+
return okResult(await api(`/api/bots/graphs/${a.graphId}/clone`, { method: "POST" }));
|
|
352
|
+
case "copy_graph":
|
|
353
|
+
return okResult(await api(`/api/bots/graphs/${a.graphId}/copy`, { method: "POST", body: { targetBotId: a.targetBotId, preview: a.preview === true } }));
|
|
354
|
+
case "delete_graph":
|
|
355
|
+
await api(`/api/bots/graphs/${a.graphId}`, { method: "DELETE" });
|
|
356
|
+
return okResult(`🗑️ Граф ${a.graphId} удалён.`);
|
|
357
|
+
case "set_active_graph":
|
|
358
|
+
await api(`/api/bots/${a.botId}/active-graph`, { method: "POST", body: { graphId: a.graphId } });
|
|
359
|
+
return okResult(`✅ Активный граф бота ${a.botId} → ${a.graphId}.`);
|
|
360
|
+
case "upload_file": {
|
|
361
|
+
const saved = await uploadMedia({ filePath: a.path, url: a.url, filename: a.filename });
|
|
362
|
+
return okResult({ ...saved, hint: "Готово. Ставь url в медиа-карточку SEND_MESSAGE (image/video/audio/file/voice/videonote → url; gallery → urls[]) или в SEND_PHOTO.photoUrl." });
|
|
363
|
+
}
|
|
364
|
+
case "list_files": return okResult(await api("/api/bots/media"));
|
|
365
|
+
case "delete_file":
|
|
366
|
+
await api(`/api/bots/media/${a.id}`, { method: "DELETE" });
|
|
367
|
+
return okResult(`🗑️ Файл ${a.id} удалён из /bots/files.`);
|
|
368
|
+
case "graph_analytics": return okResult(await api(`/api/bots/graphs/${a.graphId}/analytics`));
|
|
369
|
+
case "list_bot_users": {
|
|
370
|
+
const qs = [];
|
|
371
|
+
if (a.page != null) qs.push(`page=${encodeURIComponent(a.page)}`);
|
|
372
|
+
if (a.size != null) qs.push(`size=${encodeURIComponent(a.size)}`);
|
|
373
|
+
if (a.query) qs.push(`q=${encodeURIComponent(a.query)}`);
|
|
374
|
+
return okResult(await api(`/api/bots/${a.botId}/users${qs.length ? `?${qs.join("&")}` : ""}`));
|
|
375
|
+
}
|
|
376
|
+
case "list_links": return okResult(await api(`/api/bots/${a.botId}/links`));
|
|
377
|
+
case "article_list": return okResult(await api("/api/articles/my"));
|
|
378
|
+
case "article_get": return okResult(await api(`/api/articles/by-slug/${encodeURIComponent(a.slug)}`));
|
|
379
|
+
case "article_publish": {
|
|
380
|
+
if (!a.content || !String(a.content).trim()) throw new Error("Передай content (Markdown). Заголовок можно не передавать, если текст начинается с «# ...».");
|
|
381
|
+
const created = await api("/api/articles", { method: "POST", body: { title: a.title, content: a.content, coverImage: a.cover, excerpt: a.excerpt } });
|
|
382
|
+
return okResult({ ...created, publicUrl: created?.slug ? `${BASE}/articles/${created.slug}` : null });
|
|
383
|
+
}
|
|
384
|
+
case "article_update": {
|
|
385
|
+
if (!a.id) throw new Error("Передай id статьи (см. article_list).");
|
|
386
|
+
if (!a.content || !String(a.content).trim()) throw new Error("Передай content (Markdown).");
|
|
387
|
+
const updated = await api(`/api/articles/${a.id}`, { method: "PUT", body: { title: a.title, content: a.content } });
|
|
388
|
+
return okResult({ ...updated, publicUrl: updated?.slug ? `${BASE}/articles/${updated.slug}` : null });
|
|
389
|
+
}
|
|
390
|
+
default:
|
|
391
|
+
throw new Error(`Неизвестный инструмент: ${params && params.name}`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ---- JSON-RPC stdio (MCP) ----
|
|
396
|
+
function send(msg) { process.stdout.write(JSON.stringify(msg) + "\n"); }
|
|
397
|
+
|
|
398
|
+
const rl = createInterface({ input: process.stdin });
|
|
399
|
+
rl.on("line", async (line) => {
|
|
400
|
+
line = line.trim();
|
|
401
|
+
if (!line) return;
|
|
402
|
+
let req;
|
|
403
|
+
try { req = JSON.parse(line); } catch { return; }
|
|
404
|
+
const { id, method, params } = req;
|
|
405
|
+
try {
|
|
406
|
+
if (method === "initialize") {
|
|
407
|
+
send({ jsonrpc: "2.0", id, result: { protocolVersion: "2024-11-05", capabilities: { tools: {} }, serverInfo: { name: "retensy-mcp", version: VERSION } } });
|
|
408
|
+
} else if (method === "tools/list") {
|
|
409
|
+
send({ jsonrpc: "2.0", id, result: { tools: TOOLS } });
|
|
410
|
+
} else if (method === "tools/call") {
|
|
411
|
+
let result;
|
|
412
|
+
try { result = await handleCall(params); } catch (e) { result = errResult(e); }
|
|
413
|
+
send({ jsonrpc: "2.0", id, result });
|
|
414
|
+
} else if (method === "ping") {
|
|
415
|
+
send({ jsonrpc: "2.0", id, result: {} });
|
|
416
|
+
} else if (id !== undefined && id !== null) {
|
|
417
|
+
send({ jsonrpc: "2.0", id, error: { code: -32601, message: `Method not found: ${method}` } });
|
|
418
|
+
}
|
|
419
|
+
} catch (e) {
|
|
420
|
+
if (id !== undefined && id !== null) send({ jsonrpc: "2.0", id, error: { code: -32603, message: String(e?.message || e) } });
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
process.stderr.write(`[retensy-mcp] MCP ${VERSION}. BASE=${BASE}. Авторизация: ${getToken() ? "токен" : getCookie() ? "cookie" : "не задана (вызови setup)"}.\n`);
|