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).
90 lines
3.7 KiB
JavaScript
90 lines
3.7 KiB
JavaScript
// src/commands/jiraPoll.js
|
|
//
|
|
// /jirapoll — run the hourly Jira poller ON DEMAND. Same code
|
|
// path as the cron job. Enriches any unlabeled
|
|
// matching tickets with a phone/av snapshot comment,
|
|
// labels them, and posts the standard summary to
|
|
// JIRA_POLLER_ROOM_ID if any were enriched. Also
|
|
// replies in the invoking chat with the counts so
|
|
// you get immediate feedback.
|
|
//
|
|
// /jirapoll prime — run in PRIME mode (labels every matching ticket
|
|
// without enriching or notifying). Equivalent to a
|
|
// one-off JIRA_POLLER_PRIME_ON_START=true restart.
|
|
//
|
|
// Auth model
|
|
// The command is `mutating: true` in the registry, so hitting the
|
|
// HTTP path requires HTTP_API_TOKEN. From Webex chat any user who
|
|
// can talk to the bot can run it — same trust model as the other
|
|
// mutating chat commands (`offboarduser`, `webexhost`, etc.).
|
|
//
|
|
// Concurrency
|
|
// `pollNewTickets` uses Jira labels for idempotency, so overlapping
|
|
// invocations are safe (each ticket can only be enriched once). If
|
|
// two people trigger `/jirapoll` at the same second, they'll each
|
|
// process disjoint slices of the label-race — no double comments.
|
|
|
|
import { pollNewTickets } from '../services/jiraPollerService.js';
|
|
import { extractRequester, describeRequester } from '../utils/requester.js';
|
|
import { logger } from '../utils/logger.js';
|
|
|
|
export async function handleJiraPoll(bot, trigger) {
|
|
const requester = extractRequester(trigger);
|
|
const args = trigger.args || [];
|
|
const query = trigger.query || {};
|
|
const primeArg = (args[0] || query.mode || '').toLowerCase();
|
|
const isPrime = primeArg === 'prime';
|
|
|
|
logger('jira:poll:cmd', `Requested by ${describeRequester(requester)}${isPrime ? ' (PRIME mode)' : ''}`);
|
|
|
|
// Acknowledge immediately — a full poll can run 15-30s at N=7 or
|
|
// 60s+ at N=50, and the operator shouldn't stare at a blank chat.
|
|
const ackLines = [
|
|
isPrime
|
|
? '⏳ Running Jira poller in **PRIME mode** — will label matching tickets without enriching…'
|
|
: '⏳ Running Jira poller on demand — this may take up to a minute for a full 50-ticket batch…',
|
|
];
|
|
await bot.say('markdown', ackLines.join('\n'));
|
|
|
|
const startedAt = Date.now();
|
|
let result;
|
|
try {
|
|
result = await pollNewTickets({ prime: isPrime });
|
|
} catch (err) {
|
|
logger('jira:poll:cmd', `Poll failed: ${err.message}`, 'error');
|
|
await bot.say('markdown', `❌ Poll failed: \`${err.message}\``);
|
|
return;
|
|
}
|
|
|
|
const elapsedSec = ((Date.now() - startedAt) / 1000).toFixed(1);
|
|
|
|
if (isPrime) {
|
|
const primed = result.primed ?? 0;
|
|
const skipped = result.skipped ?? 0;
|
|
await bot.say(
|
|
'markdown',
|
|
`✅ **Prime pass complete** (${elapsedSec}s)\n` +
|
|
`Labeled **${primed}** ticket(s) as \`bot-enriched\` without enrichment.` +
|
|
(skipped ? ` ${skipped} labeling failure(s) — check logs.` : ''),
|
|
);
|
|
return;
|
|
}
|
|
|
|
const enriched = result.enriched ?? 0;
|
|
const skipped = result.skipped ?? 0;
|
|
const tokens = result.tokensUsed ?? 0;
|
|
|
|
const lines = [`✅ **Poll complete** (${elapsedSec}s, ${tokens} AI tokens)`];
|
|
lines.push(`Enriched: **${enriched}**, skipped: **${skipped}**`);
|
|
if (enriched > 0 && process.env.JIRA_POLLER_ROOM_ID) {
|
|
// Note the standard summary that already went to the configured
|
|
// room so the invoker knows where the per-ticket detail lives.
|
|
lines.push('');
|
|
lines.push(`_Per-ticket detail posted to the configured summary room._`);
|
|
} else if (enriched === 0) {
|
|
lines.push('');
|
|
lines.push('_Nothing to enrich right now._');
|
|
}
|
|
|
|
await bot.say('markdown', lines.join('\n'));
|
|
}
|