// src/commands/voiceDiag.js // // /voicediag [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); // Look-back window for WAN checks (healthscore + LQM + alarms). // Accepts `15m`, `1h`, `24h`, `1d` etc. Defaults to whatever // `buildContext` resolves via env WAN_STANDARD_WINDOW_MINUTES. const windowRaw = pickWindowArg(args) ?? query.window ?? null; const windowMinutes = parseWindowMinutes(windowRaw); if (windowRaw && windowMinutes == null) { await bot.say( 'markdown', `Unrecognised window \`${windowRaw}\`. Use \`15m\`, \`1h\`, \`6h\`, \`24h\`, or \`1d\`.`, ); return; } 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 --window 24h`\n' + '- `/voicediag 782 --only dnd,callForwarding`\n' + '- `/voicediag list-checks`', ); return; } let ctx; try { ctx = await buildContext(storeNum, { windowMinutes }); } 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, wanWindowMinutes: ctx.sdwanData?.window?.minutes ?? null, }); 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) { return pickFlagValue(args, '--only'); } function pickWindowArg(args) { return pickFlagValue(args, '--window'); } // Generic `--flag=value` / `--flag value` extractor. function pickFlagValue(args, flag) { const prefix = `${flag.toLowerCase()}=`; for (let i = 0; i < args.length; i += 1) { const a = String(args[i] || '').toLowerCase(); if (a.startsWith(prefix)) return String(args[i]).slice(prefix.length); if (a === flag.toLowerCase() && i + 1 < args.length) return args[i + 1]; } return null; } /** * Parse a window shorthand (`15m`, `1h`, `24h`, `1d`, `1440`) into * minutes. Returns null on unrecognised input so the caller can * decide whether to surface a friendly error or fall back to the * env default. */ export function parseWindowMinutes(raw) { if (raw === null || raw === undefined || raw === '') return undefined; const s = String(raw).trim().toLowerCase(); // Bare integer → treat as minutes. if (/^\d+$/.test(s)) return Math.max(1, parseInt(s, 10)); const m = s.match(/^(\d+)\s*(m|min|mins|h|hr|hrs|hour|hours|d|day|days)$/); if (!m) return null; const n = parseInt(m[1], 10); const unit = m[2]; if (['m', 'min', 'mins'].includes(unit)) return n; if (['h', 'hr', 'hrs', 'hour', 'hours'].includes(unit)) return n * 60; if (['d', 'day', 'days'].includes(unit)) return n * 60 * 24; 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 }, }, ], }; }