collabSupport/services/renderers/voiceDiagRenderer.js
jmcqueen 2eb31a2ddc Add /voicediag rules-engine command with 9 per-user calling checks
Introduces a new diagnostic command that walks a registry of check
modules against a store user's Webex Calling configuration and
surfaces per-issue adaptive-card remediation for the fixable ones.

Checks (services/voiceDiag/checks/): dnd, callForwarding, callWaiting,
callIntercept, voicemail, hoteling, executiveAssistant,
outgoingPermission, phoneOnline. Remediations offered for DND,
forwarding, waiting, and intercept.

Uses the /v1/people/{id}/features/* admin surface (spark-admin:people_read
+ spark-admin:people_write scopes we already hold) — the earlier
telephony/config/people/*/callSettings/* path scheme returns 404 from
the Webex gateway and is not a live surface. Runner distinguishes
routing-404s ("URL moved") from "not applicable" 404s ("no calling
license") via the response body.

Arg parser accepts detail/detailed/--detail/--detailed and normalises
macOS smart-dashes so --detailed doesn't die when auto-correct
turns it into an em-dash.

Wires a VOICEDIAG_ACTIONS dispatcher in index.js mirroring the IGMP
branch, and registers /voicediag in commands/registry.js. 170 tests
pass (52 new: 39 check + 12 renderer + 5 arg-normalization).

Docs updated in .env.example, services/phoneService.js:467, and a new
services/voiceDiag/README.md that includes a "how to add a check"
recipe plus a note on the earlier wrong URL scheme.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 18:51:31 -04:00

165 lines
5 KiB
JavaScript

// src/services/renderers/voiceDiagRenderer.js
//
// Renders the /voicediag CheckResult array into Webex-friendly
// markdown. Pure function — no I/O — so it's usable from both the
// chat command and (later) HTTP surfaces without recomputing.
//
// Layout:
//
// **Voice Diagnostic - Store 12345** (user: ae12345@ae.com — Store 12345)
//
// Errors (1) Warnings (2) Skipped (1) OK (5)
//
// **ERRORS**
// - **Call Intercept**: Call intercept is ACTIVE — inbound calls...
// • incomingType: INTERCEPT_ALL, outgoingType: ALLOW_ALL
//
// **WARNINGS**
// - **Do Not Disturb**: DND is enabled — incoming calls silenced.
// - **Call Forwarding**: 1 forwarding variant is active: ...
//
// **SKIPPED**
// - **Call Waiting**: 403 on Call Waiting — likely missing scope ...
//
// **OK** (5 — hidden; pass `detailed` to see them)
//
// Fixable issues (2) — see confirmation cards below.
//
// Options
// storeNum (required) header text
// detailed (default false) — expands OK checks + shows the
// `details` block under every check
// emitFooter (default true) — trailing italic timestamp
//
// The renderer never emits an adaptive card itself. The caller
// (commands/voiceDiag.js) walks the same results array to post cards.
const SEVERITY_ORDER = ['error', 'warn', 'skipped', 'ok'];
const SEVERITY_LABEL = {
error: 'ERRORS',
warn: 'WARNINGS',
skipped: 'SKIPPED',
ok: 'OK',
};
/**
* @param {Array<{id, label, status, message, details, remediation}>} results
* @param {{
* storeNum: string,
* personLabel?: string,
* email?: string,
* detailed?: boolean,
* emitFooter?: boolean,
* }} [opts]
* @returns {string} markdown, whitespace-trimmed
*/
export function renderVoiceDiagMarkdown(results, opts = {}) {
const {
storeNum,
personLabel = null,
email = null,
detailed = false,
emitFooter = true,
} = opts;
const list = Array.isArray(results) ? results : [];
let reply = `**Voice Diagnostic - Store ${storeNum}**`;
if (personLabel || email) {
const who = [personLabel, email].filter(Boolean).join(' — ');
reply += ` (user: ${who})`;
}
reply += '\n\n';
if (list.length === 0) {
reply += '_No checks were executed. Verify the store number and re-run._\n';
return reply.trim();
}
const counts = countBySeverity(list);
reply += summaryLine(counts) + '\n\n';
for (const severity of SEVERITY_ORDER) {
const bucket = list.filter((r) => r.status === severity);
if (bucket.length === 0) continue;
// Hide the OK bucket from the console body unless detailed —
// keeps the default output focused on what's actionable.
if (severity === 'ok' && !detailed) continue;
reply += `**${SEVERITY_LABEL[severity]}**\n`;
for (const r of bucket) {
reply += `- **${r.label}**: ${r.message}\n`;
if (detailed && r.details && Object.keys(r.details).length > 0) {
reply += ` - ${renderDetails(r.details)}\n`;
}
}
reply += '\n';
}
const fixable = list.filter((r) => r.status !== 'ok' && r.remediation);
if (fixable.length > 0) {
reply +=
`**Fixable issues (${fixable.length})** — see confirmation cards below.\n\n` +
fixable.map((r) => `- ${r.label}: ${r.remediation.title}`).join('\n') +
'\n\n';
}
if (!detailed && counts.ok > 0) {
reply += `_${counts.ok} OK check(s) hidden — pass \`detailed\` to include them._\n\n`;
}
if (emitFooter) {
const now = new Date();
reply += `_Last checked: ${now.toISOString()}_\n`;
}
return reply.trim();
}
function countBySeverity(list) {
const counts = { error: 0, warn: 0, skipped: 0, ok: 0 };
for (const r of list) {
if (Object.prototype.hasOwnProperty.call(counts, r.status)) {
counts[r.status] += 1;
}
}
return counts;
}
function summaryLine({ error, warn, skipped, ok }) {
return `Errors (${error}) · Warnings (${warn}) · Skipped (${skipped}) · OK (${ok})`;
}
// Compact one-line rendering of the details object. Truncates arrays
// past a small length so the chat message doesn't blow up on
// verbose payloads (outgoingPermission's rule list, for example).
function renderDetails(details) {
if (details === null || details === undefined) return '';
if (typeof details !== 'object') return String(details);
const parts = [];
for (const [k, v] of Object.entries(details)) {
parts.push(`${k}: ${formatValue(v)}`);
}
return parts.join(', ');
}
function formatValue(v) {
if (v === null || v === undefined) return '—';
if (typeof v === 'boolean' || typeof v === 'number' || typeof v === 'string') {
return String(v);
}
if (Array.isArray(v)) {
if (v.length === 0) return '[]';
if (v.length <= 3) return `[${v.map(formatValue).join(', ')}]`;
return `[${v.slice(0, 3).map(formatValue).join(', ')}, …+${v.length - 3}]`;
}
// Fall through: nested object — collapse to a JSON snippet.
try {
const s = JSON.stringify(v);
return s.length > 120 ? `${s.slice(0, 117)}` : s;
} catch {
return '[object]';
}
}