collabSupport/commands/voiceDiag.js
jmcqueen d12723d010 Voicediag: store voice standards + port-hygiene checks + apply-all card
Refactor every /voicediag check to declare a top-level `standards`
object so the desired state is legible without reading run() logic
and can drive a documented reference table. Upgrade callForwarding
to error severity, tighten voicemail with three send-to-VM error
paths + a `stop_sending_to_voicemail` remediation, and add a
`disable_hoteling` remediation.

Add a port-hygiene check bucket under services/voiceDiag/checks/port
(portType, portVlan, portPoe, portEnabled) that reuses the phone-
status snapshot to enforce switchport standards. Configurable via
VOICE_STANDARD_PHONE_VLAN (default 102) and VOICE_STANDARD_ENABLED
(kill-switch). Preserve Meraki `portType`/`voiceVlan`/`dataVlan`
through the enrichment chain so the checks have clean data to read.

Add an "apply all N fixes" combined card that shows up when 2+
remediations are available. New confirm_voicediag_all /
cancel_voicediag_all actions run each fix in sequence (readable
audit trail, no per-person write-throttle stacking), accumulate
individual failures into a summary rather than aborting.

Adds regression tests asserting every check exposes .standards,
plus coverage for port checks, kill-switch, and combined-card
iteration. 63 tests in the checks file, 188 total, all green.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 14:14:38 -04:00

