Refactor every /voicediag check to declare a top-level `standards` object so the desired state is legible without reading run() logic and can drive a documented reference table. Upgrade callForwarding to error severity, tighten voicemail with three send-to-VM error paths + a `stop_sending_to_voicemail` remediation, and add a `disable_hoteling` remediation. Add a port-hygiene check bucket under services/voiceDiag/checks/port (portType, portVlan, portPoe, portEnabled) that reuses the phone- status snapshot to enforce switchport standards. Configurable via VOICE_STANDARD_PHONE_VLAN (default 102) and VOICE_STANDARD_ENABLED (kill-switch). Preserve Meraki `portType`/`voiceVlan`/`dataVlan` through the enrichment chain so the checks have clean data to read. Add an "apply all N fixes" combined card that shows up when 2+ remediations are available. New confirm_voicediag_all / cancel_voicediag_all actions run each fix in sequence (readable audit trail, no per-person write-throttle stacking), accumulate individual failures into a summary rather than aborting. Adds regression tests asserting every check exposes .standards, plus coverage for port checks, kill-switch, and combined-card iteration. 63 tests in the checks file, 188 total, all green. Co-authored-by: Cursor <cursoragent@cursor.com>
126 lines
4.4 KiB
JavaScript
126 lines
4.4 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`;
|
|
|
|
// Store-line standard: intercept off. Intercept blocks calls with an
|
|
// announcement — never wanted on a live store line.
|
|
export const CALL_INTERCEPT_STANDARDS = Object.freeze({
|
|
enabled: false,
|
|
});
|
|
|
|
export const callInterceptCheck = {
|
|
id: 'callIntercept',
|
|
label: 'Call Intercept',
|
|
requires: ['personId'],
|
|
scope: 'spark-admin:people_read',
|
|
standards: CALL_INTERCEPT_STANDARDS,
|
|
|
|
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 === CALL_INTERCEPT_STANDARDS.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), { ...CALL_INTERCEPT_STANDARDS });
|
|
} 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})`,
|
|
);
|
|
},
|
|
},
|
|
};
|