@retensy/mcp 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +12 -0
- package/.claude-plugin/plugin.json +12 -0
- package/.mcp.json +12 -0
- package/LICENSE +21 -0
- package/README.md +159 -0
- package/package.json +21 -0
- package/skills/build-bot-funnel/SKILL.md +70 -0
- package/skills/build-bot-funnel/reference/schema.md +193 -0
- package/skills/build-bot-funnel/reference/validation.md +107 -0
- package/skills/build-bot-funnel/validate.mjs +398 -0
- package/src/index.mjs +424 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# Правила валидатора (GraphValidator) — чтобы граф публиковался
|
|
2
|
+
|
|
3
|
+
Источник истины: `retensyBackend/.../service/bot/GraphValidator.java`. При `publish` бэкенд возвращает `errors: [{ nodeId, code, message }]`. Ниже — что проверяется и как не нарваться.
|
|
4
|
+
|
|
5
|
+
## Коды ошибок и условия
|
|
6
|
+
|
|
7
|
+
### `SEND_NO_TEXT` — «Пустое сообщение — добавьте текст или картинку»
|
|
8
|
+
`SEND_MESSAGE` считается пустым, если **`config.text` пустой/из пробелов И `config.photoUrl` пустой**.
|
|
9
|
+
- Бэкенд читает плоское `config.text` (НЕ `cards`).
|
|
10
|
+
- Но редактор при сохранении пересобирает `text` из карточек через `cardsToLegacy`: берёт **первую карточку `type:"text"` с непустым `text`** (или `image.url`→`photoUrl`).
|
|
11
|
+
- ⇒ ВСЕГДА заполняй и `config.text`, и `cards[0]` (тип `text`, непустой `text`). Тогда оба пути дают непустой текст.
|
|
12
|
+
- Кнопок недостаточно: сообщение только с `buttons` — пустое.
|
|
13
|
+
- Пробелы/таб/перенос строки = пусто (`.isBlank()`).
|
|
14
|
+
|
|
15
|
+
### Длина текста
|
|
16
|
+
- `text` ≤ 4096; если есть `photoUrl` (подпись) — ≤ 1024 (`SEND_TOO_LONG`).
|
|
17
|
+
- При `parseMode:"HTML"` — текст должен быть безопасным Telegram-HTML, иначе `HTML_NOT_SAFE`. Разрешены только `b,strong,i,em,u,ins,s,strike,del,code,pre,a[href],tg-spoiler,br`. Если не уверен — `PLAIN`.
|
|
18
|
+
|
|
19
|
+
### `SEND_MESSAGE` в режиме «Вопрос» (`awaitReply:true`)
|
|
20
|
+
- `saveTo` обязателен и матчит `[a-z_][a-z0-9_]{0,63}` (`SEND_BAD_SAVE_TO`).
|
|
21
|
+
- При `validator:"REGEX"` — `regex` непустой и компилируется (`SEND_BAD_REGEX`).
|
|
22
|
+
|
|
23
|
+
### Триггеры и достижимость
|
|
24
|
+
- Нужен **хотя бы один корневой `TRIGGER_*`** без входящих рёбер.
|
|
25
|
+
- Если есть `BROADCAST_FILTER` — он должен быть единственным триггером.
|
|
26
|
+
- **Все нелистовые узлы достижимы** от какого-либо триггера. Недостижимый узел = ошибка.
|
|
27
|
+
|
|
28
|
+
### Циклы
|
|
29
|
+
- **Синхронных циклов нет.** Цикл допустим только если проходит через `ASK_QUESTION`, `DELAY` или `SCHEDULE` (узлы, которые «ждут»).
|
|
30
|
+
|
|
31
|
+
### Per-node конфиг
|
|
32
|
+
- `TRIGGER_COMMAND`: `command` обязателен.
|
|
33
|
+
- `TRIGGER_CALLBACK`: `value` обязателен; `matchMode` ∈ {EQUALS, STARTS_WITH}.
|
|
34
|
+
- `TRIGGER_TEXT`: `matchMode` ∈ {ANY,EQUALS,CONTAINS,REGEX}; для не-ANY нужен `value`.
|
|
35
|
+
- `ASK_QUESTION`: `promptText` обязателен (`ASK_NO_PROMPT`); `saveTo` ∈ `[a-z_][a-z0-9_]{0,63}` (`ASK_BAD_SAVE_TO`); при `validator:"REGEX"` — валидный `regex` (`ASK_BAD_REGEX`).
|
|
36
|
+
- `BRANCH`: ≥1 case (`BRANCH_NO_CASES`); вне `abTest` — непустое валидное `expression` (`BRANCH_EMPTY_EXPR`/`BRANCH_BAD_EXPR`) и подключённое ребро `case_<id>` (`BRANCH_CASE_UNCONNECTED`; `disabled:true` — если ветка намеренно пустая). В `abTest:true` рёбра нужны только у первых двух case.
|
|
37
|
+
- `CONDITION`: `match` ∈ {ALL, ANY} (`CONDITION_BAD_MATCH`); ≥1 элемент в `conditions` (`CONDITION_EMPTY`). Для `kind:"TAG"` — `value` ∈ `[a-z0-9_-]{1,64}` (`CONDITION_BAD_TAG`); для `kind:"VARIABLE"` — `key` ∈ `[a-z_][a-z0-9_]{0,63}` (`CONDITION_BAD_KEY`). Остальные `kind` (UTM/NAME/EMAIL/PHONE/USERNAME/SUBSCRIBED/LINK_CLICKED/CURRENT_*/DAY_OF_WEEK) бэкенд проверяет в рантайме — некорректный `kind`/`op`, нечисловой `key` у `SUBSCRIBED` или `key` у `LINK_CLICKED` без отслеживаемой кнопки молча дают `false` (выход `no`), публикацию не блокируют. Список `kind`/`op` — в schema.md.
|
|
38
|
+
- `CALL_WEBHOOK`: `url` обязателен (`WEBHOOK_NO_URL`), http(s):// (`WEBHOOK_BAD_SCHEME`).
|
|
39
|
+
- `DELAY`: `kind` ∈ {FIXED,UNTIL,TOMORROW} (`DELAY_BAD_KIND`). `FIXED` — `durationSec>0` (секунды) ИЛИ `duration>0` (+`unit` ∈ {MINUTES,HOURS,DAYS}), иначе `DELAY_BAD_DURATION`. `TOMORROW` — `time` = `HH:mm` (`DELAY_BAD_TIME`). `UNTIL` — `isoTimestamp` (ISO-8601 UTC; рантайм читает только его, не `isoDate`+`time`).
|
|
40
|
+
- `SET_VARIABLE`: `key` ∈ `[a-z_][a-z0-9_]{0,63}` (`VAR_BAD_KEY`). `ADD_TAG`/`REMOVE_TAG`: `tag` ∈ `[a-z0-9_-]{1,64}` (`TAG_BAD_NAME`).
|
|
41
|
+
- `FORMULA`: `expression` непустой (`FORMULA_NO_EXPRESSION`); `saveTo` ∈ var-формат (`FORMULA_BAD_SAVE_TO`).
|
|
42
|
+
- `SCHEDULE`: `isoDate` = валидная `YYYY-MM-DD` (`SCHEDULE_BAD_DATE`); `time` = `HH:mm` (`SCHEDULE_BAD_TIME`).
|
|
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
|
+
- `AI_REPLY`: `userPromptTemplate` непустой (`AI_NO_PROMPT`); `temperature` ∈ [0.0, 2.0] (`AI_BAD_TEMPERATURE`); нужен `sendToUser:true` ИЛИ `saveTo` (`AI_NO_OUTPUT`).
|
|
45
|
+
- `PAYMENT_LINK`: `paymentUrl` обязателен (`PAY_NO_URL`), http(s):// или `{{var.x}}` (`PAY_BAD_SCHEME`).
|
|
46
|
+
- Метки: `[a-z0-9_-]{1,64}`. Переменные: `[a-z_][a-z0-9_]{0,63}`.
|
|
47
|
+
|
|
48
|
+
## Рёбра
|
|
49
|
+
- `sourceNodeId` и `targetNodeId` должны указывать на существующие узлы (нет «висячих»).
|
|
50
|
+
- `sourceHandle` должен соответствовать типу узла-источника (см. schema.md).
|
|
51
|
+
- **Кнопка-выбор ↔ ребро `btn_N`**: у `CALLBACK`-кнопки, от которой идёт ребро `btn_N`, `value` ДОЛЖЕН быть пустым (`""`). Тогда бот рендерит навигационный `callback_data` `n:<id узла>:<индекс>` и маршрутизирует по `btn_N`. Непустой `value` рендерится как есть, не матчит навигационный формат → нажатие уходит в `NO_MATCH` (кнопка «не работает»). Непустой `value` уместен только для legacy-кнопки под отдельный узел `TRIGGER_CALLBACK` (без ребра `btn_N`).
|
|
52
|
+
- id узлов и рёбер — уникальны и **оба должны быть валидными UUID**: Jackson десериализует и `TgNode.id`, и `TgEdge.id`/`sourceNodeId`/`targetNodeId` как `java.util.UUID`. Короткая строка (`"m1"`, `"e1"`) → весь PUT падает с HTTP 400 (generic, без тела).
|
|
53
|
+
|
|
54
|
+
## Жизненный цикл
|
|
55
|
+
- Статусы графа: `DRAFT` / `PUBLISHED`. Публикация заменяет активную опубликованную версию.
|
|
56
|
+
- Перед публикацией полезно прогнать `dry_run` (kind `command`/`callback`/`text`) — поймать рантайм-проблемы стартовой ветки.
|
|
57
|
+
|
|
58
|
+
## Платформенные правила — Instagram
|
|
59
|
+
|
|
60
|
+
Источник: `GraphValidator.platformErrors()` (Java). Коды — жёсткие ошибки, **блокируют публикацию** (`publish_graph` вернёт `errors[]`).
|
|
61
|
+
|
|
62
|
+
### `IG_NODE_UNSUPPORTED` — неподдерживаемый тип узла
|
|
63
|
+
|
|
64
|
+
Каждый узел в графе (включая черновые/неподключённые) должен быть из IG-allowlist. Всё остальное → ошибка.
|
|
65
|
+
|
|
66
|
+
Allowlist: `TRIGGER_IG_COMMENT`, `TRIGGER_IG_DM`, `TRIGGER_IG_STORY_REPLY`, `TRIGGER_IG_STORY_MENTION`, `SEND_MESSAGE`, `SEND_PHOTO`, `BRANCH`, `CONDITION`, `SET_VARIABLE`, `ADD_TAG`, `REMOVE_TAG`, `FORMULA`, `ASK_QUESTION`, `DELAY`, `END`.
|
|
67
|
+
|
|
68
|
+
Не в allowlist: `TRIGGER_COMMAND`, `TRIGGER_CALLBACK`, `TRIGGER_TEXT`, `BROADCAST_FILTER`, `SCHEDULE`, `ACTIONS`, `CALL_WEBHOOK`, `AI_REPLY`, `PAYMENT_LINK` — и любой другой тип.
|
|
69
|
+
|
|
70
|
+
> Бэкенд проверяет ВСЕ узлы, включая черновые (неподключённые), так как палитра редактора не даёт ставить неподдерживаемые узлы в IG-боте — поэтому их присутствие означает реальную ошибку (напр., импортированный/скопированный граф).
|
|
71
|
+
|
|
72
|
+
### `IG_DELAY_OVER_24H` — задержка больше 24 часов
|
|
73
|
+
|
|
74
|
+
Instagram доставляет сообщения только в течение **24 часов** после последнего входящего от пользователя.
|
|
75
|
+
|
|
76
|
+
Правило (зеркало `igDelaySeconds()` в Java):
|
|
77
|
+
- `kind != "FIXED"` (т.е. `TOMORROW` или `UNTIL`) → всегда ошибка (заведомо > 24ч).
|
|
78
|
+
- `kind == "FIXED"`:
|
|
79
|
+
- `durationSec > 86400` → ошибка.
|
|
80
|
+
- `duration` (число) + `unit` → пересчёт: `MINUTES` × 60, `HOURS` × 3600, `DAYS` × 86400; если результат > 86400 → ошибка.
|
|
81
|
+
- `duration` без `unit` → считается в секундах; если > 86400 → ошибка.
|
|
82
|
+
- Отсутствует и `durationSec`, и `duration` (malformed FIXED) → ошибка (не разрешаем молча).
|
|
83
|
+
|
|
84
|
+
### `IG_INPUT_UNSUPPORTED` — неподдерживаемый вид ответа
|
|
85
|
+
|
|
86
|
+
У `ASK_QUESTION`, `inputKind` ограничен: `TEXT`, `EMAIL`, `PHONE`, `NUMBER`, `CONTACT`.
|
|
87
|
+
|
|
88
|
+
`CONTACT` допустим: кнопки «Поделиться номером» в IG Messaging API нет, поэтому вопрос уходит текстом-инструкцией, а номер принимается набранным вручную (как телефон). Не поддерживается: `LOCATION`, `PHOTO`, `DOCUMENT`.
|
|
89
|
+
|
|
90
|
+
`inputKind == null` (не задан) — допустимо (дефолт = текст).
|
|
91
|
+
|
|
92
|
+
### Фильтрация по ключевым словам (`keywords`) — только информация, не ошибка
|
|
93
|
+
|
|
94
|
+
`TRIGGER_IG_COMMENT`, `TRIGGER_IG_DM`, `TRIGGER_IG_STORY_REPLY` поддерживают опциональное поле `keywords` в config. Значение — произвольная строка (список через запятую или с новой строки). Бэкенд делает **регистронезависимый contains** (вхождение): срабатывает, если входящий текст содержит хотя бы одно из ключевых слов/фраз (по вхождению, без границ слова). Пустое или отсутствующее `keywords` = совпадение с любым событием. Никаких жёстких кодов ошибок для `keywords` нет — любая строка валидна.
|
|
95
|
+
|
|
96
|
+
`TRIGGER_IG_STORY_MENTION` фильтрацию по ключевым словам **не поддерживает**: Instagram не передаёт текст упоминания боту, поэтому `keywords` игнорируется (валидатор выдаёт предупреждение, не ошибку).
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## Локальная проверка
|
|
101
|
+
`node validate.mjs <import.json>` повторяет ключевые проверки: пустые сообщения с учётом `cardsToLegacy`, висячие рёбра, дубли id, достижимость от триггеров, длину текста, HTML-безопасность (эвристика по тегам), режим «Вопрос» (`awaitReply`→`saveTo`/`regex`), конфиг `DELAY`/`SCHEDULE`/`FORMULA`/`ACTIONS`/`AI_REPLY`/`PAYMENT_LINK`/триггеров, условия `CONDITION` (вкл. `LINK_CLICKED` со ссылкой на отслеживаемый шаг). Гонять перед каждой заливкой.
|
|
102
|
+
|
|
103
|
+
Для IG-ботов передавать `--platform=INSTAGRAM`:
|
|
104
|
+
```bash
|
|
105
|
+
node validate.mjs <import.json> --platform=INSTAGRAM
|
|
106
|
+
```
|
|
107
|
+
Добавляет проверки `IG_NODE_UNSUPPORTED`, `IG_DELAY_OVER_24H`, `IG_INPUT_UNSUPPORTED` поверх всех обычных, а также предупреждение (WARNING) для `TRIGGER_IG_STORY_MENTION` с заполненным `keywords` (поле игнорируется).
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Оффлайн-валидатор графа retensy-bot-graph. Без зависимостей.
|
|
3
|
+
// Использование: node validate.mjs <path/to/import.json> [--platform=TELEGRAM|MAX|INSTAGRAM]
|
|
4
|
+
// Повторяет ключевые правила GraphValidator + cardsToLegacy редактора.
|
|
5
|
+
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
7
|
+
|
|
8
|
+
// Разобрать аргументы порядконезависимо: первый не-флаг = путь; --platform=... в любом месте
|
|
9
|
+
let path = "";
|
|
10
|
+
let platform = "TELEGRAM";
|
|
11
|
+
for (const arg of process.argv.slice(2)) {
|
|
12
|
+
const m = arg.match(/^--platform=([A-Za-z]+)$/);
|
|
13
|
+
if (m) { platform = m[1].toUpperCase(); }
|
|
14
|
+
else if (!arg.startsWith("--") && !path) { path = arg; }
|
|
15
|
+
}
|
|
16
|
+
if (!path) { console.error("Usage: node validate.mjs <import.json> [--platform=TELEGRAM|MAX|INSTAGRAM]"); process.exit(2); }
|
|
17
|
+
if (!["TELEGRAM", "MAX", "INSTAGRAM"].includes(platform)) {
|
|
18
|
+
console.error(`❌ Неизвестная платформа: ${platform}. Допустимые: TELEGRAM, MAX, INSTAGRAM`);
|
|
19
|
+
process.exit(2);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let g;
|
|
23
|
+
try { g = JSON.parse(readFileSync(path, "utf8")); }
|
|
24
|
+
catch (e) { console.error("❌ Не удалось прочитать/распарсить JSON:", e.message); process.exit(2); }
|
|
25
|
+
|
|
26
|
+
const nodes = Array.isArray(g.nodes) ? g.nodes : [];
|
|
27
|
+
const edges = Array.isArray(g.edges) ? g.edges : [];
|
|
28
|
+
const errors = [];
|
|
29
|
+
const warns = [];
|
|
30
|
+
|
|
31
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
32
|
+
const VAR_RE = /^[a-z_][a-z0-9_]{0,63}$/;
|
|
33
|
+
const TAG_RE = /^[a-z0-9_-]{1,64}$/;
|
|
34
|
+
|
|
35
|
+
// kind -> допустимые op (для подсказок; бэкенд блокирует только TAG/VARIABLE)
|
|
36
|
+
const COND_OPS = {
|
|
37
|
+
TAG: ["HAS", "NOT_HAS"],
|
|
38
|
+
VARIABLE: ["EQUALS", "NOT_EQUALS", "CONTAINS", "NOT_EMPTY", "EMPTY", "GT", "LT"],
|
|
39
|
+
UTM: ["EQUALS", "CONTAINS", "NOT_EMPTY", "EMPTY"],
|
|
40
|
+
NAME: ["EQUALS", "CONTAINS", "NOT_EMPTY", "EMPTY"],
|
|
41
|
+
EMAIL: ["EQUALS", "CONTAINS", "NOT_EMPTY", "EMPTY"],
|
|
42
|
+
PHONE: ["EQUALS", "CONTAINS", "NOT_EMPTY", "EMPTY"],
|
|
43
|
+
USERNAME: ["EQUALS", "CONTAINS"],
|
|
44
|
+
SUBSCRIBED: ["SUBSCRIBED", "NOT_SUBSCRIBED"],
|
|
45
|
+
LINK_CLICKED: ["CLICKED", "NOT_CLICKED"],
|
|
46
|
+
CURRENT_DATE: ["BEFORE", "AFTER", "EQUALS"],
|
|
47
|
+
CURRENT_TIME: ["BETWEEN"],
|
|
48
|
+
DAY_OF_WEEK: ["IN"],
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// ACTIONS: допустимые kind (зеркало GraphValidator.KNOWN_ACTION_KINDS)
|
|
52
|
+
const ACTION_KINDS = new Set([
|
|
53
|
+
"add_tag", "remove_tag", "set_field", "stop_bot", "delete_step_message",
|
|
54
|
+
"subscribe", "unsubscribe", "autoflow_add", "autoflow_remove",
|
|
55
|
+
"subscriber_webhook", "external_request", "notify",
|
|
56
|
+
"subscriber_email", "agent_chat", "cancel_payment_subscription",
|
|
57
|
+
"getcourse_send", "getcourse_order", "amocrm_send", "amocrm_update",
|
|
58
|
+
"yametrika_event", "gsheets_send", "gsheets_get", "gsheets_update",
|
|
59
|
+
"gsheets_write_cell", "gsheets_read_cell",
|
|
60
|
+
"group_unban", "group_kick", "group_approve", "group_decline",
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
// Telegram-safe HTML — разрешённые теги (эвристика; бэкенд использует jsoup-clean)
|
|
64
|
+
const HTML_OK_TAGS = new Set([
|
|
65
|
+
"b", "strong", "i", "em", "u", "ins", "s", "strike", "del",
|
|
66
|
+
"code", "pre", "a", "tg-spoiler", "br",
|
|
67
|
+
]);
|
|
68
|
+
const isHttp = (u) => /^https?:\/\//.test(String(u || ""));
|
|
69
|
+
|
|
70
|
+
// cardsToLegacy: текст = первая text-карточка с непустым text (или image.url -> photoUrl)
|
|
71
|
+
function cardsToLegacy(cards) {
|
|
72
|
+
cards = Array.isArray(cards) ? cards : [];
|
|
73
|
+
const firstImage = cards.find((c) => c && c.type === "image" && c.url);
|
|
74
|
+
const firstText = cards.find((c) => c && c.type === "text" && c.text);
|
|
75
|
+
const firstMedia = cards.find((c) => c && ["image", "video", "audio", "file"].includes(c.type) && c.text);
|
|
76
|
+
let text, photoUrl;
|
|
77
|
+
if (firstImage) { photoUrl = firstImage.url; text = firstImage.text || (firstText && firstText.text) || ""; }
|
|
78
|
+
else if (firstText) { text = firstText.text; }
|
|
79
|
+
else if (firstMedia) { text = firstMedia.text; }
|
|
80
|
+
if (!text && !photoUrl) text = "";
|
|
81
|
+
return { text: text || "", photoUrl: photoUrl || "" };
|
|
82
|
+
}
|
|
83
|
+
const blank = (s) => !s || !String(s).trim();
|
|
84
|
+
|
|
85
|
+
// --- id / структура ---
|
|
86
|
+
const ids = new Set();
|
|
87
|
+
for (const n of nodes) {
|
|
88
|
+
if (!n || !n.id) { errors.push(`Узел без id: ${JSON.stringify(n).slice(0, 80)}`); continue; }
|
|
89
|
+
if (ids.has(n.id)) errors.push(`Дубль id узла: ${n.id}`);
|
|
90
|
+
ids.add(n.id);
|
|
91
|
+
if (!UUID_RE.test(n.id)) errors.push(`id узла не UUID — бэкенд десериализует UUID, PUT даст HTTP 400: ${n.id} «${n.config?._title || n.type}»`);
|
|
92
|
+
}
|
|
93
|
+
const edgeIds = new Set();
|
|
94
|
+
for (const e of edges) {
|
|
95
|
+
if (e.id && edgeIds.has(e.id)) errors.push(`Дубль id ребра: ${e.id}`);
|
|
96
|
+
if (e.id) edgeIds.add(e.id);
|
|
97
|
+
if (!e.id || !UUID_RE.test(e.id)) errors.push(`id ребра не UUID — бэкенд десериализует UUID, PUT даст HTTP 400: ${e.id}`);
|
|
98
|
+
if (!ids.has(e.sourceNodeId)) errors.push(`Висячее ребро ${e.id}: нет sourceNodeId ${e.sourceNodeId}`);
|
|
99
|
+
if (!ids.has(e.targetNodeId)) errors.push(`Висячее ребро ${e.id}: нет targetNodeId ${e.targetNodeId}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// --- per-node ---
|
|
103
|
+
const triggers = nodes.filter((n) => String(n.type).startsWith("TRIGGER") || n.type === "BROADCAST_FILTER");
|
|
104
|
+
if (triggers.length === 0) errors.push("Нет ни одного триггера (TRIGGER_*) — graph не опубликуется.");
|
|
105
|
+
const broadcast = nodes.filter((n) => n.type === "BROADCAST_FILTER");
|
|
106
|
+
if (broadcast.length && triggers.length > broadcast.length) errors.push("BROADCAST_FILTER должен быть единственным триггером.");
|
|
107
|
+
|
|
108
|
+
for (const n of nodes) {
|
|
109
|
+
const c = n.config || {};
|
|
110
|
+
const who = `«${c._title || n.id}» (${n.type})`;
|
|
111
|
+
if (c.igReplyChannel != null && !["comment", "dm"].includes(String(c.igReplyChannel))) {
|
|
112
|
+
warns.push(`${who}: igReplyChannel должен быть "comment" или "dm" (получено "${c.igReplyChannel}")`);
|
|
113
|
+
}
|
|
114
|
+
if (String(c.igReplyChannel) === "dm" && platform !== "INSTAGRAM") {
|
|
115
|
+
warns.push(`${who}: igReplyChannel="dm" учитывается только у Instagram-ботов`);
|
|
116
|
+
}
|
|
117
|
+
switch (n.type) {
|
|
118
|
+
case "SEND_MESSAGE": {
|
|
119
|
+
const flat = !blank(c.text) || !blank(c.photoUrl);
|
|
120
|
+
const lg = Array.isArray(c.cards) && c.cards.length ? cardsToLegacy(c.cards) : { text: c.text || "", photoUrl: c.photoUrl || "" };
|
|
121
|
+
if (!flat) errors.push(`SEND_NO_TEXT: ${who} — пустой config.text/photoUrl.`);
|
|
122
|
+
else if (blank(lg.text) && blank(lg.photoUrl)) errors.push(`SEND_NO_TEXT: ${who} — после cardsToLegacy текст пуст (нет text-карточки с содержимым).`);
|
|
123
|
+
const t = String(c.text || "");
|
|
124
|
+
const lim = !blank(c.photoUrl) ? 1024 : 4096;
|
|
125
|
+
if (t.length > lim) errors.push(`SEND_TOO_LONG: ${who} = ${t.length} > ${lim}.`);
|
|
126
|
+
// HTML-безопасность (эвристика по тегам)
|
|
127
|
+
if (!blank(t) && String(c.parseMode || "").toUpperCase() === "HTML") {
|
|
128
|
+
const bad = [...t.matchAll(/<\/?([a-zA-Z][a-zA-Z0-9-]*)\b[^>]*>/g)]
|
|
129
|
+
.map((m) => m[1].toLowerCase()).filter((tag) => !HTML_OK_TAGS.has(tag));
|
|
130
|
+
if (bad.length) errors.push(`HTML_NOT_SAFE: ${who} — неразрешённые теги: ${[...new Set(bad)].join(", ")}. Разрешено: ${[...HTML_OK_TAGS].join(",")}.`);
|
|
131
|
+
}
|
|
132
|
+
// режим «Вопрос»
|
|
133
|
+
if (c.awaitReply === true) {
|
|
134
|
+
if (!c.saveTo || !VAR_RE.test(c.saveTo)) errors.push(`SEND_BAD_SAVE_TO: ${who} — awaitReply требует saveTo ∈ [a-z_][a-z0-9_]{0,63}.`);
|
|
135
|
+
if (c.validator === "REGEX") {
|
|
136
|
+
if (blank(c.regex)) errors.push(`SEND_BAD_REGEX: ${who} — REGEX-валидатор требует непустой regex.`);
|
|
137
|
+
else { try { new RegExp(c.regex); } catch (e) { errors.push(`SEND_BAD_REGEX: ${who} — ${e.message}.`); } }
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// Кнопки-выборы: если от кнопки идёт ребро btn_N, value ДОЛЖЕН быть пустым —
|
|
141
|
+
// бот сам генерит callback_data n:<id>:<idx>; непустой value → нажатие даёт NO_MATCH.
|
|
142
|
+
{
|
|
143
|
+
const btnIdx = new Set(
|
|
144
|
+
edges.filter((e) => e.sourceNodeId === n.id && /^btn_\d+$/.test(String(e.sourceHandle || "")))
|
|
145
|
+
.map((e) => Number(String(e.sourceHandle).slice(4)))
|
|
146
|
+
);
|
|
147
|
+
(Array.isArray(c.buttons) ? c.buttons : []).flat().forEach((b, i) => {
|
|
148
|
+
if (!b) return;
|
|
149
|
+
if (String(b.kind) === "CALLBACK") {
|
|
150
|
+
const hasVal = !blank(b.value);
|
|
151
|
+
if (hasVal && btnIdx.has(i)) errors.push(`BTN_VALUE_WITH_EDGE: ${who} — кнопка-выбор #${i} («${b.text || ""}») имеет ребро btn_${i} и непустой value «${b.value}»; оставь value пустым, иначе нажатие даст NO_MATCH.`);
|
|
152
|
+
else if (hasVal) warns.push(`${who}: у CALLBACK-кнопки #${i} непустой value «${b.value}» без ребра btn_${i} — это legacy-кнопка под TRIGGER_CALLBACK; для кнопки-выбора нужен пустой value + ребро btn_${i}.`);
|
|
153
|
+
}
|
|
154
|
+
if (b.color) {
|
|
155
|
+
const cc = String(b.color).trim();
|
|
156
|
+
const okColor = ["", "primary", "success", "danger"].includes(cc.toLowerCase())
|
|
157
|
+
|| ["#2EA6FF", "#34C759", "#FF3B30"].includes(cc.toUpperCase());
|
|
158
|
+
if (!okColor) warns.push(`${who}: стиль кнопки #${i} «${b.color}» Telegram не рендерит — только ""(дефолт)/primary(#2EA6FF)/success(#34C759)/danger(#FF3B30).`);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
case "SEND_PHOTO":
|
|
165
|
+
if (blank(c.photoUrl)) errors.push(`PHOTO_NO_URL: ${who} — нужен photoUrl.`);
|
|
166
|
+
if (!blank(c.caption) && String(c.caption).length > 1024) errors.push(`PHOTO_CAPTION_TOO_LONG: ${who} — подпись > 1024.`);
|
|
167
|
+
break;
|
|
168
|
+
case "TRIGGER_COMMAND":
|
|
169
|
+
if (blank(c.command)) errors.push(`${who}: нужен command.`);
|
|
170
|
+
break;
|
|
171
|
+
case "TRIGGER_CALLBACK":
|
|
172
|
+
if (blank(c.value)) errors.push(`${who}: нужен value.`);
|
|
173
|
+
if (c.matchMode && !["EQUALS", "STARTS_WITH"].includes(c.matchMode)) errors.push(`${who}: matchMode ∈ {EQUALS,STARTS_WITH}.`);
|
|
174
|
+
break;
|
|
175
|
+
case "TRIGGER_TEXT":
|
|
176
|
+
if (c.matchMode && c.matchMode !== "ANY" && blank(c.value)) errors.push(`${who}: для matchMode≠ANY нужен value.`);
|
|
177
|
+
break;
|
|
178
|
+
case "ASK_QUESTION":
|
|
179
|
+
if (blank(c.promptText)) errors.push(`${who}: нужен promptText.`);
|
|
180
|
+
if (c.saveTo && !VAR_RE.test(c.saveTo)) errors.push(`${who}: saveTo «${c.saveTo}» не матчит [a-z_][a-z0-9_]{0,63}.`);
|
|
181
|
+
break;
|
|
182
|
+
case "CALL_WEBHOOK":
|
|
183
|
+
if (blank(c.url) || !/^https?:\/\//.test(c.url)) errors.push(`${who}: url обязателен и http(s)://.`);
|
|
184
|
+
break;
|
|
185
|
+
case "DELAY":
|
|
186
|
+
if (c.kind === "FIXED") { if (!(Number(c.durationSec) > 0) && !(Number(c.duration) > 0)) errors.push(`${who}: FIXED требует durationSec>0 (или duration>0 + unit MINUTES|HOURS|DAYS).`); }
|
|
187
|
+
else if (c.kind === "TOMORROW") { if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(String(c.time || ""))) errors.push(`${who}: TOMORROW требует time = HH:mm.`); }
|
|
188
|
+
else if (c.kind === "UNTIL") { if (blank(c.isoTimestamp)) errors.push(`${who}: UNTIL требует isoTimestamp (ISO-8601 UTC). isoDate+time рантайм НЕ читает.`); }
|
|
189
|
+
else errors.push(`${who}: kind ∈ {FIXED,TOMORROW,UNTIL}.`);
|
|
190
|
+
break;
|
|
191
|
+
case "BRANCH":
|
|
192
|
+
if (!Array.isArray(c.cases) || c.cases.length === 0) errors.push(`${who}: нужен хотя бы один case.`);
|
|
193
|
+
break;
|
|
194
|
+
case "CONDITION": {
|
|
195
|
+
if (c.match !== "ALL" && c.match !== "ANY") errors.push(`CONDITION_BAD_MATCH: ${who} — match ∈ {ALL,ANY}.`);
|
|
196
|
+
if (!Array.isArray(c.conditions) || c.conditions.length === 0) {
|
|
197
|
+
errors.push(`CONDITION_EMPTY: ${who} — нужно хотя бы одно условие.`);
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
c.conditions.forEach((cond, i) => {
|
|
201
|
+
if (!cond || typeof cond !== "object") { errors.push(`${who}: условие #${i + 1} — не объект.`); return; }
|
|
202
|
+
const kind = cond.kind;
|
|
203
|
+
// Бэкенд блокирует публикацию только для TAG.value и VARIABLE.key:
|
|
204
|
+
if (kind === "TAG" && !TAG_RE.test(String(cond.value || ""))) errors.push(`CONDITION_BAD_TAG: ${who} — TAG.value ∈ [a-z0-9_-]{1,64}.`);
|
|
205
|
+
if (kind === "VARIABLE" && !VAR_RE.test(String(cond.key || ""))) errors.push(`CONDITION_BAD_KEY: ${who} — VARIABLE.key ∈ [a-z_][a-z0-9_]{0,63}.`);
|
|
206
|
+
// Остальное — подсказки (рантайм бэкенда отдаст false, но публикацию не сорвёт):
|
|
207
|
+
if (!COND_OPS[kind]) warns.push(`${who}: неизвестный kind «${kind}» в условии #${i + 1} — рантайм даст false.`);
|
|
208
|
+
else if (cond.op && !COND_OPS[kind].includes(cond.op)) warns.push(`${who}: op «${cond.op}» не из {${COND_OPS[kind].join(",")}} для ${kind} — рантайм даст false.`);
|
|
209
|
+
if (kind === "SUBSCRIBED" && !/^-?\d+$/.test(String(cond.key || "").trim())) warns.push(`${who}: SUBSCRIBED.key должен быть числовым id канала — иначе false (и бот должен быть админом канала).`);
|
|
210
|
+
if (kind === "UTM" && !cond.key) warns.push(`${who}: UTM без key — рантайм даст false (ожидается source/medium/campaign/content/term).`);
|
|
211
|
+
if (kind === "LINK_CLICKED") {
|
|
212
|
+
const target = nodes.find((x) => x.id === cond.key);
|
|
213
|
+
const tracked = target && (((target.config || {}).buttons) || []).flat().some((b) => b && b.kind === "URL" && b.track === true);
|
|
214
|
+
if (!cond.key || !target) warns.push(`${who}: LINK_CLICKED.key должен быть id шага с отслеживаемой URL-кнопкой — иначе false.`);
|
|
215
|
+
else if (!tracked) warns.push(`${who}: у шага «${(target.config || {})._title || target.id}» из LINK_CLICKED.key нет URL-кнопки с track:true — рантайм даст false.`);
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
case "SET_VARIABLE":
|
|
221
|
+
if (!VAR_RE.test(String(c.key || ""))) errors.push(`VAR_BAD_KEY: ${who} — key ∈ [a-z_][a-z0-9_]{0,63}.`);
|
|
222
|
+
break;
|
|
223
|
+
case "ADD_TAG":
|
|
224
|
+
case "REMOVE_TAG":
|
|
225
|
+
if (!TAG_RE.test(String(c.tag || ""))) errors.push(`TAG_BAD_NAME: ${who} — tag ∈ [a-z0-9_-]{1,64}.`);
|
|
226
|
+
break;
|
|
227
|
+
case "FORMULA":
|
|
228
|
+
if (blank(c.expression)) errors.push(`FORMULA_NO_EXPRESSION: ${who} — нужен expression.`);
|
|
229
|
+
if (!VAR_RE.test(String(c.saveTo || ""))) errors.push(`FORMULA_BAD_SAVE_TO: ${who} — saveTo ∈ [a-z_][a-z0-9_]{0,63}.`);
|
|
230
|
+
break;
|
|
231
|
+
case "SCHEDULE": {
|
|
232
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(String(c.isoDate || "")) || isNaN(Date.parse(c.isoDate))) errors.push(`SCHEDULE_BAD_DATE: ${who} — isoDate = YYYY-MM-DD.`);
|
|
233
|
+
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(String(c.time || ""))) errors.push(`SCHEDULE_BAD_TIME: ${who} — time = HH:mm.`);
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
case "ACTIONS": {
|
|
237
|
+
if (!Array.isArray(c.actions) || c.actions.length === 0) { errors.push(`ACTIONS_EMPTY: ${who} — нужен непустой actions[].`); break; }
|
|
238
|
+
c.actions.forEach((a, i) => {
|
|
239
|
+
if (!a || typeof a !== "object") { errors.push(`${who}: действие #${i + 1} — не объект.`); return; }
|
|
240
|
+
if (!ACTION_KINDS.has(a.kind)) { errors.push(`ACTION_UNKNOWN_KIND: ${who} — неизвестный kind «${a.kind}» (#${i + 1}).`); return; }
|
|
241
|
+
if (["add_tag", "remove_tag", "autoflow_add", "autoflow_remove"].includes(a.kind) && !TAG_RE.test(String(a.tag || "")))
|
|
242
|
+
errors.push(`ACTION_BAD_TAG: ${who} — ${a.kind}.tag ∈ [a-z0-9_-]{1,64}.`);
|
|
243
|
+
if (a.kind === "set_field" && !VAR_RE.test(String(a.key || "")))
|
|
244
|
+
errors.push(`ACTION_BAD_KEY: ${who} — set_field.key ∈ [a-z_][a-z0-9_]{0,63}.`);
|
|
245
|
+
if (["subscriber_webhook", "external_request"].includes(a.kind) && !isHttp(a.url))
|
|
246
|
+
errors.push(`ACTION_BAD_URL: ${who} — ${a.kind}.url должен быть http(s)://.`);
|
|
247
|
+
if (a.kind === "gsheets_send" && (!a.googleEmail || !a.spreadsheetId || !Array.isArray(a.cells) || a.cells.length === 0))
|
|
248
|
+
errors.push(`ACTION_GSHEETS_INCOMPLETE: ${who} — gsheets_send требует googleEmail + spreadsheetId + непустой cells[] (Google-аккаунт подключается в вебе /bots, не через MCP).`);
|
|
249
|
+
});
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
case "AI_REPLY": {
|
|
253
|
+
if (blank(c.userPromptTemplate)) errors.push(`AI_NO_PROMPT: ${who} — нужен userPromptTemplate.`);
|
|
254
|
+
if (typeof c.temperature === "number" && (c.temperature < 0 || c.temperature > 2)) errors.push(`AI_BAD_TEMPERATURE: ${who} — temperature ∈ [0.0, 2.0].`);
|
|
255
|
+
if (c.sendToUser !== true && blank(c.saveTo)) errors.push(`AI_NO_OUTPUT: ${who} — нужен sendToUser:true или saveTo.`);
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
case "PAYMENT_LINK": {
|
|
259
|
+
const u = String(c.paymentUrl || "");
|
|
260
|
+
if (blank(u)) errors.push(`PAY_NO_URL: ${who} — нужен paymentUrl.`);
|
|
261
|
+
else if (!isHttp(u) && !u.startsWith("{{")) errors.push(`PAY_BAD_SCHEME: ${who} — paymentUrl = http(s):// или {{var.x}}.`);
|
|
262
|
+
break;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// --- достижимость от триггеров ---
|
|
268
|
+
const adj = {};
|
|
269
|
+
for (const e of edges) (adj[e.sourceNodeId] = adj[e.sourceNodeId] || []).push(e.targetNodeId);
|
|
270
|
+
const reach = new Set();
|
|
271
|
+
const stack = triggers.map((t) => t.id);
|
|
272
|
+
while (stack.length) { const x = stack.pop(); if (reach.has(x)) continue; reach.add(x); (adj[x] || []).forEach((y) => stack.push(y)); }
|
|
273
|
+
for (const n of nodes) {
|
|
274
|
+
if (String(n.type).startsWith("TRIGGER") || n.type === "BROADCAST_FILTER") continue;
|
|
275
|
+
if (!reach.has(n.id)) errors.push(`Недостижимый узел от триггера: «${n.config?._title || n.id}» (${n.type}).`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// --- синхронные циклы (цикл без DELAY/ASK_QUESTION/SCHEDULE = ошибка) ---
|
|
279
|
+
const waitTypes = new Set(["ASK_QUESTION", "DELAY", "SCHEDULE"]);
|
|
280
|
+
const typeById = Object.fromEntries(nodes.map((n) => [n.id, n.type]));
|
|
281
|
+
const color = {}; // 0=white,1=gray,2=black
|
|
282
|
+
function dfs(u, pathHasWaitAtEdgeInto) {
|
|
283
|
+
color[u] = 1;
|
|
284
|
+
for (const v of adj[u] || []) {
|
|
285
|
+
if (color[v] === 1) {
|
|
286
|
+
// нашли цикл u->...->v; ок только если в цикле есть wait-узел
|
|
287
|
+
// упрощённо: если ни u, ни v не wait — предупредим как потенциальный синхронный цикл
|
|
288
|
+
if (!waitTypes.has(typeById[u]) && !waitTypes.has(typeById[v]))
|
|
289
|
+
warns.push(`Возможный синхронный цикл рядом с ${v} — убедись, что в петле есть DELAY/ASK_QUESTION/SCHEDULE.`);
|
|
290
|
+
} else if (color[v] !== 2) {
|
|
291
|
+
dfs(v);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
color[u] = 2;
|
|
295
|
+
}
|
|
296
|
+
for (const n of nodes) if (!color[n.id]) dfs(n.id);
|
|
297
|
+
|
|
298
|
+
// ============================================================
|
|
299
|
+
// --- IG-специфические проверки (только для INSTAGRAM) ---
|
|
300
|
+
// Зеркало GraphValidator.platformErrors() (Java).
|
|
301
|
+
// IG_ALLOWED_NODES: TRIGGER_IG_COMMENT, TRIGGER_IG_DM, TRIGGER_IG_STORY_REPLY,
|
|
302
|
+
// TRIGGER_IG_STORY_MENTION, SEND_MESSAGE, SEND_PHOTO, BRANCH, CONDITION,
|
|
303
|
+
// SET_VARIABLE, ADD_TAG, REMOVE_TAG, FORMULA, ASK_QUESTION, DELAY, END.
|
|
304
|
+
// IG_ALLOWED_INPUT_KINDS: TEXT, EMAIL, PHONE, NUMBER, CONTACT (CONTACT деградирует в ручной ввод номера — кнопки в IG нет).
|
|
305
|
+
// IG_MAX_DELAY_SECONDS: 86400 (24 часа).
|
|
306
|
+
// ============================================================
|
|
307
|
+
|
|
308
|
+
if (platform === "INSTAGRAM") {
|
|
309
|
+
const IG_ALLOWED_NODES = new Set([
|
|
310
|
+
"TRIGGER_IG_COMMENT", "TRIGGER_IG_DM",
|
|
311
|
+
"TRIGGER_IG_STORY_REPLY", "TRIGGER_IG_STORY_MENTION",
|
|
312
|
+
"SEND_MESSAGE", "SEND_PHOTO",
|
|
313
|
+
"BRANCH", "CONDITION", "SET_VARIABLE",
|
|
314
|
+
"ADD_TAG", "REMOVE_TAG", "FORMULA",
|
|
315
|
+
"ASK_QUESTION", "DELAY", "END",
|
|
316
|
+
]);
|
|
317
|
+
const IG_ALLOWED_INPUT_KINDS = new Set(["TEXT", "EMAIL", "PHONE", "NUMBER", "CONTACT"]);
|
|
318
|
+
const IG_MAX_DELAY_SEC = 86400; // 24h messaging window
|
|
319
|
+
|
|
320
|
+
function igDelaySeconds(c) {
|
|
321
|
+
// Зеркало GraphValidator.igDelaySeconds():
|
|
322
|
+
// только FIXED считаем конкретно; TOMORROW/UNTIL — всегда > 24h (Long.MAX_VALUE)
|
|
323
|
+
if (!c || c.kind !== "FIXED") return Infinity;
|
|
324
|
+
// legacy: durationSec
|
|
325
|
+
if (typeof c.durationSec === "number") return c.durationSec;
|
|
326
|
+
// new format: duration + unit
|
|
327
|
+
if (typeof c.duration === "number") {
|
|
328
|
+
const u = String(c.unit || "");
|
|
329
|
+
if (u === "MINUTES") return c.duration * 60;
|
|
330
|
+
if (u === "HOURS") return c.duration * 3600;
|
|
331
|
+
if (u === "DAYS") return c.duration * 86400;
|
|
332
|
+
return c.duration; // assume seconds
|
|
333
|
+
}
|
|
334
|
+
return Infinity; // malformed FIXED — treat as unbounded (block it)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Достижимость от триггеров-комментариев: «Ответ в комментарии» (igReplyChannel="comment")
|
|
338
|
+
// имеет смысл только в сценарии, запускаемом комментарием (иначе отвечать в комментарии некуда).
|
|
339
|
+
const reachFromComment = new Set();
|
|
340
|
+
{
|
|
341
|
+
const st = nodes.filter((n) => n.type === "TRIGGER_IG_COMMENT").map((n) => n.id);
|
|
342
|
+
while (st.length) { const x = st.pop(); if (reachFromComment.has(x)) continue; reachFromComment.add(x); (adj[x] || []).forEach((y) => st.push(y)); }
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
for (const n of nodes) {
|
|
346
|
+
const type = n.type;
|
|
347
|
+
const c = n.config || {};
|
|
348
|
+
const who = `«${c._title || n.id}» (${type})`;
|
|
349
|
+
if (!type) continue;
|
|
350
|
+
|
|
351
|
+
// 1) Проверка allowlist
|
|
352
|
+
if (!IG_ALLOWED_NODES.has(type)) {
|
|
353
|
+
errors.push(`IG_NODE_UNSUPPORTED: ${who} — узел «${type}» недоступен для Instagram-бота`);
|
|
354
|
+
continue; // дальнейшие проверки для этого узла бессмысленны
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// 2) ASK_QUESTION: inputKind должен быть из IG_ALLOWED_INPUT_KINDS
|
|
358
|
+
if (type === "ASK_QUESTION") {
|
|
359
|
+
const kind = c.inputKind;
|
|
360
|
+
if (kind != null && !IG_ALLOWED_INPUT_KINDS.has(String(kind).toUpperCase())) {
|
|
361
|
+
errors.push(`IG_INPUT_UNSUPPORTED: ${who} — В Instagram нельзя запросить «${kind}» — только TEXT/EMAIL/PHONE/NUMBER/CONTACT (CONTACT = ручной ввод номера)`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// 3) DELAY: не более 24h (только для FIXED; TOMORROW/UNTIL всегда > 24h)
|
|
366
|
+
if (type === "DELAY") {
|
|
367
|
+
const sec = igDelaySeconds(c);
|
|
368
|
+
if (sec > IG_MAX_DELAY_SEC) {
|
|
369
|
+
errors.push(`IG_DELAY_OVER_24H: ${who} — задержка больше 24ч недопустима для Instagram (24-часовое окно доставки)`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// 4) «Ответ в комментарии» только в сценарии с триггером-комментарием
|
|
374
|
+
if ((type === "SEND_MESSAGE" || type === "SEND_PHOTO")
|
|
375
|
+
&& String(c.igReplyChannel) === "comment" && !reachFromComment.has(n.id)) {
|
|
376
|
+
errors.push(`IG_COMMENT_REPLY_NO_COMMENT_TRIGGER: ${who} — «Ответ в комментарии» доступен только если шаг запускается триггером «Комментарий»; выбери «Личное сообщение» (igReplyChannel:"dm") или подключи шаг к TRIGGER_IG_COMMENT`);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// 4) TRIGGER_IG_STORY_MENTION: keywords игнорируется — текста упоминания нет
|
|
380
|
+
if (type === "TRIGGER_IG_STORY_MENTION" && !blank(c.keywords)) {
|
|
381
|
+
warns.push(`${who}: TRIGGER_IG_STORY_MENTION не фильтруется по ключевым словам (у упоминания нет текста) — keywords игнорируется`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// --- отчёт ---
|
|
387
|
+
const byType = {};
|
|
388
|
+
nodes.forEach((n) => (byType[n.type] = (byType[n.type] || 0) + 1));
|
|
389
|
+
console.log(`Платформа: ${platform}`);
|
|
390
|
+
console.log(`Граф: ${nodes.length} узлов, ${edges.length} рёбер, ${triggers.length} триггеров`);
|
|
391
|
+
console.log(`Типы: ${JSON.stringify(byType)}`);
|
|
392
|
+
if (warns.length) { console.log(`\n⚠️ Предупреждения (${warns.length}):`); warns.forEach((w) => console.log(" • " + w)); }
|
|
393
|
+
if (errors.length) {
|
|
394
|
+
console.log(`\n❌ Ошибки (${errors.length}) — публикация не пройдёт:`);
|
|
395
|
+
errors.forEach((e) => console.log(" • " + e));
|
|
396
|
+
process.exit(1);
|
|
397
|
+
}
|
|
398
|
+
console.log("\n✅ Валидация пройдена — граф готов к заливке/публикации.");
|