@wondai/n8n-nodes-nucleo 0.2.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/README.md +80 -0
- package/dist/credentials/NucleoApi.credentials.d.ts +17 -0
- package/dist/credentials/NucleoApi.credentials.js +49 -0
- package/dist/nodes/Nucleo/Nucleo.node.d.ts +5 -0
- package/dist/nodes/Nucleo/Nucleo.node.js +588 -0
- package/dist/nodes/Nucleo/nucleo.svg +7 -0
- package/package.json +46 -0
package/README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# @wondai/n8n-nodes-nucleo
|
|
2
|
+
|
|
3
|
+
Node community do **n8n** para o **Núcleo Wondai** — a camada de atendimento de IA (WhatsApp → n8n
|
|
4
|
+
→ Núcleo). Um único node com as operações que o agente precisa, assinando cada chamada com **HMAC
|
|
5
|
+
v1**. O **tenant (a padaria) vem do token**, no servidor — nunca de nada que o n8n envie (a parede,
|
|
6
|
+
ADR-013).
|
|
7
|
+
|
|
8
|
+
> Substitui o pacote antigo `@wondai/n8n-nodes-crm`. Decisões: **ADR-015** (este package + resolução
|
|
9
|
+
> inteligente de catálogo) e **ADR-016** (orquestração: router + especialistas; catálogo como tool).
|
|
10
|
+
|
|
11
|
+
## Filosofia: node burro, servidor inteligente
|
|
12
|
+
|
|
13
|
+
O node **só** monta a string canônica, assina e chama. Toda inteligência (busca fuzzy, apelidos,
|
|
14
|
+
lote, teto de tokens, idempotência, isolamento entre padarias) vive no **servidor**, atrás da
|
|
15
|
+
parede. Isso mantém o segredo fora do workflow, a lógica num lugar só e o token seguro.
|
|
16
|
+
|
|
17
|
+
## Operações
|
|
18
|
+
|
|
19
|
+
| Recurso | Operação | Endpoint | Escopo do token |
|
|
20
|
+
|---|---|---|---|
|
|
21
|
+
| Cliente | Buscar | `GET /api/v1/agent/cliente` | `cliente:ler` |
|
|
22
|
+
| Catálogo | Resolver Produtos | `POST /api/v1/agent/catalogo/resolver` | `catalogo:ler` |
|
|
23
|
+
| Pedido | Criar | `POST /api/v1/agent/pedido` | `pedido:escrever` |
|
|
24
|
+
| Pedido | Alterar | `PATCH /api/v1/agent/pedido/:id` | `pedido:escrever` |
|
|
25
|
+
| Pedido | Cancelar | `POST /api/v1/agent/pedido/:id/cancelar` | `pedido:escrever` |
|
|
26
|
+
|
|
27
|
+
**Resolver Produtos** é a operação inteligente: manda várias consultas numa chamada (máx 10),
|
|
28
|
+
tolera erro de digitação (`banofe`→Banoffee), falta de acento (`pao frances`→Pão Francês) e
|
|
29
|
+
apelidos; devolve no máx 3 candidatos por consulta com `status` (`achou`/`ambiguo`/`nao_achou`).
|
|
30
|
+
|
|
31
|
+
**Criar** é idempotente: deixe *Idempotency Key* vazio (gera uma) ou repita a mesma chave num retry
|
|
32
|
+
— o Núcleo nunca duplica o pedido.
|
|
33
|
+
|
|
34
|
+
## Instalação (n8n self-hosted)
|
|
35
|
+
|
|
36
|
+
1. **Settings → Community Nodes → Install** → `@wondai/n8n-nodes-nucleo`.
|
|
37
|
+
(ou, em dev: `npm run build` aqui e aponte o n8n para a pasta / `npm link`.)
|
|
38
|
+
2. Para usar como **ferramenta do AI Agent**, o n8n precisa permitir nodes community como tools:
|
|
39
|
+
```
|
|
40
|
+
N8N_COMMUNITY_PACKAGES_ALLOW_TOOL_USAGE=true
|
|
41
|
+
```
|
|
42
|
+
(variável de ambiente do servidor n8n; sem ela o node funciona como node normal, mas não aparece
|
|
43
|
+
como tool do AI Agent.)
|
|
44
|
+
|
|
45
|
+
## Credencial — "Núcleo Wondai API"
|
|
46
|
+
|
|
47
|
+
| Campo | O que é |
|
|
48
|
+
|---|---|
|
|
49
|
+
| **Base URL** | Origem do Núcleo, sem barra final (ex: `https://app.suapadaria.com.br`). |
|
|
50
|
+
| **Token ID** | Id do token (público). Header `x-wondai-key`. |
|
|
51
|
+
| **Signing Secret** | Segredo de assinatura — exibido **uma vez** ao emitir o token. |
|
|
52
|
+
|
|
53
|
+
O par **Token ID + Signing Secret** é emitido no painel **/agente** do Núcleo (uma credencial por
|
|
54
|
+
padaria). O n8n guarda os dois **cifrados** no cofre — nunca em texto puro no workflow.
|
|
55
|
+
|
|
56
|
+
## Assinatura (contrato HMAC v1)
|
|
57
|
+
|
|
58
|
+
Cada requisição leva os headers `x-wondai-key`, `x-wondai-timestamp` (unix s, janela ±300s),
|
|
59
|
+
`x-wondai-nonce` (único — reuso = replay → 401) e `x-wondai-signature`. A assinatura é
|
|
60
|
+
`HMAC_SHA256(signingSecret, canônica)` em hex, onde a canônica é:
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
v1
|
|
64
|
+
<MÉTODO>
|
|
65
|
+
<path + query>
|
|
66
|
+
<timestamp>
|
|
67
|
+
<nonce>
|
|
68
|
+
<sha256_hex(corpo)> # corpo cru; "" no GET
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Build (dev)
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
npm install
|
|
75
|
+
npm run build # tsc → dist/ + copia ícones
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Licença
|
|
79
|
+
|
|
80
|
+
MIT
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ICredentialType, INodeProperties } from "n8n-workflow";
|
|
2
|
+
/**
|
|
3
|
+
* Credencial do Núcleo Wondai (por padaria).
|
|
4
|
+
*
|
|
5
|
+
* Guarda o par emitido pelo painel `/agente` do Núcleo: `tokenId` (público, vai no header
|
|
6
|
+
* `x-wondai-key`) + `signingSecret` (derivado da chave-mestra do servidor; equivale a uma senha).
|
|
7
|
+
* O n8n guarda ambos CIFRADOS no cofre de credenciais — nunca em texto puro no workflow (G7).
|
|
8
|
+
*
|
|
9
|
+
* NÃO há `authenticate`/`test` genérico: a assinatura é HMAC dinâmica (depende de
|
|
10
|
+
* método+caminho+corpo+timestamp+nonce), montada pelo próprio node a cada requisição.
|
|
11
|
+
*/
|
|
12
|
+
export declare class NucleoApi implements ICredentialType {
|
|
13
|
+
name: string;
|
|
14
|
+
displayName: string;
|
|
15
|
+
documentationUrl: string;
|
|
16
|
+
properties: INodeProperties[];
|
|
17
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.NucleoApi = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Credencial do Núcleo Wondai (por padaria).
|
|
6
|
+
*
|
|
7
|
+
* Guarda o par emitido pelo painel `/agente` do Núcleo: `tokenId` (público, vai no header
|
|
8
|
+
* `x-wondai-key`) + `signingSecret` (derivado da chave-mestra do servidor; equivale a uma senha).
|
|
9
|
+
* O n8n guarda ambos CIFRADOS no cofre de credenciais — nunca em texto puro no workflow (G7).
|
|
10
|
+
*
|
|
11
|
+
* NÃO há `authenticate`/`test` genérico: a assinatura é HMAC dinâmica (depende de
|
|
12
|
+
* método+caminho+corpo+timestamp+nonce), montada pelo próprio node a cada requisição.
|
|
13
|
+
*/
|
|
14
|
+
class NucleoApi {
|
|
15
|
+
constructor() {
|
|
16
|
+
this.name = "nucleoApi";
|
|
17
|
+
this.displayName = "Núcleo Wondai API";
|
|
18
|
+
this.documentationUrl = "https://github.com/wondai/n8n-nodes-nucleo";
|
|
19
|
+
this.properties = [
|
|
20
|
+
{
|
|
21
|
+
displayName: "Base URL",
|
|
22
|
+
name: "baseUrl",
|
|
23
|
+
type: "string",
|
|
24
|
+
default: "https://wondai-core-sooty.vercel.app",
|
|
25
|
+
placeholder: "https://app.suapadaria.com.br",
|
|
26
|
+
required: true,
|
|
27
|
+
description: "Origem da API do Núcleo, sem barra final. O node acrescenta /api/v1/agent/...",
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
displayName: "Token ID",
|
|
31
|
+
name: "tokenId",
|
|
32
|
+
type: "string",
|
|
33
|
+
default: "",
|
|
34
|
+
required: true,
|
|
35
|
+
description: "Id do token (público). Vai no header x-wondai-key. Emitido no painel /agente do Núcleo.",
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
displayName: "Signing Secret",
|
|
39
|
+
name: "signingSecret",
|
|
40
|
+
type: "string",
|
|
41
|
+
typeOptions: { password: true },
|
|
42
|
+
default: "",
|
|
43
|
+
required: true,
|
|
44
|
+
description: "Segredo de assinatura exibido UMA vez ao emitir o token. Guarde como senha — define o tenant que o n8n alcança.",
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
exports.NucleoApi = NucleoApi;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type IExecuteFunctions, type INodeExecutionData, type INodeType, type INodeTypeDescription } from "n8n-workflow";
|
|
2
|
+
export declare class Nucleo implements INodeType {
|
|
3
|
+
description: INodeTypeDescription;
|
|
4
|
+
execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]>;
|
|
5
|
+
}
|
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.Nucleo = void 0;
|
|
4
|
+
const node_crypto_1 = require("node:crypto");
|
|
5
|
+
const n8n_workflow_1 = require("n8n-workflow");
|
|
6
|
+
/**
|
|
7
|
+
* Node Núcleo Wondai — satélite de IA → Núcleo (ADR-003/013/015/016).
|
|
8
|
+
*
|
|
9
|
+
* NODE BURRO, SERVIDOR INTELIGENTE: este node só monta a string canônica v1, assina (HMAC) e
|
|
10
|
+
* chama. Toda regra (fuzzy, alias, lote, limite, idempotência, isolamento) mora no servidor,
|
|
11
|
+
* atrás da parede. O tenant é decidido pelo TOKEN no servidor — nunca por nada que o n8n envie.
|
|
12
|
+
*
|
|
13
|
+
* String canônica (CONTRATO — tem que bater byte a byte com core/agent-auth/signing.ts):
|
|
14
|
+
* v1 \n MÉTODO \n path+query \n timestamp \n nonce \n sha256_hex(corpo)
|
|
15
|
+
* signature = HMAC_SHA256(signingSecret, canônica) em hex.
|
|
16
|
+
*/
|
|
17
|
+
const SIGNATURE_VERSION = "v1";
|
|
18
|
+
const HEADERS = {
|
|
19
|
+
key: "x-wondai-key",
|
|
20
|
+
timestamp: "x-wondai-timestamp",
|
|
21
|
+
nonce: "x-wondai-nonce",
|
|
22
|
+
signature: "x-wondai-signature",
|
|
23
|
+
};
|
|
24
|
+
function sha256Hex(body) {
|
|
25
|
+
return (0, node_crypto_1.createHash)("sha256").update(body, "utf8").digest("hex");
|
|
26
|
+
}
|
|
27
|
+
function canonicalString(method, path, timestamp, nonce, body) {
|
|
28
|
+
return [SIGNATURE_VERSION, method.toUpperCase(), path, timestamp, nonce, sha256Hex(body)].join("\n");
|
|
29
|
+
}
|
|
30
|
+
function sign(signingSecret, canonical) {
|
|
31
|
+
return (0, node_crypto_1.createHmac)("sha256", signingSecret).update(canonical, "utf8").digest("hex");
|
|
32
|
+
}
|
|
33
|
+
/** Aceita string (linhas / vírgulas / JSON array) ou já-array. Normaliza, dedup, corta. */
|
|
34
|
+
function parseConsultas(raw, max = 10) {
|
|
35
|
+
let list = [];
|
|
36
|
+
if (Array.isArray(raw)) {
|
|
37
|
+
list = raw;
|
|
38
|
+
}
|
|
39
|
+
else if (typeof raw === "string") {
|
|
40
|
+
const t = raw.trim();
|
|
41
|
+
if (t.startsWith("[")) {
|
|
42
|
+
try {
|
|
43
|
+
const j = JSON.parse(t);
|
|
44
|
+
list = Array.isArray(j) ? j : [t];
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
list = t.split(/[\n,;]+/);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
list = t.split(/[\n,;]+/);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const out = [];
|
|
55
|
+
const seen = new Set();
|
|
56
|
+
for (const c of list) {
|
|
57
|
+
const s = typeof c === "string" ? c.trim() : String(c ?? "").trim();
|
|
58
|
+
if (s.length < 2)
|
|
59
|
+
continue;
|
|
60
|
+
const k = s.toLowerCase();
|
|
61
|
+
if (seen.has(k))
|
|
62
|
+
continue;
|
|
63
|
+
seen.add(k);
|
|
64
|
+
out.push(s);
|
|
65
|
+
if (out.length >= max)
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
/** Lê um parâmetro JSON (string ou objeto) como array. Vazio → []. */
|
|
71
|
+
function asArray(value) {
|
|
72
|
+
if (Array.isArray(value))
|
|
73
|
+
return value;
|
|
74
|
+
if (typeof value === "string") {
|
|
75
|
+
const t = value.trim();
|
|
76
|
+
if (!t)
|
|
77
|
+
return [];
|
|
78
|
+
try {
|
|
79
|
+
const j = JSON.parse(t);
|
|
80
|
+
return Array.isArray(j) ? j : [j];
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
/** IDs separados por vírgula ou JSON array → string[]. */
|
|
89
|
+
function parseIds(value) {
|
|
90
|
+
if (Array.isArray(value))
|
|
91
|
+
return value.map((v) => String(v)).filter(Boolean);
|
|
92
|
+
if (typeof value === "string") {
|
|
93
|
+
const t = value.trim();
|
|
94
|
+
if (!t)
|
|
95
|
+
return [];
|
|
96
|
+
if (t.startsWith("[")) {
|
|
97
|
+
try {
|
|
98
|
+
const j = JSON.parse(t);
|
|
99
|
+
return Array.isArray(j) ? j.map((v) => String(v)).filter(Boolean) : [];
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return t
|
|
106
|
+
.split(/[\n,;]+/)
|
|
107
|
+
.map((s) => s.trim())
|
|
108
|
+
.filter(Boolean);
|
|
109
|
+
}
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
class Nucleo {
|
|
113
|
+
constructor() {
|
|
114
|
+
this.description = {
|
|
115
|
+
displayName: "Núcleo Wondai",
|
|
116
|
+
name: "nucleo",
|
|
117
|
+
icon: "file:nucleo.svg",
|
|
118
|
+
group: ["transform"],
|
|
119
|
+
version: 1,
|
|
120
|
+
subtitle: '={{ $parameter["operation"] + ": " + $parameter["resource"] }}',
|
|
121
|
+
description: "Atendimento de IA no Núcleo Wondai: buscar cliente, resolver catálogo (fuzzy) e criar/alterar/cancelar pedido. Assinatura HMAC; o tenant vem do token.",
|
|
122
|
+
defaults: { name: "Núcleo Wondai" },
|
|
123
|
+
// Permite usar o node como ferramenta do AI Agent (router/especialistas, ADR-016).
|
|
124
|
+
usableAsTool: true,
|
|
125
|
+
inputs: ["main"],
|
|
126
|
+
outputs: ["main"],
|
|
127
|
+
credentials: [{ name: "nucleoApi", required: true }],
|
|
128
|
+
properties: [
|
|
129
|
+
// ----------------------------------------------------------------- resource
|
|
130
|
+
{
|
|
131
|
+
displayName: "Recurso",
|
|
132
|
+
name: "resource",
|
|
133
|
+
type: "options",
|
|
134
|
+
noDataExpression: true,
|
|
135
|
+
options: [
|
|
136
|
+
{ name: "Cliente", value: "cliente" },
|
|
137
|
+
{ name: "Catálogo", value: "catalogo" },
|
|
138
|
+
{ name: "Pedido", value: "pedido" },
|
|
139
|
+
{ name: "Conversa", value: "conversa" },
|
|
140
|
+
],
|
|
141
|
+
default: "catalogo",
|
|
142
|
+
},
|
|
143
|
+
// ----------------------------------------------------------------- operations
|
|
144
|
+
{
|
|
145
|
+
displayName: "Operação",
|
|
146
|
+
name: "operation",
|
|
147
|
+
type: "options",
|
|
148
|
+
noDataExpression: true,
|
|
149
|
+
displayOptions: { show: { resource: ["cliente"] } },
|
|
150
|
+
options: [
|
|
151
|
+
{
|
|
152
|
+
name: "Buscar",
|
|
153
|
+
value: "buscar",
|
|
154
|
+
action: "Buscar cliente por telefone",
|
|
155
|
+
description: "Lookup do cliente + histórico recente de pedidos",
|
|
156
|
+
},
|
|
157
|
+
],
|
|
158
|
+
default: "buscar",
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
displayName: "Operação",
|
|
162
|
+
name: "operation",
|
|
163
|
+
type: "options",
|
|
164
|
+
noDataExpression: true,
|
|
165
|
+
displayOptions: { show: { resource: ["catalogo"] } },
|
|
166
|
+
options: [
|
|
167
|
+
{
|
|
168
|
+
name: "Resolver Produtos",
|
|
169
|
+
value: "resolver",
|
|
170
|
+
action: "Resolver produtos do catálogo",
|
|
171
|
+
description: "Busca vários produtos numa chamada — tolera erro de digitação e apelidos",
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
default: "resolver",
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
displayName: "Operação",
|
|
178
|
+
name: "operation",
|
|
179
|
+
type: "options",
|
|
180
|
+
noDataExpression: true,
|
|
181
|
+
displayOptions: { show: { resource: ["pedido"] } },
|
|
182
|
+
options: [
|
|
183
|
+
{
|
|
184
|
+
name: "Detalhar",
|
|
185
|
+
value: "detalhar",
|
|
186
|
+
action: "Detalhar pedido",
|
|
187
|
+
description: "Busca o pedido (com itens e IDs) por UUID ou número curto",
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
name: "Criar",
|
|
191
|
+
value: "criar",
|
|
192
|
+
action: "Criar pedido",
|
|
193
|
+
description: "Cria pedido (idempotente)",
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
name: "Alterar",
|
|
197
|
+
value: "alterar",
|
|
198
|
+
action: "Alterar pedido",
|
|
199
|
+
description: "Altera data/observações/itens (adicionar, remover, mudar quantidade)",
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
name: "Cancelar",
|
|
203
|
+
value: "cancelar",
|
|
204
|
+
action: "Cancelar pedido",
|
|
205
|
+
description: "Cancela o pedido inteiro (idempotente)",
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
default: "criar",
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
displayName: "Operação",
|
|
212
|
+
name: "operation",
|
|
213
|
+
type: "options",
|
|
214
|
+
noDataExpression: true,
|
|
215
|
+
displayOptions: { show: { resource: ["conversa"] } },
|
|
216
|
+
options: [
|
|
217
|
+
{
|
|
218
|
+
name: "Registrar",
|
|
219
|
+
value: "registrar",
|
|
220
|
+
action: "Registrar conversa",
|
|
221
|
+
description: "Encerra a conversa com resumo + resultado (handoff = precisa_humano)",
|
|
222
|
+
},
|
|
223
|
+
],
|
|
224
|
+
default: "registrar",
|
|
225
|
+
},
|
|
226
|
+
// ----------------------------------------------------------------- cliente:buscar
|
|
227
|
+
{
|
|
228
|
+
displayName: "Telefone",
|
|
229
|
+
name: "telefone",
|
|
230
|
+
type: "string",
|
|
231
|
+
default: "",
|
|
232
|
+
required: true,
|
|
233
|
+
placeholder: "+5511999999999",
|
|
234
|
+
description: "Telefone do cliente em E.164. Pode vir do remotejid do WhatsApp — normalize antes (só dígitos + DDI).",
|
|
235
|
+
displayOptions: { show: { resource: ["cliente"], operation: ["buscar"] } },
|
|
236
|
+
},
|
|
237
|
+
// ----------------------------------------------------------------- catalogo:resolver
|
|
238
|
+
{
|
|
239
|
+
displayName: "Consultas",
|
|
240
|
+
name: "consultas",
|
|
241
|
+
type: "string",
|
|
242
|
+
typeOptions: { rows: 3 },
|
|
243
|
+
default: "",
|
|
244
|
+
required: true,
|
|
245
|
+
placeholder: "banofe, pão francês, coxinha",
|
|
246
|
+
description: "Produtos a buscar: um por linha ou separados por vírgula (máx 10). Aceita erro de digitação, falta de acento e apelidos. Ex: 'banofe, pão francês'.",
|
|
247
|
+
displayOptions: { show: { resource: ["catalogo"], operation: ["resolver"] } },
|
|
248
|
+
},
|
|
249
|
+
// ----------------------------------------------------------------- pedido:detalhar
|
|
250
|
+
{
|
|
251
|
+
displayName: "Pedido (ref)",
|
|
252
|
+
name: "ref",
|
|
253
|
+
type: "string",
|
|
254
|
+
default: "",
|
|
255
|
+
required: true,
|
|
256
|
+
placeholder: "64 ou UUID",
|
|
257
|
+
description: "Número curto (ex: 64) ou UUID do pedido. Devolve o pedido com itens e seus IDs. Pedido de outra padaria → 404.",
|
|
258
|
+
displayOptions: { show: { resource: ["pedido"], operation: ["detalhar"] } },
|
|
259
|
+
},
|
|
260
|
+
// ----------------------------------------------------------------- pedido:criar
|
|
261
|
+
{
|
|
262
|
+
displayName: "Telefone",
|
|
263
|
+
name: "telefone",
|
|
264
|
+
type: "string",
|
|
265
|
+
default: "",
|
|
266
|
+
required: true,
|
|
267
|
+
placeholder: "+5511999999999",
|
|
268
|
+
description: "Telefone do cliente (E.164). Resolve o cliente existente ou cria um novo.",
|
|
269
|
+
displayOptions: { show: { resource: ["pedido"], operation: ["criar"] } },
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
displayName: "Itens (JSON)",
|
|
273
|
+
name: "itens",
|
|
274
|
+
type: "json",
|
|
275
|
+
default: "[]",
|
|
276
|
+
required: true,
|
|
277
|
+
description: 'Array de itens. Cada item: {"produto_id" OU "alias", "variacao_id"?, "quantidade", "personalizacao"?}. Ex: [{"alias":"paozinho","quantidade":6},{"produto_id":"...","quantidade":1}]',
|
|
278
|
+
displayOptions: { show: { resource: ["pedido"], operation: ["criar"] } },
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
displayName: "Tipo de Entrega",
|
|
282
|
+
name: "tipoEntrega",
|
|
283
|
+
type: "options",
|
|
284
|
+
options: [
|
|
285
|
+
{ name: "Retirada", value: "retirada" },
|
|
286
|
+
{ name: "Entrega", value: "entrega" },
|
|
287
|
+
],
|
|
288
|
+
default: "retirada",
|
|
289
|
+
displayOptions: { show: { resource: ["pedido"], operation: ["criar"] } },
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
displayName: "Observações",
|
|
293
|
+
name: "observacoes",
|
|
294
|
+
type: "string",
|
|
295
|
+
default: "",
|
|
296
|
+
displayOptions: { show: { resource: ["pedido"], operation: ["criar"] } },
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
displayName: "Agendado Para",
|
|
300
|
+
name: "agendadoPara",
|
|
301
|
+
type: "string",
|
|
302
|
+
default: "",
|
|
303
|
+
placeholder: "2026-06-20T10:00:00Z",
|
|
304
|
+
description: "Data/hora ISO 8601 para encomenda. Vazio = imediato.",
|
|
305
|
+
displayOptions: { show: { resource: ["pedido"], operation: ["criar"] } },
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
displayName: "Loja (unit_id)",
|
|
309
|
+
name: "unitId",
|
|
310
|
+
type: "string",
|
|
311
|
+
default: "",
|
|
312
|
+
description: "Opcional. Vazio = primeira loja da padaria.",
|
|
313
|
+
displayOptions: { show: { resource: ["pedido"], operation: ["criar"] } },
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
displayName: "Idempotency Key",
|
|
317
|
+
name: "idempotencyKey",
|
|
318
|
+
type: "string",
|
|
319
|
+
default: "",
|
|
320
|
+
description: "Opcional. Vazio = gerada automaticamente. Use a MESMA chave num retry para não duplicar o pedido.",
|
|
321
|
+
displayOptions: { show: { resource: ["pedido"], operation: ["criar"] } },
|
|
322
|
+
},
|
|
323
|
+
// ----------------------------------------------------------------- pedido:alterar / cancelar
|
|
324
|
+
{
|
|
325
|
+
displayName: "Pedido ID",
|
|
326
|
+
name: "pedidoId",
|
|
327
|
+
type: "string",
|
|
328
|
+
default: "",
|
|
329
|
+
required: true,
|
|
330
|
+
description: "Id do pedido (do tenant). Pedido de outra padaria → 404.",
|
|
331
|
+
displayOptions: { show: { resource: ["pedido"], operation: ["alterar", "cancelar"] } },
|
|
332
|
+
},
|
|
333
|
+
{
|
|
334
|
+
displayName: "Observações",
|
|
335
|
+
name: "observacoes",
|
|
336
|
+
type: "string",
|
|
337
|
+
default: "",
|
|
338
|
+
displayOptions: { show: { resource: ["pedido"], operation: ["alterar"] } },
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
displayName: "Agendado Para",
|
|
342
|
+
name: "agendadoPara",
|
|
343
|
+
type: "string",
|
|
344
|
+
default: "",
|
|
345
|
+
placeholder: "2026-06-21T09:00:00Z",
|
|
346
|
+
description: "ISO 8601. Envie vazio para não alterar.",
|
|
347
|
+
displayOptions: { show: { resource: ["pedido"], operation: ["alterar"] } },
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
displayName: "Adicionar Itens (JSON)",
|
|
351
|
+
name: "adicionarItens",
|
|
352
|
+
type: "json",
|
|
353
|
+
default: "[]",
|
|
354
|
+
description: "Mesma forma de Itens. Vazio = não adiciona.",
|
|
355
|
+
displayOptions: { show: { resource: ["pedido"], operation: ["alterar"] } },
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
displayName: "Remover Itens (IDs)",
|
|
359
|
+
name: "removerItemIds",
|
|
360
|
+
type: "string",
|
|
361
|
+
default: "",
|
|
362
|
+
description: "IDs de itens a remover, separados por vírgula ou JSON array.",
|
|
363
|
+
displayOptions: { show: { resource: ["pedido"], operation: ["alterar"] } },
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
displayName: "Alterar Quantidade de Itens (JSON)",
|
|
367
|
+
name: "alterarItens",
|
|
368
|
+
type: "json",
|
|
369
|
+
default: "[]",
|
|
370
|
+
description: 'Muda a quantidade de itens já existentes: [{"item_id":"...","quantidade":4}]. Os IDs vêm do Detalhar. Trocar item = remover + adicionar. Vazio = não altera.',
|
|
371
|
+
displayOptions: { show: { resource: ["pedido"], operation: ["alterar"] } },
|
|
372
|
+
},
|
|
373
|
+
{
|
|
374
|
+
displayName: "Motivo",
|
|
375
|
+
name: "motivo",
|
|
376
|
+
type: "string",
|
|
377
|
+
default: "",
|
|
378
|
+
description: "Motivo do cancelamento (opcional).",
|
|
379
|
+
displayOptions: { show: { resource: ["pedido"], operation: ["cancelar"] } },
|
|
380
|
+
},
|
|
381
|
+
// ----------------------------------------------------------------- conversa:registrar
|
|
382
|
+
{
|
|
383
|
+
displayName: "Resumo",
|
|
384
|
+
name: "resumo",
|
|
385
|
+
type: "string",
|
|
386
|
+
typeOptions: { rows: 2 },
|
|
387
|
+
default: "",
|
|
388
|
+
required: true,
|
|
389
|
+
description: "Resumo objetivo do atendimento (vai para o inbox de conversas).",
|
|
390
|
+
displayOptions: { show: { resource: ["conversa"], operation: ["registrar"] } },
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
displayName: "Resultado",
|
|
394
|
+
name: "resultado",
|
|
395
|
+
type: "options",
|
|
396
|
+
options: [
|
|
397
|
+
{ name: "Resolvido", value: "resolvido" },
|
|
398
|
+
{ name: "Precisa de Humano (handoff)", value: "precisa_humano" },
|
|
399
|
+
{ name: "Abandonado", value: "abandonado" },
|
|
400
|
+
],
|
|
401
|
+
default: "resolvido",
|
|
402
|
+
description: "Desfecho do atendimento. 'precisa_humano' = handoff (cai no inbox como pendente).",
|
|
403
|
+
displayOptions: { show: { resource: ["conversa"], operation: ["registrar"] } },
|
|
404
|
+
},
|
|
405
|
+
{
|
|
406
|
+
displayName: "Telefone",
|
|
407
|
+
name: "convTelefone",
|
|
408
|
+
type: "string",
|
|
409
|
+
default: "",
|
|
410
|
+
placeholder: "+5511999999999",
|
|
411
|
+
description: "Telefone do cliente (E.164). Vincula ao cliente se casar — não cria.",
|
|
412
|
+
displayOptions: { show: { resource: ["conversa"], operation: ["registrar"] } },
|
|
413
|
+
},
|
|
414
|
+
{
|
|
415
|
+
displayName: "Pedido ID",
|
|
416
|
+
name: "convPedidoId",
|
|
417
|
+
type: "string",
|
|
418
|
+
default: "",
|
|
419
|
+
description: "Opcional. UUID de pedido gerado na conversa (validado contra o tenant).",
|
|
420
|
+
displayOptions: { show: { resource: ["conversa"], operation: ["registrar"] } },
|
|
421
|
+
},
|
|
422
|
+
],
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
async execute() {
|
|
426
|
+
const items = this.getInputData();
|
|
427
|
+
const returned = [];
|
|
428
|
+
const creds = await this.getCredentials("nucleoApi");
|
|
429
|
+
const baseUrl = String(creds.baseUrl ?? "").replace(/\/+$/, "");
|
|
430
|
+
const tokenId = String(creds.tokenId ?? "");
|
|
431
|
+
const signingSecret = String(creds.signingSecret ?? "");
|
|
432
|
+
for (let i = 0; i < items.length; i++) {
|
|
433
|
+
try {
|
|
434
|
+
const resource = this.getNodeParameter("resource", i);
|
|
435
|
+
const operation = this.getNodeParameter("operation", i);
|
|
436
|
+
let method = "GET";
|
|
437
|
+
let path = "";
|
|
438
|
+
let bodyObj;
|
|
439
|
+
let idempotencyKey;
|
|
440
|
+
if (resource === "cliente" && operation === "buscar") {
|
|
441
|
+
const telefone = this.getNodeParameter("telefone", i).trim();
|
|
442
|
+
method = "GET";
|
|
443
|
+
path = `/api/v1/agent/cliente?telefone=${encodeURIComponent(telefone)}`;
|
|
444
|
+
}
|
|
445
|
+
else if (resource === "catalogo" && operation === "resolver") {
|
|
446
|
+
const consultas = parseConsultas(this.getNodeParameter("consultas", i));
|
|
447
|
+
method = "POST";
|
|
448
|
+
path = "/api/v1/agent/catalogo/resolver";
|
|
449
|
+
bodyObj = { consultas };
|
|
450
|
+
}
|
|
451
|
+
else if (resource === "pedido" && operation === "detalhar") {
|
|
452
|
+
const ref = this.getNodeParameter("ref", i).trim();
|
|
453
|
+
method = "GET";
|
|
454
|
+
path = `/api/v1/agent/pedido/${encodeURIComponent(ref)}`;
|
|
455
|
+
}
|
|
456
|
+
else if (resource === "pedido" && operation === "criar") {
|
|
457
|
+
const telefone = this.getNodeParameter("telefone", i).trim();
|
|
458
|
+
const itens = asArray(this.getNodeParameter("itens", i));
|
|
459
|
+
const tipoEntrega = this.getNodeParameter("tipoEntrega", i);
|
|
460
|
+
const observacoes = this.getNodeParameter("observacoes", i, "").trim();
|
|
461
|
+
const agendadoPara = this.getNodeParameter("agendadoPara", i, "").trim();
|
|
462
|
+
const unitId = this.getNodeParameter("unitId", i, "").trim();
|
|
463
|
+
idempotencyKey =
|
|
464
|
+
this.getNodeParameter("idempotencyKey", i, "").trim() || (0, node_crypto_1.randomUUID)();
|
|
465
|
+
const b = { telefone, itens };
|
|
466
|
+
if (tipoEntrega)
|
|
467
|
+
b.tipo_entrega = tipoEntrega;
|
|
468
|
+
if (observacoes)
|
|
469
|
+
b.observacoes = observacoes;
|
|
470
|
+
if (agendadoPara)
|
|
471
|
+
b.agendado_para = agendadoPara;
|
|
472
|
+
if (unitId)
|
|
473
|
+
b.unit_id = unitId;
|
|
474
|
+
method = "POST";
|
|
475
|
+
path = "/api/v1/agent/pedido";
|
|
476
|
+
bodyObj = b;
|
|
477
|
+
}
|
|
478
|
+
else if (resource === "pedido" && operation === "alterar") {
|
|
479
|
+
const pedidoId = this.getNodeParameter("pedidoId", i).trim();
|
|
480
|
+
const observacoes = this.getNodeParameter("observacoes", i, "");
|
|
481
|
+
const agendadoPara = this.getNodeParameter("agendadoPara", i, "");
|
|
482
|
+
const adicionar = asArray(this.getNodeParameter("adicionarItens", i, "[]"));
|
|
483
|
+
const remover = parseIds(this.getNodeParameter("removerItemIds", i, ""));
|
|
484
|
+
const alterar = asArray(this.getNodeParameter("alterarItens", i, "[]"));
|
|
485
|
+
const b = {};
|
|
486
|
+
if (observacoes.trim())
|
|
487
|
+
b.observacoes = observacoes.trim();
|
|
488
|
+
if (agendadoPara.trim())
|
|
489
|
+
b.agendado_para = agendadoPara.trim();
|
|
490
|
+
if (adicionar.length)
|
|
491
|
+
b.adicionar_itens = adicionar;
|
|
492
|
+
if (remover.length)
|
|
493
|
+
b.remover_item_ids = remover;
|
|
494
|
+
if (alterar.length)
|
|
495
|
+
b.alterar_itens = alterar;
|
|
496
|
+
method = "PATCH";
|
|
497
|
+
path = `/api/v1/agent/pedido/${encodeURIComponent(pedidoId)}`;
|
|
498
|
+
bodyObj = b;
|
|
499
|
+
}
|
|
500
|
+
else if (resource === "pedido" && operation === "cancelar") {
|
|
501
|
+
const pedidoId = this.getNodeParameter("pedidoId", i).trim();
|
|
502
|
+
const motivo = this.getNodeParameter("motivo", i, "").trim();
|
|
503
|
+
method = "POST";
|
|
504
|
+
path = `/api/v1/agent/pedido/${encodeURIComponent(pedidoId)}/cancelar`;
|
|
505
|
+
bodyObj = motivo ? { motivo } : {};
|
|
506
|
+
}
|
|
507
|
+
else if (resource === "conversa" && operation === "registrar") {
|
|
508
|
+
const resumo = this.getNodeParameter("resumo", i).trim();
|
|
509
|
+
const resultado = this.getNodeParameter("resultado", i);
|
|
510
|
+
const convTelefone = this.getNodeParameter("convTelefone", i, "").trim();
|
|
511
|
+
const convPedidoId = this.getNodeParameter("convPedidoId", i, "").trim();
|
|
512
|
+
const b = { resumo, resultado };
|
|
513
|
+
if (convTelefone)
|
|
514
|
+
b.telefone = convTelefone;
|
|
515
|
+
if (convPedidoId)
|
|
516
|
+
b.pedido_id = convPedidoId;
|
|
517
|
+
method = "POST";
|
|
518
|
+
path = "/api/v1/agent/conversa/fechar";
|
|
519
|
+
bodyObj = b;
|
|
520
|
+
}
|
|
521
|
+
else {
|
|
522
|
+
throw new n8n_workflow_1.NodeApiError(this.getNode(), {
|
|
523
|
+
message: `Operação não suportada: ${resource}.${operation}`,
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
// ---- assina e envia (HMAC v1) ----
|
|
527
|
+
const rawBody = bodyObj === undefined ? "" : JSON.stringify(bodyObj);
|
|
528
|
+
const timestamp = Math.floor(Date.now() / 1000).toString();
|
|
529
|
+
const nonce = (0, node_crypto_1.randomUUID)();
|
|
530
|
+
const canonical = canonicalString(method, path, timestamp, nonce, rawBody);
|
|
531
|
+
const signature = sign(signingSecret, canonical);
|
|
532
|
+
const headers = {
|
|
533
|
+
[HEADERS.key]: tokenId,
|
|
534
|
+
[HEADERS.timestamp]: timestamp,
|
|
535
|
+
[HEADERS.nonce]: nonce,
|
|
536
|
+
[HEADERS.signature]: signature,
|
|
537
|
+
};
|
|
538
|
+
if (rawBody)
|
|
539
|
+
headers["content-type"] = "application/json";
|
|
540
|
+
if (idempotencyKey)
|
|
541
|
+
headers["idempotency-key"] = idempotencyKey;
|
|
542
|
+
const res = await this.helpers.httpRequest({
|
|
543
|
+
method,
|
|
544
|
+
url: baseUrl + path,
|
|
545
|
+
headers,
|
|
546
|
+
body: rawBody || undefined,
|
|
547
|
+
json: false,
|
|
548
|
+
returnFullResponse: true,
|
|
549
|
+
ignoreHttpStatusErrors: true,
|
|
550
|
+
});
|
|
551
|
+
const status = res.statusCode;
|
|
552
|
+
const rawRes = res.body;
|
|
553
|
+
let payload;
|
|
554
|
+
try {
|
|
555
|
+
payload = typeof rawRes === "string" ? JSON.parse(rawRes) : rawRes;
|
|
556
|
+
}
|
|
557
|
+
catch {
|
|
558
|
+
payload = { raw: rawRes };
|
|
559
|
+
}
|
|
560
|
+
if (status >= 400) {
|
|
561
|
+
const msg = (payload && payload.error) || `Núcleo respondeu HTTP ${status}.`;
|
|
562
|
+
if (this.continueOnFail()) {
|
|
563
|
+
returned.push({ json: { error: msg, status }, pairedItem: { item: i } });
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
throw new n8n_workflow_1.NodeApiError(this.getNode(), payload, {
|
|
567
|
+
message: msg,
|
|
568
|
+
httpCode: String(status),
|
|
569
|
+
itemIndex: i,
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
returned.push({ json: payload, pairedItem: { item: i } });
|
|
573
|
+
}
|
|
574
|
+
catch (error) {
|
|
575
|
+
if (this.continueOnFail()) {
|
|
576
|
+
returned.push({
|
|
577
|
+
json: { error: error.message },
|
|
578
|
+
pairedItem: { item: i },
|
|
579
|
+
});
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
throw error;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
return [returned];
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
exports.Nucleo = Nucleo;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60" fill="none">
|
|
2
|
+
<rect width="60" height="60" rx="13" fill="#6D28D9"/>
|
|
3
|
+
<ellipse cx="30" cy="30" rx="20" ry="9" stroke="#C4B5FD" stroke-width="2.5" transform="rotate(-30 30 30)"/>
|
|
4
|
+
<ellipse cx="30" cy="30" rx="20" ry="9" stroke="#C4B5FD" stroke-width="2.5" transform="rotate(30 30 30)"/>
|
|
5
|
+
<circle cx="30" cy="30" r="7.5" fill="#FFFFFF"/>
|
|
6
|
+
<circle cx="48" cy="20" r="2.6" fill="#C4B5FD"/>
|
|
7
|
+
</svg>
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@wondai/n8n-nodes-nucleo",
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Node n8n para o Núcleo Wondai — atendimento de IA (cliente, catálogo fuzzy, pedidos) com assinatura HMAC v1. Tenant vem do token (a parede, ADR-013).",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"n8n-community-node-package",
|
|
7
|
+
"n8n",
|
|
8
|
+
"wondai",
|
|
9
|
+
"nucleo",
|
|
10
|
+
"crm",
|
|
11
|
+
"padaria",
|
|
12
|
+
"ai-tool"
|
|
13
|
+
],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"author": "Wondai",
|
|
16
|
+
"homepage": "https://github.com/wondai/n8n-nodes-nucleo",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/wondai/n8n-nodes-nucleo.git"
|
|
20
|
+
},
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=18.10"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "node scripts/build.mjs",
|
|
26
|
+
"dev": "tsc --watch",
|
|
27
|
+
"prepublishOnly": "node scripts/build.mjs"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"dist"
|
|
31
|
+
],
|
|
32
|
+
"n8n": {
|
|
33
|
+
"n8nNodesApiVersion": 1,
|
|
34
|
+
"credentials": [
|
|
35
|
+
"dist/credentials/NucleoApi.credentials.js"
|
|
36
|
+
],
|
|
37
|
+
"nodes": [
|
|
38
|
+
"dist/nodes/Nucleo/Nucleo.node.js"
|
|
39
|
+
]
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/node": "^20.19.43",
|
|
43
|
+
"n8n-workflow": "*",
|
|
44
|
+
"typescript": "^5.6.0"
|
|
45
|
+
}
|
|
46
|
+
}
|