// src/commands/help.js // // Help renderer. // // /help → short, scannable list of every command (one line each) // with grouped headings. // /help → 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 `. 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', voicereport: 'Daily voice digest: CDR + Media Quality + Prisma WAN for a store', 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 ', '/avstatus detailed'], examples: ['/avstatus 782', '/avstatus 782 detailed'], notes: [ 'Aliases: `/wostatus`.', 'HTTP equivalent: `?storeNum=&detailed=true`.', 'Includes Meraki deep links per device. For interactive topology, open `/av-store-dashboard.html`.', ], }, phonestatus: { title: '/phonestatus', usage: ['/phonestatus ', '/phonestatus 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` 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 `.', 'For an in-depth voice diagnostic with per-user Webex Calling checks + fix cards, use `/voicediag `.', 'Web dashboard: `/phone-store-dashboard.html`.', ], }, dectstatus: { title: '/dectstatus', usage: [ '/dectstatus ', '/dectstatus ', ], 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=[&base=]` — 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 ', '/voicediag detail', '/voicediag --only ', '/voicediag --window ', '/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=[&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 '], 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 ', '/provision-vc '], examples: ['/provision-vc FOC2419NTN2', '/provision-vc FOC2419NTN2 todd'], notes: [ 'Aliases: `/vcprovision` (legacy).', '`` may be partial name (e.g. "todd", "american") or an org ID.', ], }, vcmonitor: { title: '/vcmonitor', usage: ['/vcmonitor [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.', ], }, voicereport: { title: '/voicereport', usage: [ '/voicereport ', '/voicereport today', '/voicereport YYYY-MM-DD', '/voicereport --detail', ], examples: [ '/voicereport 782', '/voicereport 782 today', '/voicereport 782 2026-07-22', '/voicereport 782 --detail', ], notes: [ 'Store-scoped daily voice digest for **9am–9pm local** (default: **yesterday**). `today` uses 9am → 5 minutes ago.', 'Pulls **all CDR legs** for the Webex location, **Calling Media Quality** report (Pro Pack + `analytics:read_all`), and **Prisma Webex_Calling_RTP** WAN overlay when configured.', 'Requires `spark-admin:calling_cdr_read` + Control Hub role **Webex Calling Detailed Call History API access** for CDR.', 'Report generation may take 1–3 minutes; an ack message posts first.', 'HTTP: `?storeNum=782&date=yesterday&detail=true`.', ], }, calltest: { title: '/calltest', usage: [ '/calltest ', '/calltest store [+1...]', '/calltest dial ', '/calltest status ', ], 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 '], examples: ['/wohistory 782'], notes: ['In a store-linked space, the store can be omitted.'], }, wosummary: { title: '/wosummary', usage: ['/wosummary '], examples: ['/wosummary 12345678'], }, woattachments: { title: '/woattachments', usage: ['/woattachments '], examples: ['/woattachments 12345678'], }, jirahistory: { title: '/jirahistory', usage: [ '/jirahistory ', '/jirahistory ', '/jirahistory 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 '], 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.', '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 ', '/webexhost list', '/webexhost debug ', ], examples: [ '/webexhost jdoe@company.com', '/webexhost list', '/webexhost debug jdoe@company.com', ], notes: [ '`/webexhost ` 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 ` 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 '], 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', 'voicereport', '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 ` 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)); }