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>
216 lines
11 KiB
Markdown
216 lines
11 KiB
Markdown
# /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.
|
|
|
|
## Store voice standards
|
|
|
|
The store phone standard is enforced by the checks below. Every
|
|
check descriptor exports a `standards` object so the desired state
|
|
is legible from the check file without reading `run()`. Changes
|
|
here are meant to be a two-step change: update `standards`, then
|
|
teach `run()` to interpret it — the checks compare the live state
|
|
against `standards` and emit the severity in the "when non-compliant"
|
|
column.
|
|
|
|
### Per-user Webex Calling standards
|
|
|
|
| Check | Standard | Non-compliant severity | Auto-remediation |
|
|
| -------------------- | -------------------------------------------------------- | ---------------------- | ------------------------------------ |
|
|
| `dnd` | `enabled: false` | warn | `disable_dnd` |
|
|
| `callForwarding` | `{always, busy, noAnswer}.enabled: false` | **error** | `clear_call_forwarding` |
|
|
| `callWaiting` | `enabled: true` | warn | `enable_call_waiting` |
|
|
| `callIntercept` | `enabled: false` | error | `disable_call_intercept` |
|
|
| `voicemail` | `enabled: true`, all three `send*Calls.enabled: false`, `mwiEnabled: true` | error on send-to-VM, warn on MWI-off / disabled / off-org email | `stop_sending_to_voicemail` for the send-to-VM path only |
|
|
| `hoteling` | `enabled: false` | warn | `disable_hoteling` |
|
|
| `executiveAssistant` | `type: 'UNASSIGNED'` | warn | none — Control Hub cleanup |
|
|
| `outgoingPermission` | no high-impact call type BLOCKED (LOCAL, NATIONAL, TOLL_FREE, TOLL, INTERNATIONAL) | warn | none — location-scoped fix |
|
|
|
|
`callForwarding` is intentionally at `error` severity: forwarding
|
|
active on a store line silently drops customer calls, and it's the
|
|
single most common voice ticket. Voicemail's `error` path is
|
|
narrower — only the three send-* triggers upgrade to error, since
|
|
that also silently swallows calls. MWI-off, off-org email
|
|
forwarding, and voicemail-disabled all stay at warn.
|
|
|
|
### Switchport / port-hygiene standards
|
|
|
|
These reuse the phone-status snapshot from `/phonestatus` — no
|
|
extra Webex API calls — and cross-reference against the Meraki
|
|
port config that already flows through `services/enrichment/merakiEnrichment.js`.
|
|
Trunk uplinks are treated as a visibility boundary: the phone is
|
|
behind a non-Meraki switch (typically a Cisco stack in a store)
|
|
and per-port policy isn't ours to enforce, so downstream VLAN /
|
|
PoE / admin-state checks defer to the operator.
|
|
|
|
| Check | Standard | Non-compliant severity | Notes |
|
|
| ------------- | ---------------------------------------------------- | ---------------------- | ----------------------------------------------- |
|
|
| `portType` | `portType: 'access'` on every wired phone / DECT base | warn | Trunk uplinks flagged so operator checks the downstream switch |
|
|
| `portVlan` | `vlan === VOICE_STANDARD_PHONE_VLAN` (default `102`) | warn | Env-configurable — VLAN may move from data-side to a proper voice VLAN in the future |
|
|
| `portPoe` | `poeEnabled: true` | warn | Skipped for trunk-uplinked devices |
|
|
| `portEnabled` | `portEnabled: true` | **error** | Admin-disabled port → phone is dead |
|
|
|
|
No auto-remediation on any port check — Meraki port-config PUTs
|
|
are a separate scope of work; the operator handles fixes in the
|
|
Meraki Dashboard.
|
|
|
|
### Environment overrides
|
|
|
|
Both port-hygiene knobs live in `.env`:
|
|
|
|
| Var | Default | Purpose |
|
|
| ------------------------------ | ------- | ---------------------------------------------------------------------------------------------- |
|
|
| `VOICE_STANDARD_PHONE_VLAN` | `102` | Expected VLAN for a store phone. Set per-site if the fleet moves onto a proper voice VLAN. |
|
|
| `VOICE_STANDARD_ENABLED` | `true` | Global kill-switch for the port-hygiene bucket. `false` silences portType / portVlan / portPoe / portEnabled while Meraki cleanup is in progress. Feature-config checks always run. |
|
|
|
|
## Apply-all-N-fixes card
|
|
|
|
When two or more checks return fixable results, `/voicediag`
|
|
posts one extra adaptive card at the bottom offering to apply the
|
|
whole batch in a single click. The individual per-issue cards stay
|
|
on-screen so operators can still pick and choose; the combined
|
|
card is a shortcut for the common "everything looks right, do it
|
|
all" case. The batch executes each fix in sequence (not parallel)
|
|
so audit lines stay readable and per-person Webex API write
|
|
throttling doesn't stack; failures accumulate into a final summary
|
|
line rather than aborting the run.
|
|
|
|
Dispatcher-side, the combined card uses the same
|
|
`pendingVoiceFixes` map and the same voicediag branch in
|
|
`index.js` — the payload's `combined: true` flag + the
|
|
`confirm_voicediag_all` / `cancel_voicediag_all` action ids are
|
|
what pick the batch handler over the single-fix handler.
|
|
|
|
## Adding a new check
|
|
|
|
1. Create `services/voiceDiag/checks/<newCheck>.js` and export a
|
|
descriptor:
|
|
|
|
```js
|
|
// The standards block is *the* source of truth for the desired
|
|
// state. Keep the run() comparison in sync — regression tests
|
|
// in tests/voiceDiag.checks.test.js assert every check has one.
|
|
export const MY_NEW_STANDARDS = Object.freeze({ enabled: false });
|
|
|
|
export const myNewCheck = {
|
|
id: 'myNew',
|
|
label: 'My New Check',
|
|
requires: ['personId'], // subset of ['personId','phoneStatus','telephonyProfile']
|
|
scope: 'spark-admin:people_read',
|
|
standards: MY_NEW_STANDARDS,
|
|
async run(ctx) {
|
|
const data = await ctx.webex.request('GET', `people/${ctx.personId}/features/whatever`);
|
|
if (!!data?.enabled === MY_NEW_STANDARDS.enabled) {
|
|
return { status: 'ok', message: 'Compliant.', details: null, remediation: null };
|
|
}
|
|
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.
|