collabSupport/commands/help.js
jmcqueen a25fc08fe2 Rename /voicereport to /callreport with scoped user and phone targets.
Add store/email/phone filtering, richer call-line formatting, CDR feed pagination and queueing, and split Jira poller enrichment into testable modules.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 10:10:03 -04:00

386 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// src/commands/help.js
//
// Help renderer.
//
// /help → short, scannable list of every command (one line each)
// with grouped headings.
// /help <command> → detailed usage + examples for one command.
//
// The previous version was a single 5KB blob of markdown with hard-to-read
// inline URLs. This split keeps the top-level message short for both 1:1 and
// group spaces, and pushes verbose detail behind `/help <command>`.
const SHORT_HELP = {
// Work orders
wohistory: 'Recent work order history for a store',
wosummary: 'AI summary of a specific work order',
woattachments: 'Download all attachments for a work order',
// AV / phones
avstatus: 'AV / device status for a store (alias: /wostatus)',
phonestatus: 'DECT + IP phone status for a store (plus 7d WAN follow-up)',
dectstatus: 'Full DECT basestation dump via relay (reboot / factory-reset cards)',
voicediag: 'Deep voice diagnostic: Webex Calling features + SD-WAN quality with fix cards',
callreport: 'Daily call digest: correlated CDR calls + per-call Prisma WAN (store, email, or phone)',
calltest: 'Twilio voice path test (store AA or direct dial, 60s listen)',
// Jira
jirahistory: 'Recent Jira tickets for a store (optionally filtered by component)',
jiraticket: 'Detailed AI summary of one Jira ticket',
jirapoll: 'Run the hourly Jira poller on demand (enrich unassigned AV/phone tickets)',
// Provisioning / mutating
'provision-dect': 'Interactive DECT provisioning card (add/remove bases & handsets)',
'provision-vc': 'Fully provision a video conferencing device',
vcmonitor: 'On-demand VC packet capture (start/stop/status)',
offboarduser: 'Offboard user: revoke Webex OAuth tokens + wipe MDM CORP devices',
webexhost: 'Check / assign Webex Meetings host license on aeo2go.webex.com',
// Bulk / utility
bulkavstatuscsv: 'Generate full AV devices report (CSV)',
bulkavswitchcsv: 'Generate rear-switch port report (CSV)',
devicesbymodel: 'Meraki devices grouped by model (CSV)',
};
const LONG_HELP = {
avstatus: {
title: '/avstatus',
usage: ['/avstatus <store>', '/avstatus <store> detailed'],
examples: ['/avstatus 782', '/avstatus 782 detailed'],
notes: [
'Aliases: `/wostatus`.',
'HTTP equivalent: `?storeNum=<n>&detailed=true`.',
'Includes Meraki deep links per device. For interactive topology, open `/av-store-dashboard.html`.',
],
},
phonestatus: {
title: '/phonestatus',
usage: ['/phonestatus <store>', '/phonestatus <store> detailed'],
examples: ['/phonestatus 782', '/phonestatus 782 detailed'],
notes: [
'Shows DECT basestations + IP phones with Meraki links.',
'Detailed mode adds firmware, serial, SIP details and errors.',
'When the store is Prisma SD-WAN managed (site name `CG<store>` padded to 5 digits), a follow-up **WAN Diagnostics** message arrives with per-path latency/jitter/loss/MOS, site healthscore, and any alarms — averaged over the last 7 days by default (widened from 24h so sporadic Webex Calling stores get enough call samples; override via `WAN_STANDARD_WINDOW_MINUTES` or pass `--window 24h` on /voicediag).',
'If `PRISMA_APP_ID_VOICE` is configured (e.g. pointing at `Webex_Calling_RTP` for Webex Calling shops), the follow-up also includes a **Voice Traffic Quality** section with real DPI-measured MOS / packet loss / jitter for that app — surfaces transient degradation the 7d link-probe averages smooth away.',
'For the full DECT base dump + reboot/factory-reset controls, use `/dectstatus <store>`.',
'For an in-depth voice diagnostic with per-user Webex Calling checks + fix cards, use `/voicediag <store>`.',
'Web dashboard: `/phone-store-dashboard.html`.',
],
},
dectstatus: {
title: '/dectstatus',
usage: [
'/dectstatus <store>',
'/dectstatus <store> <ip|mac>',
],
examples: [
'/dectstatus 782',
'/dectstatus 782 10.12.34.56',
'/dectstatus 782 6c:ab:05:12:34:56',
],
notes: [
'Aliases: `/dect`.',
'Pulls the full `status.xml` dump from every reachable DBS-210 base at the store via the on-prem DECT relay agent (device, firmware, reboot log, network stats, RTP, security, emergency numbers, health verdict).',
'Optional second arg filters to one base by IP, MAC, or name substring.',
'Chat-only action cards per reachable base: **Reboot**, **Force Reboot**, **Factory Reset**. Each requires a confirm click; outcomes are audited under `dect:audit`.',
'Requires `DECT_RELAY_AGENT_TOKEN` on the bot and a live `dect-relay-agent` in the data center. When the relay is offline the command still reports discovery results and the offline state.',
'HTTP equivalent: `?storeNum=<n>[&base=<ip|mac>]` — markdown dump only (no action cards).',
'The compact exception-only DECT follow-up after `/phonestatus` is separate; this command is the full dump that follow-up footer points at.',
],
},
voicediag: {
title: '/voicediag',
usage: [
'/voicediag <store>',
'/voicediag <store> detail',
'/voicediag <store> --only <check1,check2>',
'/voicediag <store> --window <duration>',
'/voicediag list-checks',
],
examples: [
'/voicediag 782',
'/voicediag 782 detail',
'/voicediag 782 --window 24h',
'/voicediag 782 --window 15m --only wanLatency,wanJitter,wanLoss,wanMos',
'/voicediag 782 --only wanAppRtpMos,wanAppRtpLoss,wanAppRtpJitter',
'/voicediag 782 --only dnd,callForwarding,voicemail',
'/voicediag list-checks',
],
notes: [
'Runs a full battery of per-user Webex Calling checks (DND, call forwarding, voicemail, call intercept, call waiting, outgoing permission, etc.) plus eleven SD-WAN checks (site, healthscore, link state, latency, jitter, loss, MOS, per-app voice MOS/loss/jitter, alarms) against the store\'s Prisma tenant.',
'The per-app checks measure REAL voice-traffic quality via Prisma DPI (worst 5-minute window over the configured look-back, default 7d), which catches transient degradation the link-probe averages smooth away. Feature-gated on `PRISMA_APP_ID_VOICE` env (set to a Prisma app id like `Webex_Calling_RTP` or `rtp-base`) — checks return skipped with an explanation when not configured.',
'Default view hides OK checks and highlights errors/warnings/skipped. Pass `detail` (or `detailed`) to also see OK checks with expanded per-link tables + thresholds + roll-ups.',
'Fixable issues (e.g. DND on, forwarding to wrong number) post a per-issue confirmation card. A single "apply all" card lets you fix everything at once after reviewing.',
'`--only` restricts the run to specific check ids (comma-separated). Use `/voicediag list-checks` to see every registered id + its scope.',
'`--window` overrides the WAN look-back window (accepts `15m`, `1h`, `6h`, `24h`, `1d`, `7d`, or a bare minute count; hard-capped at 7d). Applies to healthscore + LQM (latency/jitter/loss/MOS) + per-app voice fetches + alarms. Alarms are floored at 60m. Default is 7d — pass `--window 24h` for a tighter view during live-incident triage, or set `WAN_STANDARD_WINDOW_MINUTES` for a different global default.',
'Voice-quality thresholds default to ITU-T G.114 / RFC 3550. Override any of them via `WAN_STANDARD_*` env vars (see `.env.example`). Per-app thresholds live under `WAN_STANDARD_APP_*`. Kill-switch: `WAN_STANDARD_ENABLED=false` silences the whole WAN bucket.',
'HTTP equivalent: `?storeNum=<n>[&detailed=true][&only=id1,id2][&window=15m]`. HTTP callers see the markdown snapshot only — cards are chat-only.',
'Audit log: every remediation apply/cancel is logged under `voicediag:audit` with the requester identity.',
],
},
'provision-dect': {
title: '/provision-dect',
usage: ['/provision-dect <store>'],
examples: ['/provision-dect 782'],
notes: [
'Aliases: `/provisiondect`.',
'Posts an interactive card to add/remove bases and handsets.',
'Looks up "Store XXXX" network using 5-digit person email; access codes are auto-generated.',
],
},
'provision-vc': {
title: '/provision-vc',
usage: ['/provision-vc <serial>', '/provision-vc <serial> <org>'],
examples: ['/provision-vc FOC2419NTN2', '/provision-vc FOC2419NTN2 todd'],
notes: [
'Aliases: `/vcprovision` (legacy).',
'`<org>` may be partial name (e.g. "todd", "american") or an org ID.',
],
},
vcmonitor: {
title: '/vcmonitor',
usage: ['/vcmonitor <serial> [start|stop|status] [Full|Limited|FullRotate]'],
examples: [
'/vcmonitor FOC2419NTN2',
'/vcmonitor FOC2419NTN2 Limited',
'/vcmonitor FOC2419NTN2 stop',
'/vcmonitor FOC2419NTN2 status',
],
notes: [
'Default action: `start Full` (~3 min capture including RTP).',
'PCAPs land in the System Log bundle downloadable from Control Hub diagnostics.',
],
},
callreport: {
title: '/callreport',
usage: [
'/callreport <store>',
'/callreport <email>',
'/callreport <phone>',
'/callreport <target> today',
'/callreport <target> YYYY-MM-DD',
'/callreport <target> --detail',
],
examples: [
'/callreport 782',
'/callreport mcqueenj@ae.com',
'/callreport 7247795574',
'/callreport mcqueenj@ae.com today',
'/callreport 782 2026-07-22',
'/callreport 782 --detail',
],
notes: [
'**Store** (`782`): full location digest for **9am9pm local** (default: **yesterday**). `today` uses 9am → 5 minutes ago.',
'**Email** or **phone number**: resolves the user/line location via Webex, fetches location CDR, then filters to that person/number only.',
'Groups CDR legs by **Correlation ID** into calls; lists inbound reach, auto-attendant, outbound, and abnormal outcomes.',
'Answered/connected calls include **Prisma Webex_Calling_RTP** MOS/jitter/loss for the call window when configured.',
'Requires `spark-admin:calling_cdr_read` + Control Hub role **Webex Calling Detailed Call History API access**.',
'CDR queries are serialized with a **65s cooldown** — a second request in that window is queued and you get a wait notice.',
'`--detail` adds correlation ID and raises per-section row cap.',
'HTTP: `?target=782&date=yesterday&detail=true` (also `storeNum`, `email`, `number`).',
'Alias: `/voicereport` (legacy).',
],
},
calltest: {
title: '/calltest',
usage: [
'/calltest <store>',
'/calltest store <store> [+1...]',
'/calltest dial <e164>',
'/calltest status <testId>',
],
examples: [
'/calltest 782',
'/calltest store 782',
'/calltest dial +12125550100',
'/calltest status a1b2c3d4-...',
],
notes: [
'Places a **real outbound call** via Twilio from the configured `TWILIO_FROM_NUMBER`.',
'**Store path:** dials the store main number (Webex `locationMainNumber`), pauses for the AA greeting, sends DTMF `1`, waits for the store leg, then runs the shared **60-second** listen test (intro → pause → thank you).',
'**Direct dial:** calls any E.164 number; on answer, runs the same 60s test with no AA/DTMF.',
'Requires `TWILIO_*` env vars, `TWILIO_WEBHOOK_BASE_URL` (public HTTPS), and `CALLTEST_ENABLED=true`.',
'After the result card, follow-ups post separately: **Twilio details** (~10s), **Twilio Insights** (~6 min, requires Advanced Features), **Webex CDR match** (~6 min when location is known).',
'CDR match works for store tests and dial tests to a known store main number; legs are matched on **destination (called number)** only.',
'Per-store entry tuning: `config/calltest-stores.json` (`dtmf`, `greetingPauseSec`, `answerWaitSec`).',
],
},
wohistory: {
title: '/wohistory',
usage: ['/wohistory <store>'],
examples: ['/wohistory 782'],
notes: ['In a store-linked space, the store can be omitted.'],
},
wosummary: {
title: '/wosummary',
usage: ['/wosummary <work-order-id>'],
examples: ['/wosummary 12345678'],
},
woattachments: {
title: '/woattachments',
usage: ['/woattachments <work-order-id>'],
examples: ['/woattachments 12345678'],
},
jirahistory: {
title: '/jirahistory',
usage: [
'/jirahistory <store>',
'/jirahistory <store> <component>',
'/jirahistory <store> all',
],
examples: [
'/jirahistory 782',
'/jirahistory 782 phone',
'/jirahistory 782 av,voice,mobility',
],
notes: [
'Component shortcuts: `av` → Audio Visual, `phone`/`voice` → Communication Services, `mobility` → Mobility.',
],
},
jiraticket: {
title: '/jiraticket',
usage: ['/jiraticket <KEY>'],
examples: ['/jiraticket SUPPORT-817694'],
},
jirapoll: {
title: '/jirapoll',
usage: ['/jirapoll', '/jirapoll prime'],
examples: ['/jirapoll', '/jirapoll prime'],
notes: [
'Triggers the same Jira poller that normally runs at the top of every hour. Enriches any unlabeled matching tickets with a phone/av snapshot comment and labels them `bot-enriched`. Tickets the AI classifier decides are out-of-scope get labeled `bot-skipped` so they aren\'t re-classified every hour.',
'**Communication Services** tickets mentioning call quality (garbled, static, can\'t connect, …) get **phonestatus + callreport (today)**. Spam/robocall tickets get **callreport** only. See `services/jiraPoller/README.md` for adding more rules.',
'Idempotent — labels + JQL prevent double-processing, so running multiple times in a row is safe.',
'`/jirapoll prime` labels every matching ticket without enriching or notifying. Use once after adopting the poller to skip enriching the existing backlog. Same as `JIRA_POLLER_PRIME_ON_START=true` at startup.',
'A summary of enriched tickets goes to the configured `JIRA_POLLER_ROOM_ID`. The invoking chat also gets a compact result line.',
],
},
webexhost: {
title: '/webexhost',
usage: [
'/webexhost <email>',
'/webexhost list',
'/webexhost debug <email>',
],
examples: [
'/webexhost jdoe@company.com',
'/webexhost list',
'/webexhost debug jdoe@company.com',
],
notes: [
'`/webexhost <email>` checks whether the user holds any Webex Meetings host license on the configured site (default `aeo2go.webex.com`, override via `WEBEX_HOST_SITE_URL`).',
'If they already have one, the command reports which license. If not — and `WEBEX_HOST_LICENSE_ID` is set in `.env` — it posts a confirmation card to assign that license.',
'`/webexhost list` is a discovery helper: lists every meeting license on the site with id + remaining seats, marking the one currently configured for auto-assign.',
'`/webexhost debug <email>` dumps the raw Webex payload for the user (search-endpoint vs `GET /people/{id}` license counts, intersection with site licenses) — use this when Control Hub disagrees with what the bot says.',
'Requires service-app scopes: `spark-admin:licenses_read` + `spark-admin:people_write`. Scope/role failures surface inline.',
'There is no Webex API for the "host vs attendee" account flag itself — host status is determined entirely by holding a meeting license on the site (confirmed via Cisco docs + wxc_sdk source).',
'Audit log: requests + outcomes are emitted under the `webexhost:audit` scope with the requester identity (chat email or `via HTTP API`).',
],
},
offboarduser: {
title: '/offboarduser',
usage: ['/offboarduser <email>'],
examples: ['/offboarduser jdoe@company.com'],
notes: [
'Posts a confirmation card showing the Webex user + MDM CORP devices that will be acted on.',
'Confirming revokes every Webex OAuth authorization for the user (signs them out of all Webex clients) and enterprise-wipes each listed MDM CORP device — both run in parallel.',
'Requires the Webex service app to hold `identity:tokens_read` + `identity:tokens_write` scopes and an admin with Full / User / Device Admin role. Scope/role failures surface inline in the success message.',
'`Hide from search` is **not** part of this command — Cisco only exposes that toggle in Control Hub, not via any Webex API. Set it manually if your offboarding policy requires it.',
'Every request and outcome is logged under the `offboard:audit` scope with the requesting user (chat email or `via HTTP API`).',
],
},
bulkavstatuscsv: {
title: '/bulkavstatuscsv',
usage: ['/bulkavstatuscsv'],
notes: ['Long-running. Posts CSV attachment back to the calling room.'],
},
bulkavswitchcsv: {
title: '/bulkavswitchcsv',
usage: ['/bulkavswitchcsv'],
notes: [
'Long-running. Walks every rear switch in Meraki for VLAN 340/145 ports.',
'Posts CSV attachment back to the calling room.',
],
},
devicesbymodel: {
title: '/devicesbymodel',
usage: ['/devicesbymodel'],
notes: ['Posts a Meraki by-model count + CSV to the calling room.'],
},
};
const GROUPS = [
{ title: 'Work orders', keys: ['wohistory', 'wosummary', 'woattachments'] },
{ title: 'AV & phones', keys: ['avstatus', 'phonestatus', 'dectstatus', 'voicediag', 'callreport', 'calltest'] },
{ title: 'Jira', keys: ['jirahistory', 'jiraticket', 'jirapoll'] },
{ title: 'Provisioning', keys: ['provision-dect', 'provision-vc', 'vcmonitor'] },
{ title: 'Admin & bulk', keys: ['offboarduser', 'webexhost', 'bulkavstatuscsv', 'bulkavswitchcsv', 'devicesbymodel'] },
];
function renderTopLevelHelp(isGroup) {
const lines = ['### CollabSupport Bot Help', ''];
for (const g of GROUPS) {
lines.push(`**${g.title}**`);
for (const key of g.keys) {
const short = SHORT_HELP[key];
if (!short) continue;
lines.push(`- \`/${key}\`${short}`);
}
lines.push('');
}
lines.push('Type `/help <command>` for usage and examples (e.g. `/help avstatus`).');
if (isGroup) {
lines.push('In a space linked to a store, most commands accept no arguments and pick up the store automatically.');
}
lines.push('Web dashboards: `/av-store-dashboard.html`, `/phone-store-dashboard.html`.');
return lines.join('\n');
}
function renderCommandHelp(name) {
const detail = LONG_HELP[name];
if (!detail) return null;
const lines = [`### ${detail.title}`];
if (SHORT_HELP[name]) {
lines.push('', SHORT_HELP[name]);
}
if (detail.usage?.length) {
lines.push('', '**Usage:**');
for (const u of detail.usage) lines.push(`- \`${u}\``);
}
if (detail.examples?.length) {
lines.push('', '**Examples:**');
for (const ex of detail.examples) lines.push(`- \`${ex}\``);
}
if (detail.notes?.length) {
lines.push('', '**Notes:**');
for (const n of detail.notes) lines.push(`- ${n}`);
}
return lines.join('\n');
}
export async function handleHelp(bot, trigger) {
const isGroup = trigger.message?.roomType === 'group';
// First arg (after `/help`) selects a specific command's detail page.
// Strip a leading slash so both `/help avstatus` and `/help /avstatus` work.
const args = trigger.args || [];
const requested = (args[0] || trigger.query?.command || '')
.toString()
.trim()
.toLowerCase()
.replace(/^\//, '');
if (requested) {
const detail = renderCommandHelp(requested);
if (detail) {
await bot.say('markdown', detail);
return;
}
await bot.say(
'markdown',
`Unknown command: \`${requested}\`.\n\n${renderTopLevelHelp(isGroup)}`,
);
return;
}
await bot.say('markdown', renderTopLevelHelp(isGroup));
}