collabSupport/services/vcMonitorService.js
jmcqueen 351f89a9a4 Initial commit: CollabFinder Webex bot
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).
2026-07-01 16:55:03 -04:00

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
};