@dszp/netsapiens-lib 0.1.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/LICENSE +21 -0
- package/README.md +103 -0
- package/dist/html.d.ts +51 -0
- package/dist/html.js +332 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +22 -0
- package/dist/jwt.d.ts +174 -0
- package/dist/jwt.js +290 -0
- package/dist/mermaid.d.ts +14 -0
- package/dist/mermaid.js +0 -0
- package/dist/model.d.ts +80 -0
- package/dist/model.js +10 -0
- package/dist/nsClient.d.ts +79 -0
- package/dist/nsClient.js +205 -0
- package/dist/policy.d.ts +49 -0
- package/dist/policy.js +39 -0
- package/dist/principal.d.ts +52 -0
- package/dist/principal.js +45 -0
- package/dist/raster.d.ts +21 -0
- package/dist/raster.js +81 -0
- package/dist/resolver.d.ts +58 -0
- package/dist/resolver.js +1092 -0
- package/dist/sensitivity.d.ts +32 -0
- package/dist/sensitivity.js +30 -0
- package/dist/themes.d.ts +71 -0
- package/dist/themes.js +104 -0
- package/package.json +60 -0
package/dist/resolver.js
ADDED
|
@@ -0,0 +1,1092 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flow resolver — deterministic walker over a NetSapiens domain snapshot that emits a
|
|
3
|
+
* normalized FlowGraph. Rule-set driven; no Node-only deps (runtime-portable).
|
|
4
|
+
*
|
|
5
|
+
* NetSapiens routing model, as decoded from real snapshots:
|
|
6
|
+
* - An inbound DID (phonenumber) has a `dial-rule-application` (to-user[-residential],
|
|
7
|
+
* to-callqueue, to-voicemail, to-connection) + a destination extension/host.
|
|
8
|
+
* - Every "extension" is a user record. Some users are virtual: a queue (in callqueues),
|
|
9
|
+
* an auto attendant (in autoattendants), a time-of-day router (TOD), or a shared mailbox.
|
|
10
|
+
* - A user's routing is its answer rules, one per time-frame, ordered by ordinal-priority:
|
|
11
|
+
* forward-always (unconditional) | simultaneous-ring/<OwnDevices> then
|
|
12
|
+
* forward-no-answer (RNA timeout) | forward-on-busy | forward-when-unregistered.
|
|
13
|
+
* - Answer-rule / dial-rule params speak an alias language:
|
|
14
|
+
* <did>_callqueue_<ext>, queue_<ext> -> queue
|
|
15
|
+
* <did>_attendant_<ext> -> auto attendant
|
|
16
|
+
* user_<ext> -> user
|
|
17
|
+
* vmail_<ext> / <did>_voicemail_<ext> -> voicemail box
|
|
18
|
+
* <did>_pstn_<num> / bare 10-11 digits -> external / off-net
|
|
19
|
+
* Prompt_<id> -> played greeting
|
|
20
|
+
* <OwnDevices> -> ring the user's registered devices
|
|
21
|
+
* - A queue dispatches to its agents (dispatch-type) then overflows via its own answer
|
|
22
|
+
* rule (forward-no-answer -> if-unanswered, forward-on-busy -> if-unavailable).
|
|
23
|
+
* - Auto-attendant keypress menus are NOT in the backup (inventory-only) — flagged as a gap.
|
|
24
|
+
*/
|
|
25
|
+
const s = (v) => (v === undefined || v === null ? '' : String(v)).trim();
|
|
26
|
+
const digits = (v) => s(v).replace(/\D/g, '');
|
|
27
|
+
/** Normalize a phone number to its national form (strip a leading US "1"). */
|
|
28
|
+
const nat = (v) => {
|
|
29
|
+
const d = digits(v);
|
|
30
|
+
return d.length === 11 && d.startsWith('1') ? d.slice(1) : d;
|
|
31
|
+
};
|
|
32
|
+
/** Truncate long greeting/script text for a node label (full text goes to a hover tooltip). */
|
|
33
|
+
const GREET_MAX = 90;
|
|
34
|
+
const trim = (v, max = GREET_MAX) => (v.length > max ? `${v.slice(0, max - 1).trimEnd()}…` : v);
|
|
35
|
+
/**
|
|
36
|
+
* Endpoint type by the agent-id / extension letter suffix (heuristic):
|
|
37
|
+
* wp → SNAPmobile Web (browser phone) · t → Microsoft Teams · m → SNAPmobile (mobile) ·
|
|
38
|
+
* r → mobile/desktop app · b / other lower letters → usually a desk phone.
|
|
39
|
+
* Exact device info (model, MAC, transport) IS available via the device API but isn't pulled yet —
|
|
40
|
+
* see CLAUDE.md → API notes; this suffix guess is the cheap approximation.
|
|
41
|
+
*/
|
|
42
|
+
function deviceKindBySuffix(suffix) {
|
|
43
|
+
switch (suffix.toLowerCase()) {
|
|
44
|
+
case 'wp':
|
|
45
|
+
return { icon: '🌐', kind: 'web app' };
|
|
46
|
+
case 't':
|
|
47
|
+
return { icon: '💻', kind: 'Teams' };
|
|
48
|
+
case 'm':
|
|
49
|
+
return { icon: '📱', kind: 'mobile app' };
|
|
50
|
+
case 'r':
|
|
51
|
+
return { icon: '📱', kind: 'app' };
|
|
52
|
+
case '':
|
|
53
|
+
return { icon: '', kind: '' };
|
|
54
|
+
default:
|
|
55
|
+
return { icon: '📞', kind: 'desk phone' }; // b and other letters
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** Compact agent queue-priority badge: "P" + a keycap digit (e.g. P2️⃣). Priority is a cross-queue
|
|
59
|
+
* tie-breaker that only matters when an agent is in several queues, so we render it ONLY for 2+ —
|
|
60
|
+
* 0 is the blank/unset portal dropdown and 1 is the baseline "set" value, both left unlabeled. */
|
|
61
|
+
const PRIORITY_MIN_SHOWN = 2;
|
|
62
|
+
function priorityBadge(n) {
|
|
63
|
+
const keycap = ['0️⃣', '1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣', '6️⃣', '7️⃣', '8️⃣', '9️⃣'];
|
|
64
|
+
return `P${n >= 0 && n <= 9 ? keycap[n] : n}`;
|
|
65
|
+
}
|
|
66
|
+
/** Index of a snapshot for O(1) lookups by extension / number. */
|
|
67
|
+
class Index {
|
|
68
|
+
snap;
|
|
69
|
+
usersByExt = new Map();
|
|
70
|
+
queuesByExt = new Map();
|
|
71
|
+
attendantsByExt = new Map();
|
|
72
|
+
agentsByQueue = new Map();
|
|
73
|
+
answerRules = new Map();
|
|
74
|
+
didByNat = new Map();
|
|
75
|
+
tfByName = new Map();
|
|
76
|
+
aaDetailByExt = new Map();
|
|
77
|
+
/** All AA detail records per ext (>1 = multi-timeframe deviation). */
|
|
78
|
+
aaDetailsByExt = new Map();
|
|
79
|
+
/** Per-AA dialplan dialrules (the authoritative menu/default routing). */
|
|
80
|
+
aaDialrulesByExt = new Map();
|
|
81
|
+
/** Domain dial-rule aliases: `dial-rule-matching-to-uri` → the rule (application + destination).
|
|
82
|
+
* Lets us follow custom-named aliases like `aa_church_open` / `AAMain` that aren't in the
|
|
83
|
+
* auto-generated `<treatment>_<ext>` grammar. */
|
|
84
|
+
dialruleByUri = new Map();
|
|
85
|
+
constructor(snap) {
|
|
86
|
+
this.snap = snap;
|
|
87
|
+
for (const u of snap.users ?? [])
|
|
88
|
+
this.usersByExt.set(s(u.user), u);
|
|
89
|
+
for (const q of snap.callqueues ?? [])
|
|
90
|
+
this.queuesByExt.set(s(q.callqueue), q);
|
|
91
|
+
for (const a of snap.autoattendants ?? [])
|
|
92
|
+
this.attendantsByExt.set(s(a.user), a);
|
|
93
|
+
for (const [q, ags] of Object.entries(snap.agentsByQueue ?? {}))
|
|
94
|
+
this.agentsByQueue.set(s(q), ags);
|
|
95
|
+
for (const [u, rs] of Object.entries(snap.answerrulesByUser ?? {}))
|
|
96
|
+
this.answerRules.set(s(u), rs);
|
|
97
|
+
for (const p of snap.phonenumbers ?? [])
|
|
98
|
+
this.didByNat.set(nat(p.phonenumber), p);
|
|
99
|
+
for (const t of snap.timeframes ?? [])
|
|
100
|
+
this.tfByName.set(s(t['timeframe-name']), t);
|
|
101
|
+
// AA details from either shape: attendantDetailsByUser (array; enriched backup) or
|
|
102
|
+
// attendantDetails (single; live). Primary = the `*`/Default-timeframe record, else the first.
|
|
103
|
+
const aaSets = {};
|
|
104
|
+
for (const [ext, arr] of Object.entries(snap.attendantDetailsByUser ?? {}))
|
|
105
|
+
aaSets[s(ext)] = Array.isArray(arr) ? arr : [arr];
|
|
106
|
+
for (const [ext, d] of Object.entries(snap.attendantDetails ?? {}))
|
|
107
|
+
if (!aaSets[s(ext)])
|
|
108
|
+
aaSets[s(ext)] = [d];
|
|
109
|
+
for (const [ext, arr] of Object.entries(aaSets)) {
|
|
110
|
+
this.aaDetailsByExt.set(ext, arr);
|
|
111
|
+
const primary = arr.find((d) => { const tf = s(d['time-frame']); return tf === '' || tf === '*'; }) ?? arr[0];
|
|
112
|
+
if (primary)
|
|
113
|
+
this.aaDetailByExt.set(ext, primary);
|
|
114
|
+
}
|
|
115
|
+
for (const [plan, rules] of Object.entries(snap.dialrulesByPlan ?? {})) {
|
|
116
|
+
for (const r of rules) {
|
|
117
|
+
const uri = s(r['dial-rule-matching-to-uri']);
|
|
118
|
+
if (uri && !this.dialruleByUri.has(uri))
|
|
119
|
+
this.dialruleByUri.set(uri, r);
|
|
120
|
+
}
|
|
121
|
+
// An AA's own dialplan is keyed `<domain>_<ext>` and holds the AUTHORITATIVE menu (star /
|
|
122
|
+
// no-key / dial-by-ext) the /autoattendants detail omits. Derive aaDialrulesByExt from it so
|
|
123
|
+
// backups that store AA dialrules here (not in attendantDialrulesByExt) still render the full menu.
|
|
124
|
+
const m = plan.match(/_(\d{2,6})$/);
|
|
125
|
+
if (m) {
|
|
126
|
+
const aaRules = rules.filter((r) => /^Prompt_/i.test(s(r['dial-rule-matching-to-uri'])));
|
|
127
|
+
if (aaRules.length && !this.aaDialrulesByExt.has(s(m[1])))
|
|
128
|
+
this.aaDialrulesByExt.set(s(m[1]), aaRules);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
// Enriched-backup source (if present) is canonical — it overrides the dialrulesByPlan-derived set.
|
|
132
|
+
for (const [ext, rules] of Object.entries(snap.attendantDialrulesByExt ?? {}))
|
|
133
|
+
this.aaDialrulesByExt.set(s(ext), rules);
|
|
134
|
+
}
|
|
135
|
+
/** What IS this extension? Order matters: attendant/queue before plain user. */
|
|
136
|
+
classifyExt(ext) {
|
|
137
|
+
const e = s(ext);
|
|
138
|
+
if (this.attendantsByExt.has(e))
|
|
139
|
+
return 'attendant';
|
|
140
|
+
if (this.queuesByExt.has(e))
|
|
141
|
+
return 'queue';
|
|
142
|
+
if (this.usersByExt.has(e))
|
|
143
|
+
return 'user';
|
|
144
|
+
if (digits(e).length >= 10)
|
|
145
|
+
return 'external';
|
|
146
|
+
return 'unknown';
|
|
147
|
+
}
|
|
148
|
+
userName(ext) {
|
|
149
|
+
const u = this.usersByExt.get(s(ext));
|
|
150
|
+
if (!u)
|
|
151
|
+
return s(ext);
|
|
152
|
+
const n = `${s(u['name-first-name'])} ${s(u['name-last-name'])}`.trim();
|
|
153
|
+
return n || s(ext);
|
|
154
|
+
}
|
|
155
|
+
/** Call-queue display name (its `description`), or '' if unknown. */
|
|
156
|
+
queueName(ext) {
|
|
157
|
+
const q = this.queuesByExt.get(s(ext));
|
|
158
|
+
return q ? s(q.description) : '';
|
|
159
|
+
}
|
|
160
|
+
/** Auto-attendant display name (`attendant-name`), or '' if unknown. */
|
|
161
|
+
attendantName(ext) {
|
|
162
|
+
const a = this.attendantsByExt.get(s(ext));
|
|
163
|
+
return a ? s(a['attendant-name']) : '';
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/** A single answer-rule condition block: { enabled, parameters }. */
|
|
167
|
+
function firstParam(block) {
|
|
168
|
+
if (!block || s(block.enabled) !== 'yes')
|
|
169
|
+
return null;
|
|
170
|
+
const p = block.parameters;
|
|
171
|
+
if (!Array.isArray(p) || !p.length)
|
|
172
|
+
return null;
|
|
173
|
+
const v = s(p[0]);
|
|
174
|
+
return v || null;
|
|
175
|
+
}
|
|
176
|
+
const TREATMENT = {
|
|
177
|
+
attendant: 'attendant',
|
|
178
|
+
user: 'user',
|
|
179
|
+
callqueue: 'queue',
|
|
180
|
+
queue: 'queue',
|
|
181
|
+
voicemail: 'voicemail',
|
|
182
|
+
vmail: 'voicemail',
|
|
183
|
+
pstn: 'external',
|
|
184
|
+
};
|
|
185
|
+
/** Classify an answer-rule / dial-rule parameter into a routing target. */
|
|
186
|
+
function classifyParam(raw, idx) {
|
|
187
|
+
const p = s(raw);
|
|
188
|
+
if (!p)
|
|
189
|
+
return { kind: 'hangup' };
|
|
190
|
+
if (/^<OwnDevices>$/i.test(p))
|
|
191
|
+
return { kind: 'devices', ownDevices: true };
|
|
192
|
+
if (/^Prompt_/i.test(p))
|
|
193
|
+
return { kind: 'prompt', promptId: p };
|
|
194
|
+
// <did>_<treatment>_<dest> e.g. 13175550100_callqueue_9100
|
|
195
|
+
let m = p.match(/^\d+_(attendant|user|callqueue|voicemail|pstn)_(.+)$/i);
|
|
196
|
+
if (m)
|
|
197
|
+
return mapTreatment(m[1], m[2]);
|
|
198
|
+
// <treatment>_<dest> e.g. queue_9100, user_500, vmail_500
|
|
199
|
+
m = p.match(/^(attendant|user|callqueue|queue|voicemail|vmail|pstn)_(.+)$/i);
|
|
200
|
+
if (m)
|
|
201
|
+
return mapTreatment(m[1], m[2]);
|
|
202
|
+
// bare numeric — resolve by what the extension IS
|
|
203
|
+
if (/^\+?\d+$/.test(p)) {
|
|
204
|
+
const kind = idx.classifyExt(digits(p));
|
|
205
|
+
if (kind === 'external' || kind === 'unknown')
|
|
206
|
+
return { kind: 'external', number: digits(p) };
|
|
207
|
+
return { kind, ext: digits(p) };
|
|
208
|
+
}
|
|
209
|
+
// custom dial-plan alias (e.g. aa_church_open, AAMain) → follow the dialrule to its real target
|
|
210
|
+
const dr = idx.dialruleByUri.get(p);
|
|
211
|
+
if (dr)
|
|
212
|
+
return dialruleTarget(dr, idx);
|
|
213
|
+
return { kind: 'unknown', ext: p };
|
|
214
|
+
}
|
|
215
|
+
function mapTreatment(word, dest) {
|
|
216
|
+
const kind = TREATMENT[word.toLowerCase()] ?? 'user';
|
|
217
|
+
return kind === 'external' ? { kind, number: digits(dest) } : { kind, ext: digits(dest) || s(dest) };
|
|
218
|
+
}
|
|
219
|
+
/** Resolve a matched dial-rule alias to a routing target by its application + destination. */
|
|
220
|
+
function dialruleTarget(dr, idx) {
|
|
221
|
+
const app = s(dr['dial-rule-application']).toLowerCase();
|
|
222
|
+
const dest = s(dr['dial-rule-translation-destination-user']);
|
|
223
|
+
if (app.startsWith('to-callqueue'))
|
|
224
|
+
return { kind: 'queue', ext: dest };
|
|
225
|
+
if (app.startsWith('to-voicemail'))
|
|
226
|
+
return { kind: 'voicemail', ext: dest };
|
|
227
|
+
if (app.startsWith('to-connection'))
|
|
228
|
+
return { kind: 'external', number: digits(dest) || dest };
|
|
229
|
+
if (app.startsWith('to-user') || app.startsWith('to-single-device')) {
|
|
230
|
+
const kind = idx.classifyExt(dest);
|
|
231
|
+
if (kind === 'attendant')
|
|
232
|
+
return { kind: 'attendant', ext: dest };
|
|
233
|
+
if (kind === 'queue')
|
|
234
|
+
return { kind: 'queue', ext: dest };
|
|
235
|
+
return { kind: 'user', ext: dest };
|
|
236
|
+
}
|
|
237
|
+
if (app.startsWith('hangup'))
|
|
238
|
+
return { kind: 'hangup' };
|
|
239
|
+
return { kind: 'unknown', ext: s(dr['dial-rule-matching-to-uri']) || dest };
|
|
240
|
+
}
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
// Graph builder
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
class Builder {
|
|
245
|
+
nodes = new Map();
|
|
246
|
+
edges = [];
|
|
247
|
+
notes = [];
|
|
248
|
+
expanded = new Set();
|
|
249
|
+
node(id, kind, label, sub, lines, title) {
|
|
250
|
+
if (this.nodes.has(id))
|
|
251
|
+
return { id, isNew: false };
|
|
252
|
+
this.nodes.set(id, { id, kind, label, ...(sub ? { sub } : {}), ...(lines && lines.length ? { lines } : {}), ...(title ? { title } : {}) });
|
|
253
|
+
return { id, isNew: true };
|
|
254
|
+
}
|
|
255
|
+
edge(from, to, kind, label) {
|
|
256
|
+
// Back-edge to an ANCESTOR (a cycle — e.g. an AA option that returns to the queue that feeds it):
|
|
257
|
+
// draw a compact reference leaf instead of an edge that loops back UP to the ancestor. Layout
|
|
258
|
+
// engines (ELK especially) route such up-edges confusingly, so the fallback reads as a detached
|
|
259
|
+
// branch. The real node still shows in full where it was first expanded; this leaf just names it.
|
|
260
|
+
if (kind !== 'ref' && to !== from && this.onPath(to)) {
|
|
261
|
+
const rid = `ref_${to}__${from}`;
|
|
262
|
+
if (!this.nodes.has(rid))
|
|
263
|
+
this.nodes.set(rid, { id: rid, kind: this.kindOf(to), label: `↩ ${this.labelOf(to)}`, sub: 'loops back ↑' });
|
|
264
|
+
this.edges.push({ from, to: rid, kind: 'ref', ...(label ? { label } : {}) });
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
this.edges.push({ from, to, kind, ...(label ? { label } : {}) });
|
|
268
|
+
}
|
|
269
|
+
note(n) {
|
|
270
|
+
if (!this.notes.includes(n))
|
|
271
|
+
this.notes.push(n);
|
|
272
|
+
}
|
|
273
|
+
/** Returns true the first time a node is expanded; false thereafter (cycle guard). */
|
|
274
|
+
claim(id) {
|
|
275
|
+
if (this.expanded.has(id))
|
|
276
|
+
return false;
|
|
277
|
+
this.expanded.add(id);
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
// Current DFS expansion path (root → … → node being expanded). enter() after a successful
|
|
281
|
+
// claim(), leave() at every return. onPath() lets routing detect a back-edge to an ANCESTOR
|
|
282
|
+
// (a cycle) so it can draw a compact reference leaf instead of an edge that loops back up.
|
|
283
|
+
//
|
|
284
|
+
// TODO (loop safety — real but rare; NOT yet fully guaranteed): cross-object cyclic references can
|
|
285
|
+
// form legitimately — e.g. an AA option jumps to another TOD/AA which (if configured) jumps back to
|
|
286
|
+
// an AA earlier in the flow. Ancestor detection above prevents infinite recursion ONLY IF every
|
|
287
|
+
// expandable kind on the path calls enter()/leave() (AA, queue, user do; audit that the TOD/timeframe
|
|
288
|
+
// router and any future expandable kind do too — otherwise an untracked hop could recurse forever).
|
|
289
|
+
// Add defense-in-depth so this can never hang regardless: a global per-object expansion cap (expand
|
|
290
|
+
// any given target at most once, then emit a reference leaf) and/or a max-depth bound. The shape to
|
|
291
|
+
// worry about has been seen in the wild: a DID lands on an AA whose menu option routes into a
|
|
292
|
+
// time-of-day router that fans out to a second flow. That resolves correctly today, but nothing stops
|
|
293
|
+
// an admin adding a back-link from the second flow to the first AA — a cycle through a hop that does
|
|
294
|
+
// not currently enter()/leave().
|
|
295
|
+
path = [];
|
|
296
|
+
enter(id) {
|
|
297
|
+
this.path.push(id);
|
|
298
|
+
}
|
|
299
|
+
leave(v) {
|
|
300
|
+
this.path.pop();
|
|
301
|
+
return v;
|
|
302
|
+
}
|
|
303
|
+
onPath(id) {
|
|
304
|
+
return this.path.includes(id);
|
|
305
|
+
}
|
|
306
|
+
labelOf(id) {
|
|
307
|
+
return this.nodes.get(id)?.label ?? id;
|
|
308
|
+
}
|
|
309
|
+
kindOf(id) {
|
|
310
|
+
return this.nodes.get(id)?.kind ?? 'unknown';
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
// Schedule summarizing
|
|
315
|
+
// ---------------------------------------------------------------------------
|
|
316
|
+
const DOW = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
317
|
+
function fmtTime(hhmm) {
|
|
318
|
+
const t = s(hhmm).replace(':', '');
|
|
319
|
+
if (!/^\d{3,4}$/.test(t))
|
|
320
|
+
return s(hhmm);
|
|
321
|
+
let h = parseInt(t.slice(0, t.length - 2), 10);
|
|
322
|
+
const m = t.slice(-2);
|
|
323
|
+
const ap = h >= 12 ? 'p' : 'a';
|
|
324
|
+
h = h % 12 || 12;
|
|
325
|
+
return m === '00' ? `${h}${ap}` : `${h}:${m}${ap}`;
|
|
326
|
+
}
|
|
327
|
+
function compressDays(nums) {
|
|
328
|
+
const uniq = [...new Set(nums)].sort((a, b) => a - b);
|
|
329
|
+
if (!uniq.length)
|
|
330
|
+
return '';
|
|
331
|
+
const parts = [];
|
|
332
|
+
let start = uniq[0];
|
|
333
|
+
let prev = uniq[0];
|
|
334
|
+
for (let i = 1; i <= uniq.length; i++) {
|
|
335
|
+
const cur = uniq[i];
|
|
336
|
+
if (cur === prev + 1) {
|
|
337
|
+
prev = cur;
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
parts.push(start === prev ? DOW[start] : `${DOW[start]}–${DOW[prev]}`);
|
|
341
|
+
if (cur !== undefined)
|
|
342
|
+
start = prev = cur;
|
|
343
|
+
}
|
|
344
|
+
return parts.join(', ');
|
|
345
|
+
}
|
|
346
|
+
/** Summarize an answer-rule's time_range_data into e.g. "Mon–Fri 8a–4:29p". */
|
|
347
|
+
function scheduleLabel(rule, tfName) {
|
|
348
|
+
const rows = Array.isArray(rule.time_range_data) ? rule.time_range_data : [];
|
|
349
|
+
if (rows.length) {
|
|
350
|
+
// group by (start,end) window
|
|
351
|
+
const byWin = new Map();
|
|
352
|
+
for (const r of rows) {
|
|
353
|
+
const start = s(r['start-time']);
|
|
354
|
+
const end = s(r['end-time']);
|
|
355
|
+
const dow = Number(r['day-of-week-number']);
|
|
356
|
+
const key = `${start}-${end}`;
|
|
357
|
+
if (!byWin.has(key))
|
|
358
|
+
byWin.set(key, []);
|
|
359
|
+
if (Number.isFinite(dow))
|
|
360
|
+
byWin.get(key).push(dow % 7);
|
|
361
|
+
}
|
|
362
|
+
const segs = [...byWin.entries()].map(([win, days]) => {
|
|
363
|
+
const [start, end] = win.split('-');
|
|
364
|
+
return `${compressDays(days)} ${fmtTime(start)}–${fmtTime(end)}`.trim();
|
|
365
|
+
});
|
|
366
|
+
// Header line (timeframe name) + bulleted, left-aligned schedule segments — even for a single
|
|
367
|
+
// window. The emitter turns `\n` into line breaks and left-aligns edge labels.
|
|
368
|
+
if (segs.length)
|
|
369
|
+
return `${tfName}:\n${segs.map((x) => `• ${x}`).join('\n')}`;
|
|
370
|
+
}
|
|
371
|
+
return tfName;
|
|
372
|
+
}
|
|
373
|
+
/** Format a domain timeframe by NAME (for labels that only have the name, e.g. AA intro greetings) —
|
|
374
|
+
* same "name:\n• <days times>" style as scheduleLabel, read from the domain timeframe's weekly
|
|
375
|
+
* schedule. Falls back to the bare name for non-weekly timeframes (holiday / specific-dates). */
|
|
376
|
+
function timeframeSchedule(tfName, idx) {
|
|
377
|
+
const tf = idx.tfByName.get(tfName);
|
|
378
|
+
const rows = tf && Array.isArray(tf['timeframe-days-of-week-array']) ? tf['timeframe-days-of-week-array'] : [];
|
|
379
|
+
const days = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
|
|
380
|
+
const byWin = new Map();
|
|
381
|
+
for (const row of rows) {
|
|
382
|
+
days.forEach((d, i) => {
|
|
383
|
+
for (const slot of ['1', '2']) {
|
|
384
|
+
const begin = s(row[`timeframe-weekly-${d}-begin-time-${slot}`]);
|
|
385
|
+
const end = s(row[`timeframe-weekly-${d}-end-time-${slot}`]);
|
|
386
|
+
if (begin && end) {
|
|
387
|
+
if (!byWin.has(`${begin}-${end}`))
|
|
388
|
+
byWin.set(`${begin}-${end}`, []);
|
|
389
|
+
byWin.get(`${begin}-${end}`).push(i);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
const segs = [...byWin.entries()].map(([win, dayIdx]) => {
|
|
395
|
+
const [begin, end] = win.split('-');
|
|
396
|
+
return `${compressDays([...new Set(dayIdx)])} ${fmtTime(begin)}–${fmtTime(end)}`.trim();
|
|
397
|
+
});
|
|
398
|
+
if (segs.length)
|
|
399
|
+
return `${tfName}:\n${segs.map((x) => `• ${x}`).join('\n')}`;
|
|
400
|
+
// specific-dates (holidays) — format each date / date-range (with times if not all-day).
|
|
401
|
+
const M = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
402
|
+
const fmtDate = (d) => (/^\d{8}$/.test(d) ? `${M[+d.slice(4, 6) - 1]} ${+d.slice(6, 8)}, ${d.slice(0, 4)}` : d);
|
|
403
|
+
const dateSegs = (Array.isArray(tf?.['timeframe-specific-dates-array']) ? tf['timeframe-specific-dates-array'] : [])
|
|
404
|
+
.map((r) => {
|
|
405
|
+
const bd = s(r['timeframe-specific-dates-begin-date']);
|
|
406
|
+
if (!bd)
|
|
407
|
+
return '';
|
|
408
|
+
const ed = s(r['timeframe-specific-dates-end-date']);
|
|
409
|
+
const bt = s(r['timeframe-specific-dates-begin-time']);
|
|
410
|
+
const et = s(r['timeframe-specific-dates-end-time']);
|
|
411
|
+
let seg = fmtDate(bd) + (ed && ed !== bd ? `–${fmtDate(ed)}` : '');
|
|
412
|
+
if ((bt && bt !== '0000') || (et && et !== '0000' && et !== '2359'))
|
|
413
|
+
seg += ` ${fmtTime(bt)}–${fmtTime(et)}`;
|
|
414
|
+
return seg;
|
|
415
|
+
})
|
|
416
|
+
.filter(Boolean);
|
|
417
|
+
return dateSegs.length ? `${tfName}:\n${dateSegs.map((x) => `• ${x}`).join('\n')}` : tfName;
|
|
418
|
+
}
|
|
419
|
+
// ---------------------------------------------------------------------------
|
|
420
|
+
// The walk
|
|
421
|
+
// ---------------------------------------------------------------------------
|
|
422
|
+
export function resolveFlow(snap, entity) {
|
|
423
|
+
const idx = new Index(snap);
|
|
424
|
+
const b = new Builder();
|
|
425
|
+
const domain = s(snap.meta?.domain) || s(snap.domain?.domain);
|
|
426
|
+
let rootId;
|
|
427
|
+
let entityLabel;
|
|
428
|
+
switch (entity.kind) {
|
|
429
|
+
case 'did': {
|
|
430
|
+
const r = resolveDid(entity.ref, idx, b);
|
|
431
|
+
rootId = r.id;
|
|
432
|
+
entityLabel = r.label;
|
|
433
|
+
break;
|
|
434
|
+
}
|
|
435
|
+
case 'queue': {
|
|
436
|
+
const id = ensureQueue(entity.ref, idx, b);
|
|
437
|
+
rootId = id;
|
|
438
|
+
const qn = idx.queueName(entity.ref);
|
|
439
|
+
entityLabel = qn ? `Queue ${entity.ref} (${qn})` : `Queue ${entity.ref}`;
|
|
440
|
+
break;
|
|
441
|
+
}
|
|
442
|
+
case 'attendant': {
|
|
443
|
+
const id = ensureAttendant(entity.ref, idx, b);
|
|
444
|
+
rootId = id;
|
|
445
|
+
const an = idx.attendantName(entity.ref);
|
|
446
|
+
entityLabel = an ? `Auto Attendant ${entity.ref} (${an})` : `Auto Attendant ${entity.ref}`;
|
|
447
|
+
break;
|
|
448
|
+
}
|
|
449
|
+
default: {
|
|
450
|
+
const id = ensureExt(entity.ref, idx, b);
|
|
451
|
+
rootId = id;
|
|
452
|
+
entityLabel = `${idx.userName(entity.ref)} (${entity.ref})`;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return {
|
|
456
|
+
entity: { kind: entity.kind, ref: entity.ref, label: entityLabel },
|
|
457
|
+
domain,
|
|
458
|
+
rootId,
|
|
459
|
+
nodes: [...b.nodes.values()],
|
|
460
|
+
edges: b.edges,
|
|
461
|
+
notes: b.notes,
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
function resolveDid(ref, idx, b) {
|
|
465
|
+
const p = idx.didByNat.get(nat(ref));
|
|
466
|
+
const num = nat(ref);
|
|
467
|
+
const pretty = prettyPhone(num);
|
|
468
|
+
const id = `did_${num}`;
|
|
469
|
+
const desc = p ? s(p['dial-rule-description']) : '';
|
|
470
|
+
b.node(id, 'did', `📞 ${pretty}`, desc || 'inbound DID');
|
|
471
|
+
if (!p) {
|
|
472
|
+
b.note(`DID ${ref} not found in snapshot.`);
|
|
473
|
+
return { id, label: pretty };
|
|
474
|
+
}
|
|
475
|
+
const app = s(p['dial-rule-application']);
|
|
476
|
+
const destUser = s(p['dial-rule-translation-destination-user']);
|
|
477
|
+
const destHost = s(p['dial-rule-translation-destination-host']);
|
|
478
|
+
if (s(p.enabled) !== 'yes')
|
|
479
|
+
b.note(`DID ${pretty} is disabled.`);
|
|
480
|
+
if (/^to-connection/i.test(app)) {
|
|
481
|
+
const t = b.node(`trunk_${destHost || destUser}`, 'trunk', `🔌 ${destHost || 'SIP connection'}`, desc || 'connection / trunk');
|
|
482
|
+
b.edge(id, t.id, 'route', 'to connection');
|
|
483
|
+
}
|
|
484
|
+
else if (/^to-callqueue/i.test(app)) {
|
|
485
|
+
b.edge(id, ensureQueue(destUser, idx, b), 'route');
|
|
486
|
+
}
|
|
487
|
+
else if (/^to-voicemail/i.test(app)) {
|
|
488
|
+
b.edge(id, ensureVoicemail(destUser, idx, b), 'route');
|
|
489
|
+
}
|
|
490
|
+
else if (/^to-user/i.test(app)) {
|
|
491
|
+
b.edge(id, ensureExt(destUser, idx, b), 'route');
|
|
492
|
+
}
|
|
493
|
+
else if (/^available-number/i.test(app)) {
|
|
494
|
+
// "available-number" is a number parked in the available/unassigned pool — a legitimate dead-end,
|
|
495
|
+
// not an unmodeled gap. Render it as the terminal it is (no "not modeled" warning).
|
|
496
|
+
const t = b.node(`app_${id}`, 'unknown', 'available number', destUser || destHost || 'unassigned');
|
|
497
|
+
b.edge(id, t.id, 'route', 'unassigned');
|
|
498
|
+
}
|
|
499
|
+
else {
|
|
500
|
+
const t = b.node(`app_${id}`, 'unknown', app || 'unknown routing', destUser || destHost);
|
|
501
|
+
b.edge(id, t.id, 'route');
|
|
502
|
+
b.note(`DID ${pretty} uses application "${app}" — not modeled.`);
|
|
503
|
+
}
|
|
504
|
+
return { id, label: pretty };
|
|
505
|
+
}
|
|
506
|
+
/** Ensure a node for an extension and expand its routing. Dispatches by what it IS. */
|
|
507
|
+
function ensureExt(ext, idx, b) {
|
|
508
|
+
const kind = idx.classifyExt(ext);
|
|
509
|
+
if (kind === 'attendant')
|
|
510
|
+
return ensureAttendant(ext, idx, b);
|
|
511
|
+
if (kind === 'queue')
|
|
512
|
+
return ensureQueue(ext, idx, b);
|
|
513
|
+
if (kind === 'external' || kind === 'unknown') {
|
|
514
|
+
return ensureExternal(digits(ext) || ext, b);
|
|
515
|
+
}
|
|
516
|
+
return ensureUser(ext, idx, b);
|
|
517
|
+
}
|
|
518
|
+
function ensureUser(ext, idx, b) {
|
|
519
|
+
const id = `user_${ext}`;
|
|
520
|
+
const u = idx.usersByExt.get(s(ext));
|
|
521
|
+
const name = idx.userName(ext);
|
|
522
|
+
b.node(id, 'user', `👤 ${name}`, `ext ${ext}`);
|
|
523
|
+
if (!b.claim(id))
|
|
524
|
+
return id;
|
|
525
|
+
b.enter(id);
|
|
526
|
+
if (!u) {
|
|
527
|
+
b.note(`Extension ${ext} referenced but not in snapshot.`);
|
|
528
|
+
return b.leave(id);
|
|
529
|
+
}
|
|
530
|
+
const rules = (idx.answerRules.get(s(ext)) ?? []).filter((r) => s(r.enabled) === 'yes');
|
|
531
|
+
if (!rules.length) {
|
|
532
|
+
ringThenVoicemail(id, ext, idx, b, null, 'route');
|
|
533
|
+
return b.leave(id);
|
|
534
|
+
}
|
|
535
|
+
if (rules.length === 1) {
|
|
536
|
+
resolveRule(id, ext, rules[0], idx, b, 'route', null);
|
|
537
|
+
return b.leave(id);
|
|
538
|
+
}
|
|
539
|
+
// multiple time-frames -> a time-of-day decision
|
|
540
|
+
const tf = b.node(`tf_${ext}`, 'timeframe', '🕒 Time of day?').id;
|
|
541
|
+
b.edge(id, tf, 'route');
|
|
542
|
+
const sorted = [...rules].sort((a, c) => Number(a['ordinal-priority'] ?? 99) - Number(c['ordinal-priority'] ?? 99));
|
|
543
|
+
for (const r of sorted) {
|
|
544
|
+
const name2 = s(r['time-frame']);
|
|
545
|
+
const label = !name2 || name2 === '*' ? 'otherwise' : scheduleLabel(r, name2);
|
|
546
|
+
resolveRule(tf, ext, r, idx, b, 'time', label);
|
|
547
|
+
}
|
|
548
|
+
return b.leave(id);
|
|
549
|
+
}
|
|
550
|
+
/** Resolve one answer rule from `fromId`. */
|
|
551
|
+
function resolveRule(fromId, ext, rule, idx, b, edgeKind, edgeLabel) {
|
|
552
|
+
const fa = firstParam(rule['forward-always']);
|
|
553
|
+
if (fa) {
|
|
554
|
+
routeParam(fa, fromId, idx, b, edgeKind === 'time' ? 'time' : 'always', edgeLabel ?? 'always');
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
if (rule['do-not-disturb'] && s(rule['do-not-disturb'].enabled) === 'yes') {
|
|
558
|
+
b.edge(fromId, ensureVoicemail(ext, idx, b), 'dnd', 'DND');
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
ringThenVoicemail(fromId, ext, idx, b, rule, edgeKind, edgeLabel);
|
|
562
|
+
}
|
|
563
|
+
/** Ring the user's devices (optionally sim-ring extra exts) then apply no-answer/busy. */
|
|
564
|
+
function ringThenVoicemail(fromId, ext, idx, b, rule, edgeKind, edgeLabel = null) {
|
|
565
|
+
const u = idx.usersByExt.get(s(ext));
|
|
566
|
+
const rna = u ? s(u['ring-no-answer-timeout-seconds']) : '';
|
|
567
|
+
const name = idx.userName(ext);
|
|
568
|
+
// What this rule rings: the user's own registered devices (<OwnDevices> or the bare self ext) plus
|
|
569
|
+
// any specific extra target — a mobile/Teams/app registration (…m/…t/…r) or another user. Note a
|
|
570
|
+
// `2152m` is NOT the same as `2152` (it's the mobile app), so don't fold it into "self".
|
|
571
|
+
// `<OwnDevices>` rings ALL the user's registrations (incl. mobile/desktop apps); the bare self ext
|
|
572
|
+
// rings only the extension's primary — a real difference (e.g. time_open uses <OwnDevices> so the
|
|
573
|
+
// mobile app rings, the default rings the ext only). Track them separately.
|
|
574
|
+
let ownDevices = false;
|
|
575
|
+
let selfExt = false;
|
|
576
|
+
const extra = [];
|
|
577
|
+
const sr = rule?.['simultaneous-ring'];
|
|
578
|
+
if (sr && s(sr.enabled) === 'yes' && Array.isArray(sr.parameters)) {
|
|
579
|
+
for (const p of sr.parameters) {
|
|
580
|
+
const v = s(p);
|
|
581
|
+
if (!v)
|
|
582
|
+
continue;
|
|
583
|
+
if (/^<OwnDevices>$/i.test(v)) {
|
|
584
|
+
ownDevices = true;
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
if (v === s(ext)) {
|
|
588
|
+
selfExt = true;
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
const suf = (v.match(/[a-z]+$/i)?.[0] ?? '').toLowerCase();
|
|
592
|
+
const base = digits(v) || v;
|
|
593
|
+
const dk = deviceKindBySuffix(suf);
|
|
594
|
+
const iconChar = suf ? dk.icon : '👤'; // a bare other-ext is another user
|
|
595
|
+
extra.push({ v, line: `${iconChar} ${base === s(ext) && dk.kind ? dk.kind : idx.userName(base) || v} (${v})` });
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
else {
|
|
599
|
+
selfExt = true; // no explicit sim-ring config → rings the user's extension
|
|
600
|
+
}
|
|
601
|
+
// Distinct node per ring-set, so timeframes that ring different sets (e.g. ext-only vs all devices)
|
|
602
|
+
// render as separate nodes instead of collapsing into one.
|
|
603
|
+
const ringKey = `${selfExt ? 'self_' : ''}${ownDevices ? 'own_' : ''}${extra.map((e) => e.v).sort().join('_')}` || 'x';
|
|
604
|
+
const devId = `dev_${ext}_${ringKey}`;
|
|
605
|
+
// Labels match the portal "Simultaneous ring" checkboxes.
|
|
606
|
+
const devLines = [...(selfExt ? [`📞 user's extension (${ext})`] : []), ...(ownDevices ? [`📱 all user's phones`] : []), ...extra.map((e) => e.line)];
|
|
607
|
+
b.node(devId, 'devices', `🔔 Ring ${name}`, rna ? `${rna}s` : undefined, devLines);
|
|
608
|
+
b.edge(fromId, devId, edgeKind, edgeLabel ?? undefined);
|
|
609
|
+
const na = rule ? firstParam(rule['forward-no-answer']) : null;
|
|
610
|
+
if (na) {
|
|
611
|
+
routeParam(na, devId, idx, b, 'noanswer', rna ? `no answer ${rna}s` : 'no answer');
|
|
612
|
+
}
|
|
613
|
+
else if (!u || s(u['voicemail-enabled']) !== 'no') {
|
|
614
|
+
b.edge(devId, ensureVoicemail(ext, idx, b), 'noanswer', rna ? `no answer ${rna}s` : 'no answer');
|
|
615
|
+
}
|
|
616
|
+
else {
|
|
617
|
+
b.edge(devId, b.node(`hangup_${ext}`, 'hangup', '☎️ No route').id, 'noanswer');
|
|
618
|
+
}
|
|
619
|
+
const busy = rule ? firstParam(rule['forward-on-busy']) : null;
|
|
620
|
+
if (busy)
|
|
621
|
+
routeParam(busy, devId, idx, b, 'busy', 'busy');
|
|
622
|
+
const unreg = rule ? firstParam(rule['forward-when-unregistered']) : null;
|
|
623
|
+
if (unreg && unreg !== busy)
|
|
624
|
+
routeParam(unreg, devId, idx, b, 'unreg', 'unregistered');
|
|
625
|
+
}
|
|
626
|
+
function ensureQueue(ext, idx, b) {
|
|
627
|
+
const id = `queue_${ext}`;
|
|
628
|
+
const q = idx.queuesByExt.get(s(ext));
|
|
629
|
+
const desc = q ? s(q.description) : '';
|
|
630
|
+
const dispatch = q ? s(q['callqueue-dispatch-type']) : '';
|
|
631
|
+
const agents = idx.agentsByQueue.get(s(ext)) ?? [];
|
|
632
|
+
// A queue with no agents (or dispatch-type "-") is a call PARK: callers wait in an orbit and are
|
|
633
|
+
// retrieved by dialing the park number (= this ext). Show it as a park, not an agent-less queue.
|
|
634
|
+
const isPark = agents.length === 0 || dispatch === '-';
|
|
635
|
+
// Cross-queue agent priority (lower number = higher; 0 = the blank/unset portal dropdown).
|
|
636
|
+
// Computed once so it's shown at the queue node when uniform+set, and per-agent otherwise.
|
|
637
|
+
const qPrios = agents.map((a) => Number(a['callqueue-agent-dispatch-queue-priority-ordinal'] ?? 0));
|
|
638
|
+
const uniformPrio = new Set(qPrios).size === 1 ? qPrios[0] : 0;
|
|
639
|
+
if (isPark) {
|
|
640
|
+
b.node(id, 'queue', `🅿️ Call Park ${ext}${desc ? ` · ${desc}` : ''}`, `dial ${ext} to retrieve`);
|
|
641
|
+
}
|
|
642
|
+
else {
|
|
643
|
+
// "In Queue Options": queue ring timeout (on the queue's user record), agent ring timeout, and
|
|
644
|
+
// for linear/cascade/hunt the initial agent group + group-to-add-per-round.
|
|
645
|
+
const qUser = idx.usersByExt.get(s(ext));
|
|
646
|
+
const queueRing = qUser ? s(qUser['ring-no-answer-timeout-seconds']) : '';
|
|
647
|
+
const agentRing = q ? s(q['callqueue-agent-dispatch-timeout-seconds']) : '';
|
|
648
|
+
const qLines = [];
|
|
649
|
+
if (queueRing || agentRing)
|
|
650
|
+
qLines.push(`⏱ queue ring ${queueRing || '∞'}s · agent ring ${agentRing || '?'}s`);
|
|
651
|
+
if (/linear|cascade|hunt/i.test(dispatch)) {
|
|
652
|
+
const first = q ? s(q['callqueue-sim-ring-1st-round']) : '';
|
|
653
|
+
const inc = q ? s(q['callqueue-sim-ring-increment']) : '';
|
|
654
|
+
if (first || inc)
|
|
655
|
+
qLines.push(`initial group ${first || '1'} · +${inc || '0'} per agent-timeout`);
|
|
656
|
+
}
|
|
657
|
+
// Surface the shared queue priority ONCE when the whole queue is uniform at a notable value (2+);
|
|
658
|
+
// a compact badge keeps it out of the way for the common single-queue / baseline case.
|
|
659
|
+
if (uniformPrio >= PRIORITY_MIN_SHOWN)
|
|
660
|
+
qLines.push(`⭐ queue priority ${priorityBadge(uniformPrio)}`);
|
|
661
|
+
b.node(id, 'queue', `📋 Queue ${ext}${desc ? ` · ${desc}` : ''}`, dispatch || 'call queue', qLines);
|
|
662
|
+
}
|
|
663
|
+
if (!b.claim(id))
|
|
664
|
+
return id;
|
|
665
|
+
b.enter(id);
|
|
666
|
+
if (!isPark) {
|
|
667
|
+
const parsed = agents.map((a) => {
|
|
668
|
+
const aid = s(a['callqueue-agent-id']).replace(/^sip:/, '').split('@')[0];
|
|
669
|
+
const base = aid.replace(/[a-z]+$/i, ''); // strip device suffix (e.g. 102r → 102) for name lookup
|
|
670
|
+
const name = s(a['name-full-name']) || idx.userName(base) || aid;
|
|
671
|
+
const type = s(a['callqueue-agent-entry-type']) || 'user'; // user | device
|
|
672
|
+
const manual = s(a['callqueue-agent-availability-type']) === 'manual';
|
|
673
|
+
// Ring ORDER = the Linear Cascade round ("Order in Linear Hunt"). Read `ordinal-order`
|
|
674
|
+
// (live GET) or the manifest write-key `callqueue-agent-dispatch-order-ordinal` (preview).
|
|
675
|
+
// This is the cascade tiering — NOT the cross-queue priority ordinal (shown at queue level).
|
|
676
|
+
const order = Number(a['ordinal-order'] ?? a['callqueue-agent-dispatch-order-ordinal'] ?? 0);
|
|
677
|
+
const priority = Number(a['callqueue-agent-dispatch-queue-priority-ordinal'] ?? 0);
|
|
678
|
+
return { aid, name, type, manual, order, priority };
|
|
679
|
+
});
|
|
680
|
+
// Per-agent priority badge (P2️⃣, P3️⃣…) only for notable values (2+) and only when it isn't
|
|
681
|
+
// already summarized once at the queue node (uniformPrio). Blank (0) / baseline (1) render
|
|
682
|
+
// nothing — priority rarely matters unless an agent spans multiple queues.
|
|
683
|
+
const showPerAgentPrio = !uniformPrio;
|
|
684
|
+
// A USER-type entry → 👤 (rings per the user's own answering rules & devices). A DEVICE entry rings
|
|
685
|
+
// JUST that device regardless of the user's rules → icon by suffix (wp→🌐 web, t→💻 Teams, m/r→📱
|
|
686
|
+
// app), defaulting to 📞 a desk phone for a plain/unknown device.
|
|
687
|
+
const icon = (p) => {
|
|
688
|
+
if (p.type === 'user')
|
|
689
|
+
return '👤';
|
|
690
|
+
return deviceKindBySuffix((p.aid.match(/[a-z]+$/i)?.[0] ?? '').toLowerCase()).icon || '📞';
|
|
691
|
+
};
|
|
692
|
+
const fmt = (p) => `${icon(p)} ${p.name} (${p.aid})${p.manual ? ' · manual' : ''}${showPerAgentPrio && p.priority >= PRIORITY_MIN_SHOWN ? ` · ${priorityBadge(p.priority)}` : ''}`;
|
|
693
|
+
// Linear/cascade/hunt queues ring tier by tier — group by RING ORDER (the cascade rounds),
|
|
694
|
+
// NOT the cross-queue priority. `ordinal-order` is already 1-based (round 1 = order 1); keep
|
|
695
|
+
// gaps verbatim. Blank line above every tier (incl. the first) for consistent spacing.
|
|
696
|
+
const tiered = /linear|cascade|hunt/i.test(dispatch) && new Set(parsed.map((p) => p.order)).size > 1;
|
|
697
|
+
let lines;
|
|
698
|
+
if (tiered) {
|
|
699
|
+
lines = [];
|
|
700
|
+
[...new Set(parsed.map((p) => p.order))]
|
|
701
|
+
.sort((a, c) => a - c)
|
|
702
|
+
.forEach((t) => {
|
|
703
|
+
lines.push('');
|
|
704
|
+
lines.push(`Tier ${t}:`);
|
|
705
|
+
for (const p of parsed.filter((p) => p.order === t))
|
|
706
|
+
lines.push(` ${fmt(p)}`);
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
else {
|
|
710
|
+
lines = parsed.map((p) => fmt(p));
|
|
711
|
+
}
|
|
712
|
+
const ag = b.node(`agents_${ext}`, 'agents', `👥 ${parsed.length} agent${parsed.length > 1 ? 's' : ''}`, undefined, lines).id;
|
|
713
|
+
b.edge(id, ag, 'dispatch', dispatch || 'dispatch');
|
|
714
|
+
}
|
|
715
|
+
// Overflow via the queue's own answer rule. Emanate it from the AGENTS node (not the queue) so it
|
|
716
|
+
// reads as the post-ring fallback ("agents didn't answer → …"). Park queues have no agents node, so
|
|
717
|
+
// fall back to the queue node itself.
|
|
718
|
+
const overflowFrom = isPark ? id : `agents_${ext}`;
|
|
719
|
+
const rule = (idx.answerRules.get(s(ext)) ?? [])[0];
|
|
720
|
+
const na = rule ? firstParam(rule['forward-no-answer']) : null;
|
|
721
|
+
if (na) {
|
|
722
|
+
routeParam(na, overflowFrom, idx, b, 'overflow', 'no answer / timeout');
|
|
723
|
+
}
|
|
724
|
+
else if (!isPark) {
|
|
725
|
+
// No forward-no-answer target = the portal's "If unanswered → Stay in queue": on the queue-ring
|
|
726
|
+
// timeout the caller is NOT dropped to voicemail — they stay queued and the agents ring again.
|
|
727
|
+
// Draw it explicitly (a back-edge to the queue, which renders as a "↩ loops back" leaf) so the
|
|
728
|
+
// unanswered disposition is as visible as the if-unavailable one instead of silently vanishing.
|
|
729
|
+
b.edge(overflowFrom, id, 'overflow', 'if unanswered · stays in queue');
|
|
730
|
+
}
|
|
731
|
+
const busy = rule ? firstParam(rule['forward-on-busy']) : null;
|
|
732
|
+
if (busy)
|
|
733
|
+
routeParam(busy, overflowFrom, idx, b, 'overflow', 'if unavailable');
|
|
734
|
+
const unreg = rule ? firstParam(rule['forward-when-unregistered']) : null;
|
|
735
|
+
if (unreg && unreg !== busy)
|
|
736
|
+
routeParam(unreg, overflowFrom, idx, b, 'overflow', 'if unavailable');
|
|
737
|
+
return b.leave(id);
|
|
738
|
+
}
|
|
739
|
+
/** destination-application (AA option / dial rule) -> a routing target. */
|
|
740
|
+
function aaApp(app, dest, idx, b) {
|
|
741
|
+
if (/^to-callqueue/i.test(app))
|
|
742
|
+
return ensureQueue(dest, idx, b);
|
|
743
|
+
if (/^to-voicemail/i.test(app))
|
|
744
|
+
return ensureVoicemail(dest, idx, b);
|
|
745
|
+
if (/^to-connection/i.test(app))
|
|
746
|
+
return ensureExternal(digits(dest) || dest, b);
|
|
747
|
+
if (/^to-user|^to-single-device/i.test(app))
|
|
748
|
+
return ensureExt(dest, idx, b);
|
|
749
|
+
if (/directory/i.test(app))
|
|
750
|
+
return b.node('directory', 'user', '📇 Dial-by-name directory', 'sip:start@directory').id;
|
|
751
|
+
if (/^hangup/i.test(app))
|
|
752
|
+
return b.node(`hangup_aa_${dest || app}`, 'hangup', '☎️ Hang up').id;
|
|
753
|
+
const un = b.node(`unknown_${app}_${dest}`, 'unknown', app, dest || undefined).id;
|
|
754
|
+
b.note(`AA option application "${app}" not modeled.`);
|
|
755
|
+
return un;
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* Render an AA's menu from its OWN dialplan dialrules (authoritative — the /autoattendants detail
|
|
759
|
+
* omits no-key/star/option). Grammar (confirmed against live auto-attendants on two independent domains), suffix after
|
|
760
|
+
* `<startingPrompt>.`:
|
|
761
|
+
* Case_<0-9> → press that digit
|
|
762
|
+
* Case_[*] / Case_[#] → the literal * / # key
|
|
763
|
+
* Case_[0-9][0-9]… → dial-by-extension (bracket ranges)
|
|
764
|
+
* * → unassigned key ("Unknown Input")
|
|
765
|
+
* Default → no-key timeout
|
|
766
|
+
* Apps: Announce → play-message; Prompt→own prompt id → repeat greeting; else via aaApp().
|
|
767
|
+
* `detailTier` (the /autoattendants option-N structure, when present) enriches each key with its
|
|
768
|
+
* CNAM prefix + play-message script/audio, which the dialplan lacks.
|
|
769
|
+
*/
|
|
770
|
+
function renderAaFromDialrules(rules, startingPrompt, fromId, ext, idx, b, detailTier) {
|
|
771
|
+
const prefix = `${startingPrompt}.`;
|
|
772
|
+
const promptId = startingPrompt.replace(/^Prompt_/i, ''); // e.g. "912201"
|
|
773
|
+
let dialByExt = false;
|
|
774
|
+
const opts = [];
|
|
775
|
+
let noKey = null;
|
|
776
|
+
let unknown = null;
|
|
777
|
+
for (const r of rules) {
|
|
778
|
+
const uri = s(r['dial-rule-matching-to-uri']);
|
|
779
|
+
if (!uri.startsWith(prefix))
|
|
780
|
+
continue;
|
|
781
|
+
const suffix = uri.slice(prefix.length);
|
|
782
|
+
const e = { app: s(r['dial-rule-application']), dest: s(r['dial-rule-translation-destination-user']) };
|
|
783
|
+
if (suffix === 'Default')
|
|
784
|
+
noKey = { label: 'no key / timeout', sort: '~1', dtmf: '', ...e };
|
|
785
|
+
else if (suffix === '*')
|
|
786
|
+
unknown = { label: 'unknown input', sort: '~2', dtmf: '', ...e };
|
|
787
|
+
else if (suffix.startsWith('Case_')) {
|
|
788
|
+
const c = suffix.slice(5);
|
|
789
|
+
if (/^[0-9]$/.test(c))
|
|
790
|
+
opts.push({ label: `press ${c}`, sort: c, dtmf: c, ...e });
|
|
791
|
+
else if (c === '[*]')
|
|
792
|
+
opts.push({ label: 'press *', sort: '*', dtmf: '*', ...e });
|
|
793
|
+
else if (c === '[#]')
|
|
794
|
+
opts.push({ label: 'press #', sort: '#', dtmf: '#', ...e });
|
|
795
|
+
else if (c.includes('['))
|
|
796
|
+
dialByExt = true; // bracket ranges → dial-by-extension
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
const seen = new Set();
|
|
800
|
+
const route = (o) => {
|
|
801
|
+
const key = `${o.label}->${o.app}:${o.dest}`;
|
|
802
|
+
if (seen.has(key))
|
|
803
|
+
return;
|
|
804
|
+
seen.add(key);
|
|
805
|
+
// enrich from the detail's matching option (CNAM + play-message script), when available
|
|
806
|
+
const opt = o.dtmf ? detailTier?.[`option-${o.dtmf}`] : undefined;
|
|
807
|
+
const cnam = opt ? s(opt['caller-name-translation']) : '';
|
|
808
|
+
const script = opt ? s(opt.audio?.['file-script-text']) : '';
|
|
809
|
+
const label = o.label + (cnam && cnam !== '[*]' ? ` · ${cnam}` : '');
|
|
810
|
+
let target;
|
|
811
|
+
if (/^announce/i.test(o.app))
|
|
812
|
+
target = b.node(`aaannounce_${ext}_${o.dest}`, 'prompt', `🔊 ${script ? `“${trim(script)}”` : 'Play message'}`, undefined, undefined, script.length > GREET_MAX ? script : undefined).id;
|
|
813
|
+
else if (/^prompt/i.test(o.app))
|
|
814
|
+
target = o.dest === promptId ? b.node(`aarepeat_${ext}`, 'prompt', '🔁 Repeat greeting', 're-plays the menu').id : b.node(`aaprompt_${ext}_${o.dest}`, 'prompt', '🔊 Play prompt', o.dest || undefined).id;
|
|
815
|
+
else
|
|
816
|
+
target = aaApp(o.app, o.dest, idx, b);
|
|
817
|
+
b.edge(fromId, target, 'menu', label);
|
|
818
|
+
};
|
|
819
|
+
for (const o of opts.sort((a, c) => a.sort.localeCompare(c.sort)))
|
|
820
|
+
route(o);
|
|
821
|
+
if (noKey)
|
|
822
|
+
route(noKey);
|
|
823
|
+
if (unknown)
|
|
824
|
+
route(unknown);
|
|
825
|
+
if (dialByExt)
|
|
826
|
+
b.edge(fromId, b.node(`aadial_${ext}`, 'user', '⌨️ Dial by extension').id, 'menu', 'dial ext');
|
|
827
|
+
}
|
|
828
|
+
/** Render one AA menu tier from `fromId`, recursing into nested submenus. */
|
|
829
|
+
function renderAaTier(tier, fromId, ext, idx, b, path) {
|
|
830
|
+
const optKeys = Object.keys(tier)
|
|
831
|
+
.filter((k) => /^option-/.test(k))
|
|
832
|
+
.sort();
|
|
833
|
+
for (const k of optKeys) {
|
|
834
|
+
const o = tier[k];
|
|
835
|
+
if (!o || typeof o !== 'object')
|
|
836
|
+
continue;
|
|
837
|
+
const dtmf = k.replace('option-', '');
|
|
838
|
+
const app = s(o['destination-application']);
|
|
839
|
+
const dest = s(o['destination-user']);
|
|
840
|
+
const greet = s(o.audio?.['file-script-text']);
|
|
841
|
+
const label = `press ${dtmf}`;
|
|
842
|
+
if (o['auto-attendant'] && typeof o['auto-attendant'] === 'object') {
|
|
843
|
+
const subId = b.node(`aa_${ext}_${path}${dtmf}`, 'attendant', `🔀 Submenu (press ${dtmf})`, greet ? `“${trim(greet)}”` : 'nested menu', undefined, greet.length > GREET_MAX ? greet : undefined).id;
|
|
844
|
+
b.edge(fromId, subId, 'menu', label);
|
|
845
|
+
renderAaTier(o['auto-attendant'], subId, ext, idx, b, `${path}${dtmf}_`);
|
|
846
|
+
continue;
|
|
847
|
+
}
|
|
848
|
+
if (/^play-message/i.test(app)) {
|
|
849
|
+
const pr = b.node(`prompt_${ext}_${path}${dtmf}`, 'prompt', `🔊 ${greet ? `“${trim(greet)}”` : 'Play message'}`, 'announcement', undefined, greet.length > GREET_MAX ? greet : undefined).id;
|
|
850
|
+
b.edge(fromId, pr, 'menu', label);
|
|
851
|
+
continue;
|
|
852
|
+
}
|
|
853
|
+
b.edge(fromId, aaApp(app, dest, idx, b), 'menu', greet ? `${label} · “${greet}”` : label);
|
|
854
|
+
}
|
|
855
|
+
// Default behaviors from the portal "Options" dialog — only on the top menu tier (submenus have
|
|
856
|
+
// their own, but showing them everywhere clutters). no-key-press / unassigned-key-press + dial-by-ext.
|
|
857
|
+
if (path === '') {
|
|
858
|
+
// Empty ≠ "repeat": the /autoattendants detail returns empty no-key/unassigned when the real
|
|
859
|
+
// behavior (incl. "Follow *" / star-to-voicemail) lives in the AA's OWN dialplan dialrules
|
|
860
|
+
// (Prompt_<id>.Default / .*), which this endpoint omits. Only render explicit values; note the gap.
|
|
861
|
+
const nk = s(tier['no-key-press']);
|
|
862
|
+
const uk = s(tier['unassigned-key-press']);
|
|
863
|
+
if (nk)
|
|
864
|
+
resolveAaDefault(tier, nk, 'no key', fromId, ext, idx, b);
|
|
865
|
+
if (uk)
|
|
866
|
+
resolveAaDefault(tier, uk, 'invalid key', fromId, ext, idx, b);
|
|
867
|
+
if (!nk || !uk)
|
|
868
|
+
b.note(`AA ${ext}: the no-key / invalid-key default (and any "Follow *" star-to-voicemail) isn't in the /autoattendants detail — it lives in the AA's own dialplan dialrules (Prompt_<id>.Default / .*), not yet rendered here.`);
|
|
869
|
+
const dialDigits = ['3', '4', '5'].filter((d) => s(tier[`${d}-digit-dial-by-extension`]) === 'yes');
|
|
870
|
+
if (dialDigits.length) {
|
|
871
|
+
const dn = b.node(`aadial_${ext}`, 'user', '⌨️ Dial by extension', `${dialDigits.join('/')}-digit`).id;
|
|
872
|
+
b.edge(fromId, dn, 'menu', 'dial ext');
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
/** Render an AA no-key-press / unassigned-key-press default: repeat greeting, hang up, or "follow"
|
|
877
|
+
* another option (route as if that key were pressed). */
|
|
878
|
+
function resolveAaDefault(tier, value, label, fromId, ext, idx, b) {
|
|
879
|
+
const v = s(value) || 'repeat';
|
|
880
|
+
if (v === 'repeat') {
|
|
881
|
+
const rn = b.node(`aarepeat_${ext}`, 'prompt', '🔁 Repeat greeting', 're-plays the menu').id;
|
|
882
|
+
b.edge(fromId, rn, 'menu', label);
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
if (v === 'hangup') {
|
|
886
|
+
b.edge(fromId, b.node(`aahangup_${ext}`, 'hangup', '☎️ Hang up').id, 'menu', label);
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
const m = v.match(/^option-(.+)$/);
|
|
890
|
+
if (m) {
|
|
891
|
+
const dtmf = m[1];
|
|
892
|
+
const opt = tier[`option-${dtmf}`];
|
|
893
|
+
if (opt && typeof opt === 'object' && !opt['auto-attendant'] && !/^play-message/i.test(s(opt['destination-application']))) {
|
|
894
|
+
b.edge(fromId, aaApp(s(opt['destination-application']), s(opt['destination-user']), idx, b), 'menu', `${label} → key ${dtmf}`);
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
b.edge(fromId, b.node(`aakey_${ext}_${dtmf}`, 'prompt', `↪ Follow key ${dtmf}`).id, 'menu', label);
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
b.edge(fromId, b.node(`aadef_${ext}_${v}`, 'unknown', v).id, 'menu', label);
|
|
901
|
+
}
|
|
902
|
+
function ensureAttendant(ext, idx, b) {
|
|
903
|
+
const id = `aa_${ext}`;
|
|
904
|
+
const aa = idx.attendantsByExt.get(s(ext));
|
|
905
|
+
const detail = idx.aaDetailByExt.get(s(ext));
|
|
906
|
+
const nm = s(detail?.['attendant-name']) || (aa ? s(aa['attendant-name']) : '');
|
|
907
|
+
const greet = s(detail?.audio?.['file-script-text']);
|
|
908
|
+
// SV builds AAs on the always-available `*` timeframe; a specific timeframe is unusual. Show it
|
|
909
|
+
// plainly on the node (clear display) rather than as a loud warning — the loud validation belongs
|
|
910
|
+
// in the ns-onboard/backup path, not the viewer.
|
|
911
|
+
const aaTf = s(detail?.['time-frame']);
|
|
912
|
+
const tfTag = aaTf && aaTf !== '*' ? ` · timeframe ${aaTf}` : '';
|
|
913
|
+
b.node(id, 'attendant', `🔀 Auto Attendant ${ext}${nm ? ` · ${nm}` : ''}`, (greet ? `“${trim(greet)}”` : 'plays menu') + tfTag, undefined, greet.length > GREET_MAX ? greet : undefined);
|
|
914
|
+
if (!b.claim(id))
|
|
915
|
+
return id;
|
|
916
|
+
b.enter(id);
|
|
917
|
+
// Menu source (dialplan-preferred), computed first so intros can be a preamble the menu flows out of.
|
|
918
|
+
const startingPrompt = s(detail?.['starting-prompt']) || (aa ? s(aa['starting-prompt']) : '');
|
|
919
|
+
const aaRules = idx.aaDialrulesByExt.get(s(ext));
|
|
920
|
+
const hasDialruleMenu = !!(aaRules && aaRules.length && startingPrompt);
|
|
921
|
+
const hasTierMenu = !hasDialruleMenu && !!detail?.['auto-attendant'];
|
|
922
|
+
// Per-timeframe intro greetings play FIRST, before the menu. Render as a preamble the menu flows out
|
|
923
|
+
// of (via a "Menu" hub), not as a sibling of the keypress options. Skip empty slots ({tf:null, audio:[]}).
|
|
924
|
+
const introNode = (iv) => {
|
|
925
|
+
const script = s(iv.audio['file-script-text']);
|
|
926
|
+
return b.node(`aaintro_${ext}_${iv.tf}`, 'prompt', `🔊 ${script ? `“${trim(script)}”` : 'Intro greeting'}`, 'intro greeting', undefined, script.length > GREET_MAX ? script : undefined).id;
|
|
927
|
+
};
|
|
928
|
+
const validIntros = (Array.isArray(detail?.['intro-greetings']) ? detail['intro-greetings'] : [])
|
|
929
|
+
.map((ig) => ({ tf: s(ig?.['time-frame']), audio: ig?.audio }))
|
|
930
|
+
.filter((x) => x.tf && x.audio && !Array.isArray(x.audio) && typeof x.audio === 'object');
|
|
931
|
+
let menuSource = id;
|
|
932
|
+
if (validIntros.length && (hasDialruleMenu || hasTierMenu)) {
|
|
933
|
+
const hub = b.node(`aamenu_${ext}`, 'attendant', '🔀 Menu', 'keypress options').id;
|
|
934
|
+
let alwaysIntro = false;
|
|
935
|
+
for (const iv of validIntros) {
|
|
936
|
+
const isDefault = /^(default|\*)$/i.test(iv.tf);
|
|
937
|
+
alwaysIntro ||= isDefault;
|
|
938
|
+
const pr = introNode(iv);
|
|
939
|
+
b.edge(id, pr, 'route', isDefault ? 'plays intro' : timeframeSchedule(iv.tf, idx));
|
|
940
|
+
b.edge(pr, hub, 'route');
|
|
941
|
+
}
|
|
942
|
+
if (!alwaysIntro)
|
|
943
|
+
b.edge(id, hub, 'route', 'otherwise'); // menu also reached outside the intro timeframe(s)
|
|
944
|
+
menuSource = hub;
|
|
945
|
+
}
|
|
946
|
+
else {
|
|
947
|
+
for (const iv of validIntros)
|
|
948
|
+
b.edge(id, introNode(iv), 'time', timeframeSchedule(iv.tf, idx));
|
|
949
|
+
}
|
|
950
|
+
// Extra timeframe menus (rare) — a neutral, informational note so hidden menus aren't a surprise.
|
|
951
|
+
const allDetails = idx.aaDetailsByExt.get(s(ext)) ?? [];
|
|
952
|
+
if (allDetails.length > 1) {
|
|
953
|
+
const others = allDetails.filter((d) => d !== detail).map((d) => s(d['time-frame']) || 'default').join(', ');
|
|
954
|
+
b.note(`AA ${ext} has ${allDetails.length} timeframe menus (also: ${others}); showing the ${aaTf && aaTf !== '*' ? aaTf : 'default'} menu.`);
|
|
955
|
+
}
|
|
956
|
+
// The menu (dialplan-preferred; the /autoattendants detail omits no-key/star) flows out of menuSource.
|
|
957
|
+
if (hasDialruleMenu) {
|
|
958
|
+
renderAaFromDialrules(aaRules, startingPrompt, menuSource, s(ext), idx, b, detail?.['auto-attendant']);
|
|
959
|
+
return b.leave(id);
|
|
960
|
+
}
|
|
961
|
+
if (hasTierMenu) {
|
|
962
|
+
renderAaTier(detail['auto-attendant'], menuSource, s(ext), idx, b, '');
|
|
963
|
+
return b.leave(id);
|
|
964
|
+
}
|
|
965
|
+
// No menu detail (backup-only) -> show the greeting prompt + flag the gap.
|
|
966
|
+
const rule = (idx.answerRules.get(s(ext)) ?? [])[0];
|
|
967
|
+
const fa = rule ? firstParam(rule['forward-always']) : null;
|
|
968
|
+
if (fa && /^Prompt_/i.test(fa)) {
|
|
969
|
+
const pr = b.node(`prompt_${fa}`, 'prompt', `🔊 ${fa}`, 'greeting / announcement').id;
|
|
970
|
+
b.edge(id, pr, 'route', 'plays');
|
|
971
|
+
}
|
|
972
|
+
else if (aa && s(aa['starting-prompt'])) {
|
|
973
|
+
const sp = s(aa['starting-prompt']);
|
|
974
|
+
const pr = b.node(`prompt_${sp}`, 'prompt', `🔊 ${sp}`, 'starting prompt').id;
|
|
975
|
+
b.edge(id, pr, 'route', 'plays');
|
|
976
|
+
}
|
|
977
|
+
b.note(`Auto-attendant ${ext} keypress menu not in this snapshot — fetch GET /domains/{d}/users/${ext}/autoattendants/{prompt} to render options.`);
|
|
978
|
+
return b.leave(id);
|
|
979
|
+
}
|
|
980
|
+
function ensureVoicemail(ext, idx, b) {
|
|
981
|
+
const id = `vm_${ext}`;
|
|
982
|
+
const name = idx.userName(ext);
|
|
983
|
+
b.node(id, 'voicemail', `📭 Voicemail ${ext}`, name && name !== s(ext) ? name : undefined);
|
|
984
|
+
return id;
|
|
985
|
+
}
|
|
986
|
+
function ensureExternal(number, b) {
|
|
987
|
+
const id = `ext_${number}`;
|
|
988
|
+
b.node(id, 'external', `☎️ ${prettyPhone(number)}`, 'external / off-net');
|
|
989
|
+
return id;
|
|
990
|
+
}
|
|
991
|
+
/** Route a raw param string from `fromId`: classify, ensure target, edge, recurse. */
|
|
992
|
+
function routeParam(param, fromId, idx, b, edgeKind, edgeLabel) {
|
|
993
|
+
const t = classifyParam(param, idx);
|
|
994
|
+
switch (t.kind) {
|
|
995
|
+
case 'queue':
|
|
996
|
+
b.edge(fromId, ensureQueue(t.ext, idx, b), edgeKind, edgeLabel);
|
|
997
|
+
break;
|
|
998
|
+
case 'attendant':
|
|
999
|
+
b.edge(fromId, ensureAttendant(t.ext, idx, b), edgeKind, edgeLabel);
|
|
1000
|
+
break;
|
|
1001
|
+
case 'voicemail':
|
|
1002
|
+
b.edge(fromId, ensureVoicemail(t.ext, idx, b), edgeKind, edgeLabel);
|
|
1003
|
+
break;
|
|
1004
|
+
case 'user':
|
|
1005
|
+
b.edge(fromId, ensureExt(t.ext, idx, b), edgeKind, edgeLabel);
|
|
1006
|
+
break;
|
|
1007
|
+
case 'external':
|
|
1008
|
+
b.edge(fromId, ensureExternal(t.number, b), edgeKind, edgeLabel);
|
|
1009
|
+
break;
|
|
1010
|
+
case 'prompt': {
|
|
1011
|
+
const pr = b.node(`prompt_${t.promptId}`, 'prompt', `🔊 ${t.promptId}`, 'greeting').id;
|
|
1012
|
+
b.edge(fromId, pr, edgeKind, edgeLabel);
|
|
1013
|
+
break;
|
|
1014
|
+
}
|
|
1015
|
+
case 'devices':
|
|
1016
|
+
// bare <OwnDevices> as a forward target is unusual; treat as ring-self terminal.
|
|
1017
|
+
b.edge(fromId, b.node(`dev_self_${fromId}`, 'devices', '📱 Ring own devices').id, edgeKind, edgeLabel);
|
|
1018
|
+
break;
|
|
1019
|
+
case 'hangup':
|
|
1020
|
+
b.edge(fromId, b.node(`hangup_${fromId}`, 'hangup', '☎️ Hang up').id, edgeKind, edgeLabel);
|
|
1021
|
+
break;
|
|
1022
|
+
default: {
|
|
1023
|
+
const un = b.node(`unknown_${param}`, 'unknown', param, 'unrecognized target').id;
|
|
1024
|
+
b.edge(fromId, un, edgeKind, edgeLabel);
|
|
1025
|
+
b.note(`Unrecognized routing target "${param}".`);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
function prettyPhone(num) {
|
|
1030
|
+
const d = digits(num);
|
|
1031
|
+
const n = d.length === 11 && d.startsWith('1') ? d.slice(1) : d;
|
|
1032
|
+
if (n.length === 10)
|
|
1033
|
+
return `${n.slice(0, 3)}-${n.slice(3, 6)}-${n.slice(6)}`;
|
|
1034
|
+
return num;
|
|
1035
|
+
}
|
|
1036
|
+
// ---------------------------------------------------------------------------
|
|
1037
|
+
// Entity enumeration (for the CLI picker / "list" mode)
|
|
1038
|
+
// ---------------------------------------------------------------------------
|
|
1039
|
+
/** DID action categories for the entity picker, in display order. */
|
|
1040
|
+
export const DID_ACTIONS = {
|
|
1041
|
+
timeframe: { order: 1, label: 'Time-of-day routing' },
|
|
1042
|
+
extension: { order: 2, label: 'To extension' },
|
|
1043
|
+
queue: { order: 3, label: 'To call queue' },
|
|
1044
|
+
attendant: { order: 4, label: 'To auto attendant' },
|
|
1045
|
+
voicemail: { order: 5, label: 'To voicemail' },
|
|
1046
|
+
other: { order: 6, label: 'Other routing' },
|
|
1047
|
+
fax: { order: 7, label: 'Fax / connection' },
|
|
1048
|
+
available: { order: 8, label: 'Available (unassigned)' },
|
|
1049
|
+
};
|
|
1050
|
+
/** Categorize a DID by its routing. Time-of-day is detected from the destination user's answer
|
|
1051
|
+
* rules (>1 enabled rule = TOD) when those rules are present in the snapshot; otherwise a
|
|
1052
|
+
* to-user DID falls under "extension". */
|
|
1053
|
+
function classifyDidAction(p, idx) {
|
|
1054
|
+
const app = s(p['dial-rule-application']).toLowerCase();
|
|
1055
|
+
const dest = s(p['dial-rule-translation-destination-user']);
|
|
1056
|
+
if (app.startsWith('available-number'))
|
|
1057
|
+
return 'available';
|
|
1058
|
+
if (app.startsWith('to-connection'))
|
|
1059
|
+
return 'fax';
|
|
1060
|
+
if (app.startsWith('to-callqueue'))
|
|
1061
|
+
return 'queue';
|
|
1062
|
+
if (app.startsWith('to-voicemail'))
|
|
1063
|
+
return 'voicemail';
|
|
1064
|
+
if (app.startsWith('to-user')) {
|
|
1065
|
+
const kind = idx.classifyExt(dest);
|
|
1066
|
+
if (kind === 'attendant')
|
|
1067
|
+
return 'attendant';
|
|
1068
|
+
if (kind === 'queue')
|
|
1069
|
+
return 'queue';
|
|
1070
|
+
const rules = (idx.answerRules.get(dest) ?? []).filter((r) => s(r.enabled) === 'yes');
|
|
1071
|
+
return rules.length >= 2 ? 'timeframe' : 'extension';
|
|
1072
|
+
}
|
|
1073
|
+
return 'other';
|
|
1074
|
+
}
|
|
1075
|
+
export function listEntities(snap) {
|
|
1076
|
+
const idx = new Index(snap);
|
|
1077
|
+
const dids = (snap.phonenumbers ?? [])
|
|
1078
|
+
.map((p) => {
|
|
1079
|
+
const action = classifyDidAction(p, idx);
|
|
1080
|
+
return { ref: nat(p.phonenumber), label: prettyPhone(s(p.phonenumber)), desc: s(p['dial-rule-description']), action, actionLabel: DID_ACTIONS[action].label, order: DID_ACTIONS[action].order };
|
|
1081
|
+
})
|
|
1082
|
+
.sort((a, b) => a.order - b.order || a.label.localeCompare(b.label));
|
|
1083
|
+
return {
|
|
1084
|
+
dids,
|
|
1085
|
+
users: (snap.users ?? [])
|
|
1086
|
+
.filter((u) => !idx.queuesByExt.has(s(u.user)) && !idx.attendantsByExt.has(s(u.user)))
|
|
1087
|
+
.map((u) => ({ ref: s(u.user), label: idx.userName(s(u.user)) })),
|
|
1088
|
+
queues: (snap.callqueues ?? []).map((q) => ({ ref: s(q.callqueue), label: s(q.description) })),
|
|
1089
|
+
attendants: (snap.autoattendants ?? []).map((a) => ({ ref: s(a.user), label: s(a['attendant-name']) })),
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
//# sourceMappingURL=resolver.js.map
|