542 lines
18 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,
});
}
// If there are 2+ independent remediations, add a bulk "apply all"
// card as a shortcut. The individual cards remain on-screen so an
// operator who only wants to fix one thing can still do that; the
// combined card just spares them from N confirm clicks when the
// whole set looks fine. Single-remediation cases skip this — one
// card is already the minimum interaction.
if (fixable.length >= 2) {
await postCombinedRemediationCard(bot, {
storeNum: ctx.storeNum,
fixable,
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)}`,
);
}
/**
* Combined "apply all N fixes" dispatcher — invoked when an operator
* clicks the bulk confirm on the summary card posted after 2+
* individually-fixable results. Iterates the queued entries in
* *sequence* rather than parallel:
*
* - Sequential keeps Webex-side audit lines readable (one
* "COMPLETED foo" line per fix, in the order the check ran).
* - Sequential avoids compounding rate-limit exposure on the
* `/v1/people/{id}/features/*` surface (Webex applies a
* per-person write throttle that we've hit in bursts before).
* - Individual failure isolation: one 500 doesn't abort the rest;
* failures accumulate into `errors[]` and get reported at the end.
*/
export async function applyAllVoiceDiagRemediations(bot, data, _roomId, requester) {
const entries = Array.isArray(data?.entries) ? data.entries : [];
const { storeNum, personLabel } = data || {};
if (entries.length === 0) {
await bot.say(
'markdown',
`⚠️ No pending remediations to apply for store ${storeNum || '?'}. The card may have expired.`,
);
return;
}
logger(
'voicediag:audit',
`Dispatching APPLY-ALL (${entries.length} fixes) for store ${storeNum} ` +
`requested by ${describeRequester(requester)}`,
);
const applied = [];
const errors = [];
for (const entry of entries) {
const registered = REMEDIATION_REGISTRY.get(entry.remediationId);
if (!registered) {
errors.push({ id: entry.remediationId, reason: 'no handler registered' });
logger(
'voicediag:action',
`No handler registered for remediationId "${entry.remediationId}" in apply-all — skipping`,
'warn',
);
continue;
}
try {
logger(
'voicediag:audit',
`apply-all → ${entry.remediationId} (check=${registered.check.id}) for store ${storeNum}`,
);
await registered.handler(bot, entry.remediationPayload || {}, requester);
applied.push(entry.remediationId);
} catch (err) {
errors.push({ id: entry.remediationId, reason: err.message });
logger(
'voicediag:action',
`apply-all: ${entry.remediationId} failed: ${err.message}`,
'error',
);
}
}
// Post a final summary line so the operator sees the aggregate
// outcome in one place — individual handlers already posted their
// own ✅ / ❌ per-fix, but a final tally scannable in one glance
// is much friendlier than reading N separate lines.
const lines = [
`**Apply-all complete for store ${storeNum}** (${personLabel || 'store user'})`,
`- Applied: ${applied.length} / ${entries.length}`,
];
if (errors.length > 0) {
lines.push(`- Failed: ${errors.length}`);
for (const e of errors) {
lines.push(` - \`${e.id}\`${e.reason}`);
}
lines.push(`Re-run \`/voicediag ${storeNum}\` to re-inspect and retry any leftovers.`);
}
await bot.say('markdown', lines.join('\n'));
logger(
'voicediag:audit',
`COMPLETED apply-all for store ${storeNum} — applied=${applied.length}/${entries.length}, ` +
`errors=${errors.length}`,
);
}
export async function cancelAllVoiceDiagRemediations(bot, data, _roomId, requester) {
const { storeNum, personLabel } = data || {};
const entries = Array.isArray(data?.entries) ? data.entries : [];
await bot.say(
'markdown',
`❌ Cancelled all pending remediations for **${personLabel || `store ${storeNum || '?'}`}** ` +
`(${entries.length} fix${entries.length === 1 ? '' : 'es'} not applied).`,
);
logger(
'voicediag:audit',
`CANCELLED APPLY-ALL (${entries.length} entries) 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,
}],
});
}
/**
* Registers a *combined* pending payload holding every fixable
* result's remediationId + payload, and posts one adaptive card
* offering to apply the whole batch in a single click. Shape of the
* stored payload:
*
* {
* combined: true,
* storeNum, personLabel,
* entries: [{ remediationId, remediationPayload }, ...],
* requester,
* }
*
* Uses the same pendingVoiceFixes map (single sweep, single TTL) —
* the `combined: true` flag + the dispatcher's `switch` on
* `actionType` are what decides whether to hit the single-fix or
* batch handler.
*/
async function postCombinedRemediationCard(bot, { storeNum, fixable, requester }) {
const cardId = randomUUID();
// All fixable results in a run share the same person (they're
// per-user features on the store line), so grab the label from
// the first entry — safer than reaching for ctx here since this
// helper only receives what it needs.
const personLabel =
fixable.find((r) => r.remediation?.payload?.personLabel)?.remediation?.payload?.personLabel ||
`store ${storeNum}`;
const entries = fixable.map((r) => ({
remediationId: r.remediation.action,
remediationPayload: r.remediation.payload || {},
// Kept for the card summary + audit context. Not read by the
// dispatcher.
label: r.label,
title: r.remediation.title,
}));
pendingVoiceFixes.set(cardId, {
combined: true,
storeNum,
personLabel,
requester,
entries,
});
const card = buildCombinedVoiceDiagCard({
storeNum,
personLabel,
entries,
cardId,
});
await bot.say({
markdown: `Or apply **all ${entries.length} fixes** at once:`,
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 },
},
],
};
}
function buildCombinedVoiceDiagCard({ storeNum, personLabel, entries, cardId }) {
const bulletBody = entries.map((e) => `${e.label}: ${e.title}`).join('\n');
return {
type: 'AdaptiveCard',
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
version: '1.3',
body: [
{
type: 'TextBlock',
size: 'Medium',
weight: 'Bolder',
text: `Apply all ${entries.length} fixes — Store ${storeNum}`,
wrap: true,
},
{
type: 'TextBlock',
text: `Target: ${personLabel}`,
wrap: true,
spacing: 'Small',
isSubtle: true,
},
{
type: 'TextBlock',
text: bulletBody,
wrap: true,
spacing: 'Small',
},
{
type: 'TextBlock',
text: 'Fixes will be applied in sequence. Any failures are reported at the end.',
wrap: true,
spacing: 'Small',
isSubtle: true,
},
],
actions: [
{
type: 'Action.Submit',
title: `✅ Apply all ${entries.length}`,
data: { action: 'confirm_voicediag_all', cardId },
},
{
type: 'Action.Submit',
title: '❌ Cancel',
data: { action: 'cancel_voicediag_all', cardId },
},
],
};
}