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).
167 lines
6.3 KiB
JavaScript
167 lines
6.3 KiB
JavaScript
// Golden-ish tests for services/renderers/{phone,av}StatusRenderer.js
|
|
//
|
|
// True byte-for-byte golden strings aren't practical because both
|
|
// renderers call `simpleTimeAgo` (which uses `Date.now()`). Instead we
|
|
// assert on structural properties AND on the exact assembly of key
|
|
// lines — enough to catch a regression like "we lost the Meraki
|
|
// `[Meraki↗](url)` link" or "the header format changed" but robust to
|
|
// time-of-day drift.
|
|
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
import { renderPhoneStatusMarkdown } from '../services/renderers/phoneStatusRenderer.js';
|
|
import { renderAvStatusMarkdown } from '../services/renderers/avStatusRenderer.js';
|
|
|
|
// Timestamp exactly 3 hours in the past — makes `simpleTimeAgo`
|
|
// deterministic to "3 hours ago" for the duration of this test run.
|
|
const threeHrAgo = () => new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString();
|
|
|
|
test('phone renderer: header and empty-store message', () => {
|
|
const md = renderPhoneStatusMarkdown({}, { storeNum: '782' });
|
|
assert.match(md, /^\*\*Phone Status - Store 782\*\*/);
|
|
assert.match(md, /No phones or DECT basestations found/);
|
|
});
|
|
|
|
test('phone renderer: full desk-phone entry preserves Meraki link', () => {
|
|
const data = {
|
|
phones: {
|
|
data: [{
|
|
displayName: 'PHONE 782-1',
|
|
status: 'connected',
|
|
lastSeen: threeHrAgo(),
|
|
firmware: '12.0.4',
|
|
serial: 'ABC12345',
|
|
meraki: {
|
|
switchName: 'STORE-782-SW1',
|
|
status: 'Online',
|
|
port: '17',
|
|
vlan: '20',
|
|
ip: '10.1.1.5',
|
|
lastSeen: threeHrAgo(),
|
|
clientUrl: 'https://n123.meraki.com/example/manage/clients/abc/overview',
|
|
usage: { sent: 1024, recv: 4096 },
|
|
},
|
|
}],
|
|
},
|
|
telephonyProfile: { timeZone: 'America/New_York' },
|
|
locationMainNumber: '+14125550100',
|
|
};
|
|
const md = renderPhoneStatusMarkdown(data, { storeNum: '782', footer: false });
|
|
// Header lines the poller cares about
|
|
assert.match(md, /\*\*Timezone:\*\* America\/New_York/);
|
|
assert.match(md, /\*\*PhoneNumber:\*\* \+14125550100/);
|
|
// Bold device name
|
|
assert.match(md, /\*\*PHONE 782-1\*\*/);
|
|
// Firmware / serial line
|
|
assert.match(md, /FW: 12\.0\.4 • Serial: ABC12345/);
|
|
// Meraki port info + link — the whole reason we ripped out the codeBlock
|
|
assert.match(md, /\*\*STORE-782-SW1\*\*/);
|
|
assert.match(md, /\[Meraki↗\]\(https:\/\/n123\.meraki\.com\/example\/manage\/clients\/abc\/overview\)/);
|
|
// Data usage
|
|
assert.match(md, /Data \(recent\): 1 KB sent \/ 4 KB recv/);
|
|
// No footer when opts.footer is false — Jira uses this mode
|
|
assert.doesNotMatch(md, /Last checked/);
|
|
});
|
|
|
|
test('phone renderer: footer appears when opts.footer=true (default) on non-empty data', () => {
|
|
// The empty-store branch legitimately returns early with no footer
|
|
// — that matches the chat handler's historical behavior. Feed a
|
|
// minimal populated fixture to exercise the footer path.
|
|
const data = {
|
|
phones: {
|
|
data: [{ displayName: 'X', status: 'connected', lastSeen: threeHrAgo() }],
|
|
},
|
|
};
|
|
const md = renderPhoneStatusMarkdown(data, { storeNum: '782' });
|
|
assert.match(md, /Last checked:/);
|
|
});
|
|
|
|
test('phone renderer: detailed mode reveals SIP details', () => {
|
|
const data = {
|
|
phones: {
|
|
data: [{
|
|
displayName: 'PHONE 782-2',
|
|
status: 'connected',
|
|
lastSeen: threeHrAgo(),
|
|
primarySipUrl: 'sip:782-2@aeo2go.webex.com',
|
|
sipUrls: ['sip:a@x', 'sip:b@x', 'sip:c@x'],
|
|
}],
|
|
},
|
|
};
|
|
const compact = renderPhoneStatusMarkdown(data, { storeNum: '782', detailed: false, footer: false });
|
|
const detailed = renderPhoneStatusMarkdown(data, { storeNum: '782', detailed: true, footer: false });
|
|
assert.doesNotMatch(compact, /SIP: sip:782-2/);
|
|
assert.match(detailed, /SIP: sip:782-2@aeo2go\.webex\.com/);
|
|
assert.match(detailed, /Alt SIPs: sip:a@x, sip:b@x…/);
|
|
});
|
|
|
|
test('av renderer: header always says (Mode: detailed)', () => {
|
|
const md = renderAvStatusMarkdown({}, { storeNum: '782', footer: false });
|
|
assert.match(md, /^\*\*Device Status - Store 782\*\* \(Mode: detailed\)/);
|
|
});
|
|
|
|
test('av renderer: MDM entry with Meraki wired client preserves link', () => {
|
|
const data = {
|
|
mdm: {
|
|
data: [{
|
|
friendlyName: 'STORE-782-KIOSK-1',
|
|
lastSeen: threeHrAgo(),
|
|
meraki: {
|
|
deviceName: 'STORE-782-SW2',
|
|
recentDeviceConnection: 'Wired',
|
|
status: 'Online',
|
|
clientStatus: 'Online',
|
|
port: '4',
|
|
vlan: '30',
|
|
ip: '10.1.2.20',
|
|
mac: 'aa:bb:cc:dd:ee:ff',
|
|
lastSeen: threeHrAgo(),
|
|
clientUrl: 'https://n123.meraki.com/example/manage/clients/xyz/overview',
|
|
},
|
|
}],
|
|
},
|
|
};
|
|
const md = renderAvStatusMarkdown(data, { storeNum: '782', footer: false });
|
|
assert.match(md, /\*\*STORE-782-KIOSK-1\*\*/);
|
|
assert.match(md, /\*\*STORE-782-SW2 \(Wired - Online\)\*\*/);
|
|
assert.match(md, /\[Meraki↗\]\(https:\/\/n123\.meraki\.com\/example\/manage\/clients\/xyz\/overview\)/);
|
|
// Port line with double-arrow indent
|
|
assert.match(md, / → → Port: \*\*4\*\*/);
|
|
});
|
|
|
|
test('av renderer: absent MDM devices prints friendly message', () => {
|
|
const md = renderAvStatusMarkdown({ mdm: { data: [] } }, { storeNum: '782', footer: false });
|
|
assert.match(md, /No MDM devices found for this store/);
|
|
});
|
|
|
|
test('av renderer: Atlas AMP with vitals renders temps + fan + amps', () => {
|
|
const data = {
|
|
mdm: { data: [] },
|
|
atlas: {
|
|
data: [{
|
|
name: 'US000782AMP',
|
|
status: 'online',
|
|
last_seen_at: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
|
|
model: { name: 'Atlas-4M' },
|
|
firmware: { version: '1.9.3' },
|
|
sn: 'SN-782-AMP',
|
|
state: {
|
|
voltageMonitor: '120.4',
|
|
faultStatus: 0,
|
|
tempCpu: '40', tempPsu: '35', tempIo: '38',
|
|
fanSpeed: 45.7,
|
|
ampStatus_1: 'Active',
|
|
ampStatus_2: 'Ready',
|
|
IpAddress: '10.1.9.9',
|
|
},
|
|
}],
|
|
},
|
|
};
|
|
const md = renderAvStatusMarkdown(data, { storeNum: '782', footer: false });
|
|
assert.match(md, /\*\*Atlas Devices:\*\*/);
|
|
assert.match(md, /\*\*US000782AMP\*\* \(online\)/);
|
|
assert.match(md, /Atlas-4M • FW 1\.9\.3 • SN SN-782-AMP • IP 10\.1\.9\.9/);
|
|
assert.match(md, /CPU: 104°F • PSU: 95°F • Io: 100°F • Voltage: 120\.4V • Fan: 46%/);
|
|
assert.match(md, /Amps: Amp1: Active, Amp2: Ready/);
|
|
});
|