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).
180 lines
8 KiB
JavaScript
180 lines
8 KiB
JavaScript
// services/vcMonitorService.js
|
|
// On-demand packet capture (ExtendedLogging + PacketDump) for Cisco RoomOS / Webex video endpoints.
|
|
// Uses cloud xAPI (requires spark:xapi_commands scope on the Service App / Integration).
|
|
// After capture, logs (containing the pcap) are retrieved from Control Hub diagnostics — not a direct file download.
|
|
|
|
import webex from '../integrations/webex/WebexClient.js';
|
|
import xapi from '../integrations/webex/XapiClient.js';
|
|
import { logger } from '../utils/logger.js';
|
|
|
|
const VALID_PACKETDUMP = ['Full', 'Limited', 'FullRotate', 'None'];
|
|
|
|
export async function startPacketCapture(bot, serialNumber, packetDump = 'Full') {
|
|
if (!serialNumber) throw new Error('Serial number is required');
|
|
|
|
const dumpType = normalizePacketDump(packetDump);
|
|
logger('vc-monitor', `Starting packet capture for serial ${serialNumber} (PacketDump=${dumpType})`);
|
|
|
|
try {
|
|
await bot.say('markdown', `🚀 Starting extended logging + packet capture on **${serialNumber}** (PacketDump: **${dumpType}**)...`);
|
|
|
|
// 1. Find device (same pattern as vcProvision)
|
|
const devicesResponse = await webex.request('GET', 'devices', null, { serial: serialNumber });
|
|
|
|
if (!devicesResponse.items || devicesResponse.items.length === 0) {
|
|
throw new Error(`No device found with serial ${serialNumber}`);
|
|
}
|
|
if (devicesResponse.items.length > 1) {
|
|
throw new Error(`Multiple devices found with serial ${serialNumber}`);
|
|
}
|
|
|
|
const device = devicesResponse.items[0];
|
|
const display = device.displayName || device.serial || serialNumber;
|
|
logger('vc-monitor', `Found device ${display} (${device.id})`);
|
|
|
|
await bot.say('markdown', `✅ Device found: **${display}**`);
|
|
|
|
// 2. Issue the xCommand
|
|
await bot.say('markdown', `📡 Issuing \`Logging ExtendedLogging Start PacketDump: ${dumpType}\` ...`);
|
|
|
|
const result = await xapi.xCommandWithDevice(
|
|
'Logging.ExtendedLogging.Start',
|
|
device.id,
|
|
{ PacketDump: dumpType }
|
|
);
|
|
|
|
logger('vc-monitor', `xCommand result: ${JSON.stringify(result)}`);
|
|
|
|
const durationHint = dumpType === 'Full' ? '~3 minutes (includes RTP/media)'
|
|
: dumpType === 'Limited' ? '~10 minutes (non-RTP/signaling)'
|
|
: dumpType === 'FullRotate' ? 'rolling (keeps recent ~1h worth)'
|
|
: 'no packet dump';
|
|
|
|
await bot.say('markdown',
|
|
`✅ **Capture started successfully**\n\n` +
|
|
`**Device:** ${display}\n` +
|
|
`**PacketDump:** ${dumpType} — ${durationHint}\n\n` +
|
|
`**Next steps (important):**\n` +
|
|
`1. Reproduce the problem **now** while the capture is running.\n` +
|
|
`2. When finished, run:\n` +
|
|
` \`/vcMonitor ${serialNumber} stop\`\n` +
|
|
`3. Download the logs from **Control Hub**:\n` +
|
|
` - Devices → find device → **Issues & Diagnostics** → **System Logs**\n` +
|
|
` - Look for the most recent log bundle (the packet capture .pcap files are included, typically in the \`run/\` folder or a dedicated "Packet Captures" section).\n\n` +
|
|
`The capture will time out automatically, but stopping explicitly is recommended for a clean bundle.`
|
|
);
|
|
|
|
return { success: true, deviceId: device.id, dumpType, result };
|
|
|
|
} catch (error) {
|
|
const msg = error.response?.data?.message || error.message || 'Unknown error';
|
|
logger('vc-monitor', `startPacketCapture failed for ${serialNumber}: ${msg}`, 'error');
|
|
|
|
// Common permission hint
|
|
if (msg.toLowerCase().includes('403') || msg.toLowerCase().includes('unauthorized') || msg.toLowerCase().includes('scope')) {
|
|
await bot.say('markdown', `⚠️ **Permission error** — does your Webex integration have the \`spark:xapi_commands\` scope?`);
|
|
}
|
|
|
|
await bot.say('markdown', `❌ **Failed to start capture on ${serialNumber}**\n\n${msg}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function stopPacketCapture(bot, serialNumber) {
|
|
if (!serialNumber) throw new Error('Serial number is required');
|
|
|
|
logger('vc-monitor', `Stopping packet capture for serial ${serialNumber}`);
|
|
|
|
try {
|
|
await bot.say('markdown', `🛑 Stopping extended logging + packet capture on **${serialNumber}**...`);
|
|
|
|
const devicesResponse = await webex.request('GET', 'devices', null, { serial: serialNumber });
|
|
if (!devicesResponse.items || devicesResponse.items.length === 0) {
|
|
throw new Error(`No device found with serial ${serialNumber}`);
|
|
}
|
|
const device = devicesResponse.items[0];
|
|
const display = device.displayName || device.serial || serialNumber;
|
|
|
|
const result = await xapi.xCommandWithDevice('Logging.ExtendedLogging.Stop', device.id, {});
|
|
logger('vc-monitor', `Stop result: ${JSON.stringify(result)}`);
|
|
|
|
await bot.say('markdown',
|
|
`✅ **Capture stopped** on **${display}**\n\n` +
|
|
`Now retrieve the logs (which include the packet captures):\n` +
|
|
`- Control Hub → Devices → select the device → **Issues & Diagnostics** → **System Logs** (download the latest bundle).\n` +
|
|
`- Or use the device's local web UI (Issues and Diagnostics) if you have direct access.\n\n` +
|
|
`Tip: Full bundles are usually what you want (they contain the PCAPs in the run/ directory).`
|
|
);
|
|
|
|
return { success: true, deviceId: device.id, result };
|
|
|
|
} catch (error) {
|
|
const msg = error.response?.data?.message || error.message || 'Unknown error';
|
|
logger('vc-monitor', `stopPacketCapture failed for ${serialNumber}: ${msg}`, 'error');
|
|
await bot.say('markdown', `❌ **Failed to stop capture on ${serialNumber}**\n\n${msg}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function getExtendedLoggingStatus(bot, serialNumber) {
|
|
if (!serialNumber) throw new Error('Serial number is required');
|
|
|
|
logger('vc-monitor', `Querying ExtendedLogging status for ${serialNumber}`);
|
|
|
|
try {
|
|
await bot.say('markdown', `📡 Querying status on **${serialNumber}**...`);
|
|
|
|
const devicesResponse = await webex.request('GET', 'devices', null, { serial: serialNumber });
|
|
if (!devicesResponse.items || devicesResponse.items.length === 0) {
|
|
throw new Error(`No device found with serial ${serialNumber}`);
|
|
}
|
|
const device = devicesResponse.items[0];
|
|
const display = device.displayName || device.serial || serialNumber;
|
|
|
|
// Query the whole ExtendedLogging subtree
|
|
const status = await xapi.xStatus(device.id, 'Logging.ExtendedLogging');
|
|
|
|
logger('vc-monitor', `Status response keys: ${Object.keys(status || {})}`);
|
|
|
|
// Try to surface the useful bits (structure can vary slightly by RoomOS version)
|
|
const ext = status?.result?.Logging?.ExtendedLogging || status?.result || {};
|
|
const mode = ext.Mode || ext.mode || '—';
|
|
const packetDump = ext.PacketDump || ext.packetDump || '—';
|
|
|
|
let summary = `**ExtendedLogging status for ${display}:**\n`;
|
|
summary += `- Mode: **${mode}**\n`;
|
|
summary += `- PacketDump: **${packetDump}**\n`;
|
|
|
|
// If more detail is present (e.g. under PacketDump or files), surface a bit
|
|
if (ext.PacketDump && typeof ext.PacketDump === 'object') {
|
|
summary += `- Details: ${JSON.stringify(ext.PacketDump)}\n`;
|
|
}
|
|
|
|
await bot.say('markdown', summary);
|
|
|
|
// Also give a compact raw snippet for debugging (don't overwhelm)
|
|
const raw = JSON.stringify(status, null, 2);
|
|
const snippet = raw.length > 1200 ? raw.slice(0, 1200) + '\n... (truncated)' : raw;
|
|
await bot.say('markdown', `**Raw status (for diagnostics):**\n\`\`\`json\n${snippet}\n\`\`\``);
|
|
|
|
return { success: true, deviceId: device.id, status, mode, packetDump };
|
|
|
|
} catch (error) {
|
|
const msg = error.response?.data?.message || error.message || 'Unknown error';
|
|
logger('vc-monitor', `getExtendedLoggingStatus failed for ${serialNumber}: ${msg}`, 'error');
|
|
await bot.say('markdown', `❌ **Failed to get status for ${serialNumber}**\n\n${msg}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function normalizePacketDump(input = 'Full') {
|
|
const s = String(input || '').trim();
|
|
const match = VALID_PACKETDUMP.find(v => v.toLowerCase() === s.toLowerCase());
|
|
return match || 'Full';
|
|
}
|
|
|
|
export default {
|
|
startPacketCapture,
|
|
stopPacketCapture,
|
|
getExtendedLoggingStatus
|
|
};
|