collabSupport/commands/registry.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

118 lines
5.5 KiB
JavaScript

// src/commands/registry.js
//
// Single source of truth for every bot command.
//
// Both dispatch paths in index.js consume this registry:
// - Webex chat: framework.hears(/.*/, …) parses the text, then runs the
// entry whose name (or alias) matches the first token.
// - HTTP API: app.get('/:command', …) looks up the same registry and runs
// the entry's handler against a captured-output mock bot.
//
// Adding a new command means appending one entry below. Both Webex and HTTP
// pick it up automatically (subject to `mutating` / `http` flags).
import { handleHelp } from './help.js';
import { handleAvStatus } from './avStatus.js';
import { handlePhoneStatus } from './phoneStatus.js';
import { handleProvisionDect } from './provisionDect.js';
import { handleWoHistory } from './woHistory.js';
import { handleWoSummary } from './woSummary.js';
import { handleWoAttachments } from './woAttachments.js';
import { handleJiraHistory } from './jiraHistory.js';
import { handleJiraTicket } from './jiraTicket.js';
import { handleJiraPoll } from './jiraPoll.js';
import { handleProvisionVc } from './vcProvision.js';
import { handleVcMonitor } from './vcMonitor.js';
import { handleOffboardUser } from './offboardUser.js';
import { handleWebexHost } from './webexHost.js';
import { handleVoiceDiag } from './voiceDiag.js';
import { handleBulkAvStatusCSV } from './bulkAvStatusCSV.js';
import { handleBulkAvSwitchCSV } from './bulkAvSwitchCSV.js';
import { handleTestDevicesByModel } from './testDevicesByModel.js';
/**
* Each entry:
* - name: canonical name (lowercase, no leading slash).
* - aliases: additional names that route to the same handler.
* - handler: async (bot, trigger) => void
* - mutating: when true, the HTTP /:command path requires HTTP_API_TOKEN.
* - http: defaults to true. Set false for commands that only make
* sense over Webex chat (e.g. `help`).
*/
export const commands = [
{ name: 'help', handler: handleHelp, mutating: false, http: false },
{ name: 'avstatus', aliases: ['wostatus'], handler: handleAvStatus, mutating: false },
{ name: 'phonestatus', handler: handlePhoneStatus, mutating: false },
// /voicediag reads per-user Webex Calling features via
// /v1/people/{id}/features/* and offers per-issue adaptive-card
// remediation. Reads are non-mutating but the confirm buttons on
// the cards perform PUTs, so the HTTP path is gated
// (mutating: true). See commands/voiceDiag.js + services/voiceDiag/
// for the full behavior contract + required scopes.
{ name: 'voicediag', handler: handleVoiceDiag, mutating: true },
{ name: 'wohistory', handler: handleWoHistory, mutating: false },
{ name: 'wosummary', handler: handleWoSummary, mutating: false },
{ name: 'woattachments', handler: handleWoAttachments, mutating: false },
{ name: 'jirahistory', handler: handleJiraHistory, mutating: false },
{ name: 'jiraticket', handler: handleJiraTicket, mutating: false },
// /jirapoll [prime] — trigger the hourly Jira poller on demand. Writes
// Jira comments + labels, so mutating: true (HTTP path gated by
// HTTP_API_TOKEN). See commands/jiraPoll.js for the behavior contract.
{ name: 'jirapoll', aliases: ['pollnow'], handler: handleJiraPoll, mutating: true },
{ name: 'provision-dect', aliases: ['provisiondect'], handler: handleProvisionDect, mutating: true },
{ name: 'provision-vc', aliases: ['vcprovision'], handler: handleProvisionVc, mutating: true },
{ name: 'vcmonitor', handler: handleVcMonitor, mutating: true },
{ name: 'offboarduser', handler: handleOffboardUser, mutating: true },
{ name: 'webexhost', handler: handleWebexHost, mutating: true },
// bulkavstatuscsv delivers a CSV attachment via BotClient.sendWithAttachment,
// which requires a Webex roomId. The HTTP mock trigger has no roomId, so
// the command is chat-only; the runtime guard in the handler backstops this.
{ name: 'bulkavstatuscsv', handler: handleBulkAvStatusCSV, mutating: true, http: false },
{ name: 'bulkavswitchcsv', handler: handleBulkAvSwitchCSV, mutating: true },
{ name: 'devicesbymodel', handler: handleTestDevicesByModel, mutating: true },
];
const byName = new Map();
for (const cmd of commands) {
const all = [cmd.name, ...(cmd.aliases || [])].map(n => n.toLowerCase());
for (const key of all) {
if (byName.has(key)) {
// Fail loud on misconfiguration — silent alias overwrite is the kind of
// drift this registry is designed to prevent.
throw new Error(`Duplicate command registration: "${key}"`);
}
byName.set(key, cmd);
}
}
/**
* Look up a command by canonical name or alias. Case-insensitive.
* @returns {{name: string, handler: Function, mutating: boolean, http?: boolean, aliases?: string[]} | null}
*/
export function getCommand(name) {
if (!name) return null;
return byName.get(String(name).toLowerCase().trim()) || null;
}
/** Names + aliases of commands exposed over HTTP (used for /:command 404 hints). */
export const ALL_HTTP_COMMAND_KEYS = (() => {
const keys = [];
for (const cmd of commands) {
if (cmd.http === false) continue;
keys.push(cmd.name, ...(cmd.aliases || []));
}
return keys.sort();
})();
/** Set of names + aliases that require HTTP_API_TOKEN on the HTTP path. */
export const MUTATING_COMMAND_KEYS = (() => {
const keys = new Set();
for (const cmd of commands) {
if (!cmd.mutating) continue;
keys.add(cmd.name);
for (const a of cmd.aliases || []) keys.add(a);
}
return keys;
})();