@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.
@@ -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