n8n-nodes-chatads 0.1.2 → 0.1.4

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  ## ChatAds n8n Wrapper
2
2
 
3
- Custom n8n node + credential that call the FastAPI endpoint defined in `affiliate/chatads_backend.py`. The node wraps `/v1/chatads/messages`, handles authentication via the `x-api-key` header, and exposes every field from the `FunctionItem` Pydantic model so you can orchestrate ChatAds scoring inside n8n workflows.
3
+ Custom n8n node + credential that calls the ChatAds API endpoint `/v1/chatads/messages`. Handles authentication via the `x-api-key` header and exposes all supported API fields so you can orchestrate ChatAds inside n8n workflows.
4
4
 
5
5
  ### Layout
6
6
 
@@ -19,20 +19,34 @@ Custom n8n node + credential that call the FastAPI endpoint defined in `affiliat
19
19
  ```
20
20
  This produces `dist/credentials/ChatAdsApi.credentials.js` and `dist/nodes/ChatAds/ChatAds.node.js`.
21
21
  2. Copy the compiled `dist` directory into your n8n custom nodes directory (for example `~/.n8n/custom/`) or publish the package to your internal npm registry and install it where your n8n instance can resolve it.
22
- 3. Restart n8n. The new **ChatAds** node will appear under the Transform group. Add it to a workflow, select the `ChatAds API` credential, and supply either:
23
- - A simple `message` plus optional fields (IP, domain, etc.), or
24
- - A raw JSON payload that matches the server-side `FunctionItem` contract (only documented fields are accepted; unexpected keys are rejected to prevent tampering).
22
+ 3. Restart n8n. The new **ChatAds** node will appear under the "Transform" group. Add it to a workflow, select the `ChatAds API` credential, and supply either:
23
+ - A simple `message` plus optional fields (IP, country, etc.), or
24
+ - A raw JSON payload (only documented fields are accepted; unexpected keys are rejected to prevent tampering).
25
25
  4. Optionally tune `Max Concurrent Requests` (default 4) and `Request Timeout (seconds)` for high-volume workflows. The node keeps item ordering consistent even when issuing requests in parallel.
26
26
  5. When executed, the node sends a POST request to `{{baseUrl}}/v1/chatads/messages` (configurable via the `Endpoint Override` parameter) with your `x-api-key` header and returns the FastAPI response verbatim so downstream nodes can branch on `success`, `error`, or any ad copy the backend generated.
27
27
 
28
- Because the wrapper still uses `this.helpers.httpRequest`, it honors n8ns retry/backoff settings and the `Continue On Fail` toggle while layering per-node timeouts and error payloads for easier debugging.
29
- `Extra Fields (JSON)` is validated and limited to the same keys exposed in `FunctionItem`, so untrusted workflows cannot inject credentials or arbitrary payload fields.
28
+ Because the wrapper still uses `this.helpers.httpRequest`, it honors n8n's retry/backoff settings and the `Continue On Fail` toggle while layering per-node timeouts and error payloads for easier debugging.
29
+ `Extra Fields (JSON)` is validated to prevent conflicts with reserved parameter keys, so untrusted workflows cannot silently override core fields.
30
30
 
31
31
  ### Releasing/Patching
32
32
 
33
33
  1. Bump the version in `package.json` whenever you change behavior (validation rules, new fields, etc.).
34
34
  2. Run `npm run build` to regenerate `dist/` artifacts—**these compiled files are what n8n loads**, so they must stay in sync with the TypeScript sources.
35
35
  3. Copy/publish the updated `dist/` directory to your custom nodes location or npm registry, then restart n8n.
36
- 4. If the backend `FunctionItem` schema changes, update both the parameter list in `ChatAds.node.ts` and the `ALLOWED_PAYLOAD_FIELDS` set near the top of the file to keep validation in sync.
36
+ 4. If the backend `FunctionItem` schema changes, update both the parameter list in `ChatAds.node.ts` and the `OPTIONAL_FIELDS` set near the top of the file to keep validation in sync.
37
37
 
