@retensy/mcp 0.20.0 → 0.21.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +13 -13
- package/.claude-plugin/plugin.json +12 -12
- package/README.md +334 -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 +7 -7
|
@@ -1,479 +1,483 @@
|
|
|
1
|
-
# Формат графа `retensy-bot-graph`
|
|
2
|
-
|
|
3
|
-
## Контейнер импорта
|
|
4
|
-
```json
|
|
5
|
-
{
|
|
6
|
-
"format": "retensy-bot-graph",
|
|
7
|
-
"version": 1,
|
|
8
|
-
"name": "Название воронки",
|
|
9
|
-
"nodes": [ /* TgNode[] */ ],
|
|
10
|
-
"edges": [ /* TgEdge[] */ ],
|
|
11
|
-
"canvasMeta": {}
|
|
12
|
-
}
|
|
13
|
-
```
|
|
14
|
-
При заливке через MCP в `update_graph` передаются только `nodes`, `edges`, `canvasMeta`, `name`.
|
|
15
|
-
|
|
16
|
-
## Узел (TgNode)
|
|
17
|
-
```json
|
|
18
|
-
{ "id": "<uuid>", "type": "<NodeType>", "config": { ... }, "position": { "x": 0, "y": 0 } }
|
|
19
|
-
```
|
|
20
|
-
- `id` — валидный UUID (8-4-4-4-12), уникальный.
|
|
21
|
-
- `position` — раскладка на холсте (слева направо: шаг x ≈ 420; ветки разносим по y).
|
|
22
|
-
|
|
23
|
-
## Ребро (TgEdge) — «стрелка»
|
|
24
|
-
```json
|
|
25
|
-
{ "id": "<uuid>", "sourceNodeId": "<uuid>", "sourceHandle": "next", "targetNodeId": "<uuid>" }
|
|
26
|
-
```
|
|
27
|
-
`sourceHandle` — какой выход узла используется (см. ниже).
|
|
28
|
-
- `id`, `sourceNodeId`, `targetNodeId` — **валидные UUID** (бэкенд десериализует их как `java.util.UUID`; короткая строка вроде `"m1"` → HTTP 400 при заливке). `id` уникален среди рёбер.
|
|
29
|
-
|
|
30
|
-
## Типы узлов (NodeType) и их config
|
|
31
|
-
|
|
32
|
-
### Триггеры (точки входа, корневые)
|
|
33
|
-
|
|
34
|
-
#### Telegram / MAX
|
|
35
|
-
- `TRIGGER_COMMAND` — `{ "isRoot": true, "command": "start" }` (команда без `/`). Первый — с `isRoot:true`.
|
|
36
|
-
- `TRIGGER_CALLBACK` — `{ "matchMode": "EQUALS"|"STARTS_WITH", "value": "<callback_data>" }`
|
|
37
|
-
- `TRIGGER_TEXT` — `{ "matchMode": "ANY"|"EQUALS"|"CONTAINS"|"REGEX", "value": "..." }`
|
|
38
|
-
- `BROADCAST_FILTER` — режим рассылки (если есть — единственный триггер).
|
|
39
|
-
- `TRIGGER_PAYMENT` («Оплата прошла») — в кассе владельца прошла оплата: ЮKassa или подключённые через
|
|
40
|
-
`connect_integration` CloudPayments, Robokassa, Т-Банк, Prodamus, bePaid (подтверждённая оплата этих касс
|
|
41
|
-
нормализуется к тому же виду).
|
|
42
|
-
`{ "minAmount": "1000", "descriptionContains": "курс" }` — оба фильтра необязательны.
|
|
43
|
-
**Ставь хотя бы один фильтр:** без них сценарий будет запускаться на КАЖДУЮ оплату в кассе.
|
|
44
|
-
В контексте: `{{payment.id}}`, `{{payment.amount}}`, `{{payment.status}}`,
|
|
45
|
-
`{{payment.description}}`, `{{payment.currency}}`, `{{payment.provider}}` (какая касса), плюс весь документ платежа
|
|
46
|
-
как `body`.
|
|
47
|
-
Запуск — один раз на платёж: повторные уведомления кассы о той же оплате сценарий не перезапускают. Сюда же приходит оплата счёта, пришедшая уже после `timeout` блока со счётом, — в диалоге того подписчика (пока бот выключен, оплата ждёт повтора ЮKassa).
|
|
48
|
-
- **Счёт выставлял бот** (действие `issue_invoice`) → подписчик определяется САМ: счёт
|
|
49
|
-
помнит его в `metadata` платежа. Прогон идёт в обычной сессии — можно прямо ставить
|
|
50
|
-
«Отправить сообщение» и «Добавить метку», указывать получателя и разбирать ответ кассы
|
|
51
|
-
не нужно. Это штатный способ «оплатил → выдать доступ», когда ждать в сценарии не хочется.
|
|
52
|
-
- **Оплата мимо бота** (ссылка из рассылки, счёт из кабинета ЮKassa) → получателя нет,
|
|
53
|
-
прогон headless: узлы отправки адресуй через `config.target`, либо пиши в CRM и таблицы.
|
|
54
|
-
⚠️ Если оплату надо дождаться ПРЯМО в диалоге, триггер не нужен — веди ветку `paid`
|
|
55
|
-
от блока `ACTIONS` со счётом.
|
|
56
|
-
- `TRIGGER_TG_EVENT` — любое событие Telegram, кроме обычного сообщения: вступил/вышел из канала,
|
|
57
|
-
реакция, буст, заявка в закрытую группу, ответ в опросе и т.п.
|
|
58
|
-
`{ "event": "<имя поля Update>", "filter": { ... }, "priority": 0 }`.
|
|
59
|
-
Рабочие `event` (бэкенд их размечает, остальные молчат): `message`, `edited_message`,
|
|
60
|
-
`callback_query`, `channel_post`, `edited_channel_post`, `chat_member`, `chat_join_request`,
|
|
61
|
-
`my_chat_member`, `message_reaction`, `message_reaction_count`, `chat_boost`,
|
|
62
|
-
`removed_chat_boost`, `poll`, `poll_answer`, `inline_query`, `chosen_inline_result`,
|
|
63
|
-
`shipping_query`, `pre_checkout_query`.
|
|
64
|
-
**НЕ работают** (Telegram их шлёт, но рантайм не разбирает): `business_*`, `chat_shared`,
|
|
65
|
-
`users_shared`, `write_access_allowed`, `purchased_paid_media` — такой триггер не сработает.
|
|
66
|
-
`filter` (все условия по И, пустое = не задано):
|
|
67
|
-
`{"status":"member"|"left"|"kicked"|…}` — только там, где есть `member.status`
|
|
68
|
-
(`chat_member`, `my_chat_member`); `{"chatId":"-1001234567890"}` — конкретный чат/канал
|
|
69
|
-
(сравнение строкой, id каналов не влезают в int), доступен у 12 событий с `chat`;
|
|
70
|
-
`{"text":"купить","textMode":"contains"|"equals"}` — только `message`/`edited_message`/
|
|
71
|
-
`channel_post`/`edited_channel_post`. У `poll`/`poll_answer`/`inline_query`/
|
|
72
|
-
`chosen_inline_result`/`shipping_query`/`pre_checkout_query` чата нет → фильтров тоже.
|
|
73
|
-
Бот должен быть админом канала/группы, иначе события оттуда не придут.
|
|
74
|
-
- `TRIGGER_ANY_UPDATE` — `{}`, ловит ЛЮБОЙ апдейт. Приоритет самый низкий: срабатывает, только
|
|
75
|
-
если не подошёл ни один конкретный триггер. Удобен как «ничего не понял» / отладка.
|
|
76
|
-
- `TRIGGER_WEBHOOK` — `{}`, точка входа сценария с источником WEBHOOK (не бот). Такие сценарии
|
|
77
|
-
создаются в вебе; у графа бота этот триггер не сработает.
|
|
78
|
-
- **Внешние события интеграции** — тот же `TRIGGER_WEBHOOK`, но с заполненными
|
|
79
|
-
`{ "provider": "<provider-ключ из integration_catalog, нижний регистр>", "connectionId": "<id из list_integrations>",
|
|
80
|
-
"event": "<events[].key нужного события из inbound.events того же provider в integration_catalog>" }`.
|
|
81
|
-
Без `provider`+`connectionId` узел — обычный вебхук с произвольным телом; с ними URL ингресса
|
|
82
|
-
(`integration_ingress_url`) ведёт на обработчик конкретного провайдера, и запрос принимается только
|
|
83
|
-
в формате, который он понимает (подпись/HMAC сервиса проверяется на бэке). Переменные события —
|
|
84
|
-
`{{body.<ключ>}}`, ключи и примеры — в `inbound.events[].vars` того же события каталога.
|
|
85
|
-
amoCRM шлёт пачку сущностей одним запросом (`leads[add][0..n]`, `leads[status]`, `contacts[add]`) — сценарий
|
|
86
|
-
запускается по КАЖДОЙ сущности отдельно (порядок: сделки добавлены → смена этапа → контакты), не только по первой.
|
|
87
|
-
`event: "<key>"` с `paymentSucceeded:true` (кассы ЮKassa/CloudPayments/Robokassa/Т-Банк/Prodamus/bePaid) помимо
|
|
88
|
-
этого же `{{body.<ключ>}}`-прогона ЕЩЁ запускает узел «Оплата прошла» ваших графов — headless (без чата)
|
|
89
|
-
либо в чате подписчика, если платёжную ссылку выдавало действие `*_link`/`issue_invoice` с привязкой
|
|
90
|
-
к нему. Тестовый/demo-платёж (`test_mode`/`demo_mode` в переменных) прогоняет только обычный вебхук —
|
|
91
|
-
«Оплата прошла» на нём не срабатывает.
|
|
92
|
-
- `TRIGGER_SITE_FORM` — `{ "siteId": "<uuid сайта>", "formId": "<id формы>" }` — заявка с формы
|
|
93
|
-
опубликованного сайта владельца. `formId` пустой/нет = любая форма сайта. `siteId` обязателен
|
|
94
|
-
(`SITE_FORM_NO_SITE`), сайт должен быть свой (`SITE_FORM_FOREIGN_SITE`). Переменные: поля формы
|
|
95
|
-
`{{var.<имя>}}`, всегда `{{var.name}}`/`{{var.email}}`/`{{var.phone}}` (по имени поля, иначе по подписи),
|
|
96
|
-
UTM `{{var.utm_*}}`. Прогон без чата: узлы отправки — только с `config.target`. Работает и в сценарии
|
|
97
|
-
без бота (источник WEBHOOK — тогда `TRIGGER_WEBHOOK` не обязателен), и в сценарии бота; не для Instagram.
|
|
98
|
-
- `TRIGGER_SCHEDULE` — `{ "cron": "0 9 * * 1-5", "timezone": "Europe/Moscow" }` — запуск сценария по расписанию:
|
|
99
|
-
cron из 5 полей (минута час день месяц день-недели), не чаще раза в минуту; зона по умолчанию `Europe/Moscow`.
|
|
100
|
-
Прогон **headless** (без чата): узлы отправки — только с явным `config.target`, действия, которым нужен
|
|
101
|
-
подписчик, пропускаются; время срабатывания — `{{schedule.at}}`. Запуски, пропущенные во время простоя сервиса,
|
|
102
|
-
не догоняются. Работает в опубликованном активном сценарии (бота или вебхук-сценарии). Журнал — `scenario_runs`.
|
|
103
|
-
- ~~`TRIGGER_COMMENT`~~ — **мёртвый тип**: рантайм нигде не выставляет `event="comment"`, сработать
|
|
104
|
-
он не может. Убран из палитры редактора. Комментарии Instagram — `TRIGGER_IG_COMMENT`.
|
|
105
|
-
|
|
106
|
-
#### Instagram (только для IG-ботов)
|
|
107
|
-
IG-боты не поддерживают команды (`/start`). Вход — через взаимодействие с контентом или директ:
|
|
108
|
-
- `TRIGGER_IG_DM` — `{ "isRoot": true, "keywords": "хочу, каталог" }` — входящее сообщение в Instagram Direct. Это дефолтный триггер нового IG-графа (бэкенд сеет его при создании). `keywords` (опц.) — список через запятую или с новой строки; **регистронезависимо, совпадение по вхождению (contains)**; пусто = любое сообщение.
|
|
109
|
-
- `TRIGGER_IG_COMMENT` — `{ "isRoot": true, "keywords": "купить, цена" }` — комментарий к посту или Reel бота. `keywords` (опц.) — список через запятую или с новой строки; регистронезависимый contains; пусто = любой комментарий.
|
|
110
|
-
- `TRIGGER_IG_STORY_REPLY` — `{ "isRoot": true, "keywords": "хочу, вопрос" }` — ответ на историю бота. `keywords` (опц.) — список через запятую или с новой строки; регистронезависимый contains; пусто = любой ответ.
|
|
111
|
-
- `TRIGGER_IG_STORY_MENTION` — `{ "isRoot": true }` — упоминание бота в истории подписчика. **Текста нет → фильтрация по ключевым словам не применяется**; срабатывает на каждое упоминание независимо от содержимого истории.
|
|
112
|
-
|
|
113
|
-
> **Разные слова — разные сценарии:** добавь **несколько триггеров одного типа** с разными `keywords`; бэкенд берёт **первый совпавший по порядку узлов**. Триггер-«ловушку» с пустыми `keywords` (ловит всё) ставь **последним** — иначе он заблокирует все нижестоящие ключевые слова.
|
|
114
|
-
|
|
115
|
-
Для всех IG-триггеров реакция бота отправляется через Instagram Messaging API в течение **24-часового окна** после последнего входящего действия пользователя.
|
|
116
|
-
|
|
117
|
-
### Сообщения
|
|
118
|
-
- `SEND_MESSAGE` —
|
|
119
|
-
```json
|
|
120
|
-
{ "_title": "Заголовок узла", "parseMode": "PLAIN"|"HTML"|"MARKDOWN",
|
|
121
|
-
"text": "Текст сообщения",
|
|
122
|
-
"cards": [ { "id": "c1", "type": "text", "text": "Текст сообщения" } ],
|
|
123
|
-
"buttons": [ [ { "text": "Кнопка", "kind": "CALLBACK"|"URL", "value": "<url для URL; ПУСТО для CALLBACK>", "color": "", "track": true } ] ] }
|
|
124
|
-
```
|
|
125
|
-
ВСЕГДА заполняй и `text`, и `cards[0].text` одинаково. `buttons` — массив рядов (каждый ряд — массив кнопок).
|
|
126
|
-
- **Медиа-карточки** (`cards[].type`): `text`, `image`, `video`, `audio`, `file`, `voice`, `videonote` (кружок), `gallery` (альбом 2–10 фото), `question` (вопрос). У медиа-карточки ссылка на файл лежит в поле `url` (у `gallery` — массив `urls`). Сам файл (фото/видео/документ) в графе НЕ хранится — только URL. Чтобы получить URL, сперва загрузи файл инструментом **`upload_file`** (`path` локального файла или `url` для перезаливки) — он кладёт файл в библиотеку **/bots/files** и возвращает публичный `url`; его и вставляй в карточку. Примеры: `{ "id":"c2","type":"image","url":"https://bots.retensy.com/media/botmedia/<id>.jpg","text":"подпись (опц.)" }`, видео/аудио/файл/`voice`/`videonote` — так же с `url`; галерея — `{ "type":"gallery","urls":["https://…","https://…"] }`. Уже загруженные файлы — `list_files`, удалить — `delete_file`. Лимит 50 МБ; типы: image/video/audio/pdf/zip/doc(x)/xlsx/pptx/txt (SVG нельзя).
|
|
127
|
-
- `parseMode:"HTML"` (дефолт редактора) — текст должен быть **безопасным Telegram-HTML**: разрешены только `b,strong,i,em,u,ins,s,strike,del,code,pre,a[href],tg-spoiler,br`. Любой другой тег/атрибут → ошибка публикации `HTML_NOT_SAFE`. Не уверен — ставь `PLAIN`.
|
|
128
|
-
- **Кнопки-выборы (`kind:"CALLBACK"`)**: `value` ОСТАВЛЯЙ ПУСТЫМ (`""`). Бот сам сгенерит `callback_data` вида `n:<id узла>:<индекс>`, а переход задаётся ребром `btn_N` от кнопки. **Непустой `value`** трактуется как legacy-`callback_data` для отдельного узла `TRIGGER_CALLBACK` (у такой кнопки ребра `btn_N` быть не должно) — если поставить `value` обычной кнопке-выбору, переход по `btn_N` **сломается** (нажатие → `NO_MATCH`).
|
|
129
|
-
- **Кнопки-ссылки (`kind:"URL"`)**: `value` = URL. Могут иметь `"track": true` — клики считаются, и на такой шаг можно сослаться из условия `LINK_CLICKED` (см. ниже).
|
|
130
|
-
- **`color`** (опционально, и у CALLBACK, и у URL) — только стили, которые рендерит Telegram (как в основном боте / pengrad `ButtonStyle`): `""`=по умолчанию, `"#2EA6FF"`=primary (синий), `"#34C759"`=success (зелёный), `"#FF3B30"`=danger (красный). Других цветов нет.
|
|
131
|
-
- **Режим «Вопрос» (`awaitReply: true`)** — сообщение задаёт вопрос и ждёт ответ (паркуется как `ASK_QUESTION`). Доп. поля: `"saveTo":"name"` (обязателен, `[a-z_][a-z0-9_]{0,63}`), `"inputKind":"TEXT"|"PHOTO"|"DOCUMENT"|"CONTACT"|"LOCATION"`, `"validator":"ANY"|"PHONE"|"EMAIL"|"REGEX"`, `"regex":"..."`, `"retryText":"..."`, `"maxAttempts":3`. Выходы — `valid` / `invalid` (как у `ASK_QUESTION`), плюс `btn_N` для кнопок.
|
|
132
|
-
- **Канал ответа Instagram (`igReplyChannel`, только для IG-ботов)** — `"comment"` (по умолчанию) — публичный ответ под комментарием; `"dm"` — личное сообщение автору комментария (Private Reply). `"dm"` срабатывает только в графе с `TRIGGER_IG_COMMENT` (нужен `comment_id`): Meta разрешает **1 ЛС на комментарий**, 24-часовое окно на private reply не распространяется. Чтобы ответить и публично, и в ЛС — поставь два шага: один с `"igReplyChannel":"comment"`, второй с `"igReplyChannel":"dm"`.
|
|
133
|
-
- `SEND_PHOTO` — `{ "photoUrl": "https://...", "caption": "подпись (необязательно, ≤1024)", "igReplyChannel": "comment"|"dm" }` (`igReplyChannel` — только IG, см. `SEND_MESSAGE`). `photoUrl` можно получить через `upload_file`.
|
|
134
|
-
|
|
135
|
-
### Логика / ветвление
|
|
136
|
-
- `CONDITION` — проверка условий, выходы `yes` / `no`. `{ "match":"ALL"|"ANY", "conditions":[ { "kind":"...", "op":"...", "key":"...", "value":"..." } ] }`. `match:"ALL"` — все условия истинны; `"ANY"` — хотя бы одно. Полный список `kind`/`op`/полей — в разделе [«Условия CONDITION»](#условия-condition).
|
|
137
|
-
- `BRANCH` — `{ "cases":[ {"id":"c1","label":"...","expression":"var.x=='a'"} ], "hasDefault": false, "abTest": false }`. Выходы: `case_<id>` (+ `default`).
|
|
138
|
-
- `SWITCH` — развилка по ЗНАЧЕНИЮ, когда веток больше двух.
|
|
139
|
-
`{ "expression":"{{var.http_status}}", "cases":[ {"id":"v1","value":"200","label":"Успех"},
|
|
140
|
-
{"id":"v2","value":"404","label":"Не найдено"} ] }`. Выходы: `case_<id>` + `default` (есть всегда).
|
|
141
|
-
Выражение рендерится как шаблон (обычно просто `{{var.x}}`) и сравнивается со `value` каждого
|
|
142
|
-
случая **без учёта регистра и пробелов по краям**; первое совпадение выигрывает, иначе `default`.
|
|
143
|
-
Отличия: `CONDITION` — бинарное да/нет, `BRANCH` — случайный выбор (A/B), `SWITCH` —
|
|
144
|
-
детерминированный выбор по значению. Валидатор режет: пустое `expression`
|
|
145
|
-
(`SWITCH_NO_EXPRESSION`), нет случаев (`SWITCH_NO_CASES`), пустое `value` (`SWITCH_EMPTY_VALUE`),
|
|
146
|
-
повтор значения (`SWITCH_DUPLICATE_VALUE`), случай без ребра (`SWITCH_CASE_UNCONNECTED`).
|
|
147
|
-
Ребро на `default` необязательно.
|
|
148
|
-
- `STOP_AND_ERROR` — `{ "message":"CRM не ответила: {{var.http_status}}" }`. Обрывает прогон и
|
|
149
|
-
помечает его в журнале как ошибочный (`FAILED`), шаг — `ok:false` с этим текстом. Выходов нет,
|
|
150
|
-
ничего подписчику не отправляет. Пустой `message` → «Сценарий остановлен с ошибкой».
|
|
151
|
-
- `ASK_QUESTION` — вопрос со сбором ответа. `{ "promptText":"...","saveTo":"name","inputKind":"TEXT"|"PHOTO"|"DOCUMENT"|"CONTACT"|"LOCATION","validator":"ANY"|"PHONE"|"EMAIL"|"REGEX","regex":"...","retryText":"...","maxAttempts":3 }`. `inputKind` (по умолчанию `TEXT`) — что ждём в ответ (`CONTACT` → телефон: в Telegram показывается кнопка «Поделиться номером», в MAX/Instagram кнопки нет — номер вводится вручную и принимается как телефон на всех платформах; `LOCATION` → `lat,lon`, `PHOTO`/`DOCUMENT` → file_id). Выходы `valid` / `invalid`.
|
|
152
|
-
- `END` — `{}` (конец ветки). **Не добавляй `END`**: ветка и так завершается на узле без исходящих рёбер; явный «конец сценария» бесполезен и убран из палитры редактора. Тип оставлен лишь для совместимости со старыми графами.
|
|
153
|
-
|
|
154
|
-
### Тайминги
|
|
155
|
-
- `DELAY` — пауза. Три вида (`kind`):
|
|
156
|
-
- **`FIXED`** («Отправить через»): `{ "kind":"FIXED", "durationSec": 86400 }` — `durationSec` в **секундах** (60 = 1 мин, 3600 = 1 час, 86400 = 1 сутки). Редактор также пишет `{ "kind":"FIXED", "duration": 24, "unit":"MINUTES"|"HOURS"|"DAYS" }` (минуты/часы/дни — чтобы не вбивать большие числа). Для генерации проще `durationSec`.
|
|
157
|
-
- **`TOMORROW`** («Отправить завтра»): `{ "kind":"TOMORROW", "time":"18:00" }` — завтра в указанное время `HH:mm` (МСК), относительно момента, когда пользователь дошёл до узла.
|
|
158
|
-
- **`UNTIL`** («Отправить в»): `{ "kind":"UNTIL", "isoTimestamp":"2026-06-25T15:00:00Z" }` — конкретный момент в ISO-8601. ⚠️ Рантайм читает только `isoTimestamp`; пары `isoDate`+`time` НЕ работают. Можно шаблон из переменной и смещение: `{ "kind":"UNTIL", "isoTimestamp":"{{var.slot_at}}", "offset":"-24h", "timezone":"Europe/Moscow" }` — «за сутки до записи» (`offset`: `-30m`, `+1d`, единицы s/m/h/d/w; дата без зоны читается в `timezone`, по умолчанию Москва). Момент уже прошёл → выход `late`, если ребро нарисовано, иначе шаг выполняется сразу (`snapshot.lateBy`). Шаблон не разобрался как дата → выход `error`.
|
|
159
|
-
- `SCHEDULE` — `{ "isoDate":"2026-06-25", "time":"18:00", "timezone":"Europe/Moscow" }`. Выходы `scheduled` / `past`. `isoDate` может быть и ISO дата-временем или шаблоном (`"{{var.slot_at}}"`, тогда `time` не нужен) + `offset` как у `DELAY UNTIL`.
|
|
160
|
-
- Прогон без пауз (между `DELAY`, `ASK_QUESTION`/`awaitReply`, ожиданием оплаты) ограничен **5 минутами**: дольше — рантайм обрывает его с ошибкой шага `run deadline exceeded` (прогон `FAILED`, пауза подписчика снята). Длинные цепочки `external_request` / `CALL_WEBHOOK` / `AI_REPLY` разноси `DELAY` — после паузы начинается новый прогон.
|
|
161
|
-
- Массовый одинаковый момент срабатывания (`TOMORROW`/`UNTIL`/`SCHEDULE` у тысяч подписчиков одного бота) расходится постепенно: до 10 срабатываний на бота в секунду, время растёт с аудиторией (≈100 с на 1000 подписчиков, ≈17 мин на 10 000), другие боты не ждут. Не рассчитывай, что все получат сообщение в одну секунду.
|
|
162
|
-
- Если Telegram вернул боту 429 (`retry after N`, пауза не дольше часа), срабатывания `DELAY`/`SCHEDULE` и рассылки этого бота (и по `BROADCAST_FILTER`, и прямые) ждут конца паузы; после такой паузы рассылка продолжается без потерь и без повтора уже доставленного. Это только про паузу 429: если сервис перезапустился посреди отправки, получатель, на котором её прервали, через ≤15 мин может получить рассылку (или прогон сценария) повторно. Ответ подписчику в живом диалоге отправляется сразу, без ожидания; если Telegram откажет снова, шаг уходит в ветку «Ошибка», если она проведена, иначе дальше по обычному выходу.
|
|
163
|
-
- `AI_REPLY` при сбое провайдера (5xx, таймаут) уходит в `error` примерно через минуту — не рассчитывай на долгое ожидание ответа модели.
|
|
164
|
-
|
|
165
|
-
### Состояние / действия
|
|
166
|
-
- `SET_VARIABLE` (`{ "key":"name", "value":"..." }`), `ADD_TAG`/`REMOVE_TAG` (`{ "tag":"lead" }`), `FORMULA` (`{ "expression":"...", "saveTo":"name" }`)
|
|
167
|
-
- `ACTIONS` — непустой пакет действий `{ "actions":[ { "kind":"...", ...поля } ] }`. Допустимые `kind` (иначе ошибка `ACTION_UNKNOWN_KIND`):
|
|
168
|
-
- **метки/автоворонки**: `add_tag`, `remove_tag`, `autoflow_add`, `autoflow_remove` — поле `tag` (`[a-z0-9_-]{1,64}`)
|
|
169
|
-
- **профиль**: `set_field` — `key` (`[a-z_][a-z0-9_]{0,63}`) + `value`; `subscribe`, `unsubscribe`
|
|
170
|
-
- **HTTP**: `external_request` — **основной способ сходить в чужой API**, те же поля и возможности,
|
|
171
|
-
что у узла `CALL_WEBHOOK`: `url` + `method`/`headersJson`/`bodyTemplate`/`timeoutMs` +
|
|
172
|
-
`saveStatusTo`/`saveBodyTo`/`extract`. Отличие одно: своих выходов у действия нет — сбой уводит
|
|
173
|
-
ВЕСЬ блок `ACTIONS` в его выход `error` (если ребро нарисовано; нет — идём по `next`), а
|
|
174
|
-
ветвиться по коду ответа надо следующим блоком `SWITCH`. **Платное действие** (как `CALL_WEBHOOK`):
|
|
175
|
-
на бесплатном тарифе публикация падает с `PREMIUM_NODE_FORBIDDEN`.
|
|
176
|
-
Ещё есть `subscriber_webhook` — `url` + `method`/`headersJson`/`bodyTemplate`
|
|
177
|
-
- **уведомления**: `notify` (`text`) — владельцу бота в бот уведомлений из его профиля (Telegram/MAX); не подключён → действие не удалось, блок уходит в `error`; `subscriber_email` (`email`,`text`)
|
|
178
|
-
-
|
|
179
|
-
-
|
|
180
|
-
-
|
|
181
|
-
-
|
|
182
|
-
-
|
|
183
|
-
-
|
|
184
|
-
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
- `
|
|
188
|
-
-
|
|
189
|
-
- `
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
`
|
|
207
|
-
- `
|
|
208
|
-
|
|
209
|
-
`
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
`
|
|
213
|
-
|
|
214
|
-
- `
|
|
215
|
-
`
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
`
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
`
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
(
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
`
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
-
|
|
306
|
-
|
|
307
|
-
клиента, не
|
|
308
|
-
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
`
|
|
315
|
-
`
|
|
316
|
-
|
|
317
|
-
`budget_exhausted
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
-
|
|
357
|
-
ЮKassa
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
| `
|
|
384
|
-
|
|
385
|
-
| `
|
|
386
|
-
| `
|
|
387
|
-
| `
|
|
388
|
-
| `
|
|
389
|
-
| `
|
|
390
|
-
| `
|
|
391
|
-
| `
|
|
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
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
|
454
|
-
|
|
455
|
-
| `
|
|
456
|
-
| `
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
|
466
|
-
|
|
467
|
-
|
|
|
468
|
-
|
|
|
469
|
-
| `
|
|
470
|
-
| `
|
|
471
|
-
| `
|
|
472
|
-
| `
|
|
473
|
-
| `
|
|
474
|
-
| `
|
|
475
|
-
| `
|
|
476
|
-
| `
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
1
|
+
# Формат графа `retensy-bot-graph`
|
|
2
|
+
|
|
3
|
+
## Контейнер импорта
|
|
4
|
+
```json
|
|
5
|
+
{
|
|
6
|
+
"format": "retensy-bot-graph",
|
|
7
|
+
"version": 1,
|
|
8
|
+
"name": "Название воронки",
|
|
9
|
+
"nodes": [ /* TgNode[] */ ],
|
|
10
|
+
"edges": [ /* TgEdge[] */ ],
|
|
11
|
+
"canvasMeta": {}
|
|
12
|
+
}
|
|
13
|
+
```
|
|
14
|
+
При заливке через MCP в `update_graph` передаются только `nodes`, `edges`, `canvasMeta`, `name`.
|
|
15
|
+
|
|
16
|
+
## Узел (TgNode)
|
|
17
|
+
```json
|
|
18
|
+
{ "id": "<uuid>", "type": "<NodeType>", "config": { ... }, "position": { "x": 0, "y": 0 } }
|
|
19
|
+
```
|
|
20
|
+
- `id` — валидный UUID (8-4-4-4-12), уникальный.
|
|
21
|
+
- `position` — раскладка на холсте (слева направо: шаг x ≈ 420; ветки разносим по y).
|
|
22
|
+
|
|
23
|
+
## Ребро (TgEdge) — «стрелка»
|
|
24
|
+
```json
|
|
25
|
+
{ "id": "<uuid>", "sourceNodeId": "<uuid>", "sourceHandle": "next", "targetNodeId": "<uuid>" }
|
|
26
|
+
```
|
|
27
|
+
`sourceHandle` — какой выход узла используется (см. ниже).
|
|
28
|
+
- `id`, `sourceNodeId`, `targetNodeId` — **валидные UUID** (бэкенд десериализует их как `java.util.UUID`; короткая строка вроде `"m1"` → HTTP 400 при заливке). `id` уникален среди рёбер.
|
|
29
|
+
|
|
30
|
+
## Типы узлов (NodeType) и их config
|
|
31
|
+
|
|
32
|
+
### Триггеры (точки входа, корневые)
|
|
33
|
+
|
|
34
|
+
#### Telegram / MAX
|
|
35
|
+
- `TRIGGER_COMMAND` — `{ "isRoot": true, "command": "start" }` (команда без `/`). Первый — с `isRoot:true`.
|
|
36
|
+
- `TRIGGER_CALLBACK` — `{ "matchMode": "EQUALS"|"STARTS_WITH", "value": "<callback_data>" }`
|
|
37
|
+
- `TRIGGER_TEXT` — `{ "matchMode": "ANY"|"EQUALS"|"CONTAINS"|"REGEX", "value": "..." }`
|
|
38
|
+
- `BROADCAST_FILTER` — режим рассылки (если есть — единственный триггер).
|
|
39
|
+
- `TRIGGER_PAYMENT` («Оплата прошла») — в кассе владельца прошла оплата: ЮKassa или подключённые через
|
|
40
|
+
`connect_integration` CloudPayments, Robokassa, Т-Банк, Prodamus, bePaid (подтверждённая оплата этих касс
|
|
41
|
+
нормализуется к тому же виду).
|
|
42
|
+
`{ "minAmount": "1000", "descriptionContains": "курс" }` — оба фильтра необязательны.
|
|
43
|
+
**Ставь хотя бы один фильтр:** без них сценарий будет запускаться на КАЖДУЮ оплату в кассе.
|
|
44
|
+
В контексте: `{{payment.id}}`, `{{payment.amount}}`, `{{payment.status}}`,
|
|
45
|
+
`{{payment.description}}`, `{{payment.currency}}`, `{{payment.provider}}` (какая касса), плюс весь документ платежа
|
|
46
|
+
как `body`.
|
|
47
|
+
Запуск — один раз на платёж: повторные уведомления кассы о той же оплате сценарий не перезапускают. Сюда же приходит оплата счёта, пришедшая уже после `timeout` блока со счётом, — в диалоге того подписчика (пока бот выключен, оплата ждёт повтора ЮKassa).
|
|
48
|
+
- **Счёт выставлял бот** (действие `issue_invoice`) → подписчик определяется САМ: счёт
|
|
49
|
+
помнит его в `metadata` платежа. Прогон идёт в обычной сессии — можно прямо ставить
|
|
50
|
+
«Отправить сообщение» и «Добавить метку», указывать получателя и разбирать ответ кассы
|
|
51
|
+
не нужно. Это штатный способ «оплатил → выдать доступ», когда ждать в сценарии не хочется.
|
|
52
|
+
- **Оплата мимо бота** (ссылка из рассылки, счёт из кабинета ЮKassa) → получателя нет,
|
|
53
|
+
прогон headless: узлы отправки адресуй через `config.target`, либо пиши в CRM и таблицы.
|
|
54
|
+
⚠️ Если оплату надо дождаться ПРЯМО в диалоге, триггер не нужен — веди ветку `paid`
|
|
55
|
+
от блока `ACTIONS` со счётом.
|
|
56
|
+
- `TRIGGER_TG_EVENT` — любое событие Telegram, кроме обычного сообщения: вступил/вышел из канала,
|
|
57
|
+
реакция, буст, заявка в закрытую группу, ответ в опросе и т.п.
|
|
58
|
+
`{ "event": "<имя поля Update>", "filter": { ... }, "priority": 0 }`.
|
|
59
|
+
Рабочие `event` (бэкенд их размечает, остальные молчат): `message`, `edited_message`,
|
|
60
|
+
`callback_query`, `channel_post`, `edited_channel_post`, `chat_member`, `chat_join_request`,
|
|
61
|
+
`my_chat_member`, `message_reaction`, `message_reaction_count`, `chat_boost`,
|
|
62
|
+
`removed_chat_boost`, `poll`, `poll_answer`, `inline_query`, `chosen_inline_result`,
|
|
63
|
+
`shipping_query`, `pre_checkout_query`.
|
|
64
|
+
**НЕ работают** (Telegram их шлёт, но рантайм не разбирает): `business_*`, `chat_shared`,
|
|
65
|
+
`users_shared`, `write_access_allowed`, `purchased_paid_media` — такой триггер не сработает.
|
|
66
|
+
`filter` (все условия по И, пустое = не задано):
|
|
67
|
+
`{"status":"member"|"left"|"kicked"|…}` — только там, где есть `member.status`
|
|
68
|
+
(`chat_member`, `my_chat_member`); `{"chatId":"-1001234567890"}` — конкретный чат/канал
|
|
69
|
+
(сравнение строкой, id каналов не влезают в int), доступен у 12 событий с `chat`;
|
|
70
|
+
`{"text":"купить","textMode":"contains"|"equals"}` — только `message`/`edited_message`/
|
|
71
|
+
`channel_post`/`edited_channel_post`. У `poll`/`poll_answer`/`inline_query`/
|
|
72
|
+
`chosen_inline_result`/`shipping_query`/`pre_checkout_query` чата нет → фильтров тоже.
|
|
73
|
+
Бот должен быть админом канала/группы, иначе события оттуда не придут.
|
|
74
|
+
- `TRIGGER_ANY_UPDATE` — `{}`, ловит ЛЮБОЙ апдейт. Приоритет самый низкий: срабатывает, только
|
|
75
|
+
если не подошёл ни один конкретный триггер. Удобен как «ничего не понял» / отладка.
|
|
76
|
+
- `TRIGGER_WEBHOOK` — `{}`, точка входа сценария с источником WEBHOOK (не бот). Такие сценарии
|
|
77
|
+
создаются в вебе; у графа бота этот триггер не сработает.
|
|
78
|
+
- **Внешние события интеграции** — тот же `TRIGGER_WEBHOOK`, но с заполненными
|
|
79
|
+
`{ "provider": "<provider-ключ из integration_catalog, нижний регистр>", "connectionId": "<id из list_integrations>",
|
|
80
|
+
"event": "<events[].key нужного события из inbound.events того же provider в integration_catalog>" }`.
|
|
81
|
+
Без `provider`+`connectionId` узел — обычный вебхук с произвольным телом; с ними URL ингресса
|
|
82
|
+
(`integration_ingress_url`) ведёт на обработчик конкретного провайдера, и запрос принимается только
|
|
83
|
+
в формате, который он понимает (подпись/HMAC сервиса проверяется на бэке). Переменные события —
|
|
84
|
+
`{{body.<ключ>}}`, ключи и примеры — в `inbound.events[].vars` того же события каталога.
|
|
85
|
+
amoCRM шлёт пачку сущностей одним запросом (`leads[add][0..n]`, `leads[status]`, `contacts[add]`) — сценарий
|
|
86
|
+
запускается по КАЖДОЙ сущности отдельно (порядок: сделки добавлены → смена этапа → контакты), не только по первой.
|
|
87
|
+
`event: "<key>"` с `paymentSucceeded:true` (кассы ЮKassa/CloudPayments/Robokassa/Т-Банк/Prodamus/bePaid) помимо
|
|
88
|
+
этого же `{{body.<ключ>}}`-прогона ЕЩЁ запускает узел «Оплата прошла» ваших графов — headless (без чата)
|
|
89
|
+
либо в чате подписчика, если платёжную ссылку выдавало действие `*_link`/`issue_invoice` с привязкой
|
|
90
|
+
к нему. Тестовый/demo-платёж (`test_mode`/`demo_mode` в переменных) прогоняет только обычный вебхук —
|
|
91
|
+
«Оплата прошла» на нём не срабатывает.
|
|
92
|
+
- `TRIGGER_SITE_FORM` — `{ "siteId": "<uuid сайта>", "formId": "<id формы>" }` — заявка с формы
|
|
93
|
+
опубликованного сайта владельца. `formId` пустой/нет = любая форма сайта. `siteId` обязателен
|
|
94
|
+
(`SITE_FORM_NO_SITE`), сайт должен быть свой (`SITE_FORM_FOREIGN_SITE`). Переменные: поля формы
|
|
95
|
+
`{{var.<имя>}}`, всегда `{{var.name}}`/`{{var.email}}`/`{{var.phone}}` (по имени поля, иначе по подписи),
|
|
96
|
+
UTM `{{var.utm_*}}`. Прогон без чата: узлы отправки — только с `config.target`. Работает и в сценарии
|
|
97
|
+
без бота (источник WEBHOOK — тогда `TRIGGER_WEBHOOK` не обязателен), и в сценарии бота; не для Instagram.
|
|
98
|
+
- `TRIGGER_SCHEDULE` — `{ "cron": "0 9 * * 1-5", "timezone": "Europe/Moscow" }` — запуск сценария по расписанию:
|
|
99
|
+
cron из 5 полей (минута час день месяц день-недели), не чаще раза в минуту; зона по умолчанию `Europe/Moscow`.
|
|
100
|
+
Прогон **headless** (без чата): узлы отправки — только с явным `config.target`, действия, которым нужен
|
|
101
|
+
подписчик, пропускаются; время срабатывания — `{{schedule.at}}`. Запуски, пропущенные во время простоя сервиса,
|
|
102
|
+
не догоняются. Работает в опубликованном активном сценарии (бота или вебхук-сценарии). Журнал — `scenario_runs`.
|
|
103
|
+
- ~~`TRIGGER_COMMENT`~~ — **мёртвый тип**: рантайм нигде не выставляет `event="comment"`, сработать
|
|
104
|
+
он не может. Убран из палитры редактора. Комментарии Instagram — `TRIGGER_IG_COMMENT`.
|
|
105
|
+
|
|
106
|
+
#### Instagram (только для IG-ботов)
|
|
107
|
+
IG-боты не поддерживают команды (`/start`). Вход — через взаимодействие с контентом или директ:
|
|
108
|
+
- `TRIGGER_IG_DM` — `{ "isRoot": true, "keywords": "хочу, каталог" }` — входящее сообщение в Instagram Direct. Это дефолтный триггер нового IG-графа (бэкенд сеет его при создании). `keywords` (опц.) — список через запятую или с новой строки; **регистронезависимо, совпадение по вхождению (contains)**; пусто = любое сообщение.
|
|
109
|
+
- `TRIGGER_IG_COMMENT` — `{ "isRoot": true, "keywords": "купить, цена" }` — комментарий к посту или Reel бота. `keywords` (опц.) — список через запятую или с новой строки; регистронезависимый contains; пусто = любой комментарий.
|
|
110
|
+
- `TRIGGER_IG_STORY_REPLY` — `{ "isRoot": true, "keywords": "хочу, вопрос" }` — ответ на историю бота. `keywords` (опц.) — список через запятую или с новой строки; регистронезависимый contains; пусто = любой ответ.
|
|
111
|
+
- `TRIGGER_IG_STORY_MENTION` — `{ "isRoot": true }` — упоминание бота в истории подписчика. **Текста нет → фильтрация по ключевым словам не применяется**; срабатывает на каждое упоминание независимо от содержимого истории.
|
|
112
|
+
|
|
113
|
+
> **Разные слова — разные сценарии:** добавь **несколько триггеров одного типа** с разными `keywords`; бэкенд берёт **первый совпавший по порядку узлов**. Триггер-«ловушку» с пустыми `keywords` (ловит всё) ставь **последним** — иначе он заблокирует все нижестоящие ключевые слова.
|
|
114
|
+
|
|
115
|
+
Для всех IG-триггеров реакция бота отправляется через Instagram Messaging API в течение **24-часового окна** после последнего входящего действия пользователя.
|
|
116
|
+
|
|
117
|
+
### Сообщения
|
|
118
|
+
- `SEND_MESSAGE` —
|
|
119
|
+
```json
|
|
120
|
+
{ "_title": "Заголовок узла", "parseMode": "PLAIN"|"HTML"|"MARKDOWN",
|
|
121
|
+
"text": "Текст сообщения",
|
|
122
|
+
"cards": [ { "id": "c1", "type": "text", "text": "Текст сообщения" } ],
|
|
123
|
+
"buttons": [ [ { "text": "Кнопка", "kind": "CALLBACK"|"URL", "value": "<url для URL; ПУСТО для CALLBACK>", "color": "", "track": true } ] ] }
|
|
124
|
+
```
|
|
125
|
+
ВСЕГДА заполняй и `text`, и `cards[0].text` одинаково. `buttons` — массив рядов (каждый ряд — массив кнопок).
|
|
126
|
+
- **Медиа-карточки** (`cards[].type`): `text`, `image`, `video`, `audio`, `file`, `voice`, `videonote` (кружок), `gallery` (альбом 2–10 фото), `question` (вопрос). У медиа-карточки ссылка на файл лежит в поле `url` (у `gallery` — массив `urls`). Сам файл (фото/видео/документ) в графе НЕ хранится — только URL. Чтобы получить URL, сперва загрузи файл инструментом **`upload_file`** (`path` локального файла или `url` для перезаливки) — он кладёт файл в библиотеку **/bots/files** и возвращает публичный `url`; его и вставляй в карточку. Примеры: `{ "id":"c2","type":"image","url":"https://bots.retensy.com/media/botmedia/<id>.jpg","text":"подпись (опц.)" }`, видео/аудио/файл/`voice`/`videonote` — так же с `url`; галерея — `{ "type":"gallery","urls":["https://…","https://…"] }`. Уже загруженные файлы — `list_files`, удалить — `delete_file`. Лимит 50 МБ; типы: image/video/audio/pdf/zip/doc(x)/xlsx/pptx/txt (SVG нельзя).
|
|
127
|
+
- `parseMode:"HTML"` (дефолт редактора) — текст должен быть **безопасным Telegram-HTML**: разрешены только `b,strong,i,em,u,ins,s,strike,del,code,pre,a[href],tg-spoiler,br`. Любой другой тег/атрибут → ошибка публикации `HTML_NOT_SAFE`. Не уверен — ставь `PLAIN`.
|
|
128
|
+
- **Кнопки-выборы (`kind:"CALLBACK"`)**: `value` ОСТАВЛЯЙ ПУСТЫМ (`""`). Бот сам сгенерит `callback_data` вида `n:<id узла>:<индекс>`, а переход задаётся ребром `btn_N` от кнопки. **Непустой `value`** трактуется как legacy-`callback_data` для отдельного узла `TRIGGER_CALLBACK` (у такой кнопки ребра `btn_N` быть не должно) — если поставить `value` обычной кнопке-выбору, переход по `btn_N` **сломается** (нажатие → `NO_MATCH`).
|
|
129
|
+
- **Кнопки-ссылки (`kind:"URL"`)**: `value` = URL. Могут иметь `"track": true` — клики считаются, и на такой шаг можно сослаться из условия `LINK_CLICKED` (см. ниже).
|
|
130
|
+
- **`color`** (опционально, и у CALLBACK, и у URL) — только стили, которые рендерит Telegram (как в основном боте / pengrad `ButtonStyle`): `""`=по умолчанию, `"#2EA6FF"`=primary (синий), `"#34C759"`=success (зелёный), `"#FF3B30"`=danger (красный). Других цветов нет.
|
|
131
|
+
- **Режим «Вопрос» (`awaitReply: true`)** — сообщение задаёт вопрос и ждёт ответ (паркуется как `ASK_QUESTION`). Доп. поля: `"saveTo":"name"` (обязателен, `[a-z_][a-z0-9_]{0,63}`), `"inputKind":"TEXT"|"PHOTO"|"DOCUMENT"|"CONTACT"|"LOCATION"`, `"validator":"ANY"|"PHONE"|"EMAIL"|"REGEX"`, `"regex":"..."`, `"retryText":"..."`, `"maxAttempts":3`. Выходы — `valid` / `invalid` (как у `ASK_QUESTION`), плюс `btn_N` для кнопок.
|
|
132
|
+
- **Канал ответа Instagram (`igReplyChannel`, только для IG-ботов)** — `"comment"` (по умолчанию) — публичный ответ под комментарием; `"dm"` — личное сообщение автору комментария (Private Reply). `"dm"` срабатывает только в графе с `TRIGGER_IG_COMMENT` (нужен `comment_id`): Meta разрешает **1 ЛС на комментарий**, 24-часовое окно на private reply не распространяется. Чтобы ответить и публично, и в ЛС — поставь два шага: один с `"igReplyChannel":"comment"`, второй с `"igReplyChannel":"dm"`.
|
|
133
|
+
- `SEND_PHOTO` — `{ "photoUrl": "https://...", "caption": "подпись (необязательно, ≤1024)", "igReplyChannel": "comment"|"dm" }` (`igReplyChannel` — только IG, см. `SEND_MESSAGE`). `photoUrl` можно получить через `upload_file`.
|
|
134
|
+
|
|
135
|
+
### Логика / ветвление
|
|
136
|
+
- `CONDITION` — проверка условий, выходы `yes` / `no`. `{ "match":"ALL"|"ANY", "conditions":[ { "kind":"...", "op":"...", "key":"...", "value":"..." } ] }`. `match:"ALL"` — все условия истинны; `"ANY"` — хотя бы одно. Полный список `kind`/`op`/полей — в разделе [«Условия CONDITION»](#условия-condition).
|
|
137
|
+
- `BRANCH` — `{ "cases":[ {"id":"c1","label":"...","expression":"var.x=='a'"} ], "hasDefault": false, "abTest": false }`. Выходы: `case_<id>` (+ `default`).
|
|
138
|
+
- `SWITCH` — развилка по ЗНАЧЕНИЮ, когда веток больше двух.
|
|
139
|
+
`{ "expression":"{{var.http_status}}", "cases":[ {"id":"v1","value":"200","label":"Успех"},
|
|
140
|
+
{"id":"v2","value":"404","label":"Не найдено"} ] }`. Выходы: `case_<id>` + `default` (есть всегда).
|
|
141
|
+
Выражение рендерится как шаблон (обычно просто `{{var.x}}`) и сравнивается со `value` каждого
|
|
142
|
+
случая **без учёта регистра и пробелов по краям**; первое совпадение выигрывает, иначе `default`.
|
|
143
|
+
Отличия: `CONDITION` — бинарное да/нет, `BRANCH` — случайный выбор (A/B), `SWITCH` —
|
|
144
|
+
детерминированный выбор по значению. Валидатор режет: пустое `expression`
|
|
145
|
+
(`SWITCH_NO_EXPRESSION`), нет случаев (`SWITCH_NO_CASES`), пустое `value` (`SWITCH_EMPTY_VALUE`),
|
|
146
|
+
повтор значения (`SWITCH_DUPLICATE_VALUE`), случай без ребра (`SWITCH_CASE_UNCONNECTED`).
|
|
147
|
+
Ребро на `default` необязательно.
|
|
148
|
+
- `STOP_AND_ERROR` — `{ "message":"CRM не ответила: {{var.http_status}}" }`. Обрывает прогон и
|
|
149
|
+
помечает его в журнале как ошибочный (`FAILED`), шаг — `ok:false` с этим текстом. Выходов нет,
|
|
150
|
+
ничего подписчику не отправляет. Пустой `message` → «Сценарий остановлен с ошибкой».
|
|
151
|
+
- `ASK_QUESTION` — вопрос со сбором ответа. `{ "promptText":"...","saveTo":"name","inputKind":"TEXT"|"PHOTO"|"DOCUMENT"|"CONTACT"|"LOCATION","validator":"ANY"|"PHONE"|"EMAIL"|"REGEX","regex":"...","retryText":"...","maxAttempts":3 }`. `inputKind` (по умолчанию `TEXT`) — что ждём в ответ (`CONTACT` → телефон: в Telegram показывается кнопка «Поделиться номером», в MAX/Instagram кнопки нет — номер вводится вручную и принимается как телефон на всех платформах; `LOCATION` → `lat,lon`, `PHOTO`/`DOCUMENT` → file_id). Выходы `valid` / `invalid`.
|
|
152
|
+
- `END` — `{}` (конец ветки). **Не добавляй `END`**: ветка и так завершается на узле без исходящих рёбер; явный «конец сценария» бесполезен и убран из палитры редактора. Тип оставлен лишь для совместимости со старыми графами.
|
|
153
|
+
|
|
154
|
+
### Тайминги
|
|
155
|
+
- `DELAY` — пауза. Три вида (`kind`):
|
|
156
|
+
- **`FIXED`** («Отправить через»): `{ "kind":"FIXED", "durationSec": 86400 }` — `durationSec` в **секундах** (60 = 1 мин, 3600 = 1 час, 86400 = 1 сутки). Редактор также пишет `{ "kind":"FIXED", "duration": 24, "unit":"MINUTES"|"HOURS"|"DAYS" }` (минуты/часы/дни — чтобы не вбивать большие числа). Для генерации проще `durationSec`.
|
|
157
|
+
- **`TOMORROW`** («Отправить завтра»): `{ "kind":"TOMORROW", "time":"18:00" }` — завтра в указанное время `HH:mm` (МСК), относительно момента, когда пользователь дошёл до узла.
|
|
158
|
+
- **`UNTIL`** («Отправить в»): `{ "kind":"UNTIL", "isoTimestamp":"2026-06-25T15:00:00Z" }` — конкретный момент в ISO-8601. ⚠️ Рантайм читает только `isoTimestamp`; пары `isoDate`+`time` НЕ работают. Можно шаблон из переменной и смещение: `{ "kind":"UNTIL", "isoTimestamp":"{{var.slot_at}}", "offset":"-24h", "timezone":"Europe/Moscow" }` — «за сутки до записи» (`offset`: `-30m`, `+1d`, единицы s/m/h/d/w; дата без зоны читается в `timezone`, по умолчанию Москва). Момент уже прошёл → выход `late`, если ребро нарисовано, иначе шаг выполняется сразу (`snapshot.lateBy`). Шаблон не разобрался как дата → выход `error`.
|
|
159
|
+
- `SCHEDULE` — `{ "isoDate":"2026-06-25", "time":"18:00", "timezone":"Europe/Moscow" }`. Выходы `scheduled` / `past`. `isoDate` может быть и ISO дата-временем или шаблоном (`"{{var.slot_at}}"`, тогда `time` не нужен) + `offset` как у `DELAY UNTIL`.
|
|
160
|
+
- Прогон без пауз (между `DELAY`, `ASK_QUESTION`/`awaitReply`, ожиданием оплаты) ограничен **5 минутами**: дольше — рантайм обрывает его с ошибкой шага `run deadline exceeded` (прогон `FAILED`, пауза подписчика снята). Длинные цепочки `external_request` / `CALL_WEBHOOK` / `AI_REPLY` разноси `DELAY` — после паузы начинается новый прогон.
|
|
161
|
+
- Массовый одинаковый момент срабатывания (`TOMORROW`/`UNTIL`/`SCHEDULE` у тысяч подписчиков одного бота) расходится постепенно: до 10 срабатываний на бота в секунду, время растёт с аудиторией (≈100 с на 1000 подписчиков, ≈17 мин на 10 000), другие боты не ждут. Не рассчитывай, что все получат сообщение в одну секунду.
|
|
162
|
+
- Если Telegram вернул боту 429 (`retry after N`, пауза не дольше часа), срабатывания `DELAY`/`SCHEDULE` и рассылки этого бота (и по `BROADCAST_FILTER`, и прямые) ждут конца паузы; после такой паузы рассылка продолжается без потерь и без повтора уже доставленного. Это только про паузу 429: если сервис перезапустился посреди отправки, получатель, на котором её прервали, через ≤15 мин может получить рассылку (или прогон сценария) повторно. Ответ подписчику в живом диалоге отправляется сразу, без ожидания; если Telegram откажет снова, шаг уходит в ветку «Ошибка», если она проведена, иначе дальше по обычному выходу.
|
|
163
|
+
- `AI_REPLY` при сбое провайдера (5xx, таймаут) уходит в `error` примерно через минуту — не рассчитывай на долгое ожидание ответа модели.
|
|
164
|
+
|
|
165
|
+
### Состояние / действия
|
|
166
|
+
- `SET_VARIABLE` (`{ "key":"name", "value":"..." }`), `ADD_TAG`/`REMOVE_TAG` (`{ "tag":"lead" }`), `FORMULA` (`{ "expression":"...", "saveTo":"name" }`)
|
|
167
|
+
- `ACTIONS` — непустой пакет действий `{ "actions":[ { "kind":"...", ...поля } ] }`. Допустимые `kind` (иначе ошибка `ACTION_UNKNOWN_KIND`):
|
|
168
|
+
- **метки/автоворонки**: `add_tag`, `remove_tag`, `autoflow_add`, `autoflow_remove` — поле `tag` (`[a-z0-9_-]{1,64}`)
|
|
169
|
+
- **профиль**: `set_field` — `key` (`[a-z_][a-z0-9_]{0,63}`) + `value`; `subscribe`, `unsubscribe`
|
|
170
|
+
- **HTTP**: `external_request` — **основной способ сходить в чужой API**, те же поля и возможности,
|
|
171
|
+
что у узла `CALL_WEBHOOK`: `url` + `method`/`headersJson`/`bodyTemplate`/`timeoutMs` +
|
|
172
|
+
`saveStatusTo`/`saveBodyTo`/`extract`. Отличие одно: своих выходов у действия нет — сбой уводит
|
|
173
|
+
ВЕСЬ блок `ACTIONS` в его выход `error` (если ребро нарисовано; нет — идём по `next`), а
|
|
174
|
+
ветвиться по коду ответа надо следующим блоком `SWITCH`. **Платное действие** (как `CALL_WEBHOOK`):
|
|
175
|
+
на бесплатном тарифе публикация падает с `PREMIUM_NODE_FORBIDDEN`.
|
|
176
|
+
Ещё есть `subscriber_webhook` — `url` + `method`/`headersJson`/`bodyTemplate`
|
|
177
|
+
- **уведомления**: `notify` (`text`) — владельцу бота в бот уведомлений из его профиля (Telegram/MAX); не подключён → действие не удалось, блок уходит в `error`; `subscriber_email` (`email`,`text`)
|
|
178
|
+
- **письмо подписчику**: `send_email` — `email` (обязательно, один адрес — не список; шаблон `{{var.*}}` допустим), `subject`, `text` (обязательно; только обычный текст — HTML не поддерживается, теги уходят в письме экранированными как есть), опц. `replyTo`. Антиабуз: невалидный `email`/`replyTo`, пустой `text`, суточный лимит писем на владельца И на весь сервис, или сбой SMTP → действие не удалось, блок уходит в `error`; отправитель — всегда свой аккаунт сервиса (открытого релея нет).
|
|
179
|
+
- **передача оператору**: `agent_chat` (без полей) — «позвать человека»: диалог встаёт на паузу (бот и ИИ молчат, входящие копятся в «Диалогах»), владельцу уходит уведомление; без бота уведомлений действие помечается `NOT_CONFIGURED`, пауза всё равно включается. Вернуть боту — кнопка в «Диалогах» или MCP `dialog_handoff {active:false}`. В dry-run и headless-прогонах пропускается.
|
|
180
|
+
- **срок ответа оператора (SLA)**: `sla_check` — опц. `maxLevel` (0 — без потолка), `timezone` (для `sla_at`, по умолч. Europe/Moscow). Ставь первым в блоке `ACTIONS` после `DELAY` на срок ответа, ниже в том же блоке — эскалация (`add_tag`, `notify`). Оператор не писал в чат после `agent_chat` → `sla_breached`=`1`, `sla_level` +1, `sla_at` — ISO со смещением, действия блока выполняются; ответил / диалог у бота / тот же таймер повторно → `sla_breached`=`0`, остальные действия блока пропускаются. Новая передача — уровни с 1. Ветвиться дальше — `CONDITION` по `{{var.sla_level}}`.
|
|
181
|
+
- **запись на слоты** (календарь — `booking_calendar_create`/`booking_calendar_list`): `booking_slots` — `calendarId`, опц. `days` (1–62, по умолч. 7), `limit` (1–50, по умолч. 10), `saveTo` (по умолч. `slots`) → переменные `slots` (нумерованный список для сообщения), `slots_json`, `slots_count`; `booking_book` — `calendarId`, `slotAt` (по умолч. `{{var.slot_at}}`; можно номер из списка «2», ISO-время или подпись слота), опц. `slotsVar`, `name`/`phone` (по умолч. `{{var.name}}`/`{{var.phone}}`) → `booking_id`, `slot_at` (ISO с зоной — для напоминания `DELAY UNTIL {{var.slot_at}} offset -24h`), `slot_label`, `booking_status`; занятый слот → выход блока **`taken`** (если ребро нарисовано, иначе `error`); `booking_cancel` — `bookingId` (по умолч. `{{var.booking_id}}`), освобождает слот.
|
|
182
|
+
- **переход в другой сценарий**: `call_scenario` — `graphId` (сценарий ЭТОГО ЖЕ бота того же владельца, id из `list_graphs`; черновик тоже можно), опц. `nodeId` (стартовый блок цели; по умолч. триггер `/start` → первый триггер → первый блок без входящих рёбер — так стартует подсценарий без триггера), `variables: [{ "key":"plan", "value":"{{var.x}}" }]` (ключи `[a-z][a-z0-9_]{0,63}`, служебные `_*` запрещены — `ACTION_BAD_KEY`, в рантайме пропускаются, значения — шаблоны; сохраняются подписчику ДО перехода), `mode` — только `"goto"` (по умолч.). Подписчик уходит в целевой сценарий отдельным прогоном, текущий прогон завершается `OK`; ответы на вопросы и кнопки цели доходят до неё, хотя активный сценарий бота другой (`session.currentGraphId`). Ставь ПОСЛЕДНИМ в блоке (`CALL_SCENARIO_NOT_LAST`), не на себя (`CALL_SCENARIO_SELF`), только `goto` (`CALL_SCENARIO_BAD_MODE`), без цели — `CALL_SCENARIO_NO_TARGET`, чужой/несуществующий/вебхук-сценарий — `CALL_SCENARIO_FORBIDDEN` при публикации. В рантайме отказ = шаг `ok=false`, ошибка `CODE: call_scenario — …`, блок в `error` (нет ребра — `next`): `CALL_SCENARIO_RECURSION` (сценарий уже в цепочке переходов одного события), `CALL_SCENARIO_DEPTH` (> 5 переходов подряд), `CALL_SCENARIO_NOT_FOUND`, `CALL_SCENARIO_FORBIDDEN`, `CALL_SCENARIO_NO_ENTRY`, `CALL_SCENARIO_NO_SUBSCRIBER` (headless-вебхук), `NOT_CONFIGURED` (бэкенд в режиме развязанных сценариев — переход недоступен). Сбой более раннего действия блока не прячется переходом: шаг `ok=false` с кодом того сбоя, прогон `PARTIAL`, переход при этом выполняется. Цель-черновик исполняется как есть — правки черновика сразу видят подписчики, уже перешедшие в него. «Вызвать и вернуться» нет — для возврата поставь в конце цели свой `call_scenario` обратно (сработает, если между ними был ответ подписчика/задержка, иначе `RECURSION`).
|
|
183
|
+
- **контакт заявки**: `lead_link_contact` — `phone`/`email` (по умолч. `{{var.phone}}`/`{{var.email}}`) → склейка с единым контактом владельца по телефону E.164 или email, `{{var.contact_id}}`.
|
|
184
|
+
- **платный доступ / подписки** (бот должен быть админом группы/канала): `invite_link_create` — персональная ссылка на одно вступление: опц. `expireHours` (по умолч. 24), `name`, `saveTo` (по умолч. `invite_link`), `send` (false — не отправлять), `text`; `invite_link_revoke` — `link` (по умолч. `{{var.invite_link}}`); `subscription_extend` — `period` (`1mo`, `30d`, `1y`, `2w`, `12h`), опц. `var` (по умолч. `sub_until`), `timezone` — продлевает от max(сейчас, текущий срок); `subscription_check` — опц. `var`, `saveTo` (по умолч. `sub_active`) → `true`/`false`; `yookassa_charge_saved` — рекуррентное списание по сохранённому способу оплаты: `connectionId` (ЮKassa), `amount`, `description`, опц. `paymentMethodId` (по умолч. `{{var.yk_payment_method_id}}`), `periodKey` (по умолч. `{{var.sub_until}}` — защита от двойного списания за период). Доступ продлевает не списание, а сценарий `TRIGGER_PAYMENT` → `subscription_extend`.
|
|
185
|
+
- **Meta Conversions API** (Integration Core): `meta_capi_event` — `connectionId`, `eventName` (Purchase/Lead/…), опц. `dealId`, `stage`, `eventId`, `email`/`phone` (сырые — хешируются SHA-256), `value`, `currency`, `actionSource`. Поля других провайдеров — в `integration_catalog` (`actions[].inputs`).
|
|
186
|
+
- **Integration Core: поля и переменные-результаты** (все — с `connectionId`; пустое/неверное обязательное поле → действие не вызывает сервис, блок уходит в `error`):
|
|
187
|
+
- `ydelivery_price` (Яндекс Доставка) — ВСЕ поля обязательны: `fromLat`, `fromLon`, `toLat`, `toLon`, `fromAddress`, `toAddress` (город, улица, дом — без квартиры/подъезда), `weightKg`, `lengthCm`, `widthCm`, `heightCm` (габариты упаковки в см, > 0, запятая допустима). Без адресов или габаритов — ошибка (заглушек нет). → `ydelivery_price`, `ydelivery_currency`, `ydelivery_eta` (ISO-дедлайн).
|
|
188
|
+
- `cdek_tariff` (СДЭК) — `fromCity`, `toCity`, `weightGrams`, `tariffCode` → `cdek_price` (только доставка), **`cdek_total_sum`** (итог с доп. услугами, 2 знака — его показывай клиенту), `cdek_days_min`, `cdek_days_max`; `cdek_order_status` — `cdekNumber` → `cdek_status`.
|
|
189
|
+
- `moysklad_find_product` — `search` → `moysklad_product_name`, `moysklad_product_price`, **`moysklad_product_id`**; **`moysklad_product_stock`** — остаток: `productId` (обяз., UUID товара/модификации, обычно `{{var.moysklad_product_id}}`), опц. `stockType` (`stock` по умолч., `freeStock`, `quantity`, `reserve`, `inTransit`) → `moysklad_stock` (товар без движений — `0`). Дальше — `CONDITION` по `{{var.moysklad_stock}}`.
|
|
190
|
+
- `ymarket_order_status` (Яндекс Маркет) — `orderId` (число) → `ymarket_status` (PROCESSING, DELIVERY, DELIVERED, CANCELLED…), `ymarket_substatus` (этап/причина отмены). Подключение: `connect_integration` provider `yandex_market`, creds `{campaignId, apiKey}` (кабинет → Настройки → API и модули); businessId бэкенд определяет сам. Работает на `POST v1/businesses/{businessId}/orders` — ищет заказы за последние 30 дней, более старый → `error` «заказ не найден».
|
|
191
|
+
- **`unisender_send_sms`** — одиночное SMS: `phone` (с кодом страны), `sender` (альфа-имя 3–11 символов, зарегистрированное в Unisender), `text` (до 1000 символов) — все обязательны → `unisender_sms_id`. `unisender_subscribe` → `unisender_person_id`.
|
|
192
|
+
- `rest_api_call` — в `body` (JSON) `{{var.x}}` сам ставит кавычки строкам; число или boolean в переменной уходит своим типом (`{"qty": {{var.qty}}}` → `{"qty": 5}`). Тип — по значению переменной, не по виду строки: введённый текст из цифр (телефон) остаётся строкой.
|
|
193
|
+
- **бот/шаг**: `stop_bot`, `delete_step_message`, `cancel_payment_subscription`
|
|
194
|
+
- **Google Таблицы (работает)**: `gsheets_send` — дописать строку-заявку в таблицу: `{ "kind":"gsheets_send", "googleEmail":"me@gmail.com", "spreadsheetId":"<id таблицы>", "sheetName":"Лист1", "cells":["{{from.first_name}}","{{var.phone}}","{{var.email}}"] }`. `cells` — значения по порядку (шаблоны), бот дописывает их строкой в конец листа. Google-аккаунт подключается В ВЕБЕ (`/bots` → у действия кнопка «Подключить Google»), НЕ через MCP — у пользователя уже должен быть подключён `googleEmail`. Нужны `googleEmail` + `spreadsheetId` + непустой `cells[]` (иначе `ACTION_GSHEETS_INCOMPLETE`).
|
|
195
|
+
Остальные четыре действия с таблицами тоже РАБОТАЮТ и тоже требуют `googleEmail` + `spreadsheetId`:
|
|
196
|
+
`gsheets_get` (`range` → `saveTo`), `gsheets_update` (`range`, `values[]`), `gsheets_write_cell`
|
|
197
|
+
(`cell`, `value`), `gsheets_read_cell` (`cell` → `saveTo`; пустая ячейка не ошибка — переменная станет `""`).
|
|
198
|
+
- **CRM и внешние системы — РАБОТАЮТ** (реальные HTTP-клиенты на бэкенде, не заглушки).
|
|
199
|
+
Всем им нужен **`connectionId`** — id подключения пользователя; без него действие падает
|
|
200
|
+
«не выбрано подключение». **Узнать id: инструмент MCP `list_integrations`** (отдаёт
|
|
201
|
+
`{id, provider, title, hint}`; сами креды не отдаются). Значения полей — шаблоны
|
|
202
|
+
(`{{var.x}}`, `{{from.first_name}}`).
|
|
203
|
+
- `amocrm_send` — создать сделку (+контакт): `connectionId`, `leadName`, опц. `price`,
|
|
204
|
+
`pipelineId`, `statusId` (числа), `contactName`, `phone`, `email`. Если все три контактных
|
|
205
|
+
поля после рендера пусты — сделка уходит без контакта. В переменные кладёт `amo_lead_id`
|
|
206
|
+
и `amo_contact_id` (если amo его вернул).
|
|
207
|
+
- `amocrm_update` — частичное обновление сделки: `connectionId`, `leadId` (обычно
|
|
208
|
+
`{{var.amo_lead_id}}`), плюс те же `leadName`/`price`/`pipelineId`/`statusId`. Пустой
|
|
209
|
+
`leadId` или отсутствие полей для обновления — отказ.
|
|
210
|
+
- `bitrix24_call` — любой REST-метод Битрикс24: `connectionId`, **`b24method`** (именно так,
|
|
211
|
+
не `method` — это имя занято HTTP-методом «Внешнего запроса»), `fields` — список пар
|
|
212
|
+
`{key, value}` (ключ вида `fields[TITLE]` разворачивается во вложенную карту),
|
|
213
|
+
`extract` — список `{path, saveTo}` для JsonPath-извлечения ответа в переменные.
|
|
214
|
+
- `getcourse_send` — добавить/обновить пользователя: `connectionId`, `email`, опц. `userName`,
|
|
215
|
+
`phone`, `groups` (CSV групп), `addfields` (карта доп.полей). Повторная заявка обновляет,
|
|
216
|
+
а не дублирует.
|
|
217
|
+
- `getcourse_order` — создать заказ: те же поля пользователя + `offerCode`, опц. `dealStatus`,
|
|
218
|
+
`dealComment`. В переменные кладёт `gc_deal_id`, если GetCourse его вернул.
|
|
219
|
+
- `yametrika_event` — офлайн-конверсия в Я.Метрику: `connectionId`, `idType`
|
|
220
|
+
(`ClientId`|`Yclid`, по умолчанию `ClientId`), **`idValue`** (обязателен после рендера —
|
|
221
|
+
пустой обрывает действие), опц. `target`, `price`, `currency` (по умолчанию `RUB`),
|
|
222
|
+
`dateTime` (unix-секунды, по умолчанию «сейчас»).
|
|
223
|
+
- **Единственное НЕ интегрированное действие**: `cancel_payment_subscription` —
|
|
224
|
+
принимается как no-op с пометкой `integration_not_connected`.
|
|
225
|
+
- **модерация группы**: `group_unban`, `group_kick`, `group_approve`, `group_decline`
|
|
226
|
+
|
|
227
|
+
### Внешнее / прочее
|
|
228
|
+
- `CALL_WEBHOOK` — `{ "url":"https://...", "method":"POST", "headersJson":"{\"X-Key\":\"…\"}",
|
|
229
|
+
"bodyTemplate":"{...}", "timeoutMs":5000, "saveStatusTo":"http_status", "saveBodyTo":"http_body",
|
|
230
|
+
"extract":[{"path":"$.id","saveTo":"crm_id"}] }`. Выходы `ok` (код 2xx) / `error` (код ≥ 400,
|
|
231
|
+
сетевая ошибка, таймаут, отказ SSRF-гарда). Если ребра `error` нет — прогон идёт по `next`.
|
|
232
|
+
- `timeoutMs` — на этот узел; 0/не задан = общий клиент (connect 5 с / read 7 с), иначе значение
|
|
233
|
+
прижимается к диапазону **500…30000 мс**.
|
|
234
|
+
- `saveStatusTo` / `saveBodyTo` — имена переменных для HTTP-кода и тела ответа целиком.
|
|
235
|
+
Пишутся ВСЕГДА, в том числе на `error`: при сетевой ошибке код `0` и пустое тело (чтобы в
|
|
236
|
+
переменной не осталось значение прошлого прогона). Тело длиннее 65 536 символов обрезается — как любое строковое значение переменной (список/объект длиннее, например из `extract`, не сохраняется); все переменные подписчика вместе с именами — до 262 144 символов, запись сверх лимита не сохраняется (переменная прежняя).
|
|
237
|
+
Это штатный способ разветвиться по коду ответа: `saveStatusTo` → `SWITCH`.
|
|
238
|
+
- `extract` — разбор JSON-тела по JsonPath в переменные (работает только на валидном JSON).
|
|
239
|
+
- `AI_REPLY` — ответ модели. Два режима, переключатель — поле `mode` (`"simple"`/не задано, либо
|
|
240
|
+
`"agent"`). Узел без `mode` работает дословно как режим `simple` (обратная совместимость: в
|
|
241
|
+
проде живут графы без этого поля). **Оба режима платные** (`PREMIUM_NODES`): на бесплатном
|
|
242
|
+
тарифе публикация падает с `PREMIUM_NODE_FORBIDDEN`. Веб-редактор показывает поле
|
|
243
|
+
`quotaFallbackText` в инспекторе для ОБОИХ режимов (без подстановки `{{var.x}}` — уходит клиенту
|
|
244
|
+
как есть).
|
|
245
|
+
|
|
246
|
+
**`mode: "simple"`** (сегодняшнее поведение, без изменений):
|
|
247
|
+
`{ "systemPrompt":"Ты консультант магазина.", "userPromptTemplate":"Вопрос: {{last_text}}",
|
|
248
|
+
"sendToUser": true, "saveTo":"ai_answer", "quotaFallbackText":"Спросите менеджера" }`.
|
|
249
|
+
`userPromptTemplate` обязателен (пустой → выход `error`). `sendToUser:true` — отправить ответ
|
|
250
|
+
подписчику; `saveTo` — положить в переменную. Если месячный AI-бюджет тарифа исчерпан, узел НЕ
|
|
251
|
+
ошибка: отправляется `quotaFallbackText` (если задан) и сценарий идёт дальше по `next`. В этом
|
|
252
|
+
случае `saveTo` НЕ обновляется — переменная хранит значение прошлого прогона (или остаётся
|
|
253
|
+
пустой, если прогона ещё не было). Температура задаётся глобально на сервере — поле
|
|
254
|
+
`temperature` в конфиге рантайм не читает.
|
|
255
|
+
|
|
256
|
+
**`mode: "agent"`** — поиск по базе знаний + ветки-действия + память диалога на этом узле. Температура
|
|
257
|
+
агента своя, серверная (`botbuilder.ai.agent-temperature`, по умолчанию 0.3), `temperature` узла не читается
|
|
258
|
+
(спека `docs/superpowers/specs/2026-09-21-ai-agent-node-design.md`, раздел 10). У узла-агента
|
|
259
|
+
**нет выхода `next`** — вместо него именованные ветки ниже.
|
|
260
|
+
```json
|
|
261
|
+
{ "mode": "agent", "knowledgeBaseId": "<id базы знаний>", "strict": true,
|
|
262
|
+
"systemPrompt": "Ты консультант магазина. Отвечай по базе, вежливо и по делу.",
|
|
263
|
+
"actions": [ { "id": "order", "title": "Оформить заказ", "when": "клиент готов купить" } ],
|
|
264
|
+
"extract": [ { "variable": "phone", "description": "телефон клиента" } ],
|
|
265
|
+
"quotaFallbackText": "Спросите менеджера" }
|
|
266
|
+
```
|
|
267
|
+
Необязательные числовые/флаговые поля и их дефолты (можно не указывать вовсе):
|
|
268
|
+
`directAnswer` (`false`), `directAnswerThreshold` (`0.88`), `minScore` (`0.35`), `topK` (`5`),
|
|
269
|
+
`maxContextChars` (`6000`), `historyTurns` (`6`).
|
|
270
|
+
- `knowledgeBaseId` — id базы знаний ЭТОГО владельца (иначе рантайм ведёт себя так, будто базы
|
|
271
|
+
нет). `strict` (по умолчанию `true`) — без базы в строгом режиме публикация падает с
|
|
272
|
+
`AGENT_NO_KNOWLEDGE_BASE`.
|
|
273
|
+
- **Без найденных в базе фрагментов агент уходит в `unknown` в ЛЮБОМ режиме** — если хитов нет
|
|
274
|
+
или лучший скор ниже `minScore`, до вызова модели дело не доходит вообще (нестрогий режим
|
|
275
|
+
агента без базы или без совпадений не «отвечает по общим знаниям», он так же уходит в
|
|
276
|
+
`unknown`). **Исключение — узел с непустым `actions[]`:** он и без фрагментов зовёт модель
|
|
277
|
+
(платно), чтобы та могла выбрать действие — «запишите меня на завтра» слов из базы не содержит;
|
|
278
|
+
её ответ без фрагментов в любом режиме (и в нестрогом) уходит в `unknown` — только действие. Узел только с
|
|
279
|
+
`extract[]` выходит рано, как раньше. `strict` решает только то, что делает модель, когда релевантные фрагменты уже
|
|
280
|
+
нашлись: в строгом режиме — отвечает исключительно по ним; без строгого режима — может
|
|
281
|
+
дополнить их общими знаниями модели, но не про цены/сроки/обещания — это всегда только из
|
|
282
|
+
базы и инструкции.
|
|
283
|
+
- `directAnswer` (по умолчанию `false`) — прямой ответ готовой парой вопрос-ответ БЕЗ обращения
|
|
284
|
+
к модели (бесплатно), когда совпадение по базе ≥ `directAnswerThreshold`. Пара ищется по вектору
|
|
285
|
+
своего вопроса, так что порог 0.88 проходят почти дословные формулировки. Работает только для
|
|
286
|
+
документов-пар вопрос-ответ (не для кусков файлов/сайта) — кусок PDF, отданный дословно,
|
|
287
|
+
выглядел бы поломкой.
|
|
288
|
+
- `minScore` — порог релевантности фрагмента для решения «есть о чём отвечать»; ниже — выход
|
|
289
|
+
`unknown`. `topK`/`maxContextChars` — сколько фрагментов брать и лимит символов контекста.
|
|
290
|
+
`historyTurns` — сколько реплик истории диалога передавать модели.
|
|
291
|
+
- `systemPrompt` — инструкция агента. Мягкая норма — **до 10 000 символов**: длиннее модель хуже
|
|
292
|
+
держит середину, и каждый ответ дороже. У бэкенда для этого нет отдельного предупреждения при
|
|
293
|
+
публикации (веб-инспектор просто красит счётчик символов) — только `validate.mjs` (этот
|
|
294
|
+
скилл) предупреждает об этом явно. Жёсткий потолок — **30 000 символов**
|
|
295
|
+
(`AGENT_PROMPT_TOO_LONG`, блокирует публикацию и у бэкенда, и у `validate.mjs`). Платформа
|
|
296
|
+
сама добавляет ПЕРЕД инструкцией служебный блок: защиту от инъекций/вытягивания инструкции
|
|
297
|
+
через реплики клиента, историю и фрагменты базы, запрет писать, что действие уже выполнено
|
|
298
|
+
(это делает сценарий, не модель), запрет обещать несделанное, и формат ответа под конкретный
|
|
299
|
+
мессенджер — **дублировать это в `systemPrompt` не нужно**.
|
|
300
|
+
- `actions[]` — ветки, которые выбирает сама модель по смыслу разговора: `id` (латиница/цифры/`_`),
|
|
301
|
+
`title` (человеческое название — без него запись целиком игнорируется рантаймом и валидатором),
|
|
302
|
+
`when` (опц., подсказка модели, когда применять). Каждому действию — своё ребро
|
|
303
|
+
`action_<id>`; повтор `id` → `AGENT_DUPLICATE_ACTION_ID`, отсутствие ребра →
|
|
304
|
+
`AGENT_ACTION_NOT_CONNECTED`.
|
|
305
|
+
- `extract[]` — переменные, которые агент сам заполняет из разговора: `variable`
|
|
306
|
+
(`[a-z_][a-z0-9_]{0,63}`, не `ai_summary` — оно зарезервировано), `description` (что искать).
|
|
307
|
+
Значение — **слова клиента, а не проверенный факт**. Кривая запись → `AGENT_BAD_EXTRACT`.
|
|
308
|
+
- При уходе в действие (`action_<id>`) агент сам готовит сводку разговора для менеджера —
|
|
309
|
+
без отдельной настройки, кладёт её в `{{var.ai_summary}}` (до 1500 символов, тоже слова
|
|
310
|
+
клиента, не проверенные данные).
|
|
311
|
+
- Выходы: `answered` (ответила модель — по базе, а без строгого режима ещё и общими знаниями),
|
|
312
|
+
`action_<id>` (по числу `actions[]`), `unknown` (не знает ответа — нет фрагментов выше
|
|
313
|
+
`minScore`, ветка не подключена не блокирует публикацию, только предупреждение
|
|
314
|
+
`AGENT_UNKNOWN_NOT_CONNECTED`, но диалог упрётся в тупик), `budget_exhausted` (месячный
|
|
315
|
+
AI-бюджет исчерпан, если ветка подключена), `error` (сбой модели/поиска, ИИ не настроен на
|
|
316
|
+
сервере, headless-вебхук — у агента нет собеседника — или исчерпан бюджет, а ветка
|
|
317
|
+
`budget_exhausted` не подключена). Прямой ответ (`directAnswer`) отправляется и идёт в выход
|
|
318
|
+
`answered`, как обычный ответ модели.
|
|
319
|
+
- **Исчерпание бюджета у агента — НЕ `next`** (такого выхода у узла нет): уходит в
|
|
320
|
+
`budget_exhausted`, если ветка подключена, иначе в `error`. `quotaFallbackText` (без
|
|
321
|
+
подстановки переменных) отправляется в обоих случаях, если задан. Это касается и
|
|
322
|
+
`directAnswer` — бесплатный прямой ответ тоже не сработает при исчерпанном бюджете месяца,
|
|
323
|
+
проверка бюджета идёт раньше поиска по базе.
|
|
324
|
+
- `PAYMENT_LINK` — сообщение с кнопкой-ссылкой на оплату (сам платёж не проводит).
|
|
325
|
+
`{ "paymentUrl":"https://example.com/pay?user={{from.id}}", "description":"Оплатите подписку:",
|
|
326
|
+
"buttonText":"Оплатить" }`.
|
|
327
|
+
- `YOOKASSA_PAYMENT` — узел оплаты. **В новых сценариях его не ставь**: выставление счёта — это
|
|
328
|
+
действие, поэтому оно живёт действием `issue_invoice` внутри `ACTIONS` (см. ниже), а узла больше
|
|
329
|
+
нет в палитре. Тип жив в рантайме только ради графов, где он уже стоит.
|
|
330
|
+
- Действие **`issue_invoice`** внутри `ACTIONS` — настоящий счёт на **кассу владельца бота**
|
|
331
|
+
(деньги идут на его магазин, не на счёт сервиса) и, если нужно, ожидание оплаты.
|
|
332
|
+
`{ "kind":"issue_invoice", "connectionId":"<id подключения ЮKassa>", "amount":"990",
|
|
333
|
+
"description":"Доступ к курсу", "buttonText":"Оплатить", "timeoutMinutes":60 }`.
|
|
334
|
+
- `connectionId` **обязателен** — подключение из реестра с `provider=YOOKASSA`
|
|
335
|
+
(`list_integrations`). Нет его → `YK_NO_CONNECTION`. Касса обязана принадлежать владельцу
|
|
336
|
+
бота, чужой id рантайм отвергает.
|
|
337
|
+
- `amount` обязателен, > 0, можно шаблоном (`{{var.price}}`); `description` обязателен.
|
|
338
|
+
- **Выходы даёт БЛОК `ACTIONS`** (тем же приёмом, что и `error`): при наличии счёта у блока
|
|
339
|
+
появляются `paid` и `timeout`. Ключевое правило: **поведение зависит от того, протянул ли ты
|
|
340
|
+
ветку `paid`.**
|
|
341
|
+
- Ветки `paid` НЕТ → блок просто шлёт счёт и идёт по `next`, ничего не дожидаясь.
|
|
342
|
+
- Ветка `paid` ЕСТЬ → сценарий встаёт на паузу до подтверждения оплаты кассой; не дождался за
|
|
343
|
+
`timeoutMinutes` → уйдёт в `timeout`. Сообщения подписчика до оплаты (другие триггеры, ветки до END,
|
|
344
|
+
вопроса или задержки) ожидание не сбрасывают: `paid`/`timeout` всё равно сработают. Сбрасывает его только
|
|
345
|
+
пауза на ДРУГОМ счёте с веткой `paid` — тогда оплата прежней ссылки сценарий не продолжает.
|
|
346
|
+
- ⚠️ **Счёт обязан быть ПОСЛЕДНИМ действием в блоке** (`INVOICE_NOT_LAST`) и только один
|
|
347
|
+
(`INVOICE_DUPLICATE`). Причина: с веткой `paid` блок встаёт на паузу, а возобновиться с
|
|
348
|
+
середины списка движок не умеет — действия после счёта молча не выполнились бы.
|
|
349
|
+
- `timeoutMinutes` — 1…1440, по умолчанию 60. Имеет смысл только вместе с веткой `paid`.
|
|
350
|
+
- Переменные после шага: `{{var.payment_url}}`, `{{var.payment_id}}`.
|
|
351
|
+
- Повторный проход по счёту: пока попытка ЭТОГО шага жива (не оплачена, таймаут не сработал, другой счёт с
|
|
352
|
+
`paid` её не сменил) — та же ссылка (тот же платёж), даже если между проходами подписчик ушёл в другую
|
|
353
|
+
ветку; после оплаты или таймаута — новый счёт. Оплата, пришедшая после таймаута, ветку `paid` не продолжает, зато запускает сценарии владельца с `TRIGGER_PAYMENT` в диалоге этого подписчика (один раз на платёж; бот выключен — ЮKassa повторяет уведомление, запуск после включения бота) — для опоздавших заведи такой сценарий. Если таймаут был пропущен, пока бот был выключен, повторный проход сперва спрашивает ЮKassa: платёж оплачен — шаг сразу идёт по `paid` (нового счёта нет); статус не узнать — та же ссылка; не оплачен — новый счёт.
|
|
354
|
+
- Без ветки `paid` ожидания нет: каждый проход по такому счёту — новый платёж с новой ссылкой.
|
|
355
|
+
- Действие бесплатное (в отличие от `external_request`).
|
|
356
|
+
- Чтобы `paid` срабатывал сразу, владелец вписывает адрес уведомлений из карточки подключения в кабинет
|
|
357
|
+
ЮKassa (событие `payment.succeeded`). Без уведомлений сервис сам опрашивает статус ожидающих счетов
|
|
358
|
+
(`GET /v3/payments/{id}`: через ~30 с, дальше реже, до раза в 10 мин, пока не вышел таймаут) — `paid`
|
|
359
|
+
сработает с задержкой до нескольких минут, ровно один раз (опоздавшее уведомление ничего не задвоит).
|
|
360
|
+
- Бот выключен в момент оплаты или проверка платежа не удалась (сбой ЮKassa, устаревший ключ в подключении) →
|
|
361
|
+
ЮKassa повторяет уведомление до суток; `paid` сработает на первом удачном повторе, если сценарий к тому
|
|
362
|
+
времени ещё не ушёл в `timeout`. Не удалось за сутки — сценарий по этой оплате не продолжится.
|
|
363
|
+
- `CALL_WEBHOOK` — тоже платный узел, см. раздел «Внешнее / прочее». **В новых сценариях его не
|
|
364
|
+
ставь**: узла больше нет ни в палитре, ни в меню — внешний запрос собирается действием
|
|
365
|
+
`external_request` внутри `ACTIONS`. Тип живёт в рантайме только ради графов, где он уже стоит.
|
|
366
|
+
- Действие `external_request` внутри `ACTIONS` — **тоже платное**, гейт тот же.
|
|
367
|
+
|
|
368
|
+
## Лимиты тарифа, которые видит сборщик графов
|
|
369
|
+
|
|
370
|
+
- **Блоков в сценарии.** Публикация падает с `NODE_LIMIT_EXCEEDED` (в тексте — сколько блоков в
|
|
371
|
+
графе и сколько даёт тариф). Ошибка на весь граф, `nodeId` пустой. Проверять нечем заранее:
|
|
372
|
+
число блоков берётся из `nodes[]`, лимит — из тарифа владельца.
|
|
373
|
+
- **Число сценариев.** `create_graph`, `create_graph_from_template`, `clone_graph`, `copy_graph` и
|
|
374
|
+
создание сценария в вебе отдают **HTTP 402** `{error, upgradeUrl}`, когда лимит исчерпан.
|
|
375
|
+
Считаются сценарии, которые завёл человек; снимок публикации место не занимает.
|
|
376
|
+
- **Переменные** (глобальные и на сценарий) тоже лимитированы тарифом — сама подсистема переменных
|
|
377
|
+
ещё не построена, поле лимита в тарифе уже есть.
|
|
378
|
+
|
|
379
|
+
## Условия CONDITION
|
|
380
|
+
|
|
381
|
+
Каждый элемент `conditions[]` — `{ "kind", "op", ...поля }`. Любая внутренняя ошибка условия = `false` (узел уходит в `no`).
|
|
382
|
+
|
|
383
|
+
| `kind` | `op` (допустимые) | Поля | Что проверяет |
|
|
384
|
+
|---|---|---|---|
|
|
385
|
+
| `TAG` | `HAS`, `NOT_HAS` | `value` — метка `[a-z0-9_-]{1,64}` | есть ли у пользователя тег |
|
|
386
|
+
| `VARIABLE` | `EQUALS`, `NOT_EQUALS`, `CONTAINS`, `NOT_EMPTY`, `EMPTY`, `GT`, `LT` | `key` — имя переменной `[a-z_][a-z0-9_]{0,63}`, `value` | значение переменной (`GT`/`LT` — числовое сравнение) |
|
|
387
|
+
| `UTM` | `EQUALS`, `CONTAINS`, `NOT_EMPTY`, `EMPTY` | `key` ∈ `source`/`medium`/`campaign`/`content`/`term`, `value` | UTM-метку клика (`utm_<key>`), регистронезависимо |
|
|
388
|
+
| `NAME` | `EQUALS`, `CONTAINS`, `NOT_EMPTY`, `EMPTY` | `value` | имя из профиля, регистронезависимо |
|
|
389
|
+
| `EMAIL` | `EQUALS`, `CONTAINS`, `NOT_EMPTY`, `EMPTY` | `value` | email из профиля |
|
|
390
|
+
| `PHONE` | `EQUALS`, `CONTAINS`, `NOT_EMPTY`, `EMPTY` | `value` | телефон из профиля |
|
|
391
|
+
| `USERNAME` | `EQUALS`, `CONTAINS` | `value` | @username пользователя Telegram |
|
|
392
|
+
| `SUBSCRIBED` | `SUBSCRIBED`, `NOT_SUBSCRIBED` | `key` — **числовой** id канала/группы (напр. `-1001234567890`); узнать числовой id подключённых каналов: `list_channels(botId)` | подписан ли пользователь на канал бота |
|
|
393
|
+
| `LINK_CLICKED` | `CLICKED`, `NOT_CLICKED` | `key` — **`id` узла-шага** с отслеживаемой URL-кнопкой (тот же UUID, что у `SEND_MESSAGE`) | кликал ли пользователь по ссылке этого шага |
|
|
394
|
+
| `CURRENT_DATE` | `BEFORE`, `AFTER`, `EQUALS` | `value` — дата `YYYY-MM-DD` | сегодняшнюю дату (МСК) |
|
|
395
|
+
| `CURRENT_TIME` | `BETWEEN` | `value`, `value2` — время `HH:mm` | текущее время в интервале (через полночь — если `value`>`value2`) |
|
|
396
|
+
| `DAY_OF_WEEK` | `IN` | `days` — массив из `MON`,`TUE`,`WED`,`THU`,`FRI`,`SAT`,`SUN` | день недели (МСК) |
|
|
397
|
+
|
|
398
|
+
Для `NOT_EMPTY`/`EMPTY` поле `value` не нужно. `UTM` без `key` всегда `false`.
|
|
399
|
+
|
|
400
|
+
**`SUBSCRIBED`** работает только если бот **админ** в канале/группе и канал «привязан» (бот узнаёт о членстве через хук `my_chat_member` — добавь бота в канал админом). `key` должен парситься в число, иначе условие = `false`. Профильные поля (`NAME`/`EMAIL`/`PHONE`) и UTM заполняются по ходу воронки (`ASK_QUESTION`→`saveTo`, диплинк-клик с UTM).
|
|
401
|
+
|
|
402
|
+
**`LINK_CLICKED`** проверяет факт клика по URL-кнопке конкретного шага. Чтобы условие работало: у нужного `SEND_MESSAGE` хотя бы одна кнопка `kind:"URL"` с `"track": true`, а в условии `key` = `id` этого узла-шага. Клик фиксируется через публичный редирект бота, поэтому условие имеет смысл ставить **после** `DELAY`/`ASK_QUESTION` (дай пользователю время кликнуть).
|
|
403
|
+
|
|
404
|
+
> **Платформа MAX.** Боты конструктора умеют работать и в мессенджере MAX. Там не поддерживаются `SUBSCRIBED`/`NOT_SUBSCRIBED` (нет членства в каналах) и reply-клавиатуры; публикация такого графа на MAX-бот вернёт **мягкие предупреждения** (не блокирует). Для Telegram-ботов всё работает как описано.
|
|
405
|
+
|
|
406
|
+
## Платформа Instagram
|
|
407
|
+
|
|
408
|
+
> **Сейчас Instagram выключен в сервисе:** IG-бота не подключить, страница `/bots/instagram` ведёт на `/bots`. Раздел оставлен на будущее включение.
|
|
409
|
+
|
|
410
|
+
IG-боты подключаются через OAuth на странице **`/bots/instagram`** (раздел «Подключения» → карточка
|
|
411
|
+
Instagram; прежний раздел «Инструменты роста» / `/growth` расформирован и редиректит) — **без вставки токена вручную**; у IG нет персонального бот-токена. После OAuth бот получает доступ к Messaging API через привязанный Instagram Business/Creator-аккаунт.
|
|
412
|
+
|
|
413
|
+
### Разрешённые типы узлов для IG-ботов
|
|
414
|
+
|
|
415
|
+
Только следующие (всё остальное — ошибка `IG_NODE_UNSUPPORTED` при публикации):
|
|
416
|
+
|
|
417
|
+
| Тип | Доступен в IG |
|
|
418
|
+
|---|---|
|
|
419
|
+
| `TRIGGER_IG_COMMENT`, `TRIGGER_IG_DM`, `TRIGGER_IG_STORY_REPLY`, `TRIGGER_IG_STORY_MENTION` | ✅ (триггеры входа) |
|
|
420
|
+
| `SEND_MESSAGE`, `SEND_PHOTO` | ✅ |
|
|
421
|
+
| `BRANCH`, `CONDITION`, `SWITCH`, `STOP_AND_ERROR` | ✅ |
|
|
422
|
+
| `SET_VARIABLE`, `ADD_TAG`, `REMOVE_TAG`, `FORMULA` | ✅ |
|
|
423
|
+
| `ASK_QUESTION` | ✅ (с ограничениями — см. ниже) |
|
|
424
|
+
| `DELAY` | ✅ (не более 24ч — см. ниже) |
|
|
425
|
+
| `END` | ✅ |
|
|
426
|
+
| `TRIGGER_COMMAND`, `TRIGGER_CALLBACK`, `TRIGGER_TEXT` | ❌ |
|
|
427
|
+
| `BROADCAST_FILTER` (рассылки) | ❌ |
|
|
428
|
+
| `SCHEDULE`, `ACTIONS`, `CALL_WEBHOOK`, `AI_REPLY`, `PAYMENT_LINK`, `YOOKASSA_PAYMENT` | ❌ |
|
|
429
|
+
| `TRIGGER_PAYMENT` | ❌ |
|
|
430
|
+
|
|
431
|
+
### Ограничения IG-ботов
|
|
432
|
+
|
|
433
|
+
- **Нет команд** — вход только через `TRIGGER_IG_COMMENT` / `TRIGGER_IG_DM` / `TRIGGER_IG_STORY_REPLY` / `TRIGGER_IG_STORY_MENTION`. `/start` и другие команды не поддерживаются.
|
|
434
|
+
- **Нет рассылок** — `BROADCAST_FILTER` недоступен.
|
|
435
|
+
- **DELAY не более 24 часов** — Instagram доставляет сообщения только в течение 24-часового окна после последнего входящего действия (`IG_DELAY_OVER_24H`). `DELAY` с `kind=TOMORROW` или `kind=UNTIL` блокируются (они заведомо > 24ч). `kind=FIXED` с `durationSec > 86400` тоже блокируется.
|
|
436
|
+
- **`ASK_QUESTION`** — `inputKind`: `TEXT`, `EMAIL`, `PHONE`, `NUMBER`, `CONTACT` (`IG_INPUT_UNSUPPORTED` для остальных). `CONTACT` допустим: кнопки «Поделиться номером» в IG нет, поэтому бот отправляет текст-инструкцию и принимает номер, набранный вручную (как телефон). Нельзя запрашивать `LOCATION`, `PHOTO`, `DOCUMENT`.
|
|
437
|
+
- **Нет reply-клавиатур** — кнопки IG работают как inline (URL или Deep Link); стиль кнопок ограничен возможностями IG Messaging API.
|
|
438
|
+
- **Нет SUBSCRIBED** — условие «подписан на канал» недоступно.
|
|
439
|
+
- **Коммент → ЛС (Private Reply)** — на `SEND_MESSAGE`/`SEND_PHOTO` в графе с `TRIGGER_IG_COMMENT` укажи `"igReplyChannel": "dm"`, чтобы ответить автору комментария в Direct (а не публично под постом). По умолчанию (`"comment"`) — публичный ответ. Подробнее — `igReplyChannel` в разделе «Сообщения». **`"comment"` тоже требует, чтобы шаг был достижим от `TRIGGER_IG_COMMENT`** — иначе ошибка валидации `IG_COMMENT_REPLY_NO_COMMENT_TRIGGER` (в DM/story-флоу отвечать в комментарии некуда).
|
|
440
|
+
|
|
441
|
+
### Оффлайн-проверка IG-графа
|
|
442
|
+
|
|
443
|
+
```bash
|
|
444
|
+
node validate.mjs graph.json --platform=INSTAGRAM
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
Ловит `IG_NODE_UNSUPPORTED`, `IG_DELAY_OVER_24H`, `IG_INPUT_UNSUPPORTED` — в дополнение ко всем обычным структурным проверкам.
|
|
448
|
+
|
|
449
|
+
## Кросс-платформенное копирование (Telegram ⇄ MAX)
|
|
450
|
+
|
|
451
|
+
Инструмент `copy_graph` копирует граф в **другого бота** пользователя (`targetBotId`), в т.ч. на другую платформу. Формат графа один и тот же; различается лишь платформа бота-получателя. При копировании в MAX-бот несовместимые узлы **адаптируются**, отчёт — в `notes[]`:
|
|
452
|
+
|
|
453
|
+
| code | severity | что значит |
|
|
454
|
+
|---|---|---|
|
|
455
|
+
| `MAX_CONTACT_AS_TEXT` | TRANSFORM | вопрос с `inputKind=CONTACT` переписан в `inputKind=TEXT` + `validator=PHONE` (в MAX нет кнопки «поделиться контактом»; иначе узел стал бы тупиком) |
|
|
456
|
+
| `MAX_SUBSCRIBED_ALWAYS_NO` | MANUAL | условие `SUBSCRIBED` оставлено как есть, но в MAX всегда «не подписан» — проверьте ветвление вручную |
|
|
457
|
+
| `MAX_VOICE_AS_AUDIO` | INFO | голосовое уйдёт обычным аудио |
|
|
458
|
+
| `MAX_VIDEO_NOTE_AS_VIDEO` | INFO | кружок уйдёт обычным видео |
|
|
459
|
+
| `MAX_GALLERY_AS_ATTACHMENTS` | INFO | галерея уйдёт одним сообщением с вложениями |
|
|
460
|
+
| `MAX_DELETE_NOOP` | INFO | действие «удалить сообщение» в MAX игнорируется |
|
|
461
|
+
|
|
462
|
+
`preview: true` возвращает только `notes[]` (без копирования). Копирование в Telegram-бота (или в бота той же платформы) — точная копия, `notes[]` пустой. Новый граф создаётся как `DRAFT` с именем «… (copy)». Копировать в тот же бот нельзя (для дублирования — `clone_graph`).
|
|
463
|
+
|
|
464
|
+
## Выходные хэндлы (`sourceHandle`) — шпаргалка
|
|
465
|
+
| Узел | Хэндлы |
|
|
466
|
+
|---|---|
|
|
467
|
+
| обычный поток | `next` |
|
|
468
|
+
| кнопки сообщения (`buttons`) | `btn_0`, `btn_1`, … (по индексу кнопки, плоско по всем рядам) |
|
|
469
|
+
| `CONDITION` | `yes`, `no` |
|
|
470
|
+
| `BRANCH` | `case_<id>`, `default` |
|
|
471
|
+
| `ASK_QUESTION` | `valid`, `invalid` |
|
|
472
|
+
| `SEND_MESSAGE` с `awaitReply:true` | `valid`, `invalid` (+ `btn_N` для кнопок) |
|
|
473
|
+
| `CALL_WEBHOOK` | `ok`, `error` |
|
|
474
|
+
| `ACTIONS` | `next`, плюс `error` — если внутри есть действие, которое может упасть (внешний запрос, CRM, Таблицы); `taken` — слот `booking_book` занят |
|
|
475
|
+
| `SWITCH` | `case_<id>`, `default` |
|
|
476
|
+
| `STOP_AND_ERROR` | выходов нет (терминатор) |
|
|
477
|
+
| `SCHEDULE` | `scheduled`, `past` |
|
|
478
|
+
| `DELAY` | `next`; у `UNTIL` ещё `late` (момент уже прошёл) и `error` (шаблон не разобрался как дата) |
|
|
479
|
+
| `AI_REPLY` (`mode` не задан/`simple`) | `next`, `error` |
|
|
480
|
+
| `AI_REPLY` (`mode:"agent"`) | `answered`, `action_<id>` (по числу `actions[]`), `unknown`, `budget_exhausted`, `error` — **выхода `next` нет** |
|
|
481
|
+
|
|
482
|
+
## Подстановки в тексте
|
|
483
|
+
`{{from.first_name}}`, `{{from.username}}`, `{{var.<имя>}}`, либо `{Имя}` как плейсхолдер. Имена переменных/меток: `[a-z_][a-z0-9_]{0,63}` (var) и `[a-z0-9_-]{1,64}` (tag).
|