@retensy/mcp 0.11.0 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +51 -2
- package/package.json +2 -2
- package/skills/build-bot-funnel/SKILL.md +38 -6
- package/skills/build-bot-funnel/reference/schema.md +247 -7
- package/skills/build-bot-funnel/reference/validation.md +25 -4
- package/skills/build-bot-funnel/validate.mjs +203 -5
- package/src/index.mjs +258 -22
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Правила валидатора (GraphValidator) — чтобы граф публиковался
|
|
2
2
|
|
|
3
|
-
Источник истины: `retensyBackend/.../service/bot/GraphValidator.java`. При `publish` бэкенд
|
|
3
|
+
Источник истины: `retensyBackend/.../service/bot/GraphValidator.java`. При `publish` и при PUT **активного** графа бэкенд отвечает `HTTP 422` с `errors: [{ nodeId, code, message }]` — MCP показывает их все построчно (`code@nodeId: message`). Черновик PUT сохраняет без проверок. Ниже — что проверяется и как не нарваться.
|
|
4
4
|
|
|
5
5
|
## Коды ошибок и условия
|
|
6
6
|
|
|
@@ -41,8 +41,28 @@
|
|
|
41
41
|
- `FORMULA`: `expression` непустой (`FORMULA_NO_EXPRESSION`); `saveTo` ∈ var-формат (`FORMULA_BAD_SAVE_TO`).
|
|
42
42
|
- `SCHEDULE`: `isoDate` = валидная `YYYY-MM-DD` (`SCHEDULE_BAD_DATE`); `time` = `HH:mm` (`SCHEDULE_BAD_TIME`).
|
|
43
43
|
- `ACTIONS`: непустой `actions[]` (`ACTIONS_EMPTY`); каждый `kind` — из допустимого списка (`ACTION_UNKNOWN_KIND`, список — в schema.md); per-kind: `tag` (`ACTION_BAD_TAG`), `set_field.key` (`ACTION_BAD_KEY`), `url` у `external_request`/`subscriber_webhook` (`ACTION_BAD_URL`).
|
|
44
|
-
- `
|
|
44
|
+
- `ACTIONS` с действием `issue_invoice` (счёт на кассу владельца):
|
|
45
|
+
- `connectionId` обязателен (`YK_NO_CONNECTION`) — подключение с `provider=YOOKASSA` из `list_integrations`;
|
|
46
|
+
- `amount` обязателен (`YK_NO_AMOUNT`), число > 0 или шаблон `{{var.x}}` (`YK_BAD_AMOUNT`);
|
|
47
|
+
- `description` обязателен (`YK_NO_DESC`);
|
|
48
|
+
- `timeoutMinutes` ∈ [1, 1440] (`YK_BAD_TIMEOUT`);
|
|
49
|
+
- **счёт должен быть ПОСЛЕДНИМ действием в блоке** (`INVOICE_NOT_LAST`) и только один (`INVOICE_DUPLICATE`).
|
|
50
|
+
Причина: при наличии ветки `paid` блок встаёт на паузу до вебхука кассы, а возобновиться
|
|
51
|
+
с середины списка движок не умеет — действия после счёта молча не выполнились бы.
|
|
52
|
+
- `AI_REPLY` (`mode` не задан или `"simple"`): `userPromptTemplate` непустой (`AI_NO_PROMPT`); `temperature` ∈ [0.0, 2.0] (`AI_BAD_TEMPERATURE`); нужен `sendToUser:true` ИЛИ `saveTo` (`AI_NO_OUTPUT`).
|
|
53
|
+
- `AI_REPLY` (`mode: "agent"`) — отдельный набор проверок, старые (`AI_NO_PROMPT` и т.п.) не применяются:
|
|
54
|
+
- `strict` (по умолчанию `true`) без `knowledgeBaseId` → `AGENT_NO_KNOWLEDGE_BASE` (в строгом режиме без базы агент ответит «не знаю» на всё).
|
|
55
|
+
- `actions[]`: битая запись (пустой `id` ИЛИ `title`) пропускается целиком, как и в рантайме — на неё не ругаемся. Повтор `id` → `AGENT_DUPLICATE_ACTION_ID`. Нет ребра `action_<id>` → `AGENT_ACTION_NOT_CONNECTED`.
|
|
56
|
+
- `systemPrompt` длиннее **30 000** символов → ошибка `AGENT_PROMPT_TOO_LONG` (публикация не пройдёт). Длиннее **10 000** — только предупреждение (мягкая норма: длинная инструкция дороже и хуже держится моделью в середине; жёсткого кода для неё у бэкенда нет — в вебе это красит счётчик символов в инспекторе).
|
|
57
|
+
- `extract[]`: `variable` обязателен и матчит `[a-z_][a-z0-9_]{0,63}` (тот же `VAR_RE`), `description` непустой, имя `ai_summary` зарезервировано под сводку агента, повтор имени внутри `extract[]` — тоже ошибка. Любая из этих проблем → `AGENT_BAD_EXTRACT`.
|
|
58
|
+
- Ветка `unknown` не подключена (и узел достижим от триггера) → `AGENT_UNKNOWN_NOT_CONNECTED`, приходит уровнем `warnings` (не блокирует публикацию — специально мягкий уровень, спека §10): диалог агента упрётся в тупик там, где нужен человек.
|
|
45
59
|
- `PAYMENT_LINK`: `paymentUrl` обязателен (`PAY_NO_URL`), http(s):// или `{{var.x}}` (`PAY_BAD_SCHEME`).
|
|
60
|
+
- `YOOKASSA_PAYMENT`: **устаревший узел, в новых сценариях не ставить** — счёт собирается действием
|
|
61
|
+
`issue_invoice` внутри `ACTIONS`. Если узел всё же есть: `connectionId` (`YK_NO_CONNECTION`),
|
|
62
|
+
`amount` (`YK_NO_AMOUNT`/`YK_BAD_AMOUNT`), `description` (`YK_NO_DESC`).
|
|
63
|
+
- `TRIGGER_PAYMENT`: обязательных полей нет. Фильтры `minAmount`/`descriptionContains`
|
|
64
|
+
необязательны, но **без них сценарий стартует на КАЖДУЮ оплату в кассе**, включая продажи
|
|
65
|
+
мимо бота. Валидатор это не блокирует (бэкенд тоже) — следи сам.
|
|
46
66
|
- Метки: `[a-z0-9_-]{1,64}`. Переменные: `[a-z_][a-z0-9_]{0,63}`.
|
|
47
67
|
|
|
48
68
|
## Рёбра
|
|
@@ -53,11 +73,12 @@
|
|
|
53
73
|
|
|
54
74
|
## Жизненный цикл
|
|
55
75
|
- Статусы графа: `DRAFT` / `PUBLISHED`. Публикация заменяет активную опубликованную версию.
|
|
76
|
+
- Правка активного (`PUBLISHED`) графа применяется к боту сразу и проверяется как публикация (плюс платные блоки и лимит блоков тарифа): ошибки → 422, граф не сохранён. `DRAFT` — черновик: PUT сохраняет его без проверок, на бота он не влияет. `publish_graph` черновика копирует его в отдельный `PUBLISHED`-граф (`publishedGraphId`), черновик остаётся `DRAFT` — живые правки делай по `publishedGraphId` (в `list_graphs` у него `isActive: true`).
|
|
56
77
|
- Перед публикацией полезно прогнать `dry_run` (kind `command`/`callback`/`text`) — поймать рантайм-проблемы стартовой ветки.
|
|
57
78
|
|
|
58
79
|
## Платформенные правила — Instagram
|
|
59
80
|
|
|
60
|
-
Источник: `GraphValidator.platformErrors()` (Java). Коды — жёсткие ошибки, **блокируют публикацию** (`publish_graph` вернёт `
|
|
81
|
+
Источник: `GraphValidator.platformErrors()` (Java). Коды — жёсткие ошибки, **блокируют публикацию** (`publish_graph` вернёт ошибку `HTTP 422` со всеми `code@nodeId`).
|
|
61
82
|
|
|
62
83
|
### `IG_NODE_UNSUPPORTED` — неподдерживаемый тип узла
|
|
63
84
|
|
|
@@ -98,7 +119,7 @@ Instagram доставляет сообщения только в течение
|
|
|
98
119
|
---
|
|
99
120
|
|
|
100
121
|
## Локальная проверка
|
|
101
|
-
`node validate.mjs <import.json>` повторяет ключевые проверки: пустые сообщения с учётом `cardsToLegacy`, висячие рёбра, дубли id, достижимость от триггеров, длину текста, HTML-безопасность (эвристика по тегам), режим «Вопрос» (`awaitReply`→`saveTo`/`regex`), конфиг `DELAY`/`SCHEDULE`/`FORMULA`/`ACTIONS`/`AI_REPLY
|
|
122
|
+
`node validate.mjs <import.json>` повторяет ключевые проверки: пустые сообщения с учётом `cardsToLegacy`, висячие рёбра, дубли id, достижимость от триггеров, длину текста, HTML-безопасность (эвристика по тегам), режим «Вопрос» (`awaitReply`→`saveTo`/`regex`), конфиг `DELAY`/`SCHEDULE`/`FORMULA`/`ACTIONS`/`AI_REPLY`(оба режима, включая `AGENT_*`)/`PAYMENT_LINK`/триггеров, условия `CONDITION` (вкл. `LINK_CLICKED` со ссылкой на отслеживаемый шаг). Гонять перед каждой заливкой.
|
|
102
123
|
|
|
103
124
|
Для IG-ботов передавать `--platform=INSTAGRAM`:
|
|
104
125
|
```bash
|
|
@@ -32,6 +32,15 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|
|
32
32
|
const VAR_RE = /^[a-z_][a-z0-9_]{0,63}$/;
|
|
33
33
|
const TAG_RE = /^[a-z0-9_-]{1,64}$/;
|
|
34
34
|
|
|
35
|
+
// Зеркало boolConfig/Boolean.parseBoolean бэкенда: булево — как есть; иначе строка "true"
|
|
36
|
+
// (без учёта регистра) → true, ЛЮБАЯ другая строка (включая "false") → false, null/undefined → def.
|
|
37
|
+
// Задача 12, фикс-раунд 1, MINOR: `v !== false` расходился с бэком на строке "false".
|
|
38
|
+
function boolConfig(v, def) {
|
|
39
|
+
if (typeof v === "boolean") return v;
|
|
40
|
+
if (v == null) return def;
|
|
41
|
+
return String(v).toLowerCase() === "true";
|
|
42
|
+
}
|
|
43
|
+
|
|
35
44
|
// kind -> допустимые op (для подсказок; бэкенд блокирует только TAG/VARIABLE)
|
|
36
45
|
const COND_OPS = {
|
|
37
46
|
TAG: ["HAS", "NOT_HAS"],
|
|
@@ -53,7 +62,7 @@ const ACTION_KINDS = new Set([
|
|
|
53
62
|
"add_tag", "remove_tag", "set_field", "stop_bot", "delete_step_message",
|
|
54
63
|
"subscribe", "unsubscribe", "autoflow_add", "autoflow_remove",
|
|
55
64
|
"subscriber_webhook", "external_request", "notify",
|
|
56
|
-
"subscriber_email", "agent_chat", "cancel_payment_subscription",
|
|
65
|
+
"subscriber_email", "agent_chat", "cancel_payment_subscription", "issue_invoice",
|
|
57
66
|
"getcourse_send", "getcourse_order", "amocrm_send", "amocrm_update",
|
|
58
67
|
"yametrika_event", "gsheets_send", "gsheets_get", "gsheets_update",
|
|
59
68
|
"gsheets_write_cell", "gsheets_read_cell",
|
|
@@ -175,6 +184,31 @@ for (const n of nodes) {
|
|
|
175
184
|
case "TRIGGER_TEXT":
|
|
176
185
|
if (c.matchMode && c.matchMode !== "ANY" && blank(c.value)) errors.push(`${who}: для matchMode≠ANY нужен value.`);
|
|
177
186
|
break;
|
|
187
|
+
case "TRIGGER_TG_EVENT": {
|
|
188
|
+
// Рантайм размечает только эти апдейты (FlowExecutor.buildContext) — на остальных
|
|
189
|
+
// триггер молчит, а выглядит настроенным.
|
|
190
|
+
const LIVE = new Set(["message", "edited_message", "callback_query", "channel_post",
|
|
191
|
+
"edited_channel_post", "chat_member", "chat_join_request", "my_chat_member",
|
|
192
|
+
"message_reaction", "message_reaction_count", "chat_boost", "removed_chat_boost",
|
|
193
|
+
"poll", "poll_answer", "inline_query", "chosen_inline_result", "shipping_query",
|
|
194
|
+
"pre_checkout_query"]);
|
|
195
|
+
const DEAD = new Set(["business_connection", "business_message", "edited_business_message",
|
|
196
|
+
"deleted_business_messages", "chat_shared", "users_shared", "write_access_allowed",
|
|
197
|
+
"purchased_paid_media"]);
|
|
198
|
+
const ev = String(c.event || "");
|
|
199
|
+
if (blank(ev)) errors.push(`TG_EVENT_NO_EVENT: ${who} — нужен event (напр. chat_member, message_reaction).`);
|
|
200
|
+
else if (DEAD.has(ev)) errors.push(`TG_EVENT_UNSUPPORTED: ${who} — событие «${ev}» рантайм не разбирает, триггер не сработает.`);
|
|
201
|
+
else if (!LIVE.has(ev)) warns.push(`${who}: событие «${ev}» не из списка поддержанных — проверь, что рантайм его размечает.`);
|
|
202
|
+
// Фильтр по тексту есть только у событий с текстом сообщения.
|
|
203
|
+
const TEXTY = new Set(["message", "edited_message", "channel_post", "edited_channel_post"]);
|
|
204
|
+
const f = c.filter && typeof c.filter === "object" ? c.filter : {};
|
|
205
|
+
if (!blank(f.text) && !TEXTY.has(ev)) warns.push(`${who}: фильтр по тексту у события «${ev}» игнорируется — текста в апдейте нет.`);
|
|
206
|
+
if (!blank(f.status) && ev !== "chat_member" && ev !== "my_chat_member") warns.push(`${who}: фильтр по статусу работает только у chat_member/my_chat_member.`);
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
case "TRIGGER_COMMENT":
|
|
210
|
+
errors.push(`TRIGGER_COMMENT_DEAD: ${who} — этот тип триггера не сработает никогда (рантайм не выставляет event=comment). Для Instagram используй TRIGGER_IG_COMMENT.`);
|
|
211
|
+
break;
|
|
178
212
|
case "ASK_QUESTION":
|
|
179
213
|
if (blank(c.promptText)) errors.push(`${who}: нужен promptText.`);
|
|
180
214
|
if (c.saveTo && !VAR_RE.test(c.saveTo)) errors.push(`${who}: saveTo «${c.saveTo}» не матчит [a-z_][a-z0-9_]{0,63}.`);
|
|
@@ -191,6 +225,27 @@ for (const n of nodes) {
|
|
|
191
225
|
case "BRANCH":
|
|
192
226
|
if (!Array.isArray(c.cases) || c.cases.length === 0) errors.push(`${who}: нужен хотя бы один case.`);
|
|
193
227
|
break;
|
|
228
|
+
case "SWITCH": {
|
|
229
|
+
// Зеркало GraphValidator.validateSwitch(): развилка по значению (спека §5.3).
|
|
230
|
+
if (blank(c.expression)) errors.push(`SWITCH_NO_EXPRESSION: ${who} — нужно expression (обычно {{var.x}}).`);
|
|
231
|
+
if (!Array.isArray(c.cases) || c.cases.length === 0) {
|
|
232
|
+
errors.push(`SWITCH_NO_CASES: ${who} — нужен хотя бы один case {id, value}.`);
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
const seenValues = new Set();
|
|
236
|
+
for (const cs of c.cases) {
|
|
237
|
+
const value = String((cs && cs.value) ?? "").trim();
|
|
238
|
+
if (!value) errors.push(`SWITCH_EMPTY_VALUE: ${who} — у случая «${cs && cs.id}» пустое value.`);
|
|
239
|
+
// Рантайм сравнивает без учёта регистра → второй такой случай недостижим.
|
|
240
|
+
else if (seenValues.has(value.toLowerCase())) errors.push(`SWITCH_DUPLICATE_VALUE: ${who} — значение «${value}» повторяется.`);
|
|
241
|
+
else seenValues.add(value.toLowerCase());
|
|
242
|
+
}
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
case "STOP_AND_ERROR":
|
|
246
|
+
// message необязателен (рантайм подставит свой текст), но безымянная ошибка в журнале бесполезна.
|
|
247
|
+
if (blank(c.message)) warns.push(`${who}: STOP_AND_ERROR без message — в журнале будет обезличенное «Сценарий остановлен с ошибкой».`);
|
|
248
|
+
break;
|
|
194
249
|
case "CONDITION": {
|
|
195
250
|
if (c.match !== "ALL" && c.match !== "ANY") errors.push(`CONDITION_BAD_MATCH: ${who} — match ∈ {ALL,ANY}.`);
|
|
196
251
|
if (!Array.isArray(c.conditions) || c.conditions.length === 0) {
|
|
@@ -235,6 +290,17 @@ for (const n of nodes) {
|
|
|
235
290
|
}
|
|
236
291
|
case "ACTIONS": {
|
|
237
292
|
if (!Array.isArray(c.actions) || c.actions.length === 0) { errors.push(`ACTIONS_EMPTY: ${who} — нужен непустой actions[].`); break; }
|
|
293
|
+
// Счёт ставит блок на паузу до вебхука кассы, а возобновиться с середины списка движок
|
|
294
|
+
// не умеет — значит действия после счёта молча не выполнятся. Зеркало GraphValidator.
|
|
295
|
+
{
|
|
296
|
+
const invIdx = c.actions.map((a, i) => (a && a.kind === "issue_invoice" ? i : -1)).filter((i) => i >= 0);
|
|
297
|
+
if (invIdx.length > 1) {
|
|
298
|
+
errors.push(`INVOICE_DUPLICATE: ${who} — в блоке больше одного счёта, непонятно какой ждать.`);
|
|
299
|
+
}
|
|
300
|
+
if (invIdx.length === 1 && invIdx[0] !== c.actions.length - 1) {
|
|
301
|
+
errors.push(`INVOICE_NOT_LAST: ${who} — счёт должен быть последним действием в блоке: после него блок ждёт оплату.`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
238
304
|
c.actions.forEach((a, i) => {
|
|
239
305
|
if (!a || typeof a !== "object") { errors.push(`${who}: действие #${i + 1} — не объект.`); return; }
|
|
240
306
|
if (!ACTION_KINDS.has(a.kind)) { errors.push(`ACTION_UNKNOWN_KIND: ${who} — неизвестный kind «${a.kind}» (#${i + 1}).`); return; }
|
|
@@ -244,15 +310,106 @@ for (const n of nodes) {
|
|
|
244
310
|
errors.push(`ACTION_BAD_KEY: ${who} — set_field.key ∈ [a-z_][a-z0-9_]{0,63}.`);
|
|
245
311
|
if (["subscriber_webhook", "external_request"].includes(a.kind) && !isHttp(a.url))
|
|
246
312
|
errors.push(`ACTION_BAD_URL: ${who} — ${a.kind}.url должен быть http(s)://.`);
|
|
313
|
+
if (a.kind === "issue_invoice") {
|
|
314
|
+
if (blank(a.connectionId)) errors.push(`YK_NO_CONNECTION: ${who} — нужен connectionId подключения ЮKassa (list_integrations).`);
|
|
315
|
+
const amount = String(a.amount == null ? "" : a.amount);
|
|
316
|
+
if (blank(amount)) errors.push(`YK_NO_AMOUNT: ${who} — нужна amount.`);
|
|
317
|
+
else if (!amount.includes("{{")) {
|
|
318
|
+
const n = Number(amount.trim().replace(",", "."));
|
|
319
|
+
if (!Number.isFinite(n)) errors.push(`YK_BAD_AMOUNT: ${who} — amount должна быть числом.`);
|
|
320
|
+
else if (n <= 0) errors.push(`YK_BAD_AMOUNT: ${who} — amount должна быть > 0.`);
|
|
321
|
+
}
|
|
322
|
+
if (blank(a.description)) errors.push(`YK_NO_DESC: ${who} — нужно description (за что платят).`);
|
|
323
|
+
if (a.timeoutMinutes != null) {
|
|
324
|
+
const t = Number(a.timeoutMinutes);
|
|
325
|
+
if (!Number.isFinite(t) || t < 1 || t > 1440) errors.push(`YK_BAD_TIMEOUT: ${who} — timeoutMinutes ∈ [1, 1440].`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
if (a.kind === "external_request") {
|
|
329
|
+
// Имя переменной бэкенд не чистит: кривое имя запишется, а {{var.<имя>}} его не найдёт.
|
|
330
|
+
for (const field of ["saveStatusTo", "saveBodyTo"]) {
|
|
331
|
+
if (!blank(a[field]) && !VAR_RE.test(String(a[field])))
|
|
332
|
+
errors.push(`ACTION_BAD_SAVE_TO: ${who} — ${field} «${a[field]}» ∈ [a-z_][a-z0-9_]{0,63}.`);
|
|
333
|
+
}
|
|
334
|
+
(Array.isArray(a.extract) ? a.extract : []).forEach((ex, j) => {
|
|
335
|
+
if (blank(ex?.path) || blank(ex?.saveTo))
|
|
336
|
+
errors.push(`ACTION_BAD_EXTRACT: ${who} — извлечение #${j + 1} требует path и saveTo.`);
|
|
337
|
+
else if (!VAR_RE.test(String(ex.saveTo)))
|
|
338
|
+
errors.push(`ACTION_BAD_SAVE_TO: ${who} — extract[${j}].saveTo ∈ [a-z_][a-z0-9_]{0,63}.`);
|
|
339
|
+
});
|
|
340
|
+
if (a.timeoutMs != null && (Number(a.timeoutMs) < 500 || Number(a.timeoutMs) > 30000))
|
|
341
|
+
warns.push(`${who}: timeoutMs ${a.timeoutMs} будет прижат к диапазону 500…30000 мс.`);
|
|
342
|
+
}
|
|
247
343
|
if (a.kind === "gsheets_send" && (!a.googleEmail || !a.spreadsheetId || !Array.isArray(a.cells) || a.cells.length === 0))
|
|
248
344
|
errors.push(`ACTION_GSHEETS_INCOMPLETE: ${who} — gsheets_send требует googleEmail + spreadsheetId + непустой cells[] (Google-аккаунт подключается в вебе /bots, не через MCP).`);
|
|
249
345
|
});
|
|
250
346
|
break;
|
|
251
347
|
}
|
|
252
348
|
case "AI_REPLY": {
|
|
253
|
-
if (
|
|
254
|
-
|
|
255
|
-
|
|
349
|
+
if (String(c.mode) === "agent") {
|
|
350
|
+
// Режим агента — отдельный набор ключей конфига, старые проверки (userPromptTemplate
|
|
351
|
+
// и т.п.) к нему не относятся. Зеркало GraphValidator.validateAiAgent (Задача 12,
|
|
352
|
+
// CARRY): обязано ловить то же самое, что и бэкенд, и не быть строже него.
|
|
353
|
+
const outgoing = edges.filter((e) => e.sourceNodeId === n.id);
|
|
354
|
+
const strict = boolConfig(c.strict, true);
|
|
355
|
+
if (strict && blank(c.knowledgeBaseId)) {
|
|
356
|
+
errors.push(`AGENT_NO_KNOWLEDGE_BASE: ${who} — в строгом режиме без базы знаний агент ответит «не знаю» на всё.`);
|
|
357
|
+
}
|
|
358
|
+
if (Array.isArray(c.actions)) {
|
|
359
|
+
const seenIds = new Set();
|
|
360
|
+
for (const a of c.actions) {
|
|
361
|
+
if (!a) continue;
|
|
362
|
+
const id = a.id == null ? null : String(a.id);
|
|
363
|
+
const title = a.title == null ? null : String(a.title);
|
|
364
|
+
// Битую запись (пустой id ИЛИ title) рантайм пропускает целиком — ругаться на
|
|
365
|
+
// её ребро/дубль было бы ложной ошибкой (зеркало FlowExecutor.agentActions).
|
|
366
|
+
if (blank(id) || blank(title)) continue;
|
|
367
|
+
if (seenIds.has(id)) {
|
|
368
|
+
errors.push(`AGENT_DUPLICATE_ACTION_ID: ${who} — действие с id '${id}' повторяется — выбор модели станет недетерминированным.`);
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
seenIds.add(id);
|
|
372
|
+
if (!outgoing.some((e) => e.sourceHandle === `action_${id}`)) {
|
|
373
|
+
errors.push(`AGENT_ACTION_NOT_CONNECTED: ${who} — действие '${id}' не подключено к ветке — модель может его выбрать, а идти будет некуда.`);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
const prompt = String(c.systemPrompt || "");
|
|
378
|
+
if (prompt.length > 30000) {
|
|
379
|
+
errors.push(`AGENT_PROMPT_TOO_LONG: ${who} — инструкция агента длиннее 30 000 символов.`);
|
|
380
|
+
} else if (prompt.length > 10000) {
|
|
381
|
+
// Мягкий порог — только warn: у MCP-сборщика нет инспектора, где владелец увидел бы
|
|
382
|
+
// это предупреждение живьём (веб-редактор красит счётчик символов сам).
|
|
383
|
+
warns.push(`${who}: инструкция агента длиннее 10 000 символов — модель хуже держит середину, и каждый ответ дороже.`);
|
|
384
|
+
}
|
|
385
|
+
if (Array.isArray(c.extract)) {
|
|
386
|
+
const seenVars = new Set();
|
|
387
|
+
for (const f of c.extract) {
|
|
388
|
+
// Зеркало GraphValidator:1070 (!(o instanceof Map) → continue): элемент не объектом
|
|
389
|
+
// (строка, число, массив) рантайм молча пропускает — публикация проходит; ругаться
|
|
390
|
+
// на него было бы строже бэка (M4, фикс-раунд 1).
|
|
391
|
+
if (!f || typeof f !== "object" || Array.isArray(f)) continue;
|
|
392
|
+
const variable = f.variable == null ? null : String(f.variable);
|
|
393
|
+
const description = f.description == null ? null : String(f.description);
|
|
394
|
+
const problems = [];
|
|
395
|
+
if (!variable || !VAR_RE.test(variable)) problems.push("имя переменной должно соответствовать [a-z_][a-z0-9_]{0,63}");
|
|
396
|
+
if (blank(description)) problems.push("описание не должно быть пустым");
|
|
397
|
+
if (variable === "ai_summary") problems.push("имя 'ai_summary' занято сводкой агента");
|
|
398
|
+
if (variable && seenVars.has(variable)) problems.push("имя переменной повторяется");
|
|
399
|
+
if (variable) seenVars.add(variable);
|
|
400
|
+
if (problems.length) {
|
|
401
|
+
errors.push(`AGENT_BAD_EXTRACT: ${who} — поле извлечения${variable ? ` '${variable}'` : ""}: ${problems.join("; ")}.`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
// Ветка "не знаю" не подключена — предупреждение, но только для узла, достижимого от
|
|
406
|
+
// триггера (зеркало GraphValidator.draftWarnings:394-402): `reach` считается позже общего
|
|
407
|
+
// per-node прохода, поэтому сама проверка — в отдельном проходе ниже.
|
|
408
|
+
} else {
|
|
409
|
+
if (blank(c.userPromptTemplate)) errors.push(`AI_NO_PROMPT: ${who} — нужен userPromptTemplate.`);
|
|
410
|
+
if (typeof c.temperature === "number" && (c.temperature < 0 || c.temperature > 2)) errors.push(`AI_BAD_TEMPERATURE: ${who} — temperature ∈ [0.0, 2.0].`);
|
|
411
|
+
if (c.sendToUser !== true && blank(c.saveTo)) errors.push(`AI_NO_OUTPUT: ${who} — нужен sendToUser:true или saveTo.`);
|
|
412
|
+
}
|
|
256
413
|
break;
|
|
257
414
|
}
|
|
258
415
|
case "PAYMENT_LINK": {
|
|
@@ -261,6 +418,33 @@ for (const n of nodes) {
|
|
|
261
418
|
else if (!isHttp(u) && !u.startsWith("{{")) errors.push(`PAY_BAD_SCHEME: ${who} — paymentUrl = http(s):// или {{var.x}}.`);
|
|
262
419
|
break;
|
|
263
420
|
}
|
|
421
|
+
case "YOOKASSA_PAYMENT": {
|
|
422
|
+
// Зеркало GraphValidator.validateYooKassaPayment(): без кассы платёж создать нечем,
|
|
423
|
+
// и раньше это вылезало только в рантайме — перед клиентом, на шаге оплаты.
|
|
424
|
+
if (blank(c.connectionId)) {
|
|
425
|
+
errors.push(`YK_NO_CONNECTION: ${who} — нужен connectionId подключения ЮKassa (list_integrations).`);
|
|
426
|
+
}
|
|
427
|
+
const amount = String(c.amount == null ? "" : c.amount);
|
|
428
|
+
if (blank(amount)) {
|
|
429
|
+
errors.push(`YK_NO_AMOUNT: ${who} — нужна amount.`);
|
|
430
|
+
} else if (!amount.includes("{{")) {
|
|
431
|
+
const n = Number(amount.trim().replace(",", "."));
|
|
432
|
+
if (!Number.isFinite(n)) errors.push(`YK_BAD_AMOUNT: ${who} — amount должна быть числом.`);
|
|
433
|
+
else if (n <= 0) errors.push(`YK_BAD_AMOUNT: ${who} — amount должна быть > 0.`);
|
|
434
|
+
}
|
|
435
|
+
if (blank(c.description)) errors.push(`YK_NO_DESC: ${who} — нужно description (за что платят).`);
|
|
436
|
+
if (c.timeoutMinutes != null) {
|
|
437
|
+
const t = Number(c.timeoutMinutes);
|
|
438
|
+
if (!Number.isFinite(t) || t < 1 || t > 1440) {
|
|
439
|
+
errors.push(`YK_BAD_TIMEOUT: ${who} — timeoutMinutes ∈ [1, 1440].`);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
break;
|
|
443
|
+
}
|
|
444
|
+
// TRIGGER_PAYMENT намеренно без проверок: отсутствие фильтров (minAmount /
|
|
445
|
+
// descriptionContains) — рискованно, но для бэкенда это валидный граф. Ругаться здесь
|
|
446
|
+
// строже, чем GraphValidator, значит расходиться с сервером: сценарий падал бы у
|
|
447
|
+
// сборщика и публиковался бы вручную. Риск описан в schema.md.
|
|
264
448
|
}
|
|
265
449
|
}
|
|
266
450
|
|
|
@@ -275,6 +459,18 @@ for (const n of nodes) {
|
|
|
275
459
|
if (!reach.has(n.id)) errors.push(`Недостижимый узел от триггера: «${n.config?._title || n.id}» (${n.type}).`);
|
|
276
460
|
}
|
|
277
461
|
|
|
462
|
+
// AI-агент: ветка "не знаю" не подключена — предупреждение, зеркало GraphValidator.draftWarnings
|
|
463
|
+
// (394-402): только для узла, реально достижимого от триггера (`reach` посчитан только что выше).
|
|
464
|
+
for (const n of nodes) {
|
|
465
|
+
const c = n.config || {};
|
|
466
|
+
if (n.type !== "AI_REPLY" || String(c.mode) !== "agent" || !reach.has(n.id)) continue;
|
|
467
|
+
const hasUnknownEdge = edges.some((e) => e.sourceNodeId === n.id && e.sourceHandle === "unknown");
|
|
468
|
+
if (!hasUnknownEdge) {
|
|
469
|
+
const who = `«${c._title || n.id}» (${n.type})`;
|
|
470
|
+
warns.push(`AGENT_UNKNOWN_NOT_CONNECTED: ${who} — ветка «не знаю» не подключена — диалог упрётся в тупик там, где нужен человек.`);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
278
474
|
// --- синхронные циклы (цикл без DELAY/ASK_QUESTION/SCHEDULE = ошибка) ---
|
|
279
475
|
const waitTypes = new Set(["ASK_QUESTION", "DELAY", "SCHEDULE"]);
|
|
280
476
|
const typeById = Object.fromEntries(nodes.map((n) => [n.id, n.type]));
|
|
@@ -300,7 +496,8 @@ for (const n of nodes) if (!color[n.id]) dfs(n.id);
|
|
|
300
496
|
// Зеркало GraphValidator.platformErrors() (Java).
|
|
301
497
|
// IG_ALLOWED_NODES: TRIGGER_IG_COMMENT, TRIGGER_IG_DM, TRIGGER_IG_STORY_REPLY,
|
|
302
498
|
// TRIGGER_IG_STORY_MENTION, SEND_MESSAGE, SEND_PHOTO, BRANCH, CONDITION,
|
|
303
|
-
// SET_VARIABLE, ADD_TAG, REMOVE_TAG, FORMULA, ASK_QUESTION, DELAY, END
|
|
499
|
+
// SET_VARIABLE, ADD_TAG, REMOVE_TAG, FORMULA, ASK_QUESTION, DELAY, END,
|
|
500
|
+
// SWITCH, STOP_AND_ERROR.
|
|
304
501
|
// IG_ALLOWED_INPUT_KINDS: TEXT, EMAIL, PHONE, NUMBER, CONTACT (CONTACT деградирует в ручной ввод номера — кнопки в IG нет).
|
|
305
502
|
// IG_MAX_DELAY_SECONDS: 86400 (24 часа).
|
|
306
503
|
// ============================================================
|
|
@@ -313,6 +510,7 @@ if (platform === "INSTAGRAM") {
|
|
|
313
510
|
"BRANCH", "CONDITION", "SET_VARIABLE",
|
|
314
511
|
"ADD_TAG", "REMOVE_TAG", "FORMULA",
|
|
315
512
|
"ASK_QUESTION", "DELAY", "END",
|
|
513
|
+
"SWITCH", "STOP_AND_ERROR",
|
|
316
514
|
]);
|
|
317
515
|
const IG_ALLOWED_INPUT_KINDS = new Set(["TEXT", "EMAIL", "PHONE", "NUMBER", "CONTACT"]);
|
|
318
516
|
const IG_MAX_DELAY_SEC = 86400; // 24h messaging window
|