@retensy/mcp 0.20.0 → 0.21.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 +13 -13
- package/.claude-plugin/plugin.json +12 -12
- package/README.md +322 -322
- package/package.json +2 -2
- package/skills/build-bot-funnel/SKILL.md +108 -108
- package/skills/build-bot-funnel/reference/schema.md +483 -479
- package/skills/build-bot-funnel/reference/validation.md +134 -134
- package/skills/build-bot-funnel/validate.mjs +680 -649
- package/skills/build-site/SKILL.md +105 -105
- package/skills/send-broadcast/SKILL.md +49 -49
- package/src/index.mjs +4 -4
|
@@ -1,649 +1,680 @@
|
|
|
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
|
-
// Потолок размера графа — зеркало TgGraphController.MAX_GRAPH_BYTES (аудит data#24): PUT больше 4 МБ → HTTP 422
|
|
31
|
-
// GRAPH_TOO_LARGE, и у черновика тоже. Байты UTF-8 того же JSON {nodes, edges, canvasMeta}.
|
|
32
|
-
const MAX_GRAPH_BYTES = 4 * 1024 * 1024;
|
|
33
|
-
const graphBytes = Buffer.byteLength(JSON.stringify({ nodes, edges, canvasMeta: g.canvasMeta ?? {} }), "utf8");
|
|
34
|
-
if (graphBytes > MAX_GRAPH_BYTES) {
|
|
35
|
-
errors.push(`GRAPH_TOO_LARGE: граф ${graphBytes} байт > ${MAX_GRAPH_BYTES} (4 МБ) — сократи тексты или раздели на несколько сценариев.`);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
39
|
-
const VAR_RE = /^[a-z_][a-z0-9_]{0,63}$/;
|
|
40
|
-
const TAG_RE = /^[a-z0-9_-]{1,64}$/;
|
|
41
|
-
|
|
42
|
-
// Зеркало boolConfig/Boolean.parseBoolean бэкенда: булево — как есть; иначе строка "true"
|
|
43
|
-
// (без учёта регистра) → true, ЛЮБАЯ другая строка (включая "false") → false, null/undefined → def.
|
|
44
|
-
// Задача 12, фикс-раунд 1, MINOR: `v !== false` расходился с бэком на строке "false".
|
|
45
|
-
function boolConfig(v, def) {
|
|
46
|
-
if (typeof v === "boolean") return v;
|
|
47
|
-
if (v == null) return def;
|
|
48
|
-
return String(v).toLowerCase() === "true";
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// kind -> допустимые op (для подсказок; бэкенд блокирует только TAG/VARIABLE)
|
|
52
|
-
const COND_OPS = {
|
|
53
|
-
TAG: ["HAS", "NOT_HAS"],
|
|
54
|
-
VARIABLE: ["EQUALS", "NOT_EQUALS", "CONTAINS", "NOT_EMPTY", "EMPTY", "GT", "LT"],
|
|
55
|
-
UTM: ["EQUALS", "CONTAINS", "NOT_EMPTY", "EMPTY"],
|
|
56
|
-
NAME: ["EQUALS", "CONTAINS", "NOT_EMPTY", "EMPTY"],
|
|
57
|
-
EMAIL: ["EQUALS", "CONTAINS", "NOT_EMPTY", "EMPTY"],
|
|
58
|
-
PHONE: ["EQUALS", "CONTAINS", "NOT_EMPTY", "EMPTY"],
|
|
59
|
-
USERNAME: ["EQUALS", "CONTAINS"],
|
|
60
|
-
SUBSCRIBED: ["SUBSCRIBED", "NOT_SUBSCRIBED"],
|
|
61
|
-
LINK_CLICKED: ["CLICKED", "NOT_CLICKED"],
|
|
62
|
-
CURRENT_DATE: ["BEFORE", "AFTER", "EQUALS"],
|
|
63
|
-
CURRENT_TIME: ["BETWEEN"],
|
|
64
|
-
DAY_OF_WEEK: ["IN"],
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
// ACTIONS: допустимые kind (зеркало GraphValidator.KNOWN_ACTION_KINDS)
|
|
68
|
-
const ACTION_KINDS = new Set([
|
|
69
|
-
"add_tag", "remove_tag", "set_field", "stop_bot", "delete_step_message",
|
|
70
|
-
"subscribe", "unsubscribe", "autoflow_add", "autoflow_remove",
|
|
71
|
-
"subscriber_webhook", "external_request", "notify",
|
|
72
|
-
"subscriber_email", "agent_chat", "cancel_payment_subscription", "issue_invoice",
|
|
73
|
-
"getcourse_send", "getcourse_order", "amocrm_send", "amocrm_update", "bitrix24_call",
|
|
74
|
-
"yametrika_event", "gsheets_send", "gsheets_get", "gsheets_update",
|
|
75
|
-
"gsheets_write_cell", "gsheets_read_cell",
|
|
76
|
-
"group_unban", "group_kick", "group_approve", "group_decline",
|
|
77
|
-
"invite_link_create", "invite_link_revoke", "subscription_extend", "subscription_check",
|
|
78
|
-
"yookassa_charge_saved",
|
|
79
|
-
"booking_slots", "booking_book", "booking_cancel", "lead_link_contact", "sla_check",
|
|
80
|
-
|
|
81
|
-
"
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
if (
|
|
126
|
-
|
|
127
|
-
if (!
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
if (
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
if (!
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
}
|
|
162
|
-
//
|
|
163
|
-
if (c.
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
if (b
|
|
185
|
-
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
if (
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
if (blank(c.
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
"
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
const
|
|
226
|
-
if (
|
|
227
|
-
if (
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
if (blank(
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
case "
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
if (
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
if (c.
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
}
|
|
254
|
-
else errors.push(`${who}:
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
if (!
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
if (kind === "
|
|
297
|
-
if (kind === "
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
if (
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
}
|
|
356
|
-
if (
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
if (a.kind
|
|
363
|
-
errors.push(`
|
|
364
|
-
if (["
|
|
365
|
-
errors.push(`
|
|
366
|
-
if (a.kind
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
if (blank(
|
|
376
|
-
if (
|
|
377
|
-
const
|
|
378
|
-
if (!Number.isFinite(
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
if (!
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
errors.push(`ACTION_BAD_SAVE_TO: ${who} —
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
const
|
|
447
|
-
const
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
if (
|
|
451
|
-
if (
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
if (
|
|
463
|
-
|
|
464
|
-
if (
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
//
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
const
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
//
|
|
609
|
-
if (
|
|
610
|
-
const
|
|
611
|
-
if (
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
if (type
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
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
|
+
// Потолок размера графа — зеркало TgGraphController.MAX_GRAPH_BYTES (аудит data#24): PUT больше 4 МБ → HTTP 422
|
|
31
|
+
// GRAPH_TOO_LARGE, и у черновика тоже. Байты UTF-8 того же JSON {nodes, edges, canvasMeta}.
|
|
32
|
+
const MAX_GRAPH_BYTES = 4 * 1024 * 1024;
|
|
33
|
+
const graphBytes = Buffer.byteLength(JSON.stringify({ nodes, edges, canvasMeta: g.canvasMeta ?? {} }), "utf8");
|
|
34
|
+
if (graphBytes > MAX_GRAPH_BYTES) {
|
|
35
|
+
errors.push(`GRAPH_TOO_LARGE: граф ${graphBytes} байт > ${MAX_GRAPH_BYTES} (4 МБ) — сократи тексты или раздели на несколько сценариев.`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
39
|
+
const VAR_RE = /^[a-z_][a-z0-9_]{0,63}$/;
|
|
40
|
+
const TAG_RE = /^[a-z0-9_-]{1,64}$/;
|
|
41
|
+
|
|
42
|
+
// Зеркало boolConfig/Boolean.parseBoolean бэкенда: булево — как есть; иначе строка "true"
|
|
43
|
+
// (без учёта регистра) → true, ЛЮБАЯ другая строка (включая "false") → false, null/undefined → def.
|
|
44
|
+
// Задача 12, фикс-раунд 1, MINOR: `v !== false` расходился с бэком на строке "false".
|
|
45
|
+
function boolConfig(v, def) {
|
|
46
|
+
if (typeof v === "boolean") return v;
|
|
47
|
+
if (v == null) return def;
|
|
48
|
+
return String(v).toLowerCase() === "true";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// kind -> допустимые op (для подсказок; бэкенд блокирует только TAG/VARIABLE)
|
|
52
|
+
const COND_OPS = {
|
|
53
|
+
TAG: ["HAS", "NOT_HAS"],
|
|
54
|
+
VARIABLE: ["EQUALS", "NOT_EQUALS", "CONTAINS", "NOT_EMPTY", "EMPTY", "GT", "LT"],
|
|
55
|
+
UTM: ["EQUALS", "CONTAINS", "NOT_EMPTY", "EMPTY"],
|
|
56
|
+
NAME: ["EQUALS", "CONTAINS", "NOT_EMPTY", "EMPTY"],
|
|
57
|
+
EMAIL: ["EQUALS", "CONTAINS", "NOT_EMPTY", "EMPTY"],
|
|
58
|
+
PHONE: ["EQUALS", "CONTAINS", "NOT_EMPTY", "EMPTY"],
|
|
59
|
+
USERNAME: ["EQUALS", "CONTAINS"],
|
|
60
|
+
SUBSCRIBED: ["SUBSCRIBED", "NOT_SUBSCRIBED"],
|
|
61
|
+
LINK_CLICKED: ["CLICKED", "NOT_CLICKED"],
|
|
62
|
+
CURRENT_DATE: ["BEFORE", "AFTER", "EQUALS"],
|
|
63
|
+
CURRENT_TIME: ["BETWEEN"],
|
|
64
|
+
DAY_OF_WEEK: ["IN"],
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// ACTIONS: допустимые kind (зеркало GraphValidator.KNOWN_ACTION_KINDS)
|
|
68
|
+
const ACTION_KINDS = new Set([
|
|
69
|
+
"add_tag", "remove_tag", "set_field", "stop_bot", "delete_step_message",
|
|
70
|
+
"subscribe", "unsubscribe", "autoflow_add", "autoflow_remove",
|
|
71
|
+
"subscriber_webhook", "external_request", "notify",
|
|
72
|
+
"subscriber_email", "agent_chat", "cancel_payment_subscription", "issue_invoice",
|
|
73
|
+
"getcourse_send", "getcourse_order", "amocrm_send", "amocrm_update", "bitrix24_call",
|
|
74
|
+
"yametrika_event", "gsheets_send", "gsheets_get", "gsheets_update",
|
|
75
|
+
"gsheets_write_cell", "gsheets_read_cell",
|
|
76
|
+
"group_unban", "group_kick", "group_approve", "group_decline",
|
|
77
|
+
"invite_link_create", "invite_link_revoke", "subscription_extend", "subscription_check",
|
|
78
|
+
"yookassa_charge_saved",
|
|
79
|
+
"booking_slots", "booking_book", "booking_cancel", "lead_link_contact", "sla_check",
|
|
80
|
+
"call_scenario",
|
|
81
|
+
"send_email",
|
|
82
|
+
// Integration Core (провайдеры из integration_catalog) — бэкенд узнаёт их через реестр, здесь — известные:
|
|
83
|
+
"meta_capi_event",
|
|
84
|
+
]);
|
|
85
|
+
// Зеркало FlowExecutor.EMAIL_PATTERN (валидация send_email на бэке, fix-раунд 1): без `,;<>()"` —
|
|
86
|
+
// иначе "a@b.c,admin@evil.com" проходил бы матч целиком (запятая попадала во вторую часть домена),
|
|
87
|
+
// хотя send_email — ровно один получатель, не список.
|
|
88
|
+
const EMAIL_RE = /^[^\s@,;<>()"]+@[^\s@,;<>()"]+\.[^\s@,;<>()"]+$/;
|
|
89
|
+
// Смещение времени DELAY UNTIL / SCHEDULE: -24h, -30m, +1d (зеркало FlowTime.OFFSET)
|
|
90
|
+
const OFFSET_RE = /^([+-])?\s*\d{1,6}\s*[smhdw]$/i;
|
|
91
|
+
function checkTimeExtras(c, prefix, who) {
|
|
92
|
+
if (!blank(c.offset) && !OFFSET_RE.test(String(c.offset).trim())) errors.push(`${prefix}_BAD_OFFSET: ${who} — offset вида -24h, -30m, +1d (единицы s/m/h/d/w).`);
|
|
93
|
+
if (!blank(c.timezone) && !validZone(String(c.timezone).trim())) errors.push(`${prefix}_BAD_TIMEZONE: ${who} — неизвестный часовой пояс «${c.timezone}».`);
|
|
94
|
+
}
|
|
95
|
+
function validZone(z) {
|
|
96
|
+
try { new Intl.DateTimeFormat("en", { timeZone: z }); return true; } catch { return false; }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Telegram-safe HTML — разрешённые теги (эвристика; бэкенд использует jsoup-clean)
|
|
100
|
+
const HTML_OK_TAGS = new Set([
|
|
101
|
+
"b", "strong", "i", "em", "u", "ins", "s", "strike", "del",
|
|
102
|
+
"code", "pre", "a", "tg-spoiler", "br",
|
|
103
|
+
]);
|
|
104
|
+
const isHttp = (u) => /^https?:\/\//.test(String(u || ""));
|
|
105
|
+
|
|
106
|
+
// cardsToLegacy: текст = первая text-карточка с непустым text (или image.url -> photoUrl)
|
|
107
|
+
function cardsToLegacy(cards) {
|
|
108
|
+
cards = Array.isArray(cards) ? cards : [];
|
|
109
|
+
const firstImage = cards.find((c) => c && c.type === "image" && c.url);
|
|
110
|
+
const firstText = cards.find((c) => c && c.type === "text" && c.text);
|
|
111
|
+
const firstMedia = cards.find((c) => c && ["image", "video", "audio", "file"].includes(c.type) && c.text);
|
|
112
|
+
let text, photoUrl;
|
|
113
|
+
if (firstImage) { photoUrl = firstImage.url; text = firstImage.text || (firstText && firstText.text) || ""; }
|
|
114
|
+
else if (firstText) { text = firstText.text; }
|
|
115
|
+
else if (firstMedia) { text = firstMedia.text; }
|
|
116
|
+
if (!text && !photoUrl) text = "";
|
|
117
|
+
return { text: text || "", photoUrl: photoUrl || "" };
|
|
118
|
+
}
|
|
119
|
+
const blank = (s) => !s || !String(s).trim();
|
|
120
|
+
|
|
121
|
+
// --- id / структура ---
|
|
122
|
+
const ids = new Set();
|
|
123
|
+
for (const n of nodes) {
|
|
124
|
+
if (!n || !n.id) { errors.push(`Узел без id: ${JSON.stringify(n).slice(0, 80)}`); continue; }
|
|
125
|
+
if (ids.has(n.id)) errors.push(`Дубль id узла: ${n.id}`);
|
|
126
|
+
ids.add(n.id);
|
|
127
|
+
if (!UUID_RE.test(n.id)) errors.push(`id узла не UUID — бэкенд десериализует UUID, PUT даст HTTP 400: ${n.id} «${n.config?._title || n.type}»`);
|
|
128
|
+
}
|
|
129
|
+
const edgeIds = new Set();
|
|
130
|
+
for (const e of edges) {
|
|
131
|
+
if (e.id && edgeIds.has(e.id)) errors.push(`Дубль id ребра: ${e.id}`);
|
|
132
|
+
if (e.id) edgeIds.add(e.id);
|
|
133
|
+
if (!e.id || !UUID_RE.test(e.id)) errors.push(`id ребра не UUID — бэкенд десериализует UUID, PUT даст HTTP 400: ${e.id}`);
|
|
134
|
+
if (!ids.has(e.sourceNodeId)) errors.push(`Висячее ребро ${e.id}: нет sourceNodeId ${e.sourceNodeId}`);
|
|
135
|
+
if (!ids.has(e.targetNodeId)) errors.push(`Висячее ребро ${e.id}: нет targetNodeId ${e.targetNodeId}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// --- per-node ---
|
|
139
|
+
const triggers = nodes.filter((n) => String(n.type).startsWith("TRIGGER") || n.type === "BROADCAST_FILTER");
|
|
140
|
+
if (triggers.length === 0) errors.push("Нет ни одного триггера (TRIGGER_*) — graph не опубликуется.");
|
|
141
|
+
const broadcast = nodes.filter((n) => n.type === "BROADCAST_FILTER");
|
|
142
|
+
if (broadcast.length && triggers.length > broadcast.length) errors.push("BROADCAST_FILTER должен быть единственным триггером.");
|
|
143
|
+
|
|
144
|
+
for (const n of nodes) {
|
|
145
|
+
const c = n.config || {};
|
|
146
|
+
const who = `«${c._title || n.id}» (${n.type})`;
|
|
147
|
+
if (c.igReplyChannel != null && !["comment", "dm"].includes(String(c.igReplyChannel))) {
|
|
148
|
+
warns.push(`${who}: igReplyChannel должен быть "comment" или "dm" (получено "${c.igReplyChannel}")`);
|
|
149
|
+
}
|
|
150
|
+
if (String(c.igReplyChannel) === "dm" && platform !== "INSTAGRAM") {
|
|
151
|
+
warns.push(`${who}: igReplyChannel="dm" учитывается только у Instagram-ботов`);
|
|
152
|
+
}
|
|
153
|
+
switch (n.type) {
|
|
154
|
+
case "SEND_MESSAGE": {
|
|
155
|
+
const flat = !blank(c.text) || !blank(c.photoUrl);
|
|
156
|
+
const lg = Array.isArray(c.cards) && c.cards.length ? cardsToLegacy(c.cards) : { text: c.text || "", photoUrl: c.photoUrl || "" };
|
|
157
|
+
if (!flat) errors.push(`SEND_NO_TEXT: ${who} — пустой config.text/photoUrl.`);
|
|
158
|
+
else if (blank(lg.text) && blank(lg.photoUrl)) errors.push(`SEND_NO_TEXT: ${who} — после cardsToLegacy текст пуст (нет text-карточки с содержимым).`);
|
|
159
|
+
const t = String(c.text || "");
|
|
160
|
+
const lim = !blank(c.photoUrl) ? 1024 : 4096;
|
|
161
|
+
if (t.length > lim) errors.push(`SEND_TOO_LONG: ${who} = ${t.length} > ${lim}.`);
|
|
162
|
+
// HTML-безопасность (эвристика по тегам)
|
|
163
|
+
if (!blank(t) && String(c.parseMode || "").toUpperCase() === "HTML") {
|
|
164
|
+
const bad = [...t.matchAll(/<\/?([a-zA-Z][a-zA-Z0-9-]*)\b[^>]*>/g)]
|
|
165
|
+
.map((m) => m[1].toLowerCase()).filter((tag) => !HTML_OK_TAGS.has(tag));
|
|
166
|
+
if (bad.length) errors.push(`HTML_NOT_SAFE: ${who} — неразрешённые теги: ${[...new Set(bad)].join(", ")}. Разрешено: ${[...HTML_OK_TAGS].join(",")}.`);
|
|
167
|
+
}
|
|
168
|
+
// режим «Вопрос»
|
|
169
|
+
if (c.awaitReply === true) {
|
|
170
|
+
if (!c.saveTo || !VAR_RE.test(c.saveTo)) errors.push(`SEND_BAD_SAVE_TO: ${who} — awaitReply требует saveTo ∈ [a-z_][a-z0-9_]{0,63}.`);
|
|
171
|
+
if (c.validator === "REGEX") {
|
|
172
|
+
if (blank(c.regex)) errors.push(`SEND_BAD_REGEX: ${who} — REGEX-валидатор требует непустой regex.`);
|
|
173
|
+
else { try { new RegExp(c.regex); } catch (e) { errors.push(`SEND_BAD_REGEX: ${who} — ${e.message}.`); } }
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// Кнопки-выборы: если от кнопки идёт ребро btn_N, value ДОЛЖЕН быть пустым —
|
|
177
|
+
// бот сам генерит callback_data n:<id>:<idx>; непустой value → нажатие даёт NO_MATCH.
|
|
178
|
+
{
|
|
179
|
+
const btnIdx = new Set(
|
|
180
|
+
edges.filter((e) => e.sourceNodeId === n.id && /^btn_\d+$/.test(String(e.sourceHandle || "")))
|
|
181
|
+
.map((e) => Number(String(e.sourceHandle).slice(4)))
|
|
182
|
+
);
|
|
183
|
+
(Array.isArray(c.buttons) ? c.buttons : []).flat().forEach((b, i) => {
|
|
184
|
+
if (!b) return;
|
|
185
|
+
if (String(b.kind) === "CALLBACK") {
|
|
186
|
+
const hasVal = !blank(b.value);
|
|
187
|
+
if (hasVal && btnIdx.has(i)) errors.push(`BTN_VALUE_WITH_EDGE: ${who} — кнопка-выбор #${i} («${b.text || ""}») имеет ребро btn_${i} и непустой value «${b.value}»; оставь value пустым, иначе нажатие даст NO_MATCH.`);
|
|
188
|
+
else if (hasVal) warns.push(`${who}: у CALLBACK-кнопки #${i} непустой value «${b.value}» без ребра btn_${i} — это legacy-кнопка под TRIGGER_CALLBACK; для кнопки-выбора нужен пустой value + ребро btn_${i}.`);
|
|
189
|
+
}
|
|
190
|
+
if (b.color) {
|
|
191
|
+
const cc = String(b.color).trim();
|
|
192
|
+
const okColor = ["", "primary", "success", "danger"].includes(cc.toLowerCase())
|
|
193
|
+
|| ["#2EA6FF", "#34C759", "#FF3B30"].includes(cc.toUpperCase());
|
|
194
|
+
if (!okColor) warns.push(`${who}: стиль кнопки #${i} «${b.color}» Telegram не рендерит — только ""(дефолт)/primary(#2EA6FF)/success(#34C759)/danger(#FF3B30).`);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
case "SEND_PHOTO":
|
|
201
|
+
if (blank(c.photoUrl)) errors.push(`PHOTO_NO_URL: ${who} — нужен photoUrl.`);
|
|
202
|
+
if (!blank(c.caption) && String(c.caption).length > 1024) errors.push(`PHOTO_CAPTION_TOO_LONG: ${who} — подпись > 1024.`);
|
|
203
|
+
break;
|
|
204
|
+
case "TRIGGER_COMMAND":
|
|
205
|
+
if (blank(c.command)) errors.push(`${who}: нужен command.`);
|
|
206
|
+
break;
|
|
207
|
+
case "TRIGGER_CALLBACK":
|
|
208
|
+
if (blank(c.value)) errors.push(`${who}: нужен value.`);
|
|
209
|
+
if (c.matchMode && !["EQUALS", "STARTS_WITH"].includes(c.matchMode)) errors.push(`${who}: matchMode ∈ {EQUALS,STARTS_WITH}.`);
|
|
210
|
+
break;
|
|
211
|
+
case "TRIGGER_TEXT":
|
|
212
|
+
if (c.matchMode && c.matchMode !== "ANY" && blank(c.value)) errors.push(`${who}: для matchMode≠ANY нужен value.`);
|
|
213
|
+
break;
|
|
214
|
+
case "TRIGGER_TG_EVENT": {
|
|
215
|
+
// Рантайм размечает только эти апдейты (FlowExecutor.buildContext) — на остальных
|
|
216
|
+
// триггер молчит, а выглядит настроенным.
|
|
217
|
+
const LIVE = new Set(["message", "edited_message", "callback_query", "channel_post",
|
|
218
|
+
"edited_channel_post", "chat_member", "chat_join_request", "my_chat_member",
|
|
219
|
+
"message_reaction", "message_reaction_count", "chat_boost", "removed_chat_boost",
|
|
220
|
+
"poll", "poll_answer", "inline_query", "chosen_inline_result", "shipping_query",
|
|
221
|
+
"pre_checkout_query"]);
|
|
222
|
+
const DEAD = new Set(["business_connection", "business_message", "edited_business_message",
|
|
223
|
+
"deleted_business_messages", "chat_shared", "users_shared", "write_access_allowed",
|
|
224
|
+
"purchased_paid_media"]);
|
|
225
|
+
const ev = String(c.event || "");
|
|
226
|
+
if (blank(ev)) errors.push(`TG_EVENT_NO_EVENT: ${who} — нужен event (напр. chat_member, message_reaction).`);
|
|
227
|
+
else if (DEAD.has(ev)) errors.push(`TG_EVENT_UNSUPPORTED: ${who} — событие «${ev}» рантайм не разбирает, триггер не сработает.`);
|
|
228
|
+
else if (!LIVE.has(ev)) warns.push(`${who}: событие «${ev}» не из списка поддержанных — проверь, что рантайм его размечает.`);
|
|
229
|
+
// Фильтр по тексту есть только у событий с текстом сообщения.
|
|
230
|
+
const TEXTY = new Set(["message", "edited_message", "channel_post", "edited_channel_post"]);
|
|
231
|
+
const f = c.filter && typeof c.filter === "object" ? c.filter : {};
|
|
232
|
+
if (!blank(f.text) && !TEXTY.has(ev)) warns.push(`${who}: фильтр по тексту у события «${ev}» игнорируется — текста в апдейте нет.`);
|
|
233
|
+
if (!blank(f.status) && ev !== "chat_member" && ev !== "my_chat_member") warns.push(`${who}: фильтр по статусу работает только у chat_member/my_chat_member.`);
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
case "TRIGGER_SITE_FORM":
|
|
237
|
+
// Бэкенд (GraphValidator.validateSiteFormTrigger): siteId обязателен и должен быть UUID своего сайта.
|
|
238
|
+
if (blank(c.siteId)) errors.push(`SITE_FORM_NO_SITE: ${who} — нужен siteId (id сайта из site_list).`);
|
|
239
|
+
else if (!UUID_RE.test(String(c.siteId).trim())) errors.push(`SITE_FORM_NO_SITE: ${who} — siteId «${c.siteId}» не UUID сайта (site_list).`);
|
|
240
|
+
if (c.formId != null && typeof c.formId !== "string") errors.push(`${who}: formId — строка (id формы из site_get) или пусто = любая форма.`);
|
|
241
|
+
break;
|
|
242
|
+
case "TRIGGER_COMMENT":
|
|
243
|
+
errors.push(`TRIGGER_COMMENT_DEAD: ${who} — этот тип триггера не сработает никогда (рантайм не выставляет event=comment). Для Instagram используй TRIGGER_IG_COMMENT.`);
|
|
244
|
+
break;
|
|
245
|
+
case "ASK_QUESTION":
|
|
246
|
+
if (blank(c.promptText)) errors.push(`${who}: нужен promptText.`);
|
|
247
|
+
if (c.saveTo && !VAR_RE.test(c.saveTo)) errors.push(`${who}: saveTo «${c.saveTo}» не матчит [a-z_][a-z0-9_]{0,63}.`);
|
|
248
|
+
break;
|
|
249
|
+
case "CALL_WEBHOOK":
|
|
250
|
+
if (blank(c.url) || !/^https?:\/\//.test(c.url)) errors.push(`${who}: url обязателен и http(s)://.`);
|
|
251
|
+
break;
|
|
252
|
+
case "DELAY":
|
|
253
|
+
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).`); }
|
|
254
|
+
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.`); }
|
|
255
|
+
else if (c.kind === "UNTIL") {
|
|
256
|
+
if (blank(c.isoTimestamp)) errors.push(`${who}: UNTIL требует isoTimestamp (ISO-8601 или шаблон {{var.x}}). isoDate+time рантайм НЕ читает.`);
|
|
257
|
+
else if (!String(c.isoTimestamp).includes("{{") && isNaN(Date.parse(c.isoTimestamp))) errors.push(`DELAY_BAD_TIMESTAMP: ${who} — isoTimestamp не ISO-8601 и не шаблон {{var.x}}.`);
|
|
258
|
+
checkTimeExtras(c, "DELAY", who);
|
|
259
|
+
}
|
|
260
|
+
else errors.push(`${who}: kind ∈ {FIXED,TOMORROW,UNTIL}.`);
|
|
261
|
+
break;
|
|
262
|
+
case "BRANCH":
|
|
263
|
+
if (!Array.isArray(c.cases) || c.cases.length === 0) errors.push(`${who}: нужен хотя бы один case.`);
|
|
264
|
+
break;
|
|
265
|
+
case "SWITCH": {
|
|
266
|
+
// Зеркало GraphValidator.validateSwitch(): развилка по значению (спека §5.3).
|
|
267
|
+
if (blank(c.expression)) errors.push(`SWITCH_NO_EXPRESSION: ${who} — нужно expression (обычно {{var.x}}).`);
|
|
268
|
+
if (!Array.isArray(c.cases) || c.cases.length === 0) {
|
|
269
|
+
errors.push(`SWITCH_NO_CASES: ${who} — нужен хотя бы один case {id, value}.`);
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
const seenValues = new Set();
|
|
273
|
+
for (const cs of c.cases) {
|
|
274
|
+
const value = String((cs && cs.value) ?? "").trim();
|
|
275
|
+
if (!value) errors.push(`SWITCH_EMPTY_VALUE: ${who} — у случая «${cs && cs.id}» пустое value.`);
|
|
276
|
+
// Рантайм сравнивает без учёта регистра → второй такой случай недостижим.
|
|
277
|
+
else if (seenValues.has(value.toLowerCase())) errors.push(`SWITCH_DUPLICATE_VALUE: ${who} — значение «${value}» повторяется.`);
|
|
278
|
+
else seenValues.add(value.toLowerCase());
|
|
279
|
+
}
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
case "STOP_AND_ERROR":
|
|
283
|
+
// message необязателен (рантайм подставит свой текст), но безымянная ошибка в журнале бесполезна.
|
|
284
|
+
if (blank(c.message)) warns.push(`${who}: STOP_AND_ERROR без message — в журнале будет обезличенное «Сценарий остановлен с ошибкой».`);
|
|
285
|
+
break;
|
|
286
|
+
case "CONDITION": {
|
|
287
|
+
if (c.match !== "ALL" && c.match !== "ANY") errors.push(`CONDITION_BAD_MATCH: ${who} — match ∈ {ALL,ANY}.`);
|
|
288
|
+
if (!Array.isArray(c.conditions) || c.conditions.length === 0) {
|
|
289
|
+
errors.push(`CONDITION_EMPTY: ${who} — нужно хотя бы одно условие.`);
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
c.conditions.forEach((cond, i) => {
|
|
293
|
+
if (!cond || typeof cond !== "object") { errors.push(`${who}: условие #${i + 1} — не объект.`); return; }
|
|
294
|
+
const kind = cond.kind;
|
|
295
|
+
// Бэкенд блокирует публикацию только для TAG.value и VARIABLE.key:
|
|
296
|
+
if (kind === "TAG" && !TAG_RE.test(String(cond.value || ""))) errors.push(`CONDITION_BAD_TAG: ${who} — TAG.value ∈ [a-z0-9_-]{1,64}.`);
|
|
297
|
+
if (kind === "VARIABLE" && !VAR_RE.test(String(cond.key || ""))) errors.push(`CONDITION_BAD_KEY: ${who} — VARIABLE.key ∈ [a-z_][a-z0-9_]{0,63}.`);
|
|
298
|
+
// Остальное — подсказки (рантайм бэкенда отдаст false, но публикацию не сорвёт):
|
|
299
|
+
if (!COND_OPS[kind]) warns.push(`${who}: неизвестный kind «${kind}» в условии #${i + 1} — рантайм даст false.`);
|
|
300
|
+
else if (cond.op && !COND_OPS[kind].includes(cond.op)) warns.push(`${who}: op «${cond.op}» не из {${COND_OPS[kind].join(",")}} для ${kind} — рантайм даст false.`);
|
|
301
|
+
if (kind === "SUBSCRIBED" && !/^-?\d+$/.test(String(cond.key || "").trim())) warns.push(`${who}: SUBSCRIBED.key должен быть числовым id канала — иначе false (и бот должен быть админом канала).`);
|
|
302
|
+
if (kind === "UTM" && !cond.key) warns.push(`${who}: UTM без key — рантайм даст false (ожидается source/medium/campaign/content/term).`);
|
|
303
|
+
if (kind === "LINK_CLICKED") {
|
|
304
|
+
const target = nodes.find((x) => x.id === cond.key);
|
|
305
|
+
const tracked = target && (((target.config || {}).buttons) || []).flat().some((b) => b && b.kind === "URL" && b.track === true);
|
|
306
|
+
if (!cond.key || !target) warns.push(`${who}: LINK_CLICKED.key должен быть id шага с отслеживаемой URL-кнопкой — иначе false.`);
|
|
307
|
+
else if (!tracked) warns.push(`${who}: у шага «${(target.config || {})._title || target.id}» из LINK_CLICKED.key нет URL-кнопки с track:true — рантайм даст false.`);
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
break;
|
|
311
|
+
}
|
|
312
|
+
case "SET_VARIABLE":
|
|
313
|
+
if (!VAR_RE.test(String(c.key || ""))) errors.push(`VAR_BAD_KEY: ${who} — key ∈ [a-z_][a-z0-9_]{0,63}.`);
|
|
314
|
+
break;
|
|
315
|
+
case "ADD_TAG":
|
|
316
|
+
case "REMOVE_TAG":
|
|
317
|
+
if (!TAG_RE.test(String(c.tag || ""))) errors.push(`TAG_BAD_NAME: ${who} — tag ∈ [a-z0-9_-]{1,64}.`);
|
|
318
|
+
break;
|
|
319
|
+
case "FORMULA":
|
|
320
|
+
if (blank(c.expression)) errors.push(`FORMULA_NO_EXPRESSION: ${who} — нужен expression.`);
|
|
321
|
+
if (!VAR_RE.test(String(c.saveTo || ""))) errors.push(`FORMULA_BAD_SAVE_TO: ${who} — saveTo ∈ [a-z_][a-z0-9_]{0,63}.`);
|
|
322
|
+
break;
|
|
323
|
+
case "SCHEDULE": {
|
|
324
|
+
// Зеркало GraphValidator.validateSchedule (G2): YYYY-MM-DD (+ time), ISO-8601 дата-время или шаблон {{var.x}}.
|
|
325
|
+
const iso = String(c.isoDate ?? "").trim();
|
|
326
|
+
const template = iso.includes("{{");
|
|
327
|
+
const dateOnly = /^\d{4}-\d{2}-\d{2}$/.test(iso);
|
|
328
|
+
if (!iso || (!template && isNaN(Date.parse(iso)))) errors.push(`SCHEDULE_BAD_DATE: ${who} — isoDate = YYYY-MM-DD, ISO-8601 дата-время или шаблон {{var.x}}.`);
|
|
329
|
+
if ((!blank(c.time) || (dateOnly && !template)) && !/^([01]\d|2[0-3]):[0-5]\d$/.test(String(c.time || ""))) errors.push(`SCHEDULE_BAD_TIME: ${who} — time = HH:mm.`);
|
|
330
|
+
checkTimeExtras(c, "SCHEDULE", who);
|
|
331
|
+
break;
|
|
332
|
+
}
|
|
333
|
+
case "TRIGGER_SCHEDULE": {
|
|
334
|
+
// Зеркало GraphValidator.validateScheduleTrigger: cron из 5 полей, известная зона.
|
|
335
|
+
const parts = String(c.cron ?? "").trim().split(/\s+/).filter(Boolean);
|
|
336
|
+
if (parts.length !== 5) errors.push(`SCHEDULE_TRIGGER_BAD_CRON: ${who} — cron из 5 полей: минута час день месяц день-недели (например «0 9 * * 1-5»).`);
|
|
337
|
+
if (!blank(c.timezone) && !validZone(String(c.timezone).trim())) errors.push(`SCHEDULE_TRIGGER_BAD_TIMEZONE: ${who} — неизвестный часовой пояс «${c.timezone}».`);
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
case "ACTIONS": {
|
|
341
|
+
if (!Array.isArray(c.actions) || c.actions.length === 0) { errors.push(`ACTIONS_EMPTY: ${who} — нужен непустой actions[].`); break; }
|
|
342
|
+
// Счёт ставит блок на паузу до вебхука кассы, а возобновиться с середины списка движок
|
|
343
|
+
// не умеет — значит действия после счёта молча не выполнятся. Зеркало GraphValidator.
|
|
344
|
+
{
|
|
345
|
+
const invIdx = c.actions.map((a, i) => (a && a.kind === "issue_invoice" ? i : -1)).filter((i) => i >= 0);
|
|
346
|
+
if (invIdx.length > 1) {
|
|
347
|
+
errors.push(`INVOICE_DUPLICATE: ${who} — в блоке больше одного счёта, непонятно какой ждать.`);
|
|
348
|
+
}
|
|
349
|
+
if (invIdx.length === 1 && invIdx[0] !== c.actions.length - 1) {
|
|
350
|
+
errors.push(`INVOICE_NOT_LAST: ${who} — счёт должен быть последним действием в блоке: после него блок ждёт оплату.`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
c.actions.forEach((a, i) => {
|
|
354
|
+
if (!a || typeof a !== "object") { errors.push(`${who}: действие #${i + 1} — не объект.`); return; }
|
|
355
|
+
if (blank(a.kind)) { errors.push(`ACTION_UNKNOWN_KIND: ${who} — у действия #${i + 1} нет kind.`); return; }
|
|
356
|
+
if (!ACTION_KINDS.has(a.kind)) {
|
|
357
|
+
// Действия Integration Core (actions[].kind из integration_catalog) бэкенд узнаёт по реестру провайдеров —
|
|
358
|
+
// оффлайн их не проверить; опечатку поймает publish_graph (ACTION_UNKNOWN_KIND).
|
|
359
|
+
warns.push(`${who}: kind «${a.kind}» (#${i + 1}) не из встроенного списка — допустим, только если он есть в actions[].kind провайдера в integration_catalog.`);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (["booking_slots", "booking_book"].includes(a.kind) && blank(a.calendarId))
|
|
363
|
+
errors.push(`ACTION_BOOKING_NO_CALENDAR: ${who} — ${a.kind} требует calendarId (booking_calendar_list).`);
|
|
364
|
+
if (["booking_slots", "booking_book"].includes(a.kind) && !blank(a.saveTo) && !VAR_RE.test(String(a.saveTo)))
|
|
365
|
+
errors.push(`ACTION_BAD_KEY: ${who} — ${a.kind}.saveTo ∈ [a-z_][a-z0-9_]{0,63}.`);
|
|
366
|
+
if (["add_tag", "remove_tag", "autoflow_add", "autoflow_remove"].includes(a.kind) && !TAG_RE.test(String(a.tag || "")))
|
|
367
|
+
errors.push(`ACTION_BAD_TAG: ${who} — ${a.kind}.tag ∈ [a-z0-9_-]{1,64}.`);
|
|
368
|
+
if (a.kind === "set_field" && !VAR_RE.test(String(a.key || "")))
|
|
369
|
+
errors.push(`ACTION_BAD_KEY: ${who} — set_field.key ∈ [a-z_][a-z0-9_]{0,63}.`);
|
|
370
|
+
if (["subscriber_webhook", "external_request"].includes(a.kind) && !isHttp(a.url))
|
|
371
|
+
errors.push(`ACTION_BAD_URL: ${who} — ${a.kind}.url должен быть http(s)://.`);
|
|
372
|
+
if (a.kind === "issue_invoice") {
|
|
373
|
+
if (blank(a.connectionId)) errors.push(`YK_NO_CONNECTION: ${who} — нужен connectionId подключения ЮKassa (list_integrations).`);
|
|
374
|
+
const amount = String(a.amount == null ? "" : a.amount);
|
|
375
|
+
if (blank(amount)) errors.push(`YK_NO_AMOUNT: ${who} — нужна amount.`);
|
|
376
|
+
else if (!amount.includes("{{")) {
|
|
377
|
+
const n = Number(amount.trim().replace(",", "."));
|
|
378
|
+
if (!Number.isFinite(n)) errors.push(`YK_BAD_AMOUNT: ${who} — amount должна быть числом.`);
|
|
379
|
+
else if (n <= 0) errors.push(`YK_BAD_AMOUNT: ${who} — amount должна быть > 0.`);
|
|
380
|
+
}
|
|
381
|
+
if (blank(a.description)) errors.push(`YK_NO_DESC: ${who} — нужно description (за что платят).`);
|
|
382
|
+
if (a.timeoutMinutes != null) {
|
|
383
|
+
const t = Number(a.timeoutMinutes);
|
|
384
|
+
if (!Number.isFinite(t) || t < 1 || t > 1440) errors.push(`YK_BAD_TIMEOUT: ${who} — timeoutMinutes ∈ [1, 1440].`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
if (a.kind === "external_request") {
|
|
388
|
+
// Имя переменной бэкенд не чистит: кривое имя запишется, а {{var.<имя>}} его не найдёт.
|
|
389
|
+
for (const field of ["saveStatusTo", "saveBodyTo"]) {
|
|
390
|
+
if (!blank(a[field]) && !VAR_RE.test(String(a[field])))
|
|
391
|
+
errors.push(`ACTION_BAD_SAVE_TO: ${who} — ${field} «${a[field]}» ∈ [a-z_][a-z0-9_]{0,63}.`);
|
|
392
|
+
}
|
|
393
|
+
(Array.isArray(a.extract) ? a.extract : []).forEach((ex, j) => {
|
|
394
|
+
if (blank(ex?.path) || blank(ex?.saveTo))
|
|
395
|
+
errors.push(`ACTION_BAD_EXTRACT: ${who} — извлечение #${j + 1} требует path и saveTo.`);
|
|
396
|
+
else if (!VAR_RE.test(String(ex.saveTo)))
|
|
397
|
+
errors.push(`ACTION_BAD_SAVE_TO: ${who} — extract[${j}].saveTo ∈ [a-z_][a-z0-9_]{0,63}.`);
|
|
398
|
+
});
|
|
399
|
+
if (a.timeoutMs != null && (Number(a.timeoutMs) < 500 || Number(a.timeoutMs) > 30000))
|
|
400
|
+
warns.push(`${who}: timeoutMs ${a.timeoutMs} будет прижат к диапазону 500…30000 мс.`);
|
|
401
|
+
}
|
|
402
|
+
if (a.kind === "call_scenario") {
|
|
403
|
+
// Зеркало GraphValidator.validateScenarioCalls. Владение целью (сценарий этого же бота) проверит publish_graph.
|
|
404
|
+
if (i !== c.actions.length - 1)
|
|
405
|
+
errors.push(`CALL_SCENARIO_NOT_LAST: ${who} — переход в сценарий должен быть последним действием в блоке.`);
|
|
406
|
+
if (a.mode != null && String(a.mode) !== "goto")
|
|
407
|
+
errors.push(`CALL_SCENARIO_BAD_MODE: ${who} — поддерживается только mode «goto» (перейти).`);
|
|
408
|
+
(Array.isArray(a.variables) ? a.variables : []).forEach((v, j) => {
|
|
409
|
+
const key = String(v?.key ?? "");
|
|
410
|
+
if (!VAR_RE.test(key) || key.startsWith("_"))
|
|
411
|
+
errors.push(`ACTION_BAD_KEY: ${who} — call_scenario.variables[${j}].key ∈ [a-z][a-z0-9_]{0,63} (служебные _* нельзя).`);
|
|
412
|
+
});
|
|
413
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(a.graphId ?? "").trim()))
|
|
414
|
+
errors.push(`CALL_SCENARIO_NO_TARGET: ${who} — нужен graphId сценария этого же бота (list_graphs).`);
|
|
415
|
+
else if (g.id && String(a.graphId).trim().toLowerCase() === String(g.id).trim().toLowerCase())
|
|
416
|
+
errors.push(`CALL_SCENARIO_SELF: ${who} — сценарий не может переходить сам в себя.`);
|
|
417
|
+
}
|
|
418
|
+
if (a.kind === "gsheets_send" && (!a.googleEmail || !a.spreadsheetId || !Array.isArray(a.cells) || a.cells.length === 0))
|
|
419
|
+
errors.push(`ACTION_GSHEETS_INCOMPLETE: ${who} — gsheets_send требует googleEmail + spreadsheetId + непустой cells[] (Google-аккаунт подключается в вебе /bots, не через MCP).`);
|
|
420
|
+
if (a.kind === "send_email") {
|
|
421
|
+
// «Отправить письмо» (TASK-4): один получатель — email не массив, список адресов сюда не идёт.
|
|
422
|
+
if (blank(a.email)) errors.push(`ACTION_SEND_EMAIL_NO_RECIPIENT: ${who} — send_email требует email (адрес или {{var.*}}).`);
|
|
423
|
+
else if (!String(a.email).includes("{{") && !EMAIL_RE.test(String(a.email).trim()))
|
|
424
|
+
errors.push(`ACTION_SEND_EMAIL_BAD_EMAIL: ${who} — email «${a.email}» не похож на адрес.`);
|
|
425
|
+
if (blank(a.text)) errors.push(`ACTION_SEND_EMAIL_NO_TEXT: ${who} — send_email требует text (текст письма).`);
|
|
426
|
+
if (!blank(a.replyTo) && !String(a.replyTo).includes("{{") && !EMAIL_RE.test(String(a.replyTo).trim()))
|
|
427
|
+
errors.push(`ACTION_SEND_EMAIL_BAD_REPLYTO: ${who} — replyTo «${a.replyTo}» не похож на адрес.`);
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
break;
|
|
431
|
+
}
|
|
432
|
+
case "AI_REPLY": {
|
|
433
|
+
if (String(c.mode) === "agent") {
|
|
434
|
+
// Режим агента — отдельный набор ключей конфига, старые проверки (userPromptTemplate
|
|
435
|
+
// и т.п.) к нему не относятся. Зеркало GraphValidator.validateAiAgent (Задача 12,
|
|
436
|
+
// CARRY): обязано ловить то же самое, что и бэкенд, и не быть строже него.
|
|
437
|
+
const outgoing = edges.filter((e) => e.sourceNodeId === n.id);
|
|
438
|
+
const strict = boolConfig(c.strict, true);
|
|
439
|
+
if (strict && blank(c.knowledgeBaseId)) {
|
|
440
|
+
errors.push(`AGENT_NO_KNOWLEDGE_BASE: ${who} — в строгом режиме без базы знаний агент ответит «не знаю» на всё.`);
|
|
441
|
+
}
|
|
442
|
+
if (Array.isArray(c.actions)) {
|
|
443
|
+
const seenIds = new Set();
|
|
444
|
+
for (const a of c.actions) {
|
|
445
|
+
if (!a) continue;
|
|
446
|
+
const id = a.id == null ? null : String(a.id);
|
|
447
|
+
const title = a.title == null ? null : String(a.title);
|
|
448
|
+
// Битую запись (пустой id ИЛИ title) рантайм пропускает целиком — ругаться на
|
|
449
|
+
// её ребро/дубль было бы ложной ошибкой (зеркало FlowExecutor.agentActions).
|
|
450
|
+
if (blank(id) || blank(title)) continue;
|
|
451
|
+
if (seenIds.has(id)) {
|
|
452
|
+
errors.push(`AGENT_DUPLICATE_ACTION_ID: ${who} — действие с id '${id}' повторяется — выбор модели станет недетерминированным.`);
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
seenIds.add(id);
|
|
456
|
+
if (!outgoing.some((e) => e.sourceHandle === `action_${id}`)) {
|
|
457
|
+
errors.push(`AGENT_ACTION_NOT_CONNECTED: ${who} — действие '${id}' не подключено к ветке — модель может его выбрать, а идти будет некуда.`);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
const prompt = String(c.systemPrompt || "");
|
|
462
|
+
if (prompt.length > 30000) {
|
|
463
|
+
errors.push(`AGENT_PROMPT_TOO_LONG: ${who} — инструкция агента длиннее 30 000 символов.`);
|
|
464
|
+
} else if (prompt.length > 10000) {
|
|
465
|
+
// Мягкий порог — только warn: у MCP-сборщика нет инспектора, где владелец увидел бы
|
|
466
|
+
// это предупреждение живьём (веб-редактор красит счётчик символов сам).
|
|
467
|
+
warns.push(`${who}: инструкция агента длиннее 10 000 символов — модель хуже держит середину, и каждый ответ дороже.`);
|
|
468
|
+
}
|
|
469
|
+
if (Array.isArray(c.extract)) {
|
|
470
|
+
const seenVars = new Set();
|
|
471
|
+
for (const f of c.extract) {
|
|
472
|
+
// Зеркало GraphValidator:1070 (!(o instanceof Map) → continue): элемент не объектом
|
|
473
|
+
// (строка, число, массив) рантайм молча пропускает — публикация проходит; ругаться
|
|
474
|
+
// на него было бы строже бэка (M4, фикс-раунд 1).
|
|
475
|
+
if (!f || typeof f !== "object" || Array.isArray(f)) continue;
|
|
476
|
+
const variable = f.variable == null ? null : String(f.variable);
|
|
477
|
+
const description = f.description == null ? null : String(f.description);
|
|
478
|
+
const problems = [];
|
|
479
|
+
if (!variable || !VAR_RE.test(variable)) problems.push("имя переменной должно соответствовать [a-z_][a-z0-9_]{0,63}");
|
|
480
|
+
if (blank(description)) problems.push("описание не должно быть пустым");
|
|
481
|
+
if (variable === "ai_summary") problems.push("имя 'ai_summary' занято сводкой агента");
|
|
482
|
+
if (variable && seenVars.has(variable)) problems.push("имя переменной повторяется");
|
|
483
|
+
if (variable) seenVars.add(variable);
|
|
484
|
+
if (problems.length) {
|
|
485
|
+
errors.push(`AGENT_BAD_EXTRACT: ${who} — поле извлечения${variable ? ` '${variable}'` : ""}: ${problems.join("; ")}.`);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
// Ветка "не знаю" не подключена — предупреждение, но только для узла, достижимого от
|
|
490
|
+
// триггера (зеркало GraphValidator.draftWarnings:394-402): `reach` считается позже общего
|
|
491
|
+
// per-node прохода, поэтому сама проверка — в отдельном проходе ниже.
|
|
492
|
+
} else {
|
|
493
|
+
if (blank(c.userPromptTemplate)) errors.push(`AI_NO_PROMPT: ${who} — нужен userPromptTemplate.`);
|
|
494
|
+
if (typeof c.temperature === "number" && (c.temperature < 0 || c.temperature > 2)) errors.push(`AI_BAD_TEMPERATURE: ${who} — temperature ∈ [0.0, 2.0].`);
|
|
495
|
+
if (c.sendToUser !== true && blank(c.saveTo)) errors.push(`AI_NO_OUTPUT: ${who} — нужен sendToUser:true или saveTo.`);
|
|
496
|
+
}
|
|
497
|
+
break;
|
|
498
|
+
}
|
|
499
|
+
case "PAYMENT_LINK": {
|
|
500
|
+
const u = String(c.paymentUrl || "");
|
|
501
|
+
if (blank(u)) errors.push(`PAY_NO_URL: ${who} — нужен paymentUrl.`);
|
|
502
|
+
else if (!isHttp(u) && !u.startsWith("{{")) errors.push(`PAY_BAD_SCHEME: ${who} — paymentUrl = http(s):// или {{var.x}}.`);
|
|
503
|
+
break;
|
|
504
|
+
}
|
|
505
|
+
case "YOOKASSA_PAYMENT": {
|
|
506
|
+
// Зеркало GraphValidator.validateYooKassaPayment(): без кассы платёж создать нечем,
|
|
507
|
+
// и раньше это вылезало только в рантайме — перед клиентом, на шаге оплаты.
|
|
508
|
+
if (blank(c.connectionId)) {
|
|
509
|
+
errors.push(`YK_NO_CONNECTION: ${who} — нужен connectionId подключения ЮKassa (list_integrations).`);
|
|
510
|
+
}
|
|
511
|
+
const amount = String(c.amount == null ? "" : c.amount);
|
|
512
|
+
if (blank(amount)) {
|
|
513
|
+
errors.push(`YK_NO_AMOUNT: ${who} — нужна amount.`);
|
|
514
|
+
} else if (!amount.includes("{{")) {
|
|
515
|
+
const n = Number(amount.trim().replace(",", "."));
|
|
516
|
+
if (!Number.isFinite(n)) errors.push(`YK_BAD_AMOUNT: ${who} — amount должна быть числом.`);
|
|
517
|
+
else if (n <= 0) errors.push(`YK_BAD_AMOUNT: ${who} — amount должна быть > 0.`);
|
|
518
|
+
}
|
|
519
|
+
if (blank(c.description)) errors.push(`YK_NO_DESC: ${who} — нужно description (за что платят).`);
|
|
520
|
+
if (c.timeoutMinutes != null) {
|
|
521
|
+
const t = Number(c.timeoutMinutes);
|
|
522
|
+
if (!Number.isFinite(t) || t < 1 || t > 1440) {
|
|
523
|
+
errors.push(`YK_BAD_TIMEOUT: ${who} — timeoutMinutes ∈ [1, 1440].`);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
break;
|
|
527
|
+
}
|
|
528
|
+
// TRIGGER_PAYMENT намеренно без проверок: отсутствие фильтров (minAmount /
|
|
529
|
+
// descriptionContains) — рискованно, но для бэкенда это валидный граф. Ругаться здесь
|
|
530
|
+
// строже, чем GraphValidator, значит расходиться с сервером: сценарий падал бы у
|
|
531
|
+
// сборщика и публиковался бы вручную. Риск описан в schema.md.
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// --- достижимость от триггеров ---
|
|
536
|
+
const adj = {};
|
|
537
|
+
for (const e of edges) (adj[e.sourceNodeId] = adj[e.sourceNodeId] || []).push(e.targetNodeId);
|
|
538
|
+
const reach = new Set();
|
|
539
|
+
const stack = triggers.map((t) => t.id);
|
|
540
|
+
while (stack.length) { const x = stack.pop(); if (reach.has(x)) continue; reach.add(x); (adj[x] || []).forEach((y) => stack.push(y)); }
|
|
541
|
+
for (const n of nodes) {
|
|
542
|
+
if (String(n.type).startsWith("TRIGGER") || n.type === "BROADCAST_FILTER") continue;
|
|
543
|
+
if (!reach.has(n.id)) errors.push(`Недостижимый узел от триггера: «${n.config?._title || n.id}» (${n.type}).`);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// AI-агент: ветка "не знаю" не подключена — предупреждение, зеркало GraphValidator.draftWarnings
|
|
547
|
+
// (394-402): только для узла, реально достижимого от триггера (`reach` посчитан только что выше).
|
|
548
|
+
for (const n of nodes) {
|
|
549
|
+
const c = n.config || {};
|
|
550
|
+
if (n.type !== "AI_REPLY" || String(c.mode) !== "agent" || !reach.has(n.id)) continue;
|
|
551
|
+
const hasUnknownEdge = edges.some((e) => e.sourceNodeId === n.id && e.sourceHandle === "unknown");
|
|
552
|
+
if (!hasUnknownEdge) {
|
|
553
|
+
const who = `«${c._title || n.id}» (${n.type})`;
|
|
554
|
+
warns.push(`AGENT_UNKNOWN_NOT_CONNECTED: ${who} — ветка «не знаю» не подключена — диалог упрётся в тупик там, где нужен человек.`);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// --- синхронные циклы (цикл без DELAY/ASK_QUESTION/SCHEDULE = ошибка) ---
|
|
559
|
+
const waitTypes = new Set(["ASK_QUESTION", "DELAY", "SCHEDULE"]);
|
|
560
|
+
const typeById = Object.fromEntries(nodes.map((n) => [n.id, n.type]));
|
|
561
|
+
const color = {}; // 0=white,1=gray,2=black
|
|
562
|
+
function dfs(u, pathHasWaitAtEdgeInto) {
|
|
563
|
+
color[u] = 1;
|
|
564
|
+
for (const v of adj[u] || []) {
|
|
565
|
+
if (color[v] === 1) {
|
|
566
|
+
// нашли цикл u->...->v; ок только если в цикле есть wait-узел
|
|
567
|
+
// упрощённо: если ни u, ни v не wait — предупредим как потенциальный синхронный цикл
|
|
568
|
+
if (!waitTypes.has(typeById[u]) && !waitTypes.has(typeById[v]))
|
|
569
|
+
warns.push(`Возможный синхронный цикл рядом с ${v} — убедись, что в петле есть DELAY/ASK_QUESTION/SCHEDULE.`);
|
|
570
|
+
} else if (color[v] !== 2) {
|
|
571
|
+
dfs(v);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
color[u] = 2;
|
|
575
|
+
}
|
|
576
|
+
for (const n of nodes) if (!color[n.id]) dfs(n.id);
|
|
577
|
+
|
|
578
|
+
// ============================================================
|
|
579
|
+
// --- IG-специфические проверки (только для INSTAGRAM) ---
|
|
580
|
+
// Зеркало GraphValidator.platformErrors() (Java).
|
|
581
|
+
// IG_ALLOWED_NODES: TRIGGER_IG_COMMENT, TRIGGER_IG_DM, TRIGGER_IG_STORY_REPLY,
|
|
582
|
+
// TRIGGER_IG_STORY_MENTION, SEND_MESSAGE, SEND_PHOTO, BRANCH, CONDITION,
|
|
583
|
+
// SET_VARIABLE, ADD_TAG, REMOVE_TAG, FORMULA, ASK_QUESTION, DELAY, END,
|
|
584
|
+
// SWITCH, STOP_AND_ERROR.
|
|
585
|
+
// IG_ALLOWED_INPUT_KINDS: TEXT, EMAIL, PHONE, NUMBER, CONTACT (CONTACT деградирует в ручной ввод номера — кнопки в IG нет).
|
|
586
|
+
// IG_MAX_DELAY_SECONDS: 86400 (24 часа).
|
|
587
|
+
// ============================================================
|
|
588
|
+
|
|
589
|
+
if (platform === "INSTAGRAM") {
|
|
590
|
+
const IG_ALLOWED_NODES = new Set([
|
|
591
|
+
"TRIGGER_IG_COMMENT", "TRIGGER_IG_DM",
|
|
592
|
+
"TRIGGER_IG_STORY_REPLY", "TRIGGER_IG_STORY_MENTION",
|
|
593
|
+
"SEND_MESSAGE", "SEND_PHOTO",
|
|
594
|
+
"BRANCH", "CONDITION", "SET_VARIABLE",
|
|
595
|
+
"ADD_TAG", "REMOVE_TAG", "FORMULA",
|
|
596
|
+
"ASK_QUESTION", "DELAY", "END",
|
|
597
|
+
"SWITCH", "STOP_AND_ERROR",
|
|
598
|
+
]);
|
|
599
|
+
const IG_ALLOWED_INPUT_KINDS = new Set(["TEXT", "EMAIL", "PHONE", "NUMBER", "CONTACT"]);
|
|
600
|
+
const IG_MAX_DELAY_SEC = 86400; // 24h messaging window
|
|
601
|
+
|
|
602
|
+
function igDelaySeconds(c) {
|
|
603
|
+
// Зеркало GraphValidator.igDelaySeconds():
|
|
604
|
+
// только FIXED считаем конкретно; TOMORROW/UNTIL — всегда > 24h (Long.MAX_VALUE)
|
|
605
|
+
if (!c || c.kind !== "FIXED") return Infinity;
|
|
606
|
+
// legacy: durationSec
|
|
607
|
+
if (typeof c.durationSec === "number") return c.durationSec;
|
|
608
|
+
// new format: duration + unit
|
|
609
|
+
if (typeof c.duration === "number") {
|
|
610
|
+
const u = String(c.unit || "");
|
|
611
|
+
if (u === "MINUTES") return c.duration * 60;
|
|
612
|
+
if (u === "HOURS") return c.duration * 3600;
|
|
613
|
+
if (u === "DAYS") return c.duration * 86400;
|
|
614
|
+
return c.duration; // assume seconds
|
|
615
|
+
}
|
|
616
|
+
return Infinity; // malformed FIXED — treat as unbounded (block it)
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Достижимость от триггеров-комментариев: «Ответ в комментарии» (igReplyChannel="comment")
|
|
620
|
+
// имеет смысл только в сценарии, запускаемом комментарием (иначе отвечать в комментарии некуда).
|
|
621
|
+
const reachFromComment = new Set();
|
|
622
|
+
{
|
|
623
|
+
const st = nodes.filter((n) => n.type === "TRIGGER_IG_COMMENT").map((n) => n.id);
|
|
624
|
+
while (st.length) { const x = st.pop(); if (reachFromComment.has(x)) continue; reachFromComment.add(x); (adj[x] || []).forEach((y) => st.push(y)); }
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
for (const n of nodes) {
|
|
628
|
+
const type = n.type;
|
|
629
|
+
const c = n.config || {};
|
|
630
|
+
const who = `«${c._title || n.id}» (${type})`;
|
|
631
|
+
if (!type) continue;
|
|
632
|
+
|
|
633
|
+
// 1) Проверка allowlist
|
|
634
|
+
if (!IG_ALLOWED_NODES.has(type)) {
|
|
635
|
+
errors.push(`IG_NODE_UNSUPPORTED: ${who} — узел «${type}» недоступен для Instagram-бота`);
|
|
636
|
+
continue; // дальнейшие проверки для этого узла бессмысленны
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// 2) ASK_QUESTION: inputKind должен быть из IG_ALLOWED_INPUT_KINDS
|
|
640
|
+
if (type === "ASK_QUESTION") {
|
|
641
|
+
const kind = c.inputKind;
|
|
642
|
+
if (kind != null && !IG_ALLOWED_INPUT_KINDS.has(String(kind).toUpperCase())) {
|
|
643
|
+
errors.push(`IG_INPUT_UNSUPPORTED: ${who} — В Instagram нельзя запросить «${kind}» — только TEXT/EMAIL/PHONE/NUMBER/CONTACT (CONTACT = ручной ввод номера)`);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// 3) DELAY: не более 24h (только для FIXED; TOMORROW/UNTIL всегда > 24h)
|
|
648
|
+
if (type === "DELAY") {
|
|
649
|
+
const sec = igDelaySeconds(c);
|
|
650
|
+
if (sec > IG_MAX_DELAY_SEC) {
|
|
651
|
+
errors.push(`IG_DELAY_OVER_24H: ${who} — задержка больше 24ч недопустима для Instagram (24-часовое окно доставки)`);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// 4) «Ответ в комментарии» только в сценарии с триггером-комментарием
|
|
656
|
+
if ((type === "SEND_MESSAGE" || type === "SEND_PHOTO")
|
|
657
|
+
&& String(c.igReplyChannel) === "comment" && !reachFromComment.has(n.id)) {
|
|
658
|
+
errors.push(`IG_COMMENT_REPLY_NO_COMMENT_TRIGGER: ${who} — «Ответ в комментарии» доступен только если шаг запускается триггером «Комментарий»; выбери «Личное сообщение» (igReplyChannel:"dm") или подключи шаг к TRIGGER_IG_COMMENT`);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// 4) TRIGGER_IG_STORY_MENTION: keywords игнорируется — текста упоминания нет
|
|
662
|
+
if (type === "TRIGGER_IG_STORY_MENTION" && !blank(c.keywords)) {
|
|
663
|
+
warns.push(`${who}: TRIGGER_IG_STORY_MENTION не фильтруется по ключевым словам (у упоминания нет текста) — keywords игнорируется`);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// --- отчёт ---
|
|
669
|
+
const byType = {};
|
|
670
|
+
nodes.forEach((n) => (byType[n.type] = (byType[n.type] || 0) + 1));
|
|
671
|
+
console.log(`Платформа: ${platform}`);
|
|
672
|
+
console.log(`Граф: ${nodes.length} узлов, ${edges.length} рёбер, ${triggers.length} триггеров`);
|
|
673
|
+
console.log(`Типы: ${JSON.stringify(byType)}`);
|
|
674
|
+
if (warns.length) { console.log(`\n⚠️ Предупреждения (${warns.length}):`); warns.forEach((w) => console.log(" • " + w)); }
|
|
675
|
+
if (errors.length) {
|
|
676
|
+
console.log(`\n❌ Ошибки (${errors.length}) — публикация не пройдёт:`);
|
|
677
|
+
errors.forEach((e) => console.log(" • " + e));
|
|
678
|
+
process.exit(1);
|
|
679
|
+
}
|
|
680
|
+
console.log("\n✅ Валидация пройдена — граф готов к заливке/публикации.");
|