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).
169 lines
5.9 KiB
JavaScript
169 lines
5.9 KiB
JavaScript
// src/utils/markdownToAdf.js
|
|
//
|
|
// Narrow, hand-rolled markdown → Atlassian Document Format converter.
|
|
//
|
|
// Grammar supported (deliberately minimal, matches what our chat
|
|
// renderers actually emit — see commands/phoneStatus.js and
|
|
// commands/avStatus.js):
|
|
//
|
|
// **text** → { type:'text', text, marks:[{type:'strong'}] }
|
|
// *text* → { type:'text', text, marks:[{type:'em'}] }
|
|
// [label](url) → { type:'text', text:label, marks:[{type:'link',attrs:{href:url}}] }
|
|
// \n\n (blank line) → paragraph split
|
|
// \n (single line) → { type:'hardBreak' } inside the paragraph
|
|
// anything else → plain { type:'text', text } — emojis, arrows,
|
|
// bullets, unicode symbols all pass through.
|
|
//
|
|
// Explicitly NOT supported: headings, bulletLists, fenced code, tables,
|
|
// nested marks, block quotes, images. Our renderers don't produce any
|
|
// of that; if they start to, we'll extend the grammar then rather than
|
|
// pull in a full CommonMark library for a narrow use case.
|
|
//
|
|
// The parser is deliberately forgiving with malformed input. An unclosed
|
|
// `**` becomes plain text — never an exception. That's the right call
|
|
// for a converter that runs unattended on live ticket data.
|
|
|
|
const LINK_RE = /\[([^\]\n]+?)\]\(([^)\n]+?)\)/;
|
|
const STRONG_RE = /\*\*([^*\n][^*\n]*?)\*\*/;
|
|
const EM_RE = /\*([^*\n]+?)\*/;
|
|
|
|
// Attempt each pattern at the beginning of the remaining slice and
|
|
// return the earliest match. Ties resolve by pattern order (link,
|
|
// strong, em) — which happens to be the safe direction (link brackets
|
|
// can't be confused with `*`, and `**` must be tried before `*`).
|
|
function findEarliestMatch(text) {
|
|
const candidates = [
|
|
{ re: LINK_RE, kind: 'link' },
|
|
{ re: STRONG_RE, kind: 'strong' },
|
|
{ re: EM_RE, kind: 'em' },
|
|
];
|
|
let best = null;
|
|
for (const c of candidates) {
|
|
const m = c.re.exec(text);
|
|
if (m && (best === null || m.index < best.match.index)) {
|
|
best = { kind: c.kind, match: m };
|
|
// Can't early-exit — a link at index 5 beats a strong at index 0
|
|
// is impossible (best keeps track), but a link at index 5 CAN
|
|
// beat a strong at index 10.
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
/**
|
|
* Tokenize a single line (no `\n` in the input) into a sequence of
|
|
* ADF inline nodes. Returns [] for empty input.
|
|
*/
|
|
export function tokenizeLine(line) {
|
|
if (!line) return [];
|
|
const nodes = [];
|
|
let cursor = 0;
|
|
while (cursor < line.length) {
|
|
const rest = line.slice(cursor);
|
|
const found = findEarliestMatch(rest);
|
|
if (!found) {
|
|
// No more markup — the rest is plain text.
|
|
pushText(nodes, line.slice(cursor));
|
|
break;
|
|
}
|
|
// Emit whatever plain text sits before the match.
|
|
if (found.match.index > 0) {
|
|
pushText(nodes, rest.slice(0, found.match.index));
|
|
}
|
|
// Emit the marked node.
|
|
if (found.kind === 'link') {
|
|
const [, label, url] = found.match;
|
|
nodes.push({
|
|
type: 'text',
|
|
text: label,
|
|
marks: [{ type: 'link', attrs: { href: url } }],
|
|
});
|
|
} else {
|
|
// strong or em — both extract group 1 as inner text.
|
|
const inner = found.match[1];
|
|
nodes.push({
|
|
type: 'text',
|
|
text: inner,
|
|
marks: [{ type: found.kind }],
|
|
});
|
|
}
|
|
cursor += found.match.index + found.match[0].length;
|
|
}
|
|
return nodes;
|
|
}
|
|
|
|
// Append a text node while collapsing adjacent runs of plain text.
|
|
// ADF permits multiple sibling text nodes, but a single collapsed node
|
|
// keeps the output tidy for humans skimming a rendered ADF payload.
|
|
function pushText(nodes, text) {
|
|
if (!text) return;
|
|
const last = nodes[nodes.length - 1];
|
|
if (last && last.type === 'text' && !last.marks) {
|
|
last.text += text;
|
|
} else {
|
|
nodes.push({ type: 'text', text });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convert a markdown string to an array of ADF block nodes (paragraphs
|
|
* containing inline nodes and hardBreaks). Suitable for splicing into
|
|
* a larger ADF document's `content` array — e.g. below a fixed header
|
|
* paragraph in the Jira poller's `buildAdfComment`.
|
|
*
|
|
* Blank lines separate paragraphs. Non-blank lines within a paragraph
|
|
* are joined with `hardBreak` nodes so line-oriented output like the
|
|
* chat renderers survives visually intact.
|
|
*
|
|
* @param {string} markdown
|
|
* @returns {Array<object>} ADF content nodes (each a `paragraph`).
|
|
*/
|
|
export function markdownToAdfContent(markdown) {
|
|
if (typeof markdown !== 'string' || markdown.trim() === '') return [];
|
|
|
|
// Split into paragraph groups on blank-line boundaries. Preserve
|
|
// relative order — leading/trailing blank lines just yield empty
|
|
// groups that we drop.
|
|
const groups = markdown
|
|
.split(/\n\s*\n/)
|
|
.map((g) => g.replace(/\n+$/, ''))
|
|
.filter((g) => g.length > 0);
|
|
|
|
const paragraphs = [];
|
|
for (const group of groups) {
|
|
const lines = group.split('\n');
|
|
const content = [];
|
|
lines.forEach((line, i) => {
|
|
const inline = tokenizeLine(line);
|
|
if (inline.length > 0) content.push(...inline);
|
|
if (i < lines.length - 1) {
|
|
// Soft line break inside a paragraph. ADF's hardBreak renders
|
|
// as an in-paragraph line break in the Jira viewer.
|
|
content.push({ type: 'hardBreak' });
|
|
}
|
|
});
|
|
// Skip paragraphs that ended up empty (e.g. a group that was just
|
|
// whitespace lines). An empty ADF paragraph is legal but noisy.
|
|
if (content.length === 0) continue;
|
|
paragraphs.push({ type: 'paragraph', content });
|
|
}
|
|
|
|
return paragraphs;
|
|
}
|
|
|
|
/**
|
|
* Full-document convenience: wrap `markdownToAdfContent` in a valid
|
|
* ADF `doc`. Callers that want to embed the paragraphs inside a
|
|
* larger custom document (like the poller's header + body pattern)
|
|
* should call `markdownToAdfContent` directly.
|
|
*
|
|
* @param {string} markdown
|
|
* @returns {object} ADF document `{ version:1, type:'doc', content }`.
|
|
*/
|
|
export function markdownToAdf(markdown) {
|
|
return {
|
|
version: 1,
|
|
type: 'doc',
|
|
content: markdownToAdfContent(markdown),
|
|
};
|
|
}
|