collabSupport/commands/voiceDiag.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

314 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// src/commands/voiceDiag.js
//
// /voicediag <storeNum> [detail] [--only id1,id2]
// /voicediag list-checks
//
// Thin orchestrator around services/voiceDiag/voiceDiagService.js. This
// handler:
//
// 1. Parses args (or query params on the HTTP path).
// 2. Runs `buildContext(storeNum)` + `runVoiceDiag(ctx, opts)`.
// 3. Renders the results via voiceDiagRenderer.
// 4. For every fixable result, posts a per-issue confirmation card
// through the same "pending map + one-shot dispatch" pattern used
// by /webexhost, /offboarduser, and the IGMP-fix inline card.
//
// The remediation dispatch (`applyVoiceDiagRemediation` /
// `cancelVoiceDiagRemediation`) is called from index.js after the
// `attachmentAction` router matches `confirm_voicediag` /
// `cancel_voicediag`. Each pending card entry carries its
// `remediationId`, which we look up in a registry built from every
// check's `remediations` export. Adding a new check with a new
// remediation therefore requires zero changes to index.js — the
// action namespace routes through this single dispatcher.
import { randomUUID } from 'node:crypto';
import { logger } from '../utils/logger.js';
import { extractRequester, describeRequester } from '../utils/requester.js';
import { pendingVoiceFixes } from '../utils/pendingVoiceFixes.js';
import { renderVoiceDiagMarkdown } from '../services/renderers/voiceDiagRenderer.js';
import {
buildContext,
runVoiceDiag,
buildRemediationRegistry,
CHECKS,
} from '../services/voiceDiag/voiceDiagService.js';
// Built once at module load. `buildRemediationRegistry` throws on
// duplicate action ids, so any collision surfaces at import time —
// which is exactly what we want.
const REMEDIATION_REGISTRY = buildRemediationRegistry();
export async function handleVoiceDiag(bot, trigger) {
logger('voicediag', 'Handler entered', 'debug');
const query = trigger.query || {};
// Normalise Unicode dashes → ASCII `--`. macOS auto-correct silently
// turns `--detailed` into `—detailed` when typed inline in Webex,
// and the framework passes the arg through verbatim. Do the same
// for a run of consecutive hyphens (`---detailed` → `--detailed`).
const args = (trigger.args || []).map(normalizeArg);
// Special-case a discovery / debug mode.
if ((args[0] || query.mode || '').toString().toLowerCase() === 'list-checks') {
await bot.say('markdown', renderListChecks());
return;
}
let storeNum = args[0]?.trim() || query.storeNum || query.store || query.s;
// Accept every reasonable variant so the footer hint ("pass
// `detailed`") and the /phonestatus muscle memory ("`detailed`" as
// the second positional) both work, alongside the flag forms
// (`--detail`, `--detailed`) and the `?detailed=true` query param.
const detailed = argIncludes(args, 'detail')
|| argIncludes(args, 'detailed')
|| argIncludes(args, '--detail')
|| argIncludes(args, '--detailed')
|| query.mode === 'detailed'
|| query.detailed === 'true'
|| query.detailed === true;
const onlyRaw = pickOnlyArg(args) ?? query.only ?? '';
const only = String(onlyRaw)
.split(',')
.map((s) => s.trim())
.filter(Boolean);
if (!storeNum || !/^\d{2,4}$/.test(storeNum)) {
await bot.say(
'markdown',
'Please provide a 24 digit store number.\n' +
'Examples:\n' +
'- `/voicediag 782`\n' +
'- `/voicediag 782 detailed`\n' +
'- `/voicediag 782 --only dnd,callForwarding`\n' +
'- `/voicediag list-checks`',
);
return;
}
let ctx;
try {
ctx = await buildContext(storeNum);
} catch (err) {
logger('voicediag', `buildContext failed for store ${storeNum}: ${err.message}`, 'error');
await bot.say('markdown', `❌ Failed to build diagnostic context for store ${storeNum}: ${err.message}`);
return;
}
if (!ctx.personId) {
await bot.say(
'markdown',
`❌ Could not resolve a Webex user for store ${storeNum} (expected \`${ctx.email}\`). ` +
`Verify the store number is correct.`,
);
return;
}
let results;
try {
results = await runVoiceDiag(ctx, { only });
} catch (err) {
// runVoiceDiag should never throw (each check is isolated), but
// guard anyway so a runner-level bug surfaces to the operator.
logger('voicediag', `runVoiceDiag threw for store ${storeNum}: ${err.message}`, 'error');
await bot.say('markdown', `❌ Diagnostic run failed for store ${storeNum}: ${err.message}`);
return;
}
const reply = renderVoiceDiagMarkdown(results, {
storeNum: ctx.storeNum,
personLabel: ctx.personLabel,
email: ctx.email,
detailed,
});
await bot.say('markdown', reply || 'No diagnostic results.');
// Adaptive-card remediation is chat-only. HTTP callers get the
// markdown snapshot but no interactive cards (same rule the
// /phonestatus + IGMP-fix inline card follows). `trigger.person`
// is populated for chat, absent for the HTTP fake trigger.
if (!trigger.person) return;
const requester = extractRequester(trigger);
const fixable = results.filter((r) => r.status !== 'ok' && r.remediation);
for (const result of fixable) {
await postRemediationCard(bot, {
storeNum: ctx.storeNum,
result,
requester,
});
}
}
/**
* apply / cancel dispatchers — invoked from index.js after the
* attachmentAction router matches `confirm_voicediag` /
* `cancel_voicediag`.
*
* The pending payload (see pendingVoiceFixes) always carries a
* `remediationId` and `remediationPayload`. `remediationId` looks up
* the check that owns the action + its handler in REMEDIATION_REGISTRY.
*/
export async function applyVoiceDiagRemediation(bot, data, _roomId, requester) {
const entry = REMEDIATION_REGISTRY.get(data?.remediationId);
if (!entry) {
logger(
'voicediag:action',
`No handler registered for remediationId "${data?.remediationId}" — ignoring`,
'warn',
);
await bot.say(
'markdown',
`⚠️ No handler is registered for remediation \`${data?.remediationId || 'unknown'}\`. ` +
`This card may have been created by an older build of the bot.`,
);
return;
}
logger(
'voicediag:audit',
`Dispatching ${data.remediationId} for store ${data?.storeNum} ` +
`(check=${entry.check.id}) requested by ${describeRequester(requester)}`,
);
await entry.handler(bot, data.remediationPayload || {}, requester);
}
export async function cancelVoiceDiagRemediation(bot, data, _roomId, requester) {
const { storeNum, remediationId, personLabel } = data || {};
await bot.say(
'markdown',
`❌ Cancelled \`${remediationId || 'voicediag'}\` for **${personLabel || `store ${storeNum || '?'}`}**. No changes were made.`,
);
logger(
'voicediag:audit',
`CANCELLED ${remediationId || 'unknown'} for store ${storeNum || '?'} ` +
`by ${describeRequester(requester)}`,
);
}
// ─── internals ────────────────────────────────────────────────────
function argIncludes(args, needle) {
const n = String(needle).toLowerCase();
return args.some((a) => String(a).toLowerCase() === n);
}
// Coerce macOS-style smart-dashes back to plain ASCII hyphens so
// `—detailed` (em-dash), `detailed` (en-dash), and `---detailed`
// all end up matching `--detailed`. Applied per-arg before any
// other parsing. Exported for unit tests.
export function normalizeArg(raw) {
if (raw === null || raw === undefined) return raw;
return String(raw)
.replace(/[\u2010-\u2015\u2212]/g, '--') // hyphens/dashes/minus → --
.replace(/^-{3,}/, '--'); // collapse ---+ to --
}
// Supports both `--only=dnd,callWaiting` and `--only dnd,callWaiting`
// (i.e. the value is the token immediately after `--only`).
function pickOnlyArg(args) {
for (let i = 0; i < args.length; i += 1) {
const a = String(args[i] || '');
if (a.toLowerCase().startsWith('--only=')) {
return a.slice('--only='.length);
}
if (a.toLowerCase() === '--only' && i + 1 < args.length) {
return args[i + 1];
}
}
return null;
}
function renderListChecks() {
let md = '**/voicediag registered checks**\n\n';
for (const c of CHECKS) {
const rems = c.remediations ? Object.keys(c.remediations) : [];
md += `- **${c.id}** — ${c.label}\n`;
md += ` - Requires: ${(c.requires || []).join(', ') || 'none'}\n`;
md += ` - Scope: \`${c.scope || 'unspecified'}\`\n`;
if (rems.length > 0) {
md += ` - Remediations: ${rems.map((r) => `\`${r}\``).join(', ')}\n`;
}
}
return md;
}
/**
* Registers a pending remediation payload and posts the adaptive
* card. All cards share the same `confirm_voicediag` /
* `cancel_voicediag` action namespace — the specific remediation is
* carried by `data.remediationId` inside the pending map entry.
*/
async function postRemediationCard(bot, { storeNum, result, requester }) {
const cardId = randomUUID();
const { remediation } = result;
pendingVoiceFixes.set(cardId, {
storeNum,
personId: remediation.payload?.personId || null,
personLabel: remediation.payload?.personLabel || null,
requester,
remediationId: remediation.action,
remediationPayload: remediation.payload || {},
});
const card = buildVoiceDiagCard({
storeNum,
result,
cardId,
});
await bot.say({
markdown: `Remediation available for **${result.label}** — review and confirm:`,
attachments: [{
contentType: 'application/vnd.microsoft.card.adaptive',
content: card,
}],
});
}
function buildVoiceDiagCard({ storeNum, result, cardId }) {
const { label, message, remediation } = result;
return {
type: 'AdaptiveCard',
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
version: '1.3',
body: [
{
type: 'TextBlock',
size: 'Medium',
weight: 'Bolder',
text: `${label} — Store ${storeNum}`,
wrap: true,
},
{
type: 'TextBlock',
text: message,
wrap: true,
spacing: 'Small',
},
{
type: 'TextBlock',
text: remediation.summary || remediation.title,
wrap: true,
spacing: 'Small',
isSubtle: true,
},
],
actions: [
{
type: 'Action.Submit',
title: `${remediation.title}`,
data: { action: 'confirm_voicediag', cardId },
},
{
type: 'Action.Submit',
title: '❌ Cancel',
data: { action: 'cancel_voicediag', cardId },
},
],
};
}