Replace /phonestatus follow-ups with /voicestatus, /wanstatus, /phonediag, and /dectdiag; extend /voicediag with relay probes and section-based MPP diagnostics. Co-authored-by: Cursor <cursoragent@cursor.com>
565 lines
19 KiB
JavaScript
565 lines
19 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';
|
||
import { pickWindowArg, parseWindowMinutes } from '../utils/windowArgs.js';
|
||
|
||
export { parseWindowMinutes };
|
||
|
||
// 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 /voicestatus 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`, `7d` etc. Defaults to whatever
|
||
// `buildContext` resolves via env WAN_STANDARD_WINDOW_MINUTES
|
||
// (currently 7d — anything past that gets silently clamped by
|
||
// normalizeWindowMinutes since Prisma downsamples too coarsely
|
||
// beyond that to preserve worst-window signal).
|
||
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\`, \`1d\`, or \`7d\`.`,
|
||
);
|
||
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
|
||
// /voicestatus + 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');
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
|
||
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 },
|
||
},
|
||
],
|
||
};
|
||
}
|