collabSupport/commands/help.js
jmcqueen c15a471959 Tag skipped Jira tickets with bot-skipped label
Prevents the hourly poller from re-classifying the same "not for us"
tickets every hour and burning AI tokens on them forever.

New SKIP_LABEL='bot-skipped' is applied whenever the AI classifier
decides a ticket is out-of-scope (kind='skip', no store number, or an
unroutable kind). The JQL now excludes both bot labels while
preserving the `IS EMPTY OR` union so brand-new unlabeled tickets
still match. Transient failures (AI down, collector 5xx, comment
5xx) intentionally stay unlabeled so they retry next poll.

Labeling is wrapped in a non-throwing helper — a Jira 5xx on the
label call can't abort the batch; the ticket just gets one duplicate
classification next hour, which is far cheaper than dropping the poll.
2026-07-01 17:14:17 -04:00

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`. 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 <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));
}