servchan/tools/appleTV/appleTV-enhanced.js
jmcqueen c2e98d105e Initial commit: ServChan Webex bot for ServiceChannel work orders.
ServiceChannel webhook processor, proposal approval cards, attachment auto-post,
CollabSupport commands, and Docker deployment configuration.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-24 15:12:08 -04:00

708 lines
No EOL
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import fs from 'fs';
import csv from 'csv-parser';
import path from 'path';
import axios from 'axios';
import pLimit from 'p-limit';
import { Parser } from 'json2csv';
var merakiNetworks = [];
var config = JSON.parse(fs.readFileSync('./config/config.json'));
let lastPortFetchTime = 0;
const MIN_DELAY_MS = 150; // ~67 calls/sec — safe under Meraki's 10/sec steady limit
const MAX_RETRIES = 5;
const BASE_BACKOFF_MS = 1000; // 1s, 2s, 4s, 8s, 16s...
// Global cache for device info (serial → {model, name, type, ...})
const deviceInfoCache = new Map();
// Assume you have these functions from your previous code
// e.g., findMerakiNetworkId(storeNum) → returns network ID
// getMerakiClients(networkId) → returns array of clients with description, vlan, switchport, status, etc.
async function getMerakiDeviceInfo(serial) {
if (!serial || serial === 'N/A') return null;
if (deviceInfoCache.has(serial)) {
return deviceInfoCache.get(serial);
}
const url = `https://api.meraki.com/api/v1/devices/${serial}`;
for (let attempt = 1; attempt <= 3; attempt++) {
try {
const response = await fetch(url, {
method: 'GET',
headers: {
'X-Cisco-Meraki-API-Key': config.auth.meraki.apiKey,
'Content-Type': 'application/json'
}
});
if (response.status === 429) {
let retryAfterSec = 2;
const retryAfter = response.headers.get('retry-after');
if (retryAfter) retryAfterSec = parseInt(retryAfter, 10) || 2;
console.warn(`429 on device info for ${serial} - wait ${retryAfterSec}s`);
await new Promise(r => setTimeout(r, retryAfterSec * 1000 + 300));
continue;
}
if (!response.ok) {
console.warn(`Device info fetch failed for ${serial}: HTTP ${response.status}`);
return null;
}
const info = await response.json();
deviceInfoCache.set(serial, info);
return info;
} catch (err) {
console.warn(`Device info attempt ${attempt} error for ${serial}: ${err.message}`);
if (attempt === 3) return null;
await new Promise(r => setTimeout(r, 1000 * attempt));
}
}
return null;
}
async function throttledGetMerakiSwitchPortConfig(serial, portId) {
if (!serial || !portId) {
throw new Error('Missing serial or portId for port config fetch');
}
const url = `https://api.meraki.com/api/v1/devices/${serial}/switch/ports/${portId}`;
// Step 1: Enforce minimum delay between calls
const now = Date.now();
const timeSinceLast = now - lastPortFetchTime;
if (timeSinceLast < MIN_DELAY_MS) {
const waitMs = MIN_DELAY_MS - timeSinceLast;
console.log(`Throttling port fetch for ${serial}/${portId} — waiting ${waitMs}ms`);
await new Promise(resolve => setTimeout(resolve, waitMs));
}
// Update timestamp right before the request (more accurate for burst control)
lastPortFetchTime = Date.now();
// Step 2: Attempt the request with retries on 429 and transient errors
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
const response = await fetch(url, {
method: 'GET',
headers: {
'X-Cisco-Meraki-API-Key': config.auth.meraki.apiKey,
'Content-Type': 'application/json'
}
});
// Handle 429 specifically
if (response.status === 429) {
let retryAfterSec = 2; // default fallback
// Respect Retry-After header if present
const retryAfterHeader = response.headers.get('retry-after');
if (retryAfterHeader) {
const parsed = parseInt(retryAfterHeader, 10);
if (!isNaN(parsed) && parsed > 0) {
retryAfterSec = parsed;
}
}
console.warn(
`429 Too Many Requests on ${serial}/${portId} ` +
`(attempt ${attempt}/${MAX_RETRIES}) — waiting ${retryAfterSec}s`
);
await new Promise(resolve => setTimeout(resolve, retryAfterSec * 1000 + 200)); // +200ms jitter
continue; // retry
}
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(`Meraki API failed (${response.status}): ${errorText}`);
}
const data = await response.json();
console.log(`Successfully fetched port config for ${serial}/${portId}`);
return data;
} catch (err) {
console.error(
`Port fetch error for ${serial}/${portId} (attempt ${attempt}/${MAX_RETRIES}): ${err.message}`
);
if (attempt === MAX_RETRIES) {
throw new Error(
`Failed to fetch port ${portId} on switch ${serial} after ${MAX_RETRIES} attempts: ${err.message}`
);
}
// Exponential backoff for non-429 errors too (e.g., 500s, network blips)
const backoffMs = BASE_BACKOFF_MS * Math.pow(2, attempt - 1) + Math.random() * 200; // jitter
console.log(`Backing off for ${Math.round(backoffMs)}ms before retry ${attempt + 1}`);
await new Promise(resolve => setTimeout(resolve, backoffMs));
}
}
// Should never reach here due to throw on last attempt
throw new Error('Unexpected exit from retry loop');
}
// Helper: Fetch a single switch port configuration from Meraki API
async function getMerakiSwitchPortConfig(serial, portId) {
if (!serial || !portId) {
throw new Error('Missing serial or portId for port config fetch');
}
const url = `https://api.meraki.com/api/v1/devices/${serial}/switch/ports/${portId}`;
try {
const response = await fetch(url, {
method: 'GET',
headers: {
'X-Cisco-Meraki-API-Key': config.auth.meraki.apiKey,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(`Meraki API failed (${response.status}): ${errorText}`);
}
return await response.json();
} catch (err) {
throw new Error(`Failed to fetch port ${portId} on switch ${serial}: ${err.message}`);
}
}
// Helper: Fetch all clients for a network (with basic pagination support)
async function getMerakiClients(networkId) {
let allClients = [];
let startingAfter = null;
const perPage = 1000;
do {
let url = `https://api.meraki.com/api/v1/networks/${networkId}/clients?perPage=${perPage}`;
if (startingAfter) {
url += `&startingAfter=${encodeURIComponent(startingAfter)}`;
}
for (let attempt = 1; attempt <= 5; attempt++) {
try {
const response = await fetch(url, {
method: 'GET',
headers: {
'X-Cisco-Meraki-API-Key': config.auth.meraki.apiKey,
'Content-Type': 'application/json'
}
});
if (response.status === 429) {
let retryAfterSec = 2;
const retryAfterHeader = response.headers.get('retry-after');
if (retryAfterHeader) {
const parsed = parseInt(retryAfterHeader, 10);
if (!isNaN(parsed) && parsed > 0) retryAfterSec = parsed;
}
console.warn(`429 on clients page for ${networkId} (attempt ${attempt}) - waiting ${retryAfterSec}s`);
await new Promise(r => setTimeout(r, retryAfterSec * 1000 + 300)); // jitter
continue;
}
if (!response.ok) {
throw new Error(`Clients fetch failed: ${response.status}`);
}
const clients = await response.json();
allClients = allClients.concat(clients);
startingAfter = clients.length === perPage ? clients[clients.length - 1].id : null;
break; // success → next page
} catch (err) {
if (attempt === 5) throw err;
const backoff = 1000 * Math.pow(2, attempt - 1) + Math.random() * 500;
console.log(`Retry backoff ${Math.round(backoff)}ms for ${networkId}`);
await new Promise(r => setTimeout(r, backoff));
}
}
} while (startingAfter);
return allClients;
}
async function getAllMerakiSwitchPorts(serial) {
if (!serial || serial === 'N/A') return [];
// Check if it's actually a switch
const device = await getMerakiDeviceInfo(serial);
if (!device || !device.model?.startsWith('MS')) {
const model = device?.model || 'unknown';
console.log(`Skipping ports fetch for non-switch device ${serial} (model: ${model})`);
return [];
}
console.log(`Fetching ports for confirmed switch ${serial} (model: ${device.model})`);
const url = `https://api.meraki.com/api/v1/devices/${serial}/switch/ports`;
for (let attempt = 1; attempt <= 3; attempt++) {
try {
const response = await fetch(url, {
method: 'GET',
headers: {
'X-Cisco-Meraki-API-Key': config.auth.meraki.apiKey,
'Content-Type': 'application/json'
}
});
if (response.status === 429) {
let retryAfterSec = 2;
const retryAfter = response.headers.get('retry-after');
if (retryAfter) retryAfterSec = parseInt(retryAfter, 10) || 2;
console.warn(`429 on ports fetch for ${serial} - wait ${retryAfterSec}s`);
await new Promise(r => setTimeout(r, retryAfterSec * 1000 + 300));
continue;
}
if (!response.ok) {
const errText = await response.text().catch(() => 'Unknown');
throw new Error(`HTTP ${response.status}: ${errText}`);
}
const ports = await response.json();
console.log(`Fetched ${ports.length} ports for switch ${serial}`);
return ports;
} catch (err) {
console.warn(`Ports fetch attempt ${attempt} failed for ${serial}: ${err.message}`);
if (attempt === 3) return [];
await new Promise(r => setTimeout(r, 1000 * attempt));
}
}
return [];
}
// Main function now outputs CSV directly
async function processCSVAndEnrichMeraki(csvFilePath, outputCsvPath = 'enriched-meraki-output.csv') {
const results = [];
// 1. Parse input CSV
await new Promise((resolve, reject) => {
fs.createReadStream(csvFilePath)
.pipe(csv())
.on('data', row => results.push(row))
.on('end', () => {
console.log(`Input CSV parsed - ${results.length} rows`);
resolve();
})
.on('error', err => reject(err));
});
// 2. Cache networks if not already done
if (merakiNetworks.length === 0) {
await cacheMerakiNetworks();
}
// 3. Build unique store → network map
const storeToNetwork = new Map();
const limit = pLimit(4);
const networkPromises = results.map(row => {
const friendlyName = row.device_friendly_name?.trim() || '';
if (!friendlyName) return Promise.resolve();
const storeNum = friendlyName.match(/(\d{6})/)?.[0];
if (!storeNum || storeToNetwork.has(storeNum)) return Promise.resolve();
return limit(() =>
findMerakiNetworkId(storeNum)
.then(netId => { if (netId) storeToNetwork.set(storeNum, netId); })
.catch(() => { })
);
});
await Promise.all(networkPromises);
console.log(`Found ${storeToNetwork.size} unique networks`);
// 4. Pre-fetch clients per network
const networkClientCache = new Map();
const clientPromises = [...storeToNetwork.entries()].map(([storeNum, netId]) =>
limit(async () => {
try {
const clients = await getMerakiClients(netId);
networkClientCache.set(netId, clients);
console.log(`Clients fetched: ${clients.length} for store ${storeNum}`);
} catch (err) {
console.error(`Clients fetch failed for store ${storeNum}: ${err.message}`);
networkClientCache.set(netId, []);
}
})
);
await Promise.all(clientPromises);
// 5. Enrich rows collect unique serials first
const enrichedRows = results.map(row => ({ ...row, meraki: null, error: null }));
const serialToPorts = new Map();
const uniqueSerials = new Set();
// Phase A: Match clients & collect serials
for (let i = 0; i < results.length; i++) {
const row = results[i];
const friendlyName = row.device_friendly_name?.trim() || '';
if (!friendlyName) {
enrichedRows[i].error = 'Missing friendly name';
continue;
}
if (match.recentDeviceConnection !== 'Wired' || !match.switchport) {
enrichedRows[i].error = 'Wireless or non-switch connected client';
continue;
}
const storeNum = friendlyName.match(/(\d{6})/)?.[0];
if (!storeNum) continue;
const netId = storeToNetwork.get(storeNum);
if (!netId) continue;
const clients = networkClientCache.get(netId) || [];
const match = clients.find(c =>
c.description?.toUpperCase().trim() === friendlyName.toUpperCase().trim()
);
if (!match) {
enrichedRows[i].error = 'No matching Meraki client';
continue;
}
const merakiInfo = {
deviceName: match.recentDeviceName || 'N/A',
deviceSerial: match.recentDeviceSerial || 'N/A',
switchport: match.switchport || 'N/A',
vlan: match.vlan || 'N/A',
status: match.status || 'Unknown',
connection: match.recentDeviceConnection || 'N/A',
lastSeen: match.lastSeen || 'N/A',
ip: match.ip || 'N/A',
mac: match.mac || 'N/A'
};
enrichedRows[i].meraki = merakiInfo;
if (merakiInfo.deviceSerial !== 'N/A') {
uniqueSerials.add(merakiInfo.deviceSerial);
}
}
// Phase B: Pre-fetch all ports for unique switches
if (uniqueSerials.size > 0) {
console.log(`Fetching ports for ${uniqueSerials.size} unique switches...`);
const portPromises = [...uniqueSerials].map(serial =>
limit(async () => {
const ports = await getAllMerakiSwitchPorts(serial);
serialToPorts.set(serial, ports);
})
);
await Promise.all(portPromises);
}
// Phase C: Final enrichment from cache
for (const row of enrichedRows) {
const meraki = row.meraki;
if (!meraki || meraki.deviceSerial === 'N/A' || meraki.switchport === 'N/A') {
if (meraki) {
Object.assign(meraki, {
portType: 'N/A',
accessPolicyType: 'N/A',
stickyMacEnabled: false,
stickyMacList: [],
stickyMacAllowListLimit: 0,
stickyMacCount: 0,
portEnabled: 'N/A'
});
}
continue;
}
const ports = serialToPorts.get(meraki.deviceSerial) || [];
const portConfig = ports.find(p =>
String(p.port || p.portId || p.number) === String(meraki.switchport)
);
if (portConfig) {
meraki.portType = portConfig.type || 'N/A';
meraki.accessPolicyType = portConfig.accessPolicyType || 'N/A';
meraki.stickyMacEnabled = portConfig.accessPolicyType === 'Sticky MAC allow list';
meraki.stickyMacList = portConfig.stickyMacAllowList || [];
meraki.stickyMacAllowListLimit = portConfig.stickyMacAllowListLimit || 0;
meraki.stickyMacCount = meraki.stickyMacList.length;
meraki.portEnabled = portConfig.enabled ? 'Enabled' : 'Disabled';
} else {
meraki.portType = 'Not Found';
meraki.portEnabled = 'Not Found';
}
}
// 6. Convert enriched data → CSV
const flattened = enrichedRows.map(item => {
const m = item.meraki || {};
const stickyList = Array.isArray(m.stickyMacList) ? m.stickyMacList.join(', ') : '';
return {
device_id: item.device_id || '',
device_friendly_name: item.device_friendly_name || '',
device_enrollment_user_name: item.device_enrollment_user_name || '',
_device_platform: item._device_platform || '',
device_os_version: item.device_os_version || '',
device_last_seen_utc: item.device_last_seen_utc || '',
device_enrollment_status: item.device_enrollment_status || '',
device_model_name: item.device_model_name || '',
_device_mac_address: item._device_mac_address || '',
device_mac_address: item.device_mac_address || '',
meraki_deviceName: m.deviceName || '',
meraki_deviceSerial: m.deviceSerial || '',
meraki_switchport: m.switchport || '',
meraki_vlan: m.vlan || '',
meraki_status: m.status || '',
meraki_connection: m.connection || '',
meraki_lastSeen: m.lastSeen || '',
meraki_ip: m.ip || '',
meraki_mac: m.mac || '',
meraki_portType: m.portType || 'N/A',
meraki_accessPolicyType: m.accessPolicyType || 'N/A',
meraki_portEnabled: m.portEnabled || 'N/A',
meraki_stickyMacEnabled: m.stickyMacEnabled === true ? 'true' : 'false',
meraki_stickyMacList: stickyList,
meraki_stickyMacAllowListLimit: m.stickyMacAllowListLimit ?? '',
meraki_stickyMacCount: m.stickyMacCount ?? '',
error: item.error || ''
};
});
const fields = [
'device_id', 'device_friendly_name', 'device_enrollment_user_name', '_device_platform',
'device_os_version', 'device_last_seen_utc', 'device_enrollment_status', 'device_model_name',
'_device_mac_address', 'device_mac_address',
'meraki_deviceName', 'meraki_deviceSerial', 'meraki_switchport', 'meraki_vlan',
'meraki_status', 'meraki_connection', 'meraki_lastSeen', 'meraki_ip', 'meraki_mac',
'meraki_portType', 'meraki_accessPolicyType', 'meraki_portEnabled',
'meraki_stickyMacEnabled', 'meraki_stickyMacList', 'meraki_stickyMacAllowListLimit',
'meraki_stickyMacCount', 'error'
];
const parser = new Parser({ fields });
const csvContent = parser.parse(flattened);
fs.writeFileSync('enriched-meraki-intermediate.json', JSON.stringify(enrichedRows, null, 2));
fs.writeFileSync(outputCsvPath, csvContent);
console.log(`Enrichment complete. Output CSV saved to: ${outputCsvPath}`);
console.log(`Processed ${enrichedRows.length} rows`);
return enrichedRows; // optional for further chaining if needed
}
async function findMerakiNetworkId(storeNum) {
var startTime = new Date().getTime();
var networkSearchTerm = Number(storeNum).toString().padStart(5, "0");
try {
// Filter networks by partial name match (case-insensitive)
const matchingNetworks = merakiNetworks.filter(net =>
(net.name || '').toLowerCase().includes(networkSearchTerm.toLowerCase())
);
if (matchingNetworks.length === 0) {
return null;
}
if (matchingNetworks.length > 1) {
console.log(`Multiple networks match '${networkSearchTerm}':`);
matchingNetworks.forEach(net => console.log(`- ${net.name} (ID: ${net.id})`));
console.log('Using the first match...');
}
return matchingNetworks[0].id;
} catch (error) {
console.error(`Error finding Meraki Network: ${error}`);
return null;
}
}
async function cacheMerakiNetworks() {
try {
let allNetworks = [];
let nextUrl = `https://api.meraki.com/api/v1/organizations/${config.auth.meraki.orgId}/networks?perPage=1000`;
while (nextUrl) {
const response = await axios.get(nextUrl, {
headers: {
'X-Cisco-Meraki-API-Key': config.auth.meraki.apiKey,
'Content-Type': 'application/json'
}
});
const pageNetworks = response.data;
allNetworks = allNetworks.concat(pageNetworks);
//logger(`cacheMerakiNetworks()`, `Fetched ${pageNetworks.length} networks (total so far: ${allNetworks.length})`);
// Log raw header
//logger(`cacheMerakiNetworks()`, `Link header raw: ${response.headers.link || '(none)'}`);
// Robust next URL extraction
const linkHeader = response.headers.link;
let foundNext = null;
if (linkHeader) {
//logger(`cacheMerakiNetworks()`, `Full Link header (raw): ${linkHeader}`);
const parts = linkHeader.split(',');
//logger(`cacheMerakiNetworks()`, `Split into ${parts.length} parts`);
for (const part of parts) {
const trimmed = part.trim();
//logger(`cacheMerakiNetworks()`, `Examining part: "${trimmed}"`);
// Forgiving checks: lower case, no quotes required, partial match
const lowerTrimmed = trimmed.toLowerCase();
if (lowerTrimmed.includes('rel=next') || lowerTrimmed.includes('rel="next"') || lowerTrimmed.includes("rel='next'")) {
//logger(`cacheMerakiNetworks()`, `→ Detected rel=next in: "${trimmed}"`);
const urlMatch = trimmed.match(/<([^>]+)>/);
if (urlMatch && urlMatch[1]) {
foundNext = urlMatch[1].trim(); // extra trim just in case
//logger(`cacheMerakiNetworks()`, `→ Extracted next URL: ${foundNext}`);
break;
} else {
//logger(`cacheMerakiNetworks()`, `→ URL match failed on that part`);
}
}
}
}
nextUrl = foundNext;
if (!nextUrl) {
//logger(`cacheMerakiNetworks()`, `No next page detected ending loop`);
} else {
//logger(`cacheMerakiNetworks()`, `Advancing to next URL: ${nextUrl}`);
}
}
merakiNetworks = allNetworks;
logger(`cacheMerakiNetworks()`, `Cached ${merakiNetworks.length} networks. (${new Date().getTime() - startTime}ms)`);
} catch (error) {
console.error('Meraki Networks API error:', error.message);
if (error.response) {
console.error('Status:', error.response.status);
console.error('Data:', error.response.data);
}
}
}
async function rebootWorkspaceOneDevice(deviceId) {
const baseUrl = `https://${awHost}/api`; // e.g., as123.awmdm.com
const headers = {
'Authorization': `Basic ${Buffer.from('your_username:your_password').toString('base64')}`, // or use API key method
'aw-tenant-code': tenantCode,
'Accept': 'application/json',
'Content-Type': 'application/json'
};
try {
const response = await axios.post(
`${baseUrl}/mdm/devices/${deviceId}/commands`,
{ Command: 'RebootDevice' }, // or 'RestartDevice' for iOS/tvOS
{ headers }
);
console.log('Reboot command sent successfully:', response.data);
return response.data;
} catch (error) {
console.error('Error sending reboot:');
console.error(error.response?.data || error.message);
throw error;
}
}
/*
async function updateSwitchPort(networkId, serial, portId, settings) {
const apiKey = 'YOUR_MERAKI_API_KEY_HERE';
const baseUrl = 'https://api.meraki.com/api/v1';
try {
const response = await axios.put(
`${baseUrl}/networks/${networkId}/devices/${serial}/switch/ports/${portId}`,
settings,
{
headers: {
'X-Cisco-Meraki-API-Key': apiKey,
'Content-Type': 'application/json'
}
}
);
console.log('Success:', response.data);
return response.data;
} catch (error) {
console.error('Error updating port:');
console.error(error.response?.data || error.message);
throw error;
}
}
// Usage examples
updateSwitchPort('L_123456789012345678', 'Q3LU-ABCDE-12345', '8', false); // Disable port 8
// updateSwitchPort('L_123456789012345678', 'Q3LU-ABCDE-12345', '8', true); // Enable port 8*/
function logger(functionName, message) {
var d = new Date();
var year = d.getFullYear();
var month = (d.getMonth() + 1).toString().padStart(2, "0");
var day = d.getDate().toString().padStart(2, "0");
let logFile = path.join(`./logs/${year}${month}${day}.log`);
console.log(d.toLocaleString() + " " + functionName + ": " + message);
fs.appendFileSync(logFile, d.toLocaleString() + " " + functionName + ": " + message + "\n");
}
async function sendMdmQuery(deviceId, apiKey, tenantCode, awHost) {
const baseUrl = `https://${awHost}/api`; // e.g., as1991.awmdm.com
const headers = {
'Authorization': `Basic ${Buffer.from('your_username:your_password').toString('base64')}`, // or API key method
'aw-tenant-code': tenantCode,
'Accept': 'application/json',
'Content-Type': 'application/json'
};
try {
const response = await axios.post(
`${baseUrl}/mdm/devices/${deviceId}/commands`,
{ Command: 'QueryDevice' },
{ headers }
);
console.log('Query command sent successfully:', response.data);
return response.data;
} catch (error) {
console.error('Error sending MDM Query:');
if (error.response) {
console.error('Status:', error.response.status);
console.error('Response:', error.response.data);
} else {
console.error(error.message);
}
throw error;
}
}
// Usage example
const csvFile = 'appleTV.csv';
const inputCsv = 'appleTV.csv';
await processCSVAndEnrichMeraki(inputCsv, 'enriched-meraki-output.csv')
.catch(err => console.error('Fatal error:', err));