Multi-integration Webex chat/HTTP bot that unifies phone, AV, and network status for retail store support. Consolidates data from Webex Calling, Meraki, Workspace ONE (MDM), Atlas AMP, RED digital signage, and OptiSigns into rich per-store status commands. Key surfaces: - /phonestatus, /avstatus — per-store phone & AV device reports with clickable Meraki deep-links and per-port detail. - /webexhost — check/assign Webex Meetings host licenses via the Service App; adaptive-card confirmation flow, HTTP-API-gated. - /offboarduser — full Webex Admin offboarding (auth revoke, device wipe, license removal); adaptive-card confirmation. - /jirapoll — on-demand trigger for the hourly Jira poller. - /bulkavstatuscsv — bulk store CSV export with concurrency limits. Automation: - Hourly Jira poller (node-cron) with an X.AI (Grok) ticket classifier that categorizes unassigned tickets as phone/av/skip, extracts store numbers from free-text, and enriches Jira with the same detailed markdown the chat commands emit (converted to Jira ADF, preserves bold + Meraki links). Idempotent via a `bot-enriched` Jira label. Architecture: - Node.js 20+, ESM, Express 5, webex-node-bot-framework. - Layered integrations (integrations/*), services (services/*), commands (commands/*), utils (utils/*). - Shared markdown renderers (services/renderers/*) feed both chat handlers and the Jira poller so the two surfaces stay in sync. - Hand-rolled markdown-to-ADF converter (utils/markdownToAdf.js) — no new npm dependency. - Node built-in test runner (`node --test tests/*.test.js`), 30 tests covering the converter, renderers, and poller ADF assembly. Docker + docker-compose deployment. Config via .env (see .env.example for the full option surface).
272 lines
11 KiB
JavaScript
272 lines
11 KiB
JavaScript
// 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',
|
|
|
|
// 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.',
|
|
'Web dashboard: `/phone-store-dashboard.html`.',
|
|
],
|
|
},
|
|
'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.',
|
|
],
|
|
},
|
|
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`.',
|
|
'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'] },
|
|
{ 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));
|
|
}
|