@tiledesk/tiledesk-server 2.29.0 → 3.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/routes/faq.js CHANGED
@@ -655,9 +655,16 @@ router.get('/', roleChecker.hasRoleOrTypes('agent', ['bot', 'subscription']), fu
655
655
  // console.log("result: ", result);
656
656
  // })
657
657
 
658
- return Faq.find(query)
659
- .skip(skip).limit(limit)
660
- .populate({ path: 'faq_kb' })
658
+ // Every block carries `faq_kb`, the whole bot read again from the database (virtual of the
659
+ // model). A caller that already has the bot, such as the Design Studio V3, asks for the blocks
660
+ // without it (`populate=false`): a list of blocks then weighs the blocks alone, not the bot
661
+ // repeated in every row. Without the parameter, or with any other value, nothing changes.
662
+ let faqQuery = Faq.find(query)
663
+ .skip(skip).limit(limit);
664
+ if (req.query.populate !== 'false') {
665
+ faqQuery = faqQuery.populate({ path: 'faq_kb' });
666
+ }
667
+ return faqQuery
661
668
  .lean()
662
669
  .exec(function (err, faqs) {
663
670
 
package/routes/faq_kb.js CHANGED
@@ -23,6 +23,7 @@ const chatbotController = require('../controllers/chatbot.controller');
23
23
  const intentsController = require('../controllers/intents.controller');
24
24
  const AppError = require('../utils/AppError');
25
25
  const asyncHandler = require('../utils/asyncHandler');
26
+ const v3FlowService = require('../services/v3FlowService');
26
27
 
27
28
  let chatbot_templates_api_url = process.env.CHATBOT_TEMPLATES_API_URL
28
29
 
@@ -457,12 +458,81 @@ router.put('/:faq_kbid/language/:language', roleChecker.hasRoleOrTypes('admin',
457
458
 
458
459
  })
459
460
 
461
+ /**
462
+ * V3 agents: the connectors list, read and written as a whole.
463
+ * `GET` answers the list (or null for an agent that has none).
464
+ * `PUT` replaces it: the body is the list (`{ version: 1, items: [...] }`), validated in shape; an
465
+ * `If-Match` header (or an `if_match` query parameter) carrying the `updatedAt` the client last saw turns a save on top of a newer
466
+ * list into a 409 that returns the current one, so two editors cannot overwrite each other blindly.
467
+ * The agent is marked as modified (it has to be published again) and the engine's cache is cleared.
468
+ * Only a V3 agent has a list: for any other agent the PUT is refused and nothing changes.
469
+ */
470
+ router.get('/:faq_kbid/connectors', roleChecker.hasRoleOrTypes('admin', ['bot', 'subscription']), asyncHandler(async (req, res) => {
471
+ const faq_kb = await Faq_kb.findOne({ _id: req.params.faq_kbid, id_project: req.projectid }).select('+connectors').exec();
472
+ if (!faq_kb) {
473
+ return res.status(404).send({ success: false, msg: "Chatbot not found with id " + req.params.faq_kbid + " for project " + req.projectid });
474
+ }
475
+ return res.json(faq_kb.connectors === undefined ? null : faq_kb.connectors);
476
+ }));
477
+
478
+ router.put('/:faq_kbid/connectors', roleChecker.hasRoleOrTypes('admin', ['bot', 'subscription']), asyncHandler(async (req, res) => {
479
+ const chatbot_id = req.params.faq_kbid;
480
+ const id_project = req.projectid;
481
+ const collection = req.body;
482
+
483
+ const errors = v3FlowService.validateConnectors(collection);
484
+ if (errors.length > 0) {
485
+ return res.status(400).send({ success: false, msg: "Invalid connectors list", errors: errors });
486
+ }
487
+
488
+ const faq_kb = await Faq_kb.findOne({ _id: chatbot_id, id_project: id_project }).select('+connectors').exec();
489
+ if (!faq_kb) {
490
+ return res.status(404).send({ success: false, msg: "Chatbot not found with id " + chatbot_id + " for project " + id_project });
491
+ }
492
+ if (!v3FlowService.isV3(faq_kb)) {
493
+ return res.status(400).send({ success: false, msg: "The connectors list belongs to V3 agents only (ds_version 'v3')" });
494
+ }
495
+
496
+ // The `updatedAt` the client last saw: `If-Match` header, or `if_match` query parameter for the
497
+ // clients that cannot send a custom header through a CORS gateway.
498
+ const expected = req.get('If-Match') || req.query.if_match;
499
+ const current = faq_kb.connectors && faq_kb.connectors.updatedAt ? new Date(faq_kb.connectors.updatedAt) : null;
500
+ if (expected && current && new Date(expected).getTime() !== current.getTime()) {
501
+ winston.verbose("(faq_kb) connectors of " + chatbot_id + " changed since " + expected + ": conflict");
502
+ return res.status(409).send({ success: false, msg: "The connectors list changed since " + expected, connectors: faq_kb.connectors });
503
+ }
504
+
505
+ faq_kb.connectors = { version: collection.version, updatedAt: new Date(), items: collection.items };
506
+ faq_kb.modified = true;
507
+ faq_kb.markModified('connectors');
508
+ const saved = await faq_kb.save();
509
+ botEvent.emit('faqbot.update', saved);
510
+ winston.debug("(faq_kb) connectors of " + chatbot_id + " saved: " + saved.connectors.items.length + " records");
511
+ return res.json(saved.connectors);
512
+ }));
513
+
460
514
  router.patch('/:faq_kbid/attributes', roleChecker.hasRoleOrTypes('admin', ['bot', 'subscription']), function (req, res) { //TODO add cache_bot_here
461
515
  var data = req.body;
462
516
 
463
517
  const chatbot_id = req.params.faq_kbid;
464
518
  const id_project = req.projectid;
465
519
 
520
+ // The version label and the connectors list are fields of the bot since 15/10/2026, not attributes.
521
+ // A client still sending them the old way (`dsVersion`, `connectors`) gets them promoted: written
522
+ // to the fields, removed from the attributes, exactly as the import does. The Design Studio V3
523
+ // sends them this way on purpose when it repairs an agent saved before that date.
524
+ let promoted = null;
525
+ if (v3FlowService.legacyAttributeKeys(data).length > 0) {
526
+ promoted = { attributes: data };
527
+ v3FlowService.promoteLegacyJson(promoted);
528
+ if (promoted.connectors !== undefined && promoted.connectors !== null) {
529
+ const errors = v3FlowService.validateConnectors(promoted.connectors);
530
+ if (errors.length > 0) {
531
+ return res.status(400).send({ success: false, msg: "Invalid connectors list", errors: errors });
532
+ }
533
+ }
534
+ }
535
+
466
536
  // TODO use service method
467
537
  Faq_kb.findOne({ _id: chatbot_id, id_project: id_project }, function (err, updatedBot) {
468
538
  if (err) {
@@ -487,6 +557,20 @@ router.patch('/:faq_kbid/attributes', roleChecker.hasRoleOrTypes('admin', ['bot'
487
557
  updatedBot.attributes[key] = val;
488
558
  });
489
559
 
560
+ if (promoted) {
561
+ if (promoted.ds_version && !updatedBot.ds_version) {
562
+ updatedBot.ds_version = promoted.ds_version;
563
+ }
564
+ if (promoted.connectors !== undefined && promoted.connectors !== null) {
565
+ updatedBot.connectors = { version: promoted.connectors.version, updatedAt: new Date(), items: promoted.connectors.items };
566
+ updatedBot.markModified('connectors');
567
+ updatedBot.modified = true;
568
+ }
569
+ delete updatedBot.attributes.dsVersion;
570
+ delete updatedBot.attributes.connectors;
571
+ winston.verbose("(faq_kb) attributes of " + chatbot_id + ": dsVersion/connectors promoted to the bot fields");
572
+ }
573
+
490
574
  winston.debug("updatedBot attributes", updatedBot.attributes)
491
575
 
492
576
  // https://stackoverflow.com/questions/24054552/mongoose-not-saving-nested-object
@@ -577,7 +661,8 @@ router.get('/:faq_kbid', roleChecker.hasRoleOrTypes('admin', ['bot', 'subscripti
577
661
  const chatbot_id = req.params.faq_kbid;
578
662
  const id_project = req.projectid;
579
663
 
580
- Faq_kb.findOne({ _id: chatbot_id, id_project: id_project }, function (err, faq_kb) { //TODO add cache_bot_here
664
+ // The single GET is how the Design Studio opens an agent: a V3 agent needs its connectors list here.
665
+ Faq_kb.findOne({ _id: chatbot_id, id_project: id_project }).select('+connectors').exec(function (err, faq_kb) { //TODO add cache_bot_here
581
666
  if (err) {
582
667
  return res.status(500).send({ success: false, msg: 'Error getting object.' });
583
668
  }
@@ -6,6 +6,7 @@ const chatbotTypes = require("../models/chatbotTypes");
6
6
  const chatbotTemplates = require("../models/chatbotTemplates");
7
7
  const templates = require('../template/chatbot');
8
8
  const subagentNotes = require('../template/chatbot/subagent_notes');
9
+ const v3FlowService = require('./v3FlowService');
9
10
 
10
11
  class FaqService {
11
12
 
@@ -79,6 +80,18 @@ class FaqService {
79
80
  throw new Error("parent_id is required for subtype 'subagent'");
80
81
  }
81
82
 
83
+ // A creation body written the old way (`attributes.dsVersion`, `attributes.connectors`) is promoted
84
+ // to the fields, as the import does: a client not yet updated still creates a proper V3 agent.
85
+ v3FlowService.promoteLegacyJson(data);
86
+
87
+ if (data.connectors !== undefined && data.connectors !== null) {
88
+ const errors = v3FlowService.validateConnectors(data.connectors);
89
+ if (errors.length > 0) {
90
+ winston.error('(FaqService) invalid connectors list: ' + errors.join('; '));
91
+ throw new Error('Invalid connectors list: ' + errors.join('; '));
92
+ }
93
+ }
94
+
82
95
  let chatbot = this.createChatbotObject(id_project, id_user, data);
83
96
 
84
97
  let template;
@@ -89,6 +102,22 @@ class FaqService {
89
102
  }
90
103
  }
91
104
 
105
+ let options = {};
106
+ if (data.namespace_id) {
107
+ options.namespace_id = data.namespace_id;
108
+ }
109
+
110
+ // A V3 agent is born in V3 form: the template's blocks lose their V2 destinations and the
111
+ // connectors list is saved with the bot, so nothing has to be repaired on first open.
112
+ // A list given by the caller (an import, a fork) wins: the template is not converted then.
113
+ let templateFaqs = null;
114
+ if (template && v3FlowService.isV3(chatbot) && (data.connectors === undefined || data.connectors === null)) {
115
+ const flow = v3FlowService.toV3Flow(templates[template](options));
116
+ chatbot.connectors = flow.connectors;
117
+ templateFaqs = flow.intents;
118
+ winston.debug('(FaqService) template ' + template + ' in V3 form: ' + flow.connectors.items.length + ' connectors');
119
+ }
120
+
92
121
  let savedChatbot;
93
122
  try {
94
123
  savedChatbot = await chatbot.save();
@@ -98,11 +127,7 @@ class FaqService {
98
127
  }
99
128
 
100
129
  if (template) {
101
- let options = {};
102
- if (data.namespace_id) {
103
- options.namespace_id = data.namespace_id;
104
- }
105
- this.createGreetingsAndOperationalsFaqs(savedChatbot._id, savedChatbot.createdBy, savedChatbot.id_project, template, options);
130
+ this.createGreetingsAndOperationalsFaqs(savedChatbot._id, savedChatbot.createdBy, savedChatbot.id_project, template, options, templateFaqs);
106
131
  }
107
132
 
108
133
  botEvent.emit('faqbot.create', savedChatbot);
@@ -141,7 +166,9 @@ class FaqService {
141
166
  createdBy: id_user,
142
167
  updatedBy: id_user,
143
168
  attributes: data.attributes,
144
- parent_id: data.parent_id
169
+ parent_id: data.parent_id,
170
+ ds_version: v3FlowService.normalizeDsVersion(data.ds_version),
171
+ connectors: data.connectors
145
172
  })
146
173
  }
147
174
 
@@ -162,13 +189,14 @@ class FaqService {
162
189
  return defaultTemplate;
163
190
  }
164
191
 
165
- createGreetingsAndOperationalsFaqs(faq_kb_id, created_by, projectid, template, options) {
192
+ createGreetingsAndOperationalsFaqs(faq_kb_id, created_by, projectid, template, options, faqs) {
166
193
 
167
194
  return new Promise( async (resolve, reject) => {
168
195
 
169
196
  winston.debug('(FaqService) create faqs from template: ' + template);
170
197
 
171
- let faqsArray = templates[template](options);
198
+ // `faqs`: the template's blocks already prepared by the caller (a V3 agent); otherwise the template as it is.
199
+ let faqsArray = faqs || templates[template](options);
172
200
 
173
201
  faqsArray.forEach(faq => {
174
202
 
@@ -0,0 +1,416 @@
1
+ const uuidv4 = require('uuid/v4');
2
+ const winston = require('../config/winston');
3
+
4
+ /**
5
+ * The V3 shape of an agent's flow (Design Studio V3, since 15/10/2026).
6
+ *
7
+ * A V3 agent (`faq_kb.ds_version === 'v3'`) keeps its links in the bot-level list
8
+ * `faq_kb.connectors`, not in the destination fields of its actions (`intentName`,
9
+ * `trueIntent`, `goToIntent`, `cases[].intent`, `attributes.nextBlockAction.intentName`, ...).
10
+ * Those fields are empty in the stored blocks: the list is the only truth, and both the
11
+ * Design Studio V3 and the engine (tiledesk-chatbot, ConnectorsV3) read it from there.
12
+ *
13
+ * This module is the server's own knowledge of that shape, so an agent born here (from a
14
+ * template) or coming in (import, fork, restore) is already in V3 form:
15
+ * - `toV3Flow(intents)`: the blocks of a template, written the V2 way, become V3 blocks plus
16
+ * their connectors list (technical ids filled in, list built from the fields, fields cleared);
17
+ * - `validateConnectors(collection)`: the checks the dedicated endpoint and the import apply;
18
+ * - `promoteLegacyJson(json)`: a JSON exported before the two fields existed carried them
19
+ * inside `attributes`; they are moved to the fields.
20
+ *
21
+ * The output catalog is a copy of `connectors/connector-catalog.ts` of the Design Studio and of
22
+ * `utils/ConnectorsV3.js` of the engine: same types, same fields, same id suffixes. A record is
23
+ * `{ id, from: { intentId, actionId, slot }, to: { intentId }, kind, props? }` and its id is
24
+ * `${intentId}/${actionId}${suffix}/${toIntentId}`. If the catalog changes there, it changes here.
25
+ *
26
+ * A V2 agent never passes through here: every caller checks `ds_version` first.
27
+ */
28
+
29
+ const DS_VERSION_V3 = 'v3';
30
+ const COLLECTION_VERSION = 1;
31
+ const RECORD_KINDS = ['block', 'action', 'button', 'case', 'branch'];
32
+ const RETURN_STACK = 'returnstack';
33
+ const BUTTON_URL = 'url';
34
+ const BUTTON_ACTION = 'action';
35
+
36
+ const field = (key, suffix, name) => ({ key, kind: 'action', path: 'field', suffix, field: name });
37
+ const nested = (key, suffix, path) => ({ key, kind: 'action', path: 'nested', suffix, steps: path });
38
+ const lastCommand = (key, suffix, name) => ({ key, kind: 'action', path: 'lastCommandSetting', suffix, field: name });
39
+ const BUTTONS = { key: 'button', kind: 'button', path: 'buttons' };
40
+ const array = (key, kind, suffixOf, arrayName, idField, name) => ({ key, kind, path: 'array', suffixOf, array: arrayName, idField, field: name });
41
+
42
+ const TRUE_FALSE = [field('true', '/true', 'trueIntent'), field('false', '/false', 'falseIntent')];
43
+ const VOICE_SETTINGS = [nested('noInput', '/noInput', ['settings', 'noInputIntent']), nested('noMatch', '/noMatch', ['settings', 'noMatchIntent'])];
44
+ const REPLY_V1 = [BUTTONS, ...VOICE_SETTINGS];
45
+
46
+ const OUTPUT_CATALOG = {
47
+ intent: [field('default', '', 'intentName')],
48
+ connect_block: [field('default', '', 'intentName')],
49
+ reply: REPLY_V1,
50
+ randomreply: REPLY_V1,
51
+ replyv2: [BUTTONS, field('noInput', '/noInput', 'noInputIntent'), field('noMatch', '/noMatch', 'noMatchIntent')],
52
+ ifonlineagents: TRUE_FALSE,
53
+ ifonlineagentsv2: TRUE_FALSE,
54
+ ifopenhours: TRUE_FALSE,
55
+ jsonconditionmulti: [array('case', 'case', (c) => '/case/' + c._tdCaseId, 'cases', '_tdCaseId', 'intent'), field('else', '/else', 'elseIntent')],
56
+ jsoncondition: TRUE_FALSE,
57
+ jsoncondition2: TRUE_FALSE,
58
+ invoke_subagent: TRUE_FALSE,
59
+ askgpt: TRUE_FALSE,
60
+ askgptv2: TRUE_FALSE,
61
+ gpt_task: TRUE_FALSE,
62
+ gpt_assistant: TRUE_FALSE,
63
+ ai_prompt: TRUE_FALSE,
64
+ ai_condition: [array('branch', 'branch', (b) => '/' + b.label + '/true', 'intents', 'label', 'conditionIntentId'), field('fallback', '/fallback', 'fallbackIntent'), field('error', '/error', 'errorIntent')],
65
+ webrequestv2: TRUE_FALSE,
66
+ data_table: TRUE_FALSE,
67
+ make: TRUE_FALSE,
68
+ hubspot: TRUE_FALSE,
69
+ customerio: TRUE_FALSE,
70
+ brevo: TRUE_FALSE,
71
+ n8n: TRUE_FALSE,
72
+ qapla: TRUE_FALSE,
73
+ capture_user_reply: [field('default', '', 'goToIntent')],
74
+ iteration: [field('goto', '/goto', 'goToIntent'), field('fallback', '/fallback', 'fallbackIntent')],
75
+ dtmf_menu: [BUTTONS, ...VOICE_SETTINGS, lastCommand('noInput', '/noInput', 'noInputIntent'), lastCommand('noMatch', '/noMatch', 'noMatchIntent')],
76
+ dtmf_form: [BUTTONS, ...VOICE_SETTINGS, lastCommand('noInput', '/noInput', 'noInputIntent'), lastCommand('noMatch', '/noMatch', 'noMatchIntent')],
77
+ speech_form: [lastCommand('noInput', '/noInput', 'noInputIntent'), lastCommand('noMatch', '/noMatch', 'noMatchIntent')],
78
+ blind_transfer: [BUTTONS, ...VOICE_SETTINGS, lastCommand('true', '/true', 'trueIntent'), lastCommand('false', '/false', 'falseIntent')],
79
+ send_whatsapp: TRUE_FALSE,
80
+ };
81
+
82
+ /** The block's own exit (the dot), stored in `attributes.nextBlockAction.intentName`. */
83
+ const BLOCK_SLOT = { key: 'next', kind: 'block', path: 'block', suffix: '' };
84
+
85
+ function isSet(value) {
86
+ return typeof value === 'string' && value !== '';
87
+ }
88
+
89
+ function hasValidUid(uid) {
90
+ return typeof uid === 'string' && uid !== '' && uid !== 'UUIDV4';
91
+ }
92
+
93
+ /** Every `buttons[]` inside the object, at any depth, in visiting order (as the Design Studio's `findButtons`). */
94
+ function findButtons(obj) {
95
+ let buttons = [];
96
+ if (Array.isArray(obj)) {
97
+ for (const element of obj) { buttons = buttons.concat(findButtons(element)); }
98
+ } else if (obj && typeof obj === 'object') {
99
+ if (Array.isArray(obj.buttons)) { buttons = buttons.concat(obj.buttons); }
100
+ for (const key in obj) { if (key !== 'buttons') { buttons = buttons.concat(findButtons(obj[key])); } }
101
+ }
102
+ return buttons;
103
+ }
104
+
105
+ function isReturnStackIntent(intent) {
106
+ const actions = intent && intent.actions;
107
+ return Array.isArray(actions) && actions.length === 1 && actions[0] && actions[0]._tdActionType === RETURN_STACK;
108
+ }
109
+
110
+ /** A button's target is `#<id>` or `#<id>{...params}`; a field's target is `#<id>` (a bare id in old flows). */
111
+ function parseTarget(raw, isButton) {
112
+ if (!isSet(raw)) { return { toId: null, attrs: null, hasHash: false }; }
113
+ if (isButton) {
114
+ const start = raw.indexOf('#') + 1;
115
+ const end = raw.indexOf('{');
116
+ const toId = end > -1 ? raw.substring(start, end) : raw.substring(start);
117
+ return { toId: toId !== '' ? toId : null, attrs: end > -1 ? raw.substring(end) : null, hasHash: raw.trim().startsWith('#') };
118
+ }
119
+ const toId = raw.replace('#', '');
120
+ return { toId: toId !== '' ? toId : null, attrs: null, hasHash: raw.trim().startsWith('#') };
121
+ }
122
+
123
+ function makeSlot(spec, intentId, actionId, holder, fieldName, item, keyOverride) {
124
+ const isButton = spec.path === 'buttons';
125
+ const raw = holder && isSet(holder[fieldName]) ? holder[fieldName] : '';
126
+ const suffix = spec.path === 'array' ? spec.suffixOf(item) : (isButton ? '/' + item.uid : spec.suffix);
127
+ return {
128
+ key: keyOverride || spec.key, kind: spec.kind,
129
+ intentId, actionId, fromId: intentId + '/' + actionId + suffix,
130
+ holder, field: fieldName, item, raw, ...parseTarget(raw, isButton)
131
+ };
132
+ }
133
+
134
+ /** The exits of one action, connected or empty, in catalog order. Never throws: a malformed action just has fewer exits. */
135
+ function outputsOf(intentId, action) {
136
+ const out = [];
137
+ if (!action || typeof action !== 'object') { return out; }
138
+ const actionId = action._tdActionId;
139
+ for (const spec of OUTPUT_CATALOG[action._tdActionType] || []) {
140
+ switch (spec.path) {
141
+ case 'field':
142
+ out.push(makeSlot(spec, intentId, actionId, action, spec.field));
143
+ break;
144
+ case 'nested': {
145
+ let holder = action;
146
+ for (let i = 0; i < spec.steps.length - 1 && holder; i++) { holder = holder[spec.steps[i]]; }
147
+ if (holder && typeof holder === 'object') { out.push(makeSlot(spec, intentId, actionId, holder, spec.steps[spec.steps.length - 1])); }
148
+ break;
149
+ }
150
+ case 'lastCommandSetting': {
151
+ const commands = action.attributes && action.attributes.commands;
152
+ const settings = Array.isArray(commands) && commands.length > 0 && commands[commands.length - 1] ? commands[commands.length - 1].settings : null;
153
+ if (settings && typeof settings === 'object') { out.push(makeSlot(spec, intentId, actionId, settings, spec.field)); }
154
+ break;
155
+ }
156
+ case 'buttons':
157
+ // A text button has an empty exit; only an `action` button with a target yields a record. A url button has no exit.
158
+ for (const button of findButtons(action)) {
159
+ if (!button || typeof button !== 'object' || button.type === BUTTON_URL) { continue; }
160
+ const slot = makeSlot(spec, intentId, actionId, button, 'action', button, 'button:' + (button.uid == null ? '' : button.uid));
161
+ if (button.type !== BUTTON_ACTION) { slot.raw = ''; slot.toId = null; slot.attrs = null; slot.hasHash = false; }
162
+ out.push(slot);
163
+ }
164
+ break;
165
+ case 'array': {
166
+ const list = action[spec.array];
167
+ if (!Array.isArray(list)) { break; }
168
+ for (const item of list) {
169
+ if (!item || typeof item !== 'object') { continue; }
170
+ out.push(makeSlot(spec, intentId, actionId, item, spec.field, item, spec.key + ':' + item[spec.idField]));
171
+ }
172
+ break;
173
+ }
174
+ default:
175
+ break;
176
+ }
177
+ }
178
+ return out;
179
+ }
180
+
181
+ function blockOutputOf(intent) {
182
+ const next = intent && intent.attributes && intent.attributes.nextBlockAction;
183
+ if (!next || typeof next !== 'object' || isReturnStackIntent(intent)) { return null; }
184
+ const slot = makeSlot(BLOCK_SLOT, intent.intent_id, next._tdActionId, next, 'intentName');
185
+ slot.fromId = intent.intent_id + '/' + next._tdActionId;
186
+ return slot;
187
+ }
188
+
189
+ /** Every exit of a block: the dot first, then the actions in their order. */
190
+ function slotsOfIntent(intent) {
191
+ const out = [];
192
+ const block = blockOutputOf(intent);
193
+ if (block) { out.push(block); }
194
+ for (const action of (intent && intent.actions) || []) { out.push(...outputsOf(intent.intent_id, action)); }
195
+ return out;
196
+ }
197
+
198
+ /**
199
+ * Fills in the technical ids a template may leave out: `intent_id`, `_tdActionId` of actions and of
200
+ * the block's dot, `uid` of buttons, `_tdCaseId` of the cases of a multi condition. The Design Studio
201
+ * did the same on open; a V3 block must have them before its list is built. Mutates; returns how many.
202
+ */
203
+ function ensureTechnicalIds(intents) {
204
+ let filled = 0;
205
+ for (const intent of intents || []) {
206
+ if (!intent || typeof intent !== 'object') { continue; }
207
+ if (!isSet(intent.intent_id)) { intent.intent_id = uuidv4(); filled++; }
208
+ const next = intent.attributes && intent.attributes.nextBlockAction;
209
+ if (next && typeof next === 'object' && !isSet(next._tdActionId)) { next._tdActionId = uuidv4(); filled++; }
210
+ for (const action of intent.actions || []) {
211
+ if (!action || typeof action !== 'object') { continue; }
212
+ if (!isSet(action._tdActionId)) { action._tdActionId = uuidv4(); filled++; }
213
+ for (const button of findButtons(action)) {
214
+ if (button && typeof button === 'object' && !hasValidUid(button.uid)) { button.uid = uuidv4(); filled++; }
215
+ }
216
+ if (Array.isArray(action.cases)) {
217
+ for (const c of action.cases) {
218
+ if (c && typeof c === 'object' && !isSet(c._tdCaseId)) { c._tdCaseId = uuidv4(); filled++; }
219
+ }
220
+ }
221
+ }
222
+ }
223
+ return filled;
224
+ }
225
+
226
+ function indexFlow(intents) {
227
+ const ids = new Set();
228
+ for (const intent of intents || []) { if (intent && isSet(intent.intent_id)) { ids.add(intent.intent_id); } }
229
+ return ids;
230
+ }
231
+
232
+ /** A target that the list can hold: `#<id>` of an existing block, or a bare id that is one (the Design Studio added the `#`). */
233
+ function resolvedTarget(slot, ids) {
234
+ if (!slot.toId) { return null; }
235
+ if (slot.hasHash) { return ids.has(slot.toId) ? slot.toId : null; }
236
+ return ids.has(slot.toId) ? slot.toId : null;
237
+ }
238
+
239
+ /** A destination written the V2 way: the key exists and holds '' or a `#id` (a bare block name of old flows is left alone). */
240
+ function isV2Trace(slot, ids) {
241
+ if (!slot.holder || !Object.prototype.hasOwnProperty.call(slot.holder, slot.field)) { return false; }
242
+ if (slot.raw === '' || slot.hasHash) { return true; }
243
+ return !!slot.toId && ids.has(slot.toId);
244
+ }
245
+
246
+ function propsOf(slot, intent) {
247
+ const props = {};
248
+ if (slot.kind === 'button' && isSet(slot.attrs)) { props.params = slot.attrs; }
249
+ const looks = intent && intent.attributes && intent.attributes.connectors && intent.attributes.connectors[slot.fromId];
250
+ if (looks && typeof looks === 'object') {
251
+ if (looks.display !== undefined) { props.display = looks.display; }
252
+ if (looks.label !== undefined) { props.label = looks.label; }
253
+ if (looks.color !== undefined) { props.color = looks.color; }
254
+ }
255
+ return Object.keys(props).length > 0 ? props : null;
256
+ }
257
+
258
+ /**
259
+ * The connectors list the blocks describe: one record per connected exit towards a block that
260
+ * exists. Same ids and same order as the Design Studio's `buildCollection`.
261
+ */
262
+ function buildConnectors(intents, now) {
263
+ const ids = indexFlow(intents);
264
+ const items = [];
265
+ const seen = new Set();
266
+ for (const intent of intents || []) {
267
+ if (!intent) { continue; }
268
+ for (const slot of slotsOfIntent(intent)) {
269
+ const toId = resolvedTarget(slot, ids);
270
+ if (!toId) { continue; }
271
+ const id = slot.fromId + '/' + toId;
272
+ if (seen.has(id)) { continue; }
273
+ seen.add(id);
274
+ const record = { id, from: { intentId: slot.intentId, actionId: slot.actionId, slot: slot.key }, to: { intentId: toId }, kind: slot.kind };
275
+ const props = propsOf(slot, intent);
276
+ if (props) { record.props = props; }
277
+ items.push(record);
278
+ }
279
+ }
280
+ return { version: COLLECTION_VERSION, updatedAt: now ? now() : new Date().toISOString(), items };
281
+ }
282
+
283
+ /**
284
+ * Clears every V2 trace of the connectors from the blocks: destination keys of actions and of the
285
+ * dot, `button.action`, and the per-block visual attributes (`attributes.connectors`). Mirrors the
286
+ * Design Studio's `stripV2Connectors`. Mutates; returns how many it removed.
287
+ */
288
+ function stripDestinations(intents) {
289
+ const ids = indexFlow(intents);
290
+ let stripped = 0;
291
+ for (const intent of intents || []) {
292
+ if (!intent) { continue; }
293
+ for (const slot of slotsOfIntent(intent)) {
294
+ if (!isV2Trace(slot, ids)) { continue; }
295
+ delete slot.holder[slot.field];
296
+ stripped++;
297
+ }
298
+ if (intent.attributes && Object.prototype.hasOwnProperty.call(intent.attributes, 'connectors')) {
299
+ delete intent.attributes.connectors;
300
+ stripped++;
301
+ }
302
+ }
303
+ return stripped;
304
+ }
305
+
306
+ /**
307
+ * Blocks written the V2 way (a template, an old JSON) become V3 blocks plus their list.
308
+ * Mutates the intents it receives and returns `{ intents, connectors }`.
309
+ */
310
+ function toV3Flow(intents, now) {
311
+ const list = Array.isArray(intents) ? intents : [];
312
+ ensureTechnicalIds(list);
313
+ const connectors = buildConnectors(list, now);
314
+ stripDestinations(list);
315
+ return { intents: list, connectors };
316
+ }
317
+
318
+ /** The errors a connectors list has; empty when it is good. Shape only: targets are not checked against the blocks. */
319
+ function validateConnectors(collection) {
320
+ const errors = [];
321
+ if (!collection || typeof collection !== 'object' || Array.isArray(collection)) { return ['connectors must be an object']; }
322
+ if (collection.version !== COLLECTION_VERSION) { errors.push('connectors.version must be ' + COLLECTION_VERSION); }
323
+ if (collection.updatedAt !== undefined && collection.updatedAt !== null && isNaN(new Date(collection.updatedAt).getTime())) { errors.push('connectors.updatedAt must be a date'); }
324
+ if (!Array.isArray(collection.items)) { errors.push('connectors.items must be an array'); return errors; }
325
+ const ids = new Set();
326
+ collection.items.forEach((record, i) => {
327
+ const at = 'connectors.items[' + i + ']';
328
+ if (!record || typeof record !== 'object') { errors.push(at + ' must be an object'); return; }
329
+ if (!isSet(record.id)) { errors.push(at + '.id is required'); }
330
+ const from = record.from && typeof record.from === 'object' ? record.from : null;
331
+ if (!from || !isSet(from.intentId) || !isSet(from.actionId) || !isSet(from.slot)) { errors.push(at + '.from needs intentId, actionId and slot'); }
332
+ const to = record.to && typeof record.to === 'object' ? record.to : null;
333
+ if (!to || !isSet(to.intentId)) { errors.push(at + '.to.intentId is required'); }
334
+ if (!RECORD_KINDS.includes(record.kind)) { errors.push(at + '.kind must be one of ' + RECORD_KINDS.join(', ')); }
335
+ if (record.props !== undefined && (record.props === null || typeof record.props !== 'object' || Array.isArray(record.props))) { errors.push(at + '.props must be an object'); }
336
+ if (isSet(record.id) && from && to && isSet(from.intentId) && isSet(to.intentId)) {
337
+ if (!record.id.startsWith(from.intentId + '/') || !record.id.endsWith('/' + to.intentId)) { errors.push(at + '.id does not match from.intentId and to.intentId'); }
338
+ }
339
+ if (isSet(record.id)) {
340
+ if (ids.has(record.id)) { errors.push(at + '.id is duplicated: ' + record.id); }
341
+ ids.add(record.id);
342
+ }
343
+ });
344
+ return errors;
345
+ }
346
+
347
+ /** 'v3' for any spelling of the V3 label, otherwise undefined. */
348
+ function normalizeDsVersion(value) {
349
+ if (typeof value !== 'string') { return undefined; }
350
+ const label = value.trim().toLowerCase();
351
+ return label === DS_VERSION_V3 ? DS_VERSION_V3 : undefined;
352
+ }
353
+
354
+ function isV3(bot) {
355
+ return !!bot && bot.ds_version === DS_VERSION_V3;
356
+ }
357
+
358
+ /** The legacy keys a PATCH of the attributes must not carry any more. */
359
+ function legacyAttributeKeys(attributes) {
360
+ if (!attributes || typeof attributes !== 'object') { return []; }
361
+ return ['dsVersion', 'connectors'].filter((key) => Object.prototype.hasOwnProperty.call(attributes, key));
362
+ }
363
+
364
+ /**
365
+ * A JSON exported before 15/10/2026 carries `attributes.dsVersion` and `attributes.connectors`:
366
+ * they move to `ds_version` and `connectors` (the fields win if both are present). Applies to the
367
+ * sub agents too. Mutates; returns true if it moved anything.
368
+ */
369
+ function promoteLegacyJson(json) {
370
+ if (!json || typeof json !== 'object') { return false; }
371
+ let moved = false;
372
+ const attributes = json.attributes;
373
+ if (attributes && typeof attributes === 'object') {
374
+ if (Object.prototype.hasOwnProperty.call(attributes, 'dsVersion')) {
375
+ if (json.ds_version === undefined) {
376
+ const label = normalizeDsVersion(attributes.dsVersion);
377
+ if (label) { json.ds_version = label; }
378
+ }
379
+ delete attributes.dsVersion;
380
+ moved = true;
381
+ }
382
+ if (Object.prototype.hasOwnProperty.call(attributes, 'connectors')) {
383
+ if (json.connectors === undefined && attributes.connectors && typeof attributes.connectors === 'object') {
384
+ json.connectors = attributes.connectors;
385
+ }
386
+ delete attributes.connectors;
387
+ moved = true;
388
+ }
389
+ }
390
+ if (json.ds_version !== undefined) {
391
+ const label = normalizeDsVersion(json.ds_version);
392
+ if (label) { json.ds_version = label; } else { delete json.ds_version; }
393
+ }
394
+ for (const subagent of Array.isArray(json.subagents) ? json.subagents : []) {
395
+ if (promoteLegacyJson(subagent)) { moved = true; }
396
+ }
397
+ if (moved) { winston.verbose('(V3FlowService) legacy dsVersion/connectors moved out of attributes'); }
398
+ return moved;
399
+ }
400
+
401
+ module.exports = {
402
+ DS_VERSION_V3,
403
+ COLLECTION_VERSION,
404
+ RECORD_KINDS,
405
+ OUTPUT_CATALOG,
406
+ slotsOfIntent,
407
+ ensureTechnicalIds,
408
+ buildConnectors,
409
+ stripDestinations,
410
+ toV3Flow,
411
+ validateConnectors,
412
+ normalizeDsVersion,
413
+ isV3,
414
+ legacyAttributeKeys,
415
+ promoteLegacyJson
416
+ };