callkaro-cli-unofficial 0.4.0 → 0.5.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/README.md +276 -264
- 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 +100 -1
- package/dist/lib/api.js +109 -1
- package/dist/lib/workflowEdit.js +183 -0
- package/package.json +1 -1
package/dist/lib/api.js
CHANGED
|
@@ -48,7 +48,7 @@ class Api {
|
|
|
48
48
|
const data = text ? safeJson(text) : undefined;
|
|
49
49
|
if (!res.ok) {
|
|
50
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));
|
|
51
|
+
`Request failed (${res.status})`, parseErrorDetails(data?.errors ?? data?.data?.errors ?? invalidDetails(data?.invalid)));
|
|
52
52
|
}
|
|
53
53
|
return data;
|
|
54
54
|
}
|
|
@@ -66,6 +66,98 @@ class Api {
|
|
|
66
66
|
me() { return this.request('GET', '/cli/auth/me'); }
|
|
67
67
|
/** Client accounts assigned to this user. Sent without the monitoring header: it lists, not acts. */
|
|
68
68
|
listClientAccounts() { return this.request('GET', '/cli/auth/accounts', undefined, false); }
|
|
69
|
+
// --- workflows ------------------------------------------------------------
|
|
70
|
+
// The workflow controllers answer { success, message, data }; callers get `data`.
|
|
71
|
+
async workflow(method, path, body) {
|
|
72
|
+
return (await this.request(method, `/cli/workflows${path}`, body)).data;
|
|
73
|
+
}
|
|
74
|
+
listWorkflows(search) {
|
|
75
|
+
const q = new URLSearchParams({ limit: '100', type: 'normal' });
|
|
76
|
+
if (search)
|
|
77
|
+
q.set('search', search);
|
|
78
|
+
return this.workflow('GET', `?${q}`);
|
|
79
|
+
}
|
|
80
|
+
getWorkflow(id) { return this.workflow('GET', `/${id}`); }
|
|
81
|
+
createWorkflow(payload) { return this.workflow('POST', '', payload); }
|
|
82
|
+
updateWorkflow(id, payload) { return this.workflow('PUT', `/${id}`, payload); }
|
|
83
|
+
deleteWorkflow(id) { return this.workflow('DELETE', `/${id}`); }
|
|
84
|
+
duplicateWorkflow(id, workFlowName) {
|
|
85
|
+
return this.workflow('POST', `/${id}/duplicate`, { workFlowName });
|
|
86
|
+
}
|
|
87
|
+
validateWorkflowGraph(graph) { return this.workflow('POST', '/validate', { graph }); }
|
|
88
|
+
listWorkflowLeads(id, query = {}) {
|
|
89
|
+
return this.workflow('GET', `/${id}/leads${qs(query)}`);
|
|
90
|
+
}
|
|
91
|
+
getWorkflowLead(id, phone) {
|
|
92
|
+
return this.workflow('GET', `/${id}/leads/${encodeURIComponent(phone)}`);
|
|
93
|
+
}
|
|
94
|
+
getWorkflowRunByPhone(id, phone) {
|
|
95
|
+
return this.workflow('GET', `/${id}/runs/by-phone/${encodeURIComponent(phone)}`);
|
|
96
|
+
}
|
|
97
|
+
/** Every call to/from a number inside the workflow. Answers { success, calls }, not { data }. */
|
|
98
|
+
workflowJourney(id, phone) {
|
|
99
|
+
return this.request('GET', `/cli/workflows/${id}/journey/${encodeURIComponent(phone)}`);
|
|
100
|
+
}
|
|
101
|
+
// --- contacts (CRM) -------------------------------------------------------
|
|
102
|
+
// Unlike workflows, these controllers answer plain JSON ({ msg, contacts, ... }).
|
|
103
|
+
listContacts(query = {}) { return this.request('GET', `/cli/contacts${qs(query)}`); }
|
|
104
|
+
getContact(phone) { return this.request('GET', `/cli/contacts/detail/${encodeURIComponent(phone)}`); }
|
|
105
|
+
/** Upsert: creates the contact, or merges into an existing one. */
|
|
106
|
+
saveContact(body) { return this.request('POST', '/cli/contacts', body); }
|
|
107
|
+
updateContactAttributes(phone, attributes) {
|
|
108
|
+
return this.request('PATCH', `/cli/contacts/detail/${encodeURIComponent(phone)}`, { attributes });
|
|
109
|
+
}
|
|
110
|
+
deleteContact(phone) { return this.request('DELETE', `/cli/contacts/detail/${encodeURIComponent(phone)}`); }
|
|
111
|
+
assignContacts(body) { return this.request('POST', '/cli/contacts/assign', body); }
|
|
112
|
+
listContactAttributes() { return this.request('GET', '/cli/contacts/attributes'); }
|
|
113
|
+
saveContactAttribute(definition) { return this.request('POST', '/cli/contacts/attributes', definition); }
|
|
114
|
+
deleteContactAttribute(name) {
|
|
115
|
+
return this.request('DELETE', `/cli/contacts/attributes/${encodeURIComponent(name)}`);
|
|
116
|
+
}
|
|
117
|
+
/** CSV upload (multipart). Columns must match defined attributes unless ignoreUnknown. */
|
|
118
|
+
async uploadContacts(filePath, ignoreUnknown) {
|
|
119
|
+
const form = new FormData();
|
|
120
|
+
form.append('file', new Blob([(0, fs_1.readFileSync)(filePath)], { type: 'text/csv' }), (0, path_1.basename)(filePath));
|
|
121
|
+
if (ignoreUnknown)
|
|
122
|
+
form.append('ignoreUnknownColumns', 'true');
|
|
123
|
+
const headers = {};
|
|
124
|
+
if (this.token)
|
|
125
|
+
headers.Authorization = `Bearer ${this.token}`;
|
|
126
|
+
if (this.monitoringAccountId)
|
|
127
|
+
headers['X-CallKaro-Account-Id'] = this.monitoringAccountId;
|
|
128
|
+
const res = await fetch(`${this.baseUrl}/cli/contacts/upload`, { method: 'POST', headers, body: form });
|
|
129
|
+
const text = await res.text();
|
|
130
|
+
const data = text ? safeJson(text) : {};
|
|
131
|
+
if (!res.ok) {
|
|
132
|
+
const unknown = data?.unknownColumns || [];
|
|
133
|
+
const hint = unknown.length
|
|
134
|
+
? ' Pass --ignore-unknown to drop them, or define them with `cku contacts attributes set`.'
|
|
135
|
+
: '';
|
|
136
|
+
throw new types_1.ApiError(res.status, `${data?.msg || `Request failed (${res.status})`}${hint}`, parseErrorDetails(data?.errors ?? data?.data?.errors ?? invalidDetails(data?.invalid)));
|
|
137
|
+
}
|
|
138
|
+
return data;
|
|
139
|
+
}
|
|
140
|
+
/** CSV text for the filtered set, or { processing } when the server emails it instead. */
|
|
141
|
+
async exportContacts(query = {}) {
|
|
142
|
+
const headers = {};
|
|
143
|
+
if (this.token)
|
|
144
|
+
headers.Authorization = `Bearer ${this.token}`;
|
|
145
|
+
if (this.monitoringAccountId)
|
|
146
|
+
headers['X-CallKaro-Account-Id'] = this.monitoringAccountId;
|
|
147
|
+
let res;
|
|
148
|
+
try {
|
|
149
|
+
res = await fetch(`${this.baseUrl}/cli/contacts/export${qs(query)}`, { headers });
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
throw new types_1.ApiError(0, `Cannot reach ${this.baseUrl} (${err.message})`);
|
|
153
|
+
}
|
|
154
|
+
const text = await res.text();
|
|
155
|
+
if (!res.ok)
|
|
156
|
+
throw new types_1.ApiError(res.status, safeJson(text)?.msg || `Request failed (${res.status})`);
|
|
157
|
+
if ((res.headers.get('content-type') || '').includes('application/json'))
|
|
158
|
+
return safeJson(text);
|
|
159
|
+
return { csv: text };
|
|
160
|
+
}
|
|
69
161
|
// --- secrets --------------------------------------------------------------
|
|
70
162
|
listSecrets() { return this.request('GET', '/cli/secrets'); }
|
|
71
163
|
saveSecrets(secrets) { return this.request('PUT', '/cli/secrets', { secrets }); }
|
|
@@ -179,6 +271,8 @@ class Api {
|
|
|
179
271
|
const headers = {};
|
|
180
272
|
if (this.token)
|
|
181
273
|
headers.Authorization = `Bearer ${this.token}`;
|
|
274
|
+
if (this.monitoringAccountId)
|
|
275
|
+
headers['X-CallKaro-Account-Id'] = this.monitoringAccountId;
|
|
182
276
|
const res = await fetch(`${this.baseUrl}/cli/calls/export${qs(query)}`, { headers });
|
|
183
277
|
const contentType = res.headers.get('content-type') || '';
|
|
184
278
|
if (contentType.includes('application/json')) {
|
|
@@ -231,6 +325,8 @@ class Api {
|
|
|
231
325
|
// Let fetch set the multipart boundary — do not set Content-Type here.
|
|
232
326
|
if (this.token)
|
|
233
327
|
headers.Authorization = `Bearer ${this.token}`;
|
|
328
|
+
if (this.monitoringAccountId)
|
|
329
|
+
headers['X-CallKaro-Account-Id'] = this.monitoringAccountId;
|
|
234
330
|
const res = await fetch(url, { method: 'POST', headers, body: form });
|
|
235
331
|
const text = await res.text();
|
|
236
332
|
const data = text ? safeJson(text) : {};
|
|
@@ -246,6 +342,7 @@ class Api {
|
|
|
246
342
|
return this.request('GET', `/cli/voices/provider/${encodeURIComponent(provider)}${qs(params)}`);
|
|
247
343
|
}
|
|
248
344
|
// --- simulations ----------------------------------------------------------
|
|
345
|
+
simulationModels() { return this.request('GET', '/cli/simulations/models'); }
|
|
249
346
|
createTestCase(body) { return this.request('POST', '/cli/simulations/test-cases', body); }
|
|
250
347
|
listTestCases(agentId) {
|
|
251
348
|
return this.request('GET', `/cli/simulations/test-cases${qs({ agentId })}`);
|
|
@@ -289,6 +386,17 @@ function safeJson(text) {
|
|
|
289
386
|
return { msg: text };
|
|
290
387
|
}
|
|
291
388
|
}
|
|
389
|
+
/** The CRM's per-value refusals: [{ attribute, reason, value }] -> one line each. */
|
|
390
|
+
function invalidDetails(value) {
|
|
391
|
+
if (!Array.isArray(value))
|
|
392
|
+
return undefined;
|
|
393
|
+
return value.flatMap((v) => v && typeof v === 'object' && typeof v.reason === 'string'
|
|
394
|
+
? [{
|
|
395
|
+
message: `${v.reason}${v.value !== undefined ? ` (got "${v.value}")` : ''}`,
|
|
396
|
+
path: v.attribute || v.column,
|
|
397
|
+
}]
|
|
398
|
+
: []);
|
|
399
|
+
}
|
|
292
400
|
function parseErrorDetails(value) {
|
|
293
401
|
if (!Array.isArray(value))
|
|
294
402
|
return [];
|
|
@@ -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
|
+
}
|