collabSupport/services/voiceDiag/checks/callIntercept.js
jmcqueen 2eb31a2ddc 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>
2026-07-07 18:51:31 -04:00

119 lines
4.1 KiB
JavaScript

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