38
38
  Consider keeping a short changelog (GitHub release notes or a `CHANGELOG.md`) so downstream workflows know when to reinstall the node.
39
+
40
+ ### Request Fields
41
+
42
+ The node accepts the following fields (via parameters or `Extra Fields (JSON)`):
43
+
44
+ | Field | Type | Description |
45
+ |-------|------|-------------|
46
+ | `message` | string (required) | Message to analyze (1-5000 chars) |
47
+ | `ip` | string | IPv4 address for country detection (max 64 characters) |
48
+ | `country` | string | Country code (e.g., 'US'). If provided, skips IP-based country detection |
49
+ | `message_analysis` | string | Controls keyword extraction method. Use 'fast' to optimize for speed, 'thorough' (default) to optimize for best keyword selection |
50
+ | `fill_priority` | string | Controls affiliate link discovery. Use 'speed' to optimize for speed, 'coverage' (default) to ping multiple sources for the right affiliate link |
51
+ | `min_intent` | string | Minimum purchase intent level required for affiliate resolution. 'any' = no filtering, 'low' (default) = filter garbage, 'medium' = balanced quality/fill, 'high' = high-intent keywords only |
52
+ | `skip_message_analysis` | boolean | Treat exact message as product keyword. When true, goes straight to affiliate link discovery without keyword extraction |
@@ -3,25 +3,25 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ChatAds = void 0;
4
4
  const n8n_workflow_1 = require("n8n-workflow");
5
5
  const DEFAULT_ENDPOINT = '/v1/chatads/messages';
6
- const ALLOWED_PAYLOAD_FIELDS = new Set([
7
- 'timestamp',
8
- 'pageUrl',
9
- 'pageTitle',
10
- 'referrer',
11
- 'address',
12
- 'email',
13
- 'type',
14
- 'domain',
15
- 'userAgent',
6
+ const OPTIONAL_FIELDS = new Set([
16
7
  'ip',
17
- 'reason',
18
- 'company',
19
- 'name',
20
- 'message',
21
8
  'country',
22
- 'language',
23
- 'website',
9
+ 'message_analysis',
10
+ 'fill_priority',
11
+ 'min_intent',
12
+ 'skip_message_analysis',
24
13
  ]);
14
+ const FIELD_ALIASES = {
15
+ messageanalysis: 'message_analysis',
16
+ message_analysis: 'message_analysis',
17
+ fillpriority: 'fill_priority',
18
+ fill_priority: 'fill_priority',
19
+ minintent: 'min_intent',
20
+ min_intent: 'min_intent',
21
+ skipmessageanalysis: 'skip_message_analysis',
22
+ skip_message_analysis: 'skip_message_analysis',
23
+ };
24
+ const RESERVED_PAYLOAD_KEYS = new Set(['message', ...OPTIONAL_FIELDS]);
25
25
  const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
26
26
  const normalizeJsonPath = (path) => path.startsWith('/') ? path : `/${path}`;
27
27
  const parseJsonObject = (context, value, fieldName, itemIndex) => {
@@ -52,20 +52,6 @@ const coerceToString = (context, source, key, value, itemIndex) => {
52
52
  }
53
53
  throw new n8n_workflow_1.NodeOperationError(context.getNode(), `${source} field "${key}" must be a string or primitive`, { itemIndex });
54
54
  };
55
- const sanitizeRecord = (context, payload, source, itemIndex) => {
56
- const sanitized = {};
57
- for (const [key, value] of Object.entries(payload)) {
58
- if (value === undefined || value === null) {
59
- continue;
60
- }
61
- const normalized = coerceToString(context, source, key, value, itemIndex);
62
- if (normalized === '') {
63
- continue;
64
- }
65
- sanitized[key] = normalized;
66
- }
67
- return sanitized;
68
- };
69
55
  const ensureMessage = (context, value, itemIndex) => {
70
56
  if (typeof value !== 'string') {
71
57
  throw new n8n_workflow_1.NodeOperationError(context.getNode(), 'Field "message" must be provided as a string', { itemIndex });
@@ -76,6 +62,50 @@ const ensureMessage = (context, value, itemIndex) => {
76
62
  }
77
63
  return trimmed;
78
64
  };
65
+ const normalizeFieldKey = (key) => { var _a; return (_a = FIELD_ALIASES[key.toLowerCase()]) !== null && _a !== void 0 ? _a : key; };
66
+ const normalizeOptionalField = (key) => {
67
+ const normalized = normalizeFieldKey(key);
68
+ if (OPTIONAL_FIELDS.has(normalized)) {
69
+ return normalized;
70
+ }
71
+ return undefined;
72
+ };
73
+ const assertNoReservedConflicts = (context, source, extras, itemIndex) => {
74
+ const conflicts = Object.keys(extras).filter((key) => RESERVED_PAYLOAD_KEYS.has(normalizeFieldKey(key)));
75
+ if (conflicts.length > 0) {
76
+ throw new n8n_workflow_1.NodeOperationError(context.getNode(), `${source} contains reserved keys: ${conflicts.join(', ')}`, { itemIndex });
77
+ }
78
+ };
79
+ const buildPayloadFromObject = (context, input, source, itemIndex) => {
80
+ const payload = {};
81
+ const extras = {};
82
+ const message = ensureMessage(context, input.message, itemIndex);
83
+ payload.message = message;
84
+ for (const [rawKey, rawValue] of Object.entries(input)) {
85
+ if (rawKey === 'message') {
86
+ continue;
87
+ }
88
+ if (rawValue === undefined || rawValue === null || rawValue === '') {
89
+ continue;
90
+ }
91
+ const optionalKey = normalizeOptionalField(rawKey);
92
+ if (optionalKey) {
93
+ if (optionalKey === 'skip_message_analysis') {
94
+ if (typeof rawValue !== 'boolean') {
95
+ throw new n8n_workflow_1.NodeOperationError(context.getNode(), `${source} field "${rawKey}" must be a boolean`, { itemIndex });
96
+ }
97
+ payload[optionalKey] = rawValue;
98
+ continue;
99
+ }
100
+ payload[optionalKey] = coerceToString(context, source, rawKey, rawValue, itemIndex);
101
+ continue;
102
+ }
103
+ extras[rawKey] = rawValue;
104
+ }
105
+ assertNoReservedConflicts(context, source, extras, itemIndex);
106
+ Object.assign(payload, extras);
107
+ return payload;
108
+ };
79
109
  const buildErrorPayload = (error) => {
80
110
  const fallback = {
81
111
  message: 'Unknown error',
@@ -102,13 +132,6 @@ const buildErrorPayload = (error) => {
102
132
  }
103
133
  return fallback;
104
134
  };
105
- const assertAllowedFields = (context, source, payload, allowed, itemIndex) => {
106
- for (const key of Object.keys(payload)) {
107
- if (!allowed.has(key)) {
108
- throw new n8n_workflow_1.NodeOperationError(context.getNode(), `${source} field "${key}" is not supported`, { itemIndex });
109
- }
110
- }
111
- };
112
135
  class ChatAds {
113
136
  constructor() {
114
137
  this.description = {
@@ -192,102 +215,61 @@ class ChatAds {
192
215
  },
193
216
  },
194
217
  options: [
195
- {
196
- displayName: 'Timestamp',
197
- name: 'timestamp',
198
- type: 'string',
199
- default: '',
200
- description: 'ISO timestamp when the interaction occurred',
201
- },
202
- {
203
- displayName: 'Page URL',
204
- name: 'pageUrl',
205
- type: 'string',
206
- default: '',
207
- },
208
- {
209
- displayName: 'Page Title',
210
- name: 'pageTitle',
211
- type: 'string',
212
- default: '',
213
- },
214
- {
215
- displayName: 'Referrer',
216
- name: 'referrer',
217
- type: 'string',
218
- default: '',
219
- },
220
- {
221
- displayName: 'Address',
222
- name: 'address',
223
- type: 'string',
224
- default: '',
225
- },
226
- {
227
- displayName: 'Email',
228
- name: 'email',
229
- type: 'string',
230
- default: '',
231
- },
232
- {
233
- displayName: 'Type',
234
- name: 'type',
235
- type: 'string',
236
- default: '',
237
- },
238
- {
239
- displayName: 'Domain',
240
- name: 'domain',
241
- type: 'string',
242
- default: '',
243
- },
244
- {
245
- displayName: 'User Agent',
246
- name: 'userAgent',
247
- type: 'string',
248
- default: '',
249
- },
250
218
  {
251
219
  displayName: 'IP Address',
252
220
  name: 'ip',
253
221
  type: 'string',
254
222
  default: '',
223
+ description: 'IPv4 address for country detection (max 64 characters)',
255
224
  },
256
225
  {
257
- displayName: 'Reason',
258
- name: 'reason',
259
- type: 'string',
260
- default: '',
261
- },
262
- {
263
- displayName: 'Company',
264
- name: 'company',
226
+ displayName: 'Country',
227
+ name: 'country',
265
228
  type: 'string',
266
229
  default: '',
230
+ description: "Country code (e.g., 'US'). If provided, skips IP-based country detection",
267
231
  },
268
232
  {
269
- displayName: 'Name',
270
- name: 'name',
271
- type: 'string',
272
- default: '',
233
+ displayName: 'Message Analysis',
234
+ name: 'message_analysis',
235
+ type: 'options',
236
+ options: [
237
+ { name: 'Fast', value: 'fast' },
238
+ { name: 'Thorough', value: 'thorough' },
239
+ ],
240
+ default: 'thorough',
241
+ description: 'Controls keyword extraction method. Use fast to optimize for speed, thorough to optimize for best keyword selection.',
273
242
  },
274
243
  {
275
- displayName: 'Country',
276
- name: 'country',
277
- type: 'string',
278
- default: '',
244
+ displayName: 'Fill Priority',
245
+ name: 'fill_priority',
246
+ type: 'options',
247
+ options: [
248
+ { name: 'Speed', value: 'speed' },
249
+ { name: 'Coverage', value: 'coverage' },
250
+ ],
251
+ default: 'coverage',
252
+ description: 'Controls affiliate link discovery. Use speed to optimize for speed, coverage to ping multiple sources for the right affiliate link',
279
253
  },
280
254
  {
281
- displayName: 'Language',
282
- name: 'language',
283
- type: 'string',
284
- default: '',
255
+ displayName: 'Min Intent',
256
+ name: 'min_intent',
257
+ type: 'options',
258
+ options: [
259
+ { name: 'Any', value: 'any' },
260
+ { name: 'Low', value: 'low' },
261
+ { name: 'Medium', value: 'medium' },
262
+ { name: 'High', value: 'high' },
263
+ ],
264
+ default: 'low',
265
+ description: "Minimum purchase intent level required for affiliate resolution. 'any' = no filtering, 'low' = filter garbage, 'medium' = balanced quality/fill, 'high' = high-intent keywords only",
285
266
  },
286
267
  {
287
- displayName: 'Website',
288
- name: 'website',
289
- type: 'string',
290
- default: '',
268
+ displayName: 'Skip Message Analysis',
269
+ name: 'skip_message_analysis',
270
+ type: 'boolean',
271
+ default: false,
272
+ description: 'Treat exact message as product keyword. When true, goes straight to affiliate link discovery without keyword extraction',
291
273
  },
292
274
  {
293
275
  displayName: 'Extra Fields (JSON)',
@@ -297,7 +279,7 @@ class ChatAds {
297
279
  alwaysOpenEditWindow: true,
298
280
  },
299
281
  default: '{}',
300
- description: 'Raw JSON object merged into the payload as-is',
282
+ description: 'JSON object containing additional fields to merge into the payload',
301
283
  },
302
284
  ],
303
285
  },
@@ -364,38 +346,45 @@ class ChatAds {
364
346
  if (jsonParameters) {
365
347
  const bodyJson = this.getNodeParameter('bodyJson', itemIndex);
366
348
  const parsedBody = parseJsonObject(this, bodyJson, 'Body JSON', itemIndex);
367
- const sanitizedBody = sanitizeRecord(this, parsedBody, 'Body JSON', itemIndex);
368
- const message = ensureMessage(this, parsedBody.message, itemIndex);
369
- sanitizedBody.message = message;
370
- assertAllowedFields(this, 'Body JSON', sanitizedBody, ALLOWED_PAYLOAD_FIELDS, itemIndex);
371
- body = sanitizedBody;
349
+ body = buildPayloadFromObject(this, parsedBody, 'Body JSON', itemIndex);
372
350
  }
373
351
  else {
374
352
  const rawMessage = this.getNodeParameter('message', itemIndex);
375
353
  const message = ensureMessage(this, rawMessage, itemIndex);
376
354
  const additionalFields = this.getNodeParameter('additionalFields', itemIndex, {});
377
- body = { message };
355
+ const constructed = { message };
378
356
  for (const [field, value] of Object.entries(additionalFields)) {
379
357
  if (value === undefined || value === null || value === '') {
380
358
  continue;
381
359
  }
382
360
  if (field === 'extraFieldsJson') {
383
361
  const parsedExtras = parseJsonObject(this, value, 'Extra fields JSON', itemIndex);
384
- const sanitizedExtras = sanitizeRecord(this, parsedExtras, 'Extra fields JSON', itemIndex);
385
- assertAllowedFields(this, 'Extra fields JSON', sanitizedExtras, ALLOWED_PAYLOAD_FIELDS, itemIndex);
386
- for (const [extraKey, extraValue] of Object.entries(sanitizedExtras)) {
362
+ assertNoReservedConflicts(this, 'Extra fields JSON', parsedExtras, itemIndex);
363
+ for (const [extraKey, extraValue] of Object.entries(parsedExtras)) {
364
+ if (extraValue === undefined || extraValue === null) {
365
+ continue;
366
+ }
387
367
  if (extraKey === 'message') {
388
368
  continue;
389
369
  }
390
- body[extraKey] = extraValue;
370
+ constructed[extraKey] = extraValue;
391
371
  }
392
372
  continue;
393
373
  }
394
- if (typeof value !== 'string') {
395
- throw new n8n_workflow_1.NodeOperationError(this.getNode(), `Field "${field}" must be provided as a string`, { itemIndex });
374
+ const normalizedKey = normalizeOptionalField(field);
375
+ if (!normalizedKey) {
376
+ throw new n8n_workflow_1.NodeOperationError(this.getNode(), `Field "${field}" is not supported`, { itemIndex });
377
+ }
378
+ if (normalizedKey === 'skip_message_analysis') {
379
+ if (typeof value !== 'boolean') {
380
+ throw new n8n_workflow_1.NodeOperationError(this.getNode(), `Field "${field}" must be provided as a boolean`, { itemIndex });
381
+ }
382
+ constructed[normalizedKey] = value;
383
+ continue;
396
384
  }
397
- body[field] = value;
385
+ constructed[normalizedKey] = coerceToString(this, 'Additional fields', field, value, itemIndex);
398
386
  }
387
+ body = buildPayloadFromObject(this, constructed, 'Additional fields', itemIndex);
399
388
  }
400
389
  const response = await this.helpers.httpRequestWithAuthentication.call(this, 'chatAdsApi', {
401
390
  method: 'POST',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "n8n-nodes-chatads",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "n8n wrapper around the ChatAds FastAPI backend (affiliate/chatads_backend.py)",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",