CRM-действия роботов: контракт CRM ↔ robots-clj
Внутренний документ для разработчиков. В меню справки его нет. Это контракт между CRM (Napulse) и сервисом роботов robots-clj для четырёх действий робота:
- создать задачу;
- перевести сделку на этап;
- назначить ответственного;
- добавить заметку.
Источник: audits/mcp-research-2026-10-17/DESIGN-phases-2-4.md, разделы 4.C (сторона CRM) и 4.D (сторона robots-clj).
Состояние
| Сторона | Что есть |
|---|---|
| CRM | Готово в ветке feat/mcp-p3-robotactions-20261017: ручки POST /api/internal/robots/crm-actions и GET /api/internal/robots/crm-catalog, таблица robot_crm_action_runs (миграция 0258), журнал source = robot. Код: packages/root/server/internal-robots-crm-actions-route.ts, services/crm-actions/robot-*.ts. |
| robots-clj | Не сделано. Делает отдельная сессия после слияния веток роботов, которые правят те же файлы: conditions, save-conflict, scenario-semantics, autodialog-followup. Что сделать — раздел «Что сделать в robots-clj» ниже. |
Кто действует и в каких правах
Робот действует в пределах прав своего автора: это сотрудник из robots.created_by. Права проверяются при каждом вызове, по текущим правам автора в проекте. Права того, кто правил робота после автора, CRM не проверяет. Это открытый риск, см. раздел «Известный риск: робота правил не автор».
| Действие | Нужное право автора (то же правило, что на экране CRM) |
|---|---|
create_task |
управление задачами (tasks.manage, management.manage) или задачами клиентов (clients.manage, chats.manage) |
set_deal_stage |
ручное изменение сделок (deals.manage, clients.manage, management.manage, tech_block.manage) |
set_responsible |
роль проекта «администратор» (права уровня управления) или владелец проекта |
add_note |
любая видимость диалогов клиентов |
Отказы:
- Автор не записан (
created_by IS NULL, роботы до 13.09.2026) или записан не id сотрудника CRM →403 robot_author_unknown. Конструктор должен показать подсказку: «CRM-действия недоступны: у робота не записан автор». - Автор больше не активный сотрудник проекта (удалён, заблокирован, вышел из проекта) →
403 author_lacks_permission. Роботы ушедшего сотрудника его права не сохраняют.
Технический автор записи — служебная учётная запись проекта. Исполнителем задачи или сделки она никогда не становится.
В CRM действие подписано Робот «<имя робота>». Подпись видна:
- в ленте клиента;
- в авторе заметки;
- в истории сделки;
- в событии смены ответственного.
Выключатели MCP (на проект и на платформу) CRM-действия роботов не останавливают.
Известный риск: робота правил не автор
Статус: открыт, нужно решение владельца до раздела 4.D. Сейчас риск не действует: robots-clj эту ручку не вызывает, CRM-узлов в конструкторе нет.
В чём риск. CRM проверяет права только у автора робота (robots.created_by). Кто последним менял сценарий, CRM не знает.
При этом в robots-clj:
- сотрудник с правом редактировать роботов «все» может менять чужих роботов (
src/robots_clj/robot_access.clj,permitted?); created_byпри правке не меняется (src/robots_clj/db/robots.clj,update-robot!).
Когда появятся CRM-узлы (4.D), такой сотрудник сможет добавить CRM-действие в робота администратора или владельца. CRM проверит права автора и выполнит действие с подписью «Робот «<имя>»». Так сотрудник получит то, что ему самому не разрешено:
- назначить ответственного (можно только администратору или владельцу);
- перевести сделку на этап (нужно право на ручные изменения сделок);
- оставить заметку в диалоге, которого он не видит.
Это касается и сотрудника технического блока, которому CRM не показывает карточки клиентов.
Почему не закрыто на стороне CRM. В запросе robots-clj нет того, кто менял робота. Восстановить это по базе роботов ненадёжно:
- версии (
robot_versions.changed_by) пишутся при сохранении, автосохранении и активации; - но импорт роботов (
POST …/robots/import) перезаписывает сценарий существующего робота черезupdate-robot!и не записывает, кто это сделал.
Проверка CRM по версиям обходилась бы импортом.
Варианты (выбирает владелец):
| Вариант | Что меняется | Минус |
|---|---|---|
| А. Права автора и последнего редактора (рекомендуется) | robots-clj записывает, кто последним менял сценарий, на любом пути, включая импорт. Это id сотрудника передаётся в запросе как обязательное поле editorUserId. CRM пропускает действие, только если право есть и у автора, и у редактора. |
Если сценарий поправит сотрудник без нужного права, даже в другом узле, CRM-действия этого робота начнут получать 403 author_lacks_permission. Так будет, пока робота не пересохранит сотрудник с правом. |
| Б. Запрет в конструкторе | CRM-узлы добавляет и меняет только автор робота или сотрудник, у которого есть право этого действия. robots-clj спрашивает права у CRM. | Проверка нужна на каждом пути записи сценария (сохранение, автосохранение, импорт, активация версии). Пропущенный путь снова открывает риск. |
| В. Принять риск | Ничего, только запись в трекере аудита безопасности. | Риск остаётся. |
Рекомендация: не выкатывать CRM-узлы в robots-clj (4.D), пока владелец не выбрал вариант.
Авторизация
- Заголовок
x-robots-service-token. Его значение равноROBOTS_SERVICE_TOKENна стороне CRM иCRM_ROBOTS_SERVICE_TOKENна стороне robots-clj. - Если
ROBOTS_SERVICE_TOKENв CRM пуст, обе ручки отвечают503 service_auth_not_configured. Пустой секрет не открывает доступ. - Неверный токен или токена нет →
401 unauthorized. Токен сравнивается за постоянное время. - Адрес CRM для robots-clj —
CRM_INTERNAL_BASE_URL, как у остальных внутренних ручек.
Все ответы приходят с Cache-Control: no-store, тело — JSON.
POST /api/internal/robots/crm-actions
Тело запроса
Объект строгий: лишний ключ → 400 invalid_body. Необязательные поля можно передавать как null (так Clojure кодирует nil).
| Поле | Тип | Обязательно | Смысл |
|---|---|---|---|
action |
create_task | set_deal_stage | set_responsible | add_note |
да | действие |
projectId |
uuid | да | (:project_id robot); CRM сверяет его с проектом робота |
robotUuid |
uuid | да | uuid робота в robots-clj |
robotId |
строка ≤255 | нет | текстовый id робота, только для журнала |
stepId |
строка ≤255 | да | id узла сценария |
executionId |
строка ≤255 | нет | id прохождения (run) |
platform |
vk | telegram |
да | platform-contract-value канала |
sourceId |
строка ≤255 | да | channel-source-id канала (tgbot:…, tguser:…, vk:…) |
platformUserId |
строка ≤255 | да | id клиента в мессенджере |
idempotencyKey |
строка 1..255 | да | см. «Идемпотентность» |
params |
объект | да | параметры действия, см. ниже; не подходят к действию → 400 invalid_params |
Проект и диалог CRM определяет сама:
- Робот берётся по
robotUuid, и егоproject_idдолжен совпасть сprojectId. - Клиент ищется по
(platform, platformUserId, projectId). - Диалог — открытый диалог этого клиента с
sourceId, в этом же проекте. Это тот же поиск, что у внутренних ручек сообщений роботов.
Записать что-то в чужой проект или в чужой диалог через тело нельзя.
Параметры действий
Тексты (title, description, text) robots-clj передаёт уже после подстановки переменных (resolve-placeholders).
create_task
| Поле | Тип | Обязательно | Смысл |
|---|---|---|---|
title |
строка 1..500 (после trim) | да | заголовок |
description |
строка ≤50000 | нет | описание |
priority |
low | medium | high | urgent |
нет | по умолчанию medium |
assignee |
{mode, userId?} |
да | исполнитель |
dueInMinutes |
целое 1..525600 | нет | срок через N минут от вызова |
dueAt |
дата ISO 8601 с часовым поясом | нет | точный срок; вместе с dueInMinutes нельзя |
Исполнитель задаётся полем assignee.mode:
conversation_manager— ответственный за диалог. Если ответственного нет или он больше не может получать задачи, задача остаётся без исполнителя.user— сотрудникuserId. Он должен быть активным сотрудником проекта, иначе400 invalid_assignee.none— без исполнителя.
userId передаётся только с mode = user.
Задача привязана к диалогу робота. В ленту клиента пишется событие task_created с source: "robot".
set_deal_stage
| Поле | Тип | Обязательно | Смысл |
|---|---|---|---|
pipelineId |
uuid | да | воронка проекта, иначе 404 pipeline_not_found |
stageId |
uuid | да | этап этой воронки, иначе 404 stage_not_found |
createIfMissing |
boolean | нет | по умолчанию false |
dealTitle |
строка 1..500 | нет | название новой сделки |
Берётся последняя открытая сделка клиента в этой воронке и этом диалоге, и она переводится на этап. В историю сделки пишется событие, подписанное роботом.
Если такой сделки нет:
- при
createIfMissing = false→404 deal_not_found; - при
createIfMissing = trueсоздаётся новая сделка: линия диалога, исполнитель — ответственный за диалог (если он ещё активный сотрудник), названиеdealTitleили «Сделка N» по нумерации CRM. Срабатывает автоматика «сделка → статус диалога».
Если сделка уже стоит на этом этапе, ответ changed: false.
set_responsible
| Поле | Тип | Обязательно | Смысл |
|---|---|---|---|
mode |
user | unassign |
да | назначить сотрудника или снять ответственного |
userId |
uuid | при mode = user |
сотрудник проекта с ролью «администратор» или «менеджер», активный, иначе 400 invalid_responsible |
Распределения «по правилам» нет. Открытые сделки диалога переходят к новому ответственному, как при смене на экране. В ленту клиента пишется manager_reassigned.
add_note
| Поле | Тип | Обязательно | Смысл |
|---|---|---|---|
text |
строка 1..10000 (после trim) | да | текст заметки |
Заметка появляется в диалоге с автором Робот «<имя>». В ленту пишется note_added, открытые экраны обновляются.
Ответы
Успех: 200 и тело
{ "ok": true, "action": "create_task", "result": { "...": "..." } }
Поле result по действиям:
| Действие | result |
|---|---|
create_task |
{taskId, conversationId, assignedTo, priority, dueDate} |
set_deal_stage |
{dealId, pipelineId, stageId, created, changed} |
set_responsible |
{conversationId, assignedManagerId, changed} |
add_note |
{noteId, conversationId} |
Отказ — то же тело с ok: false: { "ok": false, "action": "...", "error": "<код>" }.
| HTTP | error |
Когда |
|---|---|---|
| 400 | invalid_body |
битый JSON, не объект, лишний ключ, неверный формат поля |
| 400 | invalid_params |
params не подходят к действию |
| 400 | invalid_assignee |
исполнитель задачи не активный сотрудник проекта |
| 400 | invalid_responsible |
ответственный не активный сотрудник проекта с ролью «администратор»/«менеджер» |
| 401 | unauthorized |
нет токена или он неверный |
| 403 | robot_project_mismatch |
робот из другого проекта |
| 403 | robot_author_unknown |
у робота не записан автор |
| 403 | author_lacks_permission |
у автора нет нужного права или он больше не сотрудник проекта |
| 404 | robot_not_found |
робота с таким uuid нет (в том числе если база роботов не видна CRM) |
| 404 | contact_not_found |
клиента с (platform, platformUserId) нет в проекте |
| 404 | conversation_not_found |
у клиента нет открытого диалога с этим sourceId в проекте |
| 404 | pipeline_not_found, stage_not_found, deal_not_found |
см. set_deal_stage |
| 405 | method_not_allowed |
не POST |
| 409 | idempotency_key_conflict |
ключ уже использован другим действием, роботом или проектом |
| 409 | idempotency_in_progress |
действие с этим ключом ещё выполняется (или прервалось) |
| 500 | internal |
внутренняя ошибка |
| 503 | service_auth_not_configured |
в CRM не задан ROBOTS_SERVICE_TOKEN |
| 503 | robots_source_unavailable |
CRM не может прочитать базу роботов (нет соединения, таймаут 5 с). Ничего не записано и не попало в журнал |
Идемпотентность
- Формат ключа:
"<robotUuid>:<executionId или run-id>:<stepId>:<action>". Один шаг одного прохождения — одно действие. - Повтор с тем же ключом возвращает первый ответ с добавленным
deduplicated: trueи ничего не делает повторно. Это касается и отказа, полученного на самом действии (например,400 invalid_assignee). Повтор отвечается раньше всех остальных проверок. - Отказы до действия не запоминаются: неизвестный робот, чужой проект, нет клиента или диалога, нет права автора. Записи не было, поэтому повтор получит тот же отказ заново.
- Ключ привязан к проекту, роботу и действию. Тот же ключ с другими значениями →
409 idempotency_key_conflict. - Конкурирующий повтор во время выполнения →
409 idempotency_in_progress. Действие при этом могло выполниться. Повторять его не нужно. - Срок хранения записей — 30 дней.
- Повтор внутри CRM: CRM сама пишущий запрос не повторяет.
Журнал
Каждый вызов, прошедший проверку робота, пишется в журнал MCP проекта (mcp_audit_log):
source = robot;user_id— автор робота (у робота без автора пусто);tool = robot.<action>;object_type = conversation,object_id— id диалога (если он найден);okи код ошибки;- длительность;
args:{robotUuid, robotId?, stepId, executionId?, params}.
Тексты клиентов в журнал не попадают. title, description и dealTitle хранятся как длина и отпечаток SHA-256, text заметки — так же, через общее редактирование журнала. Повторы по ключу в журнал не пишутся. Журнал хранится 180 дней.
Запись журнала делается после действия, и её сбой не превращает выполненное действие в ошибку: сбой уходит в лог сервера.
GET /api/internal/robots/crm-catalog?projectId=<uuid>
Справочник для конструктора: воронки с этапами и сотрудники, которых робот может назначить. Почт в ответе нет.
{
"ok": true,
"pipelines": [{ "id": "…", "name": "Продажи", "stages": [{ "id": "…", "name": "Новый лид" }] }],
"users": [{ "id": "…", "displayName": "Мария Менеджер" }]
}
users— активные сотрудники проекта с ролью «администратор» или «менеджер», до 500 человек. Это тот же список, что «кому можно передать диалог» в карточке клиента.displayNameможет бытьnull, если в профиле нет имени.- Ровно один корректный
projectId, иначе400 invalid_query. Метод только GET, иначе405. Токен — как выше. - Ручка доверяет вызывающему по токену. Права сотрудника, открывшего конструктор, проверяет robots-clj до запроса в CRM (см. п. 6 ниже).
Что сделать в robots-clj (раздел 4.D)
Делать только после слияния веток роботов (conditions, save-conflict, scenario-semantics, autodialog-followup) и после решения владельца по разделу «Известный риск: робота правил не автор». При варианте А или Б к шагам ниже добавляется его доработка: в robots-clj и, для варианта А, поле editorUserId в контракте CRM.
-
Новый ns
robots-clj.robots.crm-actionsпо образцуrobots/sheets_write.clj(если его ещё нет в main — по образцуbulk_access.cljи вызовов подписных страниц вactions.clj):service-configчитаетCRM_INTERNAL_BASE_URLиCRM_ROBOTS_SERVICE_TOKEN; без них возвращает{:status :error :message "Missing …"};execute! [project-id robot channel platform user-id action params exec-context]делает POST на/api/internal/robots/crm-actionsс заголовкомx-robots-service-token; таймауты: соединение 5000 мс, сокет 15000 мс;:throw-exceptions false;- повтор — не больше одного раза и только при ошибке соединения, с тем же
idempotencyKey. Ответы4xx/5xxне повторять; - возврат
{:status :ok :result …}или{:status :error :message <error> :code <http>}.
-
schemas.clj. ВActionTypeдобавитьcrm_create_task,crm_set_deal_stage,crm_set_responsible,crm_add_note. В схему узла — необязательный ключ:crm. -
engine.cljnormalize-intent:crm_create_task→{:type :crm/create_task :title :description :priority :assignee :dueInMinutes};crm_set_deal_stage→{:type :crm/set_deal_stage :pipelineId :stageId :createIfMissing :dealTitle};crm_set_responsible→{:type :crm/set_responsible :mode :userId};crm_add_note→{:type :crm/add_note :text};- в
blocks/action/core.clj— записи с:platforms #{:vk :tg :all}.
Ключ
:typeвparamsне передавать: тело строгое, лишний ключ даст400 invalid_params. -
actions.cljexecute-action(ветка до запасного варианта):title,descriptionиtextпрогоняются черезresolve-placeholders;sourceIdберётся изchannel-source-id,platform— изplatform-contract-value,projectId— из(:project_id robot);- ошибку CRM не бросать исключением, а возвращать
{:status :error}. Сценарий идёт дальше, как у остальных внешних действий.
-
channel_capabilities.clj: новые типы добавить только вknown-actions, не вplatform_actions. -
routes.clj: проксиGET /api/projects/:uuid/crm-catalog→ CRMGET /api/internal/robots/crm-catalog?projectId=:uuid, по образцу/subscriber-pages. Перед прокси проверить контекст сотрудника (x-session-user-*): проект из пути равенx-session-user-project-id, и у сотрудника есть право редактировать роботов. -
Конструктор (
frontend/src/components/robot-builder/operation-catalog.ts,types.ts,NodeSettingsModal.tsx):- выпадающие списки воронки, этапа и сотрудника из
crm-catalog; - для робота без автора (
created_byпуст) — подсказка «CRM-действия недоступны: у робота не записан автор».
- выпадающие списки воронки, этапа и сотрудника из
-
Тесты по образцу
test/robots_clj/robots/sheets_write_test.clj:- нет конфигурации →
:error; - заголовок и тело запроса;
- повтор только при ошибке соединения и с тем же ключом;
4xxвозвращается как{:status :error :code …};- плейсхолдеры подставлены до отправки.
- нет конфигурации →
Не проверено (проверить до выката)
- Задан ли на проде
ROBOTS_SERVICE_TOKENв CRM иCRM_ROBOTS_SERVICE_TOKENв robots-clj, и одинаковые ли они. - Откуда CRM читает таблицу
robots:ANALYTICS_ROBOTS_DATABASE_URL→ANALYTICS_SOURCE_DATABASE_URL(реплика) →DATABASE_URL, схемаANALYTICS_ROBOTS_DB_SCHEMA(по умолчаниюrobots_clj). Если это реплика, отставание даст ложный404 robot_not_foundдля робота, созданного секунды назад. - Сколько роботов на проде без
created_by: для них CRM-действия будут отказывать (robot_author_unknown).