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>
314 lines
10 KiB
JavaScript
314 lines
10 KiB
JavaScript
// 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 2–4 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 },
|
||
},
|
||
],
|
||
};
|
||
}
|