@retensy/mcp 0.21.0 → 0.21.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.mcp.json +12 -12
- package/LICENSE +21 -21
- package/README.md +17 -5
- package/package.json +54 -54
- package/skills/build-bot-funnel/SKILL.md +3 -3
- package/skills/build-bot-funnel/reference/schema.md +1 -3
- package/src/index.mjs +1683 -1682
package/src/index.mjs
CHANGED
|
@@ -1,1682 +1,1683 @@
|
|
|
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
|
-
* RETENSY_MCP_TELEMETRY=off|on|full — отчёты о неудачах (по умолчанию on, без значений аргументов)
|
|
19
|
-
* RETENSY_MCP_REPORT_URL — свой webhook вместо нашего
|
|
20
|
-
* RETENSY_MCP_AUTOUPDATE=0 — не обновлять пакет автоматически
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
import { createInterface } from "node:readline";
|
|
24
|
-
import { fileURLToPath } from "node:url";
|
|
25
|
-
import { spawn } from "node:child_process";
|
|
26
|
-
import os from "node:os";
|
|
27
|
-
import fs from "node:fs";
|
|
28
|
-
import path from "node:path";
|
|
29
|
-
|
|
30
|
-
const VERSION = "0.21.
|
|
31
|
-
/** С чего начать пустой сайт (init у /document/ops; на сайте с черновиком игнорируется). */
|
|
32
|
-
const SITE_INITS = ["starter", "blank", "mini-landing"];
|
|
33
|
-
/** Безвредная операция, когда нужен только init: бэкенд не принимает пустой ops[]. */
|
|
34
|
-
const SITE_NOOP_OPS = [{ op: "set_settings", settings: {} }];
|
|
35
|
-
const PKG_NAME = "@retensy/mcp";
|
|
36
|
-
const BASE = (process.env.RETENSY_BASE_URL || "https://bots.retensy.com").replace(/\/+$/, "");
|
|
37
|
-
const CONFIG_DIR = path.join(os.homedir(), ".retensy-bot-graph");
|
|
38
|
-
const TOKEN_FILE = path.join(CONFIG_DIR, "token");
|
|
39
|
-
const TOKENS_PAGE = `${BASE}/bots/mcp-tokens`;
|
|
40
|
-
|
|
41
|
-
function readFileToken() {
|
|
42
|
-
try { return fs.readFileSync(TOKEN_FILE, "utf8").trim(); } catch { return ""; }
|
|
43
|
-
}
|
|
44
|
-
function getToken() {
|
|
45
|
-
// Если переменная не задана, Claude Code отдаёт шаблон "${RETENSY_MCP_TOKEN}" литералом —
|
|
46
|
-
// такой env нельзя считать токеном, иначе он перекрывает файл из set_token (вечный 401).
|
|
47
|
-
const env = (process.env.RETENSY_MCP_TOKEN || "").trim();
|
|
48
|
-
if (env && !env.startsWith("${")) return env;
|
|
49
|
-
return readFileToken();
|
|
50
|
-
}
|
|
51
|
-
function getCookie() {
|
|
52
|
-
return process.env.RETENSY_COOKIE ||
|
|
53
|
-
(process.env.RETENSY_SESSION_COOKIE ? `SESSION=${process.env.RETENSY_SESSION_COOKIE}` : "");
|
|
54
|
-
}
|
|
55
|
-
function saveToken(token) {
|
|
56
|
-
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
57
|
-
fs.writeFileSync(TOKEN_FILE, token.trim() + "\n", { mode: 0o600 });
|
|
58
|
-
try { fs.chmodSync(TOKEN_FILE, 0o600); } catch { /* windows */ }
|
|
59
|
-
}
|
|
60
|
-
function isAuthed() { return !!(getToken() || getCookie()); }
|
|
61
|
-
|
|
62
|
-
// =====================================================================// Отчёты о неудачах + проверка обновлений
|
|
63
|
-
// =====================================================================// ЗАЧЕМ: если клиент пытается сделать что-то, чего сервер не умеет (неизвестный
|
|
64
|
-
// инструмент, отказ публикации, ошибка API) — мы хотим об этом узнать и добавить
|
|
65
|
-
// поддержку. Отчёт уходит на webhook АНОНИМНО и БЕЗ СЕКРЕТОВ.
|
|
66
|
-
//
|
|
67
|
-
// Что уходит: имя инструмента, категория неудачи, текст ошибки, КЛЮЧИ аргументов
|
|
68
|
-
// (значения — только для безопасного списка полей вроде graphId/botId/kind),
|
|
69
|
-
// версия, платформа и анонимный id установки (хэш, не имя машины).
|
|
70
|
-
// Что НЕ уходит НИКОГДА: токены, cookie, пароли, креды интеграций, тела графов.
|
|
71
|
-
//
|
|
72
|
-
// Полностью выключить: RETENSY_MCP_TELEMETRY=off
|
|
73
|
-
// Присылать и значения аргументов (для отладки своей же установки): =full
|
|
74
|
-
//
|
|
75
|
-
// Отчёт уходит на НАШ эндпоинт `/api/mcp/report`, а не напрямую в мессенджер: адрес приёмника
|
|
76
|
-
// не должен лежать в публичном npm-пакете (оттуда его вытащил бы любой, а у вебхуков нет ни
|
|
77
|
-
// авторизации, ни лимита частоты). Сервер сам решает, куда переслать, и ограничивает частоту.
|
|
78
|
-
const REPORT_URL = (process.env.RETENSY_MCP_REPORT_URL || `${BASE}/api/mcp/report`).trim();
|
|
79
|
-
const REPORT_MODE = (process.env.RETENSY_MCP_TELEMETRY || "on").trim().toLowerCase();
|
|
80
|
-
/** Ключи, значения которых не отправляем ни в каком режиме. */
|
|
81
|
-
const SECRET_KEY_RX = /token|secret|cookie|passw|apikey|api_key|auth|cred/i;
|
|
82
|
-
/** Ключи, значения которых безопасны и реально нужны для разбора. */
|
|
83
|
-
const SAFE_ARG_KEYS = new Set(["graphId", "botId", "targetBotId", "templateId", "kind", "value",
|
|
84
|
-
"name", "slug", "id", "page", "size", "preview", "backup", "summary", "publish", "dryRun", "query"]);
|
|
85
|
-
const REPORT_MAX = 20; // на процесс: цикл ретраев не должен залить вебхук
|
|
86
|
-
let reportCount = 0;
|
|
87
|
-
const reportSeen = new Set(); // дедуп одинаковых неудач в рамках процесса
|
|
88
|
-
|
|
89
|
-
/** Стабильный анонимный id установки (FNV-1a) — группировать отчёты одного пользователя без PII. */
|
|
90
|
-
function installId() {
|
|
91
|
-
let h = 0x811c9dc5;
|
|
92
|
-
for (const ch of `${os.hostname()}|${os.homedir()}`) { h ^= ch.charCodeAt(0); h = (h * 0x01000193) >>> 0; }
|
|
93
|
-
return h.toString(16).padStart(8, "0");
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function safeArgs(toolName, a) {
|
|
97
|
-
// set_token несёт секрет целиком — не сериализуем его вообще.
|
|
98
|
-
if (toolName === "set_token") return '{"token":"<скрыт>"}';
|
|
99
|
-
const out = {};
|
|
100
|
-
for (const [k, v] of Object.entries(a || {})) {
|
|
101
|
-
if (SECRET_KEY_RX.test(k)) { out[k] = "<скрыт>"; continue; }
|
|
102
|
-
const show = REPORT_MODE === "full" || SAFE_ARG_KEYS.has(k);
|
|
103
|
-
if (Array.isArray(v)) out[k] = `<array:${v.length}>`;
|
|
104
|
-
else if (v && typeof v === "object") out[k] = `<object:${Object.keys(v).length}>`;
|
|
105
|
-
else if (show) out[k] = typeof v === "string" ? v.slice(0, 120) : v;
|
|
106
|
-
else out[k] = `<${typeof v}>`;
|
|
107
|
-
}
|
|
108
|
-
return JSON.stringify(out).slice(0, 900); // сервер обрежет ещё раз, но зря тащить не будем
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function failureCategory(msg) {
|
|
112
|
-
const m = String(msg || "");
|
|
113
|
-
if (/^Неизвестный инструмент/.test(m)) return "unknown_tool";
|
|
114
|
-
// Токен вообще не настроен — это обычное состояние нового пользователя, а не пробел
|
|
115
|
-
// в возможностях: такие отчёты не отправляем (иначе каждый новичок зашумит канал).
|
|
116
|
-
if (/Нет доступа к retensy/.test(m)) return "not_configured";
|
|
117
|
-
if (/Доступ отклонён/.test(m)) return "auth_rejected"; // токен есть, но отвергнут — это стоит знать
|
|
118
|
-
const http = m.match(/HTTP (\d{3})/);
|
|
119
|
-
if (http) return `http_${http[1]}`;
|
|
120
|
-
return "error";
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/** Fire-and-forget: никогда не задерживает и не ломает ответ инструмента. */
|
|
124
|
-
function reportFailure({ tool, args, message, category }) {
|
|
125
|
-
if (REPORT_MODE === "off" || !REPORT_URL || reportCount >= REPORT_MAX) return;
|
|
126
|
-
const cat = category || failureCategory(message);
|
|
127
|
-
if (cat === "not_configured") return;
|
|
128
|
-
const key = `${tool}|${cat}|${String(message || "").slice(0, 120)}`;
|
|
129
|
-
if (reportSeen.has(key)) return;
|
|
130
|
-
reportSeen.add(key);
|
|
131
|
-
reportCount += 1;
|
|
132
|
-
const body = {
|
|
133
|
-
tool: String(tool || "?").slice(0, 200),
|
|
134
|
-
category: cat,
|
|
135
|
-
message: String(message || "").slice(0, 1500),
|
|
136
|
-
args: safeArgs(tool, args),
|
|
137
|
-
mcpVersion: VERSION,
|
|
138
|
-
node: process.version,
|
|
139
|
-
platform: process.platform,
|
|
140
|
-
baseUrl: BASE,
|
|
141
|
-
installId: installId(),
|
|
142
|
-
};
|
|
143
|
-
const headers = { "Content-Type": "application/json" };
|
|
144
|
-
// Токен прикладываем ТОЛЬКО когда отчёт идёт на наш же адрес — тогда сервер покажет,
|
|
145
|
-
// кому именно не хватило возможности. На сторонний RETENSY_MCP_REPORT_URL токен не уходит.
|
|
146
|
-
if (REPORT_URL.startsWith(`${BASE}/`)) {
|
|
147
|
-
const token = getToken();
|
|
148
|
-
if (token) headers.Authorization = `Bearer ${token}`;
|
|
149
|
-
}
|
|
150
|
-
try {
|
|
151
|
-
const ac = new AbortController();
|
|
152
|
-
const timer = setTimeout(() => ac.abort(), 4000);
|
|
153
|
-
fetch(REPORT_URL, { method: "POST", headers, body: JSON.stringify(body), signal: ac.signal })
|
|
154
|
-
.catch(() => {}).finally(() => clearTimeout(timer));
|
|
155
|
-
} catch { /* телеметрия не имеет права влиять на работу */ }
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// ---- Проверка обновлений ----
|
|
159
|
-
const UPDATE_FILE = path.join(CONFIG_DIR, "update-check.json");
|
|
160
|
-
const UPDATE_TTL_MS = 6 * 60 * 60 * 1000;
|
|
161
|
-
const AUTOUPDATE = (process.env.RETENSY_MCP_AUTOUPDATE || "1").trim() !== "0";
|
|
162
|
-
// Запущены из node_modules/_npx → пакет обновляется npm. Запущены из git-чекаута
|
|
163
|
-
// (плагин Claude Code) → npm бесполезен: исполняется файл репозитория, а не пакет.
|
|
164
|
-
const SELF_DIR = (() => { try { return path.dirname(fileURLToPath(import.meta.url)); } catch { return ""; } })();
|
|
165
|
-
const IS_NPM_INSTALL = /[\\/](node_modules|_npx)[\\/]/.test(SELF_DIR);
|
|
166
|
-
let updateNotice = "";
|
|
167
|
-
let noticeDelivered = false;
|
|
168
|
-
|
|
169
|
-
function cmpVersions(a, b) {
|
|
170
|
-
const pa = String(a).split("."), pb = String(b).split(".");
|
|
171
|
-
for (let i = 0; i < 3; i += 1) {
|
|
172
|
-
const x = parseInt(pa[i], 10) || 0, y = parseInt(pb[i], 10) || 0;
|
|
173
|
-
if (x !== y) return x > y ? 1 : -1;
|
|
174
|
-
}
|
|
175
|
-
return 0;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
function spawnSelfUpdate() {
|
|
179
|
-
try {
|
|
180
|
-
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
181
|
-
// stdio:"ignore" обязателен: любой вывод в stdout сломал бы JSON-RPC.
|
|
182
|
-
const child = spawn(npm, ["i", "-g", `${PKG_NAME}@latest`],
|
|
183
|
-
{ detached: true, stdio: "ignore", shell: process.platform === "win32" });
|
|
184
|
-
child.unref();
|
|
185
|
-
return true;
|
|
186
|
-
} catch { return false; }
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function applyUpdateNotice(latest) {
|
|
190
|
-
updateNotice = `⬆️ Доступна новая версия retensy-mcp: ${VERSION} → ${latest}. `;
|
|
191
|
-
if (IS_NPM_INSTALL) {
|
|
192
|
-
// Процесс НЕ МОЖЕТ подменить свой уже загруженный код — обновление вступит в силу
|
|
193
|
-
// только после перезапуска MCP-сервера. Честно об этом пишем.
|
|
194
|
-
updateNotice += AUTOUPDATE && spawnSelfUpdate()
|
|
195
|
-
? "Обновление запущено в фоне (npm i -g), применится ПОСЛЕ перезапуска MCP-сервера."
|
|
196
|
-
: `Обнови вручную: npm i -g ${PKG_NAME}@latest, затем перезапусти MCP-сервер.`;
|
|
197
|
-
} else {
|
|
198
|
-
updateNotice += "Сервер запущен из репозитория/плагина — обнови плагин (git pull) и перезапусти MCP-сервер.";
|
|
199
|
-
}
|
|
200
|
-
process.stderr.write(`[retensy-mcp] ${updateNotice}\n`);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/** Тихо: нет сети или реестр недоступен — работа не должна ломаться. */
|
|
204
|
-
async function checkForUpdate() {
|
|
205
|
-
try {
|
|
206
|
-
const cached = JSON.parse(fs.readFileSync(UPDATE_FILE, "utf8"));
|
|
207
|
-
if (cached && Date.now() - cached.at < UPDATE_TTL_MS) {
|
|
208
|
-
if (cached.latest && cmpVersions(cached.latest, VERSION) > 0) applyUpdateNotice(cached.latest);
|
|
209
|
-
return;
|
|
210
|
-
}
|
|
211
|
-
} catch { /* кэша нет или он битый — проверяем в реестре */ }
|
|
212
|
-
let latest = "";
|
|
213
|
-
try {
|
|
214
|
-
const ac = new AbortController();
|
|
215
|
-
const timer = setTimeout(() => ac.abort(), 4000);
|
|
216
|
-
// Сокращённый packument (~700 байт) + dist-tags.latest. ВАЖНО: на эндпоинте
|
|
217
|
-
// /<pkg>/latest этот accept даёт HTTP 406 — заголовок работает только на packument.
|
|
218
|
-
const res = await fetch(`https://registry.npmjs.org/${PKG_NAME.replace("/", "%2f")}`,
|
|
219
|
-
{ headers: { accept: "application/vnd.npm.install-v1+json" }, signal: ac.signal });
|
|
220
|
-
clearTimeout(timer);
|
|
221
|
-
if (res.ok) latest = (await res.json())?.["dist-tags"]?.latest || "";
|
|
222
|
-
} catch { return; }
|
|
223
|
-
if (!latest) return;
|
|
224
|
-
try {
|
|
225
|
-
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
226
|
-
fs.writeFileSync(UPDATE_FILE, JSON.stringify({ at: Date.now(), latest }));
|
|
227
|
-
} catch { /* не смогли записать кэш — не страшно */ }
|
|
228
|
-
if (cmpVersions(latest, VERSION) > 0) applyUpdateNotice(latest);
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
const NO_AUTH_HELP =
|
|
232
|
-
"Нет доступа к retensy /bots — не настроена авторизация.\n\n" +
|
|
233
|
-
`Как подключить (помоги пользователю по шагам):\n` +
|
|
234
|
-
`1) Открой ${TOKENS_PAGE} и войди в аккаунт.\n` +
|
|
235
|
-
`2) Нажми «Создать токен», скопируй секрет (вид zmcp_...). Он показывается один раз.\n` +
|
|
236
|
-
`3) Пришли токен сюда — и я вызову инструмент set_token, чтобы сохранить его. (Или задай env RETENSY_MCP_TOKEN.)\n\n` +
|
|
237
|
-
"Подсказка ассистенту: попроси пользователя выполнить шаги 1–2 и прислать токен, затем вызови set_token с этим значением.";
|
|
238
|
-
|
|
239
|
-
function authHeaders() {
|
|
240
|
-
const h = { "Content-Type": "application/json" };
|
|
241
|
-
const token = getToken();
|
|
242
|
-
const cookie = getCookie();
|
|
243
|
-
if (token) h.Authorization = `Bearer ${token}`;
|
|
244
|
-
else if (cookie) h.Cookie = cookie;
|
|
245
|
-
return h;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// Ошибка не-2xx ответа — один форматтер на все вызовы API. 422 с errors[] — отказ проверок (publish, а с
|
|
249
|
-
// аудита H2 и PUT активного графа). Агенту нужны ВСЕ причины: раньше здесь был JSON, обрезанный до 600
|
|
250
|
-
// символов, и хвост ошибок терялся. Прочее тело — как есть; пустое (так отвечают 409 и многие 400) —
|
|
251
|
-
// только статус, без «null» вместо причины.
|
|
252
|
-
function httpError(method, path_, status, data) {
|
|
253
|
-
const errors = status === 422 && Array.isArray(data?.errors) ? data.errors : null;
|
|
254
|
-
const raw = data == null ? "" : typeof data === "string" ? data : JSON.stringify(data);
|
|
255
|
-
const msg = errors
|
|
256
|
-
? `отклонено проверками (ошибок: ${errors.length}):\n` +
|
|
257
|
-
errors.map((e) => `${e?.code || "?"}${e?.nodeId ? `@${e.nodeId}` : ""}: ${e?.message || ""}`).join("\n")
|
|
258
|
-
: raw.slice(0, 600);
|
|
259
|
-
const err = new Error(`${method} ${path_} → HTTP ${status}.${msg ? ` ${msg}` : ""}`);
|
|
260
|
-
err.status = status; // структурный разбор — арка E (fe#28)
|
|
261
|
-
err.data = data;
|
|
262
|
-
return err;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
async function api(path_, { method = "GET", body, rawBody } = {}) {
|
|
266
|
-
if (!isAuthed()) throw new Error(NO_AUTH_HELP);
|
|
267
|
-
const res = await fetch(`${BASE}${path_}`, {
|
|
268
|
-
method,
|
|
269
|
-
headers: authHeaders(),
|
|
270
|
-
body: rawBody ?? (body !== undefined ? JSON.stringify(body) : undefined),
|
|
271
|
-
});
|
|
272
|
-
const text = await res.text();
|
|
273
|
-
let data = null;
|
|
274
|
-
try { data = text ? JSON.parse(text) : null; } catch { data = text; }
|
|
275
|
-
if (!res.ok) {
|
|
276
|
-
if (res.status === 401) {
|
|
277
|
-
throw new Error(`Доступ отклонён (HTTP 401). Токен невалиден, отозван или истёк.\n` +
|
|
278
|
-
`Создай новый на ${TOKENS_PAGE} и пришли мне — я сохраню через set_token.`);
|
|
279
|
-
}
|
|
280
|
-
if (res.status === 403) {
|
|
281
|
-
// 403 бэкенд отдаёт и на «не твой бот/граф/подключение» — это не всегда про токен.
|
|
282
|
-
const why = bodyReason(data);
|
|
283
|
-
throw new Error(`Доступ отклонён (HTTP 403)${why ? `: ${why}` : ""}. Нет прав на этот объект (чужой бот/граф/подключение) ` +
|
|
284
|
-
`или токен не действует. Если так отвечает любой инструмент — создай новый токен на ${TOKENS_PAGE} и пришли мне (set_token).`);
|
|
285
|
-
}
|
|
286
|
-
if (res.status === 402) throw paymentError(data);
|
|
287
|
-
throw httpError(method, path_, res.status, data);
|
|
288
|
-
}
|
|
289
|
-
return data;
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
/** Причина отказа из тела ответа: {error}/{message} или строка. */
|
|
293
|
-
function bodyReason(data) {
|
|
294
|
-
if (data == null) return "";
|
|
295
|
-
if (typeof data === "string") return data.slice(0, 300);
|
|
296
|
-
const e = data.message || data.error;
|
|
297
|
-
return typeof e === "string" && e !== "Forbidden" && e !== "Payment Required" ? e.slice(0, 300) : "";
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
const SUBSCRIPTION_PAGE = `${BASE}/bots/subscription`;
|
|
301
|
-
const PAYMENT_REASONS = {
|
|
302
|
-
broadcast_not_available: "рассылки недоступны на текущем тарифе",
|
|
303
|
-
broadcast_quota_exceeded: "исчерпана месячная квота получателей рассылок",
|
|
304
|
-
};
|
|
305
|
-
/** 402 — оплата/тариф: через API не решается, отдаём ссылку, которую пользователь откроет сам. */
|
|
306
|
-
function paymentError(data) {
|
|
307
|
-
const code = data && typeof data === "object" ? data.error : "";
|
|
308
|
-
const reason = PAYMENT_REASONS[code] || bodyReason(data) || "лимит тарифа исчерпан";
|
|
309
|
-
const url = (data && typeof data === "object" && typeof data.upgradeUrl === "string" && data.upgradeUrl) || SUBSCRIPTION_PAGE;
|
|
310
|
-
const count = data && typeof data === "object" && data.count != null ? ` (получателей: ${data.count})` : "";
|
|
311
|
-
const err = new Error(`Нужен тариф выше (HTTP 402): ${reason}${count}.\n` +
|
|
312
|
-
`🔗 Открой ${url} — смена тарифа/оплата делается только в браузере. После оплаты повтори действие.`);
|
|
313
|
-
err.status = 402;
|
|
314
|
-
err.data = data;
|
|
315
|
-
return err;
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
/**
|
|
319
|
-
* Действие, которое нельзя сделать через API (OAuth/вход/оплата/2FA): не падаем, а отдаём прямую ссылку и
|
|
320
|
-
* одну строку инструкции — ассистент передаёт её пользователю как есть.
|
|
321
|
-
*/
|
|
322
|
-
function linkResult(title, url, instruction, extra) {
|
|
323
|
-
return okResult({ needsBrowser: true, title, url, instruction, ...(extra || {}) });
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
// MIME по расширению — уходит как Content-Type части multipart, бэкенд по нему определяет тип медиа.
|
|
327
|
-
const MIME_BY_EXT = {
|
|
328
|
-
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp", ".gif": "image/gif",
|
|
329
|
-
".mp4": "video/mp4", ".webm": "video/webm", ".mov": "video/quicktime",
|
|
330
|
-
".mp3": "audio/mpeg", ".ogg": "audio/ogg", ".oga": "audio/ogg", ".wav": "audio/wav", ".m4a": "audio/mp4",
|
|
331
|
-
".pdf": "application/pdf", ".zip": "application/zip", ".doc": "application/msword", ".txt": "text/plain",
|
|
332
|
-
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
333
|
-
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
334
|
-
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
335
|
-
};
|
|
336
|
-
const guessMime = (name) => MIME_BY_EXT[path.extname(String(name || "")).toLowerCase()] || "application/octet-stream";
|
|
337
|
-
|
|
338
|
-
// Загрузка файла в библиотеку /bots/files (POST /api/bots/media, multipart). Свой fetch:
|
|
339
|
-
// у api() Content-Type=application/json, для multipart его ставить нельзя (fetch сам задаёт boundary).
|
|
340
|
-
async function uploadMedia({ filePath, url, filename }) {
|
|
341
|
-
if (!isAuthed()) throw new Error(NO_AUTH_HELP);
|
|
342
|
-
let bytes, name, mime;
|
|
343
|
-
if (filePath) {
|
|
344
|
-
const abs = path.resolve(String(filePath).replace(/^~(?=$|[/\\])/, os.homedir()));
|
|
345
|
-
try { bytes = fs.readFileSync(abs); } catch { throw new Error(`Файл не найден: ${abs}`); }
|
|
346
|
-
name = filename || path.basename(abs);
|
|
347
|
-
mime = guessMime(name);
|
|
348
|
-
} else if (url) {
|
|
349
|
-
const r = await fetch(url);
|
|
350
|
-
if (!r.ok) throw new Error(`Не удалось скачать файл по url (HTTP ${r.status}).`);
|
|
351
|
-
bytes = Buffer.from(await r.arrayBuffer());
|
|
352
|
-
let base = "file"; try { base = path.basename(new URL(url).pathname) || "file"; } catch { /* ignore */ }
|
|
353
|
-
name = filename || base;
|
|
354
|
-
mime = r.headers.get("content-type") || guessMime(name);
|
|
355
|
-
} else {
|
|
356
|
-
throw new Error("Передай path (локальный файл) ИЛИ url (ссылку для перезаливки).");
|
|
357
|
-
}
|
|
358
|
-
const headers = {};
|
|
359
|
-
const token = getToken(); const cookie = getCookie();
|
|
360
|
-
if (token) headers.Authorization = `Bearer ${token}`;
|
|
361
|
-
else if (cookie) headers.Cookie = cookie;
|
|
362
|
-
const fd = new FormData();
|
|
363
|
-
fd.append("file", new Blob([bytes], { type: mime }), name);
|
|
364
|
-
const res = await fetch(`${BASE}/api/bots/media`, { method: "POST", headers, body: fd });
|
|
365
|
-
const text = await res.text();
|
|
366
|
-
let data = null; try { data = text ? JSON.parse(text) : null; } catch { data = text; }
|
|
367
|
-
if (!res.ok) {
|
|
368
|
-
if (res.status === 401 || res.status === 403) throw new Error(`Доступ отклонён (HTTP ${res.status}). Токен невалиден/отозван — создай новый на ${TOKENS_PAGE}.`);
|
|
369
|
-
if (res.status === 402) throw new Error(`Лимит хранилища тарифа исчерпан (HTTP 402). Удали ненужные файлы (delete_file) или подними тариф: 🔗 ${SUBSCRIPTION_PAGE}`);
|
|
370
|
-
if (res.status === 413) throw new Error("Файл больше 50 МБ (HTTP 413) — лимит Telegram для видео/документов.");
|
|
371
|
-
throw httpError("POST", "/api/bots/media", res.status, data);
|
|
372
|
-
}
|
|
373
|
-
return data;
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
// Ассет сайта из блоков: POST /api/bots/pages/{id}/upload (multipart, dir=assets). Бэкенд принимает имена только из
|
|
377
|
-
// [A-Za-z0-9._@()+- ], поэтому имя приводим к латинице с коротким суффиксом.
|
|
378
|
-
async function uploadSiteAsset(siteId, { filePath, url }) {
|
|
379
|
-
if (!isAuthed()) throw new Error(NO_AUTH_HELP);
|
|
380
|
-
if (!siteId) throw new Error("Передай siteId.");
|
|
381
|
-
let bytes, name, mime;
|
|
382
|
-
if (filePath) {
|
|
383
|
-
const abs = path.resolve(String(filePath).replace(/^~(?=$|[/\\])/, os.homedir()));
|
|
384
|
-
try { bytes = fs.readFileSync(abs); } catch { throw new Error(`Файл не найден: ${abs}`); }
|
|
385
|
-
name = path.basename(abs);
|
|
386
|
-
mime = guessMime(name);
|
|
387
|
-
} else if (url) {
|
|
388
|
-
const r = await fetch(url);
|
|
389
|
-
if (!r.ok) throw new Error(`Не удалось скачать файл по url (HTTP ${r.status}).`);
|
|
390
|
-
bytes = Buffer.from(await r.arrayBuffer());
|
|
391
|
-
try { name = path.basename(new URL(url).pathname) || "file"; } catch { name = "file"; }
|
|
392
|
-
mime = r.headers.get("content-type") || guessMime(name);
|
|
393
|
-
} else {
|
|
394
|
-
throw new Error("Передай path (локальный файл) ИЛИ url.");
|
|
395
|
-
}
|
|
396
|
-
const ext = path.extname(name).toLowerCase().replace(/[^.a-z0-9]/g, "").slice(0, 9);
|
|
397
|
-
const base = path.basename(name, path.extname(name)).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "file";
|
|
398
|
-
const safe = `${base}-${Math.random().toString(36).slice(2, 6)}${ext}`;
|
|
399
|
-
const headers = {};
|
|
400
|
-
const token = getToken(); const cookie = getCookie();
|
|
401
|
-
if (token) headers.Authorization = `Bearer ${token}`;
|
|
402
|
-
else if (cookie) headers.Cookie = cookie;
|
|
403
|
-
const fd = new FormData();
|
|
404
|
-
fd.append("files", new Blob([bytes], { type: mime }), safe);
|
|
405
|
-
fd.append("dir", "assets");
|
|
406
|
-
const res = await fetch(`${BASE}/api/bots/pages/${siteId}/upload`, { method: "POST", headers, body: fd });
|
|
407
|
-
const text = await res.text();
|
|
408
|
-
let data = null; try { data = text ? JSON.parse(text) : null; } catch { data = text; }
|
|
409
|
-
if (!res.ok) {
|
|
410
|
-
if (res.status === 401 || res.status === 403) throw new Error(`Доступ отклонён (HTTP ${res.status}). Токен невалиден/отозван — создай новый на ${TOKENS_PAGE}.`);
|
|
411
|
-
if (res.status === 402) throw new Error(`Лимит хранилища тарифа исчерпан (HTTP 402). Подними тариф: 🔗 ${SUBSCRIPTION_PAGE}`);
|
|
412
|
-
throw httpError("POST", `/api/bots/pages/${siteId}/upload`, res.status, data);
|
|
413
|
-
}
|
|
414
|
-
return { asset: `assets/${safe}`, sizeBytes: bytes.length };
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
const okResult = (obj) => ({ content: [{ type: "text", text: typeof obj === "string" ? obj : JSON.stringify(obj, null, 2) }] });
|
|
418
|
-
const errResult = (e) => ({ isError: true, content: [{ type: "text", text: "❌ " + (e?.message || String(e)) }] });
|
|
419
|
-
|
|
420
|
-
function extractGraph(g) {
|
|
421
|
-
if (!g || typeof g !== "object") throw new Error("graph должен быть объектом (контейнер retensy-bot-graph или {nodes,edges}).");
|
|
422
|
-
const nodes = g.nodes ?? g.graph?.nodes;
|
|
423
|
-
const edges = g.edges ?? g.graph?.edges;
|
|
424
|
-
if (!Array.isArray(nodes) || !Array.isArray(edges)) throw new Error("В graph нет массивов nodes[] и edges[].");
|
|
425
|
-
return { name: g.name, nodes, edges, canvasMeta: g.canvasMeta ?? {} };
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
// Прочитать граф из локального файла (поддерживается ~). MCP исполняется на машине пользователя,
|
|
429
|
-
// поэтому большой граф можно не передавать инлайном, а сослаться файлом — без обрезания/ошибок.
|
|
430
|
-
function readGraphFile(p) {
|
|
431
|
-
const abs = path.resolve(String(p).replace(/^~(?=$|[/\\])/, os.homedir()));
|
|
432
|
-
let raw;
|
|
433
|
-
try { raw = fs.readFileSync(abs, "utf8"); } catch { throw new Error(`Файл графа не найден: ${abs}`); }
|
|
434
|
-
let obj;
|
|
435
|
-
try { obj = JSON.parse(raw); } catch (e) { throw new Error(`Файл графа — невалидный JSON: ${abs}. ${e?.message || e}`); }
|
|
436
|
-
return obj;
|
|
437
|
-
}
|
|
438
|
-
// Источник графа для пишущих инструментов: graphFile (путь) > graph (контейнер) > nodes/edges.
|
|
439
|
-
function resolveGraphInput(a) {
|
|
440
|
-
if (a.graphFile) return extractGraph(readGraphFile(a.graphFile));
|
|
441
|
-
if (a.graph) return extractGraph(a.graph);
|
|
442
|
-
return { nodes: a.nodes, edges: a.edges, canvasMeta: a.canvasMeta ?? {}, name: a.name };
|
|
443
|
-
}
|
|
444
|
-
// Сервер хранит id узлов/рёбер как UUID: иначе PUT падал голым HTTP 400 без причины.
|
|
445
|
-
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
446
|
-
function assertGraphIds(g) {
|
|
447
|
-
const bad = [];
|
|
448
|
-
for (const n of g.nodes || []) if (!UUID_RE.test(String(n?.id))) bad.push(`узел ${n?.id}`);
|
|
449
|
-
for (const e of g.edges || []) {
|
|
450
|
-
for (const k of ["id", "sourceNodeId", "targetNodeId"]) if (!UUID_RE.test(String(e?.[k]))) bad.push(`ребро ${e?.id ?? "?"}.${k}=${e?.[k]}`);
|
|
451
|
-
}
|
|
452
|
-
if (bad.length) throw new Error(`id узлов и рёбер должны быть UUID (crypto.randomUUID()). Не UUID: ${bad.slice(0, 10).join(", ")}${bad.length > 10 ? ` и ещё ${bad.length - 10}` : ""}.`);
|
|
453
|
-
}
|
|
454
|
-
// chatId бывает больше 2^53 (виджет, MAX) — принимаем строкой, пропускаем только цифры (и минус у групп).
|
|
455
|
-
function pageQs(a) {
|
|
456
|
-
const q = [];
|
|
457
|
-
if (a.page != null) q.push(`page=${encodeURIComponent(a.page)}`);
|
|
458
|
-
if (a.size != null) q.push(`size=${encodeURIComponent(a.size)}`);
|
|
459
|
-
return q.length ? `?${q.join("&")}` : "";
|
|
460
|
-
}
|
|
461
|
-
function chatIdArg(v) {
|
|
462
|
-
const s = String(v ?? "").trim();
|
|
463
|
-
if (!/^-?\d{1,20}$/.test(s)) throw new Error(`chatId — число (из list_bot_users), получено: ${s || "пусто"}.`);
|
|
464
|
-
return s;
|
|
465
|
-
}
|
|
466
|
-
function uuidArg(v, label) {
|
|
467
|
-
const s = String(v ?? "").trim();
|
|
468
|
-
if (!UUID_RE.test(s)) throw new Error(`${label} — UUID (например, из list_bots), получено: ${s || "пусто"}.`);
|
|
469
|
-
return s;
|
|
470
|
-
}
|
|
471
|
-
// id из Mongo/строковые ключи: только безопасный сегмент пути, без «/», «..», пробелов.
|
|
472
|
-
function idArg(v, label) {
|
|
473
|
-
const s = String(v ?? "").trim();
|
|
474
|
-
if (!/^[A-Za-z0-9_-]{1,64}$/.test(s)) throw new Error(`${label} — id из соответствующего списка, получено: ${s || "пусто"}.`);
|
|
475
|
-
return s;
|
|
476
|
-
}
|
|
477
|
-
function enumArg(v, label, allowed) {
|
|
478
|
-
const s = String(v ?? "").trim().toUpperCase();
|
|
479
|
-
if (!allowed.includes(s)) throw new Error(`${label}: ${allowed.join(" | ")}, получено: ${s || "пусто"}.`);
|
|
480
|
-
return s;
|
|
481
|
-
}
|
|
482
|
-
const LEAD_STATUSES = ["NEW", "IN_PROGRESS", "DONE", "REJECTED"];
|
|
483
|
-
function dateArg(v, label) {
|
|
484
|
-
const s = String(v ?? "").trim();
|
|
485
|
-
if (!/^\d{4}-\d{2}-\d{2}$/.test(s) || Number.isNaN(Date.parse(s))) throw new Error(`${label} — дата ГГГГ-ММ-ДД, получено: ${s || "пусто"}.`);
|
|
486
|
-
return s;
|
|
487
|
-
}
|
|
488
|
-
const HHMM_RE = /^([01]\d|2[0-3]):[0-5]\d$|^24:00$/;
|
|
489
|
-
// Поля календаря записи (BookingCalendar): переданные накладываются на base (для PUT — текущий календарь целиком).
|
|
490
|
-
function calendarBody(a, base = {}) {
|
|
491
|
-
const b = { ...base };
|
|
492
|
-
if (a.name != null) b.name = String(a.name).trim();
|
|
493
|
-
if (a.zone != null) b.zone = String(a.zone).trim();
|
|
494
|
-
if (a.botId !== undefined) b.botId = a.botId === null || a.botId === "" ? null : uuidArg(a.botId, "botId");
|
|
495
|
-
if (a.slotMinutes != null) {
|
|
496
|
-
const n = Number(a.slotMinutes);
|
|
497
|
-
if (!Number.isInteger(n) || n < 5 || n > 1440) throw new Error("slotMinutes — целое от 5 до 1440.");
|
|
498
|
-
b.slotMinutes = n;
|
|
499
|
-
}
|
|
500
|
-
if (a.hours != null) {
|
|
501
|
-
if (!Array.isArray(a.hours)) throw new Error("hours — массив [{day: 1–7, from: \"HH:mm\", to: \"HH:mm\"}].");
|
|
502
|
-
a.hours.forEach((h, i) => {
|
|
503
|
-
if (!Number.isInteger(h?.day) || h.day < 1 || h.day > 7) throw new Error(`hours[${i}].day — от 1 (пн) до 7 (вс).`);
|
|
504
|
-
if (!HHMM_RE.test(String(h.from)) || !HHMM_RE.test(String(h.to))) throw new Error(`hours[${i}]: from/to — время HH:mm.`);
|
|
505
|
-
});
|
|
506
|
-
b.hours = a.hours.map((h) => ({ day: h.day, from: h.from, to: h.to }));
|
|
507
|
-
}
|
|
508
|
-
if (a.exceptions != null) {
|
|
509
|
-
if (!Array.isArray(a.exceptions)) throw new Error("exceptions — массив [{date: \"ГГГГ-ММ-ДД\", from?, to?}].");
|
|
510
|
-
b.exceptions = a.exceptions.map((e, i) => {
|
|
511
|
-
const date = dateArg(e?.date, `exceptions[${i}].date`);
|
|
512
|
-
const closed = !e.from && !e.to;
|
|
513
|
-
if (!closed && (!HHMM_RE.test(String(e.from)) || !HHMM_RE.test(String(e.to)))) throw new Error(`exceptions[${i}]: from/to — время HH:mm или оба пусты (выходной).`);
|
|
514
|
-
return closed ? { date } : { date, from: e.from, to: e.to };
|
|
515
|
-
});
|
|
516
|
-
}
|
|
517
|
-
return b;
|
|
518
|
-
}
|
|
519
|
-
// Компактная сводка графа (без объёмных text/cards/buttons) — чтобы не упираться в лимит токенов
|
|
520
|
-
// на больших графах. Узлы: id/type/title/позиция; рёбра: id/from/handle/to.
|
|
521
|
-
function graphSummary(g) {
|
|
522
|
-
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 }));
|
|
523
|
-
const edges = (g?.edges || []).map((e) => ({ id: e.id, from: e.sourceNodeId, h: e.sourceHandle, to: e.targetNodeId }));
|
|
524
|
-
return { graphId: g?.id, name: g?.name, status: g?.status, version: g?.version, counts: { nodes: nodes.length, edges: edges.length }, nodes, edges };
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
// =====================================================================// Рассылки
|
|
528
|
-
// =====================================================================// Бэкенд на 400 отдаёт только статус (без причины), поэтому правила TgBroadcastController.validateDirectMessage
|
|
529
|
-
// повторены здесь — агент получает понятную ошибку до запроса, а не голый «HTTP 400».
|
|
530
|
-
const BC_TYPES = ["TEXT", "PHOTO", "VIDEO", "AUDIO", "FILE", "VOICE", "VIDEONOTE", "GALLERY"];
|
|
531
|
-
const BC_MEDIA = new Set(["PHOTO", "VIDEO", "AUDIO", "FILE", "VOICE", "VIDEONOTE"]);
|
|
532
|
-
const BC_MAX_MESSAGES = 5, BC_MAX_BOTS = 20, BC_MAX_BUTTONS = 8, BC_TEXT_MAX = 4096, BC_CAPTION_MAX = 1024;
|
|
533
|
-
|
|
534
|
-
/**
|
|
535
|
-
* Сообщение рассылки как его строит мастер в кабинете (broadcastBlocks.ts → blocksToMessages).
|
|
536
|
-
* Принимает и сокращения: строка → TEXT; url → mediaUrl; urls → mediaUrls; type в любом регистре.
|
|
537
|
-
* strict=false (черновик) — только нормализация, без проверки полноты.
|
|
538
|
-
*/
|
|
539
|
-
function normalizeBroadcastMessage(m, i, strict = true) {
|
|
540
|
-
if (typeof m === "string") m = { type: "TEXT", text: m };
|
|
541
|
-
if (!m || typeof m !== "object") throw new Error(`messages[${i}]: ожидается объект {type, text?, mediaUrl?, mediaUrls?, buttons?}.`);
|
|
542
|
-
const mediaUrl = (m.mediaUrl || m.url || m.photoUrl || "").trim();
|
|
543
|
-
const type = String(m.type || (mediaUrl ? "PHOTO" : "TEXT")).toUpperCase();
|
|
544
|
-
if (!BC_TYPES.includes(type)) throw new Error(`messages[${i}]: неизвестный type ${type}. Бывают: ${BC_TYPES.join(", ")}.`);
|
|
545
|
-
const out = { type, parseMode: m.parseMode === null ? undefined : "HTML" };
|
|
546
|
-
const text = typeof m.text === "string" && m.text.trim() ? m.text : undefined;
|
|
547
|
-
if (text !== undefined && type !== "VIDEONOTE") out.text = text;
|
|
548
|
-
if (BC_MEDIA.has(type)) { out.mediaUrl = mediaUrl; if (type === "PHOTO") out.photoUrl = mediaUrl; }
|
|
549
|
-
if (type === "GALLERY") out.mediaUrls = (m.mediaUrls || m.urls || []).map((u) => String(u || "").trim()).filter(Boolean);
|
|
550
|
-
const buttons = (Array.isArray(m.buttons) ? m.buttons : [])
|
|
551
|
-
.filter((b) => b && String(b.text || "").trim() && String(b.url || "").trim())
|
|
552
|
-
.map((b) => ({ text: String(b.text).trim(), url: String(b.url).trim() }));
|
|
553
|
-
if (type !== "GALLERY") out.buttons = buttons;
|
|
554
|
-
if (!strict) return out;
|
|
555
|
-
if (type === "TEXT" && !out.text) throw new Error(`messages[${i}]: у TEXT нужен text.`);
|
|
556
|
-
if (BC_MEDIA.has(type) && !out.mediaUrl) throw new Error(`messages[${i}]: у ${type} нужен mediaUrl (загрузи файл через upload_file и возьми url).`);
|
|
557
|
-
if (type === "GALLERY" && (out.mediaUrls.length < 2 || out.mediaUrls.length > 10)) throw new Error(`messages[${i}]: GALLERY — от 2 до 10 картинок в mediaUrls.`);
|
|
558
|
-
if (type === "GALLERY" && buttons.length) throw new Error(`messages[${i}]: у GALLERY не бывает кнопок — вынеси их в следующее сообщение.`);
|
|
559
|
-
if (type === "VIDEONOTE" && text) throw new Error(`messages[${i}]: VIDEONOTE (кружок) не поддерживает текст.`);
|
|
560
|
-
const plain = (out.text || "").replace(/<[^>]+>/g, "").length;
|
|
561
|
-
const limit = type === "TEXT" ? BC_TEXT_MAX : BC_CAPTION_MAX;
|
|
562
|
-
if (plain > limit) throw new Error(`messages[${i}]: текст длиннее ${limit} символов (${plain}).`);
|
|
563
|
-
if (buttons.length > BC_MAX_BUTTONS) throw new Error(`messages[${i}]: не больше ${BC_MAX_BUTTONS} кнопок.`);
|
|
564
|
-
for (const b of buttons) if (!/^(https?:\/\/|tg:\/\/)/i.test(b.url)) throw new Error(`messages[${i}]: кнопка «${b.text}» — url должен быть ссылкой https://… (callback-кнопок в рассылке нет).`);
|
|
565
|
-
return out;
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
function normalizeBroadcastMessages(list, strict = true) {
|
|
569
|
-
if (!Array.isArray(list)) list = list == null ? [] : [list];
|
|
570
|
-
if (strict && (list.length < 1 || list.length > BC_MAX_MESSAGES)) throw new Error(`messages: от 1 до ${BC_MAX_MESSAGES} сообщений.`);
|
|
571
|
-
if (list.length > BC_MAX_MESSAGES) throw new Error(`messages: не больше ${BC_MAX_MESSAGES}.`);
|
|
572
|
-
return list.map((m, i) => normalizeBroadcastMessage(m, i, strict));
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
/** botIds из botIds[] или botId. */
|
|
576
|
-
function botIdsOf(a) {
|
|
577
|
-
const ids = Array.isArray(a.botIds) ? a.botIds : a.botId ? [a.botId] : [];
|
|
578
|
-
return [...new Set(ids.map(String).filter(Boolean))];
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
const strList = (v) => (Array.isArray(v) ? v : v ? [v] : []).map((t) => String(t).trim()).filter(Boolean);
|
|
582
|
-
|
|
583
|
-
/**
|
|
584
|
-
* Время запуска → ISO-instant. Строка без пояса (2026-10-06T10:00) считается московской (+03:00) — как
|
|
585
|
-
* продукт считает расписание повторов; «now»/пусто — сразу.
|
|
586
|
-
*/
|
|
587
|
-
function toInstant(v, field) {
|
|
588
|
-
if (v == null || v === "" || v === "now") return null;
|
|
589
|
-
let s = String(v).trim();
|
|
590
|
-
if (/^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?)?$/.test(s)) s = (s.length === 10 ? `${s}T00:00` : s.replace(" ", "T")) + "+03:00";
|
|
591
|
-
const d = new Date(s);
|
|
592
|
-
if (Number.isNaN(d.getTime())) throw new Error(`${field}: не распознал время «${v}». Формат ISO 8601, например 2026-10-06T10:00:00+03:00.`);
|
|
593
|
-
return d.toISOString();
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
/** У Instagram-ботов рассылок нет (окно 24 ч Meta) — отказываем до запроса. */
|
|
597
|
-
async function assertBroadcastBots(botIds) {
|
|
598
|
-
if (!botIds.length) throw new Error("Передай botIds — id ботов (list_bots), по которым рассылать.");
|
|
599
|
-
if (botIds.length > BC_MAX_BOTS) throw new Error(`botIds: не больше ${BC_MAX_BOTS} ботов за раз.`);
|
|
600
|
-
const bots = await api("/api/bots");
|
|
601
|
-
const byId = new Map((Array.isArray(bots) ? bots : []).map((b) => [String(b.id), b]));
|
|
602
|
-
for (const id of botIds) {
|
|
603
|
-
const b = byId.get(id);
|
|
604
|
-
if (!b) throw new Error(`Бот ${id} не найден среди твоих ботов (list_bots).`);
|
|
605
|
-
if (String(b.platform || "").toUpperCase() === "INSTAGRAM") {
|
|
606
|
-
throw new Error(`Бот ${b.name || b.username || id} — Instagram: рассылок у Instagram-ботов нет (Meta разрешает писать только в окне 24 ч после сообщения пользователя). Используй сценарий с триггером.`);
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
return byId;
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
function qs(params) {
|
|
613
|
-
const parts = Object.entries(params).filter(([, v]) => v != null && v !== "").map(([k, v]) => `${k}=${encodeURIComponent(v)}`);
|
|
614
|
-
return parts.length ? `?${parts.join("&")}` : "";
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
// Выгрузка (CSV/JSON): с savePath — в локальный файл (большие выгрузки не тащим в контекст), без — текстом.
|
|
618
|
-
function exportResult(data, savePath) {
|
|
619
|
-
const text = data == null ? "" : typeof data === "string" ? data : JSON.stringify(data, null, 2);
|
|
620
|
-
if (!savePath) return okResult(text || "Пусто: выгружать нечего.");
|
|
621
|
-
const abs = path.resolve(String(savePath).replace(/^~(?=$|[/\\])/, os.homedir()));
|
|
622
|
-
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
623
|
-
fs.writeFileSync(abs, text);
|
|
624
|
-
return okResult({ saved: abs, bytes: Buffer.byteLength(text) });
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
// =====================================================================// Подключения сервисов
|
|
628
|
-
// =====================================================================/** IA v2: каталог и подключения — раздел «Интеграции». Старые /bots/connect|integrations остаются в кабинете (Instagram — там). */
|
|
629
|
-
const CONNECT_PAGE = `${BASE}/integrations`;
|
|
630
|
-
const INTEGRATIONS_PAGE = CONNECT_PAGE;
|
|
631
|
-
const LEGACY_CONNECT_PAGE = `${BASE}/bots/connect`;
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
if (
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
{ name: "
|
|
670
|
-
{ name: "
|
|
671
|
-
{ name: "
|
|
672
|
-
{ name: "
|
|
673
|
-
{ name: "
|
|
674
|
-
{ name: "
|
|
675
|
-
{ name: "
|
|
676
|
-
{ name: "
|
|
677
|
-
{ name: "
|
|
678
|
-
{ name: "
|
|
679
|
-
{ name: "
|
|
680
|
-
{ name: "
|
|
681
|
-
{ name: "
|
|
682
|
-
{ name: "
|
|
683
|
-
{ name: "
|
|
684
|
-
{ name: "
|
|
685
|
-
{ name: "
|
|
686
|
-
{ name: "
|
|
687
|
-
{ name: "
|
|
688
|
-
{ name: "
|
|
689
|
-
{ name: "
|
|
690
|
-
{ name: "
|
|
691
|
-
{ name: "
|
|
692
|
-
{ name: "
|
|
693
|
-
{ name: "
|
|
694
|
-
{ name: "
|
|
695
|
-
{ name: "
|
|
696
|
-
{ name: "
|
|
697
|
-
{ name: "
|
|
698
|
-
{ name: "
|
|
699
|
-
{ name: "
|
|
700
|
-
{ name: "
|
|
701
|
-
{ name: "
|
|
702
|
-
{ name: "
|
|
703
|
-
{ name: "
|
|
704
|
-
|
|
705
|
-
"
|
|
706
|
-
"
|
|
707
|
-
"
|
|
708
|
-
"
|
|
709
|
-
"
|
|
710
|
-
"
|
|
711
|
-
"
|
|
712
|
-
"
|
|
713
|
-
"
|
|
714
|
-
"
|
|
715
|
-
|
|
716
|
-
{ name: "
|
|
717
|
-
{ name: "
|
|
718
|
-
{ name: "
|
|
719
|
-
{ name: "
|
|
720
|
-
{ name: "
|
|
721
|
-
{ name: "
|
|
722
|
-
{ name: "
|
|
723
|
-
{ name: "
|
|
724
|
-
{ name: "
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
{ name: "
|
|
728
|
-
{ name: "
|
|
729
|
-
{ name: "
|
|
730
|
-
{ name: "
|
|
731
|
-
{ name: "
|
|
732
|
-
{ name: "
|
|
733
|
-
{ name: "
|
|
734
|
-
{ name: "
|
|
735
|
-
{ name: "
|
|
736
|
-
{ name: "
|
|
737
|
-
{ name: "
|
|
738
|
-
{ name: "
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
{ name: "
|
|
742
|
-
{ name: "
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
{ name: "
|
|
746
|
-
{ name: "
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
{ name: "
|
|
750
|
-
{ name: "
|
|
751
|
-
{ name: "
|
|
752
|
-
|
|
753
|
-
"
|
|
754
|
-
"text
|
|
755
|
-
"
|
|
756
|
-
"
|
|
757
|
-
"
|
|
758
|
-
|
|
759
|
-
{ name: "
|
|
760
|
-
{ name: "
|
|
761
|
-
{ name: "
|
|
762
|
-
|
|
763
|
-
// ----
|
|
764
|
-
|
|
765
|
-
{ name: "
|
|
766
|
-
{ name: "
|
|
767
|
-
{ name: "
|
|
768
|
-
{ name: "
|
|
769
|
-
{ name: "
|
|
770
|
-
{ name: "
|
|
771
|
-
{ name: "
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
{ name: "
|
|
775
|
-
{ name: "
|
|
776
|
-
{ name: "
|
|
777
|
-
{ name: "
|
|
778
|
-
{ name: "
|
|
779
|
-
{ name: "
|
|
780
|
-
{ name: "
|
|
781
|
-
{ name: "
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
{ name: "
|
|
785
|
-
{ name: "
|
|
786
|
-
{ name: "
|
|
787
|
-
{ name: "
|
|
788
|
-
{ name: "
|
|
789
|
-
{ name: "
|
|
790
|
-
{ name: "
|
|
791
|
-
{ name: "
|
|
792
|
-
{ name: "
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
{ name: "
|
|
796
|
-
{ name: "
|
|
797
|
-
{ name: "
|
|
798
|
-
{ name: "
|
|
799
|
-
{ name: "
|
|
800
|
-
{ name: "
|
|
801
|
-
{ name: "
|
|
802
|
-
{ name: "
|
|
803
|
-
{ name: "
|
|
804
|
-
{ name: "
|
|
805
|
-
{ name: "
|
|
806
|
-
{ name: "
|
|
807
|
-
{ name: "
|
|
808
|
-
{ name: "
|
|
809
|
-
{ name: "
|
|
810
|
-
{ name: "
|
|
811
|
-
]
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
const
|
|
831
|
-
const
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
case "
|
|
842
|
-
case "
|
|
843
|
-
case "
|
|
844
|
-
case "
|
|
845
|
-
case "
|
|
846
|
-
case "
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
const
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
fs.
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
return okResult(g);
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
case "
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
const
|
|
893
|
-
const
|
|
894
|
-
const
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
//
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
if (r.find
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
if (
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
const
|
|
937
|
-
const
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
//
|
|
952
|
-
//
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
//
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
const
|
|
975
|
-
const
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
case "
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
case "
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
case "
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
if (a.
|
|
1026
|
-
if (a.
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
case "
|
|
1031
|
-
case "
|
|
1032
|
-
|
|
1033
|
-
if (
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
case "
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
if (
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
case "
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
if (a.
|
|
1066
|
-
if (a.
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
if (a.action === "
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
if (a.
|
|
1083
|
-
if (a.action === "
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
return okResult(withoutSecrets(await api(p_
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
case "
|
|
1093
|
-
case "
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
if (!a.
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
case "
|
|
1106
|
-
case "
|
|
1107
|
-
case "
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
case "
|
|
1113
|
-
case "
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
case "
|
|
1120
|
-
case "
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
case "
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
case "
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
case "
|
|
1148
|
-
case "
|
|
1149
|
-
case "
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
case "
|
|
1157
|
-
case "
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
const {
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
if (a.
|
|
1179
|
-
if (a.
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
const
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
throw
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
const
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
if (a.
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
throw
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
if (
|
|
1256
|
-
|
|
1257
|
-
const
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
if (a.rows.length
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
if (
|
|
1268
|
-
if (r.
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
return okResult(await api(`/api/bots/${
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
case "
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
const
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
throw e;
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
case "
|
|
1304
|
-
|
|
1305
|
-
const
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
throw e;
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
const
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
case "
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
throw
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
case "
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
const
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
case "
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
const
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
if (platform === "
|
|
1398
|
-
if (platform
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
throw e;
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
case "
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
if (provider === "
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
throw e;
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
//
|
|
1465
|
-
//
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
const
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
const
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
throw e;
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
const
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
const
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
const
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
body.scheduledAt
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
if (a.action === "
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
if (a.action === "
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
const
|
|
1606
|
-
const
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
return okResult(await api(
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
if (a.
|
|
1619
|
-
if (a.action === "
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
if (
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
}
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
rl
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
//
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
//
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
}
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
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
|
+
* RETENSY_MCP_TELEMETRY=off|on|full — отчёты о неудачах (по умолчанию on, без значений аргументов)
|
|
19
|
+
* RETENSY_MCP_REPORT_URL — свой webhook вместо нашего
|
|
20
|
+
* RETENSY_MCP_AUTOUPDATE=0 — не обновлять пакет автоматически
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { createInterface } from "node:readline";
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
25
|
+
import { spawn } from "node:child_process";
|
|
26
|
+
import os from "node:os";
|
|
27
|
+
import fs from "node:fs";
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
|
|
30
|
+
const VERSION = "0.21.2";
|
|
31
|
+
/** С чего начать пустой сайт (init у /document/ops; на сайте с черновиком игнорируется). */
|
|
32
|
+
const SITE_INITS = ["starter", "blank", "mini-landing"];
|
|
33
|
+
/** Безвредная операция, когда нужен только init: бэкенд не принимает пустой ops[]. */
|
|
34
|
+
const SITE_NOOP_OPS = [{ op: "set_settings", settings: {} }];
|
|
35
|
+
const PKG_NAME = "@retensy/mcp";
|
|
36
|
+
const BASE = (process.env.RETENSY_BASE_URL || "https://bots.retensy.com").replace(/\/+$/, "");
|
|
37
|
+
const CONFIG_DIR = path.join(os.homedir(), ".retensy-bot-graph");
|
|
38
|
+
const TOKEN_FILE = path.join(CONFIG_DIR, "token");
|
|
39
|
+
const TOKENS_PAGE = `${BASE}/bots/mcp-tokens`;
|
|
40
|
+
|
|
41
|
+
function readFileToken() {
|
|
42
|
+
try { return fs.readFileSync(TOKEN_FILE, "utf8").trim(); } catch { return ""; }
|
|
43
|
+
}
|
|
44
|
+
function getToken() {
|
|
45
|
+
// Если переменная не задана, Claude Code отдаёт шаблон "${RETENSY_MCP_TOKEN}" литералом —
|
|
46
|
+
// такой env нельзя считать токеном, иначе он перекрывает файл из set_token (вечный 401).
|
|
47
|
+
const env = (process.env.RETENSY_MCP_TOKEN || "").trim();
|
|
48
|
+
if (env && !env.startsWith("${")) return env;
|
|
49
|
+
return readFileToken();
|
|
50
|
+
}
|
|
51
|
+
function getCookie() {
|
|
52
|
+
return process.env.RETENSY_COOKIE ||
|
|
53
|
+
(process.env.RETENSY_SESSION_COOKIE ? `SESSION=${process.env.RETENSY_SESSION_COOKIE}` : "");
|
|
54
|
+
}
|
|
55
|
+
function saveToken(token) {
|
|
56
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
57
|
+
fs.writeFileSync(TOKEN_FILE, token.trim() + "\n", { mode: 0o600 });
|
|
58
|
+
try { fs.chmodSync(TOKEN_FILE, 0o600); } catch { /* windows */ }
|
|
59
|
+
}
|
|
60
|
+
function isAuthed() { return !!(getToken() || getCookie()); }
|
|
61
|
+
|
|
62
|
+
// =====================================================================// Отчёты о неудачах + проверка обновлений
|
|
63
|
+
// =====================================================================// ЗАЧЕМ: если клиент пытается сделать что-то, чего сервер не умеет (неизвестный
|
|
64
|
+
// инструмент, отказ публикации, ошибка API) — мы хотим об этом узнать и добавить
|
|
65
|
+
// поддержку. Отчёт уходит на webhook АНОНИМНО и БЕЗ СЕКРЕТОВ.
|
|
66
|
+
//
|
|
67
|
+
// Что уходит: имя инструмента, категория неудачи, текст ошибки, КЛЮЧИ аргументов
|
|
68
|
+
// (значения — только для безопасного списка полей вроде graphId/botId/kind),
|
|
69
|
+
// версия, платформа и анонимный id установки (хэш, не имя машины).
|
|
70
|
+
// Что НЕ уходит НИКОГДА: токены, cookie, пароли, креды интеграций, тела графов.
|
|
71
|
+
//
|
|
72
|
+
// Полностью выключить: RETENSY_MCP_TELEMETRY=off
|
|
73
|
+
// Присылать и значения аргументов (для отладки своей же установки): =full
|
|
74
|
+
//
|
|
75
|
+
// Отчёт уходит на НАШ эндпоинт `/api/mcp/report`, а не напрямую в мессенджер: адрес приёмника
|
|
76
|
+
// не должен лежать в публичном npm-пакете (оттуда его вытащил бы любой, а у вебхуков нет ни
|
|
77
|
+
// авторизации, ни лимита частоты). Сервер сам решает, куда переслать, и ограничивает частоту.
|
|
78
|
+
const REPORT_URL = (process.env.RETENSY_MCP_REPORT_URL || `${BASE}/api/mcp/report`).trim();
|
|
79
|
+
const REPORT_MODE = (process.env.RETENSY_MCP_TELEMETRY || "on").trim().toLowerCase();
|
|
80
|
+
/** Ключи, значения которых не отправляем ни в каком режиме. */
|
|
81
|
+
const SECRET_KEY_RX = /token|secret|cookie|passw|apikey|api_key|auth|cred/i;
|
|
82
|
+
/** Ключи, значения которых безопасны и реально нужны для разбора. */
|
|
83
|
+
const SAFE_ARG_KEYS = new Set(["graphId", "botId", "targetBotId", "templateId", "kind", "value",
|
|
84
|
+
"name", "slug", "id", "page", "size", "preview", "backup", "summary", "publish", "dryRun", "query"]);
|
|
85
|
+
const REPORT_MAX = 20; // на процесс: цикл ретраев не должен залить вебхук
|
|
86
|
+
let reportCount = 0;
|
|
87
|
+
const reportSeen = new Set(); // дедуп одинаковых неудач в рамках процесса
|
|
88
|
+
|
|
89
|
+
/** Стабильный анонимный id установки (FNV-1a) — группировать отчёты одного пользователя без PII. */
|
|
90
|
+
function installId() {
|
|
91
|
+
let h = 0x811c9dc5;
|
|
92
|
+
for (const ch of `${os.hostname()}|${os.homedir()}`) { h ^= ch.charCodeAt(0); h = (h * 0x01000193) >>> 0; }
|
|
93
|
+
return h.toString(16).padStart(8, "0");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function safeArgs(toolName, a) {
|
|
97
|
+
// set_token несёт секрет целиком — не сериализуем его вообще.
|
|
98
|
+
if (toolName === "set_token") return '{"token":"<скрыт>"}';
|
|
99
|
+
const out = {};
|
|
100
|
+
for (const [k, v] of Object.entries(a || {})) {
|
|
101
|
+
if (SECRET_KEY_RX.test(k)) { out[k] = "<скрыт>"; continue; }
|
|
102
|
+
const show = REPORT_MODE === "full" || SAFE_ARG_KEYS.has(k);
|
|
103
|
+
if (Array.isArray(v)) out[k] = `<array:${v.length}>`;
|
|
104
|
+
else if (v && typeof v === "object") out[k] = `<object:${Object.keys(v).length}>`;
|
|
105
|
+
else if (show) out[k] = typeof v === "string" ? v.slice(0, 120) : v;
|
|
106
|
+
else out[k] = `<${typeof v}>`;
|
|
107
|
+
}
|
|
108
|
+
return JSON.stringify(out).slice(0, 900); // сервер обрежет ещё раз, но зря тащить не будем
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function failureCategory(msg) {
|
|
112
|
+
const m = String(msg || "");
|
|
113
|
+
if (/^Неизвестный инструмент/.test(m)) return "unknown_tool";
|
|
114
|
+
// Токен вообще не настроен — это обычное состояние нового пользователя, а не пробел
|
|
115
|
+
// в возможностях: такие отчёты не отправляем (иначе каждый новичок зашумит канал).
|
|
116
|
+
if (/Нет доступа к retensy/.test(m)) return "not_configured";
|
|
117
|
+
if (/Доступ отклонён/.test(m)) return "auth_rejected"; // токен есть, но отвергнут — это стоит знать
|
|
118
|
+
const http = m.match(/HTTP (\d{3})/);
|
|
119
|
+
if (http) return `http_${http[1]}`;
|
|
120
|
+
return "error";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Fire-and-forget: никогда не задерживает и не ломает ответ инструмента. */
|
|
124
|
+
function reportFailure({ tool, args, message, category }) {
|
|
125
|
+
if (REPORT_MODE === "off" || !REPORT_URL || reportCount >= REPORT_MAX) return;
|
|
126
|
+
const cat = category || failureCategory(message);
|
|
127
|
+
if (cat === "not_configured") return;
|
|
128
|
+
const key = `${tool}|${cat}|${String(message || "").slice(0, 120)}`;
|
|
129
|
+
if (reportSeen.has(key)) return;
|
|
130
|
+
reportSeen.add(key);
|
|
131
|
+
reportCount += 1;
|
|
132
|
+
const body = {
|
|
133
|
+
tool: String(tool || "?").slice(0, 200),
|
|
134
|
+
category: cat,
|
|
135
|
+
message: String(message || "").slice(0, 1500),
|
|
136
|
+
args: safeArgs(tool, args),
|
|
137
|
+
mcpVersion: VERSION,
|
|
138
|
+
node: process.version,
|
|
139
|
+
platform: process.platform,
|
|
140
|
+
baseUrl: BASE,
|
|
141
|
+
installId: installId(),
|
|
142
|
+
};
|
|
143
|
+
const headers = { "Content-Type": "application/json" };
|
|
144
|
+
// Токен прикладываем ТОЛЬКО когда отчёт идёт на наш же адрес — тогда сервер покажет,
|
|
145
|
+
// кому именно не хватило возможности. На сторонний RETENSY_MCP_REPORT_URL токен не уходит.
|
|
146
|
+
if (REPORT_URL.startsWith(`${BASE}/`)) {
|
|
147
|
+
const token = getToken();
|
|
148
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
const ac = new AbortController();
|
|
152
|
+
const timer = setTimeout(() => ac.abort(), 4000);
|
|
153
|
+
fetch(REPORT_URL, { method: "POST", headers, body: JSON.stringify(body), signal: ac.signal })
|
|
154
|
+
.catch(() => {}).finally(() => clearTimeout(timer));
|
|
155
|
+
} catch { /* телеметрия не имеет права влиять на работу */ }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---- Проверка обновлений ----
|
|
159
|
+
const UPDATE_FILE = path.join(CONFIG_DIR, "update-check.json");
|
|
160
|
+
const UPDATE_TTL_MS = 6 * 60 * 60 * 1000;
|
|
161
|
+
const AUTOUPDATE = (process.env.RETENSY_MCP_AUTOUPDATE || "1").trim() !== "0";
|
|
162
|
+
// Запущены из node_modules/_npx → пакет обновляется npm. Запущены из git-чекаута
|
|
163
|
+
// (плагин Claude Code) → npm бесполезен: исполняется файл репозитория, а не пакет.
|
|
164
|
+
const SELF_DIR = (() => { try { return path.dirname(fileURLToPath(import.meta.url)); } catch { return ""; } })();
|
|
165
|
+
const IS_NPM_INSTALL = /[\\/](node_modules|_npx)[\\/]/.test(SELF_DIR);
|
|
166
|
+
let updateNotice = "";
|
|
167
|
+
let noticeDelivered = false;
|
|
168
|
+
|
|
169
|
+
function cmpVersions(a, b) {
|
|
170
|
+
const pa = String(a).split("."), pb = String(b).split(".");
|
|
171
|
+
for (let i = 0; i < 3; i += 1) {
|
|
172
|
+
const x = parseInt(pa[i], 10) || 0, y = parseInt(pb[i], 10) || 0;
|
|
173
|
+
if (x !== y) return x > y ? 1 : -1;
|
|
174
|
+
}
|
|
175
|
+
return 0;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function spawnSelfUpdate() {
|
|
179
|
+
try {
|
|
180
|
+
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
181
|
+
// stdio:"ignore" обязателен: любой вывод в stdout сломал бы JSON-RPC.
|
|
182
|
+
const child = spawn(npm, ["i", "-g", `${PKG_NAME}@latest`],
|
|
183
|
+
{ detached: true, stdio: "ignore", shell: process.platform === "win32" });
|
|
184
|
+
child.unref();
|
|
185
|
+
return true;
|
|
186
|
+
} catch { return false; }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function applyUpdateNotice(latest) {
|
|
190
|
+
updateNotice = `⬆️ Доступна новая версия retensy-mcp: ${VERSION} → ${latest}. `;
|
|
191
|
+
if (IS_NPM_INSTALL) {
|
|
192
|
+
// Процесс НЕ МОЖЕТ подменить свой уже загруженный код — обновление вступит в силу
|
|
193
|
+
// только после перезапуска MCP-сервера. Честно об этом пишем.
|
|
194
|
+
updateNotice += AUTOUPDATE && spawnSelfUpdate()
|
|
195
|
+
? "Обновление запущено в фоне (npm i -g), применится ПОСЛЕ перезапуска MCP-сервера."
|
|
196
|
+
: `Обнови вручную: npm i -g ${PKG_NAME}@latest, затем перезапусти MCP-сервер.`;
|
|
197
|
+
} else {
|
|
198
|
+
updateNotice += "Сервер запущен из репозитория/плагина — обнови плагин (git pull) и перезапусти MCP-сервер.";
|
|
199
|
+
}
|
|
200
|
+
process.stderr.write(`[retensy-mcp] ${updateNotice}\n`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Тихо: нет сети или реестр недоступен — работа не должна ломаться. */
|
|
204
|
+
async function checkForUpdate() {
|
|
205
|
+
try {
|
|
206
|
+
const cached = JSON.parse(fs.readFileSync(UPDATE_FILE, "utf8"));
|
|
207
|
+
if (cached && Date.now() - cached.at < UPDATE_TTL_MS) {
|
|
208
|
+
if (cached.latest && cmpVersions(cached.latest, VERSION) > 0) applyUpdateNotice(cached.latest);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
} catch { /* кэша нет или он битый — проверяем в реестре */ }
|
|
212
|
+
let latest = "";
|
|
213
|
+
try {
|
|
214
|
+
const ac = new AbortController();
|
|
215
|
+
const timer = setTimeout(() => ac.abort(), 4000);
|
|
216
|
+
// Сокращённый packument (~700 байт) + dist-tags.latest. ВАЖНО: на эндпоинте
|
|
217
|
+
// /<pkg>/latest этот accept даёт HTTP 406 — заголовок работает только на packument.
|
|
218
|
+
const res = await fetch(`https://registry.npmjs.org/${PKG_NAME.replace("/", "%2f")}`,
|
|
219
|
+
{ headers: { accept: "application/vnd.npm.install-v1+json" }, signal: ac.signal });
|
|
220
|
+
clearTimeout(timer);
|
|
221
|
+
if (res.ok) latest = (await res.json())?.["dist-tags"]?.latest || "";
|
|
222
|
+
} catch { return; }
|
|
223
|
+
if (!latest) return;
|
|
224
|
+
try {
|
|
225
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
226
|
+
fs.writeFileSync(UPDATE_FILE, JSON.stringify({ at: Date.now(), latest }));
|
|
227
|
+
} catch { /* не смогли записать кэш — не страшно */ }
|
|
228
|
+
if (cmpVersions(latest, VERSION) > 0) applyUpdateNotice(latest);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const NO_AUTH_HELP =
|
|
232
|
+
"Нет доступа к retensy /bots — не настроена авторизация.\n\n" +
|
|
233
|
+
`Как подключить (помоги пользователю по шагам):\n` +
|
|
234
|
+
`1) Открой ${TOKENS_PAGE} и войди в аккаунт.\n` +
|
|
235
|
+
`2) Нажми «Создать токен», скопируй секрет (вид zmcp_...). Он показывается один раз.\n` +
|
|
236
|
+
`3) Пришли токен сюда — и я вызову инструмент set_token, чтобы сохранить его. (Или задай env RETENSY_MCP_TOKEN.)\n\n` +
|
|
237
|
+
"Подсказка ассистенту: попроси пользователя выполнить шаги 1–2 и прислать токен, затем вызови set_token с этим значением.";
|
|
238
|
+
|
|
239
|
+
function authHeaders() {
|
|
240
|
+
const h = { "Content-Type": "application/json" };
|
|
241
|
+
const token = getToken();
|
|
242
|
+
const cookie = getCookie();
|
|
243
|
+
if (token) h.Authorization = `Bearer ${token}`;
|
|
244
|
+
else if (cookie) h.Cookie = cookie;
|
|
245
|
+
return h;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Ошибка не-2xx ответа — один форматтер на все вызовы API. 422 с errors[] — отказ проверок (publish, а с
|
|
249
|
+
// аудита H2 и PUT активного графа). Агенту нужны ВСЕ причины: раньше здесь был JSON, обрезанный до 600
|
|
250
|
+
// символов, и хвост ошибок терялся. Прочее тело — как есть; пустое (так отвечают 409 и многие 400) —
|
|
251
|
+
// только статус, без «null» вместо причины.
|
|
252
|
+
function httpError(method, path_, status, data) {
|
|
253
|
+
const errors = status === 422 && Array.isArray(data?.errors) ? data.errors : null;
|
|
254
|
+
const raw = data == null ? "" : typeof data === "string" ? data : JSON.stringify(data);
|
|
255
|
+
const msg = errors
|
|
256
|
+
? `отклонено проверками (ошибок: ${errors.length}):\n` +
|
|
257
|
+
errors.map((e) => `${e?.code || "?"}${e?.nodeId ? `@${e.nodeId}` : ""}: ${e?.message || ""}`).join("\n")
|
|
258
|
+
: raw.slice(0, 600);
|
|
259
|
+
const err = new Error(`${method} ${path_} → HTTP ${status}.${msg ? ` ${msg}` : ""}`);
|
|
260
|
+
err.status = status; // структурный разбор — арка E (fe#28)
|
|
261
|
+
err.data = data;
|
|
262
|
+
return err;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function api(path_, { method = "GET", body, rawBody } = {}) {
|
|
266
|
+
if (!isAuthed()) throw new Error(NO_AUTH_HELP);
|
|
267
|
+
const res = await fetch(`${BASE}${path_}`, {
|
|
268
|
+
method,
|
|
269
|
+
headers: authHeaders(),
|
|
270
|
+
body: rawBody ?? (body !== undefined ? JSON.stringify(body) : undefined),
|
|
271
|
+
});
|
|
272
|
+
const text = await res.text();
|
|
273
|
+
let data = null;
|
|
274
|
+
try { data = text ? JSON.parse(text) : null; } catch { data = text; }
|
|
275
|
+
if (!res.ok) {
|
|
276
|
+
if (res.status === 401) {
|
|
277
|
+
throw new Error(`Доступ отклонён (HTTP 401). Токен невалиден, отозван или истёк.\n` +
|
|
278
|
+
`Создай новый на ${TOKENS_PAGE} и пришли мне — я сохраню через set_token.`);
|
|
279
|
+
}
|
|
280
|
+
if (res.status === 403) {
|
|
281
|
+
// 403 бэкенд отдаёт и на «не твой бот/граф/подключение» — это не всегда про токен.
|
|
282
|
+
const why = bodyReason(data);
|
|
283
|
+
throw new Error(`Доступ отклонён (HTTP 403)${why ? `: ${why}` : ""}. Нет прав на этот объект (чужой бот/граф/подключение) ` +
|
|
284
|
+
`или токен не действует. Если так отвечает любой инструмент — создай новый токен на ${TOKENS_PAGE} и пришли мне (set_token).`);
|
|
285
|
+
}
|
|
286
|
+
if (res.status === 402) throw paymentError(data);
|
|
287
|
+
throw httpError(method, path_, res.status, data);
|
|
288
|
+
}
|
|
289
|
+
return data;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Причина отказа из тела ответа: {error}/{message} или строка. */
|
|
293
|
+
function bodyReason(data) {
|
|
294
|
+
if (data == null) return "";
|
|
295
|
+
if (typeof data === "string") return data.slice(0, 300);
|
|
296
|
+
const e = data.message || data.error;
|
|
297
|
+
return typeof e === "string" && e !== "Forbidden" && e !== "Payment Required" ? e.slice(0, 300) : "";
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const SUBSCRIPTION_PAGE = `${BASE}/bots/subscription`;
|
|
301
|
+
const PAYMENT_REASONS = {
|
|
302
|
+
broadcast_not_available: "рассылки недоступны на текущем тарифе",
|
|
303
|
+
broadcast_quota_exceeded: "исчерпана месячная квота получателей рассылок",
|
|
304
|
+
};
|
|
305
|
+
/** 402 — оплата/тариф: через API не решается, отдаём ссылку, которую пользователь откроет сам. */
|
|
306
|
+
function paymentError(data) {
|
|
307
|
+
const code = data && typeof data === "object" ? data.error : "";
|
|
308
|
+
const reason = PAYMENT_REASONS[code] || bodyReason(data) || "лимит тарифа исчерпан";
|
|
309
|
+
const url = (data && typeof data === "object" && typeof data.upgradeUrl === "string" && data.upgradeUrl) || SUBSCRIPTION_PAGE;
|
|
310
|
+
const count = data && typeof data === "object" && data.count != null ? ` (получателей: ${data.count})` : "";
|
|
311
|
+
const err = new Error(`Нужен тариф выше (HTTP 402): ${reason}${count}.\n` +
|
|
312
|
+
`🔗 Открой ${url} — смена тарифа/оплата делается только в браузере. После оплаты повтори действие.`);
|
|
313
|
+
err.status = 402;
|
|
314
|
+
err.data = data;
|
|
315
|
+
return err;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Действие, которое нельзя сделать через API (OAuth/вход/оплата/2FA): не падаем, а отдаём прямую ссылку и
|
|
320
|
+
* одну строку инструкции — ассистент передаёт её пользователю как есть.
|
|
321
|
+
*/
|
|
322
|
+
function linkResult(title, url, instruction, extra) {
|
|
323
|
+
return okResult({ needsBrowser: true, title, url, instruction, ...(extra || {}) });
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// MIME по расширению — уходит как Content-Type части multipart, бэкенд по нему определяет тип медиа.
|
|
327
|
+
const MIME_BY_EXT = {
|
|
328
|
+
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp", ".gif": "image/gif",
|
|
329
|
+
".mp4": "video/mp4", ".webm": "video/webm", ".mov": "video/quicktime",
|
|
330
|
+
".mp3": "audio/mpeg", ".ogg": "audio/ogg", ".oga": "audio/ogg", ".wav": "audio/wav", ".m4a": "audio/mp4",
|
|
331
|
+
".pdf": "application/pdf", ".zip": "application/zip", ".doc": "application/msword", ".txt": "text/plain",
|
|
332
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
333
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
334
|
+
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
335
|
+
};
|
|
336
|
+
const guessMime = (name) => MIME_BY_EXT[path.extname(String(name || "")).toLowerCase()] || "application/octet-stream";
|
|
337
|
+
|
|
338
|
+
// Загрузка файла в библиотеку /bots/files (POST /api/bots/media, multipart). Свой fetch:
|
|
339
|
+
// у api() Content-Type=application/json, для multipart его ставить нельзя (fetch сам задаёт boundary).
|
|
340
|
+
async function uploadMedia({ filePath, url, filename }) {
|
|
341
|
+
if (!isAuthed()) throw new Error(NO_AUTH_HELP);
|
|
342
|
+
let bytes, name, mime;
|
|
343
|
+
if (filePath) {
|
|
344
|
+
const abs = path.resolve(String(filePath).replace(/^~(?=$|[/\\])/, os.homedir()));
|
|
345
|
+
try { bytes = fs.readFileSync(abs); } catch { throw new Error(`Файл не найден: ${abs}`); }
|
|
346
|
+
name = filename || path.basename(abs);
|
|
347
|
+
mime = guessMime(name);
|
|
348
|
+
} else if (url) {
|
|
349
|
+
const r = await fetch(url);
|
|
350
|
+
if (!r.ok) throw new Error(`Не удалось скачать файл по url (HTTP ${r.status}).`);
|
|
351
|
+
bytes = Buffer.from(await r.arrayBuffer());
|
|
352
|
+
let base = "file"; try { base = path.basename(new URL(url).pathname) || "file"; } catch { /* ignore */ }
|
|
353
|
+
name = filename || base;
|
|
354
|
+
mime = r.headers.get("content-type") || guessMime(name);
|
|
355
|
+
} else {
|
|
356
|
+
throw new Error("Передай path (локальный файл) ИЛИ url (ссылку для перезаливки).");
|
|
357
|
+
}
|
|
358
|
+
const headers = {};
|
|
359
|
+
const token = getToken(); const cookie = getCookie();
|
|
360
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
361
|
+
else if (cookie) headers.Cookie = cookie;
|
|
362
|
+
const fd = new FormData();
|
|
363
|
+
fd.append("file", new Blob([bytes], { type: mime }), name);
|
|
364
|
+
const res = await fetch(`${BASE}/api/bots/media`, { method: "POST", headers, body: fd });
|
|
365
|
+
const text = await res.text();
|
|
366
|
+
let data = null; try { data = text ? JSON.parse(text) : null; } catch { data = text; }
|
|
367
|
+
if (!res.ok) {
|
|
368
|
+
if (res.status === 401 || res.status === 403) throw new Error(`Доступ отклонён (HTTP ${res.status}). Токен невалиден/отозван — создай новый на ${TOKENS_PAGE}.`);
|
|
369
|
+
if (res.status === 402) throw new Error(`Лимит хранилища тарифа исчерпан (HTTP 402). Удали ненужные файлы (delete_file) или подними тариф: 🔗 ${SUBSCRIPTION_PAGE}`);
|
|
370
|
+
if (res.status === 413) throw new Error("Файл больше 50 МБ (HTTP 413) — лимит Telegram для видео/документов.");
|
|
371
|
+
throw httpError("POST", "/api/bots/media", res.status, data);
|
|
372
|
+
}
|
|
373
|
+
return data;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Ассет сайта из блоков: POST /api/bots/pages/{id}/upload (multipart, dir=assets). Бэкенд принимает имена только из
|
|
377
|
+
// [A-Za-z0-9._@()+- ], поэтому имя приводим к латинице с коротким суффиксом.
|
|
378
|
+
async function uploadSiteAsset(siteId, { filePath, url }) {
|
|
379
|
+
if (!isAuthed()) throw new Error(NO_AUTH_HELP);
|
|
380
|
+
if (!siteId) throw new Error("Передай siteId.");
|
|
381
|
+
let bytes, name, mime;
|
|
382
|
+
if (filePath) {
|
|
383
|
+
const abs = path.resolve(String(filePath).replace(/^~(?=$|[/\\])/, os.homedir()));
|
|
384
|
+
try { bytes = fs.readFileSync(abs); } catch { throw new Error(`Файл не найден: ${abs}`); }
|
|
385
|
+
name = path.basename(abs);
|
|
386
|
+
mime = guessMime(name);
|
|
387
|
+
} else if (url) {
|
|
388
|
+
const r = await fetch(url);
|
|
389
|
+
if (!r.ok) throw new Error(`Не удалось скачать файл по url (HTTP ${r.status}).`);
|
|
390
|
+
bytes = Buffer.from(await r.arrayBuffer());
|
|
391
|
+
try { name = path.basename(new URL(url).pathname) || "file"; } catch { name = "file"; }
|
|
392
|
+
mime = r.headers.get("content-type") || guessMime(name);
|
|
393
|
+
} else {
|
|
394
|
+
throw new Error("Передай path (локальный файл) ИЛИ url.");
|
|
395
|
+
}
|
|
396
|
+
const ext = path.extname(name).toLowerCase().replace(/[^.a-z0-9]/g, "").slice(0, 9);
|
|
397
|
+
const base = path.basename(name, path.extname(name)).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "file";
|
|
398
|
+
const safe = `${base}-${Math.random().toString(36).slice(2, 6)}${ext}`;
|
|
399
|
+
const headers = {};
|
|
400
|
+
const token = getToken(); const cookie = getCookie();
|
|
401
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
402
|
+
else if (cookie) headers.Cookie = cookie;
|
|
403
|
+
const fd = new FormData();
|
|
404
|
+
fd.append("files", new Blob([bytes], { type: mime }), safe);
|
|
405
|
+
fd.append("dir", "assets");
|
|
406
|
+
const res = await fetch(`${BASE}/api/bots/pages/${siteId}/upload`, { method: "POST", headers, body: fd });
|
|
407
|
+
const text = await res.text();
|
|
408
|
+
let data = null; try { data = text ? JSON.parse(text) : null; } catch { data = text; }
|
|
409
|
+
if (!res.ok) {
|
|
410
|
+
if (res.status === 401 || res.status === 403) throw new Error(`Доступ отклонён (HTTP ${res.status}). Токен невалиден/отозван — создай новый на ${TOKENS_PAGE}.`);
|
|
411
|
+
if (res.status === 402) throw new Error(`Лимит хранилища тарифа исчерпан (HTTP 402). Подними тариф: 🔗 ${SUBSCRIPTION_PAGE}`);
|
|
412
|
+
throw httpError("POST", `/api/bots/pages/${siteId}/upload`, res.status, data);
|
|
413
|
+
}
|
|
414
|
+
return { asset: `assets/${safe}`, sizeBytes: bytes.length };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const okResult = (obj) => ({ content: [{ type: "text", text: typeof obj === "string" ? obj : JSON.stringify(obj, null, 2) }] });
|
|
418
|
+
const errResult = (e) => ({ isError: true, content: [{ type: "text", text: "❌ " + (e?.message || String(e)) }] });
|
|
419
|
+
|
|
420
|
+
function extractGraph(g) {
|
|
421
|
+
if (!g || typeof g !== "object") throw new Error("graph должен быть объектом (контейнер retensy-bot-graph или {nodes,edges}).");
|
|
422
|
+
const nodes = g.nodes ?? g.graph?.nodes;
|
|
423
|
+
const edges = g.edges ?? g.graph?.edges;
|
|
424
|
+
if (!Array.isArray(nodes) || !Array.isArray(edges)) throw new Error("В graph нет массивов nodes[] и edges[].");
|
|
425
|
+
return { name: g.name, nodes, edges, canvasMeta: g.canvasMeta ?? {} };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Прочитать граф из локального файла (поддерживается ~). MCP исполняется на машине пользователя,
|
|
429
|
+
// поэтому большой граф можно не передавать инлайном, а сослаться файлом — без обрезания/ошибок.
|
|
430
|
+
function readGraphFile(p) {
|
|
431
|
+
const abs = path.resolve(String(p).replace(/^~(?=$|[/\\])/, os.homedir()));
|
|
432
|
+
let raw;
|
|
433
|
+
try { raw = fs.readFileSync(abs, "utf8"); } catch { throw new Error(`Файл графа не найден: ${abs}`); }
|
|
434
|
+
let obj;
|
|
435
|
+
try { obj = JSON.parse(raw); } catch (e) { throw new Error(`Файл графа — невалидный JSON: ${abs}. ${e?.message || e}`); }
|
|
436
|
+
return obj;
|
|
437
|
+
}
|
|
438
|
+
// Источник графа для пишущих инструментов: graphFile (путь) > graph (контейнер) > nodes/edges.
|
|
439
|
+
function resolveGraphInput(a) {
|
|
440
|
+
if (a.graphFile) return extractGraph(readGraphFile(a.graphFile));
|
|
441
|
+
if (a.graph) return extractGraph(a.graph);
|
|
442
|
+
return { nodes: a.nodes, edges: a.edges, canvasMeta: a.canvasMeta ?? {}, name: a.name };
|
|
443
|
+
}
|
|
444
|
+
// Сервер хранит id узлов/рёбер как UUID: иначе PUT падал голым HTTP 400 без причины.
|
|
445
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
446
|
+
function assertGraphIds(g) {
|
|
447
|
+
const bad = [];
|
|
448
|
+
for (const n of g.nodes || []) if (!UUID_RE.test(String(n?.id))) bad.push(`узел ${n?.id}`);
|
|
449
|
+
for (const e of g.edges || []) {
|
|
450
|
+
for (const k of ["id", "sourceNodeId", "targetNodeId"]) if (!UUID_RE.test(String(e?.[k]))) bad.push(`ребро ${e?.id ?? "?"}.${k}=${e?.[k]}`);
|
|
451
|
+
}
|
|
452
|
+
if (bad.length) throw new Error(`id узлов и рёбер должны быть UUID (crypto.randomUUID()). Не UUID: ${bad.slice(0, 10).join(", ")}${bad.length > 10 ? ` и ещё ${bad.length - 10}` : ""}.`);
|
|
453
|
+
}
|
|
454
|
+
// chatId бывает больше 2^53 (виджет, MAX) — принимаем строкой, пропускаем только цифры (и минус у групп).
|
|
455
|
+
function pageQs(a) {
|
|
456
|
+
const q = [];
|
|
457
|
+
if (a.page != null) q.push(`page=${encodeURIComponent(a.page)}`);
|
|
458
|
+
if (a.size != null) q.push(`size=${encodeURIComponent(a.size)}`);
|
|
459
|
+
return q.length ? `?${q.join("&")}` : "";
|
|
460
|
+
}
|
|
461
|
+
function chatIdArg(v) {
|
|
462
|
+
const s = String(v ?? "").trim();
|
|
463
|
+
if (!/^-?\d{1,20}$/.test(s)) throw new Error(`chatId — число (из list_bot_users), получено: ${s || "пусто"}.`);
|
|
464
|
+
return s;
|
|
465
|
+
}
|
|
466
|
+
function uuidArg(v, label) {
|
|
467
|
+
const s = String(v ?? "").trim();
|
|
468
|
+
if (!UUID_RE.test(s)) throw new Error(`${label} — UUID (например, из list_bots), получено: ${s || "пусто"}.`);
|
|
469
|
+
return s;
|
|
470
|
+
}
|
|
471
|
+
// id из Mongo/строковые ключи: только безопасный сегмент пути, без «/», «..», пробелов.
|
|
472
|
+
function idArg(v, label) {
|
|
473
|
+
const s = String(v ?? "").trim();
|
|
474
|
+
if (!/^[A-Za-z0-9_-]{1,64}$/.test(s)) throw new Error(`${label} — id из соответствующего списка, получено: ${s || "пусто"}.`);
|
|
475
|
+
return s;
|
|
476
|
+
}
|
|
477
|
+
function enumArg(v, label, allowed) {
|
|
478
|
+
const s = String(v ?? "").trim().toUpperCase();
|
|
479
|
+
if (!allowed.includes(s)) throw new Error(`${label}: ${allowed.join(" | ")}, получено: ${s || "пусто"}.`);
|
|
480
|
+
return s;
|
|
481
|
+
}
|
|
482
|
+
const LEAD_STATUSES = ["NEW", "IN_PROGRESS", "DONE", "REJECTED"];
|
|
483
|
+
function dateArg(v, label) {
|
|
484
|
+
const s = String(v ?? "").trim();
|
|
485
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(s) || Number.isNaN(Date.parse(s))) throw new Error(`${label} — дата ГГГГ-ММ-ДД, получено: ${s || "пусто"}.`);
|
|
486
|
+
return s;
|
|
487
|
+
}
|
|
488
|
+
const HHMM_RE = /^([01]\d|2[0-3]):[0-5]\d$|^24:00$/;
|
|
489
|
+
// Поля календаря записи (BookingCalendar): переданные накладываются на base (для PUT — текущий календарь целиком).
|
|
490
|
+
function calendarBody(a, base = {}) {
|
|
491
|
+
const b = { ...base };
|
|
492
|
+
if (a.name != null) b.name = String(a.name).trim();
|
|
493
|
+
if (a.zone != null) b.zone = String(a.zone).trim();
|
|
494
|
+
if (a.botId !== undefined) b.botId = a.botId === null || a.botId === "" ? null : uuidArg(a.botId, "botId");
|
|
495
|
+
if (a.slotMinutes != null) {
|
|
496
|
+
const n = Number(a.slotMinutes);
|
|
497
|
+
if (!Number.isInteger(n) || n < 5 || n > 1440) throw new Error("slotMinutes — целое от 5 до 1440.");
|
|
498
|
+
b.slotMinutes = n;
|
|
499
|
+
}
|
|
500
|
+
if (a.hours != null) {
|
|
501
|
+
if (!Array.isArray(a.hours)) throw new Error("hours — массив [{day: 1–7, from: \"HH:mm\", to: \"HH:mm\"}].");
|
|
502
|
+
a.hours.forEach((h, i) => {
|
|
503
|
+
if (!Number.isInteger(h?.day) || h.day < 1 || h.day > 7) throw new Error(`hours[${i}].day — от 1 (пн) до 7 (вс).`);
|
|
504
|
+
if (!HHMM_RE.test(String(h.from)) || !HHMM_RE.test(String(h.to))) throw new Error(`hours[${i}]: from/to — время HH:mm.`);
|
|
505
|
+
});
|
|
506
|
+
b.hours = a.hours.map((h) => ({ day: h.day, from: h.from, to: h.to }));
|
|
507
|
+
}
|
|
508
|
+
if (a.exceptions != null) {
|
|
509
|
+
if (!Array.isArray(a.exceptions)) throw new Error("exceptions — массив [{date: \"ГГГГ-ММ-ДД\", from?, to?}].");
|
|
510
|
+
b.exceptions = a.exceptions.map((e, i) => {
|
|
511
|
+
const date = dateArg(e?.date, `exceptions[${i}].date`);
|
|
512
|
+
const closed = !e.from && !e.to;
|
|
513
|
+
if (!closed && (!HHMM_RE.test(String(e.from)) || !HHMM_RE.test(String(e.to)))) throw new Error(`exceptions[${i}]: from/to — время HH:mm или оба пусты (выходной).`);
|
|
514
|
+
return closed ? { date } : { date, from: e.from, to: e.to };
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
return b;
|
|
518
|
+
}
|
|
519
|
+
// Компактная сводка графа (без объёмных text/cards/buttons) — чтобы не упираться в лимит токенов
|
|
520
|
+
// на больших графах. Узлы: id/type/title/позиция; рёбра: id/from/handle/to.
|
|
521
|
+
function graphSummary(g) {
|
|
522
|
+
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 }));
|
|
523
|
+
const edges = (g?.edges || []).map((e) => ({ id: e.id, from: e.sourceNodeId, h: e.sourceHandle, to: e.targetNodeId }));
|
|
524
|
+
return { graphId: g?.id, name: g?.name, status: g?.status, version: g?.version, counts: { nodes: nodes.length, edges: edges.length }, nodes, edges };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// =====================================================================// Рассылки
|
|
528
|
+
// =====================================================================// Бэкенд на 400 отдаёт только статус (без причины), поэтому правила TgBroadcastController.validateDirectMessage
|
|
529
|
+
// повторены здесь — агент получает понятную ошибку до запроса, а не голый «HTTP 400».
|
|
530
|
+
const BC_TYPES = ["TEXT", "PHOTO", "VIDEO", "AUDIO", "FILE", "VOICE", "VIDEONOTE", "GALLERY"];
|
|
531
|
+
const BC_MEDIA = new Set(["PHOTO", "VIDEO", "AUDIO", "FILE", "VOICE", "VIDEONOTE"]);
|
|
532
|
+
const BC_MAX_MESSAGES = 5, BC_MAX_BOTS = 20, BC_MAX_BUTTONS = 8, BC_TEXT_MAX = 4096, BC_CAPTION_MAX = 1024;
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Сообщение рассылки как его строит мастер в кабинете (broadcastBlocks.ts → blocksToMessages).
|
|
536
|
+
* Принимает и сокращения: строка → TEXT; url → mediaUrl; urls → mediaUrls; type в любом регистре.
|
|
537
|
+
* strict=false (черновик) — только нормализация, без проверки полноты.
|
|
538
|
+
*/
|
|
539
|
+
function normalizeBroadcastMessage(m, i, strict = true) {
|
|
540
|
+
if (typeof m === "string") m = { type: "TEXT", text: m };
|
|
541
|
+
if (!m || typeof m !== "object") throw new Error(`messages[${i}]: ожидается объект {type, text?, mediaUrl?, mediaUrls?, buttons?}.`);
|
|
542
|
+
const mediaUrl = (m.mediaUrl || m.url || m.photoUrl || "").trim();
|
|
543
|
+
const type = String(m.type || (mediaUrl ? "PHOTO" : "TEXT")).toUpperCase();
|
|
544
|
+
if (!BC_TYPES.includes(type)) throw new Error(`messages[${i}]: неизвестный type ${type}. Бывают: ${BC_TYPES.join(", ")}.`);
|
|
545
|
+
const out = { type, parseMode: m.parseMode === null ? undefined : "HTML" };
|
|
546
|
+
const text = typeof m.text === "string" && m.text.trim() ? m.text : undefined;
|
|
547
|
+
if (text !== undefined && type !== "VIDEONOTE") out.text = text;
|
|
548
|
+
if (BC_MEDIA.has(type)) { out.mediaUrl = mediaUrl; if (type === "PHOTO") out.photoUrl = mediaUrl; }
|
|
549
|
+
if (type === "GALLERY") out.mediaUrls = (m.mediaUrls || m.urls || []).map((u) => String(u || "").trim()).filter(Boolean);
|
|
550
|
+
const buttons = (Array.isArray(m.buttons) ? m.buttons : [])
|
|
551
|
+
.filter((b) => b && String(b.text || "").trim() && String(b.url || "").trim())
|
|
552
|
+
.map((b) => ({ text: String(b.text).trim(), url: String(b.url).trim() }));
|
|
553
|
+
if (type !== "GALLERY") out.buttons = buttons;
|
|
554
|
+
if (!strict) return out;
|
|
555
|
+
if (type === "TEXT" && !out.text) throw new Error(`messages[${i}]: у TEXT нужен text.`);
|
|
556
|
+
if (BC_MEDIA.has(type) && !out.mediaUrl) throw new Error(`messages[${i}]: у ${type} нужен mediaUrl (загрузи файл через upload_file и возьми url).`);
|
|
557
|
+
if (type === "GALLERY" && (out.mediaUrls.length < 2 || out.mediaUrls.length > 10)) throw new Error(`messages[${i}]: GALLERY — от 2 до 10 картинок в mediaUrls.`);
|
|
558
|
+
if (type === "GALLERY" && buttons.length) throw new Error(`messages[${i}]: у GALLERY не бывает кнопок — вынеси их в следующее сообщение.`);
|
|
559
|
+
if (type === "VIDEONOTE" && text) throw new Error(`messages[${i}]: VIDEONOTE (кружок) не поддерживает текст.`);
|
|
560
|
+
const plain = (out.text || "").replace(/<[^>]+>/g, "").length;
|
|
561
|
+
const limit = type === "TEXT" ? BC_TEXT_MAX : BC_CAPTION_MAX;
|
|
562
|
+
if (plain > limit) throw new Error(`messages[${i}]: текст длиннее ${limit} символов (${plain}).`);
|
|
563
|
+
if (buttons.length > BC_MAX_BUTTONS) throw new Error(`messages[${i}]: не больше ${BC_MAX_BUTTONS} кнопок.`);
|
|
564
|
+
for (const b of buttons) if (!/^(https?:\/\/|tg:\/\/)/i.test(b.url)) throw new Error(`messages[${i}]: кнопка «${b.text}» — url должен быть ссылкой https://… (callback-кнопок в рассылке нет).`);
|
|
565
|
+
return out;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function normalizeBroadcastMessages(list, strict = true) {
|
|
569
|
+
if (!Array.isArray(list)) list = list == null ? [] : [list];
|
|
570
|
+
if (strict && (list.length < 1 || list.length > BC_MAX_MESSAGES)) throw new Error(`messages: от 1 до ${BC_MAX_MESSAGES} сообщений.`);
|
|
571
|
+
if (list.length > BC_MAX_MESSAGES) throw new Error(`messages: не больше ${BC_MAX_MESSAGES}.`);
|
|
572
|
+
return list.map((m, i) => normalizeBroadcastMessage(m, i, strict));
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** botIds из botIds[] или botId. */
|
|
576
|
+
function botIdsOf(a) {
|
|
577
|
+
const ids = Array.isArray(a.botIds) ? a.botIds : a.botId ? [a.botId] : [];
|
|
578
|
+
return [...new Set(ids.map(String).filter(Boolean))];
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const strList = (v) => (Array.isArray(v) ? v : v ? [v] : []).map((t) => String(t).trim()).filter(Boolean);
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Время запуска → ISO-instant. Строка без пояса (2026-10-06T10:00) считается московской (+03:00) — как
|
|
585
|
+
* продукт считает расписание повторов; «now»/пусто — сразу.
|
|
586
|
+
*/
|
|
587
|
+
function toInstant(v, field) {
|
|
588
|
+
if (v == null || v === "" || v === "now") return null;
|
|
589
|
+
let s = String(v).trim();
|
|
590
|
+
if (/^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?)?$/.test(s)) s = (s.length === 10 ? `${s}T00:00` : s.replace(" ", "T")) + "+03:00";
|
|
591
|
+
const d = new Date(s);
|
|
592
|
+
if (Number.isNaN(d.getTime())) throw new Error(`${field}: не распознал время «${v}». Формат ISO 8601, например 2026-10-06T10:00:00+03:00.`);
|
|
593
|
+
return d.toISOString();
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/** У Instagram-ботов рассылок нет (окно 24 ч Meta) — отказываем до запроса. */
|
|
597
|
+
async function assertBroadcastBots(botIds) {
|
|
598
|
+
if (!botIds.length) throw new Error("Передай botIds — id ботов (list_bots), по которым рассылать.");
|
|
599
|
+
if (botIds.length > BC_MAX_BOTS) throw new Error(`botIds: не больше ${BC_MAX_BOTS} ботов за раз.`);
|
|
600
|
+
const bots = await api("/api/bots");
|
|
601
|
+
const byId = new Map((Array.isArray(bots) ? bots : []).map((b) => [String(b.id), b]));
|
|
602
|
+
for (const id of botIds) {
|
|
603
|
+
const b = byId.get(id);
|
|
604
|
+
if (!b) throw new Error(`Бот ${id} не найден среди твоих ботов (list_bots).`);
|
|
605
|
+
if (String(b.platform || "").toUpperCase() === "INSTAGRAM") {
|
|
606
|
+
throw new Error(`Бот ${b.name || b.username || id} — Instagram: рассылок у Instagram-ботов нет (Meta разрешает писать только в окне 24 ч после сообщения пользователя). Используй сценарий с триггером.`);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
return byId;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function qs(params) {
|
|
613
|
+
const parts = Object.entries(params).filter(([, v]) => v != null && v !== "").map(([k, v]) => `${k}=${encodeURIComponent(v)}`);
|
|
614
|
+
return parts.length ? `?${parts.join("&")}` : "";
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Выгрузка (CSV/JSON): с savePath — в локальный файл (большие выгрузки не тащим в контекст), без — текстом.
|
|
618
|
+
function exportResult(data, savePath) {
|
|
619
|
+
const text = data == null ? "" : typeof data === "string" ? data : JSON.stringify(data, null, 2);
|
|
620
|
+
if (!savePath) return okResult(text || "Пусто: выгружать нечего.");
|
|
621
|
+
const abs = path.resolve(String(savePath).replace(/^~(?=$|[/\\])/, os.homedir()));
|
|
622
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
623
|
+
fs.writeFileSync(abs, text);
|
|
624
|
+
return okResult({ saved: abs, bytes: Buffer.byteLength(text) });
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// =====================================================================// Подключения сервисов
|
|
628
|
+
// =====================================================================/** IA v2: каталог и подключения — раздел «Интеграции». Старые /bots/connect|integrations остаются в кабинете (Instagram — там). */
|
|
629
|
+
const CONNECT_PAGE = `${BASE}/integrations`;
|
|
630
|
+
const INTEGRATIONS_PAGE = CONNECT_PAGE;
|
|
631
|
+
const LEGACY_CONNECT_PAGE = `${BASE}/bots/connect`;
|
|
632
|
+
const INSTAGRAM_CONNECT_PAGE = `${BASE}/bots/instagram`;
|
|
633
|
+
/** Ключи с секретами: бэкенд их не отдаёт (только маска hint), но вычищаем и здесь — секрет не должен попасть в вывод. */
|
|
634
|
+
const SECRET_OUT_RX = /^(creds|credsEnc|credentials|password|api_?key|.*secret(key)?|.*token)$/i;
|
|
635
|
+
function withoutSecrets(v) {
|
|
636
|
+
if (Array.isArray(v)) return v.map(withoutSecrets);
|
|
637
|
+
if (!v || typeof v !== "object") return v;
|
|
638
|
+
const out = {};
|
|
639
|
+
for (const [k, x] of Object.entries(v)) {
|
|
640
|
+
if (SECRET_OUT_RX.test(k) && typeof x !== "boolean") continue; // secret:true в схеме каталога — флаг, не секрет
|
|
641
|
+
out[k] = withoutSecrets(x);
|
|
642
|
+
}
|
|
643
|
+
return out;
|
|
644
|
+
}
|
|
645
|
+
/** Поля кредов — как форма кабинета (IntegrationsPage.tsx PROVIDER_FIELDS). */
|
|
646
|
+
const PROVIDER_FIELDS = {
|
|
647
|
+
AMOCRM: { name: "amoCRM", fields: { subdomain: "поддомен: acme из acme.amocrm.ru", longToken: "долгосрочный токен: amoCRM → Интеграции → ваша интеграция → Ключи и доступы" } },
|
|
648
|
+
BITRIX24: { name: "Битрикс24", fields: { webhookUrl: "URL входящего вебхука: Приложения → Разработчикам → Входящий вебхук (права: CRM)" } },
|
|
649
|
+
GETCOURSE: { name: "GetCourse", fields: { account: "аккаунт: school из school.getcourse.ru", apiKey: "секретный ключ: Настройки → API (показывается один раз)" } },
|
|
650
|
+
YAMETRIKA: { name: "Яндекс Метрика", fields: { counterId: "номер счётчика", oauthToken: "OAuth-токен Яндекса с доступом к загрузке офлайн-конверсий (выдаётся на oauth.yandex.ru)" } },
|
|
651
|
+
YOOKASSA: { name: "ЮKassa", fields: { shopId: "shopId магазина — число: ЮKassa → Настройки → Магазин", secretKey: "секретный ключ: ЮKassa → Интеграция → Ключи API (показывается один раз)" } },
|
|
652
|
+
};
|
|
653
|
+
const PROVIDER_ALIASES = {
|
|
654
|
+
amocrm: "AMOCRM", amo: "AMOCRM", bitrix24: "BITRIX24", bitrix: "BITRIX24", getcourse: "GETCOURSE",
|
|
655
|
+
yametrika: "YAMETRIKA", metrika: "YAMETRIKA", yandex_metrika: "YAMETRIKA", yandexmetrika: "YAMETRIKA",
|
|
656
|
+
yookassa: "YOOKASSA", yukassa: "YOOKASSA", ukassa: "YOOKASSA",
|
|
657
|
+
google_sheets: "GOOGLE_SHEETS", googlesheets: "GOOGLE_SHEETS", sheets: "GOOGLE_SHEETS", google: "GOOGLE_SHEETS",
|
|
658
|
+
instagram: "INSTAGRAM", telegram: "TELEGRAM", max: "MAX",
|
|
659
|
+
};
|
|
660
|
+
const normProvider = (p) => PROVIDER_ALIASES[String(p || "").trim().toLowerCase().replace(/[\s.-]+/g, "_")] || String(p || "").trim().toUpperCase();
|
|
661
|
+
|
|
662
|
+
/** Instagram подключается только в браузере: кабинет /bots/instagram → вход через Instagram (OAuth), без токена/API. */
|
|
663
|
+
function instagramAnswer() {
|
|
664
|
+
return linkResult("Instagram: подключение через вход Instagram (OAuth) в кабинете", INSTAGRAM_CONNECT_PAGE,
|
|
665
|
+
"Instagram-аккаунт нельзя подключить по API или токену: только через кнопку «Подключить Instagram» в кабинете (/bots/instagram) — она откроет вход через Instagram. Нужен Instagram-аккаунт типа Business или Creator (обычный личный профиль не подойдёт). Открой ссылку и пройди вход — после этого аккаунт появится в list_bots.");
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const TOOLS = [
|
|
669
|
+
{ name: "setup", description: "Показать статус авторизации и пошаговую инструкцию подключения. Вызывай первым, если пользователь не знает, что делать, или при ошибке доступа. Токен (zmcp_...) определяет АККАУНТ, а не один проект: доступ к каждому боту/сайту — это твоя текущая роль в нём (владелец или приглашённый соредактор), и она проверяется заново при каждом вызове.", inputSchema: { type: "object", properties: {} } },
|
|
670
|
+
{ name: "set_token", description: "Сохранить персональный токен (zmcp_...), который пользователь создал на /bots/mcp-tokens. Применяется сразу, без рестарта. Токен определяет аккаунт целиком, не один проект — список доступных проектов (свои + те, куда пригласили) виден в list_bots/site_list. Если владелец проекта уберёт тебя из соредакторов, доступ к этому проекту исчезнет сразу же, без повторной настройки токена.", inputSchema: { type: "object", properties: { token: { type: "string", description: "Секрет токена, начинается с zmcp_" } }, required: ["token"] } },
|
|
671
|
+
{ name: "list_bots", description: "Список ботов (id, имя, статус). Включает и свои боты, и боты, в которые тебя пригласили соредактором — у каждого есть accessRole (OWNER/ADMIN/EDITOR/VIEWER); у чужих (не OWNER) есть ownerName — имя владельца проекта. Доступ = твоя текущая роль в проекте, проверяется на каждый вызов; владелец мог отозвать её без предупреждения.", inputSchema: { type: "object", properties: {} } },
|
|
672
|
+
{ name: "list_graphs", description: "Список сценариев САМОГО бота (без узлов). Вебхук-сценарии, которые лишь отвечают через этого бота, сюда не входят — их публикуют в вебе, в «Сценариях» автора.", inputSchema: { type: "object", properties: { botId: { type: "string" } }, required: ["botId"] } },
|
|
673
|
+
{ name: "list_channels", description: "Список каналов/групп, подключённых к боту (chatId, title, type, статус бота, дата). chatId — числовой id для условия SUBSCRIBED («Подписан на канал»).", inputSchema: { type: "object", properties: { botId: { type: "string" } }, required: ["botId"] } },
|
|
674
|
+
{ name: "integration_catalog", description: "Каталог сервисов Integration Core (GET /api/integrations/catalog): [{provider, name, category, authType, configSchema: [{key, label, hint, secret}] — поля подключения, actions: [{kind, label, inputs}] — действия для сценария и coreDelivery, healthCheck — умеет ли integration_test, inbound: {events: [{key, label, vars: [{key, label, example}], paymentSucceeded}]} | null — провайдер умеет только исходящие действия}]. inbound.events — события для узла TRIGGER_WEBHOOK с заполненным config.provider (см. integration_ingress_url): events[].key идёт в config.event, vars[].key — переменные {{body.<key>}} в сценарии. paymentSucceeded:true — событие сверх обычного вебхука ещё и заводит узел «Оплата прошла» (headless или в чате подписчика, если платёжная ссылка была привязана к нему действием *_link). 404 — Integration Core выключен в сервисе. Read-only.", inputSchema: { type: "object", properties: {} } },
|
|
675
|
+
{ name: "integration_status", description: "Статус подключения (GET /api/integrations/{id}/status): {status: UNKNOWN | OK | NEEDS_REAUTH (ключ отозван/устарел — обнови через connect_integration) | ERROR, lastCheckedAt, lastError, supported}. Только своё подключение (чужое — 403). Read-only, внешний сервис не вызывает.", inputSchema: { type: "object", properties: { connectionId: { type: "string", description: "id из list_integrations" } }, required: ["connectionId"] } },
|
|
676
|
+
{ name: "integration_test", description: "Проверить подключение (POST /api/integrations/{id}/test): выполняет ЖИВУЮ проверку ключа во внешнем сервисе от имени владельца (без побочных эффектов — ничего не создаёт) и обновляет статус. Ответ как у integration_status; supported:false — сервис проверку не умеет. Только своё подключение (чужое — 403); 404 — Integration Core выключен.", inputSchema: { type: "object", properties: { connectionId: { type: "string", description: "id из list_integrations" } }, required: ["connectionId"] } },
|
|
677
|
+
{ name: "channel_post", description: "Разовый пост от имени бота в канал/группу Telegram или MAX (раздел «Публикации», POST /api/bots/{botId}/linked-chats/{chatId}/post). Бот должен быть администратором канала (list_channels). text и/или mediaUrl — файл из upload_file (тип фото/видео/документ берётся из файла; чужие ссылки не принимаются). Лимиты: Telegram — 4096 символов текста, 1024 подписи к файлу; MAX — 4000. Ответ {ok, messageId?}. Публикует сразу — подтверди текст с пользователем.", inputSchema: { type: "object", properties: { botId: { type: "string" }, chatId: { type: "number", description: "chatId из list_channels" }, text: { type: "string" }, mediaUrl: { type: "string", description: "url из upload_file" } }, required: ["botId", "chatId"] } },
|
|
678
|
+
{ name: "list_integrations", description: "Список подключённых сервисов пользователя (GET /api/bots/integrations): {id, provider, title, hint, createdAt}. **id отсюда — это `connectionId`**, обязательное поле действий amocrm_send/amocrm_update/bitrix24_call/getcourse_send/getcourse_order/yametrika_event. Без него действие упадёт «не выбрано подключение». Креды не отдаются — только маскированный hint. Подключить новый — connect_integration. Read-only.", inputSchema: { type: "object", properties: {} } },
|
|
679
|
+
{ 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"] } },
|
|
680
|
+
{ name: "create_graph", description: "Создать пустой граф (DRAFT) в боте. Возвращает граф с id.", inputSchema: { type: "object", properties: { botId: { type: "string" }, name: { type: "string" } }, required: ["botId", "name"] } },
|
|
681
|
+
{ name: "update_graph", description: "Залить узлы/рёбра в граф (PUT, сырой replace без бэкапа). Для правок СУЩЕСТВУЮЩЕГО/живого сценария используй edit_graph_live. Активный (PUBLISHED) граф сервер проверяет как публикацию: при ошибках HTTP 422 со всеми code@nodeId, граф НЕ сохранён. Черновик сохраняется без проверок публикации, кроме размера: граф больше 4 МБ → HTTP 422 GRAPH_TOO_LARGE, не сохранён. Принимает 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"] } },
|
|
682
|
+
{ name: "edit_graph_live", description: "РЕКОМЕНДОВАННЫЙ способ правки СУЩЕСТВУЮЩЕГО (часто живого/опубликованного) сценария: редактирует ТОТ ЖЕ graphId НА МЕСТЕ (id не меняется) и сначала снимает авто-бэкап текущего состояния в один rolling-граф «🔙 Авто-бэкап». НЕ клонирует и НЕ создаёт новый активный граф. Открытые редакторы перечитают граф вживую (external_update), бот применит изменения сразу (читает активный граф заново из БД). Используй ВМЕСТО clone+publish, когда нужно поправить сценарий, который уже открыт/в проде. ВАЖНО: правку активного графа сервер проверяет как публикацию (валидатор, платные блоки, лимит блоков тарифа, платформа) — при ошибках HTTP 422 со всеми code@nodeId, граф НЕ изменён, бот работает на прежней версии. Прогоняй offline validate.mjs и dry_run заранее, чтобы не ловить 422. Живой граф бота — с isActive:true в list_graphs — для бот-сценария (после publish_graph черновика — publishedGraphId, не id черновика); правка черновика до бота не доходит.", 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"] } },
|
|
683
|
+
{ name: "patch_graph", description: "Точечная правка БОЛЬШОГО/живого графа без отправки графа целиком: сервер сам берёт граф по graphId, делает строковые замены в его JSON, проверяет валидность и заливает обратно НА МЕСТЕ (с авто-бэкапом). Идеально, когда граф слишком велик, чтобы передавать его целиком через update_graph/edit_graph_live — напр. сменить id канала в условиях SUBSCRIBED, ссылки кнопок, тексты. replacements: [{find, replace}] — заменяются ВСЕ вхождения; делай find максимально специфичным, чтобы не задеть лишнее. preview=true — только показать число совпадений, ничего не сохраняя. Бот применит изменения сразу только у опубликованного графа (читает активный граф заново из БД); патч черновика до бота не доходит. Результат для активного графа сервер проверяет как публикацию: ошибки → HTTP 422 со всеми code@nodeId, граф не изменён.", 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"] } },
|
|
684
|
+
{ name: "dry_run", description: "Прогнать сценарий без публикации. kind: command|callback|text. Внешние действия (HTTP, CRM, таблицы, письма, ИИ) пропускаются (skipped: dry-run). Статус прогона: OK, FAILED (прерван) или PARTIAL — «завершён с ошибками»: дошёл до конца, но хотя бы одно действие упало (у шага ok:false, error «КОД: …»).", 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"] } },
|
|
685
|
+
{ name: "publish_graph", description: "Опубликовать граф. У бота ОДИН активный сценарий: публикация черновика заменяет содержимое активного (id живого графа сохраняется); прежняя активная версия, которой нет ни в одном черновике бота (правки вживую), сохраняется черновиком «… (была активной до дата время)». Вернёт publishedGraphId; при отказе проверок — ошибка HTTP 422 со всеми причинами построчно (code@nodeId: message). Сценарий-вебхук (источник WEBHOOK) этим инструментом не публикуется — HTTP 409, его публикуют в вебе.", inputSchema: { type: "object", properties: { graphId: { type: "string" } }, required: ["graphId"] } },
|
|
686
|
+
{ 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"] } },
|
|
687
|
+
{ name: "list_templates", description: "Список готовых шаблонов воронок (id, имя, описание). Можно стартовать граф из шаблона вместо сборки с нуля.", inputSchema: { type: "object", properties: {} } },
|
|
688
|
+
{ 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"] } },
|
|
689
|
+
{ name: "rename_graph", description: "Переименовать сценарий (работает и для опубликованных — имя не влияет на исполнение).", inputSchema: { type: "object", properties: { graphId: { type: "string" }, name: { type: "string" } }, required: ["graphId", "name"] } },
|
|
690
|
+
{ name: "clone_graph", description: "Склонировать граф в новый DRAFT «… (copy)» — безопасно итерировать поверх опубликованного. Клон сценария-вебхука получает собственный путь и секрет вебхука.", inputSchema: { type: "object", properties: { graphId: { type: "string" } }, required: ["graphId"] } },
|
|
691
|
+
{ 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"] } },
|
|
692
|
+
{ name: "delete_graph", description: "Удалить граф. Активный (опубликованный и назначенный боту) удалить нельзя — будет 409; сначала переключи активный через set_active_graph.", inputSchema: { type: "object", properties: { graphId: { type: "string" } }, required: ["graphId"] } },
|
|
693
|
+
{ name: "set_active_graph", description: "Назначить, какой опубликованный граф активен у бота (переключение живого сценария без перепубликации). HTTP 409 — граф не опубликован или это сценарий-вебхук (источник WEBHOOK — такой включается своей публикацией в вебе).", inputSchema: { type: "object", properties: { botId: { type: "string" }, graphId: { type: "string" } }, required: ["botId", "graphId"] } },
|
|
694
|
+
{ 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: "Переопределить имя файла (необязательно)" } } } },
|
|
695
|
+
{ name: "list_files", description: "Список файлов в библиотеке /bots/files (GET /api/bots/media) + использовано/лимит байт. Бери готовые url отсюда, чтобы не загружать одно и то же повторно.", inputSchema: { type: "object", properties: {} } },
|
|
696
|
+
{ name: "delete_file", description: "Удалить файл из библиотеки /bots/files по id (DELETE /api/bots/media/{id}). Освобождает место в хранилище тарифа.", inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] } },
|
|
697
|
+
{ name: "graph_analytics", description: "Аналитика прохождения сценария по узлам (GET /api/bots/graphs/{graphId}/analytics): сколько пользователей дошло до каждого узла — видно, где отваливается воронка. Read-only.", inputSchema: { type: "object", properties: { graphId: { type: "string" } }, required: ["graphId"] } },
|
|
698
|
+
{ 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"] } },
|
|
699
|
+
{ name: "list_links", description: "Стартовые (трекинговые) ссылки бота с UTM (GET /api/bots/{botId}/links): code, метки, число стартов. Это точки входа в воронку. Read-only.", inputSchema: { type: "object", properties: { botId: { type: "string" } }, required: ["botId"] } },
|
|
700
|
+
{ name: "site_list", description: "Сайты (раздел «Страницы», GET /api/bots/pages): id, title, mode (BLOCKS — сайт из блоков, CODE — файлы/Mini App), url (основной адрес), publishedRevision, status (ACTIVE | BLOCKED — заблокирован модерацией: посетители видят HTTP 451, удалить такой сайт нельзя — 409) и blockedReason. Включает и свои сайты, и сайты команды проекта (привязанные к боту, где тебя пригласили) — у каждого accessRole (OWNER/ADMIN/EDITOR/VIEWER), у чужих — ownerName. Доступ проверяется на каждый вызов. Read-only.", inputSchema: { type: "object", properties: {} } },
|
|
701
|
+
{ name: "site_create", description: "Создать сайт из блоков (POST /api/bots/pages, mode=BLOCKS). Возвращает id. Дальше: site_edit (init=starter — стартовый лендинг, init=blank — пустая главная, init=mini-landing — мини-лендинг с кнопками мессенджеров) → site_publish. template (starter|blank|mini-landing) — сразу создать черновик из шаблона (как «Мини-лендинг» в редакторе), в ответе revision. slug — «название» в адресе pages.retensy.com/<id>/<slug>/ (необязательно, по умолчанию транслит title). Число сайтов ограничено тарифом (free — 1, «Старт» — 3, «Про» — 10, с «Бизнеса» — без лимита): сверх лимита — HTTP 402 с причиной и upgradeUrl (смена тарифа только в браузере, передай ссылку пользователю). Заблокированный модерацией сайт в site_list имеет status=BLOCKED и blockedReason, посетители видят HTTP 451.", inputSchema: { type: "object", properties: { title: { type: "string" }, slug: { type: "string" }, template: { type: "string", enum: ["starter", "blank", "mini-landing"] } }, required: ["title"] } },
|
|
702
|
+
{ name: "site_get", description: "Модель сайта из блоков (GET /api/bots/pages/{siteId}/document): revision, draft (SiteModel: theme, globals.header/footer, pages[].blocks[], popups[]) — id страниц/блоков/попапов нужны для site_edit. draft=null — сайт пуст (первый site_edit создаст его). saveToFile — записать модель на диск и вернуть путь.", inputSchema: { type: "object", properties: { siteId: { type: "string" }, saveToFile: { type: "string" } }, required: ["siteId"] } },
|
|
703
|
+
{ name: "site_schema", description: "JSON Schema модели сайта (model) и операций правки (ops) — какие блоки и поля бывают (GET /api/bots/pages/schema). Читай перед первой правкой.", inputSchema: { type: "object", properties: {} } },
|
|
704
|
+
{ name: "site_edit", description: "Правка сайта операциями — всё или ничего (POST /api/bots/pages/{siteId}/document/ops). " +
|
|
705
|
+
"Страницы: add_page{title} · update_page{pageId,patch: {title?, path?, seo?{title,description,noindex,ogTitle,ogDescription,ogImage}, showHeader?, showFooter?, folder?}} · remove_page{pageId} · move_page{pageId,delta}. " +
|
|
706
|
+
"Папки страниц: add_folder{name} (id в results) · rename_folder{folderId,name} · remove_folder{folderId}; страница в папку — update_page{pageId, patch:{folder: folderId}}. " +
|
|
707
|
+
"Дизайны (отдельные экраны-макеты из Zero-кадров): add_design{name} (id в results) · update_design{designId,name} · remove_design{designId} · add_design_frame{designId,name,w,h} → в results blockId Zero-кадра, дальше с ним работают операции Zero-элементов. " +
|
|
708
|
+
"Шаблоны: add_template{container, templateId, after?} — вставить шаблон из библиотеки (site_templates). " +
|
|
709
|
+
"Блоки: add_block{container: id страницы|попапа, type, after?, variant?, props?, style?} · update_block{blockId, props?, style?, variant?} · move_block{blockId,delta} · duplicate_block{blockId} · remove_block{blockId}. " +
|
|
710
|
+
"Код блока: get_block_code{blockId} (results[i].code: Zero — разметка <zero>…</zero>, остальные — JSON) · set_block_code{blockId,code} · add_block_code{container,code,after?}. " +
|
|
711
|
+
"Zero-блок (type zero, свободная вёрстка как в Tilda): add_element{blockId, kind: text|image|button|shape|video|html|group, frame?{d:{x,y,w,h,container?,axisX?,axisY?}, t?, m?}, props?, style?, hover?, anim?, link?, parent?, name?, fixed?} · update_element{blockId,elementId, …те же поля, hidden?, locked?, link:null — убрать} · remove_element · move_element{delta: +1 — слой выше} · group_elements{blockId,elementIds[],name?} · ungroup_element. " +
|
|
712
|
+
"Сайт: set_global{slot: header|footer, on} · set_theme{theme} · set_settings{settings} · add_popup{name} · update_popup{popupId,name?,width?} · remove_popup{popupId}. " +
|
|
713
|
+
"props/style/theme/frame — JSON Merge Patch (null удаляет ключ, массивы заменяются целиком). Типы блоков: header, cover, text, image, gallery, buttons, features, form, video, html, spacer, footer, zero, messengers. " +
|
|
714
|
+
"Значения по экранам: {d, t?, m?} (десктоп/планшет/телефон). revision — защита от перезаписи (409, если сайт изменили); init (starter|blank|mini-landing) — с чего начать пустой сайт (на сайте с черновиком игнорируется); только init без ops — создать черновик из шаблона. " +
|
|
715
|
+
"Ответ: новая revision и results[] с id созданного (и code у get_block_code). Ошибки — HTTP 422 с путями. Тариф: HTML-блок и HTML-элемент Zero публикуются только на платном тарифе (422 при site_publish).", inputSchema: { type: "object", properties: { siteId: { type: "string" }, ops: { type: "array", items: { type: "object" } }, revision: { type: "number" }, init: { type: "string", enum: ["starter", "blank", "mini-landing"] } }, required: ["siteId"] } },
|
|
716
|
+
{ name: "site_publish", description: "Опубликовать черновик сайта (POST /api/bots/pages/{siteId}/publish): рендер в статику, адрес начинает отдавать новую версию. Ошибки проверки — HTTP 422 с путями. Возвращает publishedRevision и url.", inputSchema: { type: "object", properties: { siteId: { type: "string" } }, required: ["siteId"] } },
|
|
717
|
+
{ name: "site_upload_asset", description: "Загрузить картинку/видео в сайт (POST /api/bots/pages/{siteId}/upload, папка assets). Передай path (локальный файл) ИЛИ url. Возвращает asset — строку вида assets/<имя> для полей image/logo/icon/style.bg.image.", inputSchema: { type: "object", properties: { siteId: { type: "string" }, path: { type: "string" }, url: { type: "string" } }, required: ["siteId"] } },
|
|
718
|
+
{ name: "site_rollback", description: "Вернуть прошлую публикацию сайта (POST /api/bots/pages/{siteId}/publish/rollback): revision — номер из истории публикаций (site_get → versions[]). Черновик заменяется этой версией и сразу публикуется. Возвращает publishedRevision и url.", inputSchema: { type: "object", properties: { siteId: { type: "string" }, revision: { type: "number" } }, required: ["siteId", "revision"] } },
|
|
719
|
+
{ name: "site_domains", description: "Свои домены сайта (/api/bots/pages/{siteId}/domains). action: list — домены, статусы и dnsTarget (IP для A-записи); add {host, withWww?} — привязать (withWww у корневого домена добавляет www-пару); check {domainId} — перепроверить DNS и сертификат; remove {domainId} — отвязать. Число доменов ограничено тарифом (HTTP 402 с upgradeUrl). Каждое действие возвращает актуальный список.", inputSchema: { type: "object", properties: { siteId: { type: "string" }, action: { type: "string", enum: ["list", "add", "check", "remove"] }, host: { type: "string" }, withWww: { type: "boolean" }, domainId: { type: "string" } }, required: ["siteId", "action"] } },
|
|
720
|
+
{ name: "site_lead_settings", description: "Куда доставлять заявки из форм сайта (/api/bots/pages/{siteId}/lead-settings). Без settings — прочитать: {settings, scenarios (вебхук-сценарии), amoConnections, coreConnections: [{id, name, provider}] — подключения для доставки «Интеграция», без кредов}. С settings — сохранить ЦЕЛИКОМ (сначала прочитай и поменяй нужное): {notifyBot: в бот уведомлений из профиля, notifyEmail: письмо на почту аккаунта, webhookUrl?: POST JSON на ваш адрес, scenarioId?: вебхук-сценарий, который запускает заявка, amoConnectionId?: сделка в amoCRM, coreDelivery?: {connectionId: id из coreConnections, kind: действие сервиса из integration_catalog (actions[].kind, например amocrm_send), params?: {поле действия: шаблон}} | null}. Пустые params заполнятся из заявки (имя, телефон, почта, текст); в шаблонах — {{var.name}}, {{var.phone}}, {{var.email}}, {{var.<имя поля>}}, {{var.lead_text}} (текст заявки). Чужое подключение или неизвестный kind — 400. Запуск сценариев по заявке — триггер TRIGGER_SITE_FORM (скилл build-bot-funnel).", inputSchema: { type: "object", properties: { siteId: { type: "string" }, settings: { type: "object", properties: { notifyBot: { type: "boolean" }, notifyEmail: { type: "boolean" }, webhookUrl: { type: "string" }, scenarioId: { type: "string" }, amoConnectionId: { type: "string" }, coreDelivery: { type: ["object", "null"], properties: { connectionId: { type: "string" }, kind: { type: "string" }, params: { type: "object", additionalProperties: { type: "string" } } } } } } }, required: ["siteId"] } },
|
|
721
|
+
{ name: "site_leads", description: "Заявки из форм сайта (GET /api/bots/pages/{siteId}/leads): поля, UTM, статус доставки и обработки. page (с 0), size (до 100), status — фильтр NEW|IN_PROGRESS|DONE|REJECTED. Read-only.", inputSchema: { type: "object", properties: { siteId: { type: "string" }, page: { type: "number" }, size: { type: "number" }, status: { type: "string", enum: ["NEW", "IN_PROGRESS", "DONE", "REJECTED"] } }, required: ["siteId"] } },
|
|
722
|
+
{ 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: {} } },
|
|
723
|
+
{ 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"] } },
|
|
724
|
+
{ 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"] } },
|
|
725
|
+
{ 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"] } },
|
|
726
|
+
// ---- ИИ-агенты и база знаний ----
|
|
727
|
+
{ name: "agent_list", description: "Список ИИ-агентов пользователя (GET /api/bots/agents): карточки — id, имя, статус. Read-only.", inputSchema: { type: "object", properties: {} } },
|
|
728
|
+
{ name: "agent_get", description: "ИИ-агент по id (GET /api/bots/agents/{agentId}): настройки (имя, язык, тон, длина/формат ответа, инструкции, запретные/передаточные темы, kbId базы знаний, статус). Чужой агент — 404. Read-only.", inputSchema: { type: "object", properties: { agentId: { type: "string" } }, required: ["agentId"] } },
|
|
729
|
+
{ name: "agent_create", description: "Создать ИИ-агента (POST /api/bots/agents): name, description — необязательны. Вместе с агентом создаётся его база знаний (kbId в ответе) — дальше kb_add_qa/kb_add_text/kb_add_site.", inputSchema: { type: "object", properties: { name: { type: "string" }, description: { type: "string" } } } },
|
|
730
|
+
{ name: "agent_update", description: "Изменить настройки агента (PATCH /api/bots/agents/{agentId}): patch — объект с полями для правки (name, description, language: RU|EN|AUTO, tone: FRIENDLY|NEUTRAL|FORMAL, answerLength: SHORT|MEDIUM|LONG, format: PLAIN|LIST_FRIENDLY, instructions, forbiddenTopics[], handoffTopics[], fallback). Применяется частично — передавай только то, что меняешь.", inputSchema: { type: "object", properties: { agentId: { type: "string" }, patch: { type: "object", description: "Поля агента для частичного обновления" } }, required: ["agentId", "patch"] } },
|
|
731
|
+
{ name: "agent_publish", description: "Опубликовать агента (POST /api/bots/agents/{agentId}/publish): агент начинает отвечать в подключённых сценариях. HTTP 409 CHECKLIST_FAILED с чеклистом, если агент ещё не готов (нет базы знаний, пустые инструкции и т.п.) — агент не меняется.", inputSchema: { type: "object", properties: { agentId: { type: "string" } }, required: ["agentId"] } },
|
|
732
|
+
{ name: "agent_health", description: "Здоровье базы знаний агента (GET /api/bots/agents/{agentId}/health): счётчики документов/фрагментов по запросу, без кэша. Read-only.", inputSchema: { type: "object", properties: { agentId: { type: "string" } }, required: ["agentId"] } },
|
|
733
|
+
{ name: "agent_test_chat", description: "Проверить ответ агента в песочнице без отправки клиенту (POST /api/bots/agents/{agentId}/test-chat): question (1–1000 символов), history — необязательная история диалога [{role: client|agent, text}], до 12 реплик. ТРАТИТ бюджет ИИ, как настоящий ответ — не вызывай массово.", inputSchema: { type: "object", properties: { agentId: { type: "string" }, question: { type: "string" }, history: { type: "array", items: { type: "object", properties: { role: { type: "string", enum: ["client", "agent"] }, text: { type: "string" } } } } }, required: ["agentId", "question"] } },
|
|
734
|
+
{ name: "kb_docs", description: "Документы базы знаний агента (GET /api/bots/kb/{kbId}/docs): источник (FILE/QA/SITE), статус индексации, число фрагментов. Только верхний уровень — у сайта страницы видны счётчиком. Read-only.", inputSchema: { type: "object", properties: { kbId: { type: "string", description: "kbId агента (agent_get)" } }, required: ["kbId"] } },
|
|
735
|
+
{ name: "kb_add_qa", description: "Добавить пары вопрос-ответ в базу знаний (POST /api/bots/kb/{kbId}/docs/qa): pairs — [{question, answer}], до 200 пар за раз (вопрос ≤500 символов, ответ ≤4000). Каждая пара — отдельный фрагмент для поиска.", inputSchema: { type: "object", properties: { kbId: { type: "string" }, pairs: { type: "array", items: { type: "object", properties: { question: { type: "string" }, answer: { type: "string" } }, required: ["question", "answer"] } } }, required: ["kbId", "pairs"] } },
|
|
736
|
+
{ name: "kb_add_text", description: "Добавить источник «Текст/инструкция» в базу знаний (POST /api/bots/kb/{kbId}/docs/text): title (≤120 символов), text (≤100 000 символов). Индексация уходит в фон — документ появится в kb_docs со статусом PENDING → READY.", inputSchema: { type: "object", properties: { kbId: { type: "string" }, title: { type: "string" }, text: { type: "string" } }, required: ["kbId", "title", "text"] } },
|
|
737
|
+
{ name: "kb_add_site", description: "Добавить сайт в базу знаний обходом страниц (POST /api/bots/kb/{kbId}/docs/site): url (http/https, не внутренняя сеть), schedule — расписание повторного обхода: NEVER|DAILY|WEEKLY|MONTHLY (по умолчанию NEVER). Обход уходит в фон; документ появится в kb_docs со статусом PENDING.", inputSchema: { type: "object", properties: { kbId: { type: "string" }, url: { type: "string" }, schedule: { type: "string", enum: ["NEVER", "DAILY", "WEEKLY", "MONTHLY"] } }, required: ["kbId", "url"] } },
|
|
738
|
+
{ name: "kb_reindex", description: "Переиндексировать документ-файл базы знаний из сохранённого оригинала (POST /api/bots/kb/{kbId}/docs/{docId}/reindex) — «Повторить» после ошибки. headerRow — необязательно, для табличных файлов: номер строки с шапкой (1..50), если автоопределение ошиблось. Только для источника FILE.", inputSchema: { type: "object", properties: { kbId: { type: "string" }, docId: { type: "string" }, headerRow: { type: "number" } }, required: ["kbId", "docId"] } },
|
|
739
|
+
{ name: "agent_unanswered", description: "Вопросы без ответа агента за период (GET /api/bots/agents/{agentId}/unanswered): days — 7|30|90 (по умолчанию 30). Группы вопросов, на которые агент не нашёл ответ в базе знаний — подсказка, что туда добавить. Read-only.", inputSchema: { type: "object", properties: { agentId: { type: "string" }, days: { type: "number", enum: [7, 30, 90] } }, required: ["agentId"] } },
|
|
740
|
+
// ---- Боты ----
|
|
741
|
+
{ name: "create_bot", description: "Подключить бота по токену (POST /api/bots): platform TELEGRAM (токен от @BotFather) или MAX (токен от MasterBot в MAX). Вебхук настраивается сам; name — отображаемое имя (иначе @username). Возвращает бота с id. Число ботов ограничено тарифом — HTTP 402 со ссылкой на смену тарифа. platform WEB — чат-виджет для сайта без токена (POST /api/bots/web): вернёт botId, key и snippet — код вставки на сайт (у существующего виджета — web_widget_snippet). platform INSTAGRAM по токену не подключается — только входом через Instagram (OAuth) в кабинете (/bots/instagram), нужен Instagram-аккаунт Business или Creator — инструмент вернёт ссылку на кабинет вместо ошибки.", inputSchema: { type: "object", properties: { platform: { type: "string", enum: ["TELEGRAM", "MAX", "WEB", "INSTAGRAM"] }, token: { type: "string", description: "Токен бота: 123456789:AA… (Telegram) или токен MAX" }, name: { type: "string" } }, required: ["platform"] } },
|
|
742
|
+
{ name: "bot_stop", description: "Остановить бота (POST /api/bots/{botId}/stop): снимает вебхук, бот перестаёт отвечать, сценарии и подписчики сохраняются. Запуск обратно — bot_resume.", inputSchema: { type: "object", properties: { botId: { type: "string" } }, required: ["botId"] } },
|
|
743
|
+
{ name: "bot_resume", description: "Запустить остановленного бота или бота, приостановленного лимитом тарифа (POST /api/bots/{botId}/resume). Если лимит ботов тарифа исчерпан — HTTP 402 со ссылкой на смену тарифа.", inputSchema: { type: "object", properties: { botId: { type: "string" } }, required: ["botId"] } },
|
|
744
|
+
// ---- Подключения ----
|
|
745
|
+
{ name: "connect_integration", description: "Подключить сервис (POST /api/bots/integrations) — дальше его id (= connectionId) ставится в действия сценария и в site_lead_settings. provider и creds: AMOCRM {subdomain, longToken} · BITRIX24 {webhookUrl} · GETCOURSE {account, apiKey} · YAMETRIKA {counterId, oauthToken} · YOOKASSA {shopId, secretKey}. Остальные ~25 сервисов (smsru, retailcrm, cloudpayments, rest_api, yandex_market и т.д.) берутся из каталога Integration Core (integration_catalog) — provider передавай его provider-ключом в НИЖНЕМ регистре, нужные creds — из configSchema каждого провайдера (hint подскажет, где взять; поле с «необязательно»/optional в hint можно не слать). Без нужных creds вернёт, какие поля и где их взять. connectionId — обновить креды/название существующего подключения (PUT). Сервисы со входом через браузер не падают, а возвращают ссылку для пользователя: GOOGLE_SHEETS → ссылка согласия Google (OAuth; после неё таблицы выбираются в узле «Google Таблицы»), INSTAGRAM → ссылка на кабинет /bots/instagram (вход через Instagram, нужен Instagram-аккаунт Business/Creator). TELEGRAM/MAX — это боты: используй create_bot. Провайдер каталога со статусом COMING_SOON/IN_DEVELOPMENT подключить нельзя (вернёт ошибку). Если каталог недоступен (офлайн/выключен) — работают только 5 легаси-провайдеров выше. Креды хранятся в сервисе зашифрованными и НИКОГДА не возвращаются — ни здесь, ни в list_integrations (только маска hint); в отчёты не попадают. Проверить ключ после подключения — integration_test.", inputSchema: { type: "object", properties: { provider: { type: "string", description: "AMOCRM | BITRIX24 | GETCOURSE | YAMETRIKA | YOOKASSA | GOOGLE_SHEETS | INSTAGRAM, либо провайдер из integration_catalog (его ключ в нижнем регистре, напр. smsru, retailcrm, cloudpayments)" }, title: { type: "string", description: "Название подключения в кабинете (например «amoCRM продажи»)" }, creds: { type: "object", description: "Поля провайдера: для легаси — см. описание, для остальных — ключи из configSchema провайдера в integration_catalog" }, connectionId: { type: "string", description: "id существующего подключения (list_integrations) — обновить его" } }, required: ["provider"] } },
|
|
746
|
+
{ name: "disconnect_integration", description: "Удалить подключение сервиса по id из list_integrations (DELETE /api/bots/integrations/{id}). Действия сценария с этим connectionId перестанут работать.", inputSchema: { type: "object", properties: { connectionId: { type: "string" } }, required: ["connectionId"] } },
|
|
747
|
+
{ name: "integration_ingress_url", description: "URL ингресса для триггера «Внешние события» (POST /api/bots/integrations/{id}/ingress-url, лениво создаёт токен при первом вызове; rotate:true — /ingress-url/rotate: старый URL сразу перестаёт принимать запросы). Вставь этот URL в сервис провайдера (CRM/кассу/форму) как webhook/notification/result URL, чтобы его события доходили до сценария. В сценарии добейся узла TRIGGER_WEBHOOK с config = {provider: provider-ключ в нижнем регистре (из integration_catalog), connectionId: id из list_integrations, event: events[].key из inbound.events того же provider в каталоге} — именно эти три поля конструктор и бэкенд читают для триггера интеграции (GraphValidator.validateWebhookTrigger / IngressFanout); без provider+connectionId узел ведёт себя как обычный TRIGGER_WEBHOOK с произвольным адресом. Только своё подключение (чужое — 403).", inputSchema: { type: "object", properties: { connectionId: { type: "string", description: "id из list_integrations" }, rotate: { type: "boolean", description: "true — выпустить новый URL, старый сразу отключится" } }, required: ["connectionId"] } },
|
|
748
|
+
// ---- Рассылки ----
|
|
749
|
+
{ name: "broadcast_list", description: "Рассылки. Без botId — по всем ботам постранично (GET /api/bots/broadcasts): {counts: {drafts, scheduled, sent, recurring}, page: {content: [{id, botId, botUsername, name, status, direct, totalJobs, sentJobs, failedJobs, skippedByQuota, scheduledAt, createdAt}], totalElements…}}; group: scheduled (ещё не начали) | sent (идут/завершены). С botId — полная история одного бота. status: EXPANDING/MATERIALIZING/READY (ждёт) → RUNNING → DONE | CANCELLING → CANCELLED | FAILED. Read-only.", inputSchema: { type: "object", properties: { botId: { type: "string" }, group: { type: "string", enum: ["scheduled", "sent"] }, page: { type: "number" }, size: { type: "number", description: "до 100, по умолчанию 20" } } } },
|
|
750
|
+
{ name: "broadcast_get", description: "Рассылка целиком по id (GET /api/bots/broadcasts/{id}): статус, счётчики отправки, фильтр аудитории, сообщения, время. При failedJobs > 0 — ещё errors: топ-5 причин ошибок [{error, count}] (GET …/{id}/errors; детали живут 30 дней). Read-only.", inputSchema: { type: "object", properties: { broadcastId: { type: "string" } }, required: ["broadcastId"] } },
|
|
751
|
+
{ name: "broadcast_preview", description: "Сколько подписчиков получат рассылку (POST /api/bots/{botId}/broadcasts/preview) — по каждому боту и всего. Фильтр по тегам: tagsAll — есть ВСЕ эти теги, tagsNone — нет НИ ОДНОГО; без тегов — все подписчики бота. Лимит — 50 000 получателей на бота. Ничего не отправляет.", inputSchema: { type: "object", properties: { botIds: { type: "array", items: { type: "string" } }, botId: { type: "string" }, tagsAll: { type: "array", items: { type: "string" } }, tagsNone: { type: "array", items: { type: "string" } } } } },
|
|
752
|
+
{ name: "broadcast_send", description: "Отправить рассылку сейчас или запланировать (scheduledAt). " +
|
|
753
|
+
"Прямая (по умолчанию, POST /api/bots/broadcasts/direct): name, botIds[] (1–20 ботов ОДНОГО владельца; по каждому создаётся своя рассылка), messages[] (1–5), tagsAll?/tagsNone? (фильтр по тегам). " +
|
|
754
|
+
"Сообщение: {type, text?, mediaUrl?, mediaUrls?, buttons?}; type: TEXT (text обязателен, до 4096) · PHOTO | VIDEO | AUDIO | FILE | VOICE (mediaUrl обязателен, text — подпись до 1024) · VIDEONOTE (кружок: mediaUrl, без текста) · GALLERY (mediaUrls: 2–10 картинок, подпись, БЕЗ кнопок). " +
|
|
755
|
+
"text — Telegram-HTML: <b> <i> <u> <s> <code> <pre> <blockquote> <tg-spoiler> <a href=\"https://…\">, перенос строки — \\n (не <br>); прочее экранируется. buttons — до 8 URL-кнопок [{text, url}] (callback-кнопок в рассылке нет). Можно строкой — это TEXT. Медиа — сначала upload_file, потом его url. " +
|
|
756
|
+
"По сценарию (graphId): POST /api/bots/{botId}/broadcasts — один botId, сценарий самого бота, entryNodeId? — с какого узла начать. " +
|
|
757
|
+
"draftId — отправить черновик (поля черновика, переданные аргументы их перекрывают); после отправки черновик удаляется. " +
|
|
758
|
+
"scheduledAt — ISO 8601 (без пояса — московское время); пусто — сразу. Немедленная резервирует квоту получателей тарифа, отложенная считает аудиторию в момент отправки. Рассылки только на платном тарифе — HTTP 402 со ссылкой. У Instagram-ботов рассылок нет. Возвращает {broadcastIds, totalAudience}.", inputSchema: { type: "object", properties: { name: { type: "string" }, botIds: { type: "array", items: { type: "string" } }, botId: { type: "string" }, messages: { type: "array", items: {} }, tagsAll: { type: "array", items: { type: "string" } }, tagsNone: { type: "array", items: { type: "string" } }, scheduledAt: { type: "string", description: "ISO 8601, напр. 2026-10-06T10:00:00+03:00; пусто — сразу" }, graphId: { type: "string", description: "рассылка запуском сценария бота вместо сообщений" }, entryNodeId: { type: "string" }, draftId: { type: "string" } } } },
|
|
759
|
+
{ name: "broadcast_cancel", description: "Отменить рассылку (POST /api/bots/broadcasts/{id}/cancel): запланированная не уйдёт, идущая остановится (статус CANCELLING → CANCELLED). Уже завершённую отменить нельзя — HTTP 409.", inputSchema: { type: "object", properties: { broadcastId: { type: "string" } }, required: ["broadcastId"] } },
|
|
760
|
+
{ name: "broadcast_recurring", description: "Повторяющиеся рассылки (/api/bots/broadcasts/recurring). action: list — правила (активные и остановленные); create {name, botIds[], messages[], tagsAll?, tagsNone?, recurrence: DAILY|MONTHLY|YEARLY, firstRunAt} — сообщения как в broadcast_send, firstRunAt — первый запуск в будущем (ISO 8601, без пояса — московское), дальше в то же время суток/число (Москва); stop {ruleId} — остановить правило (уже отправленные прогоны не трогаются). Нужен платный тариф (402 со ссылкой).", inputSchema: { type: "object", properties: { action: { type: "string", enum: ["list", "create", "stop"] }, ruleId: { type: "string" }, name: { type: "string" }, botIds: { type: "array", items: { type: "string" } }, botId: { type: "string" }, messages: { type: "array", items: {} }, tagsAll: { type: "array", items: { type: "string" } }, tagsNone: { type: "array", items: { type: "string" } }, recurrence: { type: "string", enum: ["DAILY", "MONTHLY", "YEARLY"] }, firstRunAt: { type: "string" } }, required: ["action"] } },
|
|
761
|
+
{ name: "broadcast_drafts", description: "Черновики рассылок (/api/bots/broadcasts/drafts) — те же, что в мастере кабинета. action: list · get {draftId} · create {name?, botIds?, messages?, tagsAll?, tagsNone?, scheduledAt?} · update {draftId, …те же поля — переданные заменяют, остальные остаются} · delete {draftId}. Черновик не проверяется на полноту; отправить — broadcast_send {draftId}. Лимит — 200 черновиков.", inputSchema: { type: "object", properties: { action: { type: "string", enum: ["list", "get", "create", "update", "delete"] }, draftId: { type: "string" }, name: { type: "string" }, botIds: { type: "array", items: { type: "string" } }, messages: { type: "array", items: {} }, tagsAll: { type: "array", items: { type: "string" } }, tagsNone: { type: "array", items: { type: "string" } }, scheduledAt: { type: "string" } }, required: ["action"] } },
|
|
762
|
+
{ name: "broadcast_duplicate", description: "Копия как новый черновик «<имя> (копия)»: исходник не меняется, время отправки и статистика не переносятся. broadcastId — прямая рассылка (POST /api/bots/broadcasts/{id}/duplicate: бот, фильтр, сообщения; по сценарию — HTTP 409); draftId — черновик (POST /api/bots/broadcasts/drafts/{id}/duplicate). Дальше broadcast_drafts update / broadcast_send {draftId}.", inputSchema: { type: "object", properties: { broadcastId: { type: "string" }, draftId: { type: "string" } } } },
|
|
763
|
+
// ---- Сайты: библиотека шаблонов ----
|
|
764
|
+
// ---- Сайт-виджет, база знаний, подписчик ----
|
|
765
|
+
{ name: "web_widget_snippet", description: "Код вставки чат-виджета на сайт (GET /api/bots/web/{botId}/snippet) — для бота с platform WEB (create_bot {platform:\"WEB\"}). Read-only.", inputSchema: { type: "object", properties: { botId: { type: "string" } }, required: ["botId"] } },
|
|
766
|
+
{ name: "kb_list", description: "Базы знаний пользователя (GET /api/bots/kb): id, name, chunkCount. id — knowledgeBaseId для узла AI_REPLY mode:\"agent\". Read-only.", inputSchema: { type: "object", properties: {} } },
|
|
767
|
+
{ name: "kb_create", description: "Создать пустую базу знаний (POST /api/bots/kb, name ≤ 120 символов). Возвращает id. Наполнение — kb_add_qa / kb_add_site; подключение к сценарию — knowledgeBaseId в узле AI_REPLY mode:\"agent\". Повторный вызов создаёт ВТОРУЮ базу — сначала проверь kb_list.", inputSchema: { type: "object", properties: { name: { type: "string" } }, required: ["name"] } },
|
|
768
|
+
{ name: "kb_delete_doc", description: "Удалить документ из базы знаний вместе с его фрагментами (DELETE /api/bots/kb/{kbId}/docs/{docId}). Необратимо.", inputSchema: { type: "object", properties: { kbId: { type: "string" }, docId: { type: "string" } }, required: ["kbId", "docId"] } },
|
|
769
|
+
{ name: "bot_user_get", description: "Карточка подписчика (GET /api/bots/{botId}/users/{chatId}): теги, переменные (поля, ответы, ai_summary), блокировка, счётчики. chatId — из list_bot_users. Read-only.", inputSchema: { type: "object", properties: { botId: { type: "string" }, chatId: { type: "string", description: "числовой chatId (строкой — без потери точности)" } }, required: ["botId", "chatId"] } },
|
|
770
|
+
{ name: "bot_user_runs", description: "Журнал запусков сценариев подписчика (GET /api/bots/{botId}/users/{chatId}/runs): триггер, статус, шаги с результатом (ветка ИИ-агента, ok/ошибка каждого действия — CRM, уведомление, HTTP). Так проверяют, что сценарий реально сделал нужное. Статус прогона: OK, FAILED (прерван) или PARTIAL — «завершён с ошибками»: дошёл до конца, но хотя бы одно действие упало (у шага ok:false, error «КОД: …»). Read-only.", inputSchema: { type: "object", properties: { botId: { type: "string" }, chatId: { type: "string" }, page: { type: "number" }, size: { type: "number" } }, required: ["botId", "chatId"] } },
|
|
771
|
+
{ name: "dialog_messages", description: "Переписка с подписчиком, свежие сверху (GET /api/bots/{botId}/users/{chatId}/messages, page/size). Read-only.", inputSchema: { type: "object", properties: { botId: { type: "string" }, chatId: { type: "string" }, page: { type: "number" }, size: { type: "number" } }, required: ["botId", "chatId"] } },
|
|
772
|
+
{ name: "dialog_reply", description: "ОТПРАВИТЬ сообщение подписчику от имени оператора (POST /api/bots/{botId}/users/{chatId}/messages) — как ответ из раздела «Диалоги». Уходит РЕАЛЬНОМУ человеку: только по явной просьбе пользователя. Бот при этом не останавливается.", inputSchema: { type: "object", properties: { botId: { type: "string" }, chatId: { type: "string" }, text: { type: "string" } }, required: ["botId", "chatId", "text"] } },
|
|
773
|
+
// ---- Паритет платформы: журнал вызовов, оператор, заявки, виджет, подписчики, бот ----
|
|
774
|
+
{ name: "integration_calls", description: "Журнал вызовов внешних сервисов из сценариев (GET /api/bots/integrations/calls): время, подключение, действие, ok/ошибка, код, попытки, correlationId (= runId прогона). Фильтры: connectionId (из list_integrations), ok (true — только успешные, false — только упавшие), limit (1–200). Так проверяют, что CRM/таблица/HTTP реально получили данные. Read-only.", inputSchema: { type: "object", properties: { connectionId: { type: "string" }, ok: { type: "boolean" }, limit: { type: "number" } } } },
|
|
775
|
+
{ name: "dialog_handoff", description: "Передача диалога оператору (POST /api/bots/{botId}/users/{chatId}/handoff {active}): active:true — бот и ИИ молчат, входящие копятся в «Диалогах», отвечает человек (dialog_reply); active:false — «Вернуть боту». Текущее состояние — поле handoff в bot_user_get.", inputSchema: { type: "object", properties: { botId: { type: "string" }, chatId: { type: "string" }, active: { type: "boolean" } }, required: ["botId", "chatId", "active"] } },
|
|
776
|
+
{ name: "site_lead_status", description: "Статус заявки сайта (PATCH /api/bots/pages/{siteId}/leads/{leadId} {status}): NEW → IN_PROGRESS → DONE | REJECTED. leadId — из site_leads. Недопустимый переход → ошибка 409.", inputSchema: { type: "object", properties: { siteId: { type: "string" }, leadId: { type: "string" }, status: { type: "string", enum: ["NEW", "IN_PROGRESS", "DONE", "REJECTED"] } }, required: ["siteId", "leadId", "status"] } },
|
|
777
|
+
{ name: "integration_update", description: "Изменить подключение (PUT /api/bots/integrations/{connectionId}): title — новое название; creds — новые ключи доступа (заменяют старые; поля — как в connect_integration для этого провайдера). Передай хотя бы одно.", inputSchema: { type: "object", properties: { connectionId: { type: "string" }, title: { type: "string" }, creds: { type: "object", additionalProperties: { type: "string" } } }, required: ["connectionId"] } },
|
|
778
|
+
{ name: "web_widget_settings", description: "Вид чат-виджета сайта (бот platform WEB). Без settings — прочитать (GET /api/bots/web/{botId}/settings: settings, brandingRemovable, snippet). С settings — изменить (PUT): переданные поля накладываются на текущие, остальные сохраняются; сервер проверяет значения (уходят в разметку чужих сайтов).", inputSchema: { type: "object", properties: { botId: { type: "string" }, settings: { type: "object", description: "Поля настроек виджета для изменения (имена — как в ответе чтения)" } }, required: ["botId"] } },
|
|
779
|
+
{ name: "bot_users_import", description: "Добавить подписчикам метки и поля (POST /api/bots/{botId}/users/import): rows — [{chatId, username?, tags?: [строки], variables?: {ключ: значение}}], до 10 000 строк. Метки ДОБАВЛЯЮТСЯ; переменная пишется, только если у подписчика её ещё нет (существующие не перезаписываются); новый chatId создаёт подписчика (в пределах лимита тарифа). Снять метку или перезаписать поле через API нельзя.", inputSchema: { type: "object", properties: { botId: { type: "string" }, rows: { type: "array", items: { type: "object", properties: { chatId: { type: "string" }, username: { type: "string" }, tags: { type: "array", items: { type: "string" } }, variables: { type: "object" } }, required: ["chatId"] } } }, required: ["botId", "rows"] } },
|
|
780
|
+
{ name: "bot_runs", description: "Журнал прогонов сценариев бота. Без runId — список свежих прогонов (GET /api/bots/{botId}/runs, page/size); с runId — один прогон с шагами (GET /api/bots/runs/{runId}, в т.ч. headless-прогоны вебхук-сценариев и расписаний). По подписчику — bot_user_runs, по сценарию — scenario_runs. Статус прогона: OK, FAILED (прерван) или PARTIAL — «завершён с ошибками»: дошёл до конца, но хотя бы одно действие упало (у шага ok:false, error «КОД: …»). Read-only.", inputSchema: { type: "object", properties: { botId: { type: "string" }, runId: { type: "string" }, page: { type: "number" }, size: { type: "number" } } } },
|
|
781
|
+
{ name: "scenario_runs", description: "Журнал прогонов одного сценария (GET /api/bots/graphs/{graphId}/runs, page/size), включая headless-прогоны без чата — вебхук-сценарии, заявки сайта, TRIGGER_SCHEDULE. Доступ — как к просмотру графа. Шаги прогона — bot_runs {runId}. Статус прогона: OK, FAILED (прерван) или PARTIAL — «завершён с ошибками»: дошёл до конца, но хотя бы одно действие упало (у шага ok:false, error «КОД: …»). Read-only.", inputSchema: { type: "object", properties: { graphId: { type: "string" }, page: { type: "number" }, size: { type: "number" } }, required: ["graphId"] } },
|
|
782
|
+
{ name: "bot_delete", description: "УДАЛИТЬ бота навсегда (DELETE /api/bots/{botId}) вместе с каналами, сценариями, подписчиками, журналами, рассылками и ссылками. Только владелец. Необратимо: вызывай только по явной просьбе пользователя и с confirm:true. Временно выключить — bot_stop.", inputSchema: { type: "object", properties: { botId: { type: "string" }, confirm: { type: "boolean", description: "true — пользователь явно подтвердил удаление" } }, required: ["botId", "confirm"] } },
|
|
783
|
+
// ---- Запись на слоты (бронирование) ----
|
|
784
|
+
{ name: "booking_calendar_list", description: "Календари записи пользователя (GET /api/bots/booking/calendars): id, name, zone, slotMinutes, hours, exceptions, botId. id — calendarId для действий сценария booking_slots/booking_book/booking_cancel. Read-only.", inputSchema: { type: "object", properties: {} } },
|
|
785
|
+
{ name: "booking_calendar_get", description: "Календарь записи по id (GET /api/bots/booking/calendars/{calendarId}). Read-only.", inputSchema: { type: "object", properties: { calendarId: { type: "string" } }, required: ["calendarId"] } },
|
|
786
|
+
{ name: "booking_calendar_create", description: "Создать календарь записи (POST /api/bots/booking/calendars): сетка слотов по рабочим часам в зоне календаря, в слоте — одна бронь. Без hours свободных слотов не будет.", inputSchema: { type: "object", properties: { name: { type: "string", description: "Название (до 100 символов)" }, zone: { type: "string", description: "Часовой пояс IANA, по умолчанию Europe/Moscow" }, slotMinutes: { type: "number", description: "Длительность слота, 5–1440 мин (по умолчанию 60)" }, hours: { type: "array", description: "Рабочие окна: day 1=пн…7=вс, from/to \"HH:mm\"; несколько окон в день — перерыв", items: { type: "object", properties: { day: { type: "number" }, from: { type: "string" }, to: { type: "string" } }, required: ["day", "from", "to"] } }, exceptions: { type: "array", description: "Исключения на даты: {date:\"ГГГГ-ММ-ДД\"} — выходной, с from/to — только это окно", items: { type: "object", properties: { date: { type: "string" }, from: { type: "string" }, to: { type: "string" } }, required: ["date"] } }, botId: { type: "string", description: "Необязательно: только для этого бота (иначе — все боты владельца)" } }, required: ["name"] } },
|
|
787
|
+
{ name: "booking_calendar_update", description: "Изменить календарь (PUT /api/bots/booking/calendars/{calendarId}): переданные поля заменяют текущие (hours/exceptions — целиком), остальные сохраняются. Существующие брони не трогаются.", inputSchema: { type: "object", properties: { calendarId: { type: "string" }, name: { type: "string", description: "Название (до 100 символов)" }, zone: { type: "string", description: "Часовой пояс IANA, по умолчанию Europe/Moscow" }, slotMinutes: { type: "number", description: "Длительность слота, 5–1440 мин (по умолчанию 60)" }, hours: { type: "array", description: "Рабочие окна: day 1=пн…7=вс, from/to \"HH:mm\"; несколько окон в день — перерыв", items: { type: "object", properties: { day: { type: "number" }, from: { type: "string" }, to: { type: "string" } }, required: ["day", "from", "to"] } }, exceptions: { type: "array", description: "Исключения на даты: {date:\"ГГГГ-ММ-ДД\"} — выходной, с from/to — только это окно", items: { type: "object", properties: { date: { type: "string" }, from: { type: "string" }, to: { type: "string" } }, required: ["date"] } }, botId: { type: "string", description: "Необязательно: только для этого бота (иначе — все боты владельца)" } }, required: ["calendarId"] } },
|
|
788
|
+
{ name: "booking_calendar_delete", description: "УДАЛИТЬ календарь записи (DELETE /api/bots/booking/calendars/{calendarId}). Сценарии с его calendarId перестанут записывать. Только по явной просьбе, с confirm:true.", inputSchema: { type: "object", properties: { calendarId: { type: "string" }, confirm: { type: "boolean" } }, required: ["calendarId", "confirm"] } },
|
|
789
|
+
{ name: "booking_slots", description: "Свободные слоты календаря (GET /api/bots/booking/calendars/{calendarId}/slots): [{at — ISO-8601 с зоной, label — «пн 12.10 15:00»}]. from/to — даты ГГГГ-ММ-ДД в зоне календаря (по умолчанию сегодня … +6 дней), limit — 1–500 (по умолчанию 50). Read-only.", inputSchema: { type: "object", properties: { calendarId: { type: "string" }, from: { type: "string" }, to: { type: "string" }, limit: { type: "number" } }, required: ["calendarId"] } },
|
|
790
|
+
{ name: "booking_list", description: "Брони календаря от даты from (ГГГГ-ММ-ДД, по умолчанию сегодня), включая отменённые, до 500 (GET /api/bots/booking/calendars/{calendarId}/bookings). Read-only.", inputSchema: { type: "object", properties: { calendarId: { type: "string" }, from: { type: "string" } }, required: ["calendarId"] } },
|
|
791
|
+
{ name: "booking_create", description: "Записать вручную на слот (POST /api/bots/booking/calendars/{calendarId}/bookings {slotAt, name, phone}): slotAt — at из booking_slots (ISO-8601 с зоной). Слот занят → понятная ошибка «занят», слота нет в сетке → ошибка 400.", inputSchema: { type: "object", properties: { calendarId: { type: "string" }, slotAt: { type: "string" }, name: { type: "string" }, phone: { type: "string" } }, required: ["calendarId", "slotAt"] } },
|
|
792
|
+
{ name: "booking_cancel", description: "Отменить бронь и освободить слот (POST /api/bots/booking/calendars/{calendarId}/bookings/{bookingId}/cancel). Повторная отмена — без ошибки. bookingId — из booking_list.", inputSchema: { type: "object", properties: { calendarId: { type: "string" }, bookingId: { type: "string" } }, required: ["calendarId", "bookingId"] } },
|
|
793
|
+
{ name: "site_templates", description: "Библиотека шаблонов блоков сайта (GET /api/bots/pages/templates): {categories: [{id, title, description?}], templates: [{id, category, title, description?, blocks: сколько блоков вставится}]}. Вставка — site_edit add_template {container, templateId, after?} (results.id — первый блок, results.ids — все); дальше блоки правятся как обычные. category — фильтр по id категории.", inputSchema: { type: "object", properties: { category: { type: "string" } } } },
|
|
794
|
+
// ---- Паритет платформы, часть 2: бот, база, агент, заявки, подписчики, каналы, ссылки, аналитика ----
|
|
795
|
+
{ name: "bot_rename", description: "Переименовать бота (PATCH /api/bots/{botId} {name}): отображаемое имя в кабинете, до 250 символов. Пустое name сбрасывает имя на @username. Имя в самом Telegram/MAX не меняется.", inputSchema: { type: "object", properties: { botId: { type: "string" }, name: { type: "string" } }, required: ["botId", "name"] } },
|
|
796
|
+
{ name: "bot_change_token", description: "Сменить токен бота (POST /api/bots/{botId}/token {token}): сервер проверяет токен и перезапускает приём сообщений; сценарии, подписчики и ссылки остаются. Нужен, если токен перевыпущен в @BotFather (/revoke) или MAX. Неверный или чужой токен → ошибка с причиной. Владелец или ADMIN.", inputSchema: { type: "object", properties: { botId: { type: "string" }, token: { type: "string", description: "Новый токен бота" } }, required: ["botId", "token"] } },
|
|
797
|
+
{ name: "bot_channel_list", description: "Дополнительные Telegram-боты (мультиканальность) этого бота (GET /api/bots/{botId}/channels): id, username, name, active. Это НЕ каналы/группы для постинга — те в list_channels. Read-only.", inputSchema: { type: "object", properties: { botId: { type: "string" } }, required: ["botId"] } },
|
|
798
|
+
{ name: "bot_channel_add", description: "Добавить к боту ещё одного Telegram-бота как канал (POST /api/bots/{botId}/channels {token, name?}): он отвечает теми же сценариями. Токен проверяется в Telegram; токен, уже подключённый где-то ещё, не принимается.", inputSchema: { type: "object", properties: { botId: { type: "string" }, token: { type: "string" }, name: { type: "string" } }, required: ["botId", "token"] } },
|
|
799
|
+
{ name: "bot_channel_delete", description: "Отключить дополнительного Telegram-бота от бота (DELETE /api/bots/{botId}/channels/{channelId}). channelId — из bot_channel_list. Только по явной просьбе, с confirm:true.", inputSchema: { type: "object", properties: { botId: { type: "string" }, channelId: { type: "string" }, confirm: { type: "boolean" } }, required: ["botId", "channelId", "confirm"] } },
|
|
800
|
+
{ name: "link_create", description: "Создать стартовую ссылку бота (POST /api/bots/{botId}/links {name, targetNodeId?}): code для t.me/<бот>?start=<code>, счётчик стартов. targetNodeId — узел сценария, на который ведёт диплинк; для одного узла повторный вызов вернёт существующую ссылку.", inputSchema: { type: "object", properties: { botId: { type: "string" }, name: { type: "string" }, targetNodeId: { type: "string" } }, required: ["botId"] } },
|
|
801
|
+
{ name: "link_delete", description: "Удалить стартовую ссылку (DELETE /api/bots/links/{linkId}); id — из list_links. Разосланные ссылки перестанут считаться. Только по явной просьбе, с confirm:true.", inputSchema: { type: "object", properties: { linkId: { type: "string" }, confirm: { type: "boolean" } }, required: ["linkId", "confirm"] } },
|
|
802
|
+
{ name: "utm_sources", description: "UTM-источники подписчиков бота (GET /api/bots/{botId}/utm-sources): по каким меткам utm_* люди входили в сценарии и сколько их. Read-only.", inputSchema: { type: "object", properties: { botId: { type: "string" } }, required: ["botId"] } },
|
|
803
|
+
{ name: "ab_results", description: "Результаты A/B-теста (GET /api/bots/graphs/{graphId}/ab-results?branchNodeId&period): статистика по вариантам узла-развилки. branchNodeId — id узла A/B в графе; period — «24h», «7d», «30d» (по умолчанию 7d). Read-only.", inputSchema: { type: "object", properties: { graphId: { type: "string" }, branchNodeId: { type: "string" }, period: { type: "string" } }, required: ["graphId", "branchNodeId"] } },
|
|
804
|
+
{ name: "kb_delete", description: "УДАЛИТЬ базу знаний целиком со всеми документами (DELETE /api/bots/kb/{kbId}). База ИИ-агента так не удаляется (ошибка KB_OWNED_BY_AGENT) — удаляй агента. Необратимо: только по явной просьбе, с confirm:true.", inputSchema: { type: "object", properties: { kbId: { type: "string" }, confirm: { type: "boolean" } }, required: ["kbId", "confirm"] } },
|
|
805
|
+
{ name: "agent_unpublish", description: "Снять ИИ-агента с публикации (POST /api/bots/agents/{agentId}/unpublish): агент возвращается в черновик и перестаёт отвечать. В ответе — agent и сценарии, которые сейчас на нём работают (их стоит проверить).", inputSchema: { type: "object", properties: { agentId: { type: "string" } }, required: ["agentId"] } },
|
|
806
|
+
{ name: "agent_delete", description: "УДАЛИТЬ ИИ-агента (DELETE /api/bots/agents/{agentId}). Пока агент или его база подключены к сценариям — ошибка AGENT_IN_USE со списком сценариев: сначала отключи его там. Необратимо: только по явной просьбе, с confirm:true.", inputSchema: { type: "object", properties: { agentId: { type: "string" }, confirm: { type: "boolean" } }, required: ["agentId", "confirm"] } },
|
|
807
|
+
{ name: "site_leads_mark_read", description: "Отметить все заявки сайта прочитанными (POST /api/bots/pages/{siteId}/leads/read): обнуляет счётчик unread из site_leads. Статусы обработки не меняются (это site_lead_status).", inputSchema: { type: "object", properties: { siteId: { type: "string" } }, required: ["siteId"] } },
|
|
808
|
+
{ name: "site_lead_delete", description: "УДАЛИТЬ заявку сайта (DELETE /api/bots/pages/{siteId}/leads/{leadId}); leadId — из site_leads. Необратимо: только по явной просьбе, с confirm:true.", inputSchema: { type: "object", properties: { siteId: { type: "string" }, leadId: { type: "string" }, confirm: { type: "boolean" } }, required: ["siteId", "leadId", "confirm"] } },
|
|
809
|
+
{ name: "site_leads_export", description: "Все заявки сайта в CSV (GET /api/bots/pages/{siteId}/leads.csv). savePath — сохранить в локальный файл и вернуть путь (для больших выгрузок); без него CSV возвращается текстом. Read-only.", inputSchema: { type: "object", properties: { siteId: { type: "string" }, savePath: { type: "string" } }, required: ["siteId"] } },
|
|
810
|
+
{ name: "bot_users_export", description: "Выгрузка всех подписчиков бота (GET /api/bots/{botId}/users/export?format=csv|json): chatId, имя, метки, переменные. savePath — сохранить в локальный файл и вернуть путь; без него данные возвращаются текстом. Read-only.", inputSchema: { type: "object", properties: { botId: { type: "string" }, format: { type: "string", enum: ["csv", "json"] }, savePath: { type: "string" } }, required: ["botId"] } },
|
|
811
|
+
{ name: "bot_user_reset", description: "Сбросить сессию подписчика (DELETE /api/bots/{botId}/sessions/{chatId}): стираются его позиция в сценарии, метки и переменные, и он пропадает из list_bot_users; при следующем сообщении начнёт как новый. Для повторного прохождения воронки при тесте. Необратимо: только по явной просьбе, с confirm:true.", inputSchema: { type: "object", properties: { botId: { type: "string" }, chatId: { type: "string" }, confirm: { type: "boolean" } }, required: ["botId", "chatId", "confirm"] } },
|
|
812
|
+
];
|
|
813
|
+
|
|
814
|
+
async function handleCall(params) {
|
|
815
|
+
const a = (params && params.arguments) || {};
|
|
816
|
+
switch (params && params.name) {
|
|
817
|
+
case "setup": {
|
|
818
|
+
const upd = updateNotice ? `\n\n${updateNotice}` : "";
|
|
819
|
+
if (isAuthed()) {
|
|
820
|
+
const via = getToken() ? "персональный токен" : "session-cookie";
|
|
821
|
+
return okResult(`✅ Авторизация настроена (${via}). База API: ${BASE}. Версия MCP: ${VERSION}.\n` +
|
|
822
|
+
`Можно собирать и публиковать ботов: list_bots, create_graph, import_funnel и др.${upd}`);
|
|
823
|
+
}
|
|
824
|
+
return okResult(NO_AUTH_HELP + upd);
|
|
825
|
+
}
|
|
826
|
+
case "set_token": {
|
|
827
|
+
const t = (a.token || "").trim();
|
|
828
|
+
if (!t) throw new Error("Передай token — секрет вида zmcp_..., который ты создал на " + TOKENS_PAGE);
|
|
829
|
+
saveToken(t);
|
|
830
|
+
const warn = t.startsWith("zmcp_") ? "" : "\n⚠️ Обычно токен начинается с «zmcp_» — проверь, что скопирован весь секрет.";
|
|
831
|
+
const envTok = (process.env.RETENSY_MCP_TOKEN || "").trim();
|
|
832
|
+
const envWarn = envTok && !envTok.startsWith("${") && envTok !== t
|
|
833
|
+
? "\n⚠️ В окружении задан другой RETENSY_MCP_TOKEN — он имеет приоритет над файлом. Убери/обнови env, иначе сохранённый токен не будет использоваться."
|
|
834
|
+
: "";
|
|
835
|
+
// лёгкая проверка валидности
|
|
836
|
+
let check = "";
|
|
837
|
+
try { const bots = await api("/api/bots"); check = `\nПроверка: доступно ботов — ${Array.isArray(bots) ? bots.length : "?"}.`; }
|
|
838
|
+
catch (e) { check = `\n⚠️ Токен сохранён, но проверка не прошла: ${(e.message || "").split("\n")[0]}`; }
|
|
839
|
+
return okResult(`✅ Токен сохранён (${TOKEN_FILE}). Применяется сразу.${warn}${envWarn}${check}`);
|
|
840
|
+
}
|
|
841
|
+
case "list_bots": return okResult(await api("/api/bots"));
|
|
842
|
+
case "list_graphs": return okResult(await api(`/api/bots/${a.botId}/graphs`));
|
|
843
|
+
case "list_channels": return okResult(await api(`/api/bots/${a.botId}/linked-chats`));
|
|
844
|
+
case "list_integrations": return okResult(withoutSecrets(await api("/api/bots/integrations")));
|
|
845
|
+
case "integration_catalog": return okResult(withoutSecrets(await api("/api/integrations/catalog")));
|
|
846
|
+
case "integration_status":
|
|
847
|
+
case "integration_test": {
|
|
848
|
+
const id = String(a.connectionId || "").trim();
|
|
849
|
+
if (!id) throw new Error("Передай connectionId — id подключения из list_integrations.");
|
|
850
|
+
const live = params.name === "integration_test";
|
|
851
|
+
const p_ = `/api/integrations/${encodeURIComponent(id)}/${live ? "test" : "status"}`;
|
|
852
|
+
return okResult(withoutSecrets(await api(p_, live ? { method: "POST" } : undefined)));
|
|
853
|
+
}
|
|
854
|
+
case "channel_post": {
|
|
855
|
+
if (!a.botId) throw new Error("Передай botId (list_bots).");
|
|
856
|
+
const chatId = Number(a.chatId);
|
|
857
|
+
if (!Number.isSafeInteger(chatId)) throw new Error("chatId — числовой id канала из list_channels (например -1001234567890).");
|
|
858
|
+
const text = a.text == null ? "" : String(a.text);
|
|
859
|
+
if (!text.trim() && !a.mediaUrl) throw new Error("Пустой пост: передай text и/или mediaUrl (файл из upload_file).");
|
|
860
|
+
const body = { text };
|
|
861
|
+
if (a.mediaUrl) body.mediaUrl = String(a.mediaUrl);
|
|
862
|
+
return okResult(await api(`/api/bots/${encodeURIComponent(a.botId)}/linked-chats/${chatId}/post`, { method: "POST", body }));
|
|
863
|
+
}
|
|
864
|
+
case "get_graph": {
|
|
865
|
+
const g = await api(`/api/bots/graphs/${a.graphId}`);
|
|
866
|
+
if (a.saveToFile) {
|
|
867
|
+
const abs = path.resolve(String(a.saveToFile).replace(/^~(?=$|[/\\])/, os.homedir()));
|
|
868
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
869
|
+
fs.writeFileSync(abs, JSON.stringify(g, null, 2));
|
|
870
|
+
return okResult({ savedTo: abs, ...graphSummary(g), note: "Полный граф записан в файл; здесь — сводка. Правь файл и заливай через update_graph/edit_graph_live с graphFile." });
|
|
871
|
+
}
|
|
872
|
+
if (a.summary) return okResult(graphSummary(g));
|
|
873
|
+
return okResult(g);
|
|
874
|
+
}
|
|
875
|
+
case "create_graph": return okResult(await api(`/api/bots/${a.botId}/graphs`, { method: "POST", body: { name: a.name } }));
|
|
876
|
+
case "update_graph": {
|
|
877
|
+
const src = resolveGraphInput(a);
|
|
878
|
+
if (!Array.isArray(src.nodes) || !Array.isArray(src.edges)) throw new Error("Нужны nodes[] и edges[] (через graphFile, graph или nodes/edges).");
|
|
879
|
+
assertGraphIds(src);
|
|
880
|
+
const payload = { nodes: src.nodes, edges: src.edges, canvasMeta: src.canvasMeta ?? {} };
|
|
881
|
+
if (a.name ?? src.name) payload.name = a.name ?? src.name;
|
|
882
|
+
return okResult(await api(`/api/bots/graphs/${a.graphId}`, { method: "PUT", body: payload }));
|
|
883
|
+
}
|
|
884
|
+
case "edit_graph_live": {
|
|
885
|
+
const src = resolveGraphInput(a);
|
|
886
|
+
if (!Array.isArray(src.nodes) || !Array.isArray(src.edges)) throw new Error("Нужны nodes[] и edges[] (через graphFile, graph или nodes/edges).");
|
|
887
|
+
assertGraphIds(src);
|
|
888
|
+
const steps = [];
|
|
889
|
+
let backupGraphId = null;
|
|
890
|
+
if (a.backup !== false) {
|
|
891
|
+
// снимок ТЕКУЩЕГО (до правки) состояния в один rolling-граф «🔙 Авто-бэкап» (один на бота, перезаписывается)
|
|
892
|
+
const current = await api(`/api/bots/graphs/${a.graphId}`);
|
|
893
|
+
const botId = current.botId;
|
|
894
|
+
const BACKUP_NAME = "🔙 Авто-бэкап (предыдущее состояние)";
|
|
895
|
+
const graphs = await api(`/api/bots/${botId}/graphs`);
|
|
896
|
+
let backup = (Array.isArray(graphs) ? graphs : [])
|
|
897
|
+
.find((g) => g.name === BACKUP_NAME && g.status === "DRAFT" && g.id !== a.graphId);
|
|
898
|
+
if (!backup) backup = await api(`/api/bots/${botId}/graphs`, { method: "POST", body: { name: BACKUP_NAME } });
|
|
899
|
+
backupGraphId = backup.id;
|
|
900
|
+
await api(`/api/bots/graphs/${backup.id}`, { method: "PUT", body: { nodes: current.nodes ?? [], edges: current.edges ?? [], canvasMeta: current.canvasMeta ?? {}, name: BACKUP_NAME } });
|
|
901
|
+
steps.push(`бэкап предыдущего состояния → ${backup.id} (DRAFT «${BACKUP_NAME}»)`);
|
|
902
|
+
}
|
|
903
|
+
const payload = { nodes: src.nodes, edges: src.edges, canvasMeta: src.canvasMeta ?? {} };
|
|
904
|
+
if (a.name ?? src.name) payload.name = a.name ?? src.name;
|
|
905
|
+
const saved = await api(`/api/bots/graphs/${a.graphId}`, { method: "PUT", body: payload });
|
|
906
|
+
// До бота доходит только правка опубликованного графа: черновик после publish_graph остаётся DRAFT, живой —
|
|
907
|
+
// его копия (publishedGraphId). Иначе агент решит, что поправил бота, а правка легла в черновик.
|
|
908
|
+
steps.push(saved?.status === "PUBLISHED"
|
|
909
|
+
? `правка применена НА МЕСТЕ к ${a.graphId} (id не изменился; редакторы и бот подхватят live)`
|
|
910
|
+
: `сохранено в черновик ${a.graphId}: до бота НЕ доходит — живые правки делай по id опубликованного графа (isActive:true в list_graphs — для бот-сценария; после publish_graph черновика — publishedGraphId)`);
|
|
911
|
+
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 });
|
|
912
|
+
}
|
|
913
|
+
case "patch_graph": {
|
|
914
|
+
const reps = Array.isArray(a.replacements) ? a.replacements : [];
|
|
915
|
+
if (!reps.length) throw new Error("Передай replacements: [{find, replace}] — хотя бы одну замену.");
|
|
916
|
+
for (const r of reps) {
|
|
917
|
+
if (!r || typeof r.find !== "string" || typeof r.replace !== "string") throw new Error("Каждая замена — объект {find:string, replace:string}.");
|
|
918
|
+
if (r.find === "") throw new Error("find не может быть пустой строкой.");
|
|
919
|
+
}
|
|
920
|
+
const current = await api(`/api/bots/graphs/${a.graphId}`);
|
|
921
|
+
let json = JSON.stringify(current);
|
|
922
|
+
const report = [];
|
|
923
|
+
for (const r of reps) {
|
|
924
|
+
const matches = json.split(r.find).length - 1;
|
|
925
|
+
if (matches > 0) json = json.split(r.find).join(r.replace);
|
|
926
|
+
report.push({ find: r.find, replace: r.replace, matches });
|
|
927
|
+
}
|
|
928
|
+
let patched;
|
|
929
|
+
try { patched = JSON.parse(json); }
|
|
930
|
+
catch (e) { throw new Error("После замен JSON графа стал невалидным — правка ОТМЕНЕНА, граф не тронут. Сделай find более специфичным. " + (e?.message || "")); }
|
|
931
|
+
const total = report.reduce((s, r) => s + r.matches, 0);
|
|
932
|
+
if (a.preview === true) return okResult({ preview: true, graphId: a.graphId, totalMatches: total, replacements: report });
|
|
933
|
+
if (total === 0) return okResult({ graphId: a.graphId, changed: false, note: "Ни одна замена не совпала — граф не изменён.", replacements: report });
|
|
934
|
+
let backupGraphId = null;
|
|
935
|
+
if (a.backup !== false) {
|
|
936
|
+
const botId = current.botId;
|
|
937
|
+
const BACKUP_NAME = "🔙 Авто-бэкап (предыдущее состояние)";
|
|
938
|
+
const graphs = await api(`/api/bots/${botId}/graphs`);
|
|
939
|
+
let backup = (Array.isArray(graphs) ? graphs : [])
|
|
940
|
+
.find((g) => g.name === BACKUP_NAME && g.status === "DRAFT" && g.id !== a.graphId);
|
|
941
|
+
if (!backup) backup = await api(`/api/bots/${botId}/graphs`, { method: "POST", body: { name: BACKUP_NAME } });
|
|
942
|
+
backupGraphId = backup.id;
|
|
943
|
+
await api(`/api/bots/graphs/${backup.id}`, { method: "PUT", body: { nodes: current.nodes ?? [], edges: current.edges ?? [], canvasMeta: current.canvasMeta ?? {}, name: BACKUP_NAME } });
|
|
944
|
+
}
|
|
945
|
+
const payload = { nodes: patched.nodes ?? [], edges: patched.edges ?? [], canvasMeta: patched.canvasMeta ?? {} };
|
|
946
|
+
if (patched.name) payload.name = patched.name;
|
|
947
|
+
const saved = await api(`/api/bots/graphs/${a.graphId}`, { method: "PUT", body: payload });
|
|
948
|
+
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 });
|
|
949
|
+
}
|
|
950
|
+
case "dry_run": {
|
|
951
|
+
// В конфиге узла команда хранится БЕЗ слэша ({command:"start"}), а рантайм матчит
|
|
952
|
+
// текст сообщения — со слэшем. Без нормализации dry_run("start") молча даёт NO_MATCH,
|
|
953
|
+
// хотя сценарий рабочий.
|
|
954
|
+
const value = a.kind === "command" && typeof a.value === "string" && !a.value.startsWith("/")
|
|
955
|
+
? `/${a.value}`
|
|
956
|
+
: a.value;
|
|
957
|
+
return okResult(await api(`/api/bots/graphs/${a.graphId}/dry-run`, { method: "POST", body: { kind: a.kind, value, fromUsername: a.fromUsername, presetVariables: a.presetVariables, presetTags: a.presetTags } }));
|
|
958
|
+
}
|
|
959
|
+
case "publish_graph": {
|
|
960
|
+
const pub = await api(`/api/bots/graphs/${a.graphId}/publish`, { method: "POST" });
|
|
961
|
+
// errors[] приходит с HTTP 200, но для пользователя это «не получилось» — и самый
|
|
962
|
+
// ценный сигнал: видно, какого узла/возможности ему не хватило.
|
|
963
|
+
if (Array.isArray(pub?.errors) && pub.errors.length) {
|
|
964
|
+
reportFailure({
|
|
965
|
+
tool: "publish_graph", args: a, category: "publish_rejected",
|
|
966
|
+
message: pub.errors.map((e) => `${e.code || "?"}${e.nodeId ? `@${e.nodeId}` : ""}: ${e.message || ""}`).join("\n"),
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
return okResult(pub);
|
|
970
|
+
}
|
|
971
|
+
case "import_funnel": {
|
|
972
|
+
const src = a.graphFile ? extractGraph(readGraphFile(a.graphFile)) : extractGraph(a.graph);
|
|
973
|
+
assertGraphIds(src);
|
|
974
|
+
const steps = [];
|
|
975
|
+
const created = await api(`/api/bots/${a.botId}/graphs`, { method: "POST", body: { name: a.name || src.name || "Воронка" } });
|
|
976
|
+
const graphId = created.id;
|
|
977
|
+
steps.push(`создан граф ${graphId}`);
|
|
978
|
+
await api(`/api/bots/graphs/${graphId}`, { method: "PUT", body: { nodes: src.nodes, edges: src.edges, canvasMeta: src.canvasMeta ?? {}, name: a.name || src.name } });
|
|
979
|
+
steps.push(`залито узлов: ${src.nodes.length}, рёбер: ${src.edges.length}`);
|
|
980
|
+
if (a.dryRun !== false) {
|
|
981
|
+
const dr = await api(`/api/bots/graphs/${graphId}/dry-run`, { method: "POST", body: { kind: "command", value: "start" } });
|
|
982
|
+
steps.push(`dry-run /start: runStatus=${dr.runStatus}`);
|
|
983
|
+
}
|
|
984
|
+
if (a.publish !== false) {
|
|
985
|
+
const pub = await api(`/api/bots/graphs/${graphId}/publish`, { method: "POST" });
|
|
986
|
+
if (pub.errors && pub.errors.length) {
|
|
987
|
+
steps.push(`❌ публикация не прошла, ошибок: ${pub.errors.length}`);
|
|
988
|
+
reportFailure({
|
|
989
|
+
tool: "import_funnel", args: a, category: "publish_rejected",
|
|
990
|
+
message: pub.errors.map((e) => `${e.code || "?"}${e.nodeId ? `@${e.nodeId}` : ""}: ${e.message || ""}`).join("\n"),
|
|
991
|
+
});
|
|
992
|
+
return okResult({ graphId, steps, publishErrors: pub.errors });
|
|
993
|
+
}
|
|
994
|
+
steps.push(`✅ опубликовано: publishedGraphId=${pub.publishedGraphId}`);
|
|
995
|
+
return okResult({ graphId, publishedGraphId: pub.publishedGraphId, steps });
|
|
996
|
+
}
|
|
997
|
+
return okResult({ graphId, steps });
|
|
998
|
+
}
|
|
999
|
+
case "list_templates": return okResult(await api("/api/bots/graph-templates"));
|
|
1000
|
+
case "create_graph_from_template":
|
|
1001
|
+
return okResult(await api(`/api/bots/${a.botId}/graphs/from-template`, { method: "POST", body: { templateId: a.templateId, name: a.name } }));
|
|
1002
|
+
case "rename_graph":
|
|
1003
|
+
return okResult(await api(`/api/bots/graphs/${a.graphId}/rename`, { method: "PATCH", body: { name: a.name } }));
|
|
1004
|
+
case "clone_graph":
|
|
1005
|
+
return okResult(await api(`/api/bots/graphs/${a.graphId}/clone`, { method: "POST" }));
|
|
1006
|
+
case "copy_graph":
|
|
1007
|
+
return okResult(await api(`/api/bots/graphs/${a.graphId}/copy`, { method: "POST", body: { targetBotId: a.targetBotId, preview: a.preview === true } }));
|
|
1008
|
+
case "delete_graph":
|
|
1009
|
+
await api(`/api/bots/graphs/${a.graphId}`, { method: "DELETE" });
|
|
1010
|
+
return okResult(`🗑️ Граф ${a.graphId} удалён.`);
|
|
1011
|
+
case "set_active_graph":
|
|
1012
|
+
await api(`/api/bots/${a.botId}/active-graph`, { method: "POST", body: { graphId: a.graphId } });
|
|
1013
|
+
return okResult(`✅ Активный граф бота ${a.botId} → ${a.graphId}.`);
|
|
1014
|
+
case "upload_file": {
|
|
1015
|
+
const saved = await uploadMedia({ filePath: a.path, url: a.url, filename: a.filename });
|
|
1016
|
+
return okResult({ ...saved, hint: "Готово. Ставь url в медиа-карточку SEND_MESSAGE (image/video/audio/file/voice/videonote → url; gallery → urls[]) или в SEND_PHOTO.photoUrl." });
|
|
1017
|
+
}
|
|
1018
|
+
case "list_files": return okResult(await api("/api/bots/media"));
|
|
1019
|
+
case "delete_file":
|
|
1020
|
+
await api(`/api/bots/media/${a.id}`, { method: "DELETE" });
|
|
1021
|
+
return okResult(`🗑️ Файл ${a.id} удалён из /bots/files.`);
|
|
1022
|
+
case "graph_analytics": return okResult(await api(`/api/bots/graphs/${a.graphId}/analytics`));
|
|
1023
|
+
case "list_bot_users": {
|
|
1024
|
+
const qs = [];
|
|
1025
|
+
if (a.page != null) qs.push(`page=${encodeURIComponent(a.page)}`);
|
|
1026
|
+
if (a.size != null) qs.push(`size=${encodeURIComponent(a.size)}`);
|
|
1027
|
+
if (a.query) qs.push(`q=${encodeURIComponent(a.query)}`);
|
|
1028
|
+
return okResult(await api(`/api/bots/${a.botId}/users${qs.length ? `?${qs.join("&")}` : ""}`));
|
|
1029
|
+
}
|
|
1030
|
+
case "list_links": return okResult(await api(`/api/bots/${a.botId}/links`));
|
|
1031
|
+
case "site_list": return okResult(await api("/api/bots/pages"));
|
|
1032
|
+
case "site_create": {
|
|
1033
|
+
if (!a.title) throw new Error("Передай title сайта.");
|
|
1034
|
+
if (a.template != null && !SITE_INITS.includes(a.template)) throw new Error(`template — одно из: ${SITE_INITS.join(", ")}.`);
|
|
1035
|
+
const page = await api("/api/bots/pages", { method: "POST", body: { title: a.title, slug: a.slug || undefined, mode: "BLOCKS" } });
|
|
1036
|
+
if (!a.template) return okResult(page);
|
|
1037
|
+
// черновик сразу, как редактор при «Мини-лендинг»: бэкенд не берёт пустой ops[] — безвредная set_settings{}
|
|
1038
|
+
const doc = await api(`/api/bots/pages/${page.id}/document/ops`, { method: "POST", body: { ops: SITE_NOOP_OPS, init: a.template } });
|
|
1039
|
+
return okResult({ ...page, template: a.template, revision: doc?.revision });
|
|
1040
|
+
}
|
|
1041
|
+
case "site_get": {
|
|
1042
|
+
const doc = await api(`/api/bots/pages/${a.siteId}/document`);
|
|
1043
|
+
if (a.saveToFile) {
|
|
1044
|
+
const abs = path.resolve(String(a.saveToFile).replace(/^~(?=$|[/\\])/, os.homedir()));
|
|
1045
|
+
fs.writeFileSync(abs, JSON.stringify(doc, null, 2));
|
|
1046
|
+
return okResult({ revision: doc?.revision, publishedRevision: doc?.publishedRevision, savedTo: abs });
|
|
1047
|
+
}
|
|
1048
|
+
return okResult(doc);
|
|
1049
|
+
}
|
|
1050
|
+
case "site_schema": return okResult(await api("/api/bots/pages/schema"));
|
|
1051
|
+
case "site_edit": {
|
|
1052
|
+
const hasOps = Array.isArray(a.ops) && a.ops.length > 0;
|
|
1053
|
+
if (!hasOps && !a.init) throw new Error("Передай ops — массив операций (см. site_schema) — или init, чтобы только создать черновик.");
|
|
1054
|
+
if (a.init != null && !SITE_INITS.includes(a.init)) throw new Error(`init — одно из: ${SITE_INITS.join(", ")}.`);
|
|
1055
|
+
const ops = hasOps ? a.ops : SITE_NOOP_OPS; // бэкенд отвечает 422 на пустой ops[]
|
|
1056
|
+
return okResult(await api(`/api/bots/pages/${a.siteId}/document/ops`, { method: "POST", body: { ops, revision: a.revision, init: a.init } }));
|
|
1057
|
+
}
|
|
1058
|
+
case "site_publish": {
|
|
1059
|
+
const r = await api(`/api/bots/pages/${a.siteId}/publish`, { method: "POST" });
|
|
1060
|
+
return okResult({ publishedRevision: r?.publishedRevision, url: r?.page?.url });
|
|
1061
|
+
}
|
|
1062
|
+
case "site_upload_asset": return okResult(await uploadSiteAsset(a.siteId, { filePath: a.path, url: a.url }));
|
|
1063
|
+
case "site_leads": {
|
|
1064
|
+
const qs = [];
|
|
1065
|
+
if (a.page != null) qs.push(`page=${encodeURIComponent(a.page)}`);
|
|
1066
|
+
if (a.size != null) qs.push(`size=${encodeURIComponent(a.size)}`);
|
|
1067
|
+
if (a.status) qs.push(`status=${enumArg(a.status, "status", LEAD_STATUSES)}`);
|
|
1068
|
+
return okResult(await api(`/api/bots/pages/${a.siteId}/leads${qs.length ? `?${qs.join("&")}` : ""}`));
|
|
1069
|
+
}
|
|
1070
|
+
case "site_rollback": {
|
|
1071
|
+
if (typeof a.revision !== "number") throw new Error("Передай revision — номер публикации (site_get → versions[]).");
|
|
1072
|
+
const r = await api(`/api/bots/pages/${a.siteId}/publish/rollback`, { method: "POST", body: { revision: a.revision } });
|
|
1073
|
+
return okResult({ publishedRevision: r?.publishedRevision, url: r?.page?.url });
|
|
1074
|
+
}
|
|
1075
|
+
case "site_domains": {
|
|
1076
|
+
const base = `/api/bots/pages/${a.siteId}/domains`;
|
|
1077
|
+
if (a.action === "list") return okResult(await api(base));
|
|
1078
|
+
if (a.action === "add") {
|
|
1079
|
+
if (!a.host) throw new Error("Передай host — домен, например example.ru.");
|
|
1080
|
+
return okResult(await api(base, { method: "POST", body: { host: a.host, withWww: !!a.withWww } }));
|
|
1081
|
+
}
|
|
1082
|
+
if (!a.domainId) throw new Error("Передай domainId (site_domains action=list).");
|
|
1083
|
+
if (a.action === "check") return okResult(await api(`${base}/${a.domainId}/check`, { method: "POST" }));
|
|
1084
|
+
if (a.action === "remove") return okResult(await api(`${base}/${a.domainId}`, { method: "DELETE" }));
|
|
1085
|
+
throw new Error("action: list | add | check | remove.");
|
|
1086
|
+
}
|
|
1087
|
+
case "site_lead_settings": {
|
|
1088
|
+
const p_ = `/api/bots/pages/${a.siteId}/lead-settings`;
|
|
1089
|
+
if (a.settings == null) return okResult(withoutSecrets(await api(p_)));
|
|
1090
|
+
return okResult(withoutSecrets(await api(p_, { method: "PUT", body: a.settings })));
|
|
1091
|
+
}
|
|
1092
|
+
case "article_list": return okResult(await api("/api/articles/my"));
|
|
1093
|
+
case "article_get": return okResult(await api(`/api/articles/by-slug/${encodeURIComponent(a.slug)}`));
|
|
1094
|
+
case "article_publish": {
|
|
1095
|
+
if (!a.content || !String(a.content).trim()) throw new Error("Передай content (Markdown). Заголовок можно не передавать, если текст начинается с «# ...».");
|
|
1096
|
+
const created = await api("/api/articles", { method: "POST", body: { title: a.title, content: a.content, coverImage: a.cover, excerpt: a.excerpt } });
|
|
1097
|
+
return okResult({ ...created, publicUrl: created?.slug ? `${BASE}/articles/${created.slug}` : null });
|
|
1098
|
+
}
|
|
1099
|
+
case "article_update": {
|
|
1100
|
+
if (!a.id) throw new Error("Передай id статьи (см. article_list).");
|
|
1101
|
+
if (!a.content || !String(a.content).trim()) throw new Error("Передай content (Markdown).");
|
|
1102
|
+
const updated = await api(`/api/articles/${a.id}`, { method: "PUT", body: { title: a.title, content: a.content } });
|
|
1103
|
+
return okResult({ ...updated, publicUrl: updated?.slug ? `${BASE}/articles/${updated.slug}` : null });
|
|
1104
|
+
}
|
|
1105
|
+
case "agent_list": return okResult(await api("/api/bots/agents"));
|
|
1106
|
+
case "agent_get": return okResult(await api(`/api/bots/agents/${a.agentId}`));
|
|
1107
|
+
case "agent_create": return okResult(await api("/api/bots/agents", { method: "POST", body: { name: a.name, description: a.description } }));
|
|
1108
|
+
case "agent_update": {
|
|
1109
|
+
if (!a.patch || typeof a.patch !== "object") throw new Error("Передай patch — объект с полями агента для правки.");
|
|
1110
|
+
return okResult(await api(`/api/bots/agents/${a.agentId}`, { method: "PATCH", body: a.patch }));
|
|
1111
|
+
}
|
|
1112
|
+
case "agent_publish": return okResult(await api(`/api/bots/agents/${a.agentId}/publish`, { method: "POST" }));
|
|
1113
|
+
case "agent_health": return okResult(await api(`/api/bots/agents/${a.agentId}/health`));
|
|
1114
|
+
case "agent_test_chat": {
|
|
1115
|
+
if (!a.question || !String(a.question).trim()) throw new Error("Передай question.");
|
|
1116
|
+
return okResult(await api(`/api/bots/agents/${a.agentId}/test-chat`, { method: "POST", body: { question: a.question, history: a.history } }));
|
|
1117
|
+
}
|
|
1118
|
+
// ---- Сайт-виджет, база знаний, подписчик ----
|
|
1119
|
+
case "web_widget_snippet": return okResult(await api(`/api/bots/web/${a.botId}/snippet`));
|
|
1120
|
+
case "kb_list": return okResult(await api("/api/bots/kb"));
|
|
1121
|
+
case "kb_create": {
|
|
1122
|
+
if (!a.name || !String(a.name).trim()) throw new Error("Передай name — название базы знаний.");
|
|
1123
|
+
return okResult(await api("/api/bots/kb", { method: "POST", body: { name: String(a.name).trim() } }));
|
|
1124
|
+
}
|
|
1125
|
+
case "kb_docs": return okResult(await api(`/api/bots/kb/${a.kbId}/docs`));
|
|
1126
|
+
case "kb_add_qa": {
|
|
1127
|
+
if (!Array.isArray(a.pairs) || a.pairs.length === 0) throw new Error("Передай pairs — непустой массив [{question, answer}].");
|
|
1128
|
+
return okResult(await api(`/api/bots/kb/${a.kbId}/docs/qa`, { method: "POST", body: a.pairs }));
|
|
1129
|
+
}
|
|
1130
|
+
case "kb_add_text": {
|
|
1131
|
+
if (!a.title || !a.text) throw new Error("Передай title и text.");
|
|
1132
|
+
return okResult(await api(`/api/bots/kb/${a.kbId}/docs/text`, { method: "POST", body: { title: a.title, text: a.text } }));
|
|
1133
|
+
}
|
|
1134
|
+
case "kb_add_site": {
|
|
1135
|
+
if (!a.url) throw new Error("Передай url.");
|
|
1136
|
+
return okResult(await api(`/api/bots/kb/${a.kbId}/docs/site`, { method: "POST", body: { url: a.url, schedule: a.schedule } }));
|
|
1137
|
+
}
|
|
1138
|
+
case "kb_reindex": return okResult(await api(`/api/bots/kb/${a.kbId}/docs/${a.docId}/reindex`, { method: "POST", body: { headerRow: a.headerRow } }));
|
|
1139
|
+
case "agent_unanswered": {
|
|
1140
|
+
const qs = a.days != null ? `?days=${encodeURIComponent(a.days)}` : "";
|
|
1141
|
+
return okResult(await api(`/api/bots/agents/${a.agentId}/unanswered${qs}`));
|
|
1142
|
+
}
|
|
1143
|
+
case "kb_delete_doc": {
|
|
1144
|
+
await api(`/api/bots/kb/${a.kbId}/docs/${a.docId}`, { method: "DELETE" });
|
|
1145
|
+
return okResult(`🗑️ Документ ${a.docId} удалён из базы ${a.kbId}.`);
|
|
1146
|
+
}
|
|
1147
|
+
case "bot_user_get": return okResult(await api(`/api/bots/${a.botId}/users/${chatIdArg(a.chatId)}`));
|
|
1148
|
+
case "bot_user_runs": return okResult(await api(`/api/bots/${a.botId}/users/${chatIdArg(a.chatId)}/runs${pageQs(a)}`));
|
|
1149
|
+
case "dialog_messages": return okResult(await api(`/api/bots/${a.botId}/users/${chatIdArg(a.chatId)}/messages${pageQs(a)}`));
|
|
1150
|
+
case "dialog_reply": {
|
|
1151
|
+
const text = String(a.text ?? "").trim();
|
|
1152
|
+
if (!text) throw new Error("Передай text — текст ответа.");
|
|
1153
|
+
return okResult(await api(`/api/bots/${a.botId}/users/${chatIdArg(a.chatId)}/messages`, { method: "POST", body: { text } }));
|
|
1154
|
+
}
|
|
1155
|
+
// ---- Запись на слоты ----
|
|
1156
|
+
case "booking_calendar_list": return okResult(await api("/api/bots/booking/calendars"));
|
|
1157
|
+
case "booking_calendar_get": return okResult(await api(`/api/bots/booking/calendars/${uuidArg(a.calendarId, "calendarId")}`));
|
|
1158
|
+
case "booking_calendar_create": {
|
|
1159
|
+
const body = calendarBody(a);
|
|
1160
|
+
if (!body.name) throw new Error("Передай name — название календаря.");
|
|
1161
|
+
return okResult(await api("/api/bots/booking/calendars", { method: "POST", body }));
|
|
1162
|
+
}
|
|
1163
|
+
case "booking_calendar_update": {
|
|
1164
|
+
const path_ = `/api/bots/booking/calendars/${uuidArg(a.calendarId, "calendarId")}`;
|
|
1165
|
+
const { calendarId, ...fields } = a;
|
|
1166
|
+
if (!Object.keys(fields).length) throw new Error("Передай хотя бы одно поле: name, zone, slotMinutes, hours, exceptions, botId.");
|
|
1167
|
+
const body = calendarBody(fields, await api(path_)); // PUT заменяет календарь целиком — накладываем на текущий
|
|
1168
|
+
return okResult(await api(path_, { method: "PUT", body }));
|
|
1169
|
+
}
|
|
1170
|
+
case "booking_calendar_delete": {
|
|
1171
|
+
const id = uuidArg(a.calendarId, "calendarId");
|
|
1172
|
+
if (a.confirm !== true) throw new Error("Удаление календаря необратимо. Спроси пользователя и повтори с confirm:true.");
|
|
1173
|
+
await api(`/api/bots/booking/calendars/${id}`, { method: "DELETE" });
|
|
1174
|
+
return okResult(`🗑️ Календарь ${id} удалён.`);
|
|
1175
|
+
}
|
|
1176
|
+
case "booking_slots": {
|
|
1177
|
+
const q = {};
|
|
1178
|
+
if (a.from) q.from = dateArg(a.from, "from");
|
|
1179
|
+
if (a.to) q.to = dateArg(a.to, "to");
|
|
1180
|
+
if (a.limit != null) {
|
|
1181
|
+
const n = Number(a.limit);
|
|
1182
|
+
if (!Number.isInteger(n) || n < 1 || n > 500) throw new Error("limit — целое от 1 до 500.");
|
|
1183
|
+
q.limit = n;
|
|
1184
|
+
}
|
|
1185
|
+
return okResult(await api(`/api/bots/booking/calendars/${uuidArg(a.calendarId, "calendarId")}/slots${qs(q)}`));
|
|
1186
|
+
}
|
|
1187
|
+
case "booking_list": {
|
|
1188
|
+
const q = a.from ? { from: dateArg(a.from, "from") } : {};
|
|
1189
|
+
return okResult(await api(`/api/bots/booking/calendars/${uuidArg(a.calendarId, "calendarId")}/bookings${qs(q)}`));
|
|
1190
|
+
}
|
|
1191
|
+
case "booking_create": {
|
|
1192
|
+
const id = uuidArg(a.calendarId, "calendarId");
|
|
1193
|
+
const slotAt = String(a.slotAt ?? "").trim();
|
|
1194
|
+
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/.test(slotAt) || Number.isNaN(Date.parse(slotAt))) {
|
|
1195
|
+
throw new Error(`slotAt — ISO-8601 с зоной (at из booking_slots), например 2026-10-12T15:00:00+03:00; получено: ${slotAt || "пусто"}.`);
|
|
1196
|
+
}
|
|
1197
|
+
try {
|
|
1198
|
+
return okResult(await api(`/api/bots/booking/calendars/${id}/bookings`, { method: "POST", body: { slotAt, name: a.name, phone: a.phone } }));
|
|
1199
|
+
} catch (e) {
|
|
1200
|
+
if (e.status === 409) throw new Error(`Слот ${slotAt} уже занят — выбери другой из booking_slots.`);
|
|
1201
|
+
throw e;
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
case "booking_cancel": {
|
|
1205
|
+
const id = uuidArg(a.calendarId, "calendarId");
|
|
1206
|
+
const bookingId = uuidArg(a.bookingId, "bookingId");
|
|
1207
|
+
await api(`/api/bots/booking/calendars/${id}/bookings/${bookingId}/cancel`, { method: "POST" });
|
|
1208
|
+
return okResult(`✅ Бронь ${bookingId} отменена, слот свободен.`);
|
|
1209
|
+
}
|
|
1210
|
+
// ---- Паритет платформы ----
|
|
1211
|
+
case "integration_calls": {
|
|
1212
|
+
const q = {};
|
|
1213
|
+
if (a.connectionId != null && a.connectionId !== "") q.connectionId = idArg(a.connectionId, "connectionId");
|
|
1214
|
+
if (a.ok != null) {
|
|
1215
|
+
if (typeof a.ok !== "boolean") throw new Error("ok — true или false.");
|
|
1216
|
+
q.ok = a.ok;
|
|
1217
|
+
}
|
|
1218
|
+
if (a.limit != null) {
|
|
1219
|
+
const n = Number(a.limit);
|
|
1220
|
+
if (!Number.isInteger(n) || n < 1 || n > 200) throw new Error("limit — целое от 1 до 200.");
|
|
1221
|
+
q.limit = n;
|
|
1222
|
+
}
|
|
1223
|
+
return okResult(await api(`/api/bots/integrations/calls${qs(q)}`));
|
|
1224
|
+
}
|
|
1225
|
+
case "dialog_handoff": {
|
|
1226
|
+
if (typeof a.active !== "boolean") throw new Error("active — true (передать оператору) или false (вернуть боту).");
|
|
1227
|
+
const res = await api(`/api/bots/${uuidArg(a.botId, "botId")}/users/${chatIdArg(a.chatId)}/handoff`, { method: "POST", body: { active: a.active } });
|
|
1228
|
+
return okResult(res ?? (a.active ? "✅ Диалог передан оператору: бот молчит." : "✅ Диалог возвращён боту."));
|
|
1229
|
+
}
|
|
1230
|
+
case "site_lead_status": {
|
|
1231
|
+
const status = enumArg(a.status, "status", LEAD_STATUSES);
|
|
1232
|
+
try {
|
|
1233
|
+
return okResult(await api(`/api/bots/pages/${uuidArg(a.siteId, "siteId")}/leads/${uuidArg(a.leadId, "leadId")}`, { method: "PATCH", body: { status } }));
|
|
1234
|
+
} catch (e) {
|
|
1235
|
+
if (e.status === 409) throw new Error(`Недопустимый переход статуса заявки в ${status}: NEW → IN_PROGRESS → DONE | REJECTED.`);
|
|
1236
|
+
throw e;
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
case "integration_update": {
|
|
1240
|
+
const body = {};
|
|
1241
|
+
if (a.title != null) {
|
|
1242
|
+
const t = String(a.title).trim();
|
|
1243
|
+
if (!t) throw new Error("title не может быть пустым.");
|
|
1244
|
+
body.title = t;
|
|
1245
|
+
}
|
|
1246
|
+
if (a.creds != null) {
|
|
1247
|
+
if (typeof a.creds !== "object" || Array.isArray(a.creds) || !Object.keys(a.creds).length) throw new Error("creds — непустой объект {поле: значение}.");
|
|
1248
|
+
body.creds = Object.fromEntries(Object.entries(a.creds).map(([k, v]) => [k, String(v)]));
|
|
1249
|
+
}
|
|
1250
|
+
if (!Object.keys(body).length) throw new Error("Передай title и/или creds.");
|
|
1251
|
+
return okResult(await api(`/api/bots/integrations/${idArg(a.connectionId, "connectionId")}`, { method: "PUT", body }));
|
|
1252
|
+
}
|
|
1253
|
+
case "web_widget_settings": {
|
|
1254
|
+
const path_ = `/api/bots/web/${uuidArg(a.botId, "botId")}/settings`;
|
|
1255
|
+
if (a.settings == null) return okResult(await api(path_));
|
|
1256
|
+
if (typeof a.settings !== "object" || Array.isArray(a.settings)) throw new Error("settings — объект с полями настроек.");
|
|
1257
|
+
const cur = await api(path_);
|
|
1258
|
+
const merged = { ...(cur?.settings ?? {}), ...a.settings };
|
|
1259
|
+
return okResult(await api(path_, { method: "PUT", body: merged }));
|
|
1260
|
+
}
|
|
1261
|
+
case "bot_users_import": {
|
|
1262
|
+
if (!Array.isArray(a.rows) || !a.rows.length) throw new Error("Передай rows — непустой массив [{chatId, tags?, variables?}].");
|
|
1263
|
+
if (a.rows.length > 10000) throw new Error("rows — не больше 10 000 строк за вызов.");
|
|
1264
|
+
// chatId бывает > 2^53: в JSON кладём числом прямо из строки, без Number().
|
|
1265
|
+
const rows = a.rows.map((r, i) => {
|
|
1266
|
+
const cid = String(r?.chatId ?? "").trim();
|
|
1267
|
+
if (!/^[1-9]\d{0,18}$/.test(cid)) throw new Error(`rows[${i}].chatId — положительное число, получено: ${cid || "пусто"}.`);
|
|
1268
|
+
if (r.tags != null && !Array.isArray(r.tags)) throw new Error(`rows[${i}].tags — массив строк.`);
|
|
1269
|
+
if (r.variables != null && (typeof r.variables !== "object" || Array.isArray(r.variables))) throw new Error(`rows[${i}].variables — объект.`);
|
|
1270
|
+
return { chatId: `@@CID${cid}@@`, username: r.username, tags: r.tags, variables: r.variables };
|
|
1271
|
+
});
|
|
1272
|
+
const raw = JSON.stringify({ rows }).replace(/"@@CID(\d+)@@"/g, "$1");
|
|
1273
|
+
return okResult(await api(`/api/bots/${uuidArg(a.botId, "botId")}/users/import`, { method: "POST", rawBody: raw }));
|
|
1274
|
+
}
|
|
1275
|
+
case "bot_runs": {
|
|
1276
|
+
if (a.runId) return okResult(await api(`/api/bots/runs/${idArg(a.runId, "runId")}`));
|
|
1277
|
+
return okResult(await api(`/api/bots/${uuidArg(a.botId, "botId")}/runs${pageQs(a)}`));
|
|
1278
|
+
}
|
|
1279
|
+
case "scenario_runs": return okResult(await api(`/api/bots/graphs/${uuidArg(a.graphId, "graphId")}/runs${pageQs(a)}`));
|
|
1280
|
+
case "bot_delete": {
|
|
1281
|
+
const botId = uuidArg(a.botId, "botId");
|
|
1282
|
+
if (a.confirm !== true) throw new Error("Удаление бота необратимо. Спроси пользователя и повтори с confirm:true.");
|
|
1283
|
+
await api(`/api/bots/${botId}`, { method: "DELETE" });
|
|
1284
|
+
return okResult(`🗑️ Бот ${botId} удалён вместе со сценариями и подписчиками.`);
|
|
1285
|
+
}
|
|
1286
|
+
// ---- Паритет платформы, часть 2 ----
|
|
1287
|
+
case "bot_rename": {
|
|
1288
|
+
if (typeof a.name !== "string") throw new Error("name — строка (пустая сбрасывает имя на @username).");
|
|
1289
|
+
return okResult(await api(`/api/bots/${uuidArg(a.botId, "botId")}`, { method: "PATCH", body: { name: a.name } }));
|
|
1290
|
+
}
|
|
1291
|
+
case "bot_change_token": {
|
|
1292
|
+
const botId = uuidArg(a.botId, "botId");
|
|
1293
|
+
const token = String(a.token ?? "").trim();
|
|
1294
|
+
if (!token) throw new Error("Передай token — новый токен бота.");
|
|
1295
|
+
try {
|
|
1296
|
+
return okResult(await api(`/api/bots/${botId}/token`, { method: "POST", body: { token } }));
|
|
1297
|
+
} catch (e) {
|
|
1298
|
+
// 400 — строка-причина (неверный токен, уже подключён к другому боту); сам токен в ошибку не кладём.
|
|
1299
|
+
if (e.status === 400) throw new Error(`Токен не принят: ${typeof e.data === "string" && e.data ? e.data : "проверь токен"}.`);
|
|
1300
|
+
throw e;
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
case "bot_channel_list": return okResult(await api(`/api/bots/${uuidArg(a.botId, "botId")}/channels`));
|
|
1304
|
+
case "bot_channel_add": {
|
|
1305
|
+
const botId = uuidArg(a.botId, "botId");
|
|
1306
|
+
const token = String(a.token ?? "").trim();
|
|
1307
|
+
if (!token) throw new Error("Передай token — токен дополнительного Telegram-бота.");
|
|
1308
|
+
try {
|
|
1309
|
+
return okResult(await api(`/api/bots/${botId}/channels`, { method: "POST", body: { token, name: a.name || undefined } }));
|
|
1310
|
+
} catch (e) {
|
|
1311
|
+
if (e.status === 400) throw new Error(`Канал не добавлен: ${typeof e.data === "string" && e.data ? e.data : "токен не принят"}.`);
|
|
1312
|
+
throw e;
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
case "bot_channel_delete": {
|
|
1316
|
+
const botId = uuidArg(a.botId, "botId");
|
|
1317
|
+
const channelId = uuidArg(a.channelId, "channelId");
|
|
1318
|
+
if (a.confirm !== true) throw new Error("Отключение канала необратимо. Спроси пользователя и повтори с confirm:true.");
|
|
1319
|
+
await api(`/api/bots/${botId}/channels/${channelId}`, { method: "DELETE" });
|
|
1320
|
+
return okResult(`🗑️ Канал ${channelId} отключён от бота ${botId}.`);
|
|
1321
|
+
}
|
|
1322
|
+
case "link_create": {
|
|
1323
|
+
const body = { name: a.name || undefined };
|
|
1324
|
+
if (a.targetNodeId) body.targetNodeId = uuidArg(a.targetNodeId, "targetNodeId");
|
|
1325
|
+
return okResult(await api(`/api/bots/${uuidArg(a.botId, "botId")}/links`, { method: "POST", body }));
|
|
1326
|
+
}
|
|
1327
|
+
case "link_delete": {
|
|
1328
|
+
const linkId = uuidArg(a.linkId, "linkId");
|
|
1329
|
+
if (a.confirm !== true) throw new Error("Удаление ссылки необратимо. Спроси пользователя и повтори с confirm:true.");
|
|
1330
|
+
await api(`/api/bots/links/${linkId}`, { method: "DELETE" });
|
|
1331
|
+
return okResult(`🗑️ Ссылка ${linkId} удалена.`);
|
|
1332
|
+
}
|
|
1333
|
+
case "utm_sources": return okResult(await api(`/api/bots/${uuidArg(a.botId, "botId")}/utm-sources`));
|
|
1334
|
+
case "ab_results": {
|
|
1335
|
+
const q = { branchNodeId: uuidArg(a.branchNodeId, "branchNodeId") };
|
|
1336
|
+
if (a.period != null && a.period !== "") {
|
|
1337
|
+
const p = String(a.period).trim();
|
|
1338
|
+
if (!/^\d{1,4}[hd]$/.test(p)) throw new Error(`period — число с h или d (24h, 7d, 30d), получено: ${p}.`);
|
|
1339
|
+
q.period = p;
|
|
1340
|
+
}
|
|
1341
|
+
return okResult(await api(`/api/bots/graphs/${uuidArg(a.graphId, "graphId")}/ab-results${qs(q)}`));
|
|
1342
|
+
}
|
|
1343
|
+
case "kb_delete": {
|
|
1344
|
+
const kbId = idArg(a.kbId, "kbId");
|
|
1345
|
+
if (a.confirm !== true) throw new Error("Удаление базы знаний необратимо. Спроси пользователя и повтори с confirm:true.");
|
|
1346
|
+
try {
|
|
1347
|
+
await api(`/api/bots/kb/${kbId}`, { method: "DELETE" });
|
|
1348
|
+
} catch (e) {
|
|
1349
|
+
if (e.status === 409) throw new Error("Это база ИИ-агента (KB_OWNED_BY_AGENT): отдельно не удаляется — удали агента (agent_delete).");
|
|
1350
|
+
throw e;
|
|
1351
|
+
}
|
|
1352
|
+
return okResult(`🗑️ База знаний ${kbId} удалена со всеми документами.`);
|
|
1353
|
+
}
|
|
1354
|
+
case "agent_unpublish": return okResult(await api(`/api/bots/agents/${idArg(a.agentId, "agentId")}/unpublish`, { method: "POST" }));
|
|
1355
|
+
case "agent_delete": {
|
|
1356
|
+
const agentId = idArg(a.agentId, "agentId");
|
|
1357
|
+
if (a.confirm !== true) throw new Error("Удаление агента необратимо. Спроси пользователя и повтори с confirm:true.");
|
|
1358
|
+
try {
|
|
1359
|
+
await api(`/api/bots/agents/${agentId}`, { method: "DELETE" });
|
|
1360
|
+
} catch (e) {
|
|
1361
|
+
if (e.status === 409) {
|
|
1362
|
+
const refs = Array.isArray(e.data?.scenarios) ? e.data.scenarios.map((s) => s?.graphName || s?.graphId).filter(Boolean) : [];
|
|
1363
|
+
throw new Error(`Агент подключён к сценариям (AGENT_IN_USE)${refs.length ? `: ${refs.join(", ")}` : ""}. Сначала отключи его в них.`);
|
|
1364
|
+
}
|
|
1365
|
+
throw e;
|
|
1366
|
+
}
|
|
1367
|
+
return okResult(`🗑️ Агент ${agentId} удалён.`);
|
|
1368
|
+
}
|
|
1369
|
+
case "site_leads_mark_read": {
|
|
1370
|
+
const siteId = uuidArg(a.siteId, "siteId");
|
|
1371
|
+
await api(`/api/bots/pages/${siteId}/leads/read`, { method: "POST" });
|
|
1372
|
+
return okResult(`✅ Все заявки сайта ${siteId} отмечены прочитанными.`);
|
|
1373
|
+
}
|
|
1374
|
+
case "site_lead_delete": {
|
|
1375
|
+
const siteId = uuidArg(a.siteId, "siteId");
|
|
1376
|
+
const leadId = uuidArg(a.leadId, "leadId");
|
|
1377
|
+
if (a.confirm !== true) throw new Error("Удаление заявки необратимо. Спроси пользователя и повтори с confirm:true.");
|
|
1378
|
+
await api(`/api/bots/pages/${siteId}/leads/${leadId}`, { method: "DELETE" });
|
|
1379
|
+
return okResult(`🗑️ Заявка ${leadId} удалена.`);
|
|
1380
|
+
}
|
|
1381
|
+
case "site_leads_export": return exportResult(await api(`/api/bots/pages/${uuidArg(a.siteId, "siteId")}/leads.csv`), a.savePath);
|
|
1382
|
+
case "bot_users_export": {
|
|
1383
|
+
const format = String(a.format || "csv").trim().toLowerCase();
|
|
1384
|
+
if (format !== "csv" && format !== "json") throw new Error(`format: csv | json, получено: ${format}.`);
|
|
1385
|
+
return exportResult(await api(`/api/bots/${uuidArg(a.botId, "botId")}/users/export?format=${format}`), a.savePath);
|
|
1386
|
+
}
|
|
1387
|
+
case "bot_user_reset": {
|
|
1388
|
+
const botId = uuidArg(a.botId, "botId");
|
|
1389
|
+
const chatId = chatIdArg(a.chatId);
|
|
1390
|
+
if (a.confirm !== true) throw new Error("Сброс сессии стирает метки и переменные подписчика. Спроси пользователя и повтори с confirm:true.");
|
|
1391
|
+
await api(`/api/bots/${botId}/sessions/${chatId}`, { method: "DELETE" });
|
|
1392
|
+
return okResult(`♻️ Сессия подписчика ${chatId} сброшена: при следующем сообщении он начнёт как новый.`);
|
|
1393
|
+
}
|
|
1394
|
+
// ---- Боты ----
|
|
1395
|
+
case "create_bot": {
|
|
1396
|
+
const platform = String(a.platform || "").trim().toUpperCase();
|
|
1397
|
+
if (platform === "INSTAGRAM") return instagramAnswer();
|
|
1398
|
+
if (platform === "WEB") return okResult(await api("/api/bots/web", { method: "POST", body: { name: a.name || undefined } }));
|
|
1399
|
+
if (platform !== "TELEGRAM" && platform !== "MAX") throw new Error("platform: TELEGRAM | MAX | WEB (Instagram подключается только в кабинете).");
|
|
1400
|
+
const token = String(a.token || "").trim();
|
|
1401
|
+
if (!token) {
|
|
1402
|
+
return okResult(platform === "MAX"
|
|
1403
|
+
? "Нужен токен MAX-бота: создай бота у MasterBot в MAX и пришли токен — я подключу его (create_bot {platform:\"MAX\", token})."
|
|
1404
|
+
: "Нужен токен Telegram-бота: открой https://t.me/BotFather → /newbot (или /token для существующего) и пришли токен вида 123456789:AA… — я подключу его (create_bot {platform:\"TELEGRAM\", token}).");
|
|
1405
|
+
}
|
|
1406
|
+
let bot;
|
|
1407
|
+
try {
|
|
1408
|
+
bot = await api("/api/bots", { method: "POST", body: { token, platform } });
|
|
1409
|
+
} catch (e) {
|
|
1410
|
+
// 400 приходит строкой-причиной (неверный токен, бот уже подключён) — её и показываем.
|
|
1411
|
+
if (e.status === 400) throw new Error(`Бот не подключён: ${typeof e.data === "string" && e.data ? e.data : "токен не принят"}. Проверь токен (${platform === "MAX" ? "MasterBot в MAX" : "@BotFather → /token"}).`);
|
|
1412
|
+
throw e;
|
|
1413
|
+
}
|
|
1414
|
+
if (a.name && bot?.id) {
|
|
1415
|
+
try { bot = await api(`/api/bots/${bot.id}`, { method: "PATCH", body: { name: a.name } }); }
|
|
1416
|
+
catch (e) { return okResult({ ...bot, warning: `Бот подключён, но имя не задано: ${(e.message || "").split("\n")[0]}` }); }
|
|
1417
|
+
}
|
|
1418
|
+
return okResult(bot);
|
|
1419
|
+
}
|
|
1420
|
+
case "bot_stop": return okResult(await api(`/api/bots/${a.botId}/stop`, { method: "POST" }));
|
|
1421
|
+
case "bot_resume": return okResult(await api(`/api/bots/${a.botId}/resume`, { method: "POST" }));
|
|
1422
|
+
// ---- Подключения ----
|
|
1423
|
+
case "connect_integration": {
|
|
1424
|
+
const provider = normProvider(a.provider);
|
|
1425
|
+
if (provider === "INSTAGRAM") return instagramAnswer();
|
|
1426
|
+
if (provider === "TELEGRAM" || provider === "MAX") {
|
|
1427
|
+
return okResult(`${provider === "MAX" ? "MAX" : "Telegram"} подключается как бот, а не интеграция: вызови create_bot {platform:"${provider}", token}.`);
|
|
1428
|
+
}
|
|
1429
|
+
if (provider === "GOOGLE_SHEETS") {
|
|
1430
|
+
const r = await api(`/api/bots/google/auth-url${qs({ returnPath: "/bots/integrations" })}`, { method: "POST" });
|
|
1431
|
+
let connected = [];
|
|
1432
|
+
try { const ids = await api("/api/bots/google/identities"); connected = Array.isArray(ids) ? ids : []; } catch { /* список не обязателен */ }
|
|
1433
|
+
if (!r?.authUrl) throw new Error(`Сервер не вернул ссылку авторизации Google. Подключи в кабинете: ${INTEGRATIONS_PAGE}`);
|
|
1434
|
+
return linkResult("Google Таблицы: вход через Google (OAuth)", r.authUrl,
|
|
1435
|
+
"Открой ссылку, выбери Google-аккаунт и разреши доступ к таблицам — затем таблица выбирается в узле сценария «Google Таблицы». Ссылка одноразовая и живёт недолго: если истекла, вызови connect_integration ещё раз.",
|
|
1436
|
+
{ connectedGoogleAccounts: connected });
|
|
1437
|
+
}
|
|
1438
|
+
const spec = PROVIDER_FIELDS[provider];
|
|
1439
|
+
if (spec) {
|
|
1440
|
+
// Легаси-провайдер (одна из исходных 5) — шлём enum `provider` как раньше, без каталога.
|
|
1441
|
+
const creds = a.creds && typeof a.creds === "object" ? Object.fromEntries(
|
|
1442
|
+
Object.entries(a.creds).filter(([, v]) => v != null && String(v).trim() !== "").map(([k, v]) => [k, String(v).trim()])) : {};
|
|
1443
|
+
const missing = Object.keys(spec.fields).filter((k) => !creds[k]);
|
|
1444
|
+
if (missing.length && (!a.connectionId || Object.keys(creds).length)) {
|
|
1445
|
+
return okResult({
|
|
1446
|
+
connected: false,
|
|
1447
|
+
provider,
|
|
1448
|
+
need: Object.fromEntries(missing.map((k) => [k, spec.fields[k]])),
|
|
1449
|
+
instruction: `Для ${spec.name} не хватает полей creds: ${missing.join(", ")}. Попроси их у пользователя и вызови connect_integration ещё раз. Или пусть подключит сам в кабинете: ${CONNECT_PAGE}`,
|
|
1450
|
+
});
|
|
1451
|
+
}
|
|
1452
|
+
const title = a.title || spec.name;
|
|
1453
|
+
try {
|
|
1454
|
+
const saved = a.connectionId
|
|
1455
|
+
? await api(`/api/bots/integrations/${a.connectionId}`, { method: "PUT", body: { title: a.title, creds: Object.keys(creds).length ? creds : undefined } })
|
|
1456
|
+
: await api("/api/bots/integrations", { method: "POST", body: { provider, title, creds } });
|
|
1457
|
+
return okResult({ connected: true, connectionId: saved?.id, ...withoutSecrets(saved), note: "Креды сохранены зашифрованными и обратно не отдаются (только маска hint).", usage: "connectionId ставь в действия сценария (amocrm_send, bitrix24_call, getcourse_send, yametrika_event, оплата ЮKassa) и в site_lead_settings (amoConnectionId или coreDelivery.connectionId). Проверить ключ — integration_test." });
|
|
1458
|
+
} catch (e) {
|
|
1459
|
+
if (e.status === 400) throw new Error(`${spec.name} не подключён: ${bodyReason(e.data) || "креды не приняты"}. Проверь поля: ${Object.entries(spec.fields).map(([k, v]) => `${k} — ${v}`).join("; ")}.`);
|
|
1460
|
+
throw e;
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
// Провайдер из каталога Integration Core (~25 сервисов, GET /api/integrations/catalog) —
|
|
1465
|
+
// ключ в запросе на создание ВСЁ ЕЩЁ называется `provider`, но в нижнем регистре (bots/integrations
|
|
1466
|
+
// одинаково принимает enum-провайдеров и свободные providerKey из реестра, см. IntegrationConnectionService.create).
|
|
1467
|
+
const catalogKey = String(a.provider || "").trim().toLowerCase().replace(/[\s.-]+/g, "_");
|
|
1468
|
+
let catalog;
|
|
1469
|
+
try { catalog = await api("/api/integrations/catalog"); } catch { /* офлайн — фолбэк на статическую таблицу ниже */ }
|
|
1470
|
+
if (!Array.isArray(catalog)) {
|
|
1471
|
+
throw new Error(`Неизвестный provider «${a.provider}». Бывают: ${Object.keys(PROVIDER_FIELDS).join(", ")}, GOOGLE_SHEETS, INSTAGRAM (каталог Integration Core сейчас недоступен — офлайн-режим). Каталог: ${CONNECT_PAGE}`);
|
|
1472
|
+
}
|
|
1473
|
+
const entry = catalog.find((p) => String(p?.provider || "").toLowerCase() === catalogKey);
|
|
1474
|
+
if (!entry) {
|
|
1475
|
+
const names = catalog.map((p) => p.provider).filter(Boolean).join(", ");
|
|
1476
|
+
throw new Error(`Неизвестный provider «${a.provider}». Бывают: ${Object.keys(PROVIDER_FIELDS).join(", ")}, GOOGLE_SHEETS, INSTAGRAM, ${names}. Каталог: ${CONNECT_PAGE}`);
|
|
1477
|
+
}
|
|
1478
|
+
if (entry.status === "COMING_SOON" || entry.status === "IN_DEVELOPMENT") {
|
|
1479
|
+
throw new Error(`${entry.name} (${entry.provider}) пока нельзя подключить: статус ${entry.status}. Каталог: ${CONNECT_PAGE}`);
|
|
1480
|
+
}
|
|
1481
|
+
const schema = Array.isArray(entry.configSchema) ? entry.configSchema : [];
|
|
1482
|
+
const creds = a.creds && typeof a.creds === "object" ? Object.fromEntries(
|
|
1483
|
+
Object.entries(a.creds).filter(([, v]) => v != null && String(v).trim() !== "").map(([k, v]) => [k, String(v).trim()])) : {};
|
|
1484
|
+
const requiredFields = schema.filter((f) => !/необязательно|optional/i.test(f?.hint || ""));
|
|
1485
|
+
const missing = requiredFields.map((f) => f.key).filter((k) => !creds[k]);
|
|
1486
|
+
if (missing.length && (!a.connectionId || Object.keys(creds).length)) {
|
|
1487
|
+
return okResult({
|
|
1488
|
+
connected: false,
|
|
1489
|
+
provider: entry.provider,
|
|
1490
|
+
need: Object.fromEntries(schema.filter((f) => missing.includes(f.key)).map((f) => [f.key, f.hint || f.label])),
|
|
1491
|
+
instruction: `Для ${entry.name} не хватает полей creds: ${missing.join(", ")}. Попроси их у пользователя и вызови connect_integration ещё раз. Или пусть подключит сам в кабинете: ${CONNECT_PAGE}`,
|
|
1492
|
+
});
|
|
1493
|
+
}
|
|
1494
|
+
const title = a.title || entry.name;
|
|
1495
|
+
try {
|
|
1496
|
+
const saved = a.connectionId
|
|
1497
|
+
? await api(`/api/bots/integrations/${a.connectionId}`, { method: "PUT", body: { title: a.title, creds: Object.keys(creds).length ? creds : undefined } })
|
|
1498
|
+
: await api("/api/bots/integrations", { method: "POST", body: { provider: entry.provider, title, creds } });
|
|
1499
|
+
return okResult({ connected: true, connectionId: saved?.id, ...withoutSecrets(saved), note: "Креды сохранены зашифрованными и обратно не отдаются (только маска hint).", usage: "connectionId ставь в действия сценария (actions из integration_catalog) и в site_lead_settings (coreDelivery.connectionId). Проверить ключ — integration_test." });
|
|
1500
|
+
} catch (e) {
|
|
1501
|
+
if (e.status === 400) throw new Error(`${entry.name} не подключён: ${bodyReason(e.data) || "креды не приняты"}. Проверь поля: ${schema.map((f) => `${f.key} — ${f.hint || f.label}`).join("; ")}.`);
|
|
1502
|
+
throw e;
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
case "disconnect_integration":
|
|
1506
|
+
await api(`/api/bots/integrations/${a.connectionId}`, { method: "DELETE" });
|
|
1507
|
+
return okResult(`🗑️ Подключение ${a.connectionId} удалено.`);
|
|
1508
|
+
case "integration_ingress_url": {
|
|
1509
|
+
const id = String(a.connectionId || "").trim();
|
|
1510
|
+
if (!id) throw new Error("Передай connectionId — id подключения из list_integrations.");
|
|
1511
|
+
const suffix = a.rotate ? "/rotate" : "";
|
|
1512
|
+
const r = await api(`/api/bots/integrations/${encodeURIComponent(id)}/ingress-url${suffix}`, { method: "POST" });
|
|
1513
|
+
return okResult({ ...r, usage: "Вставь url в сервис провайдера как webhook/notification/result URL. В узле TRIGGER_WEBHOOK сценария задай config.provider + config.connectionId + config.event (ключ события из integration_catalog → inbound.events)." });
|
|
1514
|
+
}
|
|
1515
|
+
// ---- Рассылки ----
|
|
1516
|
+
case "broadcast_list": {
|
|
1517
|
+
if (a.botId) return okResult(await api(`/api/bots/${a.botId}/broadcasts`));
|
|
1518
|
+
const page = await api(`/api/bots/broadcasts${qs({ page: a.page, size: a.size, group: a.group })}`);
|
|
1519
|
+
let counts = null;
|
|
1520
|
+
try { counts = await api("/api/bots/broadcasts/counts"); } catch { /* счётчики не обязательны */ }
|
|
1521
|
+
return okResult({ counts, page });
|
|
1522
|
+
}
|
|
1523
|
+
case "broadcast_get": {
|
|
1524
|
+
const b = await api(`/api/bots/broadcasts/${a.broadcastId}`);
|
|
1525
|
+
if (b && b.failedJobs > 0) b.errors = await api(`/api/bots/broadcasts/${a.broadcastId}/errors`).catch(() => []);
|
|
1526
|
+
return okResult(b);
|
|
1527
|
+
}
|
|
1528
|
+
case "broadcast_preview": {
|
|
1529
|
+
const ids = botIdsOf(a);
|
|
1530
|
+
await assertBroadcastBots(ids);
|
|
1531
|
+
const filter = { tagsAll: strList(a.tagsAll), tagsNone: strList(a.tagsNone) };
|
|
1532
|
+
const perBot = [];
|
|
1533
|
+
for (const id of ids) {
|
|
1534
|
+
const r = await api(`/api/bots/${id}/broadcasts/preview`, { method: "POST", body: filter });
|
|
1535
|
+
perBot.push({ botId: id, count: r?.count ?? null, limit: r?.limit ?? null });
|
|
1536
|
+
}
|
|
1537
|
+
return okResult({ total: perBot.reduce((s, r) => s + (Number(r.count) || 0), 0), perBot, filter });
|
|
1538
|
+
}
|
|
1539
|
+
case "broadcast_send": {
|
|
1540
|
+
if (a.graphId) {
|
|
1541
|
+
const ids = botIdsOf(a);
|
|
1542
|
+
if (ids.length !== 1) throw new Error("Рассылка по сценарию — ровно один botId (сценарий принадлежит боту).");
|
|
1543
|
+
await assertBroadcastBots(ids);
|
|
1544
|
+
if (!a.name) throw new Error("Передай name рассылки.");
|
|
1545
|
+
const b = await api(`/api/bots/${ids[0]}/broadcasts`, { method: "POST", body: {
|
|
1546
|
+
name: a.name, audienceFilter: { tagsAll: strList(a.tagsAll), tagsNone: strList(a.tagsNone) },
|
|
1547
|
+
graphId: a.graphId, entryNodeId: a.entryNodeId || undefined, scheduledAt: toInstant(a.scheduledAt, "scheduledAt") } });
|
|
1548
|
+
return okResult({ broadcastIds: [b?.id], status: b?.status, scheduledAt: b?.scheduledAt ?? null, broadcast: b });
|
|
1549
|
+
}
|
|
1550
|
+
let draft = null;
|
|
1551
|
+
if (a.draftId) draft = await api(`/api/bots/broadcasts/drafts/${a.draftId}`);
|
|
1552
|
+
const pick = (k) => (a[k] !== undefined ? a[k] : draft?.[k]);
|
|
1553
|
+
const ids = botIdsOf({ botIds: a.botIds ?? (a.botId ? undefined : draft?.botIds), botId: a.botId });
|
|
1554
|
+
await assertBroadcastBots(ids);
|
|
1555
|
+
const name = String(pick("name") || "").trim();
|
|
1556
|
+
if (!name) throw new Error("Передай name рассылки (видно только тебе в списке рассылок).");
|
|
1557
|
+
const body = {
|
|
1558
|
+
name, botIds: ids,
|
|
1559
|
+
tagsAll: strList(a.tagsAll !== undefined ? a.tagsAll : draft?.audienceFilter?.tagsAll),
|
|
1560
|
+
tagsNone: strList(a.tagsNone !== undefined ? a.tagsNone : draft?.audienceFilter?.tagsNone),
|
|
1561
|
+
messages: normalizeBroadcastMessages(pick("messages")),
|
|
1562
|
+
scheduledAt: toInstant(pick("scheduledAt"), "scheduledAt"),
|
|
1563
|
+
};
|
|
1564
|
+
if (body.scheduledAt && new Date(body.scheduledAt).getTime() <= Date.now()) {
|
|
1565
|
+
// Прошедшее время бэкенд молча считает «сейчас» — для черновика с устаревшей датой это сюрприз.
|
|
1566
|
+
if (a.scheduledAt !== undefined) throw new Error(`scheduledAt ${body.scheduledAt} уже прошло — укажи будущее время или не передавай его (отправить сейчас).`);
|
|
1567
|
+
body.scheduledAt = null;
|
|
1568
|
+
}
|
|
1569
|
+
const r = await api("/api/bots/broadcasts/direct", { method: "POST", body });
|
|
1570
|
+
let draftDeleted = false;
|
|
1571
|
+
if (a.draftId) { try { await api(`/api/bots/broadcasts/drafts/${a.draftId}`, { method: "DELETE" }); draftDeleted = true; } catch { /* не критично */ } }
|
|
1572
|
+
return okResult({ ...r, scheduledAt: body.scheduledAt, ...(a.draftId ? { draftDeleted } : {}) });
|
|
1573
|
+
}
|
|
1574
|
+
case "broadcast_cancel":
|
|
1575
|
+
await api(`/api/bots/broadcasts/${a.broadcastId}/cancel`, { method: "POST" });
|
|
1576
|
+
return okResult(`⏹️ Рассылка ${a.broadcastId} отменяется (CANCELLING → CANCELLED).`);
|
|
1577
|
+
case "broadcast_recurring": {
|
|
1578
|
+
const base = "/api/bots/broadcasts/recurring";
|
|
1579
|
+
if (a.action === "list") return okResult(await api(base));
|
|
1580
|
+
if (a.action === "stop") {
|
|
1581
|
+
if (!a.ruleId) throw new Error("Передай ruleId (broadcast_recurring action=list).");
|
|
1582
|
+
await api(`${base}/${a.ruleId}/stop`, { method: "POST" });
|
|
1583
|
+
return okResult(`⏹️ Правило ${a.ruleId} остановлено — новых прогонов не будет.`);
|
|
1584
|
+
}
|
|
1585
|
+
if (a.action === "create") {
|
|
1586
|
+
const ids = botIdsOf(a);
|
|
1587
|
+
await assertBroadcastBots(ids);
|
|
1588
|
+
if (!a.name) throw new Error("Передай name.");
|
|
1589
|
+
const recurrence = String(a.recurrence || "").toUpperCase();
|
|
1590
|
+
if (!["DAILY", "MONTHLY", "YEARLY"].includes(recurrence)) throw new Error("recurrence: DAILY | MONTHLY | YEARLY.");
|
|
1591
|
+
const firstRunAt = toInstant(a.firstRunAt, "firstRunAt");
|
|
1592
|
+
if (!firstRunAt || new Date(firstRunAt).getTime() <= Date.now()) throw new Error("firstRunAt — время первого запуска в будущем (ISO 8601, без пояса — московское).");
|
|
1593
|
+
return okResult(await api(base, { method: "POST", body: {
|
|
1594
|
+
name: a.name, botIds: ids, tagsAll: strList(a.tagsAll), tagsNone: strList(a.tagsNone),
|
|
1595
|
+
messages: normalizeBroadcastMessages(a.messages), recurrence, firstRunAt } }));
|
|
1596
|
+
}
|
|
1597
|
+
throw new Error("action: list | create | stop.");
|
|
1598
|
+
}
|
|
1599
|
+
case "broadcast_drafts": {
|
|
1600
|
+
const base = "/api/bots/broadcasts/drafts";
|
|
1601
|
+
if (a.action === "list") return okResult(await api(base));
|
|
1602
|
+
if (a.action === "create" || a.action === "update") {
|
|
1603
|
+
if (a.action === "update" && !a.draftId) throw new Error("Передай draftId (broadcast_drafts action=list).");
|
|
1604
|
+
// update: PUT заменяет черновик целиком — непереданные поля берём из текущего, чтобы правка текста не стёрла ботов.
|
|
1605
|
+
const cur = a.action === "update" ? (await api(`${base}/${a.draftId}`)) || {} : {};
|
|
1606
|
+
const has = (k) => a[k] !== undefined;
|
|
1607
|
+
const body = {
|
|
1608
|
+
name: has("name") ? a.name : cur.name,
|
|
1609
|
+
botIds: has("botIds") || has("botId") ? botIdsOf(a) : (cur.botIds || []),
|
|
1610
|
+
tagsAll: strList(has("tagsAll") ? a.tagsAll : cur.audienceFilter?.tagsAll),
|
|
1611
|
+
tagsNone: strList(has("tagsNone") ? a.tagsNone : cur.audienceFilter?.tagsNone),
|
|
1612
|
+
messages: normalizeBroadcastMessages(has("messages") ? a.messages : cur.messages, false),
|
|
1613
|
+
scheduledAt: toInstant(has("scheduledAt") ? a.scheduledAt : cur.scheduledAt, "scheduledAt"),
|
|
1614
|
+
};
|
|
1615
|
+
if (a.action === "create") return okResult(await api(base, { method: "POST", body }));
|
|
1616
|
+
return okResult(await api(`${base}/${a.draftId}`, { method: "PUT", body }));
|
|
1617
|
+
}
|
|
1618
|
+
if (!a.draftId) throw new Error("Передай draftId (broadcast_drafts action=list).");
|
|
1619
|
+
if (a.action === "get") return okResult(await api(`${base}/${a.draftId}`));
|
|
1620
|
+
if (a.action === "delete") { await api(`${base}/${a.draftId}`, { method: "DELETE" }); return okResult(`🗑️ Черновик ${a.draftId} удалён.`); }
|
|
1621
|
+
throw new Error("action: list | get | create | update | delete.");
|
|
1622
|
+
}
|
|
1623
|
+
case "broadcast_duplicate":
|
|
1624
|
+
if (a.draftId) return okResult(await api(`/api/bots/broadcasts/drafts/${a.draftId}/duplicate`, { method: "POST" }));
|
|
1625
|
+
if (!a.broadcastId) throw new Error("Передай broadcastId или draftId.");
|
|
1626
|
+
return okResult(await api(`/api/bots/broadcasts/${a.broadcastId}/duplicate`, { method: "POST" }));
|
|
1627
|
+
case "site_templates": {
|
|
1628
|
+
const r = await api("/api/bots/pages/templates");
|
|
1629
|
+
let templates = Array.isArray(r?.templates) ? r.templates : [];
|
|
1630
|
+
if (a.category) templates = templates.filter((t) => t?.category === a.category);
|
|
1631
|
+
return okResult({ categories: r?.categories ?? [], templates });
|
|
1632
|
+
}
|
|
1633
|
+
default:
|
|
1634
|
+
throw new Error(`Неизвестный инструмент: ${params && params.name}`);
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
// ---- JSON-RPC stdio (MCP) ----
|
|
1639
|
+
function send(msg) { process.stdout.write(JSON.stringify(msg) + "\n"); }
|
|
1640
|
+
|
|
1641
|
+
const rl = createInterface({ input: process.stdin });
|
|
1642
|
+
rl.on("line", async (line) => {
|
|
1643
|
+
line = line.trim();
|
|
1644
|
+
if (!line) return;
|
|
1645
|
+
let req;
|
|
1646
|
+
try { req = JSON.parse(line); } catch { return; }
|
|
1647
|
+
const { id, method, params } = req;
|
|
1648
|
+
try {
|
|
1649
|
+
if (method === "initialize") {
|
|
1650
|
+
send({ jsonrpc: "2.0", id, result: { protocolVersion: "2024-11-05", capabilities: { tools: {} }, serverInfo: { name: "retensy-mcp", version: VERSION } } });
|
|
1651
|
+
} else if (method === "tools/list") {
|
|
1652
|
+
send({ jsonrpc: "2.0", id, result: { tools: TOOLS } });
|
|
1653
|
+
} else if (method === "tools/call") {
|
|
1654
|
+
let result;
|
|
1655
|
+
try {
|
|
1656
|
+
result = await handleCall(params);
|
|
1657
|
+
} catch (e) {
|
|
1658
|
+
result = errResult(e);
|
|
1659
|
+
// Единая точка: сюда приходит ЛЮБАЯ неудача инструмента — в т.ч. «Неизвестный
|
|
1660
|
+
// инструмент» (значит клиент хотел возможность, которой у нас нет).
|
|
1661
|
+
reportFailure({ tool: params?.name || "?", args: params?.arguments, message: e?.message || String(e) });
|
|
1662
|
+
}
|
|
1663
|
+
// Уведомление о новой версии отдаём один раз за сессию, чтобы не шуметь в каждом ответе.
|
|
1664
|
+
// setup печатает его сам — там не дублируем.
|
|
1665
|
+
if (updateNotice && !noticeDelivered && params?.name !== "setup") {
|
|
1666
|
+
noticeDelivered = true;
|
|
1667
|
+
result = { ...result, content: [...(result.content || []), { type: "text", text: updateNotice }] };
|
|
1668
|
+
}
|
|
1669
|
+
send({ jsonrpc: "2.0", id, result });
|
|
1670
|
+
} else if (method === "ping") {
|
|
1671
|
+
send({ jsonrpc: "2.0", id, result: {} });
|
|
1672
|
+
} else if (id !== undefined && id !== null) {
|
|
1673
|
+
send({ jsonrpc: "2.0", id, error: { code: -32601, message: `Method not found: ${method}` } });
|
|
1674
|
+
}
|
|
1675
|
+
} catch (e) {
|
|
1676
|
+
if (id !== undefined && id !== null) send({ jsonrpc: "2.0", id, error: { code: -32603, message: String(e?.message || e) } });
|
|
1677
|
+
}
|
|
1678
|
+
});
|
|
1679
|
+
|
|
1680
|
+
process.stderr.write(`[retensy-mcp] MCP ${VERSION}. BASE=${BASE}. Авторизация: ${getToken() ? "токен" : getCookie() ? "cookie" : "не задана (вызови setup)"}. Отчёты о неудачах: ${REPORT_MODE}.\n`);
|
|
1681
|
+
|
|
1682
|
+
// Проверка обновлений — не блокирует старт и не ломает работу без сети.
|
|
1683
|
+
checkForUpdate();
|