@tiledesk/tiledesk-tybot-connector 2.1.2 → 2.1.5

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.
@@ -2,6 +2,7 @@
2
2
 
3
3
  const axios = require('axios');
4
4
  const { v4: uuidv4 } = require('uuid');
5
+ const winston = require('./utils/winston');
5
6
 
6
7
  class AnalyticsClient {
7
8
 
@@ -17,7 +18,10 @@ class AnalyticsClient {
17
18
  static track(eventType, projectId, payload) {
18
19
  // Read env vars at call time so tests (and runtime reconfig) can override them.
19
20
  const ingestUrl = process.env.ANALYTICS_INGEST_URL;
20
- if (!ingestUrl) return;
21
+ if (!ingestUrl) {
22
+ winston.debug("(AnalyticsClient) ANALYTICS_INGEST_URL not set; skipping event_type=" + eventType);
23
+ return;
24
+ }
21
25
 
22
26
  const event = {
23
27
  event_id: uuidv4(),
@@ -35,12 +39,26 @@ class AnalyticsClient {
35
39
  headers['X-Api-Key'] = apiKey;
36
40
  }
37
41
 
42
+ // [analytics-debug] Log the POST and its outcome so dropped/rejected events
43
+ // (e.g. ingest 422 validation failures, 503 broker-down) are visible in the
44
+ // chatbot logs instead of being silently swallowed.
45
+ winston.debug("(AnalyticsClient) POST /events event_type=" + eventType + " event_id=" + event.event_id, payload);
46
+
38
47
  axios.post(
39
48
  ingestUrl + '/events',
40
49
  event,
41
50
  { headers, timeout: 3000 }
42
- ).catch(() => {
43
- // fire-and-forget: swallow all errors, never propagate to chatbot
51
+ ).then((res) => {
52
+ winston.debug("(AnalyticsClient) event accepted event_type=" + eventType + " event_id=" + event.event_id + " status=" + res.status);
53
+ }).catch((err) => {
54
+ // fire-and-forget: never propagate to chatbot, but log so drops are visible.
55
+ const status = err.response ? err.response.status : undefined;
56
+ const body = err.response ? err.response.data : undefined;
57
+ winston.warn("(AnalyticsClient) event REJECTED/FAILED event_type=" + eventType +
58
+ " event_id=" + event.event_id +
59
+ " status=" + status +
60
+ " error=" + (err.message || err) +
61
+ " response=" + (body ? JSON.stringify(body) : '<none>'));
44
62
  });
45
63
  }
46
64
  }
package/CHANGELOG.md CHANGED
@@ -5,6 +5,9 @@
5
5
  available on:
6
6
  ▶️ https://www.npmjs.com/package/@tiledesk/tiledesk-tybot-connector
7
7
 
8
+ # this branch
9
+ - added: JSON Condition V2 runtime — new `jsoncondition2` type routed to `DirJSONConditionV2` (evaluates the `when` expression via `TiledeskWhenExpression`, no eval/vm2); V1 `jsoncondition` dispatch left unchanged (`DirJSONCondition`) for full backward-compatibility
10
+
8
11
  # 2.1.0
9
12
  - added: support to analytics
10
13
 
@@ -0,0 +1,541 @@
1
+ /**
2
+ * TiledeskWhenExpression
3
+ *
4
+ * Safe evaluator for the new `when` field of JSON conditions.
5
+ *
6
+ * Design goals (see security review of TiledeskExpression.evaluateJavascriptExpression):
7
+ * - NO `eval`, NO `Function`, NO `vm`/`vm2`. The expression is parsed into an AST
8
+ * and interpreted by walking the tree. Code is never generated from data.
9
+ * - Whitelisted operators and whitelisted functions only.
10
+ * - Property access is blocked on dangerous keys (__proto__, prototype, constructor).
11
+ * - Function calls are allowed ONLY for plain identifiers present in the function
12
+ * whitelist (no method calls on arbitrary objects -> blocks `x.constructor(...)`).
13
+ * - Identifiers resolve exclusively against the provided variables map (own props only),
14
+ * so there is no access to host globals.
15
+ * - Hard limits on input length and recursion depth.
16
+ *
17
+ * This file lives ALONGSIDE the legacy TiledeskExpression.js, which is left untouched.
18
+ *
19
+ * Public API:
20
+ * const { TiledeskWhenExpression } = require('./TiledeskWhenExpression');
21
+ * const value = new TiledeskWhenExpression().evaluate(whenString, variables);
22
+ * // throws WhenSyntaxError / WhenEvalError on failure (callers decide how to route)
23
+ */
24
+
25
+ class WhenSyntaxError extends Error {
26
+ constructor(message) { super(message); this.name = 'WhenSyntaxError'; }
27
+ }
28
+ class WhenEvalError extends Error {
29
+ constructor(message) { super(message); this.name = 'WhenEvalError'; }
30
+ }
31
+
32
+ const TOKEN = { NUM: 'num', STR: 'str', IDENT: 'ident', OP: 'op', PUNC: 'punc', EOF: 'eof' };
33
+
34
+ // Binary operator precedence (higher binds tighter). Unary operators handled separately.
35
+ const BIN_PRECEDENCE = {
36
+ '||': 1,
37
+ '&&': 2,
38
+ '==': 3, '!=': 3, '===': 3, '!==': 3,
39
+ '<': 4, '<=': 4, '>': 4, '>=': 4,
40
+ '+': 5, '-': 5,
41
+ '*': 6, '/': 6, '%': 6,
42
+ };
43
+
44
+ const FORBIDDEN_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
45
+
46
+ const MAX_EXPRESSION_LENGTH = 10000;
47
+ const MAX_DEPTH = 100;
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Tokenizer
51
+ // ---------------------------------------------------------------------------
52
+
53
+ function isIdentStart(c) { return /[A-Za-z_$]/.test(c); }
54
+ function isIdentPart(c) { return /[A-Za-z0-9_$]/.test(c); }
55
+ function isDigit(c) { return c >= '0' && c <= '9'; }
56
+
57
+ function tokenize(src) {
58
+ const tokens = [];
59
+ let i = 0;
60
+ const n = src.length;
61
+
62
+ while (i < n) {
63
+ const c = src[i];
64
+
65
+ // whitespace
66
+ if (c === ' ' || c === '\t' || c === '\n' || c === '\r') { i++; continue; }
67
+
68
+ // string literal
69
+ if (c === '"' || c === "'") {
70
+ const quote = c;
71
+ i++;
72
+ let s = '';
73
+ while (i < n && src[i] !== quote) {
74
+ if (src[i] === '\\') {
75
+ i++;
76
+ const e = src[i];
77
+ if (e === 'n') s += '\n';
78
+ else if (e === 't') s += '\t';
79
+ else if (e === 'r') s += '\r';
80
+ else if (e === '\\') s += '\\';
81
+ else if (e === '"') s += '"';
82
+ else if (e === "'") s += "'";
83
+ else s += (e === undefined ? '' : e);
84
+ i++;
85
+ } else {
86
+ s += src[i];
87
+ i++;
88
+ }
89
+ }
90
+ if (i >= n) throw new WhenSyntaxError('Unterminated string literal');
91
+ i++; // consume closing quote
92
+ tokens.push({ type: TOKEN.STR, value: s });
93
+ continue;
94
+ }
95
+
96
+ // number literal
97
+ if (isDigit(c) || (c === '.' && isDigit(src[i + 1]))) {
98
+ const start = i;
99
+ while (i < n && isDigit(src[i])) i++;
100
+ if (src[i] === '.') { i++; while (i < n && isDigit(src[i])) i++; }
101
+ if (src[i] === 'e' || src[i] === 'E') {
102
+ i++;
103
+ if (src[i] === '+' || src[i] === '-') i++;
104
+ while (i < n && isDigit(src[i])) i++;
105
+ }
106
+ tokens.push({ type: TOKEN.NUM, value: Number(src.slice(start, i)) });
107
+ continue;
108
+ }
109
+
110
+ // identifier / keyword
111
+ if (isIdentStart(c)) {
112
+ const start = i;
113
+ i++;
114
+ while (i < n && isIdentPart(src[i])) i++;
115
+ tokens.push({ type: TOKEN.IDENT, value: src.slice(start, i) });
116
+ continue;
117
+ }
118
+
119
+ // multi-char operators (longest match first)
120
+ const three = src.slice(i, i + 3);
121
+ if (three === '===' || three === '!==') { tokens.push({ type: TOKEN.OP, value: three }); i += 3; continue; }
122
+
123
+ const two = src.slice(i, i + 2);
124
+ if (two === '?.') { tokens.push({ type: TOKEN.PUNC, value: '?.' }); i += 2; continue; }
125
+ if (two === '==' || two === '!=' || two === '<=' || two === '>=' || two === '&&' || two === '||') {
126
+ tokens.push({ type: TOKEN.OP, value: two }); i += 2; continue;
127
+ }
128
+
129
+ // single-char operators
130
+ if ('+-*/%<>!'.includes(c)) { tokens.push({ type: TOKEN.OP, value: c }); i++; continue; }
131
+
132
+ // punctuators
133
+ if ('()[],.'.includes(c)) { tokens.push({ type: TOKEN.PUNC, value: c }); i++; continue; }
134
+
135
+ throw new WhenSyntaxError("Unexpected character '" + c + "' at position " + i);
136
+ }
137
+
138
+ tokens.push({ type: TOKEN.EOF, value: null });
139
+ return tokens;
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // Parser (recursive descent + precedence climbing)
144
+ // ---------------------------------------------------------------------------
145
+
146
+ class Parser {
147
+ constructor(tokens) { this.tokens = tokens; this.pos = 0; }
148
+
149
+ peek() { return this.tokens[this.pos]; }
150
+ next() { return this.tokens[this.pos++]; }
151
+
152
+ expectPunc(value) {
153
+ const t = this.next();
154
+ if (!(t.type === TOKEN.PUNC && t.value === value)) {
155
+ throw new WhenSyntaxError("Expected '" + value + "' but found '" + t.value + "'");
156
+ }
157
+ }
158
+
159
+ parse() {
160
+ const node = this.parseExpression();
161
+ if (this.peek().type !== TOKEN.EOF) {
162
+ throw new WhenSyntaxError("Unexpected token '" + this.peek().value + "'");
163
+ }
164
+ return node;
165
+ }
166
+
167
+ parseExpression() { return this.parseBinary(0); }
168
+
169
+ parseBinary(minPrec) {
170
+ let left = this.parseUnary();
171
+ while (true) {
172
+ const t = this.peek();
173
+ if (t.type !== TOKEN.OP) break;
174
+ const prec = BIN_PRECEDENCE[t.value];
175
+ if (prec === undefined || prec < minPrec) break;
176
+ this.next();
177
+ const right = this.parseBinary(prec + 1); // left-associative
178
+ if (t.value === '&&' || t.value === '||') {
179
+ left = { type: 'Logical', op: t.value, left, right };
180
+ } else {
181
+ left = { type: 'Binary', op: t.value, left, right };
182
+ }
183
+ }
184
+ return left;
185
+ }
186
+
187
+ parseUnary() {
188
+ const t = this.peek();
189
+ if (t.type === TOKEN.OP && (t.value === '!' || t.value === '-' || t.value === '+')) {
190
+ this.next();
191
+ return { type: 'Unary', op: t.value, arg: this.parseUnary() };
192
+ }
193
+ return this.parsePostfix();
194
+ }
195
+
196
+ parsePostfix() {
197
+ let node = this.parsePrimary();
198
+ while (true) {
199
+ const t = this.peek();
200
+ if (t.type === TOKEN.PUNC && t.value === '.') {
201
+ this.next();
202
+ const id = this.next();
203
+ if (id.type !== TOKEN.IDENT) throw new WhenSyntaxError("Expected property name after '.'");
204
+ node = { type: 'Member', object: node, property: id.value, computed: false, optional: false };
205
+ } else if (t.type === TOKEN.PUNC && t.value === '?.') {
206
+ this.next();
207
+ if (this.peek().type === TOKEN.PUNC && this.peek().value === '[') {
208
+ this.next();
209
+ const expr = this.parseExpression();
210
+ this.expectPunc(']');
211
+ node = { type: 'Member', object: node, property: expr, computed: true, optional: true };
212
+ } else {
213
+ const id = this.next();
214
+ if (id.type !== TOKEN.IDENT) throw new WhenSyntaxError("Expected property name after '?.'");
215
+ node = { type: 'Member', object: node, property: id.value, computed: false, optional: true };
216
+ }
217
+ } else if (t.type === TOKEN.PUNC && t.value === '[') {
218
+ this.next();
219
+ const expr = this.parseExpression();
220
+ this.expectPunc(']');
221
+ node = { type: 'Member', object: node, property: expr, computed: true, optional: false };
222
+ } else if (t.type === TOKEN.PUNC && t.value === '(') {
223
+ this.next();
224
+ const args = [];
225
+ if (!(this.peek().type === TOKEN.PUNC && this.peek().value === ')')) {
226
+ args.push(this.parseExpression());
227
+ while (this.peek().type === TOKEN.PUNC && this.peek().value === ',') {
228
+ this.next();
229
+ args.push(this.parseExpression());
230
+ }
231
+ }
232
+ this.expectPunc(')');
233
+ node = { type: 'Call', callee: node, args };
234
+ } else {
235
+ break;
236
+ }
237
+ }
238
+ return node;
239
+ }
240
+
241
+ parsePrimary() {
242
+ const t = this.next();
243
+ if (t.type === TOKEN.NUM) return { type: 'Literal', value: t.value };
244
+ if (t.type === TOKEN.STR) return { type: 'Literal', value: t.value };
245
+ if (t.type === TOKEN.IDENT) {
246
+ if (t.value === 'true') return { type: 'Literal', value: true };
247
+ if (t.value === 'false') return { type: 'Literal', value: false };
248
+ if (t.value === 'null') return { type: 'Literal', value: null };
249
+ if (t.value === 'undefined') return { type: 'Literal', value: undefined };
250
+ return { type: 'Identifier', name: t.value };
251
+ }
252
+ if (t.type === TOKEN.PUNC && t.value === '(') {
253
+ const node = this.parseExpression();
254
+ this.expectPunc(')');
255
+ return node;
256
+ }
257
+ throw new WhenSyntaxError("Unexpected token '" + (t.value === null ? 'EOF' : t.value) + "'");
258
+ }
259
+ }
260
+
261
+ // ---------------------------------------------------------------------------
262
+ // Evaluator helpers (type coercion mirrors the intent of the legacy operators)
263
+ // ---------------------------------------------------------------------------
264
+
265
+ function isNumeric(v) {
266
+ if (typeof v === 'number') return Number.isFinite(v);
267
+ if (typeof v === 'string' && v.trim() !== '') return Number.isFinite(Number(v));
268
+ return false;
269
+ }
270
+
271
+ // Boolean coercion per the `when` grammar (§3): only true / "true" coerce to
272
+ // true, only false / "false" to false; anything else coerces to false.
273
+ function coerceBoolean(v) {
274
+ if (v === true) return true;
275
+ if (v === false) return false;
276
+ if (typeof v === 'string') {
277
+ const s = v.trim().toLowerCase();
278
+ if (s === 'true') return true;
279
+ if (s === 'false') return false;
280
+ }
281
+ return false;
282
+ }
283
+
284
+ // Parse a value into a Date (ISO-8601 strings, epoch numbers, Date instances).
285
+ // Returns null when the value is missing or not a valid date.
286
+ function parseDate(v) {
287
+ if (v === null || v === undefined || v === '') return null;
288
+ const d = (v instanceof Date) ? v : new Date(v);
289
+ return Number.isNaN(d.getTime()) ? null : d;
290
+ }
291
+
292
+ // Structural (deep) equality for arrays / plain objects coming from JSON
293
+ // variables. Used when both sides of == / != resolve to objects.
294
+ function deepEqual(a, b, depth) {
295
+ if (depth > MAX_DEPTH) return false;
296
+ if (a === b) return true;
297
+ if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') return false;
298
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
299
+ const ak = Object.keys(a), bk = Object.keys(b);
300
+ if (ak.length !== bk.length) return false;
301
+ for (const k of ak) {
302
+ if (FORBIDDEN_KEYS.has(k)) continue;
303
+ if (!Object.prototype.hasOwnProperty.call(b, k)) return false;
304
+ if (!deepEqual(a[k], b[k], depth + 1)) return false;
305
+ }
306
+ return true;
307
+ }
308
+
309
+ // Equality whose semantics follow the RIGHT operand's type, mirroring the
310
+ // grammar's disambiguation of the single `==` token:
311
+ // x == "y" -> text (case-sensitive String comparison)
312
+ // x == N -> number (numeric coercion; non-numeric left -> not equal)
313
+ // x == true -> boolean (coerceBoolean on the left)
314
+ // x == otherVar -> compare by the resolved value (deep equality for objects)
315
+ function looseEq(l, r) {
316
+ if (typeof r === 'boolean') return coerceBoolean(l) === r;
317
+ if (typeof r === 'number') {
318
+ // Only real numbers / numeric strings compare numerically; arrays, objects,
319
+ // booleans and empty/non-numeric strings are NOT a number -> not equal.
320
+ return isNumeric(l) && Number(l) === r;
321
+ }
322
+ if (r !== null && typeof r === 'object') return deepEqual(l, r, 0);
323
+ // string / null / undefined right operand -> case-sensitive text comparison
324
+ return String(l) === String(r);
325
+ }
326
+
327
+ // Relational operators (<, <=, >, >=) are numeric-only per the grammar (§3).
328
+ // Anything that is not a number / numeric string (arrays, objects, booleans,
329
+ // empty strings, missing values) yields false instead of a coerced comparison.
330
+ function numericCompare(op, l, r) {
331
+ if (!isNumeric(l) || !isNumeric(r)) return false;
332
+ const a = Number(l), b = Number(r);
333
+ switch (op) {
334
+ case '<': return a < b;
335
+ case '<=': return a <= b;
336
+ case '>': return a > b;
337
+ case '>=': return a >= b;
338
+ default: return false;
339
+ }
340
+ }
341
+
342
+ function applyBinary(op, l, r) {
343
+ switch (op) {
344
+ case '+':
345
+ if (isNumeric(l) && isNumeric(r)) return Number(l) + Number(r);
346
+ return String(l) + String(r);
347
+ case '-': return Number(l) - Number(r);
348
+ case '*': return Number(l) * Number(r);
349
+ case '/': return Number(l) / Number(r);
350
+ case '%': return Number(l) % Number(r);
351
+ case '==': return looseEq(l, r);
352
+ case '!=': return !looseEq(l, r);
353
+ case '===': return l === r;
354
+ case '!==': return l !== r;
355
+ case '<':
356
+ case '<=':
357
+ case '>':
358
+ case '>=': return numericCompare(op, l, r);
359
+ default: throw new WhenEvalError('Unknown binary operator: ' + op);
360
+ }
361
+ }
362
+
363
+ // Whitelisted functions callable from a `when` expression. This table covers
364
+ // the full JSON-condition grammar (see docs/json-condition-when-grammar.md) and
365
+ // is the ONLY way an expression can invoke code. No function ever throws: on bad
366
+ // input it returns false (predicates) or a neutral value, so a condition that
367
+ // cannot be evaluated simply resolves falsy.
368
+ const DEFAULT_FUNCTIONS = {
369
+ // --- Text (case-sensitive; grammar §3) ---------------------------------
370
+ startsWith: (a, b) => String(a).startsWith(String(b)),
371
+ notStartsWith: (a, b) => !String(a).startsWith(String(b)),
372
+ endsWith: (a, b) => String(a).endsWith(String(b)),
373
+ contains: (a, b) => String(a).includes(String(b)),
374
+ matches: (a, b) => { try { return new RegExp(String(b)).test(String(a)); } catch (e) { return false; } },
375
+ // Legacy *IgnoreCase operators were removed by the grammar (§5); if they
376
+ // surface in old data, evaluate them as their case-sensitive equivalent.
377
+ startsWithIgnoreCase: (a, b) => String(a).startsWith(String(b)),
378
+ containsIgnoreCase: (a, b) => String(a).includes(String(b)),
379
+
380
+ // --- Existence / emptiness ---------------------------------------------
381
+ // isEmpty: null/undefined, "", [], or {} are empty; scalars are never empty.
382
+ isEmpty: (a) => {
383
+ if (a === null || a === undefined) return true;
384
+ if (typeof a === 'string') return a === '';
385
+ if (Array.isArray(a)) return a.length === 0;
386
+ if (typeof a === 'object') return Object.keys(a).length === 0;
387
+ return false;
388
+ },
389
+ isNull: (a) => a === null,
390
+ isUndefined: (a) => a === undefined,
391
+
392
+ // --- Date / time (ISO-8601; any invalid/missing date -> false) ---------
393
+ dateEqual: (a, b) => { const x = parseDate(a), y = parseDate(b); return !!(x && y) && x.getTime() === y.getTime(); },
394
+ isAfter: (a, b) => { const x = parseDate(a), y = parseDate(b); return !!(x && y) && x.getTime() > y.getTime(); },
395
+ isBefore: (a, b) => { const x = parseDate(a), y = parseDate(b); return !!(x && y) && x.getTime() < y.getTime(); },
396
+ isAfterOrEqual: (a, b) => { const x = parseDate(a), y = parseDate(b); return !!(x && y) && x.getTime() >= y.getTime(); },
397
+ isBeforeOrEqual: (a, b) => { const x = parseDate(a), y = parseDate(b); return !!(x && y) && x.getTime() <= y.getTime(); },
398
+
399
+ // --- Array -------------------------------------------------------------
400
+ // arrayContains: coerce the left side to an array (parse JSON strings), then
401
+ // membership-test comparing elements as strings (no substring false positive).
402
+ arrayContains: (a, b) => {
403
+ let arr = a;
404
+ if (typeof a === 'string') {
405
+ try { const parsed = JSON.parse(a); if (Array.isArray(parsed)) arr = parsed; } catch (e) { /* not a JSON array */ }
406
+ }
407
+ if (!Array.isArray(arr)) return false;
408
+ return arr.some((el) => String(el) === String(b));
409
+ },
410
+ // length: array element count, or string character length, otherwise 0.
411
+ length: (a) => {
412
+ if (Array.isArray(a)) return a.length;
413
+ if (typeof a === 'string') return a.length;
414
+ return 0;
415
+ },
416
+
417
+ // --- Scalar transforms (not condition operators, kept for expression use)
418
+ upperCase: (a) => String(a).toUpperCase(),
419
+ lowerCase: (a) => String(a).toLowerCase(),
420
+ capitalize: (a) => { const s = String(a); return s.charAt(0).toUpperCase() + s.slice(1); },
421
+ abs: (a) => Math.abs(Number(a)),
422
+ ceil: (a) => Math.ceil(Number(a)),
423
+ floor: (a) => Math.floor(Number(a)),
424
+ round: (a) => Math.round(Number(a)),
425
+ cos: (a) => Math.cos(Number(a)),
426
+ sin: (a) => Math.sin(Number(a)),
427
+ tan: (a) => Math.tan(Number(a)),
428
+ convertToNumber: (a) => Number(a),
429
+ };
430
+
431
+ // ---------------------------------------------------------------------------
432
+ // Evaluator
433
+ // ---------------------------------------------------------------------------
434
+
435
+ function evalNode(node, scope, fns, depth) {
436
+ if (depth > MAX_DEPTH) throw new WhenEvalError('Expression nesting too deep');
437
+
438
+ switch (node.type) {
439
+ case 'Literal':
440
+ return node.value;
441
+
442
+ case 'Identifier': {
443
+ const name = node.name;
444
+ if (FORBIDDEN_KEYS.has(name)) return undefined;
445
+ return Object.prototype.hasOwnProperty.call(scope, name) ? scope[name] : undefined;
446
+ }
447
+
448
+ case 'Unary': {
449
+ const v = evalNode(node.arg, scope, fns, depth + 1);
450
+ if (node.op === '!') return !v;
451
+ if (node.op === '-') return -Number(v);
452
+ if (node.op === '+') return +Number(v);
453
+ throw new WhenEvalError('Unknown unary operator: ' + node.op);
454
+ }
455
+
456
+ case 'Logical': {
457
+ const l = evalNode(node.left, scope, fns, depth + 1);
458
+ if (node.op === '&&') return l ? evalNode(node.right, scope, fns, depth + 1) : l;
459
+ if (node.op === '||') return l ? l : evalNode(node.right, scope, fns, depth + 1);
460
+ throw new WhenEvalError('Unknown logical operator: ' + node.op);
461
+ }
462
+
463
+ case 'Binary': {
464
+ const l = evalNode(node.left, scope, fns, depth + 1);
465
+ const r = evalNode(node.right, scope, fns, depth + 1);
466
+ return applyBinary(node.op, l, r);
467
+ }
468
+
469
+ case 'Member': {
470
+ const obj = evalNode(node.object, scope, fns, depth + 1);
471
+ if (obj === null || obj === undefined) return undefined; // safe + handles optional chaining
472
+ let key = node.computed ? evalNode(node.property, scope, fns, depth + 1) : node.property;
473
+ key = String(key);
474
+ if (FORBIDDEN_KEYS.has(key)) return undefined;
475
+ return obj[key];
476
+ }
477
+
478
+ case 'Call': {
479
+ // Only plain whitelisted identifiers may be called. This blocks any method
480
+ // call on arbitrary objects (e.g. x.constructor(...), [].map(...)).
481
+ if (node.callee.type !== 'Identifier') {
482
+ throw new WhenEvalError('Only whitelisted function calls are allowed');
483
+ }
484
+ const fname = node.callee.name;
485
+ const fn = fns[fname];
486
+ if (typeof fn !== 'function') throw new WhenEvalError('Function not allowed: ' + fname);
487
+ const args = node.args.map((a) => evalNode(a, scope, fns, depth + 1));
488
+ return fn(...args);
489
+ }
490
+
491
+ default:
492
+ throw new WhenEvalError('Unknown node type: ' + node.type);
493
+ }
494
+ }
495
+
496
+ // ---------------------------------------------------------------------------
497
+ // Public class
498
+ // ---------------------------------------------------------------------------
499
+
500
+ class TiledeskWhenExpression {
501
+ /**
502
+ * @param {object} [options]
503
+ * @param {object} [options.functions] extra/override whitelisted functions
504
+ * @param {number} [options.maxLength] max expression length
505
+ */
506
+ constructor(options) {
507
+ this.functions = Object.assign({}, DEFAULT_FUNCTIONS, options && options.functions);
508
+ this.maxLength = (options && options.maxLength) || MAX_EXPRESSION_LENGTH;
509
+ }
510
+
511
+ /** Parse only (useful for validation/tests). Throws WhenSyntaxError on failure. */
512
+ parse(expression) {
513
+ if (typeof expression !== 'string') throw new WhenSyntaxError('expression must be a string');
514
+ if (expression.length > this.maxLength) throw new WhenSyntaxError('expression too long');
515
+ return new Parser(tokenize(expression)).parse();
516
+ }
517
+
518
+ /**
519
+ * Evaluate a `when` expression against a variables map.
520
+ * @returns the evaluated value (commonly a boolean).
521
+ * @throws WhenSyntaxError on parse errors, WhenEvalError on evaluation errors.
522
+ */
523
+ evaluate(expression, variables) {
524
+ const ast = this.parse(expression);
525
+ const scope = (variables && typeof variables === 'object') ? variables : {};
526
+ return evalNode(ast, scope, this.functions, 0);
527
+ }
528
+
529
+ /** Convenience: evaluate and coerce to boolean; returns null on any error. */
530
+ evaluateAsBoolean(expression, variables) {
531
+ try {
532
+ const v = this.evaluate(expression, variables);
533
+ if (v === null || v === undefined) return null;
534
+ return Boolean(v);
535
+ } catch (e) {
536
+ return null;
537
+ }
538
+ }
539
+ }
540
+
541
+ module.exports = { TiledeskWhenExpression, WhenSyntaxError, WhenEvalError };
@@ -391,15 +391,18 @@ class TiledeskChatbot {
391
391
  // Store intent tracking data for downstream completion/block analytics
392
392
  this._lastIntentId = answerObj.intent_id || answerObj._id?.toString() || '';
393
393
 
394
- AnalyticsClient.track('agent.intent_matched', this.projectId, {
395
- agent_id: this.bot.root_id || this.botId,
396
- intent_id: answerObj.intent_id || answerObj._id?.toString() || '',
397
- intent_name: intent_name,
398
- match_type: matchContext.match_type || 'explicit',
399
- confidence: (answerObj.score != null) ? answerObj.score : null,
400
- step_count: _step,
401
- request_id: this.requestId || null
402
- });
394
+ // Only track published (production) runs (root/draft copy has no root_id).
395
+ if (this.bot.root_id) {
396
+ AnalyticsClient.track('agent.intent_matched', this.projectId, {
397
+ agent_id: this.bot.root_id,
398
+ intent_id: answerObj.intent_id || answerObj._id?.toString() || '',
399
+ intent_name: intent_name,
400
+ match_type: matchContext.match_type || 'explicit',
401
+ confidence: (answerObj.score != null) ? answerObj.score : null,
402
+ step_count: _step,
403
+ request_id: this.requestId || null
404
+ });
405
+ }
403
406
 
404
407
  const context = {
405
408
  payload: {
package/index.js CHANGED
@@ -187,10 +187,12 @@ router.post('/ext/:botid', async (req, res) => {
187
187
  winston.error("(tybotRoute) Error while processing actions:", error);
188
188
  }
189
189
 
190
- if (chatbot._intentStartTime) {
190
+ // Only track published (production) runs: the root/draft copy has no root_id,
191
+ // so draft/test executions are intentionally excluded from analytics.
192
+ if (chatbot._intentStartTime && bot.root_id) {
191
193
  const intentDuration = Date.now() - chatbot._intentStartTime;
192
194
  AnalyticsClient.track('agent.intent_completed', projectId, {
193
- agent_id: bot.root_id || botId,
195
+ agent_id: bot.root_id,
194
196
  intent_id: chatbot._lastIntentId || '',
195
197
  intent_name: reply.attributes?.intent_info?.intent_name || 'unknown',
196
198
  duration_ms: intentDuration,
@@ -217,7 +219,9 @@ router.post('/ext/:botid', async (req, res) => {
217
219
  winston.verbose("(tybotRoute) sendSupportMessageExt reply sent: ", reply)
218
220
  });
219
221
 
220
- if (chatbot._intentStartTime) {
222
+ // Only track published (production) runs: the root/draft copy has no root_id,
223
+ // so draft/test executions are intentionally excluded from analytics.
224
+ if (chatbot._intentStartTime && bot.root_id) {
221
225
  const intentDuration = Date.now() - chatbot._intentStartTime;
222
226
  AnalyticsClient.track('agent.intent_completed', projectId, {
223
227
  agent_id: bot.root_id,
@@ -593,12 +597,10 @@ router.post('/block/:project_id/:bot_id/:block_id', async (req, res) => {
593
597
  const execution_id = uuidv4().replace(/-/g, '');
594
598
  request_id = "automation-request-" + project_id + "-" + execution_id;
595
599
  }
596
- AnalyticsClient.track('webhook.triggered', project_id, {
597
- agent_id: bot_id,
598
- block_id: block_id,
599
- async: async === true,
600
- request_id: request_id
601
- });
600
+ // webhook.triggered is emitted by tiledesk-server (routes/webhook.js) the
601
+ // single source for production webhook automations (it carries webhook_id and
602
+ // excludes dev/draft runs). Not emitted here to avoid double-counting; the
603
+ // block execution itself is already recorded via agent.block_executed.
602
604
 
603
605
  const command = "/#" + block_id;
604
606
  let message = {