Add /voicediag rules-engine command with 9 per-user calling checks
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>
This commit is contained in:
parent
1705c88ba2
commit
2eb31a2ddc
21 changed files with 2801 additions and 3 deletions
24
.env.example
24
.env.example
|
|
@ -41,11 +41,33 @@ WEBEX_BOT_TOKEN=your-bot-token-here
|
||||||
# calls — people lookup, room operations, etc.). Separate from the bot token.
|
# calls — people lookup, room operations, etc.). Separate from the bot token.
|
||||||
#
|
#
|
||||||
# Required scopes (set when creating the service app at developer.webex.com):
|
# Required scopes (set when creating the service app at developer.webex.com):
|
||||||
# - spark-admin:people_read (people lookup)
|
# - spark-admin:people_read (people lookup, voicediag reads,
|
||||||
|
# webexhost, findEmptyLocations)
|
||||||
|
# - spark-admin:people_write (webexhost: assign licenses,
|
||||||
|
# voicediag: apply remediations —
|
||||||
|
# disable DND / clear forwarding /
|
||||||
|
# disable intercept / enable call
|
||||||
|
# waiting via /v1/people/{id}/features/*)
|
||||||
|
# - spark-admin:licenses_read (webexhost, findEmptyLocations)
|
||||||
# - identity:tokens_read (offboarduser: list a user's authorizations)
|
# - identity:tokens_read (offboarduser: list a user's authorizations)
|
||||||
# - identity:tokens_write (offboarduser: revoke a user's authorizations)
|
# - identity:tokens_write (offboarduser: revoke a user's authorizations)
|
||||||
# The authorizing admin must also hold Full / User / Device Admin role for the
|
# The authorizing admin must also hold Full / User / Device Admin role for the
|
||||||
# token-management calls to succeed.
|
# token-management calls to succeed.
|
||||||
|
#
|
||||||
|
# NOTE — early drafts of /voicediag documented `spark-admin:telephony_config_*`
|
||||||
|
# scopes and a `/v1/telephony/config/people/{id}/callSettings/*` URL scheme.
|
||||||
|
# That URL family returns 404 "no static resource" from the Webex gateway.
|
||||||
|
# The live admin surface is `/v1/people/{id}/features/{feature}` (see
|
||||||
|
# services/voiceDiag/ for details) and it uses the same `spark-admin:people_*`
|
||||||
|
# scopes that /webexhost and /offboarduser already require — no additional
|
||||||
|
# scope work is needed to enable /voicediag.
|
||||||
|
#
|
||||||
|
# Refresh tokens preserve their original scope set, so if you ever DO add a
|
||||||
|
# new scope you must:
|
||||||
|
# 1. Save the updated scopes on the service app.
|
||||||
|
# 2. Re-authorize the app for the org as a Full / User admin.
|
||||||
|
# 3. Delete/rotate tokens/webex-service-tokens.json so the next request
|
||||||
|
# re-bootstraps with the enlarged scope set.
|
||||||
WEBEX_CLIENT_ID=your-service-app-client-id
|
WEBEX_CLIENT_ID=your-service-app-client-id
|
||||||
WEBEX_CLIENT_SECRET=your-service-app-client-secret
|
WEBEX_CLIENT_SECRET=your-service-app-client-secret
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import { handleProvisionVc } from './vcProvision.js';
|
||||||
import { handleVcMonitor } from './vcMonitor.js';
|
import { handleVcMonitor } from './vcMonitor.js';
|
||||||
import { handleOffboardUser } from './offboardUser.js';
|
import { handleOffboardUser } from './offboardUser.js';
|
||||||
import { handleWebexHost } from './webexHost.js';
|
import { handleWebexHost } from './webexHost.js';
|
||||||
|
import { handleVoiceDiag } from './voiceDiag.js';
|
||||||
import { handleBulkAvStatusCSV } from './bulkAvStatusCSV.js';
|
import { handleBulkAvStatusCSV } from './bulkAvStatusCSV.js';
|
||||||
import { handleBulkAvSwitchCSV } from './bulkAvSwitchCSV.js';
|
import { handleBulkAvSwitchCSV } from './bulkAvSwitchCSV.js';
|
||||||
import { handleTestDevicesByModel } from './testDevicesByModel.js';
|
import { handleTestDevicesByModel } from './testDevicesByModel.js';
|
||||||
|
|
@ -43,6 +44,13 @@ export const commands = [
|
||||||
|
|
||||||
{ name: 'avstatus', aliases: ['wostatus'], handler: handleAvStatus, mutating: false },
|
{ name: 'avstatus', aliases: ['wostatus'], handler: handleAvStatus, mutating: false },
|
||||||
{ name: 'phonestatus', handler: handlePhoneStatus, mutating: false },
|
{ name: 'phonestatus', handler: handlePhoneStatus, mutating: false },
|
||||||
|
// /voicediag reads per-user Webex Calling features via
|
||||||
|
// /v1/people/{id}/features/* and offers per-issue adaptive-card
|
||||||
|
// remediation. Reads are non-mutating but the confirm buttons on
|
||||||
|
// the cards perform PUTs, so the HTTP path is gated
|
||||||
|
// (mutating: true). See commands/voiceDiag.js + services/voiceDiag/
|
||||||
|
// for the full behavior contract + required scopes.
|
||||||
|
{ name: 'voicediag', handler: handleVoiceDiag, mutating: true },
|
||||||
{ name: 'wohistory', handler: handleWoHistory, mutating: false },
|
{ name: 'wohistory', handler: handleWoHistory, mutating: false },
|
||||||
{ name: 'wosummary', handler: handleWoSummary, mutating: false },
|
{ name: 'wosummary', handler: handleWoSummary, mutating: false },
|
||||||
{ name: 'woattachments', handler: handleWoAttachments, mutating: false },
|
{ name: 'woattachments', handler: handleWoAttachments, mutating: false },
|
||||||
|
|
|
||||||
314
commands/voiceDiag.js
Normal file
314
commands/voiceDiag.js
Normal file
|
|
@ -0,0 +1,314 @@
|
||||||
|
// 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 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
56
index.js
56
index.js
|
|
@ -34,6 +34,11 @@ import {
|
||||||
cancelIgmpFixCard,
|
cancelIgmpFixCard,
|
||||||
} from './commands/igmpFix.js';
|
} from './commands/igmpFix.js';
|
||||||
import { pendingIgmpFixes } from './utils/pendingIgmpFixes.js';
|
import { pendingIgmpFixes } from './utils/pendingIgmpFixes.js';
|
||||||
|
import {
|
||||||
|
applyVoiceDiagRemediation,
|
||||||
|
cancelVoiceDiagRemediation,
|
||||||
|
} from './commands/voiceDiag.js';
|
||||||
|
import { pendingVoiceFixes } from './utils/pendingVoiceFixes.js';
|
||||||
import { extractRequester } from './utils/requester.js';
|
import { extractRequester } from './utils/requester.js';
|
||||||
import { getDectRelayHub } from './services/dectRelayHub.js';
|
import { getDectRelayHub } from './services/dectRelayHub.js';
|
||||||
import {
|
import {
|
||||||
|
|
@ -298,6 +303,7 @@ const DECT_ACTIONS = new Set([
|
||||||
const OFFBOARD_ACTIONS = new Set(['confirm_offboard', 'cancel_offboard']);
|
const OFFBOARD_ACTIONS = new Set(['confirm_offboard', 'cancel_offboard']);
|
||||||
const HOST_ASSIGN_ACTIONS = new Set(['confirm_host_assign', 'cancel_host_assign']);
|
const HOST_ASSIGN_ACTIONS = new Set(['confirm_host_assign', 'cancel_host_assign']);
|
||||||
const IGMP_FIX_ACTIONS = new Set(['confirm_igmp_fix', 'cancel_igmp_fix']);
|
const IGMP_FIX_ACTIONS = new Set(['confirm_igmp_fix', 'cancel_igmp_fix']);
|
||||||
|
const VOICEDIAG_ACTIONS = new Set(['confirm_voicediag', 'cancel_voicediag']);
|
||||||
|
|
||||||
// Best-effort delete of the adaptive-card message that fired this action.
|
// Best-effort delete of the adaptive-card message that fired this action.
|
||||||
// Removing the card prevents users from clicking Confirm/Cancel a second time
|
// Removing the card prevents users from clicking Confirm/Cancel a second time
|
||||||
|
|
@ -483,6 +489,56 @@ framework.on('attachmentAction', async (bot, trigger) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── /voicediag confirm / cancel ──
|
||||||
|
// Mirrors the IGMP branch above. Every /voicediag remediation card
|
||||||
|
// shares the same two action types (`confirm_voicediag` /
|
||||||
|
// `cancel_voicediag`) — the specific remediation to apply is
|
||||||
|
// carried in the pending payload's `remediationId` so that adding
|
||||||
|
// new remediations to a check module never requires touching this
|
||||||
|
// dispatcher.
|
||||||
|
if (VOICEDIAG_ACTIONS.has(actionType)) {
|
||||||
|
const { cardId } = action.inputs;
|
||||||
|
const roomId = trigger.roomId || action.roomId;
|
||||||
|
|
||||||
|
if (!cardId) {
|
||||||
|
logger('voicediag:action', `Missing cardId on ${actionType} — ignoring`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!pendingVoiceFixes.has(cardId)) {
|
||||||
|
logger('voicediag:action', `Card ${cardId} is expired or unknown`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const voiceData = pendingVoiceFixes.get(cardId);
|
||||||
|
pendingVoiceFixes.delete(cardId); // one-shot
|
||||||
|
logger(
|
||||||
|
'voicediag:action',
|
||||||
|
`Received ${actionType} for card ${cardId} ` +
|
||||||
|
`(store: ${voiceData.storeNum}, remediation: ${voiceData.remediationId})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await censorActionCard(bot, trigger, 'voicediag:action');
|
||||||
|
|
||||||
|
const requester = extractRequester(trigger);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (actionType === 'confirm_voicediag') {
|
||||||
|
await applyVoiceDiagRemediation(bot, voiceData, roomId, requester);
|
||||||
|
} else {
|
||||||
|
await cancelVoiceDiagRemediation(bot, voiceData, roomId, requester);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger(
|
||||||
|
'voicediag:action',
|
||||||
|
`Error processing ${actionType} for store ${voiceData.storeNum} ` +
|
||||||
|
`remediation ${voiceData.remediationId}: ${err.message}`,
|
||||||
|
'error',
|
||||||
|
);
|
||||||
|
await bot.say('markdown', `⚠️ Error during voice diagnostic remediation: ${err.message}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
logger('action', `Ignoring unhandled attachmentAction: ${actionType}`, 'debug');
|
logger('action', `Ignoring unhandled attachmentAction: ${actionType}`, 'debug');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -465,6 +465,11 @@ export async function getDevicesForPerson(personId) {
|
||||||
// - /people/{id} → full person (phoneNumbers, displayName etc.)
|
// - /people/{id} → full person (phoneNumbers, displayName etc.)
|
||||||
// - telephony/config/locations/{id} → callingLineId.phoneNumber (the main +1 number assigned to AA)
|
// - telephony/config/locations/{id} → callingLineId.phoneNumber (the main +1 number assigned to AA)
|
||||||
// DND/callForwarding etc. still 404 under current scopes → resilient, only log at debug/warn.
|
// DND/callForwarding etc. still 404 under current scopes → resilient, only log at debug/warn.
|
||||||
|
// The full per-person calling-features surface is exposed via
|
||||||
|
// /voicediag (see services/voiceDiag/) using the admin path
|
||||||
|
// `/v1/people/{id}/features/*` — no additional scopes required
|
||||||
|
// beyond `spark-admin:people_read` / `spark-admin:people_write`
|
||||||
|
// which we already hold.
|
||||||
// Location fetch started early for parallelism with DECT detail calls.
|
// Location fetch started early for parallelism with DECT detail calls.
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
|
||||||
165
services/renderers/voiceDiagRenderer.js
Normal file
165
services/renderers/voiceDiagRenderer.js
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
// src/services/renderers/voiceDiagRenderer.js
|
||||||
|
//
|
||||||
|
// Renders the /voicediag CheckResult array into Webex-friendly
|
||||||
|
// markdown. Pure function — no I/O — so it's usable from both the
|
||||||
|
// chat command and (later) HTTP surfaces without recomputing.
|
||||||
|
//
|
||||||
|
// Layout:
|
||||||
|
//
|
||||||
|
// **Voice Diagnostic - Store 12345** (user: ae12345@ae.com — Store 12345)
|
||||||
|
//
|
||||||
|
// Errors (1) Warnings (2) Skipped (1) OK (5)
|
||||||
|
//
|
||||||
|
// **ERRORS**
|
||||||
|
// - **Call Intercept**: Call intercept is ACTIVE — inbound calls...
|
||||||
|
// • incomingType: INTERCEPT_ALL, outgoingType: ALLOW_ALL
|
||||||
|
//
|
||||||
|
// **WARNINGS**
|
||||||
|
// - **Do Not Disturb**: DND is enabled — incoming calls silenced.
|
||||||
|
// - **Call Forwarding**: 1 forwarding variant is active: ...
|
||||||
|
//
|
||||||
|
// **SKIPPED**
|
||||||
|
// - **Call Waiting**: 403 on Call Waiting — likely missing scope ...
|
||||||
|
//
|
||||||
|
// **OK** (5 — hidden; pass `detailed` to see them)
|
||||||
|
//
|
||||||
|
// Fixable issues (2) — see confirmation cards below.
|
||||||
|
//
|
||||||
|
// Options
|
||||||
|
// storeNum (required) header text
|
||||||
|
// detailed (default false) — expands OK checks + shows the
|
||||||
|
// `details` block under every check
|
||||||
|
// emitFooter (default true) — trailing italic timestamp
|
||||||
|
//
|
||||||
|
// The renderer never emits an adaptive card itself. The caller
|
||||||
|
// (commands/voiceDiag.js) walks the same results array to post cards.
|
||||||
|
|
||||||
|
const SEVERITY_ORDER = ['error', 'warn', 'skipped', 'ok'];
|
||||||
|
const SEVERITY_LABEL = {
|
||||||
|
error: 'ERRORS',
|
||||||
|
warn: 'WARNINGS',
|
||||||
|
skipped: 'SKIPPED',
|
||||||
|
ok: 'OK',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Array<{id, label, status, message, details, remediation}>} results
|
||||||
|
* @param {{
|
||||||
|
* storeNum: string,
|
||||||
|
* personLabel?: string,
|
||||||
|
* email?: string,
|
||||||
|
* detailed?: boolean,
|
||||||
|
* emitFooter?: boolean,
|
||||||
|
* }} [opts]
|
||||||
|
* @returns {string} markdown, whitespace-trimmed
|
||||||
|
*/
|
||||||
|
export function renderVoiceDiagMarkdown(results, opts = {}) {
|
||||||
|
const {
|
||||||
|
storeNum,
|
||||||
|
personLabel = null,
|
||||||
|
email = null,
|
||||||
|
detailed = false,
|
||||||
|
emitFooter = true,
|
||||||
|
} = opts;
|
||||||
|
|
||||||
|
const list = Array.isArray(results) ? results : [];
|
||||||
|
|
||||||
|
let reply = `**Voice Diagnostic - Store ${storeNum}**`;
|
||||||
|
if (personLabel || email) {
|
||||||
|
const who = [personLabel, email].filter(Boolean).join(' — ');
|
||||||
|
reply += ` (user: ${who})`;
|
||||||
|
}
|
||||||
|
reply += '\n\n';
|
||||||
|
|
||||||
|
if (list.length === 0) {
|
||||||
|
reply += '_No checks were executed. Verify the store number and re-run._\n';
|
||||||
|
return reply.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const counts = countBySeverity(list);
|
||||||
|
reply += summaryLine(counts) + '\n\n';
|
||||||
|
|
||||||
|
for (const severity of SEVERITY_ORDER) {
|
||||||
|
const bucket = list.filter((r) => r.status === severity);
|
||||||
|
if (bucket.length === 0) continue;
|
||||||
|
|
||||||
|
// Hide the OK bucket from the console body unless detailed —
|
||||||
|
// keeps the default output focused on what's actionable.
|
||||||
|
if (severity === 'ok' && !detailed) continue;
|
||||||
|
|
||||||
|
reply += `**${SEVERITY_LABEL[severity]}**\n`;
|
||||||
|
for (const r of bucket) {
|
||||||
|
reply += `- **${r.label}**: ${r.message}\n`;
|
||||||
|
if (detailed && r.details && Object.keys(r.details).length > 0) {
|
||||||
|
reply += ` - ${renderDetails(r.details)}\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reply += '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
const fixable = list.filter((r) => r.status !== 'ok' && r.remediation);
|
||||||
|
if (fixable.length > 0) {
|
||||||
|
reply +=
|
||||||
|
`**Fixable issues (${fixable.length})** — see confirmation cards below.\n\n` +
|
||||||
|
fixable.map((r) => `- ${r.label}: ${r.remediation.title}`).join('\n') +
|
||||||
|
'\n\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!detailed && counts.ok > 0) {
|
||||||
|
reply += `_${counts.ok} OK check(s) hidden — pass \`detailed\` to include them._\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (emitFooter) {
|
||||||
|
const now = new Date();
|
||||||
|
reply += `_Last checked: ${now.toISOString()}_\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function countBySeverity(list) {
|
||||||
|
const counts = { error: 0, warn: 0, skipped: 0, ok: 0 };
|
||||||
|
for (const r of list) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(counts, r.status)) {
|
||||||
|
counts[r.status] += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summaryLine({ error, warn, skipped, ok }) {
|
||||||
|
return `Errors (${error}) · Warnings (${warn}) · Skipped (${skipped}) · OK (${ok})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact one-line rendering of the details object. Truncates arrays
|
||||||
|
// past a small length so the chat message doesn't blow up on
|
||||||
|
// verbose payloads (outgoingPermission's rule list, for example).
|
||||||
|
function renderDetails(details) {
|
||||||
|
if (details === null || details === undefined) return '';
|
||||||
|
if (typeof details !== 'object') return String(details);
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
for (const [k, v] of Object.entries(details)) {
|
||||||
|
parts.push(`${k}: ${formatValue(v)}`);
|
||||||
|
}
|
||||||
|
return parts.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatValue(v) {
|
||||||
|
if (v === null || v === undefined) return '—';
|
||||||
|
if (typeof v === 'boolean' || typeof v === 'number' || typeof v === 'string') {
|
||||||
|
return String(v);
|
||||||
|
}
|
||||||
|
if (Array.isArray(v)) {
|
||||||
|
if (v.length === 0) return '[]';
|
||||||
|
if (v.length <= 3) return `[${v.map(formatValue).join(', ')}]`;
|
||||||
|
return `[${v.slice(0, 3).map(formatValue).join(', ')}, …+${v.length - 3}]`;
|
||||||
|
}
|
||||||
|
// Fall through: nested object — collapse to a JSON snippet.
|
||||||
|
try {
|
||||||
|
const s = JSON.stringify(v);
|
||||||
|
return s.length > 120 ? `${s.slice(0, 117)}…` : s;
|
||||||
|
} catch {
|
||||||
|
return '[object]';
|
||||||
|
}
|
||||||
|
}
|
||||||
130
services/voiceDiag/README.md
Normal file
130
services/voiceDiag/README.md
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
# /voicediag
|
||||||
|
|
||||||
|
Rules-engine style diagnostic for the store's Webex Calling
|
||||||
|
per-user configuration. The orchestrator (`voiceDiagService.js`)
|
||||||
|
walks a registry of check modules, gathers their `CheckResult`
|
||||||
|
objects, and hands them to the renderer + adaptive-card layer in
|
||||||
|
`commands/voiceDiag.js`.
|
||||||
|
|
||||||
|
## Command
|
||||||
|
|
||||||
|
```
|
||||||
|
/voicediag <storeNum> default: hides OK checks, posts fixable cards
|
||||||
|
/voicediag <storeNum> detail include OK checks + expand every details block
|
||||||
|
/voicediag <storeNum> --only dnd,callForwarding
|
||||||
|
/voicediag list-checks enumerate every registered check + its scope
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP path: `GET /voicediag?storeNum=<n>[&detailed=true][&only=dnd,callWaiting]`.
|
||||||
|
HTTP callers get the markdown snapshot only — remediation cards are
|
||||||
|
chat-only.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
All checks call `/v1/people/{personId}/features/{feature}` (with a
|
||||||
|
few Webex-namespace variants noted inline in each check module).
|
||||||
|
The scopes required are:
|
||||||
|
|
||||||
|
- `spark-admin:people_read` — every read (all checks)
|
||||||
|
- `spark-admin:people_write` — remediations (disable DND, clear
|
||||||
|
forwarding, enable call waiting, disable call intercept)
|
||||||
|
|
||||||
|
These are the same scopes `/webexhost` and `/offboarduser` already
|
||||||
|
use, so no operator work is needed to enable `/voicediag` on an
|
||||||
|
existing deployment. If the token ever loses those scopes,
|
||||||
|
each check self-reports as `skipped: scope missing` — the run still
|
||||||
|
completes, and the phone-online summary keeps working.
|
||||||
|
|
||||||
|
### About the URL scheme
|
||||||
|
|
||||||
|
Early drafts of this feature documented `/v1/telephony/config/people/{id}/callSettings/*`.
|
||||||
|
That path family returns 404 "no static resource" from the Webex API
|
||||||
|
gateway — it is not a live surface. The correct admin path is
|
||||||
|
`/v1/people/{id}/features/{feature}`, per Webex's User Call Settings
|
||||||
|
docs. A couple of features use different feature-name segments than
|
||||||
|
their check id — most notably `intercept` (not `callIntercept`);
|
||||||
|
see each check module for the exact endpoint. The runner
|
||||||
|
distinguishes routing-404s ("URL moved, update the check") from
|
||||||
|
"not applicable" 404s ("this person isn't a calling user") using
|
||||||
|
the `no static resource` marker in the response body.
|
||||||
|
|
||||||
|
## Adding a new check
|
||||||
|
|
||||||
|
1. Create `services/voiceDiag/checks/<newCheck>.js` and export a
|
||||||
|
descriptor:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export const myNewCheck = {
|
||||||
|
id: 'myNew',
|
||||||
|
label: 'My New Check',
|
||||||
|
requires: ['personId'], // subset of ['personId','phoneStatus','telephonyProfile']
|
||||||
|
scope: 'spark-admin:people_read',
|
||||||
|
async run(ctx) {
|
||||||
|
const data = await ctx.webex.request('GET', `people/${ctx.personId}/features/whatever`);
|
||||||
|
// ...evaluate...
|
||||||
|
return {
|
||||||
|
status: 'warn', // 'ok' | 'warn' | 'error' | 'skipped'
|
||||||
|
message: 'Human sentence for the chat row.',
|
||||||
|
details: { relevant: 'facts' },
|
||||||
|
remediation: { // optional
|
||||||
|
action: 'fix_my_thing', // globally unique remediation id
|
||||||
|
title: 'Fix My Thing',
|
||||||
|
summary: 'One-line description of what the fix will do.',
|
||||||
|
payload: { personId: ctx.personId, personLabel: ctx.personLabel, storeNum: ctx.storeNum },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
remediations: { // optional
|
||||||
|
async fix_my_thing(bot, data, requester) {
|
||||||
|
// do the PUT, audit, reply
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Register it in `services/voiceDiag/checks/index.js` at the
|
||||||
|
position you want the renderer to output it.
|
||||||
|
|
||||||
|
3. Add a `describe`-level block to `tests/voiceDiag.checks.test.js`
|
||||||
|
covering the enabled / disabled / 403 branches.
|
||||||
|
|
||||||
|
That's it — no changes to the runner, renderer, command handler,
|
||||||
|
`index.js` dispatcher, or `commands/registry.js`. The single
|
||||||
|
`confirm_voicediag` / `cancel_voicediag` action namespace routes
|
||||||
|
through the shared dispatcher, which looks the remediation up in the
|
||||||
|
central registry built at module load. Remediation id collisions
|
||||||
|
throw at import so you'll notice immediately.
|
||||||
|
|
||||||
|
### CheckResult shape
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type Severity = 'ok' | 'warn' | 'error' | 'skipped';
|
||||||
|
|
||||||
|
type Remediation = {
|
||||||
|
action: string; // globally unique across all checks
|
||||||
|
title: string; // button label
|
||||||
|
summary?: string; // optional card body line
|
||||||
|
payload: Record<string, unknown>; // handed to remediations[action](bot, payload, requester)
|
||||||
|
};
|
||||||
|
|
||||||
|
type CheckResult = {
|
||||||
|
id: string; // set from check.id automatically
|
||||||
|
label: string; // set from check.label automatically
|
||||||
|
status: Severity;
|
||||||
|
message: string;
|
||||||
|
details?: object | null; // shown under `detail` mode
|
||||||
|
remediation?: Remediation | null;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error handling
|
||||||
|
|
||||||
|
Never throw from `run()`. The runner wraps every check in a
|
||||||
|
try/catch and converts:
|
||||||
|
|
||||||
|
- HTTP 401/403/404 → `status: 'skipped'` with a "missing scope"
|
||||||
|
hint pointing at `check.scope`.
|
||||||
|
- Any other error → `status: 'error'` with the API message.
|
||||||
|
|
||||||
|
So if you _can't_ reason about a specific 5xx, just let it bubble —
|
||||||
|
the runner will surface it.
|
||||||
175
services/voiceDiag/checks/callForwarding.js
Normal file
175
services/voiceDiag/checks/callForwarding.js
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
// src/services/voiceDiag/checks/callForwarding.js
|
||||||
|
//
|
||||||
|
// Detects Webex Calling forwarding variants on the store's canonical
|
||||||
|
// user. Endpoint returns three sub-blocks that are all evaluated in a
|
||||||
|
// single API round-trip:
|
||||||
|
//
|
||||||
|
// {
|
||||||
|
// callForwarding: {
|
||||||
|
// always: { enabled, destination, destinationVoicemailEnabled, ringReminderEnabled },
|
||||||
|
// busy: { enabled, destination, destinationVoicemailEnabled },
|
||||||
|
// noAnswer: { enabled, destination, numberOfRings, destinationVoicemailEnabled, systemMaxNumberOfRings }
|
||||||
|
// },
|
||||||
|
// businessContinuity: { enabled, destination, ... } // optional; not fixed here
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// Endpoint: GET/PUT /v1/people/{personId}/features/callForwarding
|
||||||
|
// Scope: spark-admin:people_read (GET) + spark-admin:people_write (PUT).
|
||||||
|
//
|
||||||
|
// Because PUT expects the full callForwarding object shape (partial
|
||||||
|
// updates are rejected as bad request in practice), remediation
|
||||||
|
// fetches fresh state right before writing and only mutates the
|
||||||
|
// targeted variant's `enabled` flag. This avoids clobbering a
|
||||||
|
// destination the operator might still want configured for the day
|
||||||
|
// they re-enable it — clearing forwarding in Control Hub UI works the
|
||||||
|
// same way (leaves the destination string intact).
|
||||||
|
//
|
||||||
|
// Severity rules:
|
||||||
|
// - Any variant with enabled=true → warn (with per-variant
|
||||||
|
// remediation card).
|
||||||
|
// - `always` forwarded to a destination outside the +1AE prefix
|
||||||
|
// range is currently just noted in the message, not upgraded to
|
||||||
|
// an error — the plan calls it out but false positives on hand-
|
||||||
|
// entered destinations are high enough that we keep it at warn
|
||||||
|
// for now and let the operator judge.
|
||||||
|
|
||||||
|
import { logger } from '../../../utils/logger.js';
|
||||||
|
import { describeRequester } from '../../../utils/requester.js';
|
||||||
|
|
||||||
|
const ENDPOINT = (personId) =>
|
||||||
|
`people/${personId}/features/callForwarding`;
|
||||||
|
|
||||||
|
/** Registered forwarding variants + their human labels. Order matters
|
||||||
|
* for stable messages / audit lines. */
|
||||||
|
const VARIANTS = [
|
||||||
|
{ key: 'always', label: 'Call Forwarding — Always' },
|
||||||
|
{ key: 'busy', label: 'Call Forwarding — Busy' },
|
||||||
|
{ key: 'noAnswer', label: 'Call Forwarding — No Answer' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const callForwardingCheck = {
|
||||||
|
id: 'callForwarding',
|
||||||
|
label: 'Call Forwarding',
|
||||||
|
requires: ['personId'],
|
||||||
|
scope: 'spark-admin:people_read',
|
||||||
|
|
||||||
|
async run(ctx) {
|
||||||
|
const data = await ctx.webex.request('GET', ENDPOINT(ctx.personId));
|
||||||
|
const cf = data?.callForwarding || {};
|
||||||
|
|
||||||
|
const perVariant = VARIANTS.map(({ key, label }) => {
|
||||||
|
const v = cf[key] || {};
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
label,
|
||||||
|
enabled: !!v.enabled,
|
||||||
|
destination: v.destination || null,
|
||||||
|
destinationVoicemailEnabled: !!v.destinationVoicemailEnabled,
|
||||||
|
numberOfRings: v.numberOfRings ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const active = perVariant.filter((v) => v.enabled);
|
||||||
|
|
||||||
|
if (active.length === 0) {
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
message: 'No forwarding variants are active.',
|
||||||
|
details: { perVariant },
|
||||||
|
remediation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// A single check produces at most one remediation card in this
|
||||||
|
// architecture. When multiple forwarding variants are active, we
|
||||||
|
// build a compound remediation carrying every enabled variant —
|
||||||
|
// one PUT clears them all in a single button click, which is
|
||||||
|
// what the operator wants ("stop everything forwarding this user").
|
||||||
|
const variantsToClear = active.map((v) => v.key);
|
||||||
|
const summaryLine = active
|
||||||
|
.map((v) => `${v.label} → ${v.destination || 'unspecified'}`)
|
||||||
|
.join('; ');
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'warn',
|
||||||
|
message:
|
||||||
|
`${active.length} forwarding ${active.length === 1 ? 'variant is' : 'variants are'} ` +
|
||||||
|
`active: ${summaryLine}.`,
|
||||||
|
details: { perVariant, active: variantsToClear },
|
||||||
|
remediation: {
|
||||||
|
action: 'clear_call_forwarding',
|
||||||
|
title:
|
||||||
|
active.length === 1
|
||||||
|
? `Turn off ${active[0].label}`
|
||||||
|
: `Turn off ${active.length} forwarding variants`,
|
||||||
|
summary:
|
||||||
|
active.length === 1
|
||||||
|
? `Disable ${active[0].label} for ${ctx.personLabel} (destination ${active[0].destination || 'unspecified'} preserved).`
|
||||||
|
: `Disable ${active.length} forwarding variants for ${ctx.personLabel}. Destinations are preserved so they can be re-enabled later without re-typing.`,
|
||||||
|
payload: {
|
||||||
|
personId: ctx.personId,
|
||||||
|
personLabel: ctx.personLabel,
|
||||||
|
storeNum: ctx.storeNum,
|
||||||
|
variantsToClear,
|
||||||
|
before: perVariant,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
remediations: {
|
||||||
|
async clear_call_forwarding(bot, data, requester) {
|
||||||
|
const { personId, personLabel, storeNum, variantsToClear, before } = data;
|
||||||
|
if (!Array.isArray(variantsToClear) || variantsToClear.length === 0) {
|
||||||
|
await bot.say(
|
||||||
|
'markdown',
|
||||||
|
`⚠️ Forwarding remediation for **${personLabel}** had no variants to clear — nothing to do.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger(
|
||||||
|
'voicediag:audit',
|
||||||
|
`CONFIRMED clear_call_forwarding for ${personLabel} (person=${personId}, store=${storeNum}) ` +
|
||||||
|
`by ${describeRequester(requester)} — variants=${variantsToClear.join(',')}, ` +
|
||||||
|
`before=${JSON.stringify(before)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { default: webex } = await import('../../../integrations/webex/WebexClient.js');
|
||||||
|
// Fetch fresh state to avoid stomping on a destination change
|
||||||
|
// that happened between diagnose and remediate.
|
||||||
|
const fresh = await webex.request('GET', ENDPOINT(personId));
|
||||||
|
const cf = { ...(fresh?.callForwarding || {}) };
|
||||||
|
for (const variant of variantsToClear) {
|
||||||
|
cf[variant] = { ...(cf[variant] || {}), enabled: false };
|
||||||
|
}
|
||||||
|
await webex.request('PUT', ENDPOINT(personId), { callForwarding: cf });
|
||||||
|
} catch (err) {
|
||||||
|
logger(
|
||||||
|
'voicediag:audit',
|
||||||
|
`FAILED clear_call_forwarding for ${personLabel}: ${err.message}`,
|
||||||
|
'error',
|
||||||
|
);
|
||||||
|
await bot.say(
|
||||||
|
'markdown',
|
||||||
|
`❌ Failed to clear call forwarding for **${personLabel}**: ${err.message}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const humanList = variantsToClear
|
||||||
|
.map((k) => VARIANTS.find((v) => v.key === k)?.label || k)
|
||||||
|
.join(', ');
|
||||||
|
await bot.say(
|
||||||
|
'markdown',
|
||||||
|
`✅ Cleared forwarding for **${personLabel}** (store ${storeNum}): ${humanList}. ` +
|
||||||
|
`Destinations were preserved. Re-run \`/voicediag ${storeNum}\` to verify.`,
|
||||||
|
);
|
||||||
|
logger(
|
||||||
|
'voicediag:audit',
|
||||||
|
`COMPLETED clear_call_forwarding for ${personLabel} (store ${storeNum}) — variants=${variantsToClear.join(',')}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
119
services/voiceDiag/checks/callIntercept.js
Normal file
119
services/voiceDiag/checks/callIntercept.js
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
// src/services/voiceDiag/checks/callIntercept.js
|
||||||
|
//
|
||||||
|
// Detects whether Call Intercept is active. Intercept is a common
|
||||||
|
// silent cause of "calls just don't come through" — when it's on,
|
||||||
|
// incoming calls get an announcement instead of ringing the desk,
|
||||||
|
// and callers hear a generic "the person you're trying to reach is
|
||||||
|
// not available" prompt with no way to leave a message unless the
|
||||||
|
// intercept was configured with a rerouting target.
|
||||||
|
//
|
||||||
|
// Endpoint: GET/PUT /v1/people/{personId}/features/intercept
|
||||||
|
// NOTE the URL segment is `intercept`, not `callIntercept`
|
||||||
|
// (the check id keeps the descriptive name for the UI).
|
||||||
|
// Scope: spark-admin:people_read (GET) + spark-admin:people_write (PUT).
|
||||||
|
//
|
||||||
|
// Response shape (relevant fields):
|
||||||
|
// {
|
||||||
|
// enabled: true,
|
||||||
|
// incoming: {
|
||||||
|
// type: 'INTERCEPT_ALL' | 'ALLOW_ALL',
|
||||||
|
// voicemailEnabled: true,
|
||||||
|
// announcements: {
|
||||||
|
// greeting: 'CUSTOM' | 'DEFAULT',
|
||||||
|
// newNumber: { enabled: false, destination: '' },
|
||||||
|
// zeroTransfer: { enabled: false, destination: '' }
|
||||||
|
// }
|
||||||
|
// },
|
||||||
|
// outgoing: { type: 'INTERCEPT_ALL' | 'ALLOW_ALL', transferEnabled: false, destination: '' }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// Remediation: PUT `{enabled: false}` disables intercept in both
|
||||||
|
// directions in one call. When the operator wants finer control
|
||||||
|
// (e.g. only outgoing intercept off) they should do it in Control
|
||||||
|
// Hub — the bot's role here is to unstick "no calls at all" stores.
|
||||||
|
|
||||||
|
import { logger } from '../../../utils/logger.js';
|
||||||
|
import { describeRequester } from '../../../utils/requester.js';
|
||||||
|
|
||||||
|
const ENDPOINT = (personId) =>
|
||||||
|
`people/${personId}/features/intercept`;
|
||||||
|
|
||||||
|
export const callInterceptCheck = {
|
||||||
|
id: 'callIntercept',
|
||||||
|
label: 'Call Intercept',
|
||||||
|
requires: ['personId'],
|
||||||
|
scope: 'spark-admin:people_read',
|
||||||
|
|
||||||
|
async run(ctx) {
|
||||||
|
const data = await ctx.webex.request('GET', ENDPOINT(ctx.personId));
|
||||||
|
const enabled = !!data?.enabled;
|
||||||
|
const incomingType = data?.incoming?.type || null;
|
||||||
|
const outgoingType = data?.outgoing?.type || null;
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
message: 'Call intercept is off.',
|
||||||
|
details: { enabled, incomingType, outgoingType },
|
||||||
|
remediation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
message:
|
||||||
|
'Call intercept is ACTIVE — inbound and/or outbound calls are being blocked with an announcement.',
|
||||||
|
details: { enabled, incomingType, outgoingType },
|
||||||
|
remediation: {
|
||||||
|
action: 'disable_call_intercept',
|
||||||
|
title: 'Disable Call Intercept',
|
||||||
|
summary: `Turn call intercept off for ${ctx.personLabel} so calls resume.`,
|
||||||
|
payload: {
|
||||||
|
personId: ctx.personId,
|
||||||
|
personLabel: ctx.personLabel,
|
||||||
|
storeNum: ctx.storeNum,
|
||||||
|
before: { enabled, incomingType, outgoingType },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
remediations: {
|
||||||
|
async disable_call_intercept(bot, data, requester) {
|
||||||
|
const { personId, personLabel, storeNum, before } = data;
|
||||||
|
|
||||||
|
logger(
|
||||||
|
'voicediag:audit',
|
||||||
|
`CONFIRMED disable_call_intercept for ${personLabel} (person=${personId}, store=${storeNum}) ` +
|
||||||
|
`by ${describeRequester(requester)} — was enabled=${before?.enabled ?? 'unknown'}, ` +
|
||||||
|
`incoming=${before?.incomingType ?? 'unknown'}, outgoing=${before?.outgoingType ?? 'unknown'}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { default: webex } = await import('../../../integrations/webex/WebexClient.js');
|
||||||
|
await webex.request('PUT', ENDPOINT(personId), { enabled: false });
|
||||||
|
} catch (err) {
|
||||||
|
logger(
|
||||||
|
'voicediag:audit',
|
||||||
|
`FAILED disable_call_intercept for ${personLabel}: ${err.message}`,
|
||||||
|
'error',
|
||||||
|
);
|
||||||
|
await bot.say(
|
||||||
|
'markdown',
|
||||||
|
`❌ Failed to disable call intercept for **${personLabel}**: ${err.message}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await bot.say(
|
||||||
|
'markdown',
|
||||||
|
`✅ Call intercept disabled for **${personLabel}** (store ${storeNum}). ` +
|
||||||
|
`Re-run \`/voicediag ${storeNum}\` to verify.`,
|
||||||
|
);
|
||||||
|
logger(
|
||||||
|
'voicediag:audit',
|
||||||
|
`COMPLETED disable_call_intercept for ${personLabel} (store ${storeNum})`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
98
services/voiceDiag/checks/callWaiting.js
Normal file
98
services/voiceDiag/checks/callWaiting.js
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
// src/services/voiceDiag/checks/callWaiting.js
|
||||||
|
//
|
||||||
|
// Detects whether Call Waiting is enabled for the store user. Call
|
||||||
|
// waiting being *off* is uncommon in a store context — it means a
|
||||||
|
// second incoming call while the operator is on the phone will get a
|
||||||
|
// busy tone or hit the busy-forward target instead of showing a
|
||||||
|
// beep-in on the desk phone.
|
||||||
|
//
|
||||||
|
// Endpoint: GET/PUT /v1/people/{personId}/features/callWaiting
|
||||||
|
// Payload: { enabled: boolean }
|
||||||
|
// Scope: spark-admin:people_read (GET) + spark-admin:people_write (PUT).
|
||||||
|
//
|
||||||
|
// Severity: warn when disabled — we can't tell for sure it's a bug
|
||||||
|
// (some sites want it off), but for a store phone it's usually
|
||||||
|
// inadvertent. Remediation: turn call waiting back on.
|
||||||
|
|
||||||
|
import { logger } from '../../../utils/logger.js';
|
||||||
|
import { describeRequester } from '../../../utils/requester.js';
|
||||||
|
|
||||||
|
const ENDPOINT = (personId) =>
|
||||||
|
`people/${personId}/features/callWaiting`;
|
||||||
|
|
||||||
|
export const callWaitingCheck = {
|
||||||
|
id: 'callWaiting',
|
||||||
|
label: 'Call Waiting',
|
||||||
|
requires: ['personId'],
|
||||||
|
scope: 'spark-admin:people_read',
|
||||||
|
|
||||||
|
async run(ctx) {
|
||||||
|
const data = await ctx.webex.request('GET', ENDPOINT(ctx.personId));
|
||||||
|
const enabled = !!data?.enabled;
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
message: 'Call waiting is on.',
|
||||||
|
details: { enabled },
|
||||||
|
remediation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'warn',
|
||||||
|
message:
|
||||||
|
'Call waiting is disabled — a second inbound call will not beep in.',
|
||||||
|
details: { enabled },
|
||||||
|
remediation: {
|
||||||
|
action: 'enable_call_waiting',
|
||||||
|
title: 'Enable Call Waiting',
|
||||||
|
summary: `Turn call waiting on for ${ctx.personLabel}.`,
|
||||||
|
payload: {
|
||||||
|
personId: ctx.personId,
|
||||||
|
personLabel: ctx.personLabel,
|
||||||
|
storeNum: ctx.storeNum,
|
||||||
|
before: { enabled },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
remediations: {
|
||||||
|
async enable_call_waiting(bot, data, requester) {
|
||||||
|
const { personId, personLabel, storeNum, before } = data;
|
||||||
|
|
||||||
|
logger(
|
||||||
|
'voicediag:audit',
|
||||||
|
`CONFIRMED enable_call_waiting for ${personLabel} (person=${personId}, store=${storeNum}) ` +
|
||||||
|
`by ${describeRequester(requester)} — was enabled=${before?.enabled ?? 'unknown'}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { default: webex } = await import('../../../integrations/webex/WebexClient.js');
|
||||||
|
await webex.request('PUT', ENDPOINT(personId), { enabled: true });
|
||||||
|
} catch (err) {
|
||||||
|
logger(
|
||||||
|
'voicediag:audit',
|
||||||
|
`FAILED enable_call_waiting for ${personLabel}: ${err.message}`,
|
||||||
|
'error',
|
||||||
|
);
|
||||||
|
await bot.say(
|
||||||
|
'markdown',
|
||||||
|
`❌ Failed to enable call waiting for **${personLabel}**: ${err.message}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await bot.say(
|
||||||
|
'markdown',
|
||||||
|
`✅ Call waiting enabled for **${personLabel}** (store ${storeNum}). ` +
|
||||||
|
`Re-run \`/voicediag ${storeNum}\` to verify.`,
|
||||||
|
);
|
||||||
|
logger(
|
||||||
|
'voicediag:audit',
|
||||||
|
`COMPLETED enable_call_waiting for ${personLabel} (store ${storeNum})`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
120
services/voiceDiag/checks/dnd.js
Normal file
120
services/voiceDiag/checks/dnd.js
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
// src/services/voiceDiag/checks/dnd.js
|
||||||
|
//
|
||||||
|
// Detects whether the store's canonical Webex Calling user has Do
|
||||||
|
// Not Disturb enabled. DND-on is a top-5 cause of "the store phone
|
||||||
|
// doesn't ring" tickets — with DND on, incoming Webex calls skip the
|
||||||
|
// device entirely and go straight to voicemail (or the configured
|
||||||
|
// forward-when-no-answer target).
|
||||||
|
//
|
||||||
|
// Endpoint: GET/PUT /v1/people/{personId}/features/doNotDisturb
|
||||||
|
// Payload: { enabled: boolean, ringSplashEnabled: boolean }
|
||||||
|
//
|
||||||
|
// Scope: spark-admin:people_read (GET) + spark-admin:people_write (PUT).
|
||||||
|
// These are the scopes /webexhost / /offboarduser already use,
|
||||||
|
// so no re-bootstrapping is needed. NOTE: the plan initially
|
||||||
|
// referenced /v1/telephony/config/people/{id}/callSettings/*
|
||||||
|
// but every one of those paths returns 404 "no static resource"
|
||||||
|
// from the Webex API gateway — the current admin surface for
|
||||||
|
// per-person call settings is `/v1/people/{id}/features/*`.
|
||||||
|
// See wxc_sdk's user-call-settings table for the full list.
|
||||||
|
//
|
||||||
|
// Remediation: PUT `{enabled: false, ringSplashEnabled: false}`. We
|
||||||
|
// force ringSplashEnabled off too on the reasonable assumption that a
|
||||||
|
// store phone user in a diagnostic sweep doesn't want the visual
|
||||||
|
// "someone is calling you" splash left dangling half-configured. If
|
||||||
|
// this ever becomes a policy issue, split it into two remediations
|
||||||
|
// and drop the ringSplash toggle here.
|
||||||
|
|
||||||
|
import { logger } from '../../../utils/logger.js';
|
||||||
|
import { describeRequester } from '../../../utils/requester.js';
|
||||||
|
|
||||||
|
const ENDPOINT = (personId) =>
|
||||||
|
`people/${personId}/features/doNotDisturb`;
|
||||||
|
|
||||||
|
export const dndCheck = {
|
||||||
|
id: 'dnd',
|
||||||
|
label: 'Do Not Disturb',
|
||||||
|
requires: ['personId'],
|
||||||
|
scope: 'spark-admin:people_read',
|
||||||
|
|
||||||
|
async run(ctx) {
|
||||||
|
const data = await ctx.webex.request('GET', ENDPOINT(ctx.personId));
|
||||||
|
|
||||||
|
const enabled = !!data?.enabled;
|
||||||
|
const ringSplashEnabled = !!data?.ringSplashEnabled;
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
message: 'DND is off.',
|
||||||
|
details: { enabled, ringSplashEnabled },
|
||||||
|
remediation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'warn',
|
||||||
|
message:
|
||||||
|
'DND is enabled — incoming calls will be silenced on this user\'s phones.',
|
||||||
|
details: { enabled, ringSplashEnabled },
|
||||||
|
remediation: {
|
||||||
|
action: 'disable_dnd',
|
||||||
|
title: 'Disable DND',
|
||||||
|
summary: `Turn DND off for ${ctx.personLabel}.`,
|
||||||
|
payload: {
|
||||||
|
personId: ctx.personId,
|
||||||
|
personLabel: ctx.personLabel,
|
||||||
|
storeNum: ctx.storeNum,
|
||||||
|
// Snapshot of the "before" state so the audit log line
|
||||||
|
// reads correctly after we PUT the new value.
|
||||||
|
before: { enabled, ringSplashEnabled },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
remediations: {
|
||||||
|
async disable_dnd(bot, data, requester) {
|
||||||
|
const { personId, personLabel, storeNum, before } = data;
|
||||||
|
|
||||||
|
logger(
|
||||||
|
'voicediag:audit',
|
||||||
|
`CONFIRMED disable_dnd for ${personLabel} (person=${personId}, store=${storeNum}) ` +
|
||||||
|
`by ${describeRequester(requester)} — was enabled=${before?.enabled ?? 'unknown'}, ` +
|
||||||
|
`ringSplashEnabled=${before?.ringSplashEnabled ?? 'unknown'}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Import lazily so unit tests can replace the exported webex
|
||||||
|
// singleton via ctx.webex in run(), while the remediation path
|
||||||
|
// stays honest about which client it uses in production.
|
||||||
|
const { default: webex } = await import('../../../integrations/webex/WebexClient.js');
|
||||||
|
await webex.request('PUT', ENDPOINT(personId), {
|
||||||
|
enabled: false,
|
||||||
|
ringSplashEnabled: false,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger(
|
||||||
|
'voicediag:audit',
|
||||||
|
`FAILED disable_dnd for ${personLabel}: ${err.message}`,
|
||||||
|
'error',
|
||||||
|
);
|
||||||
|
await bot.say(
|
||||||
|
'markdown',
|
||||||
|
`❌ Failed to disable DND for **${personLabel}**: ${err.message}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await bot.say(
|
||||||
|
'markdown',
|
||||||
|
`✅ DND disabled for **${personLabel}** (store ${storeNum}). ` +
|
||||||
|
`Re-run \`/voicediag ${storeNum}\` to verify.`,
|
||||||
|
);
|
||||||
|
logger(
|
||||||
|
'voicediag:audit',
|
||||||
|
`COMPLETED disable_dnd for ${personLabel} (store ${storeNum})`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
55
services/voiceDiag/checks/executiveAssistant.js
Normal file
55
services/voiceDiag/checks/executiveAssistant.js
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
// src/services/voiceDiag/checks/executiveAssistant.js
|
||||||
|
//
|
||||||
|
// Info-only check for the Executive / Executive Assistant feature.
|
||||||
|
// This isn't something a store line would normally be configured
|
||||||
|
// for; when the type is anything other than 'UNASSIGNED', calls can
|
||||||
|
// be filtered/screened through an assistant relationship in ways
|
||||||
|
// that look like "the store phone isn't behaving right". We surface
|
||||||
|
// the current type so an operator can decide whether to unassign it
|
||||||
|
// in Control Hub. No remediation button — this is intentional
|
||||||
|
// executive configuration, we don't auto-flip it.
|
||||||
|
//
|
||||||
|
// Endpoint: GET /v1/people/{personId}/features/executiveAssistant
|
||||||
|
// Scope: spark-admin:people_read
|
||||||
|
// Response shape: { type: 'UNASSIGNED' | 'EXECUTIVE' | 'EXECUTIVE_ASSISTANT' }
|
||||||
|
//
|
||||||
|
// NOTE: Webex has announced a path migration for this endpoint (see
|
||||||
|
// developer.webex.com changelog — /v1/telephony/config/people/{id}/executive
|
||||||
|
// is the new path). When that ships and starts returning 404 on the
|
||||||
|
// legacy path, swap the ENDPOINT constant. Do NOT eagerly switch
|
||||||
|
// before Cisco's cutover window closes — until then only the legacy
|
||||||
|
// path is live.
|
||||||
|
|
||||||
|
const ENDPOINT = (personId) =>
|
||||||
|
`people/${personId}/features/executiveAssistant`;
|
||||||
|
|
||||||
|
export const executiveAssistantCheck = {
|
||||||
|
id: 'executiveAssistant',
|
||||||
|
label: 'Executive / Assistant',
|
||||||
|
requires: ['personId'],
|
||||||
|
scope: 'spark-admin:people_read',
|
||||||
|
|
||||||
|
async run(ctx) {
|
||||||
|
const data = await ctx.webex.request('GET', ENDPOINT(ctx.personId));
|
||||||
|
const type = data?.type || 'UNASSIGNED';
|
||||||
|
|
||||||
|
if (type === 'UNASSIGNED') {
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
message: 'No executive / assistant relationship (normal for a store line).',
|
||||||
|
details: { type },
|
||||||
|
remediation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'warn',
|
||||||
|
message:
|
||||||
|
`This line is configured as **${type}** in the executive/assistant feature. ` +
|
||||||
|
`Verify this is intentional — otherwise calls may be routed through an ` +
|
||||||
|
`assistant relationship.`,
|
||||||
|
details: { type },
|
||||||
|
remediation: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
46
services/voiceDiag/checks/hoteling.js
Normal file
46
services/voiceDiag/checks/hoteling.js
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
// src/services/voiceDiag/checks/hoteling.js
|
||||||
|
//
|
||||||
|
// Info-only check for the Hoteling feature on the store user's line.
|
||||||
|
// Hoteling lets a "guest" line temporarily associate with a shared
|
||||||
|
// desk phone — if it's turned on for a store user that shouldn't have
|
||||||
|
// it, calls can end up at whatever guest device most recently checked
|
||||||
|
// in, which usually presents as "the phone at the store isn't the
|
||||||
|
// one that rings when we call". No remediation is offered because
|
||||||
|
// the intended state is site-dependent; the operator can flip it via
|
||||||
|
// Control Hub if the current state is wrong.
|
||||||
|
//
|
||||||
|
// Endpoint: GET /v1/people/{personId}/features/hoteling
|
||||||
|
// Scope: spark-admin:people_read
|
||||||
|
// Response shape: { enabled: boolean }
|
||||||
|
|
||||||
|
const ENDPOINT = (personId) =>
|
||||||
|
`people/${personId}/features/hoteling`;
|
||||||
|
|
||||||
|
export const hotelingCheck = {
|
||||||
|
id: 'hoteling',
|
||||||
|
label: 'Hoteling',
|
||||||
|
requires: ['personId'],
|
||||||
|
scope: 'spark-admin:people_read',
|
||||||
|
|
||||||
|
async run(ctx) {
|
||||||
|
const data = await ctx.webex.request('GET', ENDPOINT(ctx.personId));
|
||||||
|
const enabled = !!data?.enabled;
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
message: 'Hoteling is disabled (normal for a store line).',
|
||||||
|
details: { enabled },
|
||||||
|
remediation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'warn',
|
||||||
|
message:
|
||||||
|
'Hoteling is ENABLED — this line may be roaming to another desk phone. Verify in Control Hub.',
|
||||||
|
details: { enabled },
|
||||||
|
remediation: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
74
services/voiceDiag/checks/index.js
Normal file
74
services/voiceDiag/checks/index.js
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
// src/services/voiceDiag/checks/index.js
|
||||||
|
//
|
||||||
|
// Ordered registry of every /voicediag check. Order matters — the
|
||||||
|
// renderer walks this list to produce the output, so put the checks a
|
||||||
|
// human operator would notice first (DND, forwarding) at the top,
|
||||||
|
// then the deeper feature-config ones, then finally the online-device
|
||||||
|
// summary which is often the last thing they want to see.
|
||||||
|
//
|
||||||
|
// Adding a new check
|
||||||
|
// 1. Drop a new file next to this one (e.g. `myNewCheck.js`) that
|
||||||
|
// exports the check descriptor as its default export or a named
|
||||||
|
// export.
|
||||||
|
// 2. Add it to CHECKS below in the position you want it to render.
|
||||||
|
// 3. If it exports a `remediations: {actionId: handler}` map, the
|
||||||
|
// voiceDiagService remediation registry will pick it up
|
||||||
|
// automatically. Action ids must be globally unique across all
|
||||||
|
// checks — a collision throws at startup.
|
||||||
|
//
|
||||||
|
// Every check must export an object matching:
|
||||||
|
//
|
||||||
|
// {
|
||||||
|
// id: string, // stable, snake_case, globally unique
|
||||||
|
// label: string, // human-readable heading
|
||||||
|
// requires: string[], // subset of ['personId','phoneStatus',
|
||||||
|
// // 'telephonyProfile'] — runner skips
|
||||||
|
// // the check if any are missing on ctx
|
||||||
|
// scope: string, // primary Webex scope needed; surfaced
|
||||||
|
// // in the skipped message on 401/403/404
|
||||||
|
// run: async (ctx) => CheckResult,
|
||||||
|
// remediations?: { [actionId: string]: async (bot, data, requester) => void }
|
||||||
|
// }
|
||||||
|
|
||||||
|
import { dndCheck } from './dnd.js';
|
||||||
|
import { callForwardingCheck } from './callForwarding.js';
|
||||||
|
import { callWaitingCheck } from './callWaiting.js';
|
||||||
|
import { voicemailCheck } from './voicemail.js';
|
||||||
|
import { callInterceptCheck } from './callIntercept.js';
|
||||||
|
import { hotelingCheck } from './hoteling.js';
|
||||||
|
import { executiveAssistantCheck } from './executiveAssistant.js';
|
||||||
|
import { outgoingPermissionCheck } from './outgoingPermission.js';
|
||||||
|
import { phoneOnlineCheck } from './phoneOnline.js';
|
||||||
|
|
||||||
|
export const CHECKS = [
|
||||||
|
dndCheck,
|
||||||
|
callForwardingCheck,
|
||||||
|
callInterceptCheck,
|
||||||
|
callWaitingCheck,
|
||||||
|
voicemailCheck,
|
||||||
|
hotelingCheck,
|
||||||
|
executiveAssistantCheck,
|
||||||
|
outgoingPermissionCheck,
|
||||||
|
phoneOnlineCheck,
|
||||||
|
];
|
||||||
|
|
||||||
|
const _byId = new Map();
|
||||||
|
for (const c of CHECKS) {
|
||||||
|
if (!c || typeof c !== 'object') {
|
||||||
|
throw new Error('voicediag: encountered non-object check in registry');
|
||||||
|
}
|
||||||
|
if (!c.id || typeof c.id !== 'string') {
|
||||||
|
throw new Error('voicediag: check is missing required "id" string');
|
||||||
|
}
|
||||||
|
const key = c.id.toLowerCase();
|
||||||
|
if (_byId.has(key)) {
|
||||||
|
throw new Error(`voicediag: duplicate check id "${c.id}"`);
|
||||||
|
}
|
||||||
|
_byId.set(key, c);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Case-insensitive lookup so `--only DND` and `--only dnd` both work. */
|
||||||
|
export function getCheckById(id) {
|
||||||
|
if (!id) return null;
|
||||||
|
return _byId.get(String(id).toLowerCase().trim()) || null;
|
||||||
|
}
|
||||||
98
services/voiceDiag/checks/outgoingPermission.js
Normal file
98
services/voiceDiag/checks/outgoingPermission.js
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
// src/services/voiceDiag/checks/outgoingPermission.js
|
||||||
|
//
|
||||||
|
// Info-only check for outgoing call permissions. Extends the
|
||||||
|
// count-only surface currently in `/phonestatus` with per-rule
|
||||||
|
// detail. When a call type is set to BLOCK (rather than ALLOW /
|
||||||
|
// AUTH_CODE / TRANSFER), users on that line can't dial calls of that
|
||||||
|
// class. Blocked domestic-toll or international are especially
|
||||||
|
// common tickets ("we can't dial out from the store"), so we flag
|
||||||
|
// those as a warning.
|
||||||
|
//
|
||||||
|
// Endpoint: GET /v1/people/{personId}/features/outgoingPermission
|
||||||
|
// Scope: spark-admin:people_read
|
||||||
|
// Response shape:
|
||||||
|
// {
|
||||||
|
// useCustomEnabled: boolean,
|
||||||
|
// callingPermissions: [
|
||||||
|
// { callType: 'INTERNAL_CALL' | 'LOCAL' | 'TOLL_FREE' | 'TOLL' |
|
||||||
|
// 'NATIONAL' | 'INTERNATIONAL' | 'OPERATOR_ASSISTED' |
|
||||||
|
// 'CHARGEABLE_DIRECTORY_ASSISTED' | 'SPECIAL_SERVICES_I'|II |
|
||||||
|
// 'PREMIUM_SERVICES_I'|II | 'CASUAL',
|
||||||
|
// action: 'ALLOW' | 'BLOCK' | 'AUTH_CODE' | 'TRANSFER_NUMBER_1'..3,
|
||||||
|
// transferEnabled: boolean }
|
||||||
|
// ]
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// When `useCustomEnabled=false` the account defers to the site's
|
||||||
|
// default outgoing permission set — in that case there's nothing
|
||||||
|
// user-scoped to report, so we return OK with a note.
|
||||||
|
|
||||||
|
const ENDPOINT = (personId) =>
|
||||||
|
`people/${personId}/features/outgoingPermission`;
|
||||||
|
|
||||||
|
// Call types that being BLOCKED tends to be a bug for a store line.
|
||||||
|
// Others (premium/casual/operator) are almost always intentionally
|
||||||
|
// blocked, so blocking them is fine.
|
||||||
|
const HIGH_IMPACT_CALL_TYPES = new Set([
|
||||||
|
'LOCAL',
|
||||||
|
'NATIONAL',
|
||||||
|
'TOLL_FREE',
|
||||||
|
'TOLL',
|
||||||
|
'INTERNATIONAL',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const outgoingPermissionCheck = {
|
||||||
|
id: 'outgoingPermission',
|
||||||
|
label: 'Outgoing Call Permissions',
|
||||||
|
requires: ['personId'],
|
||||||
|
scope: 'spark-admin:people_read',
|
||||||
|
|
||||||
|
async run(ctx) {
|
||||||
|
const data = await ctx.webex.request('GET', ENDPOINT(ctx.personId));
|
||||||
|
|
||||||
|
const useCustomEnabled = !!data?.useCustomEnabled;
|
||||||
|
const rules = Array.isArray(data?.callingPermissions) ? data.callingPermissions : [];
|
||||||
|
|
||||||
|
if (!useCustomEnabled) {
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
message: 'Using the location default outgoing permission set (no user override).',
|
||||||
|
details: { useCustomEnabled, rules },
|
||||||
|
remediation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const blocked = rules
|
||||||
|
.filter((r) => r?.action === 'BLOCK')
|
||||||
|
.map((r) => r.callType);
|
||||||
|
|
||||||
|
const highImpactBlocked = blocked.filter((t) => HIGH_IMPACT_CALL_TYPES.has(t));
|
||||||
|
|
||||||
|
if (highImpactBlocked.length > 0) {
|
||||||
|
return {
|
||||||
|
status: 'warn',
|
||||||
|
message:
|
||||||
|
`Custom outgoing permissions are set and the following high-impact call types ` +
|
||||||
|
`are BLOCKED: ${highImpactBlocked.join(', ')}. Users on this line will hear a fast-busy for those calls.`,
|
||||||
|
details: { useCustomEnabled, rules, blocked, highImpactBlocked },
|
||||||
|
remediation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blocked.length > 0) {
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
message: `Custom outgoing permissions with ${blocked.length} blocked call type(s): ${blocked.join(', ')}. None are high-impact.`,
|
||||||
|
details: { useCustomEnabled, rules, blocked, highImpactBlocked: [] },
|
||||||
|
remediation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
message: `Custom outgoing permissions (${rules.length} rules) — nothing is blocked.`,
|
||||||
|
details: { useCustomEnabled, rules, blocked: [], highImpactBlocked: [] },
|
||||||
|
remediation: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
130
services/voiceDiag/checks/phoneOnline.js
Normal file
130
services/voiceDiag/checks/phoneOnline.js
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
// src/services/voiceDiag/checks/phoneOnline.js
|
||||||
|
//
|
||||||
|
// Summarises how many of the store's registered devices are online
|
||||||
|
// right now, plus a per-device drill-down. Deliberately reuses the
|
||||||
|
// phone-status snapshot already fetched during buildContext() — this
|
||||||
|
// check makes ZERO new API calls, which keeps it fast and keeps the
|
||||||
|
// /voicediag output aligned with /phonestatus for the same store
|
||||||
|
// (same source, same view of "which phones are up right now").
|
||||||
|
//
|
||||||
|
// Signal shape:
|
||||||
|
// status: 'error' when at least one phone is offline (a store with
|
||||||
|
// a dead phone is a real ticket).
|
||||||
|
// status: 'warn' when the store has no registered devices at all
|
||||||
|
// (this is often "we didn't ship / provision yet" but still worth
|
||||||
|
// surfacing).
|
||||||
|
// status: 'ok' when every device reports `connected`.
|
||||||
|
//
|
||||||
|
// Device shape from collectPhoneStatus:
|
||||||
|
// data.phones.data[i] = { mac, name, status, lastSeen, firmware, model, ... }
|
||||||
|
// data.dectBasestations[i] = { mac, name, status, lastSeen, firmware, ... }
|
||||||
|
//
|
||||||
|
// Statuses observed in the wild: 'connected', 'disconnected',
|
||||||
|
// 'unknown', 'activating', 'offline'. We treat 'connected' as the
|
||||||
|
// only truly OK value; everything else counts as offline for the
|
||||||
|
// purposes of the summary.
|
||||||
|
|
||||||
|
const OK_STATUSES = new Set(['connected']);
|
||||||
|
|
||||||
|
function isOnline(dev) {
|
||||||
|
const s = String(dev?.status || '').toLowerCase();
|
||||||
|
return OK_STATUSES.has(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const phoneOnlineCheck = {
|
||||||
|
id: 'phoneOnline',
|
||||||
|
label: 'Phone Online Status',
|
||||||
|
requires: ['phoneStatus'],
|
||||||
|
// No new API scope required — reuses collectPhoneStatus() output,
|
||||||
|
// which is already gated on the /phonestatus set of scopes.
|
||||||
|
scope: null,
|
||||||
|
|
||||||
|
async run(ctx) {
|
||||||
|
const data = ctx.phoneStatus;
|
||||||
|
if (!data) {
|
||||||
|
return {
|
||||||
|
status: 'skipped',
|
||||||
|
message: 'phoneStatus snapshot unavailable — cannot report device online counts.',
|
||||||
|
details: null,
|
||||||
|
remediation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const phones = Array.isArray(data.phones?.data) ? data.phones.data : [];
|
||||||
|
const dectBases = Array.isArray(data.dectBasestations) ? data.dectBasestations : [];
|
||||||
|
|
||||||
|
const phonesOnline = phones.filter(isOnline);
|
||||||
|
const phonesOffline = phones.filter((p) => !isOnline(p));
|
||||||
|
const basesOnline = dectBases.filter(isOnline);
|
||||||
|
const basesOffline = dectBases.filter((b) => !isOnline(b));
|
||||||
|
|
||||||
|
const totalDevices = phones.length + dectBases.length;
|
||||||
|
|
||||||
|
if (totalDevices === 0) {
|
||||||
|
return {
|
||||||
|
status: 'warn',
|
||||||
|
message: 'No desk phones or DECT basestations are registered for this store.',
|
||||||
|
details: {
|
||||||
|
phonesTotal: 0,
|
||||||
|
phonesOnline: 0,
|
||||||
|
phonesOffline: 0,
|
||||||
|
basesTotal: 0,
|
||||||
|
basesOnline: 0,
|
||||||
|
basesOffline: 0,
|
||||||
|
},
|
||||||
|
remediation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const offlineCount = phonesOffline.length + basesOffline.length;
|
||||||
|
const summary =
|
||||||
|
`Desk phones: ${phonesOnline.length}/${phones.length} online. ` +
|
||||||
|
`DECT bases: ${basesOnline.length}/${dectBases.length} online.`;
|
||||||
|
|
||||||
|
const offlineList = [
|
||||||
|
...phonesOffline.map((p) => ({
|
||||||
|
kind: 'phone',
|
||||||
|
name: p.name || 'Unknown phone',
|
||||||
|
mac: p.mac || null,
|
||||||
|
status: p.status || 'unknown',
|
||||||
|
lastSeen: p.lastSeen || null,
|
||||||
|
model: p.model || null,
|
||||||
|
})),
|
||||||
|
...basesOffline.map((b) => ({
|
||||||
|
kind: 'dect-base',
|
||||||
|
name: b.name || 'Unknown DECT base',
|
||||||
|
mac: b.mac || null,
|
||||||
|
status: b.status || 'unknown',
|
||||||
|
lastSeen: b.lastSeen || null,
|
||||||
|
model: b.model || null,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
const details = {
|
||||||
|
phonesTotal: phones.length,
|
||||||
|
phonesOnline: phonesOnline.length,
|
||||||
|
phonesOffline: phonesOffline.length,
|
||||||
|
basesTotal: dectBases.length,
|
||||||
|
basesOnline: basesOnline.length,
|
||||||
|
basesOffline: basesOffline.length,
|
||||||
|
offlineDevices: offlineList,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (offlineCount === 0) {
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
message: summary,
|
||||||
|
details,
|
||||||
|
remediation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
message:
|
||||||
|
`${offlineCount} device(s) offline for this store. ${summary}`,
|
||||||
|
details,
|
||||||
|
remediation: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
138
services/voiceDiag/checks/voicemail.js
Normal file
138
services/voiceDiag/checks/voicemail.js
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
// src/services/voiceDiag/checks/voicemail.js
|
||||||
|
//
|
||||||
|
// Detects voicemail configuration for the store user. Voicemail has
|
||||||
|
// enough sub-facets that we surface the current settings without
|
||||||
|
// offering a one-click remediation — the "right" answer for a store
|
||||||
|
// is site-specific (some sites disable VM entirely and forward busy
|
||||||
|
// to the AA, others rely on it as the noAnswer target). We flag two
|
||||||
|
// classes of finding:
|
||||||
|
//
|
||||||
|
// - **error**: enabled=true AND no PIN set — the user cannot pick
|
||||||
|
// up messages, and every caller who reaches VM will be dumped
|
||||||
|
// into the "please set your PIN" prompt.
|
||||||
|
// - **warn**: enabled=true AND a forward-to-email target is
|
||||||
|
// configured that doesn't look like an @ae.com address — mail
|
||||||
|
// forwarding of voicemail off-org is worth double-checking.
|
||||||
|
// - **ok**: everything else.
|
||||||
|
//
|
||||||
|
// Endpoint: GET /v1/people/{personId}/features/voicemail
|
||||||
|
// Scope: spark-admin:people_read
|
||||||
|
// Response shape (relevant fields):
|
||||||
|
// {
|
||||||
|
// enabled: true,
|
||||||
|
// sendAllCalls: { enabled: false },
|
||||||
|
// sendBusyCalls: { enabled: false, greeting: 'DEFAULT' },
|
||||||
|
// sendUnansweredCalls: { enabled: true, ... },
|
||||||
|
// notifications: { enabled: false, destination: '' },
|
||||||
|
// transferToNumber: { enabled: false, destination: '' },
|
||||||
|
// emailCopyOfMessage: { enabled: false, emailId: '' },
|
||||||
|
// messageStorage: {
|
||||||
|
// mwiEnabled: true,
|
||||||
|
// storageType: 'INTERNAL' | 'EXTERNAL',
|
||||||
|
// externalEmail: ''
|
||||||
|
// },
|
||||||
|
// faxMessage: { ... }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// Webex doesn't currently expose PIN-set status via the public API,
|
||||||
|
// so "PIN set" is inferred pragmatically: we check whether the person
|
||||||
|
// has ever accessed their voicemail (via passcode lastChanged if the
|
||||||
|
// endpoint returns it) or, as a fallback, we simply note that PIN
|
||||||
|
// state is unknown and treat it as informational only. See the note
|
||||||
|
// inline below.
|
||||||
|
|
||||||
|
const ENDPOINT = (personId) =>
|
||||||
|
`people/${personId}/features/voicemail`;
|
||||||
|
|
||||||
|
const AE_EMAIL_RE = /@ae\.com$/i;
|
||||||
|
|
||||||
|
export const voicemailCheck = {
|
||||||
|
id: 'voicemail',
|
||||||
|
label: 'Voicemail',
|
||||||
|
requires: ['personId'],
|
||||||
|
scope: 'spark-admin:people_read',
|
||||||
|
|
||||||
|
async run(ctx) {
|
||||||
|
const data = await ctx.webex.request('GET', ENDPOINT(ctx.personId));
|
||||||
|
|
||||||
|
const enabled = !!data?.enabled;
|
||||||
|
const storage = data?.messageStorage || {};
|
||||||
|
const mwiEnabled = !!storage?.mwiEnabled;
|
||||||
|
const emailCopy = data?.emailCopyOfMessage || {};
|
||||||
|
const transferTo = data?.transferToNumber || {};
|
||||||
|
const externalEmail = storage?.externalEmail || '';
|
||||||
|
const forwardEmailTarget = emailCopy?.enabled ? (emailCopy?.emailId || '') : '';
|
||||||
|
|
||||||
|
const details = {
|
||||||
|
enabled,
|
||||||
|
mwiEnabled,
|
||||||
|
storageType: storage?.storageType || null,
|
||||||
|
externalEmail,
|
||||||
|
emailCopyEnabled: !!emailCopy?.enabled,
|
||||||
|
emailCopyTarget: forwardEmailTarget,
|
||||||
|
transferToEnabled: !!transferTo?.enabled,
|
||||||
|
transferToDestination: transferTo?.destination || null,
|
||||||
|
sendAllCallsEnabled: !!data?.sendAllCalls?.enabled,
|
||||||
|
sendBusyCallsEnabled: !!data?.sendBusyCalls?.enabled,
|
||||||
|
sendUnansweredCallsEnabled: !!data?.sendUnansweredCalls?.enabled,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
message: 'Voicemail is disabled — callers will not be able to leave messages.',
|
||||||
|
details,
|
||||||
|
remediation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendAllCalls silently swallowing every inbound call is almost
|
||||||
|
// always a mistake — surface it loudly.
|
||||||
|
if (details.sendAllCallsEnabled) {
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
message: 'Voicemail is enabled AND "send all calls to voicemail" is on — the phone will never ring.',
|
||||||
|
details,
|
||||||
|
remediation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mwiEnabled) {
|
||||||
|
return {
|
||||||
|
status: 'warn',
|
||||||
|
message: 'Voicemail is enabled but MWI (message-waiting indicator) is off — new messages won\'t light up the phone.',
|
||||||
|
details,
|
||||||
|
remediation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (forwardEmailTarget && !AE_EMAIL_RE.test(forwardEmailTarget)) {
|
||||||
|
return {
|
||||||
|
status: 'warn',
|
||||||
|
message: `Voicemail-to-email is forwarding to an off-org address: ${forwardEmailTarget}. Verify this is intentional.`,
|
||||||
|
details,
|
||||||
|
remediation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (details.transferToEnabled && !details.transferToDestination) {
|
||||||
|
return {
|
||||||
|
status: 'warn',
|
||||||
|
message: 'Voicemail transfer-to-number is enabled but no destination is set — will fall through to the default greeting.',
|
||||||
|
details,
|
||||||
|
remediation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
message: 'Voicemail is enabled with MWI on and no unusual forwarding.',
|
||||||
|
details,
|
||||||
|
remediation: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// No remediations — voicemail policy is too site-specific for a
|
||||||
|
// one-size-fits-all button. The renderer surfaces the details so
|
||||||
|
// the operator can act via Control Hub if they want.
|
||||||
|
};
|
||||||
318
services/voiceDiag/voiceDiagService.js
Normal file
318
services/voiceDiag/voiceDiagService.js
Normal file
|
|
@ -0,0 +1,318 @@
|
||||||
|
// src/services/voiceDiag/voiceDiagService.js
|
||||||
|
//
|
||||||
|
// Orchestrator for the /voicediag command. Two responsibilities:
|
||||||
|
//
|
||||||
|
// 1. buildContext(storeNum) — resolves the store's canonical Webex
|
||||||
|
// user (currently 1:1 with a store number via the ae<pad5>@ae.com
|
||||||
|
// convention) and pre-fetches shared data that most checks want
|
||||||
|
// (device list, telephony profile, phone status). Doing this
|
||||||
|
// once here means individual check modules don't each re-do the
|
||||||
|
// lookup.
|
||||||
|
//
|
||||||
|
// 2. runVoiceDiag(storeNum, opts) — iterates the check registry,
|
||||||
|
// filters by `requires` capabilities present in the context and
|
||||||
|
// an optional `only` allow-list, executes each check in parallel
|
||||||
|
// via Promise.allSettled so one 500 doesn't take the run down,
|
||||||
|
// and normalises any thrown error into a `status: 'skipped'` or
|
||||||
|
// `status: 'error'` CheckResult. The returned array is in
|
||||||
|
// registry order — the renderer relies on that for stable output.
|
||||||
|
//
|
||||||
|
// Design principles:
|
||||||
|
// - Checks never see raw axios errors — the runner turns 401/403/404
|
||||||
|
// into `skipped: scope missing` and everything else into
|
||||||
|
// `error: <message>`. Individual checks decide their own OK/WARN
|
||||||
|
// when their fetch succeeds.
|
||||||
|
// - Context is intentionally minimal. If a future check needs an
|
||||||
|
// extra pre-fetch, add it here rather than duplicating the fetch
|
||||||
|
// per check.
|
||||||
|
// - Never throws — every failure surfaces as a CheckResult so the
|
||||||
|
// command handler can render *something* to the user.
|
||||||
|
|
||||||
|
import { logger } from '../../utils/logger.js';
|
||||||
|
import { CHECKS, getCheckById } from './checks/index.js';
|
||||||
|
|
||||||
|
// The WebexClient singleton + phoneService helpers are imported LAZILY
|
||||||
|
// inside buildContext(). Eager top-level imports would run the
|
||||||
|
// WebexClient constructor at module load time, which requires
|
||||||
|
// WEBEX_CLIENT_ID / WEBEX_CLIENT_SECRET in the environment. That's
|
||||||
|
// fine at runtime but breaks unit tests that only exercise
|
||||||
|
// runVoiceDiag / buildRemediationRegistry against a stub ctx (see
|
||||||
|
// tests/voiceDiag.checks.test.js).
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the shared execution context for a /voicediag run. Pre-fetches
|
||||||
|
* the person + telephony profile + phone-status snapshot up-front so
|
||||||
|
* each check has cheap random access to them. Missing pieces are set
|
||||||
|
* to null so `requires` gating in runVoiceDiag can short-circuit.
|
||||||
|
*
|
||||||
|
* @param {string} storeNum 2-4 digit store id
|
||||||
|
* @returns {Promise<{
|
||||||
|
* storeNum: string,
|
||||||
|
* email: string,
|
||||||
|
* personId: string | null,
|
||||||
|
* person: object | null,
|
||||||
|
* telephonyProfile: object,
|
||||||
|
* phoneStatus: object | null,
|
||||||
|
* personLabel: string,
|
||||||
|
* }>}
|
||||||
|
*/
|
||||||
|
export async function buildContext(storeNum) {
|
||||||
|
const email = `ae${String(storeNum).padStart(5, '0')}@ae.com`;
|
||||||
|
logger('voicediag', `Building context for store ${storeNum} → ${email}`, 'debug');
|
||||||
|
|
||||||
|
const { default: webex } = await import('../../integrations/webex/WebexClient.js');
|
||||||
|
const {
|
||||||
|
getPersonIdByEmail,
|
||||||
|
getPersonDetails,
|
||||||
|
getTelephonyProfile,
|
||||||
|
collectPhoneStatus,
|
||||||
|
} = await import('../phoneService.js');
|
||||||
|
|
||||||
|
const personId = await getPersonIdByEmail(email);
|
||||||
|
|
||||||
|
// If we have no person, skip the expensive phoneStatus fetch — the
|
||||||
|
// renderer will show every check as `skipped: no user`. This keeps
|
||||||
|
// the "unknown store" case cheap.
|
||||||
|
const [personRes, telProfRes, phoneStatusRes] = personId
|
||||||
|
? await Promise.allSettled([
|
||||||
|
getPersonDetails(personId),
|
||||||
|
getTelephonyProfile(personId),
|
||||||
|
collectPhoneStatus(storeNum).catch((err) => {
|
||||||
|
// collectPhoneStatus can throw noisily on network hiccups.
|
||||||
|
// We tolerate this: the phoneOnline check will read
|
||||||
|
// ctx.phoneStatus and show 'skipped' if it's null.
|
||||||
|
logger('voicediag', `collectPhoneStatus soft-failed: ${err.message}`, 'warn');
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
: [
|
||||||
|
{ status: 'fulfilled', value: null },
|
||||||
|
{ status: 'fulfilled', value: {} },
|
||||||
|
{ status: 'fulfilled', value: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
const person = personRes.status === 'fulfilled' ? personRes.value : null;
|
||||||
|
const telephonyProfile = telProfRes.status === 'fulfilled' ? (telProfRes.value || {}) : {};
|
||||||
|
const phoneStatus = phoneStatusRes.status === 'fulfilled' ? phoneStatusRes.value : null;
|
||||||
|
|
||||||
|
const personLabel = person?.displayName || email;
|
||||||
|
|
||||||
|
return {
|
||||||
|
storeNum: String(storeNum),
|
||||||
|
email,
|
||||||
|
personId,
|
||||||
|
person,
|
||||||
|
telephonyProfile,
|
||||||
|
phoneStatus,
|
||||||
|
personLabel,
|
||||||
|
// Bare reference to the WebexClient singleton (lazy-loaded above)
|
||||||
|
// so checks can make their own targeted calls
|
||||||
|
// (`people/{id}/features/*`) without re-importing from every
|
||||||
|
// check module. Keeps mocking easy in tests — a test can pass a
|
||||||
|
// stub context with a fake webex; no ctx passes through the real
|
||||||
|
// client unless it came from buildContext().
|
||||||
|
webex,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for runVoiceDiag.
|
||||||
|
*
|
||||||
|
* @typedef {Object} RunVoiceDiagOptions
|
||||||
|
* @property {string[]} [only]
|
||||||
|
* Restrict execution to the given check ids. Unknown ids are logged
|
||||||
|
* at 'warn' and skipped silently in the results (no CheckResult for
|
||||||
|
* them). Empty array runs everything (same as omitted).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute all registered checks against the given context. Never
|
||||||
|
* throws. Returns one CheckResult per check that actually ran; checks
|
||||||
|
* whose `requires` aren't satisfied by the context appear as
|
||||||
|
* `skipped: missing <cap>` so the renderer can still show them.
|
||||||
|
*
|
||||||
|
* @param {ReturnType<typeof buildContext> extends Promise<infer T> ? T : never} ctx
|
||||||
|
* @param {RunVoiceDiagOptions} [opts]
|
||||||
|
* @returns {Promise<CheckResult[]>}
|
||||||
|
*/
|
||||||
|
export async function runVoiceDiag(ctx, opts = {}) {
|
||||||
|
const onlyIds = Array.isArray(opts.only) && opts.only.length > 0
|
||||||
|
? new Set(opts.only.map((s) => String(s).toLowerCase().trim()))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (onlyIds) {
|
||||||
|
for (const id of onlyIds) {
|
||||||
|
if (!getCheckById(id)) {
|
||||||
|
logger('voicediag', `--only referenced unknown check id: ${id}`, 'warn');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both sides lowercased so `--only DND` matches check.id === 'dnd'
|
||||||
|
// and `--only callforwarding` matches check.id === 'callForwarding'.
|
||||||
|
const toRun = CHECKS.filter((c) => !onlyIds || onlyIds.has(String(c.id).toLowerCase()));
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
toRun.map(async (check) => {
|
||||||
|
const missing = missingRequirements(check, ctx);
|
||||||
|
if (missing.length > 0) {
|
||||||
|
return skipped(
|
||||||
|
check,
|
||||||
|
`not applicable — missing ${missing.join(', ')} for this store`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await check.run(ctx);
|
||||||
|
return normalizeResult(check, result);
|
||||||
|
} catch (err) {
|
||||||
|
return errorToResult(check, err);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
function missingRequirements(check, ctx) {
|
||||||
|
const reqs = Array.isArray(check.requires) ? check.requires : [];
|
||||||
|
const missing = [];
|
||||||
|
for (const req of reqs) {
|
||||||
|
if (req === 'personId' && !ctx.personId) missing.push('personId');
|
||||||
|
else if (req === 'phoneStatus' && !ctx.phoneStatus) missing.push('phoneStatus');
|
||||||
|
else if (req === 'telephonyProfile' && (!ctx.telephonyProfile || Object.keys(ctx.telephonyProfile).length === 0)) {
|
||||||
|
missing.push('telephonyProfile');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return missing;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeResult(check, result) {
|
||||||
|
if (!result || typeof result !== 'object') {
|
||||||
|
return {
|
||||||
|
id: check.id,
|
||||||
|
label: check.label,
|
||||||
|
status: 'error',
|
||||||
|
message: `Check "${check.id}" returned no result`,
|
||||||
|
details: null,
|
||||||
|
remediation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: check.id,
|
||||||
|
label: check.label,
|
||||||
|
status: result.status || 'ok',
|
||||||
|
message: result.message || '',
|
||||||
|
details: result.details ?? null,
|
||||||
|
remediation: result.remediation ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorToResult(check, err) {
|
||||||
|
const status = err?.response?.status;
|
||||||
|
const apiMsg =
|
||||||
|
err?.response?.data?.message ||
|
||||||
|
err?.response?.data?.errors?.[0]?.description ||
|
||||||
|
err?.message ||
|
||||||
|
String(err);
|
||||||
|
|
||||||
|
// 401/403 → the service-app token doesn't have the scope. Surface
|
||||||
|
// as `skipped: scope missing` and keep the run going against the
|
||||||
|
// other checks. The scope is uniformly `spark-admin:people_read`
|
||||||
|
// for the `/v1/people/{id}/features/*` surface, so if this fires
|
||||||
|
// on ANY check it will fire on all of them — but we still emit
|
||||||
|
// one skipped row per check so the operator can see the pattern.
|
||||||
|
if (status === 401 || status === 403) {
|
||||||
|
return {
|
||||||
|
id: check.id,
|
||||||
|
label: check.label,
|
||||||
|
status: 'skipped',
|
||||||
|
message:
|
||||||
|
`${status} on ${check.label} — the service app token is missing ` +
|
||||||
|
`\`${check.scope || 'spark-admin:people_read'}\`. ` +
|
||||||
|
`Re-authorize the app and re-bootstrap ` +
|
||||||
|
`\`tokens/webex-service-tokens.json\`.`,
|
||||||
|
details: { httpStatus: status },
|
||||||
|
remediation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 404 on `/v1/people/{id}/features/*` most commonly means the
|
||||||
|
// person isn't a Webex Calling user (no calling license, no
|
||||||
|
// location assigned, or a service line) — the same admin URL
|
||||||
|
// returns 200 for a properly-provisioned calling user. If the
|
||||||
|
// path itself moved (see Webex changelog for scheduled migrations
|
||||||
|
// to `/v1/telephony/config/people/{id}/...`), you'll see the
|
||||||
|
// "no static resource" message quoted below, which is a routing
|
||||||
|
// 404 rather than a "not applicable" 404 — the response body
|
||||||
|
// distinguishes them.
|
||||||
|
if (status === 404) {
|
||||||
|
const routingMiss = /no static resource/i.test(apiMsg);
|
||||||
|
return {
|
||||||
|
id: check.id,
|
||||||
|
label: check.label,
|
||||||
|
status: 'skipped',
|
||||||
|
message: routingMiss
|
||||||
|
? `404 (routing) on ${check.label} — the URL for this feature has moved. ` +
|
||||||
|
`Update the ENDPOINT constant in the check module. Details: ${apiMsg}`
|
||||||
|
: `404 on ${check.label} — this user has no Webex Calling license or the ` +
|
||||||
|
`feature isn't applicable to their line type.`,
|
||||||
|
details: { httpStatus: 404, routingMiss, apiMsg },
|
||||||
|
remediation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
logger('voicediag', `Check ${check.id} failed: ${apiMsg}`, 'warn');
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: check.id,
|
||||||
|
label: check.label,
|
||||||
|
status: 'error',
|
||||||
|
message: `Error running ${check.label}: ${apiMsg}${status ? ` (HTTP ${status})` : ''}`,
|
||||||
|
details: { httpStatus: status || null },
|
||||||
|
remediation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function skipped(check, message) {
|
||||||
|
return {
|
||||||
|
id: check.id,
|
||||||
|
label: check.label,
|
||||||
|
status: 'skipped',
|
||||||
|
message,
|
||||||
|
details: null,
|
||||||
|
remediation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a lookup { remediationId -> handler } across every check.
|
||||||
|
* Used by commands/voiceDiag.js's `applyVoiceDiagRemediation` to
|
||||||
|
* dispatch a `confirm_voicediag` attachmentAction to the right
|
||||||
|
* check's remediation function.
|
||||||
|
*
|
||||||
|
* @returns {Map<string, {check: object, handler: Function}>}
|
||||||
|
*/
|
||||||
|
export function buildRemediationRegistry() {
|
||||||
|
const map = new Map();
|
||||||
|
for (const check of CHECKS) {
|
||||||
|
const rems = check.remediations;
|
||||||
|
if (!rems || typeof rems !== 'object') continue;
|
||||||
|
for (const [actionId, handler] of Object.entries(rems)) {
|
||||||
|
if (typeof handler !== 'function') continue;
|
||||||
|
if (map.has(actionId)) {
|
||||||
|
// Duplicate remediation ids across two checks would silently
|
||||||
|
// swallow one. Fail loud so the mistake is obvious in tests.
|
||||||
|
throw new Error(
|
||||||
|
`Duplicate voicediag remediation id "${actionId}" — check "${check.id}" ` +
|
||||||
|
`collides with an already-registered handler`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
map.set(actionId, { check, handler });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export CHECKS so tests / list-checks handlers don't need a
|
||||||
|
// second import from ./checks/index.js.
|
||||||
|
export { CHECKS };
|
||||||
492
tests/voiceDiag.checks.test.js
Normal file
492
tests/voiceDiag.checks.test.js
Normal file
|
|
@ -0,0 +1,492 @@
|
||||||
|
// Unit tests for services/voiceDiag/checks/*. Every check gets a
|
||||||
|
// happy-path, an actionable-finding path, and a scope-missing path
|
||||||
|
// (403 or 404 surfaced by the runner as `status: 'skipped'`).
|
||||||
|
//
|
||||||
|
// Remediation functions are exercised in tests/voiceDiag.remediations.test.js
|
||||||
|
// where we can stub the WebexClient import. This file only covers the
|
||||||
|
// `run(ctx)` shape — ctx.webex.request is trivially stubbed.
|
||||||
|
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import { dndCheck } from '../services/voiceDiag/checks/dnd.js';
|
||||||
|
import { callForwardingCheck } from '../services/voiceDiag/checks/callForwarding.js';
|
||||||
|
import { callWaitingCheck } from '../services/voiceDiag/checks/callWaiting.js';
|
||||||
|
import { voicemailCheck } from '../services/voiceDiag/checks/voicemail.js';
|
||||||
|
import { callInterceptCheck } from '../services/voiceDiag/checks/callIntercept.js';
|
||||||
|
import { hotelingCheck } from '../services/voiceDiag/checks/hoteling.js';
|
||||||
|
import { executiveAssistantCheck } from '../services/voiceDiag/checks/executiveAssistant.js';
|
||||||
|
import { outgoingPermissionCheck } from '../services/voiceDiag/checks/outgoingPermission.js';
|
||||||
|
import { phoneOnlineCheck } from '../services/voiceDiag/checks/phoneOnline.js';
|
||||||
|
import {
|
||||||
|
runVoiceDiag,
|
||||||
|
buildRemediationRegistry,
|
||||||
|
} from '../services/voiceDiag/voiceDiagService.js';
|
||||||
|
import { CHECKS } from '../services/voiceDiag/checks/index.js';
|
||||||
|
|
||||||
|
// ─── Helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Build a stub context with a webex.request that returns whatever
|
||||||
|
* callback returns for its (method, endpoint) signature. */
|
||||||
|
function mkCtx({ response, error } = {}) {
|
||||||
|
return {
|
||||||
|
storeNum: '12345',
|
||||||
|
email: 'ae12345@ae.com',
|
||||||
|
personId: 'PID_TEST',
|
||||||
|
personLabel: 'Store 12345',
|
||||||
|
telephonyProfile: {},
|
||||||
|
phoneStatus: null,
|
||||||
|
person: null,
|
||||||
|
webex: {
|
||||||
|
async request(_method, _endpoint) {
|
||||||
|
if (error) throw error;
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build an http error that mirrors what axios throws. */
|
||||||
|
function httpError(status, message = 'Boom') {
|
||||||
|
const err = new Error(message);
|
||||||
|
err.response = { status, data: { message } };
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── dnd ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('dnd: disabled → ok', async () => {
|
||||||
|
const ctx = mkCtx({ response: { enabled: false, ringSplashEnabled: false } });
|
||||||
|
const r = await dndCheck.run(ctx);
|
||||||
|
assert.equal(r.status, 'ok');
|
||||||
|
assert.equal(r.remediation, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dnd: enabled → warn + disable_dnd remediation', async () => {
|
||||||
|
const ctx = mkCtx({ response: { enabled: true, ringSplashEnabled: true } });
|
||||||
|
const r = await dndCheck.run(ctx);
|
||||||
|
assert.equal(r.status, 'warn');
|
||||||
|
assert.equal(r.remediation.action, 'disable_dnd');
|
||||||
|
assert.equal(r.remediation.payload.personId, 'PID_TEST');
|
||||||
|
assert.equal(r.remediation.payload.before.enabled, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dnd: 403 flows through runner as skipped with scope hint', async () => {
|
||||||
|
const ctx = mkCtx({ error: httpError(403) });
|
||||||
|
const results = await runVoiceDiag(ctx, { only: ['dnd'] });
|
||||||
|
assert.equal(results.length, 1);
|
||||||
|
assert.equal(results[0].status, 'skipped');
|
||||||
|
assert.match(results[0].message, /missing/);
|
||||||
|
assert.match(results[0].message, /spark-admin:people_read/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dnd: 404 flows through runner as skipped ("not applicable" wording)', async () => {
|
||||||
|
const ctx = mkCtx({ error: httpError(404) });
|
||||||
|
const results = await runVoiceDiag(ctx, { only: ['dnd'] });
|
||||||
|
assert.equal(results[0].status, 'skipped');
|
||||||
|
assert.match(results[0].message, /Webex Calling license|feature isn't applicable/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dnd: 404 with "no static resource" body → skipped with routing-miss diagnostic', async () => {
|
||||||
|
const ctx = mkCtx({
|
||||||
|
error: httpError(404, 'No static resource hydra/v1/people/.../features/doNotDisturb.'),
|
||||||
|
});
|
||||||
|
const results = await runVoiceDiag(ctx, { only: ['dnd'] });
|
||||||
|
assert.equal(results[0].status, 'skipped');
|
||||||
|
assert.match(results[0].message, /URL for this feature has moved/);
|
||||||
|
assert.equal(results[0].details.routingMiss, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── callForwarding ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('callForwarding: nothing enabled → ok', async () => {
|
||||||
|
const ctx = mkCtx({
|
||||||
|
response: {
|
||||||
|
callForwarding: {
|
||||||
|
always: { enabled: false },
|
||||||
|
busy: { enabled: false },
|
||||||
|
noAnswer: { enabled: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const r = await callForwardingCheck.run(ctx);
|
||||||
|
assert.equal(r.status, 'ok');
|
||||||
|
assert.equal(r.remediation, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('callForwarding: always-forwarding enabled → warn + compound remediation', async () => {
|
||||||
|
const ctx = mkCtx({
|
||||||
|
response: {
|
||||||
|
callForwarding: {
|
||||||
|
always: { enabled: true, destination: '+18005551212' },
|
||||||
|
busy: { enabled: false },
|
||||||
|
noAnswer: { enabled: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const r = await callForwardingCheck.run(ctx);
|
||||||
|
assert.equal(r.status, 'warn');
|
||||||
|
assert.equal(r.remediation.action, 'clear_call_forwarding');
|
||||||
|
assert.deepEqual(r.remediation.payload.variantsToClear, ['always']);
|
||||||
|
assert.match(r.message, /\+18005551212/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('callForwarding: multiple variants active → single compound remediation', async () => {
|
||||||
|
const ctx = mkCtx({
|
||||||
|
response: {
|
||||||
|
callForwarding: {
|
||||||
|
always: { enabled: true, destination: '+15551111111' },
|
||||||
|
busy: { enabled: true, destination: '+15552222222' },
|
||||||
|
noAnswer: { enabled: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const r = await callForwardingCheck.run(ctx);
|
||||||
|
assert.equal(r.status, 'warn');
|
||||||
|
assert.deepEqual(r.remediation.payload.variantsToClear, ['always', 'busy']);
|
||||||
|
assert.match(r.remediation.title, /2 forwarding variants/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('callForwarding: 403 → skipped via runner', async () => {
|
||||||
|
const ctx = mkCtx({ error: httpError(403) });
|
||||||
|
const results = await runVoiceDiag(ctx, { only: ['callForwarding'] });
|
||||||
|
assert.equal(results[0].status, 'skipped');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── callWaiting ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('callWaiting: enabled → ok', async () => {
|
||||||
|
const ctx = mkCtx({ response: { enabled: true } });
|
||||||
|
const r = await callWaitingCheck.run(ctx);
|
||||||
|
assert.equal(r.status, 'ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('callWaiting: disabled → warn + enable_call_waiting remediation', async () => {
|
||||||
|
const ctx = mkCtx({ response: { enabled: false } });
|
||||||
|
const r = await callWaitingCheck.run(ctx);
|
||||||
|
assert.equal(r.status, 'warn');
|
||||||
|
assert.equal(r.remediation.action, 'enable_call_waiting');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('callWaiting: 404 → skipped via runner', async () => {
|
||||||
|
const ctx = mkCtx({ error: httpError(404) });
|
||||||
|
const results = await runVoiceDiag(ctx, { only: ['callWaiting'] });
|
||||||
|
assert.equal(results[0].status, 'skipped');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── voicemail ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('voicemail: disabled → ok', async () => {
|
||||||
|
const ctx = mkCtx({ response: { enabled: false } });
|
||||||
|
const r = await voicemailCheck.run(ctx);
|
||||||
|
assert.equal(r.status, 'ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('voicemail: sendAllCalls on → error', async () => {
|
||||||
|
const ctx = mkCtx({
|
||||||
|
response: {
|
||||||
|
enabled: true,
|
||||||
|
sendAllCalls: { enabled: true },
|
||||||
|
messageStorage: { mwiEnabled: true },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const r = await voicemailCheck.run(ctx);
|
||||||
|
assert.equal(r.status, 'error');
|
||||||
|
assert.match(r.message, /never ring/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('voicemail: enabled but MWI off → warn', async () => {
|
||||||
|
const ctx = mkCtx({
|
||||||
|
response: {
|
||||||
|
enabled: true,
|
||||||
|
messageStorage: { mwiEnabled: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const r = await voicemailCheck.run(ctx);
|
||||||
|
assert.equal(r.status, 'warn');
|
||||||
|
assert.match(r.message, /MWI/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('voicemail: enabled, email forward to off-org → warn', async () => {
|
||||||
|
const ctx = mkCtx({
|
||||||
|
response: {
|
||||||
|
enabled: true,
|
||||||
|
messageStorage: { mwiEnabled: true },
|
||||||
|
emailCopyOfMessage: { enabled: true, emailId: 'random@gmail.com' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const r = await voicemailCheck.run(ctx);
|
||||||
|
assert.equal(r.status, 'warn');
|
||||||
|
assert.match(r.message, /off-org/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('voicemail: enabled + MWI + no unusual forwarding → ok', async () => {
|
||||||
|
const ctx = mkCtx({
|
||||||
|
response: {
|
||||||
|
enabled: true,
|
||||||
|
messageStorage: { mwiEnabled: true },
|
||||||
|
emailCopyOfMessage: { enabled: false, emailId: '' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const r = await voicemailCheck.run(ctx);
|
||||||
|
assert.equal(r.status, 'ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('voicemail: 401 → skipped via runner', async () => {
|
||||||
|
const ctx = mkCtx({ error: httpError(401) });
|
||||||
|
const results = await runVoiceDiag(ctx, { only: ['voicemail'] });
|
||||||
|
assert.equal(results[0].status, 'skipped');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── callIntercept ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('callIntercept: disabled → ok', async () => {
|
||||||
|
const ctx = mkCtx({ response: { enabled: false } });
|
||||||
|
const r = await callInterceptCheck.run(ctx);
|
||||||
|
assert.equal(r.status, 'ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('callIntercept: enabled → error + disable_call_intercept remediation', async () => {
|
||||||
|
const ctx = mkCtx({
|
||||||
|
response: {
|
||||||
|
enabled: true,
|
||||||
|
incoming: { type: 'INTERCEPT_ALL' },
|
||||||
|
outgoing: { type: 'ALLOW_ALL' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const r = await callInterceptCheck.run(ctx);
|
||||||
|
assert.equal(r.status, 'error');
|
||||||
|
assert.equal(r.remediation.action, 'disable_call_intercept');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('callIntercept: 403 → skipped via runner', async () => {
|
||||||
|
const ctx = mkCtx({ error: httpError(403) });
|
||||||
|
const results = await runVoiceDiag(ctx, { only: ['callIntercept'] });
|
||||||
|
assert.equal(results[0].status, 'skipped');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── hoteling (info-only) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
test('hoteling: disabled → ok', async () => {
|
||||||
|
const ctx = mkCtx({ response: { enabled: false } });
|
||||||
|
const r = await hotelingCheck.run(ctx);
|
||||||
|
assert.equal(r.status, 'ok');
|
||||||
|
assert.equal(r.remediation, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hoteling: enabled → warn (info only, no remediation)', async () => {
|
||||||
|
const ctx = mkCtx({ response: { enabled: true } });
|
||||||
|
const r = await hotelingCheck.run(ctx);
|
||||||
|
assert.equal(r.status, 'warn');
|
||||||
|
assert.equal(r.remediation, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hoteling: 404 → skipped via runner', async () => {
|
||||||
|
const ctx = mkCtx({ error: httpError(404) });
|
||||||
|
const results = await runVoiceDiag(ctx, { only: ['hoteling'] });
|
||||||
|
assert.equal(results[0].status, 'skipped');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── executiveAssistant (info-only) ─────────────────────────────────
|
||||||
|
|
||||||
|
test('executiveAssistant: UNASSIGNED → ok', async () => {
|
||||||
|
const ctx = mkCtx({ response: { type: 'UNASSIGNED' } });
|
||||||
|
const r = await executiveAssistantCheck.run(ctx);
|
||||||
|
assert.equal(r.status, 'ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('executiveAssistant: EXECUTIVE_ASSISTANT → warn (no remediation)', async () => {
|
||||||
|
const ctx = mkCtx({ response: { type: 'EXECUTIVE_ASSISTANT' } });
|
||||||
|
const r = await executiveAssistantCheck.run(ctx);
|
||||||
|
assert.equal(r.status, 'warn');
|
||||||
|
assert.equal(r.remediation, null);
|
||||||
|
assert.match(r.message, /EXECUTIVE_ASSISTANT/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('executiveAssistant: 403 → skipped via runner', async () => {
|
||||||
|
const ctx = mkCtx({ error: httpError(403) });
|
||||||
|
const results = await runVoiceDiag(ctx, { only: ['executiveAssistant'] });
|
||||||
|
assert.equal(results[0].status, 'skipped');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── outgoingPermission (info-only) ─────────────────────────────────
|
||||||
|
|
||||||
|
test('outgoingPermission: useCustomEnabled=false → ok (defers to location)', async () => {
|
||||||
|
const ctx = mkCtx({
|
||||||
|
response: { useCustomEnabled: false, callingPermissions: [] },
|
||||||
|
});
|
||||||
|
const r = await outgoingPermissionCheck.run(ctx);
|
||||||
|
assert.equal(r.status, 'ok');
|
||||||
|
assert.match(r.message, /location default/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('outgoingPermission: NATIONAL BLOCK → warn', async () => {
|
||||||
|
const ctx = mkCtx({
|
||||||
|
response: {
|
||||||
|
useCustomEnabled: true,
|
||||||
|
callingPermissions: [
|
||||||
|
{ callType: 'NATIONAL', action: 'BLOCK' },
|
||||||
|
{ callType: 'INTERNAL_CALL', action: 'ALLOW' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const r = await outgoingPermissionCheck.run(ctx);
|
||||||
|
assert.equal(r.status, 'warn');
|
||||||
|
assert.deepEqual(r.details.highImpactBlocked, ['NATIONAL']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('outgoingPermission: only premium blocked → ok', async () => {
|
||||||
|
const ctx = mkCtx({
|
||||||
|
response: {
|
||||||
|
useCustomEnabled: true,
|
||||||
|
callingPermissions: [
|
||||||
|
{ callType: 'PREMIUM_SERVICES_I', action: 'BLOCK' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const r = await outgoingPermissionCheck.run(ctx);
|
||||||
|
assert.equal(r.status, 'ok');
|
||||||
|
assert.deepEqual(r.details.highImpactBlocked, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('outgoingPermission: 404 → skipped via runner', async () => {
|
||||||
|
const ctx = mkCtx({ error: httpError(404) });
|
||||||
|
const results = await runVoiceDiag(ctx, { only: ['outgoingPermission'] });
|
||||||
|
assert.equal(results[0].status, 'skipped');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── phoneOnline (no API — reads phoneStatus snapshot) ──────────────
|
||||||
|
|
||||||
|
test('phoneOnline: no phoneStatus → skipped by runner (requires guard)', async () => {
|
||||||
|
const ctx = { ...mkCtx(), phoneStatus: null };
|
||||||
|
const results = await runVoiceDiag(ctx, { only: ['phoneOnline'] });
|
||||||
|
assert.equal(results[0].status, 'skipped');
|
||||||
|
assert.match(results[0].message, /missing phoneStatus/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('phoneOnline: no devices → warn', async () => {
|
||||||
|
const ctx = {
|
||||||
|
...mkCtx(),
|
||||||
|
phoneStatus: { phones: { data: [] }, dectBasestations: [] },
|
||||||
|
};
|
||||||
|
const r = await phoneOnlineCheck.run(ctx);
|
||||||
|
assert.equal(r.status, 'warn');
|
||||||
|
assert.match(r.message, /No desk phones/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('phoneOnline: all connected → ok', async () => {
|
||||||
|
const ctx = {
|
||||||
|
...mkCtx(),
|
||||||
|
phoneStatus: {
|
||||||
|
phones: { data: [{ mac: 'aa:bb', status: 'connected', name: 'A' }] },
|
||||||
|
dectBasestations: [{ mac: 'cc:dd', status: 'connected', name: 'DB' }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const r = await phoneOnlineCheck.run(ctx);
|
||||||
|
assert.equal(r.status, 'ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('phoneOnline: one offline phone → error, offlineDevices populated', async () => {
|
||||||
|
const ctx = {
|
||||||
|
...mkCtx(),
|
||||||
|
phoneStatus: {
|
||||||
|
phones: { data: [
|
||||||
|
{ mac: 'aa:bb', status: 'connected', name: 'A' },
|
||||||
|
{ mac: 'ee:ff', status: 'disconnected', name: 'B', lastSeen: 'now' },
|
||||||
|
] },
|
||||||
|
dectBasestations: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const r = await phoneOnlineCheck.run(ctx);
|
||||||
|
assert.equal(r.status, 'error');
|
||||||
|
assert.equal(r.details.offlineDevices.length, 1);
|
||||||
|
assert.equal(r.details.offlineDevices[0].name, 'B');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── runner-level integration ───────────────────────────────────────
|
||||||
|
|
||||||
|
test('runVoiceDiag: missing personId → every personId-requiring check is skipped', async () => {
|
||||||
|
const ctx = mkCtx();
|
||||||
|
ctx.personId = null;
|
||||||
|
const results = await runVoiceDiag(ctx);
|
||||||
|
const personRequired = results.filter((r) =>
|
||||||
|
['dnd', 'callForwarding', 'callWaiting', 'voicemail', 'callIntercept', 'hoteling', 'executiveAssistant', 'outgoingPermission'].includes(r.id),
|
||||||
|
);
|
||||||
|
for (const r of personRequired) {
|
||||||
|
assert.equal(r.status, 'skipped', `expected ${r.id} skipped, got ${r.status}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runVoiceDiag: --only filter honours case-insensitive names', async () => {
|
||||||
|
const ctx = mkCtx({ response: { enabled: false } });
|
||||||
|
const results = await runVoiceDiag(ctx, { only: ['DND'] });
|
||||||
|
assert.equal(results.length, 1);
|
||||||
|
assert.equal(results[0].id, 'dnd');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runVoiceDiag: check runner isolates thrown errors as error status', async () => {
|
||||||
|
const ctx = mkCtx({ error: new Error('random blowup') });
|
||||||
|
const results = await runVoiceDiag(ctx, { only: ['dnd'] });
|
||||||
|
assert.equal(results[0].status, 'error');
|
||||||
|
assert.match(results[0].message, /random blowup/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('runVoiceDiag: registry order preserved in results', async () => {
|
||||||
|
const ctx = mkCtx({ response: { enabled: false } });
|
||||||
|
ctx.phoneStatus = { phones: { data: [] }, dectBasestations: [] };
|
||||||
|
const results = await runVoiceDiag(ctx);
|
||||||
|
const orderedIds = CHECKS.map((c) => c.id);
|
||||||
|
assert.deepEqual(results.map((r) => r.id), orderedIds);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── command-arg normalization ─────────────────────────────────────
|
||||||
|
|
||||||
|
test('normalizeArg: em-dash converted to double-hyphen', async () => {
|
||||||
|
const { normalizeArg } = await import('../commands/voiceDiag.js');
|
||||||
|
assert.equal(normalizeArg('\u2014detailed'), '--detailed');
|
||||||
|
assert.equal(normalizeArg('\u2014only'), '--only');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeArg: en-dash and hyphen-like Unicode range all collapse to --', async () => {
|
||||||
|
const { normalizeArg } = await import('../commands/voiceDiag.js');
|
||||||
|
assert.equal(normalizeArg('\u2013detailed'), '--detailed'); // en dash
|
||||||
|
assert.equal(normalizeArg('\u2015detailed'), '--detailed'); // horizontal bar
|
||||||
|
assert.equal(normalizeArg('\u2212detailed'), '--detailed'); // minus sign
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeArg: run of 3+ ASCII hyphens collapses to --', async () => {
|
||||||
|
const { normalizeArg } = await import('../commands/voiceDiag.js');
|
||||||
|
assert.equal(normalizeArg('---detailed'), '--detailed');
|
||||||
|
assert.equal(normalizeArg('----only'), '--only');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeArg: leaves plain --flag and bare word untouched', async () => {
|
||||||
|
const { normalizeArg } = await import('../commands/voiceDiag.js');
|
||||||
|
assert.equal(normalizeArg('--detailed'), '--detailed');
|
||||||
|
assert.equal(normalizeArg('detailed'), 'detailed');
|
||||||
|
assert.equal(normalizeArg('782'), '782');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeArg: nullish input passed through', async () => {
|
||||||
|
const { normalizeArg } = await import('../commands/voiceDiag.js');
|
||||||
|
assert.equal(normalizeArg(null), null);
|
||||||
|
assert.equal(normalizeArg(undefined), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildRemediationRegistry: contains every declared remediation exactly once', () => {
|
||||||
|
const registry = buildRemediationRegistry();
|
||||||
|
const declared = new Map();
|
||||||
|
for (const c of CHECKS) {
|
||||||
|
for (const rid of Object.keys(c.remediations || {})) {
|
||||||
|
if (declared.has(rid)) {
|
||||||
|
throw new Error(`duplicate remediation id in fixture: ${rid}`);
|
||||||
|
}
|
||||||
|
declared.set(rid, c.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.equal(registry.size, declared.size);
|
||||||
|
for (const [rid, checkId] of declared.entries()) {
|
||||||
|
assert.ok(registry.has(rid), `missing remediation ${rid}`);
|
||||||
|
assert.equal(registry.get(rid).check.id, checkId);
|
||||||
|
}
|
||||||
|
});
|
||||||
160
tests/voiceDiagRenderer.test.js
Normal file
160
tests/voiceDiagRenderer.test.js
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
// Unit tests for services/renderers/voiceDiagRenderer.js. Renderer
|
||||||
|
// is pure — no I/O — so the tests just assert on the produced
|
||||||
|
// markdown string. The interesting cases are:
|
||||||
|
//
|
||||||
|
// - severity buckets appear in the right order
|
||||||
|
// - the OK bucket is hidden by default and shown under `detailed`
|
||||||
|
// - the fixable-issues footer counts and lists only remediable
|
||||||
|
// results (an OK check with a remediation still doesn't count,
|
||||||
|
// since we filter status !== 'ok')
|
||||||
|
// - the details block is only rendered under `detailed`
|
||||||
|
// - empty result list yields a benign single-line message
|
||||||
|
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import { renderVoiceDiagMarkdown } from '../services/renderers/voiceDiagRenderer.js';
|
||||||
|
|
||||||
|
const R = (id, status, message, remediation = null, details = null, label = null) => ({
|
||||||
|
id,
|
||||||
|
label: label || `Check ${id}`,
|
||||||
|
status,
|
||||||
|
message,
|
||||||
|
details,
|
||||||
|
remediation,
|
||||||
|
});
|
||||||
|
|
||||||
|
const REMEDIATION = {
|
||||||
|
action: 'disable_dnd',
|
||||||
|
title: 'Disable DND',
|
||||||
|
summary: 'Turn DND off.',
|
||||||
|
payload: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
test('renderer: empty results → benign message', () => {
|
||||||
|
const md = renderVoiceDiagMarkdown([], { storeNum: '12345' });
|
||||||
|
assert.match(md, /Voice Diagnostic - Store 12345/);
|
||||||
|
assert.match(md, /No checks were executed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderer: header includes personLabel + email when provided', () => {
|
||||||
|
const md = renderVoiceDiagMarkdown([], {
|
||||||
|
storeNum: '12345',
|
||||||
|
personLabel: 'Store 12345',
|
||||||
|
email: 'ae12345@ae.com',
|
||||||
|
});
|
||||||
|
assert.match(md, /user: Store 12345 — ae12345@ae\.com/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderer: mixed severities render in error → warn → skipped order (default hides ok)', () => {
|
||||||
|
const results = [
|
||||||
|
R('c1', 'ok', 'all good'),
|
||||||
|
R('c2', 'warn', 'watch this', REMEDIATION),
|
||||||
|
R('c3', 'error', 'boom'),
|
||||||
|
R('c4', 'skipped', '403 missing scope'),
|
||||||
|
R('c5', 'ok', 'also good'),
|
||||||
|
];
|
||||||
|
const md = renderVoiceDiagMarkdown(results, { storeNum: '99' });
|
||||||
|
|
||||||
|
const orderIdx = ['**ERRORS**', '**WARNINGS**', '**SKIPPED**'].map((h) => md.indexOf(h));
|
||||||
|
assert.ok(orderIdx.every((i) => i > -1), `all severity headings present, got ${orderIdx}`);
|
||||||
|
assert.ok(orderIdx[0] < orderIdx[1], 'ERRORS before WARNINGS');
|
||||||
|
assert.ok(orderIdx[1] < orderIdx[2], 'WARNINGS before SKIPPED');
|
||||||
|
assert.equal(md.includes('**OK**'), false, 'OK bucket hidden by default');
|
||||||
|
assert.match(md, /OK check\(s\) hidden — pass `detailed`/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderer: summary line reports every bucket count', () => {
|
||||||
|
const results = [
|
||||||
|
R('c1', 'ok', 'a'),
|
||||||
|
R('c2', 'warn', 'b'),
|
||||||
|
R('c3', 'warn', 'c'),
|
||||||
|
R('c4', 'error', 'd'),
|
||||||
|
R('c5', 'skipped', 'e'),
|
||||||
|
R('c6', 'ok', 'f'),
|
||||||
|
R('c7', 'ok', 'g'),
|
||||||
|
];
|
||||||
|
const md = renderVoiceDiagMarkdown(results, { storeNum: '99' });
|
||||||
|
assert.match(md, /Errors \(1\) · Warnings \(2\) · Skipped \(1\) · OK \(3\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderer: detailed mode surfaces OK bucket + details block', () => {
|
||||||
|
const results = [
|
||||||
|
R('c1', 'ok', 'all good', null, { enabled: false, mwiEnabled: true }),
|
||||||
|
];
|
||||||
|
const md = renderVoiceDiagMarkdown(results, { storeNum: '99', detailed: true });
|
||||||
|
assert.match(md, /\*\*OK\*\*/);
|
||||||
|
assert.match(md, /enabled: false, mwiEnabled: true/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderer: fixable footer counts non-ok results with a remediation only', () => {
|
||||||
|
const results = [
|
||||||
|
R('c1', 'warn', 'w1', REMEDIATION),
|
||||||
|
R('c2', 'error', 'e1', REMEDIATION),
|
||||||
|
R('c3', 'ok', 'o1', REMEDIATION), // OK w/ remediation should NOT count
|
||||||
|
R('c4', 'warn', 'w2'), // warn w/o remediation should NOT count
|
||||||
|
];
|
||||||
|
const md = renderVoiceDiagMarkdown(results, { storeNum: '99' });
|
||||||
|
assert.match(md, /Fixable issues \(2\)/);
|
||||||
|
const footerLineCount = (md.match(/^- Check c[12]: Disable DND/gm) || []).length;
|
||||||
|
assert.equal(footerLineCount, 2, 'footer lists exactly the fixable ones');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderer: no fixable-footer emitted when nothing is fixable', () => {
|
||||||
|
const results = [
|
||||||
|
R('c1', 'warn', 'no fix'),
|
||||||
|
R('c2', 'error', 'no fix'),
|
||||||
|
];
|
||||||
|
const md = renderVoiceDiagMarkdown(results, { storeNum: '99' });
|
||||||
|
assert.equal(md.includes('Fixable issues'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderer: emitFooter=false suppresses trailing timestamp', () => {
|
||||||
|
const md = renderVoiceDiagMarkdown([R('c1', 'ok', 'good')], {
|
||||||
|
storeNum: '99',
|
||||||
|
detailed: true,
|
||||||
|
emitFooter: false,
|
||||||
|
});
|
||||||
|
assert.equal(md.includes('Last checked'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderer: emitFooter=true (default) adds an ISO timestamp line', () => {
|
||||||
|
const md = renderVoiceDiagMarkdown([R('c1', 'ok', 'good')], {
|
||||||
|
storeNum: '99',
|
||||||
|
detailed: true,
|
||||||
|
});
|
||||||
|
assert.match(md, /_Last checked: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderer: details values — arrays truncated past 3 items, nested objects JSON-ified', () => {
|
||||||
|
const results = [
|
||||||
|
R('c1', 'warn', 'x', null, {
|
||||||
|
long: ['a', 'b', 'c', 'd', 'e'],
|
||||||
|
short: ['a', 'b'],
|
||||||
|
nested: { foo: 'bar', n: 1 },
|
||||||
|
nada: null,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const md = renderVoiceDiagMarkdown(results, { storeNum: '99', detailed: true });
|
||||||
|
assert.match(md, /long: \[a, b, c, …\+2\]/);
|
||||||
|
assert.match(md, /short: \[a, b\]/);
|
||||||
|
assert.match(md, /nested: \{"foo":"bar","n":1\}/);
|
||||||
|
assert.match(md, /nada: —/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderer: --detail off does not include details even when present', () => {
|
||||||
|
const results = [
|
||||||
|
R('c1', 'warn', 'x', null, { foo: 'bar' }),
|
||||||
|
];
|
||||||
|
const md = renderVoiceDiagMarkdown(results, { storeNum: '99' });
|
||||||
|
assert.equal(md.includes('foo: bar'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderer: skipped bucket shown even without detail', () => {
|
||||||
|
const results = [
|
||||||
|
R('c1', 'skipped', '403 missing scope'),
|
||||||
|
];
|
||||||
|
const md = renderVoiceDiagMarkdown(results, { storeNum: '99' });
|
||||||
|
assert.match(md, /\*\*SKIPPED\*\*/);
|
||||||
|
assert.match(md, /403 missing scope/);
|
||||||
|
});
|
||||||
75
utils/pendingVoiceFixes.js
Normal file
75
utils/pendingVoiceFixes.js
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
// src/utils/pendingVoiceFixes.js
|
||||||
|
//
|
||||||
|
// In-memory store for pending /voicediag remediation cards. Identical
|
||||||
|
// shape to pendingIgmpFixes / pendingHostAssigns — every entry is
|
||||||
|
// stamped with a `timestamp` on insert, and a background sweep expires
|
||||||
|
// un-acted cards after TTL_MS so the map never leaks.
|
||||||
|
//
|
||||||
|
// Entries hold:
|
||||||
|
// {
|
||||||
|
// storeNum,
|
||||||
|
// personId, // Webex person id the remediation targets
|
||||||
|
// personLabel, // displayName or email for message/audit
|
||||||
|
// remediationId, // e.g. 'disable_dnd', 'clear_forwarding_always'
|
||||||
|
// remediationPayload, // free-form; whatever the check's remediation
|
||||||
|
// // handler expects (usually the pre-computed
|
||||||
|
// // PUT body plus contextual identifiers).
|
||||||
|
// requester, // from extractRequester(trigger)
|
||||||
|
// timestamp, // auto-set on .set()
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// TTL matches pendingIgmpFixes at 15 minutes — long enough for a
|
||||||
|
// normal "let me check with the store first" handoff without leaving
|
||||||
|
// a stale card interactive past the point where the underlying state
|
||||||
|
// may have already been changed by hand.
|
||||||
|
|
||||||
|
import { logger } from './logger.js';
|
||||||
|
|
||||||
|
const TTL_MS = 15 * 60 * 1000;
|
||||||
|
const SWEEP_INTERVAL_MS = 60 * 1000;
|
||||||
|
|
||||||
|
const _store = new Map();
|
||||||
|
|
||||||
|
export const pendingVoiceFixes = {
|
||||||
|
set(cardId, data) {
|
||||||
|
_store.set(cardId, { ...data, timestamp: Date.now() });
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
get(cardId) {
|
||||||
|
return _store.get(cardId);
|
||||||
|
},
|
||||||
|
|
||||||
|
has(cardId) {
|
||||||
|
return _store.has(cardId);
|
||||||
|
},
|
||||||
|
|
||||||
|
delete(cardId) {
|
||||||
|
return _store.delete(cardId);
|
||||||
|
},
|
||||||
|
|
||||||
|
get size() {
|
||||||
|
return _store.size;
|
||||||
|
},
|
||||||
|
|
||||||
|
entries() {
|
||||||
|
return _store.entries();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const sweepHandle = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [cardId, data] of _store.entries()) {
|
||||||
|
const stamped = typeof data?.timestamp === 'number' ? data.timestamp : 0;
|
||||||
|
if (now - stamped > TTL_MS) {
|
||||||
|
logger(
|
||||||
|
'voicediag:cleanup',
|
||||||
|
`Expired card ${cardId} for store ${data?.storeNum || 'unknown'} ` +
|
||||||
|
`remediation ${data?.remediationId || 'unknown'}`,
|
||||||
|
);
|
||||||
|
_store.delete(cardId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, SWEEP_INTERVAL_MS);
|
||||||
|
|
||||||
|
sweepHandle.unref?.();
|
||||||
Loading…
Reference in a new issue