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:
jmcqueen 2026-07-07 18:51:31 -04:00
parent 1705c88ba2
commit 2eb31a2ddc
21 changed files with 2801 additions and 3 deletions

View file

@ -41,11 +41,33 @@ WEBEX_BOT_TOKEN=your-bot-token-here
# calls — people lookup, room operations, etc.). Separate from the bot token.
#
# Required scopes (set when creating the service app at developer.webex.com):
# - spark-admin:people_read (people lookup)
# - identity:tokens_read (offboarduser: list a user's authorizations)
# - identity:tokens_write (offboarduser: revoke a user's authorizations)
# - 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_write (offboarduser: revoke a user's authorizations)
# The authorizing admin must also hold Full / User / Device Admin role for the
# 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_SECRET=your-service-app-client-secret

View file

@ -25,6 +25,7 @@ import { handleProvisionVc } from './vcProvision.js';
import { handleVcMonitor } from './vcMonitor.js';
import { handleOffboardUser } from './offboardUser.js';
import { handleWebexHost } from './webexHost.js';
import { handleVoiceDiag } from './voiceDiag.js';
import { handleBulkAvStatusCSV } from './bulkAvStatusCSV.js';
import { handleBulkAvSwitchCSV } from './bulkAvSwitchCSV.js';
import { handleTestDevicesByModel } from './testDevicesByModel.js';
@ -43,6 +44,13 @@ export const commands = [
{ name: 'avstatus', aliases: ['wostatus'], handler: handleAvStatus, 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: 'wosummary', handler: handleWoSummary, mutating: false },
{ name: 'woattachments', handler: handleWoAttachments, mutating: false },

314
commands/voiceDiag.js Normal file
View 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 24 digit store number.\n' +
'Examples:\n' +
'- `/voicediag 782`\n' +
'- `/voicediag 782 detailed`\n' +
'- `/voicediag 782 --only dnd,callForwarding`\n' +
'- `/voicediag list-checks`',
);
return;
}
let ctx;
try {
ctx = await buildContext(storeNum);
} catch (err) {
logger('voicediag', `buildContext failed for store ${storeNum}: ${err.message}`, 'error');
await bot.say('markdown', `❌ Failed to build diagnostic context for store ${storeNum}: ${err.message}`);
return;
}
if (!ctx.personId) {
await bot.say(
'markdown',
`❌ Could not resolve a Webex user for store ${storeNum} (expected \`${ctx.email}\`). ` +
`Verify the store number is correct.`,
);
return;
}
let results;
try {
results = await runVoiceDiag(ctx, { only });
} catch (err) {
// runVoiceDiag should never throw (each check is isolated), but
// guard anyway so a runner-level bug surfaces to the operator.
logger('voicediag', `runVoiceDiag threw for store ${storeNum}: ${err.message}`, 'error');
await bot.say('markdown', `❌ Diagnostic run failed for store ${storeNum}: ${err.message}`);
return;
}
const reply = renderVoiceDiagMarkdown(results, {
storeNum: ctx.storeNum,
personLabel: ctx.personLabel,
email: ctx.email,
detailed,
});
await bot.say('markdown', reply || 'No diagnostic results.');
// Adaptive-card remediation is chat-only. HTTP callers get the
// markdown snapshot but no interactive cards (same rule the
// /phonestatus + IGMP-fix inline card follows). `trigger.person`
// is populated for chat, absent for the HTTP fake trigger.
if (!trigger.person) return;
const requester = extractRequester(trigger);
const fixable = results.filter((r) => r.status !== 'ok' && r.remediation);
for (const result of fixable) {
await postRemediationCard(bot, {
storeNum: ctx.storeNum,
result,
requester,
});
}
}
/**
* 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 },
},
],
};
}

View file

@ -34,6 +34,11 @@ import {
cancelIgmpFixCard,
} from './commands/igmpFix.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 { getDectRelayHub } from './services/dectRelayHub.js';
import {
@ -298,6 +303,7 @@ const DECT_ACTIONS = new Set([
const OFFBOARD_ACTIONS = new Set(['confirm_offboard', 'cancel_offboard']);
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 VOICEDIAG_ACTIONS = new Set(['confirm_voicediag', 'cancel_voicediag']);
// Best-effort delete of the adaptive-card message that fired this action.
// Removing the card prevents users from clicking Confirm/Cancel a second time
@ -483,6 +489,56 @@ framework.on('attachmentAction', async (bot, trigger) => {
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');
});

View file

@ -465,6 +465,11 @@ export async function getDevicesForPerson(personId) {
// - /people/{id} → full person (phoneNumbers, displayName etc.)
// - 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.
// 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.
// ──────────────────────────────────────────────

View 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]';
}
}

View 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.

View 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(',')}`,
);
},
},
};

View 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})`,
);
},
},
};

View 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})`,
);
},
},
};

View 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})`,
);
},
},
};

View 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,
};
},
};

View 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,
};
},
};

View 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;
}

View 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,
};
},
};

View 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,
};
},
};

View 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.
};

View 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 };

View 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);
}
});

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

View 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?.();