@henry-dev/botkit 0.0.0-stage → 1.0.0
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/dist/index.cjs +2695 -0
- package/dist/index.d.cts +749 -0
- package/dist/index.d.ts +749 -0
- package/dist/index.js +2621 -0
- package/package.json +34 -6
- package/README.md +0 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,2621 @@
|
|
|
1
|
+
// matchers/matcher.literal.ts
|
|
2
|
+
function escapeRegExp(string) {
|
|
3
|
+
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
4
|
+
}
|
|
5
|
+
var hasRegexSyntax = (p) => /[\{\}\[\]\(\)\|\+\?\^\$]/.test(p);
|
|
6
|
+
function createLiteralMatcher(patterns, opts) {
|
|
7
|
+
const normalizeText = (text) => {
|
|
8
|
+
if (opts?.normalize === false) {
|
|
9
|
+
return text.trim().toLowerCase();
|
|
10
|
+
}
|
|
11
|
+
let res = text.trim().toLowerCase();
|
|
12
|
+
res = res.replace(/[.,\/#!$%\^&\*;:{}=\-_`~()?"'’]/g, "");
|
|
13
|
+
res = res.replace(/(.)\1{2,}/g, "$1");
|
|
14
|
+
res = res.replace(/\s+/g, " ");
|
|
15
|
+
return res.trim();
|
|
16
|
+
};
|
|
17
|
+
const compilePattern = (pattern) => {
|
|
18
|
+
const normalized = normalizeText(pattern);
|
|
19
|
+
const hasRegex = hasRegexSyntax(normalized);
|
|
20
|
+
const escaped = hasRegex ? normalized : escapeRegExp(normalized);
|
|
21
|
+
const useBoundary = opts?.boundary ?? true;
|
|
22
|
+
if (useBoundary && !hasRegex) {
|
|
23
|
+
return `\\b${escaped}\\b`;
|
|
24
|
+
}
|
|
25
|
+
return escaped;
|
|
26
|
+
};
|
|
27
|
+
const exactPatterns = patterns.filter((p) => p !== "*");
|
|
28
|
+
const hasWildcard = patterns.includes("*");
|
|
29
|
+
const compiledExact = exactPatterns.map((pattern) => {
|
|
30
|
+
const regexStr = compilePattern(pattern);
|
|
31
|
+
return {
|
|
32
|
+
pattern,
|
|
33
|
+
regex: new RegExp(regexStr, "i")
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
const positiveRegExp = exactPatterns.length > 0 ? new RegExp(exactPatterns.map(compilePattern).join("|"), "i") : null;
|
|
37
|
+
const excludePatterns = opts?.excludeIf || [];
|
|
38
|
+
const negationRegExp = excludePatterns.length > 0 ? new RegExp(excludePatterns.map(compilePattern).join("|"), "i") : null;
|
|
39
|
+
return {
|
|
40
|
+
patterns,
|
|
41
|
+
normalize: normalizeText,
|
|
42
|
+
match: (text) => {
|
|
43
|
+
const normalizedInput = normalizeText(text);
|
|
44
|
+
if (negationRegExp && negationRegExp.test(normalizedInput)) {
|
|
45
|
+
return {
|
|
46
|
+
matched: false,
|
|
47
|
+
pattern: "",
|
|
48
|
+
confidence: 0
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
if (positiveRegExp && positiveRegExp.test(normalizedInput)) {
|
|
52
|
+
for (const item of compiledExact) {
|
|
53
|
+
if (item.regex.test(normalizedInput)) {
|
|
54
|
+
return {
|
|
55
|
+
matched: true,
|
|
56
|
+
pattern: item.pattern,
|
|
57
|
+
confidence: 1
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (hasWildcard) {
|
|
63
|
+
return {
|
|
64
|
+
matched: true,
|
|
65
|
+
pattern: "*",
|
|
66
|
+
confidence: 0.1
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
matched: false,
|
|
71
|
+
pattern: "",
|
|
72
|
+
confidence: 0
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function matchesAny(patterns, opts) {
|
|
78
|
+
return createLiteralMatcher(patterns, opts);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// core/builder.ts
|
|
82
|
+
var CompileError = class extends Error {
|
|
83
|
+
constructor(message) {
|
|
84
|
+
super(message);
|
|
85
|
+
this.name = "CompileError";
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
var ActionNodeBuilder = class {
|
|
89
|
+
constructor(bot, id, options) {
|
|
90
|
+
this.bot = bot;
|
|
91
|
+
this.id = id;
|
|
92
|
+
this._timeoutMs = 5e3;
|
|
93
|
+
this._text = "";
|
|
94
|
+
if (options) {
|
|
95
|
+
if (options.execute) this._actionFn = options.execute;
|
|
96
|
+
if (options.timeoutMs) this._timeoutMs = options.timeoutMs;
|
|
97
|
+
if (typeof options.onSuccess === "string") {
|
|
98
|
+
this._onSuccess = { targetNodeId: options.onSuccess };
|
|
99
|
+
} else if (options.onSuccess) {
|
|
100
|
+
this._onSuccess = options.onSuccess;
|
|
101
|
+
}
|
|
102
|
+
if (typeof options.onFailure === "string") {
|
|
103
|
+
this._onFailure = { targetNodeId: options.onFailure };
|
|
104
|
+
} else if (options.onFailure) {
|
|
105
|
+
this._onFailure = options.onFailure;
|
|
106
|
+
}
|
|
107
|
+
if (typeof options.onTimeout === "string") {
|
|
108
|
+
this._onTimeout = { targetNodeId: options.onTimeout };
|
|
109
|
+
} else if (options.onTimeout) {
|
|
110
|
+
this._onTimeout = options.onTimeout;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
execute(fn) {
|
|
115
|
+
this._actionFn = fn;
|
|
116
|
+
return this;
|
|
117
|
+
}
|
|
118
|
+
timeout(ms) {
|
|
119
|
+
this._timeoutMs = ms;
|
|
120
|
+
return this;
|
|
121
|
+
}
|
|
122
|
+
text(msg) {
|
|
123
|
+
this._text = msg;
|
|
124
|
+
return this;
|
|
125
|
+
}
|
|
126
|
+
onSuccess(targetNodeId, handler) {
|
|
127
|
+
this._onSuccess = { targetNodeId, handler };
|
|
128
|
+
return this;
|
|
129
|
+
}
|
|
130
|
+
onFailure(targetNodeId, handler) {
|
|
131
|
+
this._onFailure = { targetNodeId, handler };
|
|
132
|
+
return this;
|
|
133
|
+
}
|
|
134
|
+
onTimeout(targetNodeId, handler) {
|
|
135
|
+
this._onTimeout = { targetNodeId, handler };
|
|
136
|
+
return this;
|
|
137
|
+
}
|
|
138
|
+
onEnter(fn) {
|
|
139
|
+
this._onEnter = fn;
|
|
140
|
+
return this;
|
|
141
|
+
}
|
|
142
|
+
onExit(fn) {
|
|
143
|
+
this._onExit = fn;
|
|
144
|
+
return this;
|
|
145
|
+
}
|
|
146
|
+
ask(text) {
|
|
147
|
+
return this.bot.ask(text);
|
|
148
|
+
}
|
|
149
|
+
action(id, options) {
|
|
150
|
+
return this.bot.action(id, options);
|
|
151
|
+
}
|
|
152
|
+
handoff(id, options) {
|
|
153
|
+
return this.bot.handoff(id, options);
|
|
154
|
+
}
|
|
155
|
+
global(matcher, options) {
|
|
156
|
+
return this.bot.global(matcher, options);
|
|
157
|
+
}
|
|
158
|
+
getId() {
|
|
159
|
+
return this.id;
|
|
160
|
+
}
|
|
161
|
+
getText() {
|
|
162
|
+
return this._text;
|
|
163
|
+
}
|
|
164
|
+
getActionFn() {
|
|
165
|
+
return this._actionFn;
|
|
166
|
+
}
|
|
167
|
+
getTimeoutMs() {
|
|
168
|
+
return this._timeoutMs;
|
|
169
|
+
}
|
|
170
|
+
getOnSuccess() {
|
|
171
|
+
return this._onSuccess;
|
|
172
|
+
}
|
|
173
|
+
getOnFailure() {
|
|
174
|
+
return this._onFailure;
|
|
175
|
+
}
|
|
176
|
+
getOnTimeout() {
|
|
177
|
+
return this._onTimeout;
|
|
178
|
+
}
|
|
179
|
+
getOnEnterFn() {
|
|
180
|
+
return this._onEnter;
|
|
181
|
+
}
|
|
182
|
+
getOnExitFn() {
|
|
183
|
+
return this._onExit;
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
var HandoffNodeBuilder = class {
|
|
187
|
+
constructor(bot, id, options) {
|
|
188
|
+
this.bot = bot;
|
|
189
|
+
this.id = id;
|
|
190
|
+
this._timeoutMs = 10 * 60 * 1e3;
|
|
191
|
+
this._text = "Connecting you with an agent. Please hold...";
|
|
192
|
+
this._queueMessage = "You are in the queue. An agent will be with you shortly.";
|
|
193
|
+
if (options) {
|
|
194
|
+
if (options.reason) this._reason = options.reason;
|
|
195
|
+
if (options.timeoutMs !== void 0) this._timeoutMs = options.timeoutMs;
|
|
196
|
+
if (options.resumeNodeId) this._resumeNodeId = options.resumeNodeId;
|
|
197
|
+
if (options.fallbackNodeId) this._fallbackNodeId = options.fallbackNodeId;
|
|
198
|
+
if (options.message) this._text = options.message;
|
|
199
|
+
if (options.queueMessage !== void 0) this._queueMessage = options.queueMessage;
|
|
200
|
+
if (options.metadata) this._metadata = options.metadata;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
reason(text) {
|
|
204
|
+
this._reason = text;
|
|
205
|
+
return this;
|
|
206
|
+
}
|
|
207
|
+
timeout(ms) {
|
|
208
|
+
this._timeoutMs = ms;
|
|
209
|
+
return this;
|
|
210
|
+
}
|
|
211
|
+
resumeAt(targetNodeId) {
|
|
212
|
+
this._resumeNodeId = targetNodeId;
|
|
213
|
+
return this;
|
|
214
|
+
}
|
|
215
|
+
fallbackTo(targetNodeId) {
|
|
216
|
+
this._fallbackNodeId = targetNodeId;
|
|
217
|
+
return this;
|
|
218
|
+
}
|
|
219
|
+
message(text) {
|
|
220
|
+
this._text = text;
|
|
221
|
+
return this;
|
|
222
|
+
}
|
|
223
|
+
queueMessage(text) {
|
|
224
|
+
this._queueMessage = text;
|
|
225
|
+
return this;
|
|
226
|
+
}
|
|
227
|
+
metadata(data) {
|
|
228
|
+
this._metadata = data;
|
|
229
|
+
return this;
|
|
230
|
+
}
|
|
231
|
+
onEnter(fn) {
|
|
232
|
+
this._onEnter = fn;
|
|
233
|
+
return this;
|
|
234
|
+
}
|
|
235
|
+
onExit(fn) {
|
|
236
|
+
this._onExit = fn;
|
|
237
|
+
return this;
|
|
238
|
+
}
|
|
239
|
+
ignoreGlobals(ignore = true) {
|
|
240
|
+
this._ignoreGlobals = ignore;
|
|
241
|
+
return this;
|
|
242
|
+
}
|
|
243
|
+
ask(text) {
|
|
244
|
+
return this.bot.ask(text);
|
|
245
|
+
}
|
|
246
|
+
action(id, options) {
|
|
247
|
+
return this.bot.action(id, options);
|
|
248
|
+
}
|
|
249
|
+
handoff(id, options) {
|
|
250
|
+
return this.bot.handoff(id, options);
|
|
251
|
+
}
|
|
252
|
+
global(matcher, options) {
|
|
253
|
+
return this.bot.global(matcher, options);
|
|
254
|
+
}
|
|
255
|
+
getId() {
|
|
256
|
+
return this.id;
|
|
257
|
+
}
|
|
258
|
+
getText() {
|
|
259
|
+
return this._text;
|
|
260
|
+
}
|
|
261
|
+
getReason() {
|
|
262
|
+
return this._reason;
|
|
263
|
+
}
|
|
264
|
+
getTimeoutMs() {
|
|
265
|
+
return this._timeoutMs;
|
|
266
|
+
}
|
|
267
|
+
getResumeNodeId() {
|
|
268
|
+
return this._resumeNodeId;
|
|
269
|
+
}
|
|
270
|
+
getFallbackNodeId() {
|
|
271
|
+
return this._fallbackNodeId;
|
|
272
|
+
}
|
|
273
|
+
getQueueMessage() {
|
|
274
|
+
return this._queueMessage;
|
|
275
|
+
}
|
|
276
|
+
getMetadata() {
|
|
277
|
+
return this._metadata;
|
|
278
|
+
}
|
|
279
|
+
getOnEnterFn() {
|
|
280
|
+
return this._onEnter;
|
|
281
|
+
}
|
|
282
|
+
getOnExitFn() {
|
|
283
|
+
return this._onExit;
|
|
284
|
+
}
|
|
285
|
+
getIgnoreGlobals() {
|
|
286
|
+
return this._ignoreGlobals;
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
var GlobalTransitionBuilder = class {
|
|
290
|
+
constructor(bot, matcher, options) {
|
|
291
|
+
this.bot = bot;
|
|
292
|
+
this._patterns = [];
|
|
293
|
+
this._priority = "high";
|
|
294
|
+
if (typeof matcher === "object" && matcher !== null && "match" in matcher && typeof matcher.match === "function") {
|
|
295
|
+
this._matcher = matcher;
|
|
296
|
+
this._patterns = [...this._matcher.patterns];
|
|
297
|
+
} else if (typeof matcher === "string") {
|
|
298
|
+
this._patterns = [matcher];
|
|
299
|
+
} else if (Array.isArray(matcher)) {
|
|
300
|
+
this._patterns = [...matcher];
|
|
301
|
+
} else {
|
|
302
|
+
this._patterns = [];
|
|
303
|
+
}
|
|
304
|
+
if (typeof options === "string") {
|
|
305
|
+
this._targetNodeId = options;
|
|
306
|
+
} else if (options) {
|
|
307
|
+
if (options.id) this._id = options.id;
|
|
308
|
+
if (options.target) this._targetNodeId = options.target;
|
|
309
|
+
if (options.targetNodeId) this._targetNodeId = options.targetNodeId;
|
|
310
|
+
if (options.priority) this._priority = options.priority;
|
|
311
|
+
if (options.response) this._responseFn = options.response;
|
|
312
|
+
if (options.matcherOptions) this._opts = options.matcherOptions;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
id(customId) {
|
|
316
|
+
this._id = customId;
|
|
317
|
+
return this;
|
|
318
|
+
}
|
|
319
|
+
then(targetNodeId) {
|
|
320
|
+
this._targetNodeId = targetNodeId;
|
|
321
|
+
return this;
|
|
322
|
+
}
|
|
323
|
+
response(fn) {
|
|
324
|
+
this._responseFn = fn;
|
|
325
|
+
return this;
|
|
326
|
+
}
|
|
327
|
+
priority(p) {
|
|
328
|
+
this._priority = p;
|
|
329
|
+
return this;
|
|
330
|
+
}
|
|
331
|
+
getId(defaultId) {
|
|
332
|
+
return this._id ?? defaultId;
|
|
333
|
+
}
|
|
334
|
+
getTargetNodeId() {
|
|
335
|
+
return this._targetNodeId;
|
|
336
|
+
}
|
|
337
|
+
getPatterns() {
|
|
338
|
+
return this._patterns;
|
|
339
|
+
}
|
|
340
|
+
getOpts() {
|
|
341
|
+
return this._opts;
|
|
342
|
+
}
|
|
343
|
+
getMatcher() {
|
|
344
|
+
return this._matcher;
|
|
345
|
+
}
|
|
346
|
+
getPriority() {
|
|
347
|
+
return this._priority;
|
|
348
|
+
}
|
|
349
|
+
getResponseFn() {
|
|
350
|
+
return this._responseFn;
|
|
351
|
+
}
|
|
352
|
+
ask(text) {
|
|
353
|
+
return this.bot.ask(text);
|
|
354
|
+
}
|
|
355
|
+
action(id, options) {
|
|
356
|
+
return this.bot.action(id, options);
|
|
357
|
+
}
|
|
358
|
+
handoff(id, options) {
|
|
359
|
+
return this.bot.handoff(id, options);
|
|
360
|
+
}
|
|
361
|
+
global(matcher, options) {
|
|
362
|
+
return this.bot.global(matcher, options);
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
var FlowNodeBuilder = class {
|
|
366
|
+
constructor(bot, defaultId, text) {
|
|
367
|
+
this.bot = bot;
|
|
368
|
+
this.defaultId = defaultId;
|
|
369
|
+
this.text = text;
|
|
370
|
+
this._branches = [];
|
|
371
|
+
this._fallbackText = "Please try again.";
|
|
372
|
+
this._maxRetries = 3;
|
|
373
|
+
}
|
|
374
|
+
getId() {
|
|
375
|
+
return this._customId ?? this.defaultId;
|
|
376
|
+
}
|
|
377
|
+
id(customId) {
|
|
378
|
+
this._customId = customId;
|
|
379
|
+
return this;
|
|
380
|
+
}
|
|
381
|
+
expect(matcher, opts) {
|
|
382
|
+
let patterns;
|
|
383
|
+
let customMatcher;
|
|
384
|
+
if (typeof matcher === "object" && matcher !== null && "match" in matcher && typeof matcher.match === "function") {
|
|
385
|
+
customMatcher = matcher;
|
|
386
|
+
patterns = [...customMatcher.patterns];
|
|
387
|
+
} else if (typeof matcher === "string") {
|
|
388
|
+
patterns = [matcher];
|
|
389
|
+
} else if (Array.isArray(matcher)) {
|
|
390
|
+
patterns = [...matcher];
|
|
391
|
+
} else {
|
|
392
|
+
patterns = [];
|
|
393
|
+
}
|
|
394
|
+
this._branches.push({
|
|
395
|
+
patterns,
|
|
396
|
+
opts,
|
|
397
|
+
matcher: customMatcher
|
|
398
|
+
});
|
|
399
|
+
return this;
|
|
400
|
+
}
|
|
401
|
+
then(targetNodeId) {
|
|
402
|
+
if (this._branches.length === 0) {
|
|
403
|
+
throw new Error(`Cannot call then("${targetNodeId}") before expect() on node "${this.getId()}"`);
|
|
404
|
+
}
|
|
405
|
+
const lastBranch = this._branches[this._branches.length - 1];
|
|
406
|
+
lastBranch.targetNodeId = targetNodeId;
|
|
407
|
+
return this;
|
|
408
|
+
}
|
|
409
|
+
response(fn) {
|
|
410
|
+
this._responseFn = fn;
|
|
411
|
+
return this;
|
|
412
|
+
}
|
|
413
|
+
fallback(textOrFn) {
|
|
414
|
+
this._fallbackText = textOrFn;
|
|
415
|
+
return this;
|
|
416
|
+
}
|
|
417
|
+
retries(n) {
|
|
418
|
+
this._maxRetries = n;
|
|
419
|
+
return this;
|
|
420
|
+
}
|
|
421
|
+
onMaxRetries(fn) {
|
|
422
|
+
this._onMaxRetriesFn = fn;
|
|
423
|
+
return this;
|
|
424
|
+
}
|
|
425
|
+
onEnter(fn) {
|
|
426
|
+
this._onEnter = fn;
|
|
427
|
+
return this;
|
|
428
|
+
}
|
|
429
|
+
onExit(fn) {
|
|
430
|
+
this._onExit = fn;
|
|
431
|
+
return this;
|
|
432
|
+
}
|
|
433
|
+
ignoreGlobals(ignore = true) {
|
|
434
|
+
this._ignoreGlobals = ignore;
|
|
435
|
+
return this;
|
|
436
|
+
}
|
|
437
|
+
ask(text) {
|
|
438
|
+
return this.bot.ask(text);
|
|
439
|
+
}
|
|
440
|
+
action(id, options) {
|
|
441
|
+
return this.bot.action(id, options);
|
|
442
|
+
}
|
|
443
|
+
handoff(id, options) {
|
|
444
|
+
return this.bot.handoff(id, options);
|
|
445
|
+
}
|
|
446
|
+
global(matcher, options) {
|
|
447
|
+
return this.bot.global(matcher, options);
|
|
448
|
+
}
|
|
449
|
+
getBranches() {
|
|
450
|
+
return this._branches;
|
|
451
|
+
}
|
|
452
|
+
getResponseFn() {
|
|
453
|
+
return this._responseFn;
|
|
454
|
+
}
|
|
455
|
+
getFallbackText() {
|
|
456
|
+
return this._fallbackText;
|
|
457
|
+
}
|
|
458
|
+
getMaxRetries() {
|
|
459
|
+
return this._maxRetries;
|
|
460
|
+
}
|
|
461
|
+
getOnMaxRetriesFn() {
|
|
462
|
+
return this._onMaxRetriesFn;
|
|
463
|
+
}
|
|
464
|
+
getOnEnterFn() {
|
|
465
|
+
return this._onEnter;
|
|
466
|
+
}
|
|
467
|
+
getOnExitFn() {
|
|
468
|
+
return this._onExit;
|
|
469
|
+
}
|
|
470
|
+
getIgnoreGlobals() {
|
|
471
|
+
return this._ignoreGlobals;
|
|
472
|
+
}
|
|
473
|
+
};
|
|
474
|
+
var Bot = class {
|
|
475
|
+
constructor(flowId, version = 1) {
|
|
476
|
+
this.flowId = flowId;
|
|
477
|
+
this.version = version;
|
|
478
|
+
this.builders = [];
|
|
479
|
+
this.actionBuilders = [];
|
|
480
|
+
this.handoffBuilders = [];
|
|
481
|
+
this.globalBuilders = [];
|
|
482
|
+
}
|
|
483
|
+
ask(text) {
|
|
484
|
+
const defaultId = `node_${this.builders.length}`;
|
|
485
|
+
const builder = new FlowNodeBuilder(this, defaultId, text);
|
|
486
|
+
this.builders.push(builder);
|
|
487
|
+
return builder;
|
|
488
|
+
}
|
|
489
|
+
action(id, options) {
|
|
490
|
+
const builder = new ActionNodeBuilder(this, id, options);
|
|
491
|
+
this.actionBuilders.push(builder);
|
|
492
|
+
return builder;
|
|
493
|
+
}
|
|
494
|
+
handoff(id, options) {
|
|
495
|
+
const builder = new HandoffNodeBuilder(this, id, options);
|
|
496
|
+
this.handoffBuilders.push(builder);
|
|
497
|
+
return builder;
|
|
498
|
+
}
|
|
499
|
+
global(matcher, options) {
|
|
500
|
+
const builder = new GlobalTransitionBuilder(this, matcher, options);
|
|
501
|
+
this.globalBuilders.push(builder);
|
|
502
|
+
return builder;
|
|
503
|
+
}
|
|
504
|
+
escalate(ctx) {
|
|
505
|
+
ctx.set("escalated", true);
|
|
506
|
+
}
|
|
507
|
+
compile() {
|
|
508
|
+
const idSet = /* @__PURE__ */ new Set();
|
|
509
|
+
const knownNodeIds = [];
|
|
510
|
+
for (const builder of this.builders) {
|
|
511
|
+
const nodeId = builder.getId();
|
|
512
|
+
if (idSet.has(nodeId)) {
|
|
513
|
+
throw new CompileError(`Duplicate node id "${nodeId}".`);
|
|
514
|
+
}
|
|
515
|
+
idSet.add(nodeId);
|
|
516
|
+
knownNodeIds.push(nodeId);
|
|
517
|
+
}
|
|
518
|
+
for (const ab of this.actionBuilders) {
|
|
519
|
+
const nodeId = ab.getId();
|
|
520
|
+
if (idSet.has(nodeId)) {
|
|
521
|
+
throw new CompileError(`Duplicate node id "${nodeId}".`);
|
|
522
|
+
}
|
|
523
|
+
idSet.add(nodeId);
|
|
524
|
+
knownNodeIds.push(nodeId);
|
|
525
|
+
}
|
|
526
|
+
for (const hb of this.handoffBuilders) {
|
|
527
|
+
const nodeId = hb.getId();
|
|
528
|
+
if (idSet.has(nodeId)) {
|
|
529
|
+
throw new CompileError(`Duplicate node id "${nodeId}".`);
|
|
530
|
+
}
|
|
531
|
+
idSet.add(nodeId);
|
|
532
|
+
knownNodeIds.push(nodeId);
|
|
533
|
+
}
|
|
534
|
+
const globalIdSet = /* @__PURE__ */ new Set();
|
|
535
|
+
const globals = this.globalBuilders.map((gBuilder, idx) => {
|
|
536
|
+
const gId = gBuilder.getId(`global_${idx}`);
|
|
537
|
+
if (globalIdSet.has(gId)) {
|
|
538
|
+
throw new CompileError(`Duplicate global transition id "${gId}".`);
|
|
539
|
+
}
|
|
540
|
+
globalIdSet.add(gId);
|
|
541
|
+
const targetId = gBuilder.getTargetNodeId();
|
|
542
|
+
if (!targetId) {
|
|
543
|
+
throw new CompileError(
|
|
544
|
+
`Global transition "${gId}" for pattern(s) [${gBuilder.getPatterns().join(", ")}] is missing an explicit .then() target.`
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
if (!idSet.has(targetId)) {
|
|
548
|
+
throw new CompileError(
|
|
549
|
+
`Unknown node "${targetId}" referenced by global transition "${gId}". Known node IDs: ${knownNodeIds.join(", ")}.`
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
const branchMatcher = gBuilder.getMatcher() ?? matchesAny(gBuilder.getPatterns(), gBuilder.getOpts());
|
|
553
|
+
return {
|
|
554
|
+
id: gId,
|
|
555
|
+
matcher: branchMatcher,
|
|
556
|
+
patterns: gBuilder.getPatterns(),
|
|
557
|
+
targetNodeId: targetId,
|
|
558
|
+
priority: gBuilder.getPriority(),
|
|
559
|
+
responseFn: gBuilder.getResponseFn()
|
|
560
|
+
};
|
|
561
|
+
});
|
|
562
|
+
const nodes = this.builders.map((builder, idx) => {
|
|
563
|
+
const branches = builder.getBranches();
|
|
564
|
+
if (branches.length === 0) {
|
|
565
|
+
throw new CompileError(`Node "${builder.getId()}" is missing an expect() matcher`);
|
|
566
|
+
}
|
|
567
|
+
if (branches.length > 1) {
|
|
568
|
+
for (const branch of branches) {
|
|
569
|
+
if (!branch.targetNodeId) {
|
|
570
|
+
throw new CompileError(
|
|
571
|
+
`branch for pattern(s) [${branch.patterns.join(", ")}] is missing an explicit .then() target \u2014 required once a node has multiple branches`
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
const defaultNextNodeId = idx < this.builders.length - 1 ? knownNodeIds[idx + 1] : void 0;
|
|
577
|
+
const allPatterns = [];
|
|
578
|
+
const transitions = [];
|
|
579
|
+
const nonWildcardMatchers = [];
|
|
580
|
+
const wildcardMatchers = [];
|
|
581
|
+
for (const branch of branches) {
|
|
582
|
+
let targetId = branch.targetNodeId;
|
|
583
|
+
if (!targetId && branches.length === 1 && defaultNextNodeId) {
|
|
584
|
+
targetId = defaultNextNodeId;
|
|
585
|
+
}
|
|
586
|
+
if (targetId) {
|
|
587
|
+
if (!idSet.has(targetId)) {
|
|
588
|
+
throw new CompileError(
|
|
589
|
+
`Unknown node "${targetId}" referenced by transition from node "${builder.getId()}". Known node IDs: ${knownNodeIds.join(", ")}.`
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
allPatterns.push(...branch.patterns);
|
|
594
|
+
transitions.push({
|
|
595
|
+
targetNodeId: targetId ?? "",
|
|
596
|
+
patterns: branch.patterns
|
|
597
|
+
});
|
|
598
|
+
const branchMatcher = branch.matcher ?? matchesAny(branch.patterns, branch.opts);
|
|
599
|
+
if (branch.patterns.includes("*")) {
|
|
600
|
+
wildcardMatchers.push(branchMatcher);
|
|
601
|
+
} else {
|
|
602
|
+
nonWildcardMatchers.push(branchMatcher);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
const compositeMatcher = {
|
|
606
|
+
patterns: allPatterns,
|
|
607
|
+
normalize: (t) => t.trim().toLowerCase(),
|
|
608
|
+
match: (inboundText) => {
|
|
609
|
+
for (const m of nonWildcardMatchers) {
|
|
610
|
+
const res = m.match(inboundText);
|
|
611
|
+
if (res.matched) {
|
|
612
|
+
return res;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
for (const m of wildcardMatchers) {
|
|
616
|
+
const res = m.match(inboundText);
|
|
617
|
+
if (res.matched) {
|
|
618
|
+
return res;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
return { matched: false, pattern: "", confidence: 0 };
|
|
622
|
+
}
|
|
623
|
+
};
|
|
624
|
+
return {
|
|
625
|
+
id: builder.getId(),
|
|
626
|
+
text: builder.text,
|
|
627
|
+
matcher: compositeMatcher,
|
|
628
|
+
transitions,
|
|
629
|
+
fallbackText: builder.getFallbackText(),
|
|
630
|
+
maxRetries: builder.getMaxRetries(),
|
|
631
|
+
onMaxRetriesFn: builder.getOnMaxRetriesFn(),
|
|
632
|
+
responseFn: builder.getResponseFn(),
|
|
633
|
+
onEnter: builder.getOnEnterFn(),
|
|
634
|
+
onExit: builder.getOnExitFn(),
|
|
635
|
+
ignoreGlobals: builder.getIgnoreGlobals()
|
|
636
|
+
};
|
|
637
|
+
});
|
|
638
|
+
const actionNodes = this.actionBuilders.map((ab) => {
|
|
639
|
+
const actionFn = ab.getActionFn();
|
|
640
|
+
if (!actionFn) {
|
|
641
|
+
throw new CompileError(`Action node "${ab.getId()}" is missing an execute() handler.`);
|
|
642
|
+
}
|
|
643
|
+
const onSuccess = ab.getOnSuccess();
|
|
644
|
+
const onFailure = ab.getOnFailure();
|
|
645
|
+
const onTimeout = ab.getOnTimeout();
|
|
646
|
+
if (!onSuccess && !onFailure) {
|
|
647
|
+
throw new CompileError(
|
|
648
|
+
`Action node "${ab.getId()}" must define at least an onSuccess or onFailure transition target.`
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
if (onSuccess && !idSet.has(onSuccess.targetNodeId)) {
|
|
652
|
+
throw new CompileError(
|
|
653
|
+
`Unknown node "${onSuccess.targetNodeId}" referenced by onSuccess transition on action node "${ab.getId()}". Known node IDs: ${knownNodeIds.join(", ")}.`
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
if (onFailure && !idSet.has(onFailure.targetNodeId)) {
|
|
657
|
+
throw new CompileError(
|
|
658
|
+
`Unknown node "${onFailure.targetNodeId}" referenced by onFailure transition on action node "${ab.getId()}". Known node IDs: ${knownNodeIds.join(", ")}.`
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
if (onTimeout && !idSet.has(onTimeout.targetNodeId)) {
|
|
662
|
+
throw new CompileError(
|
|
663
|
+
`Unknown node "${onTimeout.targetNodeId}" referenced by onTimeout transition on action node "${ab.getId()}". Known node IDs: ${knownNodeIds.join(", ")}.`
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
const transitions = [];
|
|
667
|
+
if (onSuccess) transitions.push({ targetNodeId: onSuccess.targetNodeId, patterns: ["success"] });
|
|
668
|
+
if (onFailure) transitions.push({ targetNodeId: onFailure.targetNodeId, patterns: ["failure"] });
|
|
669
|
+
if (onTimeout) transitions.push({ targetNodeId: onTimeout.targetNodeId, patterns: ["timeout"] });
|
|
670
|
+
return {
|
|
671
|
+
id: ab.getId(),
|
|
672
|
+
text: ab.getText(),
|
|
673
|
+
matcher: {
|
|
674
|
+
patterns: ["*"],
|
|
675
|
+
normalize: (t) => t,
|
|
676
|
+
match: () => ({ matched: true, pattern: "*", confidence: 1 })
|
|
677
|
+
},
|
|
678
|
+
transitions,
|
|
679
|
+
fallbackText: "",
|
|
680
|
+
maxRetries: 0,
|
|
681
|
+
onEnter: ab.getOnEnterFn(),
|
|
682
|
+
onExit: ab.getOnExitFn(),
|
|
683
|
+
isAction: true,
|
|
684
|
+
action: {
|
|
685
|
+
execute: actionFn,
|
|
686
|
+
timeoutMs: ab.getTimeoutMs(),
|
|
687
|
+
onSuccess,
|
|
688
|
+
onFailure,
|
|
689
|
+
onTimeout
|
|
690
|
+
}
|
|
691
|
+
};
|
|
692
|
+
});
|
|
693
|
+
const allNodes = [...nodes, ...actionNodes];
|
|
694
|
+
const handoffNodes = this.handoffBuilders.map((hb) => {
|
|
695
|
+
const resumeId = hb.getResumeNodeId();
|
|
696
|
+
const fallbackId = hb.getFallbackNodeId();
|
|
697
|
+
if (resumeId && !idSet.has(resumeId)) {
|
|
698
|
+
throw new CompileError(
|
|
699
|
+
`Unknown node "${resumeId}" referenced by resumeAt on handoff node "${hb.getId()}". Known node IDs: ${knownNodeIds.join(", ")}.`
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
if (fallbackId && !idSet.has(fallbackId)) {
|
|
703
|
+
throw new CompileError(
|
|
704
|
+
`Unknown node "${fallbackId}" referenced by fallbackTo on handoff node "${hb.getId()}". Known node IDs: ${knownNodeIds.join(", ")}.`
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
const transitions = [];
|
|
708
|
+
if (resumeId) transitions.push({ targetNodeId: resumeId, patterns: ["resume"] });
|
|
709
|
+
if (fallbackId) transitions.push({ targetNodeId: fallbackId, patterns: ["timeout"] });
|
|
710
|
+
return {
|
|
711
|
+
id: hb.getId(),
|
|
712
|
+
text: hb.getText(),
|
|
713
|
+
matcher: {
|
|
714
|
+
patterns: ["*"],
|
|
715
|
+
normalize: (t) => t,
|
|
716
|
+
match: () => ({ matched: true, pattern: "*", confidence: 1 })
|
|
717
|
+
},
|
|
718
|
+
transitions,
|
|
719
|
+
fallbackText: "",
|
|
720
|
+
maxRetries: 0,
|
|
721
|
+
onEnter: hb.getOnEnterFn(),
|
|
722
|
+
onExit: hb.getOnExitFn(),
|
|
723
|
+
ignoreGlobals: hb.getIgnoreGlobals(),
|
|
724
|
+
isHandoff: true,
|
|
725
|
+
handoff: {
|
|
726
|
+
reason: hb.getReason(),
|
|
727
|
+
timeoutMs: hb.getTimeoutMs(),
|
|
728
|
+
resumeNodeId: resumeId,
|
|
729
|
+
fallbackNodeId: fallbackId,
|
|
730
|
+
queueMessage: hb.getQueueMessage(),
|
|
731
|
+
metadata: hb.getMetadata()
|
|
732
|
+
}
|
|
733
|
+
};
|
|
734
|
+
});
|
|
735
|
+
const finalNodes = [...allNodes, ...handoffNodes];
|
|
736
|
+
return {
|
|
737
|
+
flowId: this.flowId,
|
|
738
|
+
version: this.version,
|
|
739
|
+
nodes: finalNodes,
|
|
740
|
+
globals: globals.length > 0 ? globals : void 0
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
};
|
|
744
|
+
|
|
745
|
+
// core/context.ts
|
|
746
|
+
var Context = class {
|
|
747
|
+
constructor(initialData = {}) {
|
|
748
|
+
this.data = initialData;
|
|
749
|
+
}
|
|
750
|
+
set(key, value) {
|
|
751
|
+
this.data[key] = value;
|
|
752
|
+
}
|
|
753
|
+
get(key) {
|
|
754
|
+
return this.data[key];
|
|
755
|
+
}
|
|
756
|
+
has(key) {
|
|
757
|
+
return Object.prototype.hasOwnProperty.call(this.data, key);
|
|
758
|
+
}
|
|
759
|
+
delete(key) {
|
|
760
|
+
delete this.data[key];
|
|
761
|
+
}
|
|
762
|
+
clear() {
|
|
763
|
+
this.data = {};
|
|
764
|
+
}
|
|
765
|
+
all() {
|
|
766
|
+
return { ...this.data };
|
|
767
|
+
}
|
|
768
|
+
};
|
|
769
|
+
|
|
770
|
+
// core/fsm.ts
|
|
771
|
+
async function runWithTimeout(fn, timeoutMs = 5e3) {
|
|
772
|
+
return new Promise((resolve, reject) => {
|
|
773
|
+
const timer = setTimeout(() => {
|
|
774
|
+
reject(new Error("Handler execution timed out"));
|
|
775
|
+
}, timeoutMs);
|
|
776
|
+
Promise.resolve(fn()).then((val) => {
|
|
777
|
+
clearTimeout(timer);
|
|
778
|
+
resolve(val);
|
|
779
|
+
}).catch((err) => {
|
|
780
|
+
clearTimeout(timer);
|
|
781
|
+
reject(err);
|
|
782
|
+
});
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
var FSMEngine = class {
|
|
786
|
+
constructor(flow) {
|
|
787
|
+
this.flow = flow;
|
|
788
|
+
}
|
|
789
|
+
createInitialState(waUserId) {
|
|
790
|
+
return {
|
|
791
|
+
waUserId,
|
|
792
|
+
flowId: this.flow.flowId,
|
|
793
|
+
flowVersion: this.flow.version,
|
|
794
|
+
currentNodeId: this.flow.nodes[0]?.id || "",
|
|
795
|
+
context: {},
|
|
796
|
+
retryCount: 0,
|
|
797
|
+
status: "active"
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
async executeInitialEnter(state) {
|
|
801
|
+
if (state.status !== "active") return state;
|
|
802
|
+
const node = this.flow.nodes.find((n) => n.id === state.currentNodeId);
|
|
803
|
+
if (node && node.onEnter) {
|
|
804
|
+
const ctx = new Context({ ...state.context });
|
|
805
|
+
await this.safelyExecuteHook(
|
|
806
|
+
node.onEnter,
|
|
807
|
+
ctx,
|
|
808
|
+
{ fromNodeId: void 0, toNodeId: node.id, isInitial: true },
|
|
809
|
+
`onEnter (initial) on node "${node.id}"`
|
|
810
|
+
);
|
|
811
|
+
return {
|
|
812
|
+
...state,
|
|
813
|
+
context: ctx.all()
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
return state;
|
|
817
|
+
}
|
|
818
|
+
async executeActionNode(state) {
|
|
819
|
+
const node = this.flow.nodes.find((n) => n.id === state.currentNodeId);
|
|
820
|
+
if (!node || !node.isAction) {
|
|
821
|
+
return {
|
|
822
|
+
nextState: state,
|
|
823
|
+
outboundText: null
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
const ctx = new Context({ ...state.context });
|
|
827
|
+
return await this.resolveDestination(state, node, node.id, "", ctx);
|
|
828
|
+
}
|
|
829
|
+
isGlobalIgnored(node, global) {
|
|
830
|
+
if (node.ignoreGlobals === true) {
|
|
831
|
+
return true;
|
|
832
|
+
}
|
|
833
|
+
if (Array.isArray(node.ignoreGlobals)) {
|
|
834
|
+
const ignoredList = node.ignoreGlobals;
|
|
835
|
+
if (ignoredList.includes(global.id)) {
|
|
836
|
+
return true;
|
|
837
|
+
}
|
|
838
|
+
if (global.patterns && global.patterns.some((p) => ignoredList.includes(p))) {
|
|
839
|
+
return true;
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
return false;
|
|
843
|
+
}
|
|
844
|
+
async safelyExecuteHook(hook, ctx, info, hookName) {
|
|
845
|
+
if (!hook) return;
|
|
846
|
+
try {
|
|
847
|
+
await runWithTimeout(() => hook(ctx, info));
|
|
848
|
+
} catch (err) {
|
|
849
|
+
console.error(`[FSMEngine] ${hookName} hook failed or timed out:`, err);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
async resolveDestination(state, sourceNode, targetNodeId, triggerText, ctx, matchedGlobalId, depth = 0) {
|
|
853
|
+
if (depth > 10) {
|
|
854
|
+
console.error(`[FSMEngine] Maximum action chaining depth exceeded (depth > 10). Breaking loop.`);
|
|
855
|
+
return {
|
|
856
|
+
nextState: {
|
|
857
|
+
...state,
|
|
858
|
+
context: ctx.all(),
|
|
859
|
+
status: "escalated"
|
|
860
|
+
},
|
|
861
|
+
outboundText: "An unexpected error occurred. Connecting you with an agent."
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
const targetNode = this.flow.nodes.find((n) => n.id === targetNodeId);
|
|
865
|
+
if (!targetNode) {
|
|
866
|
+
const nextState2 = {
|
|
867
|
+
...state,
|
|
868
|
+
context: ctx.all(),
|
|
869
|
+
status: "completed"
|
|
870
|
+
};
|
|
871
|
+
return {
|
|
872
|
+
nextState: nextState2,
|
|
873
|
+
outboundText: null,
|
|
874
|
+
matchedGlobalId
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
await this.safelyExecuteHook(
|
|
878
|
+
targetNode.onEnter,
|
|
879
|
+
ctx,
|
|
880
|
+
{ fromNodeId: sourceNode.id, toNodeId: targetNode.id, triggerText, isInitial: false },
|
|
881
|
+
`onEnter on node "${targetNode.id}"`
|
|
882
|
+
);
|
|
883
|
+
if (targetNode.isAction && targetNode.action) {
|
|
884
|
+
const actionDef = targetNode.action;
|
|
885
|
+
const timeoutMs = actionDef.timeoutMs ?? 5e3;
|
|
886
|
+
let actionSuccess = false;
|
|
887
|
+
let actionResult = null;
|
|
888
|
+
let actionTimedOut = false;
|
|
889
|
+
let actionError = null;
|
|
890
|
+
try {
|
|
891
|
+
actionResult = await runWithTimeout(() => actionDef.execute(ctx), timeoutMs);
|
|
892
|
+
actionSuccess = true;
|
|
893
|
+
} catch (err) {
|
|
894
|
+
if (err?.message?.includes("timed out")) {
|
|
895
|
+
actionTimedOut = true;
|
|
896
|
+
}
|
|
897
|
+
actionError = err;
|
|
898
|
+
}
|
|
899
|
+
if (actionSuccess) {
|
|
900
|
+
if (actionDef.onSuccess?.handler) {
|
|
901
|
+
try {
|
|
902
|
+
await runWithTimeout(() => actionDef.onSuccess.handler(actionResult, ctx), timeoutMs);
|
|
903
|
+
} catch (err) {
|
|
904
|
+
console.error(`[FSMEngine] onSuccess handler on action node "${targetNode.id}" failed:`, err);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
const nextTargetId = actionDef.onSuccess?.targetNodeId;
|
|
908
|
+
await this.safelyExecuteHook(
|
|
909
|
+
targetNode.onExit,
|
|
910
|
+
ctx,
|
|
911
|
+
{ fromNodeId: targetNode.id, toNodeId: nextTargetId, triggerText },
|
|
912
|
+
`onExit on action node "${targetNode.id}"`
|
|
913
|
+
);
|
|
914
|
+
if (nextTargetId) {
|
|
915
|
+
return await this.resolveDestination(state, targetNode, nextTargetId, triggerText, ctx, matchedGlobalId, depth + 1);
|
|
916
|
+
} else {
|
|
917
|
+
return {
|
|
918
|
+
nextState: { ...state, context: ctx.all(), status: "completed" },
|
|
919
|
+
outboundText: null,
|
|
920
|
+
matchedGlobalId
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
} else if (actionTimedOut) {
|
|
924
|
+
const timeoutBranch = actionDef.onTimeout ?? actionDef.onFailure;
|
|
925
|
+
if (timeoutBranch?.handler) {
|
|
926
|
+
try {
|
|
927
|
+
await runWithTimeout(() => timeoutBranch.handler(actionError, ctx), timeoutMs);
|
|
928
|
+
} catch (err) {
|
|
929
|
+
console.error(`[FSMEngine] onTimeout handler on action node "${targetNode.id}" failed:`, err);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
const nextTargetId = timeoutBranch?.targetNodeId;
|
|
933
|
+
await this.safelyExecuteHook(
|
|
934
|
+
targetNode.onExit,
|
|
935
|
+
ctx,
|
|
936
|
+
{ fromNodeId: targetNode.id, toNodeId: nextTargetId, triggerText },
|
|
937
|
+
`onExit on action node "${targetNode.id}"`
|
|
938
|
+
);
|
|
939
|
+
if (nextTargetId) {
|
|
940
|
+
return await this.resolveDestination(state, targetNode, nextTargetId, triggerText, ctx, matchedGlobalId, depth + 1);
|
|
941
|
+
} else {
|
|
942
|
+
return {
|
|
943
|
+
nextState: { ...state, context: ctx.all(), status: "completed" },
|
|
944
|
+
outboundText: null,
|
|
945
|
+
matchedGlobalId
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
} else {
|
|
949
|
+
const failureBranch = actionDef.onFailure;
|
|
950
|
+
if (failureBranch?.handler) {
|
|
951
|
+
try {
|
|
952
|
+
await runWithTimeout(() => failureBranch.handler(actionError, ctx), timeoutMs);
|
|
953
|
+
} catch (err) {
|
|
954
|
+
console.error(`[FSMEngine] onFailure handler on action node "${targetNode.id}" failed:`, err);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
const nextTargetId = failureBranch?.targetNodeId;
|
|
958
|
+
await this.safelyExecuteHook(
|
|
959
|
+
targetNode.onExit,
|
|
960
|
+
ctx,
|
|
961
|
+
{ fromNodeId: targetNode.id, toNodeId: nextTargetId, triggerText },
|
|
962
|
+
`onExit on action node "${targetNode.id}"`
|
|
963
|
+
);
|
|
964
|
+
if (nextTargetId) {
|
|
965
|
+
return await this.resolveDestination(state, targetNode, nextTargetId, triggerText, ctx, matchedGlobalId, depth + 1);
|
|
966
|
+
} else {
|
|
967
|
+
return {
|
|
968
|
+
nextState: { ...state, context: ctx.all(), status: "completed" },
|
|
969
|
+
outboundText: null,
|
|
970
|
+
matchedGlobalId
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
if (targetNode.isHandoff) {
|
|
976
|
+
const handoffDef = targetNode.handoff;
|
|
977
|
+
const handoffSession = {
|
|
978
|
+
state: "pending",
|
|
979
|
+
reason: handoffDef?.reason,
|
|
980
|
+
requestedAt: Date.now(),
|
|
981
|
+
resumeNodeId: handoffDef?.resumeNodeId,
|
|
982
|
+
fallbackNodeId: handoffDef?.fallbackNodeId,
|
|
983
|
+
timeoutMs: handoffDef?.timeoutMs ?? 10 * 60 * 1e3,
|
|
984
|
+
queueMessage: handoffDef?.queueMessage,
|
|
985
|
+
metadata: handoffDef?.metadata ? { ...handoffDef.metadata } : void 0
|
|
986
|
+
};
|
|
987
|
+
const nextState2 = {
|
|
988
|
+
...state,
|
|
989
|
+
currentNodeId: targetNode.id,
|
|
990
|
+
retryCount: 0,
|
|
991
|
+
context: ctx.all(),
|
|
992
|
+
status: "handoff",
|
|
993
|
+
handoff: handoffSession
|
|
994
|
+
};
|
|
995
|
+
return {
|
|
996
|
+
nextState: nextState2,
|
|
997
|
+
outboundText: targetNode.text,
|
|
998
|
+
matchedGlobalId
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
const nextState = {
|
|
1002
|
+
...state,
|
|
1003
|
+
currentNodeId: targetNode.id,
|
|
1004
|
+
retryCount: 0,
|
|
1005
|
+
context: ctx.all(),
|
|
1006
|
+
status: "active"
|
|
1007
|
+
};
|
|
1008
|
+
return {
|
|
1009
|
+
nextState,
|
|
1010
|
+
outboundText: targetNode.text,
|
|
1011
|
+
matchedGlobalId
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
async executeGlobalTransition(state, node, global, matchedPattern, inboundText, ctx) {
|
|
1015
|
+
if (global.responseFn) {
|
|
1016
|
+
try {
|
|
1017
|
+
await runWithTimeout(
|
|
1018
|
+
() => global.responseFn({ raw: inboundText, pattern: matchedPattern }, ctx)
|
|
1019
|
+
);
|
|
1020
|
+
} catch (err) {
|
|
1021
|
+
console.error(`[FSMEngine] Global response handler for "${global.id}" failed or timed out:`, err);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
await this.safelyExecuteHook(
|
|
1025
|
+
node.onExit,
|
|
1026
|
+
ctx,
|
|
1027
|
+
{ fromNodeId: node.id, toNodeId: global.targetNodeId, triggerText: inboundText },
|
|
1028
|
+
`onExit on node "${node.id}"`
|
|
1029
|
+
);
|
|
1030
|
+
return await this.resolveDestination(state, node, global.targetNodeId, inboundText, ctx, global.id);
|
|
1031
|
+
}
|
|
1032
|
+
async transition(state, inboundText) {
|
|
1033
|
+
if (state.status === "completed" || state.status === "escalated" || state.status === "abandoned") {
|
|
1034
|
+
return {
|
|
1035
|
+
nextState: state,
|
|
1036
|
+
outboundText: null
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
if (state.status === "handoff") {
|
|
1040
|
+
const node2 = this.flow.nodes.find((n) => n.id === state.currentNodeId);
|
|
1041
|
+
if (!node2) {
|
|
1042
|
+
return {
|
|
1043
|
+
nextState: state,
|
|
1044
|
+
outboundText: null
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
1047
|
+
const ctx2 = new Context({ ...state.context });
|
|
1048
|
+
const allGlobals2 = this.flow.globals ?? [];
|
|
1049
|
+
const activeGlobals2 = allGlobals2.filter((g) => !this.isGlobalIgnored(node2, g));
|
|
1050
|
+
for (const g of activeGlobals2.filter((g2) => g2.priority === "high")) {
|
|
1051
|
+
let gMatch;
|
|
1052
|
+
try {
|
|
1053
|
+
gMatch = g.matcher.match(inboundText);
|
|
1054
|
+
} catch {
|
|
1055
|
+
gMatch = { matched: false, pattern: "", confidence: 0 };
|
|
1056
|
+
}
|
|
1057
|
+
if (gMatch.matched) {
|
|
1058
|
+
const resolvedHandoff = state.handoff ? {
|
|
1059
|
+
...state.handoff,
|
|
1060
|
+
state: "resolved",
|
|
1061
|
+
resolvedAt: Date.now()
|
|
1062
|
+
} : void 0;
|
|
1063
|
+
const stateWithResolvedHandoff = {
|
|
1064
|
+
...state,
|
|
1065
|
+
status: "active",
|
|
1066
|
+
handoff: resolvedHandoff
|
|
1067
|
+
};
|
|
1068
|
+
return await this.executeGlobalTransition(
|
|
1069
|
+
stateWithResolvedHandoff,
|
|
1070
|
+
node2,
|
|
1071
|
+
g,
|
|
1072
|
+
gMatch.pattern,
|
|
1073
|
+
inboundText,
|
|
1074
|
+
ctx2
|
|
1075
|
+
);
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
for (const g of activeGlobals2.filter((g2) => g2.priority === "low")) {
|
|
1079
|
+
let gMatch;
|
|
1080
|
+
try {
|
|
1081
|
+
gMatch = g.matcher.match(inboundText);
|
|
1082
|
+
} catch {
|
|
1083
|
+
gMatch = { matched: false, pattern: "", confidence: 0 };
|
|
1084
|
+
}
|
|
1085
|
+
if (gMatch.matched) {
|
|
1086
|
+
const resolvedHandoff = state.handoff ? {
|
|
1087
|
+
...state.handoff,
|
|
1088
|
+
state: "resolved",
|
|
1089
|
+
resolvedAt: Date.now()
|
|
1090
|
+
} : void 0;
|
|
1091
|
+
const stateWithResolvedHandoff = {
|
|
1092
|
+
...state,
|
|
1093
|
+
status: "active",
|
|
1094
|
+
handoff: resolvedHandoff
|
|
1095
|
+
};
|
|
1096
|
+
return await this.executeGlobalTransition(
|
|
1097
|
+
stateWithResolvedHandoff,
|
|
1098
|
+
node2,
|
|
1099
|
+
g,
|
|
1100
|
+
gMatch.pattern,
|
|
1101
|
+
inboundText,
|
|
1102
|
+
ctx2
|
|
1103
|
+
);
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
if (state.handoff?.state === "pending") {
|
|
1107
|
+
const queueMsg = state.handoff.queueMessage !== void 0 ? state.handoff.queueMessage : "You are in the queue. An agent will be with you shortly.";
|
|
1108
|
+
return {
|
|
1109
|
+
nextState: state,
|
|
1110
|
+
outboundText: queueMsg
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
1113
|
+
return {
|
|
1114
|
+
nextState: state,
|
|
1115
|
+
outboundText: null
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
const node = this.flow.nodes.find((n) => n.id === state.currentNodeId);
|
|
1119
|
+
if (!node) {
|
|
1120
|
+
return {
|
|
1121
|
+
nextState: state,
|
|
1122
|
+
outboundText: null
|
|
1123
|
+
};
|
|
1124
|
+
}
|
|
1125
|
+
const ctx = new Context({ ...state.context });
|
|
1126
|
+
const allGlobals = this.flow.globals ?? [];
|
|
1127
|
+
const activeGlobals = allGlobals.filter((g) => !this.isGlobalIgnored(node, g));
|
|
1128
|
+
const highPriorityGlobals = activeGlobals.filter((g) => g.priority === "high");
|
|
1129
|
+
const lowPriorityGlobals = activeGlobals.filter((g) => g.priority === "low");
|
|
1130
|
+
for (const g of highPriorityGlobals) {
|
|
1131
|
+
let gMatch;
|
|
1132
|
+
try {
|
|
1133
|
+
gMatch = g.matcher.match(inboundText);
|
|
1134
|
+
} catch (err) {
|
|
1135
|
+
console.error(`[FSMEngine] Global matcher "${g.id}" threw:`, err);
|
|
1136
|
+
gMatch = { matched: false, pattern: "", confidence: 0 };
|
|
1137
|
+
}
|
|
1138
|
+
if (gMatch.matched) {
|
|
1139
|
+
return await this.executeGlobalTransition(state, node, g, gMatch.pattern, inboundText, ctx);
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
let matchResult;
|
|
1143
|
+
try {
|
|
1144
|
+
matchResult = node.matcher.match(inboundText);
|
|
1145
|
+
} catch (err) {
|
|
1146
|
+
console.error("Matcher threw an error:", err);
|
|
1147
|
+
matchResult = { matched: false, pattern: "", confidence: 0 };
|
|
1148
|
+
}
|
|
1149
|
+
if (matchResult.matched) {
|
|
1150
|
+
let handlerSucceeded = true;
|
|
1151
|
+
if (node.responseFn) {
|
|
1152
|
+
try {
|
|
1153
|
+
await runWithTimeout(() => node.responseFn({ raw: inboundText, pattern: matchResult.pattern }, ctx));
|
|
1154
|
+
} catch (err) {
|
|
1155
|
+
console.error("Response handler failed or timed out:", err);
|
|
1156
|
+
handlerSucceeded = false;
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
if (handlerSucceeded) {
|
|
1160
|
+
let selectedTransition = node.transitions[0];
|
|
1161
|
+
if (node.transitions.length > 1) {
|
|
1162
|
+
if (matchResult.pattern !== "*") {
|
|
1163
|
+
const exactBranch = node.transitions.find(
|
|
1164
|
+
(t) => t.patterns && t.patterns.includes(matchResult.pattern)
|
|
1165
|
+
);
|
|
1166
|
+
if (exactBranch) {
|
|
1167
|
+
selectedTransition = exactBranch;
|
|
1168
|
+
} else {
|
|
1169
|
+
const wildcardBranch = node.transitions.find(
|
|
1170
|
+
(t) => t.patterns && t.patterns.includes("*")
|
|
1171
|
+
);
|
|
1172
|
+
if (wildcardBranch) {
|
|
1173
|
+
selectedTransition = wildcardBranch;
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
} else {
|
|
1177
|
+
const wildcardBranch = node.transitions.find(
|
|
1178
|
+
(t) => t.patterns && t.patterns.includes("*")
|
|
1179
|
+
);
|
|
1180
|
+
if (wildcardBranch) {
|
|
1181
|
+
selectedTransition = wildcardBranch;
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
const nextNodeId = selectedTransition?.targetNodeId;
|
|
1186
|
+
await this.safelyExecuteHook(
|
|
1187
|
+
node.onExit,
|
|
1188
|
+
ctx,
|
|
1189
|
+
{ fromNodeId: node.id, toNodeId: nextNodeId || void 0, triggerText: inboundText },
|
|
1190
|
+
`onExit on node "${node.id}"`
|
|
1191
|
+
);
|
|
1192
|
+
if (nextNodeId) {
|
|
1193
|
+
return await this.resolveDestination(state, node, nextNodeId, inboundText, ctx);
|
|
1194
|
+
} else {
|
|
1195
|
+
const nextState = {
|
|
1196
|
+
...state,
|
|
1197
|
+
context: ctx.all(),
|
|
1198
|
+
status: "completed"
|
|
1199
|
+
};
|
|
1200
|
+
return {
|
|
1201
|
+
nextState,
|
|
1202
|
+
outboundText: null
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
for (const g of lowPriorityGlobals) {
|
|
1208
|
+
let gMatch;
|
|
1209
|
+
try {
|
|
1210
|
+
gMatch = g.matcher.match(inboundText);
|
|
1211
|
+
} catch (err) {
|
|
1212
|
+
console.error(`[FSMEngine] Global matcher "${g.id}" threw:`, err);
|
|
1213
|
+
gMatch = { matched: false, pattern: "", confidence: 0 };
|
|
1214
|
+
}
|
|
1215
|
+
if (gMatch.matched) {
|
|
1216
|
+
return await this.executeGlobalTransition(state, node, g, gMatch.pattern, inboundText, ctx);
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
return this.handleFallback(state, node, ctx);
|
|
1220
|
+
}
|
|
1221
|
+
async handleFallback(state, node, ctx) {
|
|
1222
|
+
const nextRetryCount = state.retryCount + 1;
|
|
1223
|
+
if (nextRetryCount <= node.maxRetries) {
|
|
1224
|
+
let fallbackMessage = "Please try again.";
|
|
1225
|
+
if (typeof node.fallbackText === "function") {
|
|
1226
|
+
try {
|
|
1227
|
+
const res = await runWithTimeout(() => node.fallbackText(ctx));
|
|
1228
|
+
if (typeof res === "string") {
|
|
1229
|
+
fallbackMessage = res;
|
|
1230
|
+
}
|
|
1231
|
+
} catch (err) {
|
|
1232
|
+
console.error("Fallback generator function failed or timed out:", err);
|
|
1233
|
+
}
|
|
1234
|
+
} else if (typeof node.fallbackText === "string") {
|
|
1235
|
+
fallbackMessage = node.fallbackText;
|
|
1236
|
+
}
|
|
1237
|
+
const nextState = {
|
|
1238
|
+
...state,
|
|
1239
|
+
retryCount: nextRetryCount,
|
|
1240
|
+
context: ctx.all()
|
|
1241
|
+
};
|
|
1242
|
+
return {
|
|
1243
|
+
nextState,
|
|
1244
|
+
outboundText: fallbackMessage
|
|
1245
|
+
};
|
|
1246
|
+
} else {
|
|
1247
|
+
let escalationMessage = "Let me connect you with a human \u2014 someone will be with you shortly.";
|
|
1248
|
+
if (node.onMaxRetriesFn) {
|
|
1249
|
+
try {
|
|
1250
|
+
const res = await runWithTimeout(() => node.onMaxRetriesFn(ctx));
|
|
1251
|
+
if (typeof res === "string") {
|
|
1252
|
+
escalationMessage = res;
|
|
1253
|
+
}
|
|
1254
|
+
} catch (err) {
|
|
1255
|
+
console.error("onMaxRetries handler failed or timed out:", err);
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
const nextState = {
|
|
1259
|
+
...state,
|
|
1260
|
+
retryCount: nextRetryCount,
|
|
1261
|
+
context: ctx.all(),
|
|
1262
|
+
status: "escalated"
|
|
1263
|
+
};
|
|
1264
|
+
return {
|
|
1265
|
+
nextState,
|
|
1266
|
+
outboundText: escalationMessage
|
|
1267
|
+
};
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
connectAgent(state, agentId, metadata) {
|
|
1271
|
+
if (state.status !== "handoff" || !state.handoff) {
|
|
1272
|
+
throw new Error(`Cannot connect agent: state status is "${state.status}", not "handoff".`);
|
|
1273
|
+
}
|
|
1274
|
+
const updatedHandoff = {
|
|
1275
|
+
...state.handoff,
|
|
1276
|
+
state: "connected",
|
|
1277
|
+
assignedAgentId: agentId,
|
|
1278
|
+
connectedAt: Date.now(),
|
|
1279
|
+
metadata: metadata ? { ...state.handoff.metadata, ...metadata } : state.handoff.metadata
|
|
1280
|
+
};
|
|
1281
|
+
return {
|
|
1282
|
+
...state,
|
|
1283
|
+
handoff: updatedHandoff
|
|
1284
|
+
};
|
|
1285
|
+
}
|
|
1286
|
+
async resolveHandoff(state, options) {
|
|
1287
|
+
if (state.status !== "handoff" || !state.handoff) {
|
|
1288
|
+
throw new Error(`Cannot resolve handoff: state status is "${state.status}", not "handoff".`);
|
|
1289
|
+
}
|
|
1290
|
+
const currentNode = this.flow.nodes.find((n) => n.id === state.currentNodeId);
|
|
1291
|
+
const ctx = new Context({ ...state.context, ...options?.contextUpdates || {} });
|
|
1292
|
+
if (options?.resolutionNotes) {
|
|
1293
|
+
ctx.set("handoff_resolution_notes", options.resolutionNotes);
|
|
1294
|
+
}
|
|
1295
|
+
const targetResumeId = options?.resumeNodeId ?? state.handoff.resumeNodeId;
|
|
1296
|
+
if (currentNode) {
|
|
1297
|
+
await this.safelyExecuteHook(
|
|
1298
|
+
currentNode.onExit,
|
|
1299
|
+
ctx,
|
|
1300
|
+
{
|
|
1301
|
+
fromNodeId: currentNode.id,
|
|
1302
|
+
toNodeId: targetResumeId,
|
|
1303
|
+
triggerText: "agent_resolve"
|
|
1304
|
+
},
|
|
1305
|
+
`onExit on handoff node "${currentNode.id}"`
|
|
1306
|
+
);
|
|
1307
|
+
}
|
|
1308
|
+
const resolvedSession = {
|
|
1309
|
+
...state.handoff,
|
|
1310
|
+
state: "resolved",
|
|
1311
|
+
resolvedAt: Date.now()
|
|
1312
|
+
};
|
|
1313
|
+
const resolvedState = {
|
|
1314
|
+
...state,
|
|
1315
|
+
status: "active",
|
|
1316
|
+
context: ctx.all(),
|
|
1317
|
+
handoff: resolvedSession
|
|
1318
|
+
};
|
|
1319
|
+
if (targetResumeId) {
|
|
1320
|
+
return await this.resolveDestination(
|
|
1321
|
+
resolvedState,
|
|
1322
|
+
currentNode ?? { id: state.currentNodeId },
|
|
1323
|
+
targetResumeId,
|
|
1324
|
+
"agent_resolve",
|
|
1325
|
+
ctx
|
|
1326
|
+
);
|
|
1327
|
+
} else {
|
|
1328
|
+
return {
|
|
1329
|
+
nextState: {
|
|
1330
|
+
...resolvedState,
|
|
1331
|
+
status: "completed"
|
|
1332
|
+
},
|
|
1333
|
+
outboundText: null
|
|
1334
|
+
};
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
async checkHandoffTimeout(state, currentTime = Date.now()) {
|
|
1338
|
+
if (state.status !== "handoff" || !state.handoff || state.handoff.state !== "pending") {
|
|
1339
|
+
return null;
|
|
1340
|
+
}
|
|
1341
|
+
const timeoutLimit = state.handoff.timeoutMs ?? 10 * 60 * 1e3;
|
|
1342
|
+
if (currentTime - state.handoff.requestedAt < timeoutLimit) {
|
|
1343
|
+
return null;
|
|
1344
|
+
}
|
|
1345
|
+
const currentNode = this.flow.nodes.find((n) => n.id === state.currentNodeId);
|
|
1346
|
+
const ctx = new Context({ ...state.context });
|
|
1347
|
+
const timedOutSession = {
|
|
1348
|
+
...state.handoff,
|
|
1349
|
+
state: "timed_out"
|
|
1350
|
+
};
|
|
1351
|
+
const fallbackTargetId = state.handoff.fallbackNodeId;
|
|
1352
|
+
if (currentNode) {
|
|
1353
|
+
await this.safelyExecuteHook(
|
|
1354
|
+
currentNode.onExit,
|
|
1355
|
+
ctx,
|
|
1356
|
+
{
|
|
1357
|
+
fromNodeId: currentNode.id,
|
|
1358
|
+
toNodeId: fallbackTargetId,
|
|
1359
|
+
triggerText: "handoff_timeout"
|
|
1360
|
+
},
|
|
1361
|
+
`onExit on handoff node "${currentNode.id}"`
|
|
1362
|
+
);
|
|
1363
|
+
}
|
|
1364
|
+
const timedOutState = {
|
|
1365
|
+
...state,
|
|
1366
|
+
status: "active",
|
|
1367
|
+
context: ctx.all(),
|
|
1368
|
+
handoff: timedOutSession
|
|
1369
|
+
};
|
|
1370
|
+
if (fallbackTargetId) {
|
|
1371
|
+
return await this.resolveDestination(
|
|
1372
|
+
timedOutState,
|
|
1373
|
+
currentNode ?? { id: state.currentNodeId },
|
|
1374
|
+
fallbackTargetId,
|
|
1375
|
+
"handoff_timeout",
|
|
1376
|
+
ctx
|
|
1377
|
+
);
|
|
1378
|
+
} else {
|
|
1379
|
+
return {
|
|
1380
|
+
nextState: {
|
|
1381
|
+
...timedOutState,
|
|
1382
|
+
status: "escalated"
|
|
1383
|
+
},
|
|
1384
|
+
outboundText: "Live agent request timed out. Please try again later or reach out via email."
|
|
1385
|
+
};
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
getAvailableCommands(state) {
|
|
1389
|
+
const currentNode = this.flow.nodes.find((n) => n.id === state.currentNodeId);
|
|
1390
|
+
const commands = [];
|
|
1391
|
+
if (state.status === "completed" || state.status === "escalated" || state.status === "abandoned") {
|
|
1392
|
+
return {
|
|
1393
|
+
currentNodeId: state.currentNodeId,
|
|
1394
|
+
status: state.status,
|
|
1395
|
+
handoffState: state.handoff?.state,
|
|
1396
|
+
commands: []
|
|
1397
|
+
};
|
|
1398
|
+
}
|
|
1399
|
+
if (currentNode && this.flow.globals) {
|
|
1400
|
+
for (const g of this.flow.globals) {
|
|
1401
|
+
if (!this.isGlobalIgnored(currentNode, g)) {
|
|
1402
|
+
for (const p of g.patterns || [g.id]) {
|
|
1403
|
+
commands.push({
|
|
1404
|
+
kind: "global",
|
|
1405
|
+
command: p,
|
|
1406
|
+
targetNodeId: g.targetNodeId,
|
|
1407
|
+
description: `Global interrupt to "${g.targetNodeId}"`
|
|
1408
|
+
});
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
if (state.status === "handoff") {
|
|
1414
|
+
if (state.handoff?.state === "pending") {
|
|
1415
|
+
commands.push({
|
|
1416
|
+
kind: "handoff",
|
|
1417
|
+
command: "agent:connect",
|
|
1418
|
+
description: "Connect available human agent"
|
|
1419
|
+
});
|
|
1420
|
+
commands.push({
|
|
1421
|
+
kind: "handoff",
|
|
1422
|
+
command: "agent:timeout",
|
|
1423
|
+
targetNodeId: state.handoff?.fallbackNodeId,
|
|
1424
|
+
description: "Trigger handoff timeout fallback"
|
|
1425
|
+
});
|
|
1426
|
+
} else if (state.handoff?.state === "connected") {
|
|
1427
|
+
commands.push({
|
|
1428
|
+
kind: "handoff",
|
|
1429
|
+
command: "agent:resolve",
|
|
1430
|
+
targetNodeId: state.handoff?.resumeNodeId,
|
|
1431
|
+
description: "Resolve live session and resume bot flow"
|
|
1432
|
+
});
|
|
1433
|
+
}
|
|
1434
|
+
return {
|
|
1435
|
+
currentNodeId: state.currentNodeId,
|
|
1436
|
+
status: state.status,
|
|
1437
|
+
handoffState: state.handoff?.state,
|
|
1438
|
+
commands
|
|
1439
|
+
};
|
|
1440
|
+
}
|
|
1441
|
+
if (currentNode) {
|
|
1442
|
+
if (currentNode.isAction) {
|
|
1443
|
+
commands.push({
|
|
1444
|
+
kind: "action",
|
|
1445
|
+
command: "action:execute",
|
|
1446
|
+
targetNodeId: currentNode.action?.onSuccess?.targetNodeId,
|
|
1447
|
+
description: "Execute async action handler"
|
|
1448
|
+
});
|
|
1449
|
+
} else {
|
|
1450
|
+
for (const t of currentNode.transitions) {
|
|
1451
|
+
for (const p of t.patterns || ["*"]) {
|
|
1452
|
+
commands.push({
|
|
1453
|
+
kind: "local",
|
|
1454
|
+
command: p,
|
|
1455
|
+
targetNodeId: t.targetNodeId,
|
|
1456
|
+
description: `Transition to "${t.targetNodeId}"`
|
|
1457
|
+
});
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
return {
|
|
1463
|
+
currentNodeId: state.currentNodeId,
|
|
1464
|
+
status: state.status,
|
|
1465
|
+
handoffState: state.handoff?.state,
|
|
1466
|
+
commands
|
|
1467
|
+
};
|
|
1468
|
+
}
|
|
1469
|
+
getLegalTransitions(state) {
|
|
1470
|
+
return this.getAvailableCommands(state);
|
|
1471
|
+
}
|
|
1472
|
+
};
|
|
1473
|
+
async function runConversationStep(waUserId, messageText, options) {
|
|
1474
|
+
const fsm = new FSMEngine(options.flow);
|
|
1475
|
+
let state = await options.persistence.load(waUserId);
|
|
1476
|
+
if (!state) {
|
|
1477
|
+
state = fsm.createInitialState(waUserId);
|
|
1478
|
+
state = await fsm.executeInitialEnter(state);
|
|
1479
|
+
}
|
|
1480
|
+
const result = await fsm.transition(state, messageText);
|
|
1481
|
+
await options.persistence.save(result.nextState);
|
|
1482
|
+
return {
|
|
1483
|
+
nextState: result.nextState,
|
|
1484
|
+
result
|
|
1485
|
+
};
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
// persistence/persistence.ts
|
|
1489
|
+
import mongoose, { Schema } from "mongoose";
|
|
1490
|
+
var OptimisticConcurrencyError = class extends Error {
|
|
1491
|
+
constructor(waUserId, expectedVersion) {
|
|
1492
|
+
super(`Optimistic concurrency violation for user ${waUserId}. Expected version: ${expectedVersion}`);
|
|
1493
|
+
this.waUserId = waUserId;
|
|
1494
|
+
this.expectedVersion = expectedVersion;
|
|
1495
|
+
this.name = "OptimisticConcurrencyError";
|
|
1496
|
+
}
|
|
1497
|
+
};
|
|
1498
|
+
var compiledRegistry = /* @__PURE__ */ new Map();
|
|
1499
|
+
function registerFlowDefinition(flow) {
|
|
1500
|
+
const key = `${flow.flowId}:${flow.version}`;
|
|
1501
|
+
compiledRegistry.set(key, flow);
|
|
1502
|
+
}
|
|
1503
|
+
var ConversationStateSchema = new Schema({
|
|
1504
|
+
waUserId: { type: String, required: true, unique: true, index: true },
|
|
1505
|
+
flowId: { type: String, required: true },
|
|
1506
|
+
flowVersion: { type: Number, required: true },
|
|
1507
|
+
currentNodeId: { type: String, required: true },
|
|
1508
|
+
context: { type: Schema.Types.Mixed, default: {} },
|
|
1509
|
+
retryCount: { type: Number, required: true, default: 0 },
|
|
1510
|
+
status: { type: String, required: true, enum: ["active", "escalated", "completed", "abandoned", "handoff"], default: "active" },
|
|
1511
|
+
handoff: { type: Schema.Types.Mixed },
|
|
1512
|
+
version: { type: Number, required: true, default: 0 }
|
|
1513
|
+
});
|
|
1514
|
+
var FlowNodeSchema = new Schema({
|
|
1515
|
+
id: { type: String, required: true },
|
|
1516
|
+
text: { type: String, required: true },
|
|
1517
|
+
patterns: [{ type: String }],
|
|
1518
|
+
fallbackText: { type: Schema.Types.Mixed },
|
|
1519
|
+
maxRetries: { type: Number, required: true }
|
|
1520
|
+
});
|
|
1521
|
+
var FlowDefinitionSchema = new Schema({
|
|
1522
|
+
flowId: { type: String, required: true },
|
|
1523
|
+
version: { type: Number, required: true },
|
|
1524
|
+
nodes: [FlowNodeSchema]
|
|
1525
|
+
});
|
|
1526
|
+
FlowDefinitionSchema.index({ flowId: 1, version: 1 }, { unique: true });
|
|
1527
|
+
var MongoosePersistenceAdapter = class {
|
|
1528
|
+
constructor(connection = mongoose.connection) {
|
|
1529
|
+
this.ConversationStateModel = connection.models.ConversationState || connection.model("ConversationState", ConversationStateSchema);
|
|
1530
|
+
this.FlowDefinitionModel = connection.models.FlowDefinition || connection.model("FlowDefinition", FlowDefinitionSchema);
|
|
1531
|
+
}
|
|
1532
|
+
async load(waUserId) {
|
|
1533
|
+
const doc = await this.ConversationStateModel.findOne({ waUserId });
|
|
1534
|
+
if (!doc) return null;
|
|
1535
|
+
return {
|
|
1536
|
+
waUserId: doc.waUserId,
|
|
1537
|
+
flowId: doc.flowId,
|
|
1538
|
+
flowVersion: doc.flowVersion,
|
|
1539
|
+
currentNodeId: doc.currentNodeId,
|
|
1540
|
+
context: doc.context instanceof Map ? Object.fromEntries(doc.context) : doc.context,
|
|
1541
|
+
retryCount: doc.retryCount,
|
|
1542
|
+
status: doc.status,
|
|
1543
|
+
version: doc.version
|
|
1544
|
+
};
|
|
1545
|
+
}
|
|
1546
|
+
async save(state) {
|
|
1547
|
+
const currentVersion = state.version;
|
|
1548
|
+
if (currentVersion === void 0) {
|
|
1549
|
+
try {
|
|
1550
|
+
const doc = new this.ConversationStateModel({
|
|
1551
|
+
waUserId: state.waUserId,
|
|
1552
|
+
flowId: state.flowId,
|
|
1553
|
+
flowVersion: state.flowVersion,
|
|
1554
|
+
currentNodeId: state.currentNodeId,
|
|
1555
|
+
context: state.context,
|
|
1556
|
+
retryCount: state.retryCount,
|
|
1557
|
+
status: state.status,
|
|
1558
|
+
version: 1
|
|
1559
|
+
});
|
|
1560
|
+
await doc.save();
|
|
1561
|
+
} catch (err) {
|
|
1562
|
+
if (err.code === 11e3) {
|
|
1563
|
+
throw new OptimisticConcurrencyError(state.waUserId, 0);
|
|
1564
|
+
}
|
|
1565
|
+
throw err;
|
|
1566
|
+
}
|
|
1567
|
+
} else {
|
|
1568
|
+
const result = await this.ConversationStateModel.updateOne(
|
|
1569
|
+
{ waUserId: state.waUserId, version: currentVersion },
|
|
1570
|
+
{
|
|
1571
|
+
$set: {
|
|
1572
|
+
flowId: state.flowId,
|
|
1573
|
+
flowVersion: state.flowVersion,
|
|
1574
|
+
currentNodeId: state.currentNodeId,
|
|
1575
|
+
context: state.context,
|
|
1576
|
+
retryCount: state.retryCount,
|
|
1577
|
+
status: state.status,
|
|
1578
|
+
version: currentVersion + 1
|
|
1579
|
+
}
|
|
1580
|
+
},
|
|
1581
|
+
{ runValidators: true }
|
|
1582
|
+
);
|
|
1583
|
+
if (result.matchedCount === 0) {
|
|
1584
|
+
throw new OptimisticConcurrencyError(state.waUserId, currentVersion);
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
async loadFlowDefinition(flowId, version) {
|
|
1589
|
+
const registryKey = `${flowId}:${version}`;
|
|
1590
|
+
const registered = compiledRegistry.get(registryKey);
|
|
1591
|
+
if (registered) {
|
|
1592
|
+
return registered;
|
|
1593
|
+
}
|
|
1594
|
+
throw new Error(
|
|
1595
|
+
`Flow "${flowId}" (v${version}) is not loaded/registered in the in-memory compiledRegistry. Active flows must be registered on application startup by calling registerFlowDefinition() or compiling a flow with bot.compile() to preserve execution callbacks and handlers.`
|
|
1596
|
+
);
|
|
1597
|
+
}
|
|
1598
|
+
async saveFlowDefinition(flow) {
|
|
1599
|
+
registerFlowDefinition(flow);
|
|
1600
|
+
const serializedNodes = flow.nodes.map((n) => ({
|
|
1601
|
+
id: n.id,
|
|
1602
|
+
text: n.text,
|
|
1603
|
+
patterns: n.matcher.patterns,
|
|
1604
|
+
fallbackText: typeof n.fallbackText === "string" ? n.fallbackText : "[Dynamic Handler]",
|
|
1605
|
+
maxRetries: n.maxRetries
|
|
1606
|
+
}));
|
|
1607
|
+
await this.FlowDefinitionModel.updateOne(
|
|
1608
|
+
{ flowId: flow.flowId, version: flow.version },
|
|
1609
|
+
{
|
|
1610
|
+
$set: {
|
|
1611
|
+
flowId: flow.flowId,
|
|
1612
|
+
version: flow.version,
|
|
1613
|
+
nodes: serializedNodes
|
|
1614
|
+
}
|
|
1615
|
+
},
|
|
1616
|
+
{
|
|
1617
|
+
upsert: true,
|
|
1618
|
+
runValidators: true
|
|
1619
|
+
}
|
|
1620
|
+
);
|
|
1621
|
+
}
|
|
1622
|
+
};
|
|
1623
|
+
var CallbackPersistenceAdapter = class {
|
|
1624
|
+
constructor(options) {
|
|
1625
|
+
this.options = options;
|
|
1626
|
+
}
|
|
1627
|
+
async load(waUserId) {
|
|
1628
|
+
return this.options.load(waUserId);
|
|
1629
|
+
}
|
|
1630
|
+
async save(state) {
|
|
1631
|
+
return this.options.save(state);
|
|
1632
|
+
}
|
|
1633
|
+
async loadFlowDefinition(flowId, version) {
|
|
1634
|
+
return this.options.loadFlowDefinition(flowId, version);
|
|
1635
|
+
}
|
|
1636
|
+
async saveFlowDefinition(flow) {
|
|
1637
|
+
return this.options.saveFlowDefinition(flow);
|
|
1638
|
+
}
|
|
1639
|
+
};
|
|
1640
|
+
|
|
1641
|
+
// core/idempotency.ts
|
|
1642
|
+
var IdempotencyService = class {
|
|
1643
|
+
constructor(redis) {
|
|
1644
|
+
this.redis = redis;
|
|
1645
|
+
}
|
|
1646
|
+
/**
|
|
1647
|
+
* Attempts to acquire an idempotency lock on a WhatsApp message ID (wamid).
|
|
1648
|
+
* @param wamid Unique WhatsApp message ID from the webhook payload.
|
|
1649
|
+
* @param ttlSeconds Expiration time in seconds (default: 21600 - 6 hours).
|
|
1650
|
+
* @returns Promise<boolean> True if the lock was acquired (first time), false if it is a duplicate.
|
|
1651
|
+
*/
|
|
1652
|
+
async checkAndLock(wamid, ttlSeconds = 21600) {
|
|
1653
|
+
if (!wamid) {
|
|
1654
|
+
throw new Error("wamid is required for idempotency check");
|
|
1655
|
+
}
|
|
1656
|
+
const key = `dedup:${wamid}`;
|
|
1657
|
+
const result = await this.redis.set(key, "1", "EX", ttlSeconds, "NX");
|
|
1658
|
+
return result === "OK";
|
|
1659
|
+
}
|
|
1660
|
+
/**
|
|
1661
|
+
* Releases/deletes the idempotency lock for a message ID.
|
|
1662
|
+
* Useful to roll back locks when downstream operations (e.g. queue enqueue) fail.
|
|
1663
|
+
*/
|
|
1664
|
+
async release(wamid) {
|
|
1665
|
+
if (wamid) {
|
|
1666
|
+
const key = `dedup:${wamid}`;
|
|
1667
|
+
await this.redis.del(key);
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
};
|
|
1671
|
+
|
|
1672
|
+
// adapters/flaresend.ts
|
|
1673
|
+
import { Flaresend } from "flaresend";
|
|
1674
|
+
var FlaresendSendError = class extends Error {
|
|
1675
|
+
constructor(message, isAmbiguous, statusCode, code) {
|
|
1676
|
+
super(message);
|
|
1677
|
+
this.name = "FlaresendSendError";
|
|
1678
|
+
this.isAmbiguous = isAmbiguous;
|
|
1679
|
+
this.statusCode = statusCode;
|
|
1680
|
+
this.code = code;
|
|
1681
|
+
}
|
|
1682
|
+
};
|
|
1683
|
+
var KnownNotSentError = class extends FlaresendSendError {
|
|
1684
|
+
constructor(message, statusCode, code) {
|
|
1685
|
+
super(message, false, statusCode, code);
|
|
1686
|
+
this.name = "KnownNotSentError";
|
|
1687
|
+
}
|
|
1688
|
+
};
|
|
1689
|
+
var AmbiguousSendError = class extends FlaresendSendError {
|
|
1690
|
+
constructor(message, statusCode, code) {
|
|
1691
|
+
super(message, true, statusCode, code);
|
|
1692
|
+
this.name = "AmbiguousSendError";
|
|
1693
|
+
}
|
|
1694
|
+
};
|
|
1695
|
+
function classifyFlaresendError(err) {
|
|
1696
|
+
if (err instanceof FlaresendSendError) {
|
|
1697
|
+
return err;
|
|
1698
|
+
}
|
|
1699
|
+
const message = err?.message || String(err);
|
|
1700
|
+
const status = typeof err?.status === "number" ? err.status : typeof err?.statusCode === "number" ? err.statusCode : typeof err?.response?.status === "number" ? err.response.status : void 0;
|
|
1701
|
+
const code = err?.code || err?.errno;
|
|
1702
|
+
if (status !== void 0 && status >= 400 && status < 500 || code === "ENOTFOUND" || code === "ECONNREFUSED" || code === "EADDRNOTAVAIL") {
|
|
1703
|
+
return new KnownNotSentError(
|
|
1704
|
+
`Flaresend rejected request (Status: ${status ?? "N/A"}, Code: ${code ?? "N/A"}): ${message}`,
|
|
1705
|
+
status,
|
|
1706
|
+
code
|
|
1707
|
+
);
|
|
1708
|
+
}
|
|
1709
|
+
return new AmbiguousSendError(
|
|
1710
|
+
`Unconfirmed delivery status (Status: ${status ?? "N/A"}, Code: ${code ?? "N/A"}): ${message}`,
|
|
1711
|
+
status,
|
|
1712
|
+
code
|
|
1713
|
+
);
|
|
1714
|
+
}
|
|
1715
|
+
var FlaresendClient = class {
|
|
1716
|
+
constructor(options = {}) {
|
|
1717
|
+
this.sdk = null;
|
|
1718
|
+
this.isMock = !!options.mock;
|
|
1719
|
+
const apiKey = options.apiKey || process.env.FLARESEND_API_KEY || "";
|
|
1720
|
+
if (!this.isMock) {
|
|
1721
|
+
if (!apiKey) {
|
|
1722
|
+
throw new Error(
|
|
1723
|
+
"FlaresendClient: FLARESEND_API_KEY is missing and mock mode is disabled. Provide a valid API key or explicitly set options.mock = true."
|
|
1724
|
+
);
|
|
1725
|
+
}
|
|
1726
|
+
this.sdk = new Flaresend(apiKey, {
|
|
1727
|
+
baseUrl: options.baseUrl
|
|
1728
|
+
});
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
/**
|
|
1732
|
+
* Sends an outbound WhatsApp message to the recipient using the official Flaresend SDK.
|
|
1733
|
+
* Categorizes any transmission errors into KnownNotSentError or AmbiguousSendError.
|
|
1734
|
+
*/
|
|
1735
|
+
async sendMessage(to, text) {
|
|
1736
|
+
if (this.isMock || !this.sdk) {
|
|
1737
|
+
console.warn(`[FlaresendClient MOCK] Sending message to ${to}: ${text}`);
|
|
1738
|
+
return { success: true, messageId: `mock-fs-${Date.now()}` };
|
|
1739
|
+
}
|
|
1740
|
+
try {
|
|
1741
|
+
const response = await this.sdk.messages.send({
|
|
1742
|
+
recipients: [to],
|
|
1743
|
+
type: "text",
|
|
1744
|
+
text
|
|
1745
|
+
});
|
|
1746
|
+
return {
|
|
1747
|
+
success: response.success,
|
|
1748
|
+
messageId: response.successful_recipients?.[0]
|
|
1749
|
+
};
|
|
1750
|
+
} catch (err) {
|
|
1751
|
+
const classifiedError = classifyFlaresendError(err);
|
|
1752
|
+
if (classifiedError.isAmbiguous) {
|
|
1753
|
+
console.warn(`[AMBIGUOUS_SEND_FAILURE] Flaresend send to ${to} resulted in ambiguous error:`, classifiedError.message);
|
|
1754
|
+
} else {
|
|
1755
|
+
console.error(`[KNOWN_NOT_SENT_FAILURE] Flaresend send to ${to} failed before transmission:`, classifiedError.message);
|
|
1756
|
+
}
|
|
1757
|
+
throw classifiedError;
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
};
|
|
1761
|
+
|
|
1762
|
+
// adapters/webhook.ts
|
|
1763
|
+
import { createHmac, timingSafeEqual } from "crypto";
|
|
1764
|
+
function extractWebhookPayload(body) {
|
|
1765
|
+
const messages = [];
|
|
1766
|
+
const statuses = [];
|
|
1767
|
+
if (!body) {
|
|
1768
|
+
return { messages, statuses };
|
|
1769
|
+
}
|
|
1770
|
+
if (Array.isArray(body.messages)) {
|
|
1771
|
+
for (const msg of body.messages) {
|
|
1772
|
+
if (msg && msg.id && msg.from) {
|
|
1773
|
+
messages.push({
|
|
1774
|
+
id: msg.id,
|
|
1775
|
+
from: msg.from,
|
|
1776
|
+
text: msg.text?.body || msg.text || "",
|
|
1777
|
+
timestamp: Number(msg.timestamp) || Date.now()
|
|
1778
|
+
});
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
if (Array.isArray(body.statuses)) {
|
|
1783
|
+
for (const st of body.statuses) {
|
|
1784
|
+
if (st && st.id) {
|
|
1785
|
+
statuses.push({
|
|
1786
|
+
id: st.id,
|
|
1787
|
+
status: st.status || "",
|
|
1788
|
+
recipient_id: st.recipient_id || st.recipient || "",
|
|
1789
|
+
timestamp: Number(st.timestamp) || Date.now()
|
|
1790
|
+
});
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
if (Array.isArray(body.entry)) {
|
|
1795
|
+
for (const entry of body.entry) {
|
|
1796
|
+
if (Array.isArray(entry.changes)) {
|
|
1797
|
+
for (const change of entry.changes) {
|
|
1798
|
+
const val = change.value;
|
|
1799
|
+
if (val) {
|
|
1800
|
+
if (Array.isArray(val.messages)) {
|
|
1801
|
+
for (const msg of val.messages) {
|
|
1802
|
+
if (msg && msg.id && msg.from) {
|
|
1803
|
+
messages.push({
|
|
1804
|
+
id: msg.id,
|
|
1805
|
+
from: msg.from,
|
|
1806
|
+
text: msg.text?.body || msg.text || "",
|
|
1807
|
+
timestamp: Number(msg.timestamp) || Date.now()
|
|
1808
|
+
});
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
if (Array.isArray(val.statuses)) {
|
|
1813
|
+
for (const st of val.statuses) {
|
|
1814
|
+
if (st && st.id) {
|
|
1815
|
+
statuses.push({
|
|
1816
|
+
id: st.id,
|
|
1817
|
+
status: st.status || "",
|
|
1818
|
+
recipient_id: st.recipient_id || st.recipient || "",
|
|
1819
|
+
timestamp: Number(st.timestamp) || Date.now()
|
|
1820
|
+
});
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
return { messages, statuses };
|
|
1830
|
+
}
|
|
1831
|
+
function validateWebhookCoreOptions(options) {
|
|
1832
|
+
if (!options.webhookSecret && !options.allowUnsigned) {
|
|
1833
|
+
throw new Error(
|
|
1834
|
+
"Webhook security violation: webhookSecret is not configured, and allowUnsigned is not set to true. You must explicitly provide webhookSecret or set allowUnsigned: true to allow unverified payloads."
|
|
1835
|
+
);
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
function verifyWebhookSignature(rawBody, signatureHeader, secret) {
|
|
1839
|
+
if (!signatureHeader || !secret) return false;
|
|
1840
|
+
const signature = signatureHeader.startsWith("sha256=") ? signatureHeader.substring(7) : signatureHeader;
|
|
1841
|
+
const hmac = createHmac("sha256", secret);
|
|
1842
|
+
hmac.update(rawBody);
|
|
1843
|
+
const expectedSignature = hmac.digest("hex");
|
|
1844
|
+
try {
|
|
1845
|
+
return timingSafeEqual(
|
|
1846
|
+
Buffer.from(signature, "hex"),
|
|
1847
|
+
Buffer.from(expectedSignature, "hex")
|
|
1848
|
+
);
|
|
1849
|
+
} catch {
|
|
1850
|
+
return false;
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
async function handleWebhookCore(req, options) {
|
|
1854
|
+
validateWebhookCoreOptions(options);
|
|
1855
|
+
const { idempotencyService, queue, webhookSecret } = options;
|
|
1856
|
+
if (webhookSecret && !options.allowUnsigned) {
|
|
1857
|
+
const sigHeader = req.headers["x-hub-signature-256"] || req.headers["x-flaresend-signature"];
|
|
1858
|
+
const rawBody = req.rawBody;
|
|
1859
|
+
if (!rawBody) {
|
|
1860
|
+
throw new Error(
|
|
1861
|
+
"Webhook signature verification failed on configuration: webhookSecret is active, but raw request body capture (e.g. req.rawBody) is missing. Please configure a raw-body capture middleware/plugin to preserve exact wire bytes."
|
|
1862
|
+
);
|
|
1863
|
+
}
|
|
1864
|
+
if (!verifyWebhookSignature(rawBody, sigHeader, webhookSecret)) {
|
|
1865
|
+
return {
|
|
1866
|
+
status: 401,
|
|
1867
|
+
body: { error: "Unauthorized: Invalid webhook signature" }
|
|
1868
|
+
};
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
const { messages, statuses } = extractWebhookPayload(req.body);
|
|
1872
|
+
if (messages.length === 0 && statuses.length === 0) {
|
|
1873
|
+
return {
|
|
1874
|
+
status: 400,
|
|
1875
|
+
body: { error: "Bad Request: Invalid payload structure or missing entries" }
|
|
1876
|
+
};
|
|
1877
|
+
}
|
|
1878
|
+
let enqueuedCount = 0;
|
|
1879
|
+
let duplicateCount = 0;
|
|
1880
|
+
for (const msg of messages) {
|
|
1881
|
+
const isNew = await idempotencyService.checkAndLock(msg.id);
|
|
1882
|
+
if (isNew) {
|
|
1883
|
+
try {
|
|
1884
|
+
await queue.add("whatsapp-message", {
|
|
1885
|
+
wamid: msg.id,
|
|
1886
|
+
waUserId: msg.from,
|
|
1887
|
+
messageText: msg.text,
|
|
1888
|
+
timestamp: msg.timestamp
|
|
1889
|
+
});
|
|
1890
|
+
enqueuedCount++;
|
|
1891
|
+
} catch (err) {
|
|
1892
|
+
await idempotencyService.release(msg.id);
|
|
1893
|
+
throw err;
|
|
1894
|
+
}
|
|
1895
|
+
} else {
|
|
1896
|
+
duplicateCount++;
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
return {
|
|
1900
|
+
status: 200,
|
|
1901
|
+
body: {
|
|
1902
|
+
status: "success",
|
|
1903
|
+
processed: {
|
|
1904
|
+
enqueuedMessages: enqueuedCount,
|
|
1905
|
+
duplicateMessages: duplicateCount,
|
|
1906
|
+
statusUpdates: statuses.length
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
};
|
|
1910
|
+
}
|
|
1911
|
+
function createFastifyAdapter(options) {
|
|
1912
|
+
validateWebhookCoreOptions(options);
|
|
1913
|
+
return async (request, reply) => {
|
|
1914
|
+
let rawBody = request.rawBody;
|
|
1915
|
+
if (Buffer.isBuffer(rawBody)) {
|
|
1916
|
+
rawBody = rawBody.toString("utf8");
|
|
1917
|
+
} else if (typeof rawBody !== "string") {
|
|
1918
|
+
rawBody = void 0;
|
|
1919
|
+
}
|
|
1920
|
+
if (options.webhookSecret && !options.allowUnsigned && !rawBody) {
|
|
1921
|
+
throw new Error(
|
|
1922
|
+
"Webhook signature verification failed on configuration: webhookSecret is active, but raw request body capture (e.g. request.rawBody) is missing. Please configure a raw-body capture plugin (such as fastify-raw-body) to preserve exact wire bytes."
|
|
1923
|
+
);
|
|
1924
|
+
}
|
|
1925
|
+
const normalizedReq = {
|
|
1926
|
+
body: request.body,
|
|
1927
|
+
rawBody,
|
|
1928
|
+
headers: request.headers
|
|
1929
|
+
};
|
|
1930
|
+
const res = await handleWebhookCore(normalizedReq, options);
|
|
1931
|
+
return reply.code(res.status).send(res.body);
|
|
1932
|
+
};
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
// adapters/fastify.ts
|
|
1936
|
+
function fastifyAdapter(options) {
|
|
1937
|
+
return createFastifyAdapter(options);
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
// adapters/express.ts
|
|
1941
|
+
function createExpressAdapter(options) {
|
|
1942
|
+
validateWebhookCoreOptions(options);
|
|
1943
|
+
return async (req, res) => {
|
|
1944
|
+
try {
|
|
1945
|
+
let rawBody = req.rawBody;
|
|
1946
|
+
if (Buffer.isBuffer(rawBody)) {
|
|
1947
|
+
rawBody = rawBody.toString("utf8");
|
|
1948
|
+
} else if (typeof rawBody !== "string") {
|
|
1949
|
+
rawBody = void 0;
|
|
1950
|
+
}
|
|
1951
|
+
const normalizedReq = {
|
|
1952
|
+
body: req.body,
|
|
1953
|
+
rawBody,
|
|
1954
|
+
headers: req.headers
|
|
1955
|
+
};
|
|
1956
|
+
const result = await handleWebhookCore(normalizedReq, options);
|
|
1957
|
+
res.status(result.status).json(result.body);
|
|
1958
|
+
} catch (err) {
|
|
1959
|
+
res.status(500).json({ error: err.message });
|
|
1960
|
+
}
|
|
1961
|
+
};
|
|
1962
|
+
}
|
|
1963
|
+
function expressAdapter(options) {
|
|
1964
|
+
return createExpressAdapter(options);
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
// adapters/http.ts
|
|
1968
|
+
function readRawBody(req) {
|
|
1969
|
+
return new Promise((resolve, reject) => {
|
|
1970
|
+
let data = "";
|
|
1971
|
+
req.on("data", (chunk) => {
|
|
1972
|
+
data += chunk;
|
|
1973
|
+
});
|
|
1974
|
+
req.on("end", () => {
|
|
1975
|
+
resolve(data);
|
|
1976
|
+
});
|
|
1977
|
+
req.on("error", (err) => {
|
|
1978
|
+
reject(err);
|
|
1979
|
+
});
|
|
1980
|
+
});
|
|
1981
|
+
}
|
|
1982
|
+
function createRawHttpAdapter(options) {
|
|
1983
|
+
validateWebhookCoreOptions(options);
|
|
1984
|
+
return async (req, res) => {
|
|
1985
|
+
try {
|
|
1986
|
+
const rawBody = await readRawBody(req);
|
|
1987
|
+
let body = null;
|
|
1988
|
+
if (rawBody) {
|
|
1989
|
+
try {
|
|
1990
|
+
body = JSON.parse(rawBody);
|
|
1991
|
+
} catch {
|
|
1992
|
+
body = rawBody;
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
const normalizedReq = {
|
|
1996
|
+
body,
|
|
1997
|
+
rawBody,
|
|
1998
|
+
headers: req.headers
|
|
1999
|
+
};
|
|
2000
|
+
const result = await handleWebhookCore(normalizedReq, options);
|
|
2001
|
+
res.writeHead(result.status, { "Content-Type": "application/json" });
|
|
2002
|
+
res.end(JSON.stringify(result.body));
|
|
2003
|
+
} catch (err) {
|
|
2004
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
2005
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
2006
|
+
}
|
|
2007
|
+
};
|
|
2008
|
+
}
|
|
2009
|
+
function rawHttpAdapter(options) {
|
|
2010
|
+
return createRawHttpAdapter(options);
|
|
2011
|
+
}
|
|
2012
|
+
var httpAdapter = rawHttpAdapter;
|
|
2013
|
+
|
|
2014
|
+
// queue/worker.ts
|
|
2015
|
+
import { Worker, Queue } from "bullmq";
|
|
2016
|
+
|
|
2017
|
+
// queue/lease.ts
|
|
2018
|
+
import { randomUUID } from "crypto";
|
|
2019
|
+
var ConcurrencyLockError = class extends Error {
|
|
2020
|
+
constructor(waUserId, message) {
|
|
2021
|
+
super(message || `Failed to acquire concurrency lease for user "${waUserId}" within timeout.`);
|
|
2022
|
+
this.waUserId = waUserId;
|
|
2023
|
+
this.name = "ConcurrencyLockError";
|
|
2024
|
+
}
|
|
2025
|
+
};
|
|
2026
|
+
var InMemoryConcurrencyLeaseService = class {
|
|
2027
|
+
constructor() {
|
|
2028
|
+
this.locks = /* @__PURE__ */ new Map();
|
|
2029
|
+
}
|
|
2030
|
+
async acquire(waUserId, options) {
|
|
2031
|
+
const ttlMs = options?.ttlMs ?? 1e4;
|
|
2032
|
+
const acquireTimeoutMs = options?.acquireTimeoutMs ?? 5e3;
|
|
2033
|
+
const retryIntervalMs = options?.retryIntervalMs ?? 50;
|
|
2034
|
+
const prefix = options?.keyPrefix ?? "fsm:lock:";
|
|
2035
|
+
const leaseKey = `${prefix}${waUserId}`;
|
|
2036
|
+
const token = randomUUID();
|
|
2037
|
+
const startTime = Date.now();
|
|
2038
|
+
while (true) {
|
|
2039
|
+
const now = Date.now();
|
|
2040
|
+
const current = this.locks.get(leaseKey);
|
|
2041
|
+
if (!current || current.expiresAt <= now) {
|
|
2042
|
+
this.locks.set(leaseKey, {
|
|
2043
|
+
token,
|
|
2044
|
+
expiresAt: now + ttlMs
|
|
2045
|
+
});
|
|
2046
|
+
const acquiredAt = now;
|
|
2047
|
+
return {
|
|
2048
|
+
leaseKey,
|
|
2049
|
+
token,
|
|
2050
|
+
waUserId,
|
|
2051
|
+
acquiredAt,
|
|
2052
|
+
ttlMs,
|
|
2053
|
+
release: async () => {
|
|
2054
|
+
const entry = this.locks.get(leaseKey);
|
|
2055
|
+
if (entry && entry.token === token) {
|
|
2056
|
+
this.locks.delete(leaseKey);
|
|
2057
|
+
return true;
|
|
2058
|
+
}
|
|
2059
|
+
return false;
|
|
2060
|
+
}
|
|
2061
|
+
};
|
|
2062
|
+
}
|
|
2063
|
+
if (Date.now() - startTime >= acquireTimeoutMs) {
|
|
2064
|
+
throw new ConcurrencyLockError(
|
|
2065
|
+
waUserId,
|
|
2066
|
+
`Failed to acquire in-memory concurrency lease for user "${waUserId}" within ${acquireTimeoutMs}ms.`
|
|
2067
|
+
);
|
|
2068
|
+
}
|
|
2069
|
+
await new Promise((resolve) => setTimeout(resolve, retryIntervalMs));
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2072
|
+
async isLocked(waUserId, keyPrefix = "fsm:lock:") {
|
|
2073
|
+
const leaseKey = `${keyPrefix}${waUserId}`;
|
|
2074
|
+
const current = this.locks.get(leaseKey);
|
|
2075
|
+
if (!current) return false;
|
|
2076
|
+
return current.expiresAt > Date.now();
|
|
2077
|
+
}
|
|
2078
|
+
clear() {
|
|
2079
|
+
this.locks.clear();
|
|
2080
|
+
}
|
|
2081
|
+
};
|
|
2082
|
+
var RedisConcurrencyLeaseService = class _RedisConcurrencyLeaseService {
|
|
2083
|
+
constructor(redisClient) {
|
|
2084
|
+
this.redisClient = redisClient;
|
|
2085
|
+
}
|
|
2086
|
+
static {
|
|
2087
|
+
this.UNLOCK_SCRIPT = `
|
|
2088
|
+
if redis.call("get", KEYS[1]) == ARGV[1] then
|
|
2089
|
+
return redis.call("del", KEYS[1])
|
|
2090
|
+
else
|
|
2091
|
+
return 0
|
|
2092
|
+
end
|
|
2093
|
+
`;
|
|
2094
|
+
}
|
|
2095
|
+
async acquire(waUserId, options) {
|
|
2096
|
+
const ttlMs = options?.ttlMs ?? 1e4;
|
|
2097
|
+
const acquireTimeoutMs = options?.acquireTimeoutMs ?? 5e3;
|
|
2098
|
+
const retryIntervalMs = options?.retryIntervalMs ?? 50;
|
|
2099
|
+
const prefix = options?.keyPrefix ?? "fsm:lock:";
|
|
2100
|
+
const leaseKey = `${prefix}${waUserId}`;
|
|
2101
|
+
const token = randomUUID();
|
|
2102
|
+
const startTime = Date.now();
|
|
2103
|
+
while (true) {
|
|
2104
|
+
try {
|
|
2105
|
+
const result = await this.redisClient.set(leaseKey, token, "PX", ttlMs, "NX");
|
|
2106
|
+
if (result === "OK" || result === 1) {
|
|
2107
|
+
const acquiredAt = Date.now();
|
|
2108
|
+
return {
|
|
2109
|
+
leaseKey,
|
|
2110
|
+
token,
|
|
2111
|
+
waUserId,
|
|
2112
|
+
acquiredAt,
|
|
2113
|
+
ttlMs,
|
|
2114
|
+
release: async () => {
|
|
2115
|
+
try {
|
|
2116
|
+
const res = await this.redisClient.eval(
|
|
2117
|
+
_RedisConcurrencyLeaseService.UNLOCK_SCRIPT,
|
|
2118
|
+
1,
|
|
2119
|
+
leaseKey,
|
|
2120
|
+
token
|
|
2121
|
+
);
|
|
2122
|
+
return res === 1;
|
|
2123
|
+
} catch (err) {
|
|
2124
|
+
console.error(`[RedisConcurrencyLease] Failed to release lease for user ${waUserId}:`, err);
|
|
2125
|
+
return false;
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
};
|
|
2129
|
+
}
|
|
2130
|
+
} catch (err) {
|
|
2131
|
+
console.error(`[RedisConcurrencyLease] Redis error while acquiring lock for ${waUserId}:`, err);
|
|
2132
|
+
}
|
|
2133
|
+
if (Date.now() - startTime >= acquireTimeoutMs) {
|
|
2134
|
+
throw new ConcurrencyLockError(
|
|
2135
|
+
waUserId,
|
|
2136
|
+
`Failed to acquire Redis concurrency lease for user "${waUserId}" within ${acquireTimeoutMs}ms.`
|
|
2137
|
+
);
|
|
2138
|
+
}
|
|
2139
|
+
const jitter = Math.floor(Math.random() * 20);
|
|
2140
|
+
await new Promise((resolve) => setTimeout(resolve, retryIntervalMs + jitter));
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
async isLocked(waUserId, keyPrefix = "fsm:lock:") {
|
|
2144
|
+
const leaseKey = `${keyPrefix}${waUserId}`;
|
|
2145
|
+
const val = await this.redisClient.get(leaseKey);
|
|
2146
|
+
return val !== null;
|
|
2147
|
+
}
|
|
2148
|
+
};
|
|
2149
|
+
function createConcurrencyLeaseService(connection) {
|
|
2150
|
+
if (connection && typeof connection.set === "function") {
|
|
2151
|
+
return new RedisConcurrencyLeaseService(connection);
|
|
2152
|
+
}
|
|
2153
|
+
return new InMemoryConcurrencyLeaseService();
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
// queue/worker.ts
|
|
2157
|
+
async function processFSMJob(jobData, options) {
|
|
2158
|
+
const { waUserId, messageText } = jobData;
|
|
2159
|
+
if (!options.flow.nodes || options.flow.nodes.length === 0) {
|
|
2160
|
+
throw new Error(`[FSM Worker] Flow definition "${options.flow.flowId}" is invalid: no nodes defined.`);
|
|
2161
|
+
}
|
|
2162
|
+
let lease = null;
|
|
2163
|
+
if (options.leaseService) {
|
|
2164
|
+
lease = await options.leaseService.acquire(waUserId, options.leaseOptions);
|
|
2165
|
+
}
|
|
2166
|
+
try {
|
|
2167
|
+
const { nextState, result } = await runConversationStep(waUserId, messageText, {
|
|
2168
|
+
persistence: options.persistence,
|
|
2169
|
+
flow: options.flow
|
|
2170
|
+
});
|
|
2171
|
+
let finalNextState = nextState;
|
|
2172
|
+
let sentReply = false;
|
|
2173
|
+
if (result.outboundText) {
|
|
2174
|
+
try {
|
|
2175
|
+
const sendRes = await options.flaresendClient.sendMessage(waUserId, result.outboundText);
|
|
2176
|
+
sentReply = true;
|
|
2177
|
+
if (sendRes?.messageId) {
|
|
2178
|
+
try {
|
|
2179
|
+
const currentState = await options.persistence.load(waUserId);
|
|
2180
|
+
if (currentState) {
|
|
2181
|
+
currentState.context.lastOutboundMessageId = sendRes.messageId;
|
|
2182
|
+
await options.persistence.save(currentState);
|
|
2183
|
+
finalNextState = currentState;
|
|
2184
|
+
}
|
|
2185
|
+
} catch (telemetryErr) {
|
|
2186
|
+
console.warn(`Failed to persist lastOutboundMessageId for ${waUserId}`, telemetryErr);
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
} catch (err) {
|
|
2190
|
+
const isAmbiguous = err?.isAmbiguous ?? true;
|
|
2191
|
+
if (isAmbiguous) {
|
|
2192
|
+
console.warn(
|
|
2193
|
+
`[AMBIGUOUS_SEND_FAILURE] Outbound send failed with unconfirmed delivery status for user ${waUserId}. State saved successfully. Delegating to resend queue with conservative initial backoff.`,
|
|
2194
|
+
err
|
|
2195
|
+
);
|
|
2196
|
+
} else {
|
|
2197
|
+
console.error(
|
|
2198
|
+
`[KNOWN_NOT_SENT_FAILURE] Outbound send failed before transmission for user ${waUserId}. State saved successfully. Delegating to resend queue with standard initial backoff.`,
|
|
2199
|
+
err
|
|
2200
|
+
);
|
|
2201
|
+
}
|
|
2202
|
+
if (options.resendQueue) {
|
|
2203
|
+
const initialDelay = isAmbiguous ? 5e3 : 1e3;
|
|
2204
|
+
await options.resendQueue.add(
|
|
2205
|
+
"resend-message",
|
|
2206
|
+
{ waUserId, outboundText: result.outboundText, isAmbiguous },
|
|
2207
|
+
{
|
|
2208
|
+
attempts: 3,
|
|
2209
|
+
backoff: {
|
|
2210
|
+
type: "exponential",
|
|
2211
|
+
delay: initialDelay
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
2214
|
+
);
|
|
2215
|
+
} else {
|
|
2216
|
+
throw err;
|
|
2217
|
+
}
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
return {
|
|
2221
|
+
nextState: finalNextState,
|
|
2222
|
+
sentReply,
|
|
2223
|
+
result
|
|
2224
|
+
};
|
|
2225
|
+
} finally {
|
|
2226
|
+
if (lease) {
|
|
2227
|
+
try {
|
|
2228
|
+
await lease.release();
|
|
2229
|
+
} catch (releaseErr) {
|
|
2230
|
+
console.warn(`[FSM Worker] Failed to release concurrency lease for user ${waUserId}:`, releaseErr);
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
}
|
|
2235
|
+
function createFSMWorker(options) {
|
|
2236
|
+
const queueName = options.queueName || "webhook-messages";
|
|
2237
|
+
const resendQueueName = options.resendQueueName || `${queueName}-resends`;
|
|
2238
|
+
const resendQueue = new Queue(resendQueueName, {
|
|
2239
|
+
connection: options.connection
|
|
2240
|
+
});
|
|
2241
|
+
const resendWorker = new Worker(
|
|
2242
|
+
resendQueueName,
|
|
2243
|
+
async (job) => {
|
|
2244
|
+
const { waUserId, outboundText, isAmbiguous } = job.data;
|
|
2245
|
+
if (isAmbiguous) {
|
|
2246
|
+
console.warn(`[Resend Worker AMBIGUOUS_RETRY] Attempting delivery retry for unconfirmed send (user: ${waUserId}, job: ${job.id})`);
|
|
2247
|
+
} else {
|
|
2248
|
+
console.log(`[Resend Worker STANDARD_RETRY] Attempting delivery retry for known-not-sent message (user: ${waUserId}, job: ${job.id})`);
|
|
2249
|
+
}
|
|
2250
|
+
const res = await options.flaresendClient.sendMessage(waUserId, outboundText);
|
|
2251
|
+
if (res?.messageId) {
|
|
2252
|
+
try {
|
|
2253
|
+
const currentState = await options.persistence.load(waUserId);
|
|
2254
|
+
if (currentState) {
|
|
2255
|
+
currentState.context.lastOutboundMessageId = res.messageId;
|
|
2256
|
+
await options.persistence.save(currentState);
|
|
2257
|
+
}
|
|
2258
|
+
} catch (stateErr) {
|
|
2259
|
+
console.warn(`[Resend Worker] Failed to update lastOutboundMessageId in state for user ${waUserId}`, stateErr);
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
},
|
|
2263
|
+
{
|
|
2264
|
+
connection: options.connection,
|
|
2265
|
+
concurrency: options.concurrency || 1
|
|
2266
|
+
}
|
|
2267
|
+
);
|
|
2268
|
+
resendWorker.on("completed", (job) => {
|
|
2269
|
+
console.log(`[Resend Worker] Resend job ${job.id} completed successfully.`);
|
|
2270
|
+
});
|
|
2271
|
+
resendWorker.on("failed", (job, err) => {
|
|
2272
|
+
console.error(`[Resend Worker] Resend job ${job?.id} failed after maximum retry attempts. Error:`, err);
|
|
2273
|
+
});
|
|
2274
|
+
const leaseService = options.leaseService ?? (options.enableConcurrencyLease !== false && options.connection ? createConcurrencyLeaseService(options.connection) : void 0);
|
|
2275
|
+
const worker = new Worker(
|
|
2276
|
+
queueName,
|
|
2277
|
+
async (job) => {
|
|
2278
|
+
console.log(`[FSM Worker] Received queued job ${job.id} for user: ${job.data.waUserId}`);
|
|
2279
|
+
const { wamid, waUserId, messageText, timestamp } = job.data;
|
|
2280
|
+
if (!waUserId || typeof messageText !== "string") {
|
|
2281
|
+
throw new Error(`[FSM Worker] Invalid job payload: ${JSON.stringify(job.data)}`);
|
|
2282
|
+
}
|
|
2283
|
+
return await processFSMJob(
|
|
2284
|
+
{ wamid, waUserId, messageText, timestamp },
|
|
2285
|
+
{
|
|
2286
|
+
persistence: options.persistence,
|
|
2287
|
+
flow: options.flow,
|
|
2288
|
+
flaresendClient: options.flaresendClient,
|
|
2289
|
+
resendQueue,
|
|
2290
|
+
leaseService,
|
|
2291
|
+
leaseOptions: options.leaseOptions
|
|
2292
|
+
}
|
|
2293
|
+
);
|
|
2294
|
+
},
|
|
2295
|
+
{
|
|
2296
|
+
connection: options.connection,
|
|
2297
|
+
concurrency: options.concurrency || 1
|
|
2298
|
+
}
|
|
2299
|
+
);
|
|
2300
|
+
worker.on("completed", (job) => {
|
|
2301
|
+
console.log(`[FSM Worker] Job ${job.id} completed successfully.`);
|
|
2302
|
+
});
|
|
2303
|
+
worker.on("failed", (job, err) => {
|
|
2304
|
+
console.error(`[FSM Worker] Job ${job?.id} failed with error:`, err);
|
|
2305
|
+
});
|
|
2306
|
+
const originalClose = worker.close.bind(worker);
|
|
2307
|
+
worker.close = async () => {
|
|
2308
|
+
console.log(`[FSM Worker] Initiating graceful shutdown of FSM worker and companion resend system...`);
|
|
2309
|
+
await Promise.all([
|
|
2310
|
+
originalClose(),
|
|
2311
|
+
resendWorker.close(),
|
|
2312
|
+
resendQueue.close()
|
|
2313
|
+
]);
|
|
2314
|
+
};
|
|
2315
|
+
return worker;
|
|
2316
|
+
}
|
|
2317
|
+
|
|
2318
|
+
// analytics/trace.ts
|
|
2319
|
+
var TraceCollector = class {
|
|
2320
|
+
constructor(initialTraces) {
|
|
2321
|
+
this.traces = [];
|
|
2322
|
+
this.listeners = [];
|
|
2323
|
+
if (initialTraces) {
|
|
2324
|
+
this.traces = [...initialTraces];
|
|
2325
|
+
}
|
|
2326
|
+
}
|
|
2327
|
+
logTurn(turn) {
|
|
2328
|
+
const fullTurn = {
|
|
2329
|
+
...turn,
|
|
2330
|
+
id: `turn_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`,
|
|
2331
|
+
timestamp: Date.now()
|
|
2332
|
+
};
|
|
2333
|
+
this.traces.push(fullTurn);
|
|
2334
|
+
this.listeners.forEach((listener) => listener(fullTurn));
|
|
2335
|
+
return fullTurn;
|
|
2336
|
+
}
|
|
2337
|
+
onTurn(listener) {
|
|
2338
|
+
this.listeners.push(listener);
|
|
2339
|
+
return () => {
|
|
2340
|
+
this.listeners = this.listeners.filter((l) => l !== listener);
|
|
2341
|
+
};
|
|
2342
|
+
}
|
|
2343
|
+
getTraces() {
|
|
2344
|
+
return [...this.traces];
|
|
2345
|
+
}
|
|
2346
|
+
getSessions() {
|
|
2347
|
+
const sessionMap = /* @__PURE__ */ new Map();
|
|
2348
|
+
for (const turn of this.traces) {
|
|
2349
|
+
if (!sessionMap.has(turn.sessionId)) {
|
|
2350
|
+
sessionMap.set(turn.sessionId, []);
|
|
2351
|
+
}
|
|
2352
|
+
sessionMap.get(turn.sessionId).push(turn);
|
|
2353
|
+
}
|
|
2354
|
+
const sessions = [];
|
|
2355
|
+
sessionMap.forEach((turns, sessionId) => {
|
|
2356
|
+
turns.sort((a, b) => a.timestamp - b.timestamp);
|
|
2357
|
+
const lastTurn = turns[turns.length - 1];
|
|
2358
|
+
const totalRetries = turns.reduce((acc, t) => acc + (t.retryCount > 0 ? 1 : 0), 0);
|
|
2359
|
+
sessions.push({
|
|
2360
|
+
sessionId,
|
|
2361
|
+
flowId: turns[0].flowId,
|
|
2362
|
+
startTime: turns[0].timestamp,
|
|
2363
|
+
endTime: lastTurn.timestamp,
|
|
2364
|
+
turns,
|
|
2365
|
+
finalStatus: lastTurn.status,
|
|
2366
|
+
totalRetries
|
|
2367
|
+
});
|
|
2368
|
+
});
|
|
2369
|
+
return sessions.sort((a, b) => b.endTime - a.endTime);
|
|
2370
|
+
}
|
|
2371
|
+
computeMetrics(flow) {
|
|
2372
|
+
const sessions = this.getSessions();
|
|
2373
|
+
const totalSessions = sessions.length;
|
|
2374
|
+
if (totalSessions === 0) {
|
|
2375
|
+
return {
|
|
2376
|
+
totalSessions: 0,
|
|
2377
|
+
completedSessions: 0,
|
|
2378
|
+
escalatedSessions: 0,
|
|
2379
|
+
activeSessions: 0,
|
|
2380
|
+
containmentRate: 100,
|
|
2381
|
+
completionRate: 0,
|
|
2382
|
+
escalationRate: 0,
|
|
2383
|
+
avgTurnsPerSession: 0,
|
|
2384
|
+
totalRetries: 0,
|
|
2385
|
+
nodeStats: {}
|
|
2386
|
+
};
|
|
2387
|
+
}
|
|
2388
|
+
let completedSessions = 0;
|
|
2389
|
+
let escalatedSessions = 0;
|
|
2390
|
+
let activeSessions = 0;
|
|
2391
|
+
let totalTurnsCount = 0;
|
|
2392
|
+
let totalRetries = 0;
|
|
2393
|
+
const nodeStats = {};
|
|
2394
|
+
if (flow) {
|
|
2395
|
+
for (const node of flow.nodes) {
|
|
2396
|
+
nodeStats[node.id] = {
|
|
2397
|
+
visits: 0,
|
|
2398
|
+
retries: 0,
|
|
2399
|
+
dropOffs: 0,
|
|
2400
|
+
unhandledInputs: [],
|
|
2401
|
+
patternBreakdown: {}
|
|
2402
|
+
};
|
|
2403
|
+
}
|
|
2404
|
+
}
|
|
2405
|
+
for (const session of sessions) {
|
|
2406
|
+
totalTurnsCount += session.turns.length;
|
|
2407
|
+
if (session.finalStatus === "completed") completedSessions++;
|
|
2408
|
+
else if (session.finalStatus === "escalated") escalatedSessions++;
|
|
2409
|
+
else activeSessions++;
|
|
2410
|
+
for (let i = 0; i < session.turns.length; i++) {
|
|
2411
|
+
const turn = session.turns[i];
|
|
2412
|
+
const isLastTurn = i === session.turns.length - 1;
|
|
2413
|
+
if (!nodeStats[turn.fromNodeId]) {
|
|
2414
|
+
nodeStats[turn.fromNodeId] = {
|
|
2415
|
+
visits: 0,
|
|
2416
|
+
retries: 0,
|
|
2417
|
+
dropOffs: 0,
|
|
2418
|
+
unhandledInputs: [],
|
|
2419
|
+
patternBreakdown: {}
|
|
2420
|
+
};
|
|
2421
|
+
}
|
|
2422
|
+
const stat = nodeStats[turn.fromNodeId];
|
|
2423
|
+
stat.visits++;
|
|
2424
|
+
if (turn.retryCount > 0) {
|
|
2425
|
+
stat.retries++;
|
|
2426
|
+
if (!stat.unhandledInputs.includes(turn.rawInput)) {
|
|
2427
|
+
stat.unhandledInputs.push(turn.rawInput);
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
const patternKey = turn.matchedPattern || "[unmatched]";
|
|
2431
|
+
stat.patternBreakdown[patternKey] = (stat.patternBreakdown[patternKey] || 0) + 1;
|
|
2432
|
+
if (isLastTurn && session.finalStatus !== "completed") {
|
|
2433
|
+
stat.dropOffs++;
|
|
2434
|
+
}
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2437
|
+
const containmentRate = totalSessions > 0 ? Math.round((totalSessions - escalatedSessions) / totalSessions * 100) : 100;
|
|
2438
|
+
const completionRate = Math.round(completedSessions / totalSessions * 100);
|
|
2439
|
+
const escalationRate = Math.round(escalatedSessions / totalSessions * 100);
|
|
2440
|
+
const avgTurnsPerSession = Number((totalTurnsCount / totalSessions).toFixed(1));
|
|
2441
|
+
return {
|
|
2442
|
+
totalSessions,
|
|
2443
|
+
completedSessions,
|
|
2444
|
+
escalatedSessions,
|
|
2445
|
+
activeSessions,
|
|
2446
|
+
containmentRate,
|
|
2447
|
+
completionRate,
|
|
2448
|
+
escalationRate,
|
|
2449
|
+
avgTurnsPerSession,
|
|
2450
|
+
totalRetries,
|
|
2451
|
+
nodeStats
|
|
2452
|
+
};
|
|
2453
|
+
}
|
|
2454
|
+
clear() {
|
|
2455
|
+
this.traces = [];
|
|
2456
|
+
}
|
|
2457
|
+
};
|
|
2458
|
+
|
|
2459
|
+
// evals/evaluator.ts
|
|
2460
|
+
var Evaluator = class {
|
|
2461
|
+
constructor(flow) {
|
|
2462
|
+
this.flow = flow;
|
|
2463
|
+
}
|
|
2464
|
+
async runScenario(scenario) {
|
|
2465
|
+
const startTime = Date.now();
|
|
2466
|
+
const engine = new FSMEngine(this.flow);
|
|
2467
|
+
let state = engine.createInitialState(`eval_user_${scenario.id}`);
|
|
2468
|
+
if (scenario.initialContext) {
|
|
2469
|
+
state = {
|
|
2470
|
+
...state,
|
|
2471
|
+
context: { ...scenario.initialContext }
|
|
2472
|
+
};
|
|
2473
|
+
}
|
|
2474
|
+
const stepResults = [];
|
|
2475
|
+
const visitedNodes = /* @__PURE__ */ new Set([state.currentNodeId]);
|
|
2476
|
+
let overallPassed = true;
|
|
2477
|
+
for (let i = 0; i < scenario.steps.length; i++) {
|
|
2478
|
+
const step = scenario.steps[i];
|
|
2479
|
+
const result = await engine.transition(state, step.input);
|
|
2480
|
+
state = result.nextState;
|
|
2481
|
+
visitedNodes.add(state.currentNodeId);
|
|
2482
|
+
let stepPassed = true;
|
|
2483
|
+
const failureReasons = [];
|
|
2484
|
+
if (step.expectedNodeId && state.currentNodeId !== step.expectedNodeId) {
|
|
2485
|
+
stepPassed = false;
|
|
2486
|
+
failureReasons.push(
|
|
2487
|
+
`Expected node "${step.expectedNodeId}", but transitioned to "${state.currentNodeId}"`
|
|
2488
|
+
);
|
|
2489
|
+
}
|
|
2490
|
+
if (step.expectedOutboundPattern) {
|
|
2491
|
+
const outbound = result.outboundText ?? "";
|
|
2492
|
+
if (typeof step.expectedOutboundPattern === "string") {
|
|
2493
|
+
if (!outbound.toLowerCase().includes(step.expectedOutboundPattern.toLowerCase())) {
|
|
2494
|
+
stepPassed = false;
|
|
2495
|
+
failureReasons.push(
|
|
2496
|
+
`Outbound text "${outbound}" did not contain expected substring "${step.expectedOutboundPattern}"`
|
|
2497
|
+
);
|
|
2498
|
+
}
|
|
2499
|
+
} else if (step.expectedOutboundPattern instanceof RegExp) {
|
|
2500
|
+
if (!step.expectedOutboundPattern.test(outbound)) {
|
|
2501
|
+
stepPassed = false;
|
|
2502
|
+
failureReasons.push(
|
|
2503
|
+
`Outbound text "${outbound}" did not match regex pattern ${step.expectedOutboundPattern}`
|
|
2504
|
+
);
|
|
2505
|
+
}
|
|
2506
|
+
}
|
|
2507
|
+
}
|
|
2508
|
+
if (step.expectedContext) {
|
|
2509
|
+
for (const [key, val] of Object.entries(step.expectedContext)) {
|
|
2510
|
+
if (state.context[key] !== val) {
|
|
2511
|
+
stepPassed = false;
|
|
2512
|
+
failureReasons.push(
|
|
2513
|
+
`Context key "${key}" expected value ${JSON.stringify(val)}, got ${JSON.stringify(state.context[key])}`
|
|
2514
|
+
);
|
|
2515
|
+
}
|
|
2516
|
+
}
|
|
2517
|
+
}
|
|
2518
|
+
if (step.expectEscalated && state.status !== "escalated") {
|
|
2519
|
+
stepPassed = false;
|
|
2520
|
+
failureReasons.push(`Expected conversation to be escalated, but status was "${state.status}"`);
|
|
2521
|
+
}
|
|
2522
|
+
if (step.expectCompleted && state.status !== "completed") {
|
|
2523
|
+
stepPassed = false;
|
|
2524
|
+
failureReasons.push(`Expected conversation to be completed, but status was "${state.status}"`);
|
|
2525
|
+
}
|
|
2526
|
+
if (!stepPassed) {
|
|
2527
|
+
overallPassed = false;
|
|
2528
|
+
}
|
|
2529
|
+
stepResults.push({
|
|
2530
|
+
stepIndex: i + 1,
|
|
2531
|
+
input: step.input,
|
|
2532
|
+
actualNodeId: state.currentNodeId,
|
|
2533
|
+
expectedNodeId: step.expectedNodeId,
|
|
2534
|
+
actualOutbound: result.outboundText,
|
|
2535
|
+
expectedOutboundPattern: step.expectedOutboundPattern,
|
|
2536
|
+
actualStatus: state.status,
|
|
2537
|
+
actualContext: { ...state.context },
|
|
2538
|
+
passed: stepPassed,
|
|
2539
|
+
failureReason: failureReasons.join("; ")
|
|
2540
|
+
});
|
|
2541
|
+
}
|
|
2542
|
+
return {
|
|
2543
|
+
scenarioId: scenario.id,
|
|
2544
|
+
scenarioName: scenario.name,
|
|
2545
|
+
passed: overallPassed,
|
|
2546
|
+
stepResults,
|
|
2547
|
+
durationMs: Date.now() - startTime,
|
|
2548
|
+
visitedNodeIds: Array.from(visitedNodes)
|
|
2549
|
+
};
|
|
2550
|
+
}
|
|
2551
|
+
async runSuite(scenarios) {
|
|
2552
|
+
const startTime = Date.now();
|
|
2553
|
+
const scenarioResults = [];
|
|
2554
|
+
const allVisitedNodes = /* @__PURE__ */ new Set();
|
|
2555
|
+
for (const scenario of scenarios) {
|
|
2556
|
+
const res = await this.runScenario(scenario);
|
|
2557
|
+
scenarioResults.push(res);
|
|
2558
|
+
res.visitedNodeIds.forEach((id) => allVisitedNodes.add(id));
|
|
2559
|
+
}
|
|
2560
|
+
const totalScenarios = scenarios.length;
|
|
2561
|
+
const passedScenarios = scenarioResults.filter((r) => r.passed).length;
|
|
2562
|
+
const failedScenarios = totalScenarios - passedScenarios;
|
|
2563
|
+
const passRate = totalScenarios > 0 ? Math.round(passedScenarios / totalScenarios * 100) : 0;
|
|
2564
|
+
const allNodeIds = this.flow.nodes.map((n) => n.id);
|
|
2565
|
+
const coveredNodeIds = allNodeIds.filter((id) => allVisitedNodes.has(id));
|
|
2566
|
+
const uncoveredNodeIds = allNodeIds.filter((id) => !allVisitedNodes.has(id));
|
|
2567
|
+
const nodeCoveragePercentage = allNodeIds.length > 0 ? Math.round(coveredNodeIds.length / allNodeIds.length * 100) : 100;
|
|
2568
|
+
return {
|
|
2569
|
+
totalScenarios,
|
|
2570
|
+
passedScenarios,
|
|
2571
|
+
failedScenarios,
|
|
2572
|
+
passRate,
|
|
2573
|
+
nodeCoveragePercentage,
|
|
2574
|
+
coveredNodeIds,
|
|
2575
|
+
uncoveredNodeIds,
|
|
2576
|
+
scenarioResults,
|
|
2577
|
+
totalDurationMs: Date.now() - startTime
|
|
2578
|
+
};
|
|
2579
|
+
}
|
|
2580
|
+
};
|
|
2581
|
+
export {
|
|
2582
|
+
ActionNodeBuilder,
|
|
2583
|
+
AmbiguousSendError,
|
|
2584
|
+
Bot,
|
|
2585
|
+
CallbackPersistenceAdapter,
|
|
2586
|
+
CompileError,
|
|
2587
|
+
ConcurrencyLockError,
|
|
2588
|
+
Context,
|
|
2589
|
+
Evaluator,
|
|
2590
|
+
FSMEngine,
|
|
2591
|
+
FlaresendClient,
|
|
2592
|
+
FlaresendSendError,
|
|
2593
|
+
FlowNodeBuilder,
|
|
2594
|
+
GlobalTransitionBuilder,
|
|
2595
|
+
HandoffNodeBuilder,
|
|
2596
|
+
IdempotencyService,
|
|
2597
|
+
InMemoryConcurrencyLeaseService,
|
|
2598
|
+
KnownNotSentError,
|
|
2599
|
+
MongoosePersistenceAdapter,
|
|
2600
|
+
OptimisticConcurrencyError,
|
|
2601
|
+
RedisConcurrencyLeaseService,
|
|
2602
|
+
TraceCollector,
|
|
2603
|
+
classifyFlaresendError,
|
|
2604
|
+
createConcurrencyLeaseService,
|
|
2605
|
+
createExpressAdapter,
|
|
2606
|
+
createFSMWorker,
|
|
2607
|
+
createFastifyAdapter,
|
|
2608
|
+
createLiteralMatcher,
|
|
2609
|
+
createRawHttpAdapter,
|
|
2610
|
+
expressAdapter,
|
|
2611
|
+
extractWebhookPayload,
|
|
2612
|
+
fastifyAdapter,
|
|
2613
|
+
handleWebhookCore,
|
|
2614
|
+
httpAdapter,
|
|
2615
|
+
matchesAny,
|
|
2616
|
+
processFSMJob,
|
|
2617
|
+
rawHttpAdapter,
|
|
2618
|
+
registerFlowDefinition,
|
|
2619
|
+
runConversationStep,
|
|
2620
|
+
verifyWebhookSignature
|
|
2621
|
+
};
|