collabSupport/services/voiceDiag
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
..
checks Add /voicediag rules-engine command with 9 per-user calling checks 2026-07-07 18:51:31 -04:00
README.md Add /voicediag rules-engine command with 9 per-user calling checks 2026-07-07 18:51:31 -04:00
voiceDiagService.js Add /voicediag rules-engine command with 9 per-user calling checks 2026-07-07 18:51:31 -04:00

/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:

    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

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.