callkaro-cli-unofficial 0.4.1 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/accounts.js +30 -3
- package/dist/commands/contacts.js +378 -0
- package/dist/commands/simulations.js +45 -9
- package/dist/commands/update.js +13 -6
- package/dist/commands/workflows.js +458 -0
- package/dist/index.js +101 -1
- package/dist/lib/api.js +149 -14
- package/dist/lib/config.js +59 -4
- package/dist/lib/workflowEdit.js +183 -0
- package/package.json +1 -1
package/dist/lib/api.js
CHANGED
|
@@ -12,30 +12,64 @@ const types_1 = require("./types");
|
|
|
12
12
|
* logic. Public methods map 1:1 to the `/cli/*` endpoints.
|
|
13
13
|
*/
|
|
14
14
|
class Api {
|
|
15
|
-
token;
|
|
16
15
|
monitoringAccountId;
|
|
16
|
+
renewToken;
|
|
17
17
|
baseUrl;
|
|
18
18
|
callBaseUrl;
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
/** Mutable: a renewed token replaces the rejected one for later requests. */
|
|
20
|
+
token;
|
|
21
|
+
constructor(baseUrl, callBaseUrl, token, monitoringAccountId,
|
|
22
|
+
/**
|
|
23
|
+
* Re-issue a rejected token. The `ck_` token is opaque and has no refresh
|
|
24
|
+
* route, so the only way back in is to sign in again — which needs the
|
|
25
|
+
* account's password. Returns the new token, or undefined when the account
|
|
26
|
+
* cannot renew itself.
|
|
27
|
+
*/
|
|
28
|
+
renewToken) {
|
|
21
29
|
this.monitoringAccountId = monitoringAccountId;
|
|
30
|
+
this.renewToken = renewToken;
|
|
22
31
|
this.baseUrl = baseUrl.replace(/\/+$/, '');
|
|
23
32
|
this.callBaseUrl = (callBaseUrl || baseUrl).replace(/\/+$/, '');
|
|
33
|
+
this.token = token;
|
|
24
34
|
}
|
|
25
35
|
request(method, path, body, includeAccount = true) {
|
|
26
36
|
return this.requestTo(this.baseUrl, method, path, body, includeAccount);
|
|
27
37
|
}
|
|
28
38
|
/** Same as request(), but against a specific base URL (e.g. the call cloud). */
|
|
29
39
|
async requestTo(base, method, path, body, includeAccount = true) {
|
|
40
|
+
let res = await this.send(base, method, path, body, includeAccount, this.token);
|
|
41
|
+
// A rejected token is not a failure the user can act on by retrying: the
|
|
42
|
+
// same token gets the same answer. Renew once, silently, when the account
|
|
43
|
+
// has the means to renew itself. The renewed token then serves the rest of
|
|
44
|
+
// the command, so one renewal covers every request in it.
|
|
45
|
+
if (res.status === 401 && this.renewToken) {
|
|
46
|
+
const fresh = await this.renewToken();
|
|
47
|
+
if (fresh) {
|
|
48
|
+
this.token = fresh;
|
|
49
|
+
res = await this.send(base, method, path, body, includeAccount, fresh);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const text = await res.text();
|
|
53
|
+
const data = text ? safeJson(text) : undefined;
|
|
54
|
+
if (!res.ok) {
|
|
55
|
+
throw new types_1.ApiError(res.status, res.status === 401
|
|
56
|
+
? `${data?.msg || 'Your session is no longer valid'} — the stored token was rejected and could not be renewed.\n` +
|
|
57
|
+
' Run `cku login` to sign in again.'
|
|
58
|
+
: data?.msg || data?.message || (typeof data?.error === 'string' ? data.error : undefined) ||
|
|
59
|
+
`Request failed (${res.status})`, parseErrorDetails(data?.errors ?? data?.data?.errors ?? invalidDetails(data?.invalid)));
|
|
60
|
+
}
|
|
61
|
+
return data;
|
|
62
|
+
}
|
|
63
|
+
/** One HTTP round trip, with the given bearer token. */
|
|
64
|
+
async send(base, method, path, body, includeAccount, token) {
|
|
30
65
|
const headers = { 'Content-Type': 'application/json' };
|
|
31
|
-
if (
|
|
32
|
-
headers.Authorization = `Bearer ${
|
|
66
|
+
if (token)
|
|
67
|
+
headers.Authorization = `Bearer ${token}`;
|
|
33
68
|
// Monitoring mode: every request, writes included, acts on the client account.
|
|
34
69
|
if (includeAccount && this.monitoringAccountId)
|
|
35
70
|
headers['X-CallKaro-Account-Id'] = this.monitoringAccountId;
|
|
36
|
-
let res;
|
|
37
71
|
try {
|
|
38
|
-
|
|
72
|
+
return await fetch(`${base}${path}`, {
|
|
39
73
|
method,
|
|
40
74
|
headers,
|
|
41
75
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
@@ -44,13 +78,6 @@ class Api {
|
|
|
44
78
|
catch (err) {
|
|
45
79
|
throw new types_1.ApiError(0, `Cannot reach ${base} (${err.message})`);
|
|
46
80
|
}
|
|
47
|
-
const text = await res.text();
|
|
48
|
-
const data = text ? safeJson(text) : undefined;
|
|
49
|
-
if (!res.ok) {
|
|
50
|
-
throw new types_1.ApiError(res.status, data?.msg || data?.message || (typeof data?.error === 'string' ? data.error : undefined) ||
|
|
51
|
-
`Request failed (${res.status})`, parseErrorDetails(data?.errors));
|
|
52
|
-
}
|
|
53
|
-
return data;
|
|
54
81
|
}
|
|
55
82
|
// --- auth endpoints -------------------------------------------------------
|
|
56
83
|
registerStart(email) { return this.request('POST', '/cli/auth/register/start', { email }); }
|
|
@@ -66,6 +93,98 @@ class Api {
|
|
|
66
93
|
me() { return this.request('GET', '/cli/auth/me'); }
|
|
67
94
|
/** Client accounts assigned to this user. Sent without the monitoring header: it lists, not acts. */
|
|
68
95
|
listClientAccounts() { return this.request('GET', '/cli/auth/accounts', undefined, false); }
|
|
96
|
+
// --- workflows ------------------------------------------------------------
|
|
97
|
+
// The workflow controllers answer { success, message, data }; callers get `data`.
|
|
98
|
+
async workflow(method, path, body) {
|
|
99
|
+
return (await this.request(method, `/cli/workflows${path}`, body)).data;
|
|
100
|
+
}
|
|
101
|
+
listWorkflows(search) {
|
|
102
|
+
const q = new URLSearchParams({ limit: '100', type: 'normal' });
|
|
103
|
+
if (search)
|
|
104
|
+
q.set('search', search);
|
|
105
|
+
return this.workflow('GET', `?${q}`);
|
|
106
|
+
}
|
|
107
|
+
getWorkflow(id) { return this.workflow('GET', `/${id}`); }
|
|
108
|
+
createWorkflow(payload) { return this.workflow('POST', '', payload); }
|
|
109
|
+
updateWorkflow(id, payload) { return this.workflow('PUT', `/${id}`, payload); }
|
|
110
|
+
deleteWorkflow(id) { return this.workflow('DELETE', `/${id}`); }
|
|
111
|
+
duplicateWorkflow(id, workFlowName) {
|
|
112
|
+
return this.workflow('POST', `/${id}/duplicate`, { workFlowName });
|
|
113
|
+
}
|
|
114
|
+
validateWorkflowGraph(graph) { return this.workflow('POST', '/validate', { graph }); }
|
|
115
|
+
listWorkflowLeads(id, query = {}) {
|
|
116
|
+
return this.workflow('GET', `/${id}/leads${qs(query)}`);
|
|
117
|
+
}
|
|
118
|
+
getWorkflowLead(id, phone) {
|
|
119
|
+
return this.workflow('GET', `/${id}/leads/${encodeURIComponent(phone)}`);
|
|
120
|
+
}
|
|
121
|
+
getWorkflowRunByPhone(id, phone) {
|
|
122
|
+
return this.workflow('GET', `/${id}/runs/by-phone/${encodeURIComponent(phone)}`);
|
|
123
|
+
}
|
|
124
|
+
/** Every call to/from a number inside the workflow. Answers { success, calls }, not { data }. */
|
|
125
|
+
workflowJourney(id, phone) {
|
|
126
|
+
return this.request('GET', `/cli/workflows/${id}/journey/${encodeURIComponent(phone)}`);
|
|
127
|
+
}
|
|
128
|
+
// --- contacts (CRM) -------------------------------------------------------
|
|
129
|
+
// Unlike workflows, these controllers answer plain JSON ({ msg, contacts, ... }).
|
|
130
|
+
listContacts(query = {}) { return this.request('GET', `/cli/contacts${qs(query)}`); }
|
|
131
|
+
getContact(phone) { return this.request('GET', `/cli/contacts/detail/${encodeURIComponent(phone)}`); }
|
|
132
|
+
/** Upsert: creates the contact, or merges into an existing one. */
|
|
133
|
+
saveContact(body) { return this.request('POST', '/cli/contacts', body); }
|
|
134
|
+
updateContactAttributes(phone, attributes) {
|
|
135
|
+
return this.request('PATCH', `/cli/contacts/detail/${encodeURIComponent(phone)}`, { attributes });
|
|
136
|
+
}
|
|
137
|
+
deleteContact(phone) { return this.request('DELETE', `/cli/contacts/detail/${encodeURIComponent(phone)}`); }
|
|
138
|
+
assignContacts(body) { return this.request('POST', '/cli/contacts/assign', body); }
|
|
139
|
+
listContactAttributes() { return this.request('GET', '/cli/contacts/attributes'); }
|
|
140
|
+
saveContactAttribute(definition) { return this.request('POST', '/cli/contacts/attributes', definition); }
|
|
141
|
+
deleteContactAttribute(name) {
|
|
142
|
+
return this.request('DELETE', `/cli/contacts/attributes/${encodeURIComponent(name)}`);
|
|
143
|
+
}
|
|
144
|
+
/** CSV upload (multipart). Columns must match defined attributes unless ignoreUnknown. */
|
|
145
|
+
async uploadContacts(filePath, ignoreUnknown) {
|
|
146
|
+
const form = new FormData();
|
|
147
|
+
form.append('file', new Blob([(0, fs_1.readFileSync)(filePath)], { type: 'text/csv' }), (0, path_1.basename)(filePath));
|
|
148
|
+
if (ignoreUnknown)
|
|
149
|
+
form.append('ignoreUnknownColumns', 'true');
|
|
150
|
+
const headers = {};
|
|
151
|
+
if (this.token)
|
|
152
|
+
headers.Authorization = `Bearer ${this.token}`;
|
|
153
|
+
if (this.monitoringAccountId)
|
|
154
|
+
headers['X-CallKaro-Account-Id'] = this.monitoringAccountId;
|
|
155
|
+
const res = await fetch(`${this.baseUrl}/cli/contacts/upload`, { method: 'POST', headers, body: form });
|
|
156
|
+
const text = await res.text();
|
|
157
|
+
const data = text ? safeJson(text) : {};
|
|
158
|
+
if (!res.ok) {
|
|
159
|
+
const unknown = data?.unknownColumns || [];
|
|
160
|
+
const hint = unknown.length
|
|
161
|
+
? ' Pass --ignore-unknown to drop them, or define them with `cku contacts attributes set`.'
|
|
162
|
+
: '';
|
|
163
|
+
throw new types_1.ApiError(res.status, `${data?.msg || `Request failed (${res.status})`}${hint}`, parseErrorDetails(data?.errors ?? data?.data?.errors ?? invalidDetails(data?.invalid)));
|
|
164
|
+
}
|
|
165
|
+
return data;
|
|
166
|
+
}
|
|
167
|
+
/** CSV text for the filtered set, or { processing } when the server emails it instead. */
|
|
168
|
+
async exportContacts(query = {}) {
|
|
169
|
+
const headers = {};
|
|
170
|
+
if (this.token)
|
|
171
|
+
headers.Authorization = `Bearer ${this.token}`;
|
|
172
|
+
if (this.monitoringAccountId)
|
|
173
|
+
headers['X-CallKaro-Account-Id'] = this.monitoringAccountId;
|
|
174
|
+
let res;
|
|
175
|
+
try {
|
|
176
|
+
res = await fetch(`${this.baseUrl}/cli/contacts/export${qs(query)}`, { headers });
|
|
177
|
+
}
|
|
178
|
+
catch (err) {
|
|
179
|
+
throw new types_1.ApiError(0, `Cannot reach ${this.baseUrl} (${err.message})`);
|
|
180
|
+
}
|
|
181
|
+
const text = await res.text();
|
|
182
|
+
if (!res.ok)
|
|
183
|
+
throw new types_1.ApiError(res.status, safeJson(text)?.msg || `Request failed (${res.status})`);
|
|
184
|
+
if ((res.headers.get('content-type') || '').includes('application/json'))
|
|
185
|
+
return safeJson(text);
|
|
186
|
+
return { csv: text };
|
|
187
|
+
}
|
|
69
188
|
// --- secrets --------------------------------------------------------------
|
|
70
189
|
listSecrets() { return this.request('GET', '/cli/secrets'); }
|
|
71
190
|
saveSecrets(secrets) { return this.request('PUT', '/cli/secrets', { secrets }); }
|
|
@@ -179,6 +298,8 @@ class Api {
|
|
|
179
298
|
const headers = {};
|
|
180
299
|
if (this.token)
|
|
181
300
|
headers.Authorization = `Bearer ${this.token}`;
|
|
301
|
+
if (this.monitoringAccountId)
|
|
302
|
+
headers['X-CallKaro-Account-Id'] = this.monitoringAccountId;
|
|
182
303
|
const res = await fetch(`${this.baseUrl}/cli/calls/export${qs(query)}`, { headers });
|
|
183
304
|
const contentType = res.headers.get('content-type') || '';
|
|
184
305
|
if (contentType.includes('application/json')) {
|
|
@@ -231,6 +352,8 @@ class Api {
|
|
|
231
352
|
// Let fetch set the multipart boundary — do not set Content-Type here.
|
|
232
353
|
if (this.token)
|
|
233
354
|
headers.Authorization = `Bearer ${this.token}`;
|
|
355
|
+
if (this.monitoringAccountId)
|
|
356
|
+
headers['X-CallKaro-Account-Id'] = this.monitoringAccountId;
|
|
234
357
|
const res = await fetch(url, { method: 'POST', headers, body: form });
|
|
235
358
|
const text = await res.text();
|
|
236
359
|
const data = text ? safeJson(text) : {};
|
|
@@ -246,6 +369,7 @@ class Api {
|
|
|
246
369
|
return this.request('GET', `/cli/voices/provider/${encodeURIComponent(provider)}${qs(params)}`);
|
|
247
370
|
}
|
|
248
371
|
// --- simulations ----------------------------------------------------------
|
|
372
|
+
simulationModels() { return this.request('GET', '/cli/simulations/models'); }
|
|
249
373
|
createTestCase(body) { return this.request('POST', '/cli/simulations/test-cases', body); }
|
|
250
374
|
listTestCases(agentId) {
|
|
251
375
|
return this.request('GET', `/cli/simulations/test-cases${qs({ agentId })}`);
|
|
@@ -289,6 +413,17 @@ function safeJson(text) {
|
|
|
289
413
|
return { msg: text };
|
|
290
414
|
}
|
|
291
415
|
}
|
|
416
|
+
/** The CRM's per-value refusals: [{ attribute, reason, value }] -> one line each. */
|
|
417
|
+
function invalidDetails(value) {
|
|
418
|
+
if (!Array.isArray(value))
|
|
419
|
+
return undefined;
|
|
420
|
+
return value.flatMap((v) => v && typeof v === 'object' && typeof v.reason === 'string'
|
|
421
|
+
? [{
|
|
422
|
+
message: `${v.reason}${v.value !== undefined ? ` (got "${v.value}")` : ''}`,
|
|
423
|
+
path: v.attribute || v.column,
|
|
424
|
+
}]
|
|
425
|
+
: []);
|
|
426
|
+
}
|
|
292
427
|
function parseErrorDetails(value) {
|
|
293
428
|
if (!Array.isArray(value))
|
|
294
429
|
return [];
|
package/dist/lib/config.js
CHANGED
|
@@ -30,6 +30,7 @@ exports.saveLogin = saveLogin;
|
|
|
30
30
|
exports.monitoringAccountId = monitoringAccountId;
|
|
31
31
|
exports.setMonitoring = setMonitoring;
|
|
32
32
|
exports.getClient = getClient;
|
|
33
|
+
exports.renewTokenFor = renewTokenFor;
|
|
33
34
|
const fs_1 = require("fs");
|
|
34
35
|
const os_1 = require("os");
|
|
35
36
|
const path_1 = require("path");
|
|
@@ -238,7 +239,16 @@ function saveAccount(email, session) {
|
|
|
238
239
|
// so without this a later `cku login` would silently drop a dashboard session
|
|
239
240
|
// the account already holds.
|
|
240
241
|
const dashboard = session.dashboard ?? loadAccount(slug).dashboard;
|
|
241
|
-
|
|
242
|
+
// The password is the one field carried across a re-login: it is what lets a
|
|
243
|
+
// rejected token be re-issued without the user signing in again.
|
|
244
|
+
const password = session.password ?? loadAccount(slug).password;
|
|
245
|
+
writeJson(accountPath(slug), {
|
|
246
|
+
...session,
|
|
247
|
+
email,
|
|
248
|
+
savedAt: new Date().toISOString(),
|
|
249
|
+
...(dashboard ? { dashboard } : {}),
|
|
250
|
+
...(password ? { password } : {}),
|
|
251
|
+
});
|
|
242
252
|
setActiveAccount(email);
|
|
243
253
|
return slug;
|
|
244
254
|
}
|
|
@@ -323,8 +333,10 @@ function clearSession() {
|
|
|
323
333
|
if (!(0, fs_1.existsSync)(path))
|
|
324
334
|
return undefined;
|
|
325
335
|
const { email, savedAt, dashboard } = loadAccount(slug);
|
|
326
|
-
// Dropping the ck_ token is what logout means.
|
|
327
|
-
//
|
|
336
|
+
// Dropping the ck_ token is what logout means. The password goes with it: it
|
|
337
|
+
// exists only to re-issue that token, so keeping it would let a logged-out
|
|
338
|
+
// account sign itself back in. A dashboard session is a separate credential
|
|
339
|
+
// and is left in place — `cku dashboard logout` removes it.
|
|
328
340
|
writeJson(path, { email, savedAt, ...(dashboard ? { dashboard } : {}) });
|
|
329
341
|
return email;
|
|
330
342
|
}
|
|
@@ -365,5 +377,48 @@ function setMonitoring(monitoring) {
|
|
|
365
377
|
/** Build the API client from the endpoint URLs + resolved token. */
|
|
366
378
|
function getClient() {
|
|
367
379
|
const cfg = loadConfig();
|
|
368
|
-
return {
|
|
380
|
+
return {
|
|
381
|
+
api: new api_1.Api(backendUrl(), callUrl(), cfg.token, monitoringAccountId(cfg), renewTokenFor(cfg)),
|
|
382
|
+
cfg,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Sign in again for an account that stored its password, and persist the new
|
|
387
|
+
* token.
|
|
388
|
+
*
|
|
389
|
+
* The `ck_` token is opaque and has no refresh route, so a token the server
|
|
390
|
+
* stops accepting leaves no way back in except a fresh sign-in. An account that
|
|
391
|
+
* stored its password renews itself; one that did not cannot, and the caller
|
|
392
|
+
* gets undefined so the error can name `cku login`.
|
|
393
|
+
*
|
|
394
|
+
* Under CK_TOKEN there is no stored account in effect, so nothing is renewed:
|
|
395
|
+
* the environment owns the credential.
|
|
396
|
+
*/
|
|
397
|
+
function renewTokenFor(cfg = loadConfig()) {
|
|
398
|
+
if (process.env.CK_TOKEN)
|
|
399
|
+
return undefined;
|
|
400
|
+
const email = cfg.email;
|
|
401
|
+
const password = cfg.password;
|
|
402
|
+
if (!email || !password)
|
|
403
|
+
return undefined;
|
|
404
|
+
return async () => {
|
|
405
|
+
try {
|
|
406
|
+
const res = await fetch(`${backendUrl()}/cli/auth/login`, {
|
|
407
|
+
method: 'POST',
|
|
408
|
+
headers: { 'Content-Type': 'application/json' },
|
|
409
|
+
body: JSON.stringify({ email, password }),
|
|
410
|
+
});
|
|
411
|
+
const data = (await res.json().catch(() => ({})));
|
|
412
|
+
if (!res.ok || !data.token)
|
|
413
|
+
return undefined;
|
|
414
|
+
// Persist the new token, keeping everything else the account holds.
|
|
415
|
+
const slug = slugFor(email);
|
|
416
|
+
const current = loadAccount(slug);
|
|
417
|
+
writeJson(accountPath(slug), { ...current, email, token: data.token, savedAt: new Date().toISOString() });
|
|
418
|
+
return data.token;
|
|
419
|
+
}
|
|
420
|
+
catch {
|
|
421
|
+
return undefined;
|
|
422
|
+
}
|
|
423
|
+
};
|
|
369
424
|
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.handlesOf = exports.ADDABLE_TYPES = void 0;
|
|
4
|
+
exports.addNode = addNode;
|
|
5
|
+
exports.setNode = setNode;
|
|
6
|
+
exports.removeNode = removeNode;
|
|
7
|
+
exports.connect = connect;
|
|
8
|
+
exports.disconnect = disconnect;
|
|
9
|
+
/**
|
|
10
|
+
* Pure edits on a workflow graph. Each takes a graph, mutates it in place and
|
|
11
|
+
* returns a one-line summary; none talks to the server. The server still
|
|
12
|
+
* validates on save, so these only guard what would make an edit meaningless
|
|
13
|
+
* (unknown ids, a handle that does not exist) and keep the shapes the canvas
|
|
14
|
+
* writes: one edge per (from, fromHandle), exit ids on `conditions` nodes.
|
|
15
|
+
*/
|
|
16
|
+
const DEFAULT_HANDLE = 'out';
|
|
17
|
+
const X_STEP = 320;
|
|
18
|
+
// Mirrors NODE_META in the dashboard's WorkflowCanvas.jsx.
|
|
19
|
+
const DEFAULT_NAMES = {
|
|
20
|
+
end: 'End',
|
|
21
|
+
'send-call': 'Send Call',
|
|
22
|
+
'wa-template': 'WhatsApp Template',
|
|
23
|
+
'wa-message': 'WhatsApp Message',
|
|
24
|
+
wait: 'Wait',
|
|
25
|
+
conditions: 'Conditions',
|
|
26
|
+
'api-basic': 'API Call',
|
|
27
|
+
'api-advanced': 'Custom Function',
|
|
28
|
+
'update-lead': 'Update Contact',
|
|
29
|
+
};
|
|
30
|
+
exports.ADDABLE_TYPES = Object.keys(DEFAULT_NAMES);
|
|
31
|
+
// Fields with no sensible default: the server refuses the node until they are set, so say so up front.
|
|
32
|
+
const NEEDS = {
|
|
33
|
+
'send-call': { fields: ['agentId'], example: '{"agentId":"<id from cku agents list>"}' },
|
|
34
|
+
'wa-template': { fields: ['templateId'], example: 'a template entry (see cku workflows schema)' },
|
|
35
|
+
'wa-message': { fields: ['message'], example: '{"message":"Hello","phoneNumberId":"…","displayPhoneNumber":"…","provider":"whatsapp","parameters":[]}' },
|
|
36
|
+
'api-basic': { fields: ['url'], example: '{"method":"POST","url":"https://…"}' },
|
|
37
|
+
'update-lead': { fields: ['updates'], example: '{"updates":[{"attribute":"stage","source":"static_value","value":"contacted"}]}' },
|
|
38
|
+
};
|
|
39
|
+
let counter = 0;
|
|
40
|
+
const stamp = () => `${Date.now()}${counter++ % 10}`;
|
|
41
|
+
const exitId = () => `o_${Math.random().toString(36).slice(2, 10)}`;
|
|
42
|
+
const defaultsFor = (type) => {
|
|
43
|
+
switch (type) {
|
|
44
|
+
case 'send-call':
|
|
45
|
+
return { retries: 0, retryGapMinutes: 30, retryGaps: [] };
|
|
46
|
+
case 'wait':
|
|
47
|
+
return { minutes: 15 };
|
|
48
|
+
case 'update-lead':
|
|
49
|
+
return { updates: [{ attribute: '', source: 'post_call_variables', value: '' }] };
|
|
50
|
+
case 'api-advanced':
|
|
51
|
+
return { code: 'async function handler(context) {\n // Everything is on context, e.g.\n // context.call_metadata.lead_id\n}' };
|
|
52
|
+
// The dashboard seeds an incomplete rule and lets the canvas hold it as a draft; a CLI edit
|
|
53
|
+
// saves at once, so this starts as a complete one ("did the call connect?"). Change it with set-node.
|
|
54
|
+
case 'conditions':
|
|
55
|
+
return {
|
|
56
|
+
exits: [
|
|
57
|
+
{ id: exitId(), label: 'connected', next: '', condition: { source: 'connected_call', field: '', op: 'eq', value: true } },
|
|
58
|
+
{ id: exitId(), label: 'otherwise', next: '' },
|
|
59
|
+
],
|
|
60
|
+
};
|
|
61
|
+
default:
|
|
62
|
+
return {};
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
const nodeOf = (g, id) => {
|
|
66
|
+
const node = g.nodes.find((n) => n.id === id);
|
|
67
|
+
if (!node)
|
|
68
|
+
throw new Error(`No node with id "${id}". Ids: ${g.nodes.map((n) => n.id).join(', ')}`);
|
|
69
|
+
return node;
|
|
70
|
+
};
|
|
71
|
+
const handlesOf = (node) => node.type === 'conditions' ? (node.exits || []).map((e) => e.id) : [DEFAULT_HANDLE];
|
|
72
|
+
exports.handlesOf = handlesOf;
|
|
73
|
+
/** The handle to use when the caller named none: the only one, or an error listing the choices. */
|
|
74
|
+
function resolveHandle(node, handle) {
|
|
75
|
+
const handles = (0, exports.handlesOf)(node);
|
|
76
|
+
if (handle) {
|
|
77
|
+
if (!handles.includes(handle)) {
|
|
78
|
+
throw new Error(`"${node.id}" has no handle "${handle}". Handles: ${handles.join(', ')}`);
|
|
79
|
+
}
|
|
80
|
+
return handle;
|
|
81
|
+
}
|
|
82
|
+
if (handles.length !== 1) {
|
|
83
|
+
throw new Error(`"${node.id}" has several branches; pass --handle. Handles: ${handles.join(', ')}`);
|
|
84
|
+
}
|
|
85
|
+
return handles[0];
|
|
86
|
+
}
|
|
87
|
+
const edgeFrom = (g, from, handle) => g.edges.find((e) => e.from === from && e.fromHandle === handle);
|
|
88
|
+
/** One edge per (from, fromHandle): connecting replaces whatever left that handle. */
|
|
89
|
+
function setEdge(g, from, handle, to) {
|
|
90
|
+
g.edges = g.edges.filter((e) => !(e.from === from && e.fromHandle === handle));
|
|
91
|
+
g.edges.push({ id: `edge-${stamp()}`, from, fromHandle: handle, to });
|
|
92
|
+
}
|
|
93
|
+
function addNode(g, opts) {
|
|
94
|
+
if (!exports.ADDABLE_TYPES.includes(opts.type)) {
|
|
95
|
+
throw new Error(`--type must be one of: ${exports.ADDABLE_TYPES.join(', ')} (a workflow has exactly one start, which already exists)`);
|
|
96
|
+
}
|
|
97
|
+
const anchor = opts.after ? nodeOf(g, opts.after) : undefined;
|
|
98
|
+
if (anchor?.type === 'end')
|
|
99
|
+
throw new Error(`Nothing can follow "${anchor.id}": a run stops at an End node.`);
|
|
100
|
+
const afterHandle = anchor ? resolveHandle(anchor, opts.handle) : undefined;
|
|
101
|
+
const id = `${opts.type}-${stamp()}`;
|
|
102
|
+
const node = {
|
|
103
|
+
id,
|
|
104
|
+
type: opts.type,
|
|
105
|
+
name: opts.name ?? DEFAULT_NAMES[opts.type],
|
|
106
|
+
...defaultsFor(opts.type),
|
|
107
|
+
...(opts.set || {}),
|
|
108
|
+
x: opts.x ?? (anchor ? Math.round((anchor.x ?? 0) + X_STEP) : Math.round(Math.max(0, ...g.nodes.map((n) => n.x ?? 0)) + X_STEP)),
|
|
109
|
+
y: opts.y ?? Math.round(anchor?.y ?? g.nodes[0]?.y ?? 180),
|
|
110
|
+
};
|
|
111
|
+
node.id = id;
|
|
112
|
+
node.type = opts.type;
|
|
113
|
+
const need = NEEDS[opts.type];
|
|
114
|
+
const empty = (v) => v === undefined || v === '' || (Array.isArray(v) && (v.length === 0 || v.every((r) => !r?.attribute)));
|
|
115
|
+
if (need && need.fields.some((f) => empty(node[f]))) {
|
|
116
|
+
throw new Error(`A ${opts.type} node needs ${need.fields.join(', ')}: pass --set, e.g. --set '${need.example}'`);
|
|
117
|
+
}
|
|
118
|
+
g.nodes.push(node);
|
|
119
|
+
let note = '';
|
|
120
|
+
if (anchor && afterHandle) {
|
|
121
|
+
const previous = edgeFrom(g, anchor.id, afterHandle)?.to;
|
|
122
|
+
setEdge(g, anchor.id, afterHandle, id);
|
|
123
|
+
if (previous) {
|
|
124
|
+
if (opts.type === 'end') {
|
|
125
|
+
note = ` "${previous}" is no longer connected (an End has nothing after it).`;
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
const exits = node.exits || [];
|
|
129
|
+
const out = node.type === 'conditions' ? (exits.find((e) => !e.condition)?.id ?? exits[exits.length - 1].id) : DEFAULT_HANDLE;
|
|
130
|
+
setEdge(g, id, out, previous);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return `Added ${opts.type} node "${id}"${anchor ? ` after "${anchor.id}"` : ' (not connected yet; use `connect`)'}.${note}`;
|
|
135
|
+
}
|
|
136
|
+
function setNode(g, nodeId, fields) {
|
|
137
|
+
const node = nodeOf(g, nodeId);
|
|
138
|
+
const { id: _id, type: _type, ...rest } = fields;
|
|
139
|
+
if ((_id !== undefined && _id !== nodeId) || (_type !== undefined && _type !== node.type)) {
|
|
140
|
+
throw new Error("A node's id and type cannot be changed; remove it and add a new one.");
|
|
141
|
+
}
|
|
142
|
+
Object.assign(node, rest);
|
|
143
|
+
const live = (0, exports.handlesOf)(node);
|
|
144
|
+
const before = g.edges.length;
|
|
145
|
+
g.edges = g.edges.filter((e) => e.from !== nodeId || live.includes(e.fromHandle));
|
|
146
|
+
const dropped = before - g.edges.length;
|
|
147
|
+
return `Updated "${nodeId}".${dropped ? ` Removed ${dropped} edge(s) from branches that no longer exist.` : ''}`;
|
|
148
|
+
}
|
|
149
|
+
function removeNode(g, nodeId, reconnect = false) {
|
|
150
|
+
const node = nodeOf(g, nodeId);
|
|
151
|
+
if (node.type === 'start')
|
|
152
|
+
throw new Error('The Start node cannot be removed.');
|
|
153
|
+
const next = node.type !== 'conditions' ? edgeFrom(g, nodeId, DEFAULT_HANDLE)?.to : undefined;
|
|
154
|
+
const incoming = g.edges.filter((e) => e.to === nodeId);
|
|
155
|
+
if (reconnect && !next) {
|
|
156
|
+
throw new Error(`--reconnect needs "${nodeId}" to have a single next step to bridge to; it has none.`);
|
|
157
|
+
}
|
|
158
|
+
g.nodes = g.nodes.filter((n) => n.id !== nodeId);
|
|
159
|
+
g.edges = g.edges.filter((e) => e.from !== nodeId && e.to !== nodeId);
|
|
160
|
+
if (reconnect && next)
|
|
161
|
+
incoming.forEach((e) => setEdge(g, e.from, e.fromHandle, next));
|
|
162
|
+
return `Removed "${nodeId}" and its edges${reconnect && next ? `; ${incoming.length} incoming edge(s) now lead to "${next}"` : ''}.`;
|
|
163
|
+
}
|
|
164
|
+
function connect(g, from, to, handle) {
|
|
165
|
+
const source = nodeOf(g, from);
|
|
166
|
+
const target = nodeOf(g, to);
|
|
167
|
+
if (source.type === 'end')
|
|
168
|
+
throw new Error(`Nothing can follow "${from}": a run stops at an End node.`);
|
|
169
|
+
if (target.type === 'start')
|
|
170
|
+
throw new Error(`Nothing can lead into "${to}": a run begins at Start.`);
|
|
171
|
+
const h = resolveHandle(source, handle);
|
|
172
|
+
const replaced = edgeFrom(g, from, h)?.to;
|
|
173
|
+
setEdge(g, from, h, to);
|
|
174
|
+
return `Connected "${from}" (${h}) → "${to}".${replaced && replaced !== to ? ` Replaced the old link to "${replaced}".` : ''}`;
|
|
175
|
+
}
|
|
176
|
+
function disconnect(g, from, handle) {
|
|
177
|
+
const h = resolveHandle(nodeOf(g, from), handle);
|
|
178
|
+
const edge = edgeFrom(g, from, h);
|
|
179
|
+
if (!edge)
|
|
180
|
+
throw new Error(`"${from}" (${h}) is not connected to anything.`);
|
|
181
|
+
g.edges = g.edges.filter((e) => e !== edge);
|
|
182
|
+
return `Disconnected "${from}" (${h}) from "${edge.to}".`;
|
|
183
|
+
}
|