// 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); 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 }, }, ], }; }