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>
175 lines
6.6 KiB
JavaScript
175 lines
6.6 KiB
JavaScript
// 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(',')}`,
|
|
);
|
|
},
|
|
},
|
|
};
|