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).
369 lines
16 KiB
JavaScript
369 lines
16 KiB
JavaScript
// src/services/jiraPollerService.js
|
|
//
|
|
// Hourly Jira poller.
|
|
//
|
|
// Fetches unassigned tickets in the AV / Communication Services / Mobility
|
|
// queue, enriches each store-scoped ticket with a compact phone or AV
|
|
// status snapshot posted as a Jira comment, labels the ticket
|
|
// `bot-enriched` so it's not re-processed on subsequent polls, and posts
|
|
// a summary of newly-enriched tickets to a configured Webex space.
|
|
//
|
|
// Idempotency model — a Jira label is the source of truth. The JQL
|
|
// includes `AND labels != bot-enriched`, so Jira itself only returns
|
|
// unseen tickets. This survives bot restarts, deploys, and (harmlessly)
|
|
// concurrent runs — no local state file, no in-memory cursor.
|
|
//
|
|
// Store number handling — the poller looks up the `Store Number` custom
|
|
// field id via `JiraClient.getFieldIdByName()` (cached for process
|
|
// lifetime) or a `JIRA_STORE_FIELD_ID` env override. If a ticket has no
|
|
// store value, it's skipped entirely — no comment, no label, no summary
|
|
// line — so future non-store enrichment tooling can pick it up later.
|
|
//
|
|
// Comment format — Jira Cloud v3 requires ADF. We emit an italic
|
|
// header paragraph followed by the same markdown the /phonestatus or
|
|
// /avstatus chat command would produce, converted to ADF paragraphs
|
|
// via utils/markdownToAdf. This preserves clickable Meraki links,
|
|
// bold device names, and paragraph structure that the previous
|
|
// code-block format flattened to unformatted text.
|
|
//
|
|
// Failure isolation — every per-ticket step is in a per-ticket
|
|
// try/catch. One flaky ticket cannot stop the batch. The label is only
|
|
// added *after* `addComment` succeeds, so a transient Jira 5xx on the
|
|
// comment retries next hour rather than silently swallowing the ticket.
|
|
|
|
import { logger } from '../utils/logger.js';
|
|
import jira from '../integrations/jira/JiraClient.js';
|
|
import botClient from '../integrations/webex/BotClient.js';
|
|
import { collectPhoneStatus } from './phoneService.js';
|
|
import { collectDeviceStatus } from './deviceService.js';
|
|
import { classifyTicket, TicketClassifierError } from './ticketClassifier.js';
|
|
import { adfToPlainText } from '../utils/adfToPlainText.js';
|
|
import { markdownToAdfContent } from '../utils/markdownToAdf.js';
|
|
import { buildAdfComment } from '../utils/adfComment.js';
|
|
import { renderPhoneStatusMarkdown } from './renderers/phoneStatusRenderer.js';
|
|
import { renderAvStatusMarkdown } from './renderers/avStatusRenderer.js';
|
|
|
|
const BOT_LABEL = 'bot-enriched';
|
|
const STORE_FIELD_NAME = 'Store Number';
|
|
|
|
// Hard safety cap on tickets processed per poll. AI classification
|
|
// costs money AND rate-limit budget per call, so a runaway (mass ticket
|
|
// import, JQL change that suddenly matches thousands of rows) should
|
|
// not translate into an unbounded X.AI bill in a single hour. Backlog
|
|
// drains at MAX_TICKETS_PER_POLL/hour once it exists.
|
|
const MAX_TICKETS_PER_POLL = 50;
|
|
|
|
// Maps classifier's `kind` to the enrichment collector. Replaces the
|
|
// component-name -> collector map that used to be the source of truth
|
|
// pre-AI. The classifier's output space is closed (phone|av|skip), so
|
|
// this map only needs the two enrichable kinds — 'skip' short-circuits
|
|
// before we get here.
|
|
const KIND_TO_COLLECTOR = {
|
|
phone: collectPhoneStatus,
|
|
av: collectDeviceStatus,
|
|
};
|
|
|
|
// Component name -> enrichment collector. Strict mapping per plan; a
|
|
// ticket whose components don't match any key here is skipped (though
|
|
// the poller's JQL should ensure this is never actually hit).
|
|
export const COMPONENT_ROUTES = {
|
|
'Communication Services': { kind: 'phone', collect: collectPhoneStatus },
|
|
'Mobility': { kind: 'phone', collect: collectPhoneStatus },
|
|
'Audio Visual': { kind: 'av', collect: collectDeviceStatus },
|
|
};
|
|
|
|
// The JQL kept as a single owned constant so it's obvious in one place
|
|
// and easy to audit against the spec. Any status/component change lives
|
|
// here.
|
|
//
|
|
// Label clause gotcha: JQL's `!=` operator excludes issues where the
|
|
// field is empty (documented Atlassian behavior), and brand-new tickets
|
|
// almost always have zero labels. A naive `labels != bot-enriched`
|
|
// therefore filters out precisely the tickets we want. The
|
|
// `IS EMPTY OR ... != ...` union is the standard workaround — it
|
|
// matches "no labels at all" plus "has labels, none of them are
|
|
// bot-enriched". Do NOT "simplify" this back to a bare `!=`.
|
|
export const POLLER_JQL = [
|
|
'component IN ("Communication Services", "Audio Visual", Mobility)',
|
|
'AND assignee = empty',
|
|
'AND status IN ("Assign to Team", "Equipment Sent", Escalated, "High Severity Incident",',
|
|
' "In Progress", "New Request", "Not Started", Open, Pending, "Work in progress")',
|
|
`AND (labels IS EMPTY OR labels != "${BOT_LABEL}")`,
|
|
].join(' ');
|
|
|
|
// Resolve the Store Number field id. Env override wins so an operator
|
|
// can pin it during Jira schema experiments. Otherwise cached inside
|
|
// JiraClient after the first successful discovery. On error, clears the
|
|
// memoization so the next poll retries.
|
|
let _storeFieldIdPromise = null;
|
|
async function resolveStoreFieldId() {
|
|
const override = process.env.JIRA_STORE_FIELD_ID;
|
|
if (override) return override;
|
|
if (!_storeFieldIdPromise) {
|
|
_storeFieldIdPromise = jira.getFieldIdByName(STORE_FIELD_NAME).catch((err) => {
|
|
_storeFieldIdPromise = null;
|
|
throw err;
|
|
});
|
|
}
|
|
return _storeFieldIdPromise;
|
|
}
|
|
|
|
// Pick the first component whose name we recognize. Jira allows a
|
|
// ticket to have multiple components; we honor the first match rather
|
|
// than trying to blend two enrichment kinds.
|
|
export function routeForTicket(components) {
|
|
for (const c of components || []) {
|
|
const route = COMPONENT_ROUTES[c?.name];
|
|
if (route) return route;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Custom fields can return strings, numbers, `{value}` objects, or
|
|
// nulls depending on the field configuration. Accept the simplest cases
|
|
// and require a 2-6 digit numeric value so we don't confuse "N/A" or
|
|
// "unknown" text with a real store.
|
|
export function extractStore(fieldValue) {
|
|
if (fieldValue === null || fieldValue === undefined) return null;
|
|
const raw = typeof fieldValue === 'object'
|
|
? (fieldValue.value ?? fieldValue.name ?? '')
|
|
: fieldValue;
|
|
const s = String(raw).trim();
|
|
const m = s.match(/^\d{2,6}$/);
|
|
return m ? m[0] : null;
|
|
}
|
|
|
|
// Re-export the extracted `buildAdfComment` helper so any existing
|
|
// callers that pulled it from this module keep working. Actual body
|
|
// lives in utils/adfComment.js — pure, side-effect-free, testable in
|
|
// isolation from the Jira / Webex clients this service imports.
|
|
export { buildAdfComment };
|
|
|
|
/**
|
|
* Poll Jira for unassigned tickets in the AV / Comm / Mobility queue,
|
|
* enrich store-scoped ones with a phone/av snapshot comment, label
|
|
* processed tickets `bot-enriched`, and post a summary to Webex.
|
|
*
|
|
* @param {object} [opts]
|
|
* @param {boolean} [opts.prime=false] If true, label every matching
|
|
* ticket as `bot-enriched` WITHOUT enriching or notifying. Used for
|
|
* a one-time backlog prime pass via JIRA_POLLER_PRIME_ON_START=true.
|
|
* @returns {Promise<{enriched: number, skipped: number, primed?: number}>}
|
|
*/
|
|
export async function pollNewTickets({ prime = false } = {}) {
|
|
const startedAt = Date.now();
|
|
logger('jira:poller', `Poll starting${prime ? ' (PRIME mode — labels only, no enrichment/notify)' : ''}`);
|
|
|
|
let storeFieldId;
|
|
try {
|
|
storeFieldId = await resolveStoreFieldId();
|
|
} catch (err) {
|
|
logger('jira:poller', `Aborting poll — could not resolve Store Number field id: ${err.message}`, 'error');
|
|
return { enriched: 0, skipped: 0 };
|
|
}
|
|
if (!storeFieldId) {
|
|
logger('jira:poller', `Aborting poll — Jira field '${STORE_FIELD_NAME}' not found; set JIRA_STORE_FIELD_ID to override`, 'error');
|
|
return { enriched: 0, skipped: 0 };
|
|
}
|
|
|
|
// Explicitly ask for the store custom field — the default fields list
|
|
// in JiraClient.search() doesn't include it, so without this every
|
|
// ticket would look store-less. `description` and `reporter` are
|
|
// pulled in for the AI classifier's context payload; both are needed
|
|
// per-ticket so we ask up front rather than fetching per-issue.
|
|
const fields = [
|
|
'key', 'summary', 'description', 'status', 'components',
|
|
'assignee', 'reporter', 'created', storeFieldId,
|
|
].join(',');
|
|
|
|
// Emit the effective JQL + store field id every poll so operators can
|
|
// paste the exact string into Jira's advanced-search UI to compare
|
|
// what the bot sees vs what a human sees. Silent "0 results" from a
|
|
// misconfigured component name or a service-account visibility gap
|
|
// is otherwise near-impossible to diagnose.
|
|
logger('jira:poller', `Executing search — storeFieldId=${storeFieldId}, JQL=${POLLER_JQL}`);
|
|
|
|
let searchResult;
|
|
try {
|
|
searchResult = await jira.search(POLLER_JQL, fields);
|
|
} catch (err) {
|
|
logger('jira:poller', `Aborting poll — Jira search failed: ${err.message}`, 'error');
|
|
return { enriched: 0, skipped: 0 };
|
|
}
|
|
|
|
let issues = Array.isArray(searchResult?.issues) ? searchResult.issues : [];
|
|
logger('jira:poller', `Search returned ${issues.length} unlabeled candidate ticket(s)`);
|
|
|
|
if (issues.length === 0) {
|
|
logger('jira:poller', `Poll complete in ${((Date.now() - startedAt) / 1000).toFixed(1)}s — nothing new`);
|
|
return { enriched: 0, skipped: 0 };
|
|
}
|
|
|
|
// Cap enforcement — anything past MAX_TICKETS_PER_POLL waits for
|
|
// next hour. Deliberately NOT sampled (first-N slice) so operators
|
|
// can predict which tickets the poller will attempt each hour; the
|
|
// cap is a safety net, not a load-balancer.
|
|
if (issues.length > MAX_TICKETS_PER_POLL) {
|
|
logger(
|
|
'jira:poller',
|
|
`Ticket count ${issues.length} exceeds MAX_TICKETS_PER_POLL=${MAX_TICKETS_PER_POLL} — ` +
|
|
`processing first ${MAX_TICKETS_PER_POLL}, remaining ${issues.length - MAX_TICKETS_PER_POLL} will be picked up next poll`,
|
|
'warn'
|
|
);
|
|
issues = issues.slice(0, MAX_TICKETS_PER_POLL);
|
|
}
|
|
|
|
if (prime) {
|
|
let primed = 0;
|
|
for (const issue of issues) {
|
|
try {
|
|
await jira.addLabel(issue.key, BOT_LABEL);
|
|
primed++;
|
|
} catch (err) {
|
|
logger('jira:poller', `PRIME: failed to label ${issue.key}: ${err.message}`, 'warn');
|
|
}
|
|
}
|
|
const elapsedSec = ((Date.now() - startedAt) / 1000).toFixed(1);
|
|
logger('jira:poller', `PRIME complete — labeled ${primed}/${issues.length} tickets as '${BOT_LABEL}' in ${elapsedSec}s`);
|
|
return { enriched: 0, skipped: issues.length - primed, primed };
|
|
}
|
|
|
|
const enriched = []; // { key, summary, storeNum, kind, reason }
|
|
const skipped = []; // { key, reason }
|
|
let totalTokens = 0;
|
|
for (const issue of issues) {
|
|
const key = issue.key;
|
|
const f = issue.fields || {};
|
|
const summary = f.summary || '(no summary)';
|
|
|
|
// Jira Cloud v3 returns `description` as an ADF document
|
|
// (structured JSON), not plain text. Flatten it before handing to
|
|
// the classifier — otherwise we'd pay tokens for JSON syntax the
|
|
// model has to parse itself. The `adfToPlainText` helper is the
|
|
// same one the summarizer uses on ticket bodies.
|
|
const descriptionText = f.description
|
|
? (typeof f.description === 'string' ? f.description : adfToPlainText(f.description))
|
|
: '';
|
|
|
|
// Build the classifier payload. Components / raw Store Number
|
|
// field value are included as *hints* — the classifier is free to
|
|
// ignore them if the summary/description tell a different story.
|
|
const ticketPayload = {
|
|
key,
|
|
summary,
|
|
description: descriptionText,
|
|
components: f.components || [],
|
|
status: f.status?.name,
|
|
reporter: f.reporter?.displayName || f.reporter?.emailAddress,
|
|
storeFieldRaw: f[storeFieldId],
|
|
};
|
|
|
|
let classification;
|
|
try {
|
|
classification = await classifyTicket(ticketPayload);
|
|
totalTokens += classification.tokensUsed || 0;
|
|
} catch (err) {
|
|
// Skip-until-recovery per the classifier plan: AI failure means
|
|
// the ticket waits for next hour rather than falling back to a
|
|
// stale component-based decision.
|
|
const label = err instanceof TicketClassifierError ? 'AI classification failed' : 'unexpected classifier error';
|
|
logger('jira:poller', `${key}: SKIP — ${label}: ${err.message}`, 'warn');
|
|
skipped.push({ key, reason: label });
|
|
continue;
|
|
}
|
|
|
|
if (classification.kind === 'skip' || !classification.storeNum) {
|
|
logger('jira:poller', `${key}: SKIP — AI: ${classification.reason}`);
|
|
skipped.push({ key, reason: classification.reason });
|
|
continue;
|
|
}
|
|
|
|
const collect = KIND_TO_COLLECTOR[classification.kind];
|
|
if (!collect) {
|
|
// Belt-and-suspenders: parseAndValidate already gates kind to
|
|
// phone|av|skip, but if the schema ever loosens we don't want to
|
|
// silently no-op.
|
|
logger('jira:poller', `${key}: SKIP — no collector for kind '${classification.kind}'`, 'warn');
|
|
skipped.push({ key, reason: `unsupported kind: ${classification.kind}` });
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
logger('jira:poller', `${key}: enriching (${classification.kind}, store ${classification.storeNum}) — AI: ${classification.reason}`);
|
|
const data = await collect(classification.storeNum);
|
|
|
|
// Same markdown the chat commands emit. `footer: false` strips
|
|
// the "*Last checked: HH:MM*" line — a Jira comment already has
|
|
// an authoritative timestamp in the header paragraph below and
|
|
// Jira's own `created` field. Detailed mode always on for Jira
|
|
// so triagers get the richest possible per-device info.
|
|
const markdown = classification.kind === 'phone'
|
|
? renderPhoneStatusMarkdown(data, {
|
|
storeNum: classification.storeNum,
|
|
detailed: true,
|
|
footer: false,
|
|
})
|
|
: renderAvStatusMarkdown(data, {
|
|
storeNum: classification.storeNum,
|
|
detailed: true,
|
|
footer: false,
|
|
});
|
|
|
|
const headerLine =
|
|
`Auto-enriched by CollabFinder — ${classification.kind} snapshot for store ${classification.storeNum} ` +
|
|
`at ${new Date().toISOString()} · AI: ${classification.reason}`;
|
|
const adf = buildAdfComment({
|
|
headerLine,
|
|
bodyNodes: markdownToAdfContent(markdown),
|
|
});
|
|
|
|
await jira.addComment(key, adf);
|
|
await jira.addLabel(key, BOT_LABEL);
|
|
|
|
enriched.push({
|
|
key,
|
|
summary,
|
|
storeNum: classification.storeNum,
|
|
kind: classification.kind,
|
|
reason: classification.reason,
|
|
});
|
|
} catch (err) {
|
|
logger('jira:poller', `${key}: enrichment failed — ${err.message}`, 'error');
|
|
skipped.push({ key, reason: `error: ${err.message}` });
|
|
}
|
|
}
|
|
|
|
const elapsedSec = ((Date.now() - startedAt) / 1000).toFixed(1);
|
|
logger('jira:poller', `Poll complete in ${elapsedSec}s — enriched ${enriched.length}, skipped ${skipped.length}, tokens ${totalTokens}`);
|
|
|
|
// Summary post — only when there's something worth reporting AND a
|
|
// target room is configured. Skipped-only polls stay silent to avoid
|
|
// spamming the space every hour. The AI's reason is included per
|
|
// ticket so a human can spot-check misclassifications at a glance
|
|
// (this is the "compensating control" for full-auto mode).
|
|
const roomId = process.env.JIRA_POLLER_ROOM_ID;
|
|
if (enriched.length > 0 && roomId) {
|
|
const lines = [
|
|
`**${enriched.length} new ticket${enriched.length === 1 ? '' : 's'} auto-enriched** (${elapsedSec}s, ${totalTokens} AI tokens)`,
|
|
'',
|
|
...enriched.map((t) =>
|
|
`• **${t.key}** [${t.kind === 'phone' ? 'Phone' : 'AV'}, store ${t.storeNum}] — ${t.summary}\n` +
|
|
` _AI: ${t.reason}_`,
|
|
),
|
|
];
|
|
if (skipped.length > 0) {
|
|
lines.push('');
|
|
lines.push(
|
|
`_${skipped.length} ticket(s) skipped: ` +
|
|
`${skipped.map((s) => `${s.key} (${s.reason})`).join(', ')}_`,
|
|
);
|
|
}
|
|
try {
|
|
await botClient.sendMarkdown(roomId, lines.join('\n'));
|
|
} catch (err) {
|
|
logger('jira:poller', `Failed to post summary to Webex: ${err.message}`, 'warn');
|
|
}
|
|
}
|
|
|
|
return { enriched: enriched.length, skipped: skipped.length, tokensUsed: totalTokens };
|
|
}